126 lines
4.0 KiB
TypeScript
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()
|
|
}
|