feat(auth): allow dedicated PKCE MCP client only on MCP endpoint
Some checks failed
CI / verify (push) Has been cancelled
CI / print-rust (push) Has been cancelled

This commit is contained in:
Admin 2026-09-27 00:22:30 +00:00
parent c41d242667
commit 478f90a230
3 changed files with 12 additions and 3 deletions

View File

@ -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: {

View File

@ -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
}

View File

@ -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,