feat(reui): update ReUI components and documentation
Build and Push Auth Portal Docker Image / build-and-push (push) Successful in 2m30s
Build and Push Auth Portal Docker Image / create-release (push) Skipped

- Added new OIDC configuration options in `.env.example`.
- Expanded documentation in `AGENTS.md` to include OIDC endpoints and admin UI.
- Updated ReUI skill version and component count from 17 to 20 across various documentation files.
- Enhanced `README.md` and other related files to reflect the new component structure and usage guidelines.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Denozordec
2026-08-11 16:58:47 +07:00
co-authored by Cursor
parent cc38bf06f8
commit 0b6fa65b08
61 changed files with 2387 additions and 103 deletions
+7
View File
@@ -4,6 +4,7 @@ import cors from '@fastify/cors'
import helmet from '@fastify/helmet'
import rateLimit from '@fastify/rate-limit'
import sensible from '@fastify/sensible'
import formbody from '@fastify/formbody'
import fjwt from '@fastify/jwt'
import fastifyStatic from '@fastify/static'
import { existsSync, mkdirSync } from 'node:fs'
@@ -29,7 +30,9 @@ import { authRoutes } from './routes/auth.js'
import { adminRoutes } from './routes/admin.js'
import { auditAdminRoutes } from './routes/audit.js'
import { auditIngestRoutes } from './routes/ingest-audit.js'
import { oidcRoutes } from './routes/oidc.js'
import { startAuditRetentionJob } from './services/audit-retention.js'
import { ensureOidcSigningKey, resetOidcKeyCache } from './lib/oidc/keys.js'
declare module 'fastify' {
interface FastifyInstance {
@@ -100,6 +103,7 @@ export async function buildApp(opts: {
app.decorate('sqlite', sqlite)
await app.register(sensible)
await app.register(formbody)
await app.register(cors, { origin: true, credentials: true })
await app.register(helmet, { contentSecurityPolicy: false })
await app.register(rateLimit, { max: 200, timeWindow: '1 minute' })
@@ -117,11 +121,14 @@ export async function buildApp(opts: {
app.get('/api/v1/health', async () => ({ ok: true, service: 'auth-portal' }))
resetOidcKeyCache()
await ensureOidcSigningKey(app)
await ensureBootstrapAdmin(app)
await app.register(authRoutes)
await app.register(adminRoutes)
await app.register(auditAdminRoutes)
await app.register(auditIngestRoutes)
await app.register(oidcRoutes)
if (process.env.NODE_ENV !== 'test') {
const stopRetention = startAuditRetentionJob(app)
+12 -1
View File
@@ -11,6 +11,10 @@ export const configSchema = z.object({
jwtTtlHours: z.coerce.number().positive().default(1),
refreshTtlDays: z.coerce.number().positive().default(14),
issuer: z.string().url().default('https://auth.shnt.top'),
/** OIDC issuer URL; defaults to `issuer`. */
oidcIssuer: z.string().url().optional(),
/** Optional PEM PKCS8 RSA private key; otherwise auto-generated & persisted. */
oidcRsaPrivateKey: z.string().optional(),
adminEmail: z.string().email().default('admin@shnt.top'),
adminPassword: z.string().default('admin'),
adminName: z.string().default('Admin'),
@@ -30,13 +34,16 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
const auditIngestSecret =
env.AUDIT_INGEST_SECRET ??
(isProd ? undefined : 'dev-audit-ingest-secret')
const issuer = env.ISSUER ?? 'https://auth.shnt.top'
return configSchema.parse({
databaseUrl: env.DATABASE_URL ?? 'sqlite:data/app.db',
jwtSecret,
jwtTtlHours: env.JWT_TTL_HOURS ?? 1,
refreshTtlDays: env.REFRESH_TTL_DAYS ?? 14,
issuer: env.ISSUER ?? 'https://auth.shnt.top',
issuer,
oidcIssuer: env.OIDC_ISSUER || issuer,
oidcRsaPrivateKey: env.OIDC_RSA_PRIVATE_KEY || undefined,
adminEmail: env.ADMIN_EMAIL ?? 'admin@shnt.top',
adminPassword: env.ADMIN_PASSWORD ?? 'admin',
adminName: env.ADMIN_NAME ?? 'Admin',
@@ -51,3 +58,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
auditIngestSecret,
})
}
export function oidcIssuerFromConfig(config: AppConfig): string {
return (config.oidcIssuer ?? config.issuer).replace(/\/$/, '')
}
+181
View File
@@ -0,0 +1,181 @@
import {
exportJWK,
exportPKCS8,
generateKeyPair,
importJWK,
importPKCS8,
jwtVerify,
SignJWT,
type JWK,
type KeyLike,
} from 'jose'
import { createHash, randomUUID } from 'node:crypto'
import type { FastifyInstance } from 'fastify'
import {
getActiveOidcSigningKey,
insertOidcSigningKey,
listOidcSigningKeys,
} from '@authportal/db'
import { oidcIssuerFromConfig, type AppConfig } from '../../config.js'
export type OidcKeyMaterial = {
kid: string
privateKey: KeyLike
publicJwk: JWK
}
let cached: OidcKeyMaterial | null = null
function publicJwkFromPrivateExport(jwk: JWK, kid: string): JWK {
return {
kty: jwk.kty,
n: jwk.n,
e: jwk.e,
alg: 'RS256',
use: 'sig',
kid,
}
}
async function materialFromPem(
kid: string,
privatePem: string,
): Promise<OidcKeyMaterial> {
const privateKey = await importPKCS8(privatePem, 'RS256')
const full = await exportJWK(privateKey)
return {
kid,
privateKey,
publicJwk: publicJwkFromPrivateExport(full, kid),
}
}
/**
* Ensure an active RSA signing key exists (env PEM or DB / generate).
*/
export async function ensureOidcSigningKey(
app: FastifyInstance,
): Promise<OidcKeyMaterial> {
if (cached) return cached
const envPem = app.config.oidcRsaPrivateKey?.trim()
if (envPem) {
const kid = createHash('sha256').update(envPem).digest('hex').slice(0, 16)
cached = await materialFromPem(kid, envPem)
return cached
}
const existing = getActiveOidcSigningKey(app.db)
if (existing) {
cached = await materialFromPem(existing.kid, existing.privatePem)
return cached
}
const { privateKey, publicKey } = await generateKeyPair('RS256', {
extractable: true,
})
const kid = randomUUID().replace(/-/g, '').slice(0, 16)
const privatePem = await exportPKCS8(privateKey)
const publicJwk = await exportJWK(publicKey)
publicJwk.alg = 'RS256'
publicJwk.use = 'sig'
publicJwk.kid = kid
insertOidcSigningKey(app.db, {
kid,
privatePem,
publicJwkJson: JSON.stringify(publicJwk),
})
cached = { kid, privateKey, publicJwk }
app.log.info({ kid }, 'OIDC RS256 signing key generated')
return cached
}
/** Reset cache (tests). */
export function resetOidcKeyCache(): void {
cached = null
}
export function buildOidcDiscovery(config: AppConfig) {
const issuer = oidcIssuerFromConfig(config)
return {
issuer,
authorization_endpoint: `${issuer}/oauth/authorize`,
token_endpoint: `${issuer}/oauth/token`,
userinfo_endpoint: `${issuer}/oauth/userinfo`,
jwks_uri: `${issuer}/.well-known/jwks.json`,
response_types_supported: ['code'],
subject_types_supported: ['public'],
id_token_signing_alg_values_supported: ['RS256'],
scopes_supported: ['openid', 'profile', 'email', 'groups'],
token_endpoint_auth_methods_supported: [
'client_secret_post',
'client_secret_basic',
],
claims_supported: [
'sub',
'iss',
'aud',
'exp',
'iat',
'email',
'email_verified',
'name',
'preferred_username',
'groups',
'roles',
],
grant_types_supported: ['authorization_code'],
code_challenge_methods_supported: ['S256', 'plain'],
}
}
export async function buildJwks(
app: FastifyInstance,
): Promise<{ keys: JWK[] }> {
await ensureOidcSigningKey(app)
const keys = listOidcSigningKeys(app.db)
.map((row) => {
try {
const jwk = JSON.parse(row.publicJwkJson) as JWK
return { ...jwk, kid: row.kid, alg: 'RS256', use: 'sig' }
} catch {
return null
}
})
.filter((k): k is JWK => k != null)
if (cached && !keys.some((k) => k.kid === cached!.kid)) {
keys.unshift(cached.publicJwk)
}
return { keys }
}
export async function signOidcJwt(
app: FastifyInstance,
claims: Record<string, unknown>,
expiresInSeconds: number,
): Promise<string> {
const key = await ensureOidcSigningKey(app)
const issuer = oidcIssuerFromConfig(app.config)
return new SignJWT(claims)
.setProtectedHeader({ alg: 'RS256', kid: key.kid, typ: 'JWT' })
.setIssuer(issuer)
.setIssuedAt()
.setExpirationTime(`${expiresInSeconds}s`)
.sign(key.privateKey)
}
/** Verify OIDC access token (RS256) and return payload. */
export async function verifyOidcAccessToken(
app: FastifyInstance,
token: string,
): Promise<Record<string, unknown>> {
const key = await ensureOidcSigningKey(app)
const issuer = oidcIssuerFromConfig(app.config)
const publicKey = await importJWK(key.publicJwk, 'RS256')
const { payload } = await jwtVerify(token, publicKey, {
issuer,
algorithms: ['RS256'],
})
return payload as Record<string, unknown>
}
+7
View File
@@ -31,6 +31,13 @@ export function targetAppFromReturnTo(
) {
return 'fw'
}
if (
/\bdns\b/.test(hay) ||
host.includes('technitium') ||
path.includes('/sso/')
) {
return 'dns'
}
return 'portal'
}
+139
View File
@@ -15,20 +15,32 @@ import {
setAppSwitcherConfig,
setUserAccess,
updateUser,
createOidcClient,
deleteOidcClient,
generateOidcClientSecret,
getOidcClientById,
listOidcClients,
parseJsonStringArray,
updateOidcClient,
} from '@authportal/db'
import {
APP_IDS,
OIDC_SCOPES,
allPermissionKeys,
appSwitcherConfigSchema,
createOidcClientRequestSchema,
createUserRequestSchema,
normalizePermissionKeys,
patchOidcClientRequestSchema,
patchUserRequestSchema,
putUserAccessRequestSchema,
type AdminUser,
type AppId,
type OidcClientPublic,
} from '@authportal/shared'
import { requireAdmin } from '../plugins/auth-guards.js'
import { actorFromRequest, clientIp, safeAudit } from '../lib/audit.js'
import { oidcIssuerFromConfig } from '../config.js'
const allowedPermissions = new Set(allPermissionKeys())
@@ -69,6 +81,21 @@ function validateAccess(apps: string[], permissions: string[]): string | null {
return null
}
function mapOidcClient(
row: NonNullable<ReturnType<typeof getOidcClientById>>,
): OidcClientPublic {
return {
id: row.id,
client_id: row.clientId,
name: row.name,
redirect_uris: parseJsonStringArray(row.redirectUrisJson),
scopes: parseJsonStringArray(row.scopesJson),
enabled: row.enabled,
created_at: row.createdAt,
updated_at: row.updatedAt,
}
}
export async function adminRoutes(app: FastifyInstance): Promise<void> {
app.addHook('onRequest', async (request, reply) => {
if (!request.url.startsWith('/api/v1/admin')) return
@@ -361,4 +388,116 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
return { revoked }
},
)
app.get('/api/v1/admin/oidc/meta', async () => {
const issuer = oidcIssuerFromConfig(app.config)
return {
issuer,
discovery_url: `${issuer}/.well-known/openid-configuration`,
jwks_url: `${issuer}/.well-known/jwks.json`,
scopes: [...OIDC_SCOPES],
}
})
app.get('/api/v1/admin/oidc/clients', async () =>
listOidcClients(app.db).map(mapOidcClient),
)
app.post('/api/v1/admin/oidc/clients', async (request, reply) => {
const parsed = createOidcClientRequestSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({
error: { code: 'VALIDATION_ERROR', message: 'Некорректные данные' },
})
}
const secret = generateOidcClientSecret()
const secretHash = await hash(secret)
const row = createOidcClient(app.db, {
name: parsed.data.name,
clientSecretHash: secretHash,
redirectUris: parsed.data.redirect_uris,
scopes: parsed.data.scopes,
enabled: parsed.data.enabled,
})
const mapped = mapOidcClient(row)
safeAudit(app, {
action: 'app_switcher.update',
severity: 'info',
...actorFromRequest(request),
targetType: 'settings',
targetId: row.id,
summary: `OIDC client создан: ${row.name}`,
details: { client_id: row.clientId },
ip: clientIp(request),
})
return { ...mapped, client_secret: secret }
})
app.get<{ Params: { id: string } }>(
'/api/v1/admin/oidc/clients/:id',
async (request, reply) => {
const row = getOidcClientById(app.db, request.params.id)
if (!row) {
return reply.status(404).send({
error: { code: 'NOT_FOUND', message: 'Клиент не найден' },
})
}
return mapOidcClient(row)
},
)
app.patch<{ Params: { id: string } }>(
'/api/v1/admin/oidc/clients/:id',
async (request, reply) => {
const parsed = patchOidcClientRequestSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({
error: { code: 'VALIDATION_ERROR', message: 'Некорректные данные' },
})
}
const row = updateOidcClient(app.db, request.params.id, {
name: parsed.data.name,
redirectUris: parsed.data.redirect_uris,
scopes: parsed.data.scopes,
enabled: parsed.data.enabled,
})
if (!row) {
return reply.status(404).send({
error: { code: 'NOT_FOUND', message: 'Клиент не найден' },
})
}
return mapOidcClient(row)
},
)
app.delete<{ Params: { id: string } }>(
'/api/v1/admin/oidc/clients/:id',
async (request, reply) => {
const ok = deleteOidcClient(app.db, request.params.id)
if (!ok) {
return reply.status(404).send({
error: { code: 'NOT_FOUND', message: 'Клиент не найден' },
})
}
return { ok: true }
},
)
app.post<{ Params: { id: string } }>(
'/api/v1/admin/oidc/clients/:id/rotate-secret',
async (request, reply) => {
const existing = getOidcClientById(app.db, request.params.id)
if (!existing) {
return reply.status(404).send({
error: { code: 'NOT_FOUND', message: 'Клиент не найден' },
})
}
const secret = generateOidcClientSecret()
const secretHash = await hash(secret)
const row = updateOidcClient(app.db, request.params.id, {
clientSecretHash: secretHash,
})!
return { ...mapOidcClient(row), client_secret: secret }
},
)
}
+433
View File
@@ -0,0 +1,433 @@
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'
import { verify } from '@node-rs/argon2'
import {
createOidcAuthCode,
consumeOidcAuthCode,
getOidcClientByClientId,
getUserById,
getUserByRefreshToken,
getUserApps,
getUserPermissions,
parseJsonStringArray,
type OidcClientRow,
type UserRow,
} from '@authportal/db'
import {
oidcGroupsForUser,
normalizePermissionKeys,
} from '@authportal/shared'
import { oidcIssuerFromConfig } from '../config.js'
import {
buildJwks,
buildOidcDiscovery,
ensureOidcSigningKey,
signOidcJwt,
verifyOidcAccessToken,
} from '../lib/oidc/keys.js'
const REFRESH_COOKIE = 'refresh_token'
const CODE_TTL_MS = 5 * 60 * 1000
const ACCESS_TTL_SEC = 3600
const ID_TOKEN_TTL_SEC = 3600
function cookieValue(
cookieHeader: string | undefined,
name: string,
): string | null {
if (!cookieHeader) return null
const match = cookieHeader.match(new RegExp(`${name}=([^;]+)`))
return match?.[1] ? decodeURIComponent(match[1]) : null
}
function parseBasicAuth(
header: string | undefined,
): { clientId: string; clientSecret: string } | null {
if (!header?.startsWith('Basic ')) return null
try {
const decoded = Buffer.from(header.slice(6), 'base64').toString('utf8')
const idx = decoded.indexOf(':')
if (idx < 0) return null
return {
clientId: decoded.slice(0, idx),
clientSecret: decoded.slice(idx + 1),
}
} catch {
return null
}
}
async function authenticateClient(
client: OidcClientRow,
secret: string,
): Promise<boolean> {
try {
return await verify(client.clientSecretHash, secret)
} catch {
return false
}
}
function redirectError(
reply: FastifyReply,
redirectUri: string | undefined,
error: string,
description: string,
state?: string | null,
) {
if (!redirectUri) {
return reply.status(400).send({
error,
error_description: description,
})
}
const url = new URL(redirectUri)
url.searchParams.set('error', error)
url.searchParams.set('error_description', description)
if (state) url.searchParams.set('state', state)
return reply.redirect(url.toString())
}
function resolveSessionUser(
app: FastifyInstance,
request: FastifyRequest,
): UserRow | undefined {
const raw = cookieValue(request.headers.cookie, REFRESH_COOKIE)
if (!raw) return undefined
return getUserByRefreshToken(app.db, raw)
}
function buildUserInfoClaims(
app: FastifyInstance,
user: UserRow,
scope: string,
) {
const scopes = new Set(scope.split(/\s+/).filter(Boolean))
const permissions = normalizePermissionKeys(
getUserPermissions(app.db, user.id),
)
const groups = oidcGroupsForUser({
isAdmin: user.isAdmin,
permissions,
})
const claims: Record<string, unknown> = {
sub: user.id,
}
if (scopes.has('email') || scopes.has('profile') || scopes.has('openid')) {
claims.email = user.email
claims.email_verified = true
}
if (scopes.has('profile') || scopes.has('openid')) {
claims.name = user.name
claims.preferred_username = user.email
}
if (scopes.has('groups')) {
claims.groups = groups
claims.roles = groups
}
return claims
}
function safeEqualStr(a: string, b: string): boolean {
const ba = Buffer.from(a)
const bb = Buffer.from(b)
if (ba.length !== bb.length) return false
return timingSafeEqual(ba, bb)
}
function verifyPkce(
codeVerifier: string | undefined,
challenge: string | null,
method: string | null,
): boolean {
if (!challenge) return true
if (!codeVerifier) return false
if (method === 'plain' || !method) {
return safeEqualStr(codeVerifier, challenge)
}
if (method === 'S256') {
const hash = createHash('sha256')
.update(codeVerifier)
.digest('base64url')
return hash === challenge
}
return false
}
export async function oidcRoutes(app: FastifyInstance): Promise<void> {
await ensureOidcSigningKey(app)
app.get('/.well-known/openid-configuration', async () =>
buildOidcDiscovery(app.config),
)
app.get('/.well-known/jwks.json', async () => buildJwks(app))
app.get('/oauth/authorize', async (request, reply) => {
const q = request.query as Record<string, string | undefined>
const clientId = q.client_id
const redirectUri = q.redirect_uri
const responseType = q.response_type
const scope = q.scope ?? 'openid'
const state = q.state
const nonce = q.nonce
const codeChallenge = q.code_challenge
const codeChallengeMethod = q.code_challenge_method
if (!clientId || !redirectUri) {
return reply.status(400).send({
error: 'invalid_request',
error_description: 'client_id and redirect_uri are required',
})
}
if (responseType !== 'code') {
return redirectError(
reply,
redirectUri,
'unsupported_response_type',
'Only response_type=code is supported',
state,
)
}
const client = getOidcClientByClientId(app.db, clientId)
if (!client || !client.enabled) {
return reply.status(400).send({
error: 'invalid_client',
error_description: 'Unknown or disabled client',
})
}
const allowedRedirects = parseJsonStringArray(client.redirectUrisJson)
if (!allowedRedirects.includes(redirectUri)) {
return reply.status(400).send({
error: 'invalid_request',
error_description: 'redirect_uri is not registered',
})
}
const requestedScopes = scope.split(/\s+/).filter(Boolean)
const clientScopes = new Set(parseJsonStringArray(client.scopesJson))
if (!requestedScopes.includes('openid')) {
return redirectError(
reply,
redirectUri,
'invalid_scope',
'openid scope is required',
state,
)
}
for (const s of requestedScopes) {
if (!clientScopes.has(s)) {
return redirectError(
reply,
redirectUri,
'invalid_scope',
`Scope not allowed: ${s}`,
state,
)
}
}
const user = resolveSessionUser(app, request)
if (!user) {
const issuer = oidcIssuerFromConfig(app.config)
const authorizeUrl = new URL(`${issuer}/oauth/authorize`)
for (const [k, v] of Object.entries(q)) {
if (v != null) authorizeUrl.searchParams.set(k, v)
}
const login = new URL(`${issuer}/`)
login.searchParams.set('return_to', authorizeUrl.toString())
return reply.redirect(login.toString())
}
const apps = getUserApps(app.db, user.id)
// Soft check: prefer dns app assignment for Technitium-like clients
if (!user.isAdmin && apps.length === 0) {
return redirectError(
reply,
redirectUri,
'access_denied',
'User has no application access',
state,
)
}
const rawCode = randomBytes(32).toString('base64url')
createOidcAuthCode(app.db, {
rawCode,
clientId: client.clientId,
userId: user.id,
redirectUri,
scope: requestedScopes.join(' '),
nonce: nonce ?? null,
codeChallenge: codeChallenge ?? null,
codeChallengeMethod: codeChallengeMethod ?? null,
expiresAt: new Date(Date.now() + CODE_TTL_MS),
})
const dest = new URL(redirectUri)
dest.searchParams.set('code', rawCode)
if (state) dest.searchParams.set('state', state)
return reply.redirect(dest.toString())
})
app.post('/oauth/token', {
config: { rateLimit: { max: 60, timeWindow: '1 minute' } },
handler: async (request, reply) => {
const raw = request.body
const body: Record<string, string | undefined> =
typeof raw === 'string'
? Object.fromEntries(new URLSearchParams(raw))
: raw && typeof raw === 'object'
? (raw as Record<string, string | undefined>)
: {}
const basic = parseBasicAuth(request.headers.authorization)
const clientId = body.client_id ?? basic?.clientId
const clientSecret = body.client_secret ?? basic?.clientSecret
const grantType = body.grant_type
const code = body.code
const redirectUri = body.redirect_uri
const codeVerifier = body.code_verifier
if (grantType !== 'authorization_code') {
return reply.status(400).send({
error: 'unsupported_grant_type',
error_description: 'Only authorization_code is supported',
})
}
if (!clientId || !clientSecret || !code || !redirectUri) {
return reply.status(400).send({
error: 'invalid_request',
error_description:
'client_id, client_secret, code, and redirect_uri are required',
})
}
const client = getOidcClientByClientId(app.db, clientId)
if (!client || !client.enabled) {
return reply.status(401).send({
error: 'invalid_client',
error_description: 'Unknown or disabled client',
})
}
const secretOk = await authenticateClient(client, clientSecret)
if (!secretOk) {
return reply.status(401).send({
error: 'invalid_client',
error_description: 'Invalid client credentials',
})
}
const authCode = consumeOidcAuthCode(app.db, code)
if (!authCode) {
return reply.status(400).send({
error: 'invalid_grant',
error_description: 'Invalid or expired authorization code',
})
}
if (authCode.clientId !== client.clientId) {
return reply.status(400).send({
error: 'invalid_grant',
error_description: 'Code was not issued to this client',
})
}
if (authCode.redirectUri !== redirectUri) {
return reply.status(400).send({
error: 'invalid_grant',
error_description: 'redirect_uri mismatch',
})
}
if (
!verifyPkce(
codeVerifier,
authCode.codeChallenge,
authCode.codeChallengeMethod,
)
) {
return reply.status(400).send({
error: 'invalid_grant',
error_description: 'PKCE verification failed',
})
}
const user = getUserById(app.db, authCode.userId)
if (!user || user.disabled) {
return reply.status(400).send({
error: 'invalid_grant',
error_description: 'User unavailable',
})
}
const info = buildUserInfoClaims(app, user, authCode.scope)
const idClaims: Record<string, unknown> = {
...info,
aud: client.clientId,
}
if (authCode.nonce) idClaims.nonce = authCode.nonce
const accessClaims: Record<string, unknown> = {
sub: user.id,
aud: client.clientId,
scope: authCode.scope,
token_use: 'access',
}
if (info.groups) {
accessClaims.groups = info.groups
accessClaims.roles = info.roles
}
const [idToken, accessToken] = await Promise.all([
signOidcJwt(app, idClaims, ID_TOKEN_TTL_SEC),
signOidcJwt(app, accessClaims, ACCESS_TTL_SEC),
])
return {
access_token: accessToken,
token_type: 'Bearer',
expires_in: ACCESS_TTL_SEC,
id_token: idToken,
scope: authCode.scope,
}
},
})
app.get('/oauth/userinfo', async (request, reply) => {
const auth = request.headers.authorization
if (!auth?.startsWith('Bearer ')) {
return reply.status(401).send({
error: 'invalid_token',
error_description: 'Bearer token required',
})
}
const token = auth.slice(7)
let payload: Record<string, unknown>
try {
payload = await verifyOidcAccessToken(app, token)
} catch {
return reply.status(401).send({
error: 'invalid_token',
error_description: 'Token invalid or expired',
})
}
const sub = typeof payload.sub === 'string' ? payload.sub : null
if (!sub) {
return reply.status(401).send({
error: 'invalid_token',
error_description: 'Missing sub',
})
}
const user = getUserById(app.db, sub)
if (!user || user.disabled) {
return reply.status(401).send({
error: 'invalid_token',
error_description: 'User unavailable',
})
}
const scope =
typeof payload.scope === 'string'
? payload.scope
: 'openid profile email groups'
return buildUserInfoClaims(app, user, scope)
})
}