Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions packages/server/src/controllers/predictions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,9 @@ const createPrediction = async (req: Request, res: Response, next: NextFunction)
`Error: predictionsController.createPrediction - body not provided!`
)
}
const workspaceId = req.user?.activeWorkspaceId

const chatflow = await chatflowsService.getChatflowById(req.params.id, workspaceId)
// Not scoped by req.user.activeWorkspaceId (upstream #5228): this route is public, so the filter
// guards nothing, and it 404s chatflows in the caller's other workspaces. Same fix as 3cddc905c.
const chatflow = await chatflowsService.getChatflowById(req.params.id)
if (!chatflow) {
throw new InternalFlowiseError(StatusCodes.NOT_FOUND, `Chatflow ${req.params.id} not found`)
}
Expand Down
11 changes: 2 additions & 9 deletions packages/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ import { ExpressAdapter } from '@bull-board/express'

import { createRedisStore } from './AppConfig'
import { aaiPostAuthMiddleware } from './middlewares/authentication/aaiPostAuthMiddleware'
import { verifyAAIToken } from './middlewares/authentication/verifyAAIToken'
import { verifyAAIToken, isBearerJWT } from './middlewares/authentication/verifyAAIToken'
import { populateWorkspaceData } from './middlewares/authentication/populateWorkspaceData'
import { User } from './database/entities/User'
import { initAAI } from './aai/repository'
Expand Down Expand Up @@ -259,13 +259,6 @@ export class App {
await initializeJwtCookieMiddleware(this.app, this.identityManager)

this.app.use(async (req, res, next) => {
// Helper to detect JWT tokens (three dot-separated base64 segments)
const looksLikeJWT = (): boolean => {
const authHeader = req.headers.authorization as string | undefined
const token = authHeader?.startsWith('Bearer ') ? authHeader.split(' ')[1] : null
return token ? token.split('.').length === 3 : false
}

// Helper for API key authentication (used as primary or fallback)
const handleApiKeyAuth = async () => {
// Only check license validity for non-open-source platforms
Expand Down Expand Up @@ -359,7 +352,7 @@ export class App {
const isWhitelisted = whitelistURLs.some((url) => req.path.startsWith(url))
if (isWhitelisted) {
next()
} else if (req.headers['x-request-from'] === 'aai' || looksLikeJWT()) {
} else if (req.headers['x-request-from'] === 'aai' || isBearerJWT(req)) {
// AAI requests OR auto-detected JWT tokens: Auth0 RS256 JWT (primary) with enterprise HS256 fallback
// If JWT fails, fall through to API key validation
verifyAAIToken(this.AppDataSource)(req, res, (err?: any) => {
Expand Down
21 changes: 20 additions & 1 deletion packages/server/src/middlewares/authentication/verifyAAIToken.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,18 @@ const jwtCheck = auth({
tokenSigningAlg: process.env.AUTH0_TOKEN_SIGN_ALG ?? 'RS256'
})

export const verifyAAIToken = (AppDataSource: DataSource) => {
export const isBearerJWT = (req: Request): boolean => {
const authHeader = req.headers.authorization as string | undefined
const token = authHeader?.startsWith('Bearer ') ? authHeader.split(' ')[1] : null
return token ? token.split('.').length === 3 : false
}

// optional: for public routes (/prediction). Identifies the caller when a valid Auth0 JWT is sent,
// otherwise continues anonymous; never sends a response.
export const verifyAAIToken = (AppDataSource: DataSource, opts: { optional?: boolean } = {}) => {
return async (req: Request, res: Response, next: NextFunction) => {
if (opts.optional && (req.user || !isBearerJWT(req))) return next()

// STEP 1: Try Auth0 RS256 verification first
jwtCheck(req, res, async (auth0Err?: any) => {
if (!auth0Err) {
Expand All @@ -52,6 +62,10 @@ export const verifyAAIToken = (AppDataSource: DataSource) => {
const isValidOrg = userOrgId && process.env.AUTH0_ORGANIZATION_ID?.split(',')?.includes(userOrgId)

if (!isValidOrg) {
if (opts.optional) {
console.warn(`[verifyAAIToken] optional: org '${userOrgId}' not allowed, continuing anonymous`)
return next()
}
console.warn(`[verifyAAIToken] User ${authPayload.email} from org '${userOrgId}' - not in allowed orgs`)
return res.status(401).json({ message: "Unauthorized: Organization doesn't match" })
}
Expand Down Expand Up @@ -164,6 +178,11 @@ export const verifyAAIToken = (AppDataSource: DataSource) => {
}
}

if (opts.optional) {
console.warn('[verifyAAIToken] optional: continuing anonymous', auth0Err?.message)
return next()
}

// STEP 2: Auth0 failed - try enterprise passport (HS256)
// Import verifyToken lazily to avoid circular dependencies
try {
Expand Down
10 changes: 9 additions & 1 deletion packages/server/src/routes/predictions/index.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,24 @@
import express from 'express'
import express, { NextFunction, Request, Response } from 'express'
import predictionsController from '../../controllers/predictions'
import { getMulterStorage } from '../../utils'
import { getRunningExpressApp } from '../../utils/getRunningExpressApp'
import { verifyAAIToken } from '../../middlewares/authentication/verifyAAIToken'

const router = express.Router()

// /prediction is whitelisted, so the global auth never reads the token. Identify Auth0 callers
// (browser extension) here; anonymous callers pass through. The data source exists only at request time.
const optionalAAIAuth = (req: Request, res: Response, next: NextFunction) =>
verifyAAIToken(getRunningExpressApp().AppDataSource, { optional: true })(req, res, next)

// NOTE: extractChatflowId function in XSS.ts extracts the chatflow ID from the prediction URL.
// It assumes the URL format is /prediction/{chatflowId}. Make sure to update the function if the URL format changes.
// CREATE
router.post(
['/', '/:id'],
getMulterStorage().array('files'),
predictionsController.getRateLimiterMiddleware,
optionalAAIAuth,
predictionsController.createPrediction
)

Expand Down
193 changes: 193 additions & 0 deletions packages/server/test/auth/verifyAAIToken/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
/**
* verifyAAIToken unit tests — optional mode (public /prediction) and strict mode (unchanged).
*/

const mockJwtMiddleware = jest.fn()
jest.mock('express-oauth2-jwt-bearer', () => ({
auth: () => mockJwtMiddleware
}))

const mockVerifyToken = jest.fn()
jest.mock('../../../src/enterprise/middleware/passport', () => ({
verifyToken: (...args: any[]) => mockVerifyToken(...args)
}))
jest.mock('../../../src/utils/quotaUsage', () => ({
ENTERPRISE_FEATURE_FLAGS: []
}))
jest.mock('../../../src/middlewares/authentication/findOrCreateOrganization', () => ({
findOrCreateOrganization: jest.fn()
}))
jest.mock('../../../src/middlewares/authentication/findOrCreateUser', () => ({
findOrCreateUser: jest.fn(),
updateUserOrganization: jest.fn()
}))
jest.mock('../../../src/middlewares/authentication/findOrCreateWorkspacesForUser', () => ({
findOrCreateWorkspacesForUser: jest.fn().mockResolvedValue(undefined)
}))
jest.mock('../../../src/middlewares/authentication/populateWorkspaceData', () => ({
populateWorkspaceData: jest.fn().mockResolvedValue({ activeWorkspaceId: 'ws-1' })
}))

import { Request, Response } from 'express'
import { DataSource } from 'typeorm'
import { verifyAAIToken, isBearerJWT } from '../../../src/middlewares/authentication/verifyAAIToken'
import { findOrCreateUser } from '../../../src/middlewares/authentication/findOrCreateUser'

const AUTH0_ORG = 'org_valid123'
process.env.AUTH0_ORGANIZATION_ID = AUTH0_ORG

const dataSource = {
getRepository: jest.fn(() => ({ findOneBy: jest.fn().mockResolvedValue({ id: 'org-db-1', auth0Id: AUTH0_ORG }) }))
} as unknown as DataSource

const makeReq = (authorization?: string, user?: any) => ({ headers: authorization ? { authorization } : {}, user } as unknown as Request)

const makeRes = () => {
const res: any = {}
res.status = jest.fn().mockReturnValue(res)
res.json = jest.fn().mockReturnValue(res)
return res as Response & { status: jest.Mock; json: jest.Mock }
}

// Resolves when the middleware either calls next() or sends a response.
const run = (mw: ReturnType<typeof verifyAAIToken>, req: Request, res: ReturnType<typeof makeRes>) =>
new Promise<jest.Mock>((resolve) => {
const next: jest.Mock = jest.fn(() => resolve(next))
res.json.mockImplementation(() => {
resolve(next)
return res
})
mw(req, res, next)
})

const jwtSucceeds = (payload: Record<string, any>) =>
mockJwtMiddleware.mockImplementation((req: any, _res: any, next: any) => {
req.auth = { payload }
next()
})

const validPayload = { sub: 'auth0|u1', email: 'a@b.c', org_id: AUTH0_ORG }

beforeEach(() => {
jest.clearAllMocks()
;(findOrCreateUser as jest.Mock).mockResolvedValue({ id: 'user-1', email: 'a@b.c', name: 'A', organizationId: 'org-db-1' })
})

describe('isBearerJWT', () => {
it('is true only for Bearer with three dot-separated segments', () => {
expect(isBearerJWT(makeReq('Bearer a.b.c'))).toBe(true)
expect(isBearerJWT(makeReq('Bearer abc123'))).toBe(false)
expect(isBearerJWT(makeReq('Basic a.b.c'))).toBe(false)
expect(isBearerJWT(makeReq())).toBe(false)
})
})

describe('verifyAAIToken optional mode', () => {
const mw = verifyAAIToken(dataSource, { optional: true })

it('optional: no header skips jwtCheck', async () => {
const req = makeReq()
const res = makeRes()
const next = await run(mw, req, res)
expect(mockJwtMiddleware).not.toHaveBeenCalled()
expect(next).toHaveBeenCalledTimes(1)
expect(req.user).toBeUndefined()
expect(res.status).not.toHaveBeenCalled()
})

it('optional: api key is not treated as JWT', async () => {
const req = makeReq('Bearer abc123')
const res = makeRes()
const next = await run(mw, req, res)
expect(mockJwtMiddleware).not.toHaveBeenCalled()
expect(next).toHaveBeenCalledTimes(1)
expect(req.user).toBeUndefined()
})

it('optional: valid token sets req.user', async () => {
jwtSucceeds(validPayload)
const req = makeReq('Bearer a.b.c')
const res = makeRes()
const next = await run(mw, req, res)
expect(next).toHaveBeenCalledTimes(1)
expect((req.user as any).id).toBe('user-1')
expect((req.user as any).activeWorkspaceId).toBe('ws-1')
expect(res.status).not.toHaveBeenCalled()
})

it('optional: disallowed org continues anonymous', async () => {
jwtSucceeds({ ...validPayload, org_id: 'org_other' })
const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined)
const req = makeReq('Bearer a.b.c')
const res = makeRes()
const next = await run(mw, req, res)
expect(next).toHaveBeenCalledTimes(1)
expect(req.user).toBeUndefined()
expect(res.status).not.toHaveBeenCalled()
expect(findOrCreateUser).not.toHaveBeenCalled()
expect(JSON.stringify(warn.mock.calls)).not.toContain('a@b.c')
warn.mockRestore()
})

it('optional: invalid token continues anonymous', async () => {
mockJwtMiddleware.mockImplementation((_req: any, _res: any, next: any) => next(new Error('invalid_token')))
jest.spyOn(console, 'warn').mockImplementation(() => undefined)
const req = makeReq('Bearer a.b.c')
const res = makeRes()
const next = await run(mw, req, res)
expect(next).toHaveBeenCalledTimes(1)
expect(next).toHaveBeenCalledWith()
expect(req.user).toBeUndefined()
expect(res.status).not.toHaveBeenCalled()
expect(mockVerifyToken).not.toHaveBeenCalled()
})

it('optional: user sync error continues anonymous', async () => {
jwtSucceeds(validPayload)
;(findOrCreateUser as jest.Mock).mockRejectedValue(new Error('db down'))
jest.spyOn(console, 'error').mockImplementation(() => undefined)
jest.spyOn(console, 'warn').mockImplementation(() => undefined)
const req = makeReq('Bearer a.b.c')
const res = makeRes()
const next = await run(mw, req, res)
expect(next).toHaveBeenCalledTimes(1)
expect(req.user).toBeUndefined()
expect(res.status).not.toHaveBeenCalled()
expect(mockVerifyToken).not.toHaveBeenCalled()
})

it('optional: existing req.user is kept', async () => {
const existing = { id: 'session-user' }
const req = makeReq('Bearer a.b.c', existing)
const res = makeRes()
const next = await run(mw, req, res)
expect(mockJwtMiddleware).not.toHaveBeenCalled()
expect(next).toHaveBeenCalledTimes(1)
expect(req.user).toBe(existing)
})
})

describe('verifyAAIToken strict mode', () => {
const mw = verifyAAIToken(dataSource)

it('strict: invalid token still returns 401', async () => {
mockJwtMiddleware.mockImplementation((_req: any, _res: any, next: any) => next(new Error('invalid_token')))
mockVerifyToken.mockImplementation((_req: any, _res: any, cb: any) => cb(new Error('no passport user')))
jest.spyOn(console, 'warn').mockImplementation(() => undefined)
const req = makeReq('Bearer a.b.c')
const res = makeRes()
const next = await run(mw, req, res)
expect(res.status).toHaveBeenCalledWith(401)
expect(next).not.toHaveBeenCalled()
})

it('strict: disallowed org still returns 401', async () => {
jwtSucceeds({ ...validPayload, org_id: 'org_other' })
jest.spyOn(console, 'warn').mockImplementation(() => undefined)
const req = makeReq('Bearer a.b.c')
const res = makeRes()
const next = await run(mw, req, res)
expect(res.status).toHaveBeenCalledWith(401)
expect(next).not.toHaveBeenCalled()
})
})
Loading
Loading