diff --git a/apps/api/src/auth.test.ts b/apps/api/src/auth.test.ts index e87235d..bc0c60c 100644 --- a/apps/api/src/auth.test.ts +++ b/apps/api/src/auth.test.ts @@ -1,7 +1,13 @@ import { describe, expect, it } from 'vitest' -import { canApproveAccess, hasAccess, realmRolesFromPayload, rolesFromPayload } from './auth.js' +import { canApproveAccess, hasAccess, intendedForFieldLens, realmRolesFromPayload, rolesFromPayload } from './auth.js' describe('FieldLens authorization isolation', () => { + it('accepts the dedicated MCP client only at the MCP endpoint', () => { + const token = { azp: 'fieldlens-mcp', aud: 'fieldlens-api' } + expect(intendedForFieldLens(token, true)).toBe(true) + expect(intendedForFieldLens(token, false)).toBe(false) + expect(intendedForFieldLens({ azp: 'unrelated' }, true)).toBe(false) + }) it('extracts FieldLens client roles and ignores realm/platform roles for app access', () => { const payload = { resource_access: { diff --git a/apps/api/src/auth.ts b/apps/api/src/auth.ts index 52dbde3..86c03ae 100644 --- a/apps/api/src/auth.ts +++ b/apps/api/src/auth.ts @@ -33,6 +33,7 @@ export function rolesFromPayload(payload: JWTPayload) { return [...new Set([ ...strings(resourceAccess?.[config.audience]?.roles), ...strings(resourceAccess?.[config.browserClientId]?.roles), + ...strings(resourceAccess?.[config.mcpClientId]?.roles), ...config.desktopClientIds.flatMap((clientId) => strings(resourceAccess?.[clientId]?.roles)), ...strings(resourceAccess?.[config.machineClientId]?.roles), ])] @@ -43,9 +44,10 @@ export function realmRolesFromPayload(payload: JWTPayload) { return [...new Set(strings(realmAccess?.roles))] } -function intendedForFieldLens(payload: JWTPayload) { +export function intendedForFieldLens(payload: JWTPayload, mcpEndpoint = false) { const audience = Array.isArray(payload.aud) ? payload.aud : payload.aud ? [payload.aud] : [] const azp = typeof payload.azp === 'string' ? payload.azp : '' + if (azp === config.mcpClientId) return mcpEndpoint return audience.includes(config.audience) || azp === config.browserClientId || config.desktopClientIds.includes(azp) @@ -62,7 +64,7 @@ export async function authenticate(req: Request, res: Response, next: NextFuncti try { const token = header.slice('Bearer '.length) const { payload } = await jwtVerify(token, jwks, { issuer: config.issuer }) - if (!intendedForFieldLens(payload) || !payload.sub) { + if (!intendedForFieldLens(payload, req.baseUrl === '/api/mcp') || !payload.sub) { res.status(401).json({ error: 'invalid_token_target' }) return } diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts index 453f5e6..bc8dc15 100644 --- a/apps/api/src/config.ts +++ b/apps/api/src/config.ts @@ -19,6 +19,7 @@ export const config = { jwksUri: (process.env.KEYCLOAK_JWKS_URI ?? '').trim(), audience: process.env.KEYCLOAK_AUDIENCE?.trim() || 'fieldlens-api', browserClientId: process.env.KEYCLOAK_BROWSER_CLIENT_ID?.trim() || 'fieldlens', + mcpClientId: process.env.KEYCLOAK_MCP_CLIENT_ID?.trim() || 'fieldlens-mcp', desktopClientId, desktopClientIds: list('KEYCLOAK_DESKTOP_CLIENT_IDS', [ desktopClientId,