feat(auth): allow dedicated PKCE MCP client only on MCP endpoint
This commit is contained in:
parent
c41d242667
commit
478f90a230
@ -1,7 +1,13 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
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', () => {
|
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', () => {
|
it('extracts FieldLens client roles and ignores realm/platform roles for app access', () => {
|
||||||
const payload = {
|
const payload = {
|
||||||
resource_access: {
|
resource_access: {
|
||||||
|
|||||||
@ -33,6 +33,7 @@ export function rolesFromPayload(payload: JWTPayload) {
|
|||||||
return [...new Set([
|
return [...new Set([
|
||||||
...strings(resourceAccess?.[config.audience]?.roles),
|
...strings(resourceAccess?.[config.audience]?.roles),
|
||||||
...strings(resourceAccess?.[config.browserClientId]?.roles),
|
...strings(resourceAccess?.[config.browserClientId]?.roles),
|
||||||
|
...strings(resourceAccess?.[config.mcpClientId]?.roles),
|
||||||
...config.desktopClientIds.flatMap((clientId) => strings(resourceAccess?.[clientId]?.roles)),
|
...config.desktopClientIds.flatMap((clientId) => strings(resourceAccess?.[clientId]?.roles)),
|
||||||
...strings(resourceAccess?.[config.machineClientId]?.roles),
|
...strings(resourceAccess?.[config.machineClientId]?.roles),
|
||||||
])]
|
])]
|
||||||
@ -43,9 +44,10 @@ export function realmRolesFromPayload(payload: JWTPayload) {
|
|||||||
return [...new Set(strings(realmAccess?.roles))]
|
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 audience = Array.isArray(payload.aud) ? payload.aud : payload.aud ? [payload.aud] : []
|
||||||
const azp = typeof payload.azp === 'string' ? payload.azp : ''
|
const azp = typeof payload.azp === 'string' ? payload.azp : ''
|
||||||
|
if (azp === config.mcpClientId) return mcpEndpoint
|
||||||
return audience.includes(config.audience)
|
return audience.includes(config.audience)
|
||||||
|| azp === config.browserClientId
|
|| azp === config.browserClientId
|
||||||
|| config.desktopClientIds.includes(azp)
|
|| config.desktopClientIds.includes(azp)
|
||||||
@ -62,7 +64,7 @@ export async function authenticate(req: Request, res: Response, next: NextFuncti
|
|||||||
try {
|
try {
|
||||||
const token = header.slice('Bearer '.length)
|
const token = header.slice('Bearer '.length)
|
||||||
const { payload } = await jwtVerify(token, jwks, { issuer: config.issuer })
|
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' })
|
res.status(401).json({ error: 'invalid_token_target' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,6 +19,7 @@ export const config = {
|
|||||||
jwksUri: (process.env.KEYCLOAK_JWKS_URI ?? '').trim(),
|
jwksUri: (process.env.KEYCLOAK_JWKS_URI ?? '').trim(),
|
||||||
audience: process.env.KEYCLOAK_AUDIENCE?.trim() || 'fieldlens-api',
|
audience: process.env.KEYCLOAK_AUDIENCE?.trim() || 'fieldlens-api',
|
||||||
browserClientId: process.env.KEYCLOAK_BROWSER_CLIENT_ID?.trim() || 'fieldlens',
|
browserClientId: process.env.KEYCLOAK_BROWSER_CLIENT_ID?.trim() || 'fieldlens',
|
||||||
|
mcpClientId: process.env.KEYCLOAK_MCP_CLIENT_ID?.trim() || 'fieldlens-mcp',
|
||||||
desktopClientId,
|
desktopClientId,
|
||||||
desktopClientIds: list('KEYCLOAK_DESKTOP_CLIENT_IDS', [
|
desktopClientIds: list('KEYCLOAK_DESKTOP_CLIENT_IDS', [
|
||||||
desktopClientId,
|
desktopClientId,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user