import type { NextFunction, Request, Response } from 'express' import { createRemoteJWKSet, jwtVerify, type JWTPayload } from 'jose' import { approvedRoleForSubject } from './access.js' import { config } from './config.js' export type AuthIdentity = { subject: string username?: string email?: string roles: string[] realmRoles: string[] clientId?: string } declare global { namespace Express { interface Request { identity?: AuthIdentity } } } const jwks = createRemoteJWKSet(new URL( config.jwksUri || `${config.issuer}/protocol/openid-connect/certs`, )) function strings(value: unknown) { return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : [] } export function rolesFromPayload(payload: JWTPayload) { const resourceAccess = payload.resource_access as Record | undefined return [...new Set([ ...strings(resourceAccess?.[config.audience]?.roles), ...strings(resourceAccess?.[config.browserClientId]?.roles), ...strings(resourceAccess?.[config.machineClientId]?.roles), ])] } export function realmRolesFromPayload(payload: JWTPayload) { const realmAccess = payload.realm_access as { roles?: unknown } | undefined return [...new Set(strings(realmAccess?.roles))] } function intendedForFieldLens(payload: JWTPayload) { const audience = Array.isArray(payload.aud) ? payload.aud : payload.aud ? [payload.aud] : [] const azp = typeof payload.azp === 'string' ? payload.azp : '' return audience.includes(config.audience) || azp === config.browserClientId || azp === config.machineClientId } export async function authenticate(req: Request, res: Response, next: NextFunction) { const header = req.header('authorization') if (!header?.startsWith('Bearer ')) { res.status(401).json({ error: 'authentication_required' }) return } try { const token = header.slice('Bearer '.length) const { payload } = await jwtVerify(token, jwks, { issuer: config.issuer }) if (!intendedForFieldLens(payload) || !payload.sub) { res.status(401).json({ error: 'invalid_token_target' }) return } const tokenRoles = rolesFromPayload(payload) const approvedRole = await approvedRoleForSubject(payload.sub) req.identity = { subject: payload.sub, username: typeof payload.preferred_username === 'string' ? payload.preferred_username : undefined, email: typeof payload.email === 'string' ? payload.email : undefined, roles: [...new Set([...tokenRoles, ...(approvedRole ? [approvedRole] : [])])], realmRoles: realmRolesFromPayload(payload), clientId: typeof payload.azp === 'string' ? payload.azp : undefined, } next() } catch { res.status(401).json({ error: 'invalid_token' }) } } const access = { operator: ['fieldlens-operator', 'fieldlens-supervisor', 'fieldlens-admin'], supervisor: ['fieldlens-supervisor', 'fieldlens-admin'], admin: ['fieldlens-admin'], integration: ['fieldlens-integration', 'fieldlens-admin'], } as const export function hasAccess( level: keyof typeof access, identity: Pick | undefined, ) { const exactMachineIdentity = level === 'integration' && identity?.clientId === config.machineClientId return exactMachineIdentity || access[level].some((role) => identity?.roles.includes(role) ?? false) } export function canApproveAccess( identity: Pick | undefined, ) { return Boolean( identity?.roles.includes('fieldlens-admin') || identity?.realmRoles.includes('admin') || identity?.realmRoles.includes('owner'), ) } export function requireAccess(level: keyof typeof access) { return (req: Request, res: Response, next: NextFunction) => { if (!hasAccess(level, req.identity)) { res.status(403).json({ error: 'forbidden' }) return } next() } } export function requireAccessApprover(req: Request, res: Response, next: NextFunction) { if (!canApproveAccess(req.identity)) { res.status(403).json({ error: 'forbidden' }) return } next() }