Admin 9152cccb0a
Some checks failed
CI / verify (push) Has been cancelled
Sync FieldLens access request approvals
2026-09-23 02:20:39 +00:00

126 lines
4.0 KiB
TypeScript

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<string, { roles?: unknown }> | 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<AuthIdentity, 'roles' | 'clientId'> | 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<AuthIdentity, 'roles' | 'realmRoles'> | 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()
}