idp-flow.ts824 lines · main
1/**
2 * OIDC authorization code + PKCE + refresh flow (production IdP).
3 */
4
5import { createHash, randomBytes } from 'node:crypto';
6
7import { SignJWT, jwtVerify, importJWK, type JWTPayload } from 'jose';
8
9import { env } from '../../env.js';
10import { getEnginePool } from './db.js';
11import { ensureOidcSigningKey } from './idp-signing.js';
12import {
13 getOidcClientByClientId,
14 redirectUriAllowed,
15 type OidcClient,
16 verifyOidcClientSecret,
17} from './idp-clients.js';
18import { recordBrivenEngineAudit } from './audit.js';
19
20export const ACCESS_TOKEN_TTL = 3600; // 1h
21export const REFRESH_TOKEN_TTL_DAYS = 30;
22export const AUTH_CODE_TTL_SEC = 600; // 10m
23export const AUTH_REQUEST_TTL_SEC = 900; // 15m
24
25function hash(value: string): string {
26 return createHash('sha256').update(value).digest('hex');
27}
28
29function sha256Base64Url(input: string): string {
30 return createHash('sha256').update(input).digest('base64url');
31}
32
33export function oidcIssuer(): string {
34 return `${env.BRIVEN_API_ORIGIN.replace(/\/$/, '')}/v1/auth-core/oidc`;
35}
36
37export function webOrigin(): string {
38 return env.BRIVEN_WEB_ORIGIN.replace(/\/$/, '');
39}
40
41export type AuthRequest = {
42 id: string;
43 clientId: string;
44 projectId: string;
45 redirectUri: string;
46 scope: string;
47 state: string | null;
48 nonce: string | null;
49 codeChallenge: string | null;
50 codeChallengeMethod: string | null;
51 userId: string | null;
52 consentedAt: string | null;
53 expiresAt: string;
54};
55
56function mapAuthReq(r: Record<string, unknown>): AuthRequest {
57 return {
58 id: String(r.id),
59 clientId: String(r.client_id),
60 projectId: String(r.project_id),
61 redirectUri: String(r.redirect_uri),
62 scope: String(r.scope),
63 state: r.state ? String(r.state) : null,
64 nonce: r.nonce ? String(r.nonce) : null,
65 codeChallenge: r.code_challenge ? String(r.code_challenge) : null,
66 codeChallengeMethod: r.code_challenge_method
67 ? String(r.code_challenge_method)
68 : null,
69 userId: r.user_id ? String(r.user_id) : null,
70 consentedAt: r.consented_at
71 ? r.consented_at instanceof Date
72 ? r.consented_at.toISOString()
73 : String(r.consented_at)
74 : null,
75 expiresAt:
76 r.expires_at instanceof Date
77 ? r.expires_at.toISOString()
78 : String(r.expires_at),
79 };
80}
81
82export async function createAuthRequest(input: {
83 client: OidcClient;
84 redirectUri: string;
85 scope: string;
86 state?: string | null;
87 nonce?: string | null;
88 codeChallenge?: string | null;
89 codeChallengeMethod?: string | null;
90}): Promise<AuthRequest> {
91 if (input.client.revokedAt) throw new Error('client_revoked');
92 if (!redirectUriAllowed(input.client, input.redirectUri)) {
93 throw new Error('invalid_redirect_uri');
94 }
95 if (input.client.isPublic) {
96 if (!input.codeChallenge) throw new Error('pkce_required');
97 const method = (input.codeChallengeMethod ?? 'S256').toUpperCase();
98 if (method !== 'S256' && method !== 'PLAIN') {
99 throw new Error('unsupported_code_challenge_method');
100 }
101 }
102
103 const scopes = input.scope.split(/\s+/).filter(Boolean);
104 if (!scopes.includes('openid')) {
105 throw new Error('openid_scope_required');
106 }
107 for (const s of scopes) {
108 if (!input.client.scopes.includes(s) && s !== 'openid') {
109 // allow openid always; others must be registered on client
110 if (!['profile', 'email', 'offline_access'].includes(s)) {
111 throw new Error(`invalid_scope:${s}`);
112 }
113 }
114 }
115
116 const id = `oar_${randomBytes(16).toString('hex')}`;
117 const expiresAt = new Date(Date.now() + AUTH_REQUEST_TTL_SEC * 1000);
118 const pool = getEnginePool();
119 await pool.query(
120 `INSERT INTO be_oidc_auth_requests
121 (id, client_id, project_id, redirect_uri, scope, state, nonce,
122 code_challenge, code_challenge_method, response_type, expires_at)
123 VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,'code',$10)`,
124 [
125 id,
126 input.client.clientId,
127 input.client.projectId,
128 input.redirectUri,
129 scopes.join(' '),
130 input.state ?? null,
131 input.nonce ?? null,
132 input.codeChallenge ?? null,
133 input.codeChallengeMethod ?? null,
134 expiresAt.toISOString(),
135 ],
136 );
137 const req = await getAuthRequest(id);
138 if (!req) throw new Error('auth_request_create_failed');
139 return req;
140}
141
142export async function getAuthRequest(id: string): Promise<AuthRequest | null> {
143 const pool = getEnginePool();
144 const res = await pool.query(
145 `SELECT * FROM be_oidc_auth_requests WHERE id = $1 LIMIT 1`,
146 [id],
147 );
148 const row = res.rows[0] as Record<string, unknown> | undefined;
149 if (!row) return null;
150 const req = mapAuthReq(row);
151 if (new Date(req.expiresAt).getTime() < Date.now()) return null;
152 return req;
153}
154
155export async function attachUserToAuthRequest(
156 id: string,
157 userId: string,
158): Promise<AuthRequest | null> {
159 const pool = getEnginePool();
160 await pool.query(
161 `UPDATE be_oidc_auth_requests SET user_id = $2 WHERE id = $1 AND user_id IS NULL`,
162 [id, userId],
163 );
164 return getAuthRequest(id);
165}
166
167export async function hasConsent(
168 userId: string,
169 clientId: string,
170 scope: string,
171): Promise<boolean> {
172 const pool = getEnginePool();
173 const res = await pool.query(
174 `SELECT scope FROM be_oidc_consents WHERE user_id = $1 AND client_id = $2 LIMIT 1`,
175 [userId, clientId],
176 );
177 const row = res.rows[0] as { scope?: string } | undefined;
178 if (!row?.scope) return false;
179 const granted = new Set(row.scope.split(/\s+/));
180 return scope.split(/\s+/).every((s) => granted.has(s));
181}
182
183export async function grantConsent(
184 userId: string,
185 clientId: string,
186 scope: string,
187): Promise<void> {
188 const pool = getEnginePool();
189 await pool.query(
190 `INSERT INTO be_oidc_consents (user_id, client_id, scope, granted_at)
191 VALUES ($1, $2, $3, NOW())
192 ON CONFLICT (user_id, client_id) DO UPDATE SET scope = $3, granted_at = NOW()`,
193 [userId, clientId, scope],
194 );
195 void recordBrivenEngineAudit({
196 action: 'oidc.consent.granted',
197 userId,
198 metadata: { clientId, scope },
199 });
200}
201
202/** Issue authorization code after consent; returns redirect URL. */
203export async function issueAuthCodeAndRedirect(
204 requestId: string,
205 userId: string,
206): Promise<{ redirectUrl: string }> {
207 const req = await getAuthRequest(requestId);
208 if (!req) throw new Error('invalid_request');
209 if (req.userId && req.userId !== userId) throw new Error('user_mismatch');
210
211 const client = await getOidcClientByClientId(req.clientId);
212 if (!client || client.revokedAt) throw new Error('invalid_client');
213
214 await grantConsent(userId, req.clientId, req.scope);
215
216 const code = `ac_${randomBytes(24).toString('base64url')}`;
217 const codeHash = hash(code);
218 const expiresAt = new Date(Date.now() + AUTH_CODE_TTL_SEC * 1000);
219 const pool = getEnginePool();
220
221 await pool.query(
222 `INSERT INTO be_oidc_auth_codes
223 (code_hash, client_id, project_id, user_id, redirect_uri, scope, nonce,
224 code_challenge, code_challenge_method, expires_at)
225 VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`,
226 [
227 codeHash,
228 req.clientId,
229 req.projectId,
230 userId,
231 req.redirectUri,
232 req.scope,
233 req.nonce,
234 req.codeChallenge,
235 req.codeChallengeMethod,
236 expiresAt.toISOString(),
237 ],
238 );
239 await pool.query(
240 `UPDATE be_oidc_auth_requests SET user_id = $2, consented_at = NOW() WHERE id = $1`,
241 [requestId, userId],
242 );
243
244 void recordBrivenEngineAudit({
245 action: 'oidc.code.issued',
246 projectId: req.projectId,
247 userId,
248 metadata: { clientId: req.clientId },
249 });
250
251 const url = new URL(req.redirectUri);
252 url.searchParams.set('code', code);
253 if (req.state) url.searchParams.set('state', req.state);
254 return { redirectUrl: url.toString() };
255}
256
257export async function denyAuthRequest(
258 requestId: string,
259): Promise<{ redirectUrl: string }> {
260 const req = await getAuthRequest(requestId);
261 if (!req) throw new Error('invalid_request');
262 const url = new URL(req.redirectUri);
263 url.searchParams.set('error', 'access_denied');
264 url.searchParams.set('error_description', 'user denied consent');
265 if (req.state) url.searchParams.set('state', req.state);
266 void recordBrivenEngineAudit({
267 action: 'oidc.consent.denied',
268 projectId: req.projectId,
269 metadata: { clientId: req.clientId },
270 });
271 return { redirectUrl: url.toString() };
272}
273
274async function loadUserClaims(userId: string): Promise<{
275 sub: string;
276 email?: string;
277 email_verified?: boolean;
278 name?: string;
279 preferred_username?: string;
280 projectId?: string;
281 custom?: Record<string, string | number | boolean>;
282}> {
283 const pool = getEnginePool();
284 const res = await pool.query(
285 `SELECT id, email, email_verified, metadata_json, tenant_id FROM be_users WHERE id = $1 LIMIT 1`,
286 [userId],
287 );
288 const row = res.rows[0] as
289 | {
290 id: string;
291 email?: string | null;
292 email_verified?: boolean;
293 metadata_json?: string;
294 tenant_id?: string;
295 }
296 | undefined;
297 if (!row) return { sub: userId };
298 let name: string | undefined;
299 let preferred_username: string | undefined;
300 let projectId: string | undefined;
301 try {
302 const meta = JSON.parse(row.metadata_json ?? '{}') as {
303 name?: string;
304 username?: string;
305 };
306 if (meta.name) name = meta.name;
307 if (meta.username) preferred_username = meta.username;
308 } catch {
309 /* ignore */
310 }
311 // tenant_id often equals projectId for project-mapped tenants
312 if (row.tenant_id?.startsWith('p_')) projectId = row.tenant_id;
313 else if (row.tenant_id) {
314 try {
315 const t = await pool.query(
316 `SELECT project_id FROM be_tenants WHERE tenant_id = $1 LIMIT 1`,
317 [row.tenant_id],
318 );
319 const pid = (t.rows[0] as { project_id?: string } | undefined)?.project_id;
320 if (pid) projectId = pid;
321 } catch {
322 /* ignore */
323 }
324 }
325 let custom: Record<string, string | number | boolean> = {};
326 if (projectId) {
327 try {
328 const { getBrivenEngineJwtClaims } = await import('./project-config.js');
329 custom = await getBrivenEngineJwtClaims(projectId);
330 } catch {
331 custom = {};
332 }
333 }
334 return {
335 sub: row.id,
336 email: row.email ?? undefined,
337 email_verified: Boolean(row.email_verified),
338 name,
339 preferred_username,
340 projectId,
341 custom,
342 };
343}
344
345async function signAccessAndIdToken(input: {
346 client: OidcClient;
347 userId: string;
348 scope: string;
349 nonce?: string | null;
350}): Promise<{ accessToken: string; idToken: string; expiresIn: number }> {
351 const key = await ensureOidcSigningKey();
352 const claims = await loadUserClaims(input.userId);
353 const issuer = oidcIssuer();
354 const now = Math.floor(Date.now() / 1000);
355
356 const accessToken = await new SignJWT({
357 scope: input.scope,
358 client_id: input.client.clientId,
359 project_id: input.client.projectId,
360 token_use: 'access',
361 })
362 .setProtectedHeader({ alg: 'RS256', kid: key.kid, typ: 'JWT' })
363 .setIssuer(issuer)
364 .setAudience(input.client.clientId)
365 .setSubject(input.userId)
366 .setIssuedAt(now)
367 .setExpirationTime(now + ACCESS_TOKEN_TTL)
368 .setJti(`at_${randomBytes(8).toString('hex')}`)
369 .sign(key.privateKey);
370
371 const idPayload: Record<string, unknown> = {
372 token_use: 'id',
373 };
374 if (input.scope.includes('email') && claims.email) {
375 idPayload.email = claims.email;
376 idPayload.email_verified = claims.email_verified ?? false;
377 }
378 if (input.scope.includes('profile')) {
379 if (claims.name) idPayload.name = claims.name;
380 if (claims.preferred_username) {
381 idPayload.preferred_username = claims.preferred_username;
382 }
383 }
384 // Project-level custom JWT claim templates (SuperTokens-class depth).
385 if (claims.custom) {
386 for (const [k, v] of Object.entries(claims.custom)) {
387 if (k === 'sub' || k === 'iss' || k === 'aud' || k === 'exp' || k === 'iat') {
388 continue;
389 }
390 idPayload[k] = v;
391 }
392 }
393 if (input.nonce) idPayload.nonce = input.nonce;
394
395 const idToken = await new SignJWT(idPayload)
396 .setProtectedHeader({ alg: 'RS256', kid: key.kid, typ: 'JWT' })
397 .setIssuer(issuer)
398 .setAudience(input.client.clientId)
399 .setSubject(input.userId)
400 .setIssuedAt(now)
401 .setExpirationTime(now + ACCESS_TOKEN_TTL)
402 .sign(key.privateKey);
403
404 return { accessToken, idToken, expiresIn: ACCESS_TOKEN_TTL };
405}
406
407async function mintRefreshToken(input: {
408 clientId: string;
409 projectId: string;
410 userId: string;
411 scope: string;
412}): Promise<string | null> {
413 if (!input.scope.includes('offline_access')) return null;
414 const raw = `rt_${randomBytes(32).toString('base64url')}`;
415 const expiresAt = new Date(
416 Date.now() + REFRESH_TOKEN_TTL_DAYS * 24 * 60 * 60 * 1000,
417 );
418 const pool = getEnginePool();
419 await pool.query(
420 `INSERT INTO be_oidc_refresh_tokens
421 (token_hash, client_id, project_id, user_id, scope, expires_at)
422 VALUES ($1,$2,$3,$4,$5,$6)`,
423 [
424 hash(raw),
425 input.clientId,
426 input.projectId,
427 input.userId,
428 input.scope,
429 expiresAt.toISOString(),
430 ],
431 );
432 return raw;
433}
434
435function verifyPkce(
436 method: string | null,
437 challenge: string | null,
438 verifier: string | null | undefined,
439): boolean {
440 if (!challenge) return true; // confidential may omit
441 if (!verifier) return false;
442 const m = (method ?? 'S256').toUpperCase();
443 if (m === 'PLAIN') return verifier === challenge;
444 if (m === 'S256') return sha256Base64Url(verifier) === challenge;
445 return false;
446}
447
448export async function exchangeAuthorizationCode(input: {
449 code: string;
450 redirectUri: string;
451 clientId: string;
452 clientSecret?: string | null;
453 codeVerifier?: string | null;
454}): Promise<
455 | {
456 ok: true;
457 access_token: string;
458 id_token: string;
459 refresh_token?: string;
460 token_type: 'Bearer';
461 expires_in: number;
462 scope: string;
463 }
464 | { ok: false; error: string; error_description: string }
465> {
466 const client = await getOidcClientByClientId(input.clientId);
467 if (!client || client.revokedAt) {
468 return {
469 ok: false,
470 error: 'invalid_client',
471 error_description: 'unknown or revoked client',
472 };
473 }
474 if (!(await verifyOidcClientSecret(client, input.clientSecret))) {
475 return {
476 ok: false,
477 error: 'invalid_client',
478 error_description: 'client authentication failed',
479 };
480 }
481
482 const pool = getEnginePool();
483 const codeHash = hash(input.code);
484 const res = await pool.query(
485 `SELECT * FROM be_oidc_auth_codes WHERE code_hash = $1 LIMIT 1`,
486 [codeHash],
487 );
488 const row = res.rows[0] as Record<string, unknown> | undefined;
489 if (!row || row.used_at) {
490 return {
491 ok: false,
492 error: 'invalid_grant',
493 error_description: 'code invalid or already used',
494 };
495 }
496 if (String(row.client_id) !== input.clientId) {
497 return {
498 ok: false,
499 error: 'invalid_grant',
500 error_description: 'code client mismatch',
501 };
502 }
503 if (String(row.redirect_uri) !== input.redirectUri) {
504 return {
505 ok: false,
506 error: 'invalid_grant',
507 error_description: 'redirect_uri mismatch',
508 };
509 }
510 const exp = new Date(row.expires_at as string | Date);
511 if (exp.getTime() < Date.now()) {
512 return {
513 ok: false,
514 error: 'invalid_grant',
515 error_description: 'code expired',
516 };
517 }
518
519 const challenge = row.code_challenge ? String(row.code_challenge) : null;
520 const method = row.code_challenge_method
521 ? String(row.code_challenge_method)
522 : null;
523 if (client.isPublic || challenge) {
524 if (!verifyPkce(method, challenge, input.codeVerifier)) {
525 return {
526 ok: false,
527 error: 'invalid_grant',
528 error_description: 'pkce verification failed',
529 };
530 }
531 }
532
533 await pool.query(
534 `UPDATE be_oidc_auth_codes SET used_at = NOW() WHERE code_hash = $1`,
535 [codeHash],
536 );
537
538 const userId = String(row.user_id);
539 const scope = String(row.scope);
540 const nonce = row.nonce ? String(row.nonce) : null;
541
542 const tokens = await signAccessAndIdToken({
543 client,
544 userId,
545 scope,
546 nonce,
547 });
548 const refresh = await mintRefreshToken({
549 clientId: client.clientId,
550 projectId: client.projectId,
551 userId,
552 scope,
553 });
554
555 void recordBrivenEngineAudit({
556 action: 'oidc.token.issued',
557 projectId: client.projectId,
558 userId,
559 metadata: { clientId: client.clientId, grant: 'authorization_code' },
560 });
561
562 return {
563 ok: true,
564 access_token: tokens.accessToken,
565 id_token: tokens.idToken,
566 refresh_token: refresh ?? undefined,
567 token_type: 'Bearer',
568 expires_in: tokens.expiresIn,
569 scope,
570 };
571}
572
573export async function exchangeRefreshToken(input: {
574 refreshToken: string;
575 clientId: string;
576 clientSecret?: string | null;
577}): Promise<
578 | {
579 ok: true;
580 access_token: string;
581 id_token: string;
582 refresh_token?: string;
583 token_type: 'Bearer';
584 expires_in: number;
585 scope: string;
586 }
587 | { ok: false; error: string; error_description: string }
588> {
589 const client = await getOidcClientByClientId(input.clientId);
590 if (!client || client.revokedAt) {
591 return {
592 ok: false,
593 error: 'invalid_client',
594 error_description: 'unknown or revoked client',
595 };
596 }
597 if (!(await verifyOidcClientSecret(client, input.clientSecret))) {
598 return {
599 ok: false,
600 error: 'invalid_client',
601 error_description: 'client authentication failed',
602 };
603 }
604
605 const pool = getEnginePool();
606 const th = hash(input.refreshToken);
607 const res = await pool.query(
608 `SELECT * FROM be_oidc_refresh_tokens WHERE token_hash = $1 LIMIT 1`,
609 [th],
610 );
611 const row = res.rows[0] as Record<string, unknown> | undefined;
612 if (!row || row.revoked_at) {
613 return {
614 ok: false,
615 error: 'invalid_grant',
616 error_description: 'refresh token invalid',
617 };
618 }
619 if (String(row.client_id) !== input.clientId) {
620 return {
621 ok: false,
622 error: 'invalid_grant',
623 error_description: 'refresh client mismatch',
624 };
625 }
626 if (new Date(row.expires_at as string | Date).getTime() < Date.now()) {
627 return {
628 ok: false,
629 error: 'invalid_grant',
630 error_description: 'refresh token expired',
631 };
632 }
633
634 // Rotate refresh token
635 await pool.query(
636 `UPDATE be_oidc_refresh_tokens SET revoked_at = NOW() WHERE token_hash = $1`,
637 [th],
638 );
639
640 const userId = String(row.user_id);
641 const scope = String(row.scope);
642 const tokens = await signAccessAndIdToken({ client, userId, scope });
643 const refresh = await mintRefreshToken({
644 clientId: client.clientId,
645 projectId: client.projectId,
646 userId,
647 scope,
648 });
649
650 void recordBrivenEngineAudit({
651 action: 'oidc.token.issued',
652 projectId: client.projectId,
653 userId,
654 metadata: { clientId: client.clientId, grant: 'refresh_token' },
655 });
656
657 return {
658 ok: true,
659 access_token: tokens.accessToken,
660 id_token: tokens.idToken,
661 refresh_token: refresh ?? undefined,
662 token_type: 'Bearer',
663 expires_in: tokens.expiresIn,
664 scope,
665 };
666}
667
668export type AccessTokenPayload = JWTPayload & {
669 scope?: string;
670 client_id?: string;
671 project_id?: string;
672 token_use?: string;
673 sub: string;
674};
675
676export async function verifyOidcAccessToken(
677 token: string,
678): Promise<AccessTokenPayload> {
679 const key = await ensureOidcSigningKey();
680 const pub = await importJWK(key.publicJwk, 'RS256');
681 const { payload } = await jwtVerify(token, pub, { issuer: oidcIssuer() });
682 if (payload.token_use && payload.token_use !== 'access') {
683 throw new Error('not_access_token');
684 }
685 if (typeof payload.sub !== 'string') throw new Error('missing_sub');
686 return payload as AccessTokenPayload;
687}
688
689export async function buildUserInfo(accessToken: string): Promise<
690 | { ok: true; body: Record<string, unknown> }
691 | { ok: false; status: number; error: string }
692> {
693 try {
694 const payload = await verifyOidcAccessToken(accessToken);
695 const claims = await loadUserClaims(payload.sub);
696 const scope = String(payload.scope ?? '');
697 const body: Record<string, unknown> = { sub: claims.sub };
698 if (scope.includes('email') && claims.email) {
699 body.email = claims.email;
700 body.email_verified = claims.email_verified ?? false;
701 }
702 if (scope.includes('profile') && claims.name) {
703 body.name = claims.name;
704 }
705 return { ok: true, body };
706 } catch {
707 return { ok: false, status: 401, error: 'invalid_token' };
708 }
709}
710
711export async function revokeToken(input: {
712 token: string;
713 clientId: string;
714 clientSecret?: string | null;
715}): Promise<{ ok: true }> {
716 const client = await getOidcClientByClientId(input.clientId);
717 if (!client) return { ok: true }; // RFC 7009: always 200
718 if (!(await verifyOidcClientSecret(client, input.clientSecret))) {
719 return { ok: true };
720 }
721 const pool = getEnginePool();
722 await pool.query(
723 `UPDATE be_oidc_refresh_tokens SET revoked_at = NOW()
724 WHERE token_hash = $1 AND client_id = $2`,
725 [hash(input.token), input.clientId],
726 );
727 void recordBrivenEngineAudit({
728 action: 'oidc.token.revoked',
729 projectId: client.projectId,
730 metadata: { clientId: input.clientId },
731 });
732 return { ok: true };
733}
734
735export async function introspectToken(input: {
736 token: string;
737 clientId: string;
738 clientSecret?: string | null;
739}): Promise<Record<string, unknown>> {
740 const client = await getOidcClientByClientId(input.clientId);
741 if (!client || !(await verifyOidcClientSecret(client, input.clientSecret))) {
742 return { active: false };
743 }
744
745 // Try as refresh
746 const pool = getEnginePool();
747 const th = hash(input.token);
748 const rt = await pool.query(
749 `SELECT * FROM be_oidc_refresh_tokens WHERE token_hash = $1 LIMIT 1`,
750 [th],
751 );
752 const rrow = rt.rows[0] as Record<string, unknown> | undefined;
753 if (rrow && !rrow.revoked_at) {
754 const exp = new Date(rrow.expires_at as string | Date);
755 if (exp.getTime() > Date.now() && String(rrow.client_id) === input.clientId) {
756 return {
757 active: true,
758 token_type: 'refresh_token',
759 client_id: input.clientId,
760 sub: String(rrow.user_id),
761 scope: String(rrow.scope),
762 exp: Math.floor(exp.getTime() / 1000),
763 };
764 }
765 }
766
767 try {
768 const payload = await verifyOidcAccessToken(input.token);
769 if (payload.client_id && payload.client_id !== input.clientId) {
770 return { active: false };
771 }
772 return {
773 active: true,
774 token_type: 'access_token',
775 client_id: payload.client_id ?? input.clientId,
776 sub: payload.sub,
777 scope: payload.scope,
778 exp: payload.exp,
779 iat: payload.iat,
780 iss: payload.iss,
781 };
782 } catch {
783 return { active: false };
784 }
785}
786
787export function discoveryDocument(): Record<string, unknown> {
788 const iss = oidcIssuer();
789 return {
790 issuer: iss,
791 authorization_endpoint: `${iss}/authorize`,
792 token_endpoint: `${iss}/token`,
793 userinfo_endpoint: `${iss}/userinfo`,
794 jwks_uri: `${iss}/jwks.json`,
795 revocation_endpoint: `${iss}/revoke`,
796 introspection_endpoint: `${iss}/introspect`,
797 end_session_endpoint: `${iss}/end_session`,
798 response_types_supported: ['code'],
799 grant_types_supported: ['authorization_code', 'refresh_token'],
800 subject_types_supported: ['public'],
801 id_token_signing_alg_values_supported: ['RS256'],
802 token_endpoint_auth_methods_supported: [
803 'client_secret_post',
804 'client_secret_basic',
805 'none',
806 ],
807 code_challenge_methods_supported: ['S256', 'plain'],
808 scopes_supported: ['openid', 'profile', 'email', 'offline_access'],
809 claims_supported: [
810 'sub',
811 'iss',
812 'aud',
813 'exp',
814 'iat',
815 'email',
816 'email_verified',
817 'name',
818 'nonce',
819 ],
820 request_parameter_supported: false,
821 engine: 'briven-engine',
822 };
823}
824