fix(security): restrict API key access on internal-only routes (#2964)

* fix(security): restrict API key access on internal-only routes

* test(security): update function execute tests for checkInternalAuth

* updated agent handler

* move session check higher in checkSessionOrInternalAuth

* extracted duplicate code into helper for resolving user from jwt
This commit is contained in:
Waleed
2026-01-24 10:15:52 -08:00
committed by GitHub
parent 211a7ac3a4
commit 48adaa00d8
88 changed files with 575 additions and 758 deletions

View File

@@ -16,6 +16,168 @@ export interface AuthResult {
error?: string
}
/**
* Resolves userId from a verified internal JWT token.
* Extracts workflowId/userId from URL params or POST body, then looks up userId if needed.
*/
async function resolveUserFromJwt(
request: NextRequest,
verificationUserId: string | null,
options: { requireWorkflowId?: boolean }
): Promise<AuthResult> {
let workflowId: string | null = null
let userId: string | null = verificationUserId
const { searchParams } = new URL(request.url)
workflowId = searchParams.get('workflowId')
if (!userId) {
userId = searchParams.get('userId')
}
if (!workflowId && !userId && request.method === 'POST') {
try {
const clonedRequest = request.clone()
const bodyText = await clonedRequest.text()
if (bodyText) {
const body = JSON.parse(bodyText)
workflowId = body.workflowId || body._context?.workflowId
userId = userId || body.userId || body._context?.userId
}
} catch {
// Ignore JSON parse errors
}
}
if (userId) {
return { success: true, userId, authType: 'internal_jwt' }
}
if (workflowId) {
const [workflowData] = await db
.select({ userId: workflow.userId })
.from(workflow)
.where(eq(workflow.id, workflowId))
.limit(1)
if (!workflowData) {
return { success: false, error: 'Workflow not found' }
}
return { success: true, userId: workflowData.userId, authType: 'internal_jwt' }
}
if (options.requireWorkflowId !== false) {
return { success: false, error: 'workflowId or userId required for internal JWT calls' }
}
return { success: true, authType: 'internal_jwt' }
}
/**
* Check for internal JWT authentication only.
* Use this for routes that should ONLY be accessible by the executor (server-to-server).
* Rejects session and API key authentication.
*
* @param request - The incoming request
* @param options - Optional configuration
* @param options.requireWorkflowId - Whether workflowId/userId is required (default: true)
*/
export async function checkInternalAuth(
request: NextRequest,
options: { requireWorkflowId?: boolean } = {}
): Promise<AuthResult> {
try {
const authHeader = request.headers.get('authorization')
const apiKeyHeader = request.headers.get('x-api-key')
if (apiKeyHeader) {
return {
success: false,
error: 'API key access not allowed for this endpoint. Use workflow execution instead.',
}
}
if (!authHeader?.startsWith('Bearer ')) {
return {
success: false,
error: 'Internal authentication required',
}
}
const token = authHeader.split(' ')[1]
const verification = await verifyInternalToken(token)
if (!verification.valid) {
return { success: false, error: 'Invalid internal token' }
}
return resolveUserFromJwt(request, verification.userId || null, options)
} catch (error) {
logger.error('Error in internal authentication:', error)
return {
success: false,
error: 'Authentication error',
}
}
}
/**
* Check for session or internal JWT authentication.
* Use this for routes that should be accessible by the UI and executor,
* but NOT by external API keys.
*
* @param request - The incoming request
* @param options - Optional configuration
* @param options.requireWorkflowId - Whether workflowId/userId is required for JWT (default: true)
*/
export async function checkSessionOrInternalAuth(
request: NextRequest,
options: { requireWorkflowId?: boolean } = {}
): Promise<AuthResult> {
try {
// 1. Reject API keys first
const apiKeyHeader = request.headers.get('x-api-key')
if (apiKeyHeader) {
return {
success: false,
error: 'API key access not allowed for this endpoint',
}
}
// 2. Check for internal JWT token
const authHeader = request.headers.get('authorization')
if (authHeader?.startsWith('Bearer ')) {
const token = authHeader.split(' ')[1]
const verification = await verifyInternalToken(token)
if (verification.valid) {
return resolveUserFromJwt(request, verification.userId || null, options)
}
}
// 3. Try session auth (for web UI)
const session = await getSession()
if (session?.user?.id) {
return {
success: true,
userId: session.user.id,
authType: 'session',
}
}
return {
success: false,
error: 'Authentication required - provide session or internal JWT',
}
} catch (error) {
logger.error('Error in session/internal authentication:', error)
return {
success: false,
error: 'Authentication error',
}
}
}
/**
* Check for authentication using any of the 3 supported methods:
* 1. Session authentication (cookies)
@@ -36,70 +198,7 @@ export async function checkHybridAuth(
const verification = await verifyInternalToken(token)
if (verification.valid) {
let workflowId: string | null = null
let userId: string | null = verification.userId || null
const { searchParams } = new URL(request.url)
workflowId = searchParams.get('workflowId')
if (!userId) {
userId = searchParams.get('userId')
}
if (!workflowId && !userId && request.method === 'POST') {
try {
// Clone the request to avoid consuming the original body
const clonedRequest = request.clone()
const bodyText = await clonedRequest.text()
if (bodyText) {
const body = JSON.parse(bodyText)
workflowId = body.workflowId || body._context?.workflowId
userId = userId || body.userId || body._context?.userId
}
} catch {
// Ignore JSON parse errors
}
}
if (userId) {
return {
success: true,
userId,
authType: 'internal_jwt',
}
}
if (workflowId) {
const [workflowData] = await db
.select({ userId: workflow.userId })
.from(workflow)
.where(eq(workflow.id, workflowId))
.limit(1)
if (!workflowData) {
return {
success: false,
error: 'Workflow not found',
}
}
return {
success: true,
userId: workflowData.userId,
authType: 'internal_jwt',
}
}
if (options.requireWorkflowId !== false) {
return {
success: false,
error: 'workflowId or userId required for internal JWT calls',
}
}
return {
success: true,
authType: 'internal_jwt',
}
return resolveUserFromJwt(request, verification.userId || null, options)
}
}

View File

@@ -2,3 +2,5 @@ export type { AnonymousSession } from './anonymous'
export { createAnonymousSession, ensureAnonymousUserExists } from './anonymous'
export { auth, getSession, signIn, signUp } from './auth'
export { ANONYMOUS_USER, ANONYMOUS_USER_ID } from './constants'
export type { AuthResult } from './hybrid'
export { checkHybridAuth, checkInternalAuth, checkSessionOrInternalAuth } from './hybrid'