auth-sso.ts729 lines · main
1/**
2 * Briven Auth Enterprise SSO — SAML 2.0 + OIDC enterprise connections.
3 *
4 * Each project can configure multiple SSO connections (SAML or OIDC).
5 * Users with matching email domains are routed to the appropriate IdP.
6 * JIT provisioning creates users on first SSO sign-in when enabled.
7 *
8 * SAML flow (SP-initiated):
9 * 1. User visits /v1/auth-tenant/sso/saml/:connectionId
10 * 2. We generate an AuthnRequest and redirect to the IdP SSO URL
11 * 3. IdP authenticates the user and POSTs SAMLResponse to ACS
12 * 4. We validate the response, extract email/NameID
13 * 5. Find or JIT-create user, create session, set cookie
14 *
15 * SAML flow (IdP-initiated):
16 * 1. IdP POSTs SAMLResponse directly to ACS
17 * 2. Same validation + session creation as SP-initiated
18 *
19 * OIDC enterprise:
20 * - Uses the existing genericOAuth plugin infrastructure
21 * - Domain restrictions and JIT provisioning are enforced at the
22 * connection level via this service
23 */
24
25import { randomBytes } from 'node:crypto';
26import { SAML, ValidateInResponseTo } from '@node-saml/node-saml';
27import { ValidationError } from '@briven/shared';
28
29import { runInProjectDatabase } from '../db/data-plane.js';
30import { env } from '../env.js';
31
32// ─── Types ────────────────────────────────────────────────────────────────
33
34export type SsoProviderType = 'saml' | 'oidc';
35
36export interface SsoConnectionOutput {
37 id: string;
38 name: string;
39 providerType: SsoProviderType;
40 domains: string[];
41 jitEnabled: boolean;
42 deactivatedAt: string | null;
43 createdAt: string;
44}
45
46export interface SamlConfig {
47 /** IdP metadata XML (optional — can supply url instead). */
48 idpMetadataXml?: string;
49 /** IdP metadata URL (optional — can supply xml instead). */
50 idpMetadataUrl?: string;
51 /** IdP SSO URL (extracted from metadata or supplied directly). */
52 idpSsoUrl?: string;
53 /** IdP signing certificate PEM (extracted from metadata or supplied directly). */
54 idpCert?: string;
55 /** Optional IdP logout URL. */
56 idpLogoutUrl?: string;
57 /** SP entity ID. Defaults to hosted ACS URL. */
58 spEntityId?: string;
59}
60
61export interface OidcEnterpriseConfig {
62 issuer?: string;
63 authorizationUrl?: string;
64 tokenUrl?: string;
65 userinfoUrl?: string;
66 clientId?: string;
67 /** Stored in tenant-secret-store, not here. */
68 scopes?: string;
69 pkce?: boolean;
70}
71
72// ─── Connection CRUD ─────────────────────────────────────────────────────
73
74export async function listSsoConnections(
75 projectId: string,
76): Promise<SsoConnectionOutput[]> {
77 return runInProjectDatabase<SsoConnectionOutput[]>(projectId, async (tx) => {
78 const rows = (await tx.unsafe(
79 `SELECT id, name, provider_type, domains, jit_enabled, deactivated_at, created_at
80 FROM "_briven_auth_sso_connections"
81 WHERE deactivated_at IS NULL
82 ORDER BY created_at`,
83 )) as {
84 id: string; name: string; provider_type: string; domains: unknown;
85 jit_enabled: boolean; deactivated_at: Date | null; created_at: Date;
86 }[];
87 return rows.map((r) => ({
88 id: r.id,
89 name: r.name,
90 providerType: r.provider_type as SsoProviderType,
91 domains: Array.isArray(r.domains) ? (r.domains as string[]) : [],
92 jitEnabled: r.jit_enabled,
93 deactivatedAt: r.deactivated_at?.toISOString() ?? null,
94 createdAt: r.created_at.toISOString(),
95 }));
96 });
97}
98
99export async function getSsoConnection(
100 projectId: string,
101 connectionId: string,
102): Promise<SsoConnectionOutput & { config: Record<string, unknown> } | null> {
103 return runInProjectDatabase<(SsoConnectionOutput & { config: Record<string, unknown> }) | null>(
104 projectId,
105 async (tx) => {
106 const rows = (await tx.unsafe(
107 `SELECT id, name, provider_type, config, domains, jit_enabled, deactivated_at, created_at
108 FROM "_briven_auth_sso_connections"
109 WHERE id = $1
110 LIMIT 1`,
111 [connectionId],
112 )) as {
113 id: string; name: string; provider_type: string; config: unknown;
114 domains: unknown; jit_enabled: boolean; deactivated_at: Date | null;
115 created_at: Date;
116 }[];
117 const row = rows[0];
118 if (!row) return null;
119 return {
120 id: row.id,
121 name: row.name,
122 providerType: row.provider_type as SsoProviderType,
123 config: (row.config as Record<string, unknown>) ?? {},
124 domains: Array.isArray(row.domains) ? (row.domains as string[]) : [],
125 jitEnabled: row.jit_enabled,
126 deactivatedAt: row.deactivated_at?.toISOString() ?? null,
127 createdAt: row.created_at.toISOString(),
128 };
129 },
130 );
131}
132
133export async function createSsoConnection(
134 projectId: string,
135 input: {
136 name: string;
137 providerType: SsoProviderType;
138 config: Record<string, unknown>;
139 domains?: string[];
140 jitEnabled?: boolean;
141 },
142): Promise<SsoConnectionOutput> {
143 if (!input.name || input.name.length > 128) {
144 throw new ValidationError('name is required and must be <= 128 chars');
145 }
146 if (!['saml', 'oidc'].includes(input.providerType)) {
147 throw new ValidationError('providerType must be saml or oidc');
148 }
149
150 const created = await runInProjectDatabase<SsoConnectionOutput>(projectId, async (tx) => {
151 const rows = (await tx.unsafe(
152 `INSERT INTO "_briven_auth_sso_connections"
153 (name, provider_type, config, domains, jit_enabled)
154 VALUES ($1, $2, $3, $4, $5)
155 RETURNING id, name, provider_type, domains, jit_enabled, deactivated_at, created_at`,
156 [
157 input.name,
158 input.providerType,
159 JSON.stringify(input.config),
160 JSON.stringify(input.domains ?? []),
161 input.jitEnabled ?? true,
162 ],
163 )) as {
164 id: string; name: string; provider_type: string; domains: unknown;
165 jit_enabled: boolean; deactivated_at: Date | null; created_at: Date;
166 }[];
167 const row = rows[0];
168 if (!row) throw new Error('sso connection insert failed');
169 return {
170 id: row.id,
171 name: row.name,
172 providerType: row.provider_type as SsoProviderType,
173 domains: Array.isArray(row.domains) ? (row.domains as string[]) : [],
174 jitEnabled: row.jit_enabled,
175 deactivatedAt: row.deactivated_at?.toISOString() ?? null,
176 createdAt: row.created_at.toISOString(),
177 };
178 });
179 // Phase 5.7 — meter active connection count after commit.
180 void import('./auth-sso-pricing.js').then((m) => m.recordSsoConnectionGauge(projectId));
181 return created;
182}
183
184export async function updateSsoConnection(
185 projectId: string,
186 connectionId: string,
187 patch: {
188 name?: string;
189 config?: Record<string, unknown>;
190 domains?: string[];
191 jitEnabled?: boolean;
192 },
193): Promise<SsoConnectionOutput> {
194 return runInProjectDatabase<SsoConnectionOutput>(projectId, async (tx) => {
195 const sets: string[] = [];
196 const params: (string | boolean | null)[] = [];
197 if (patch.name !== undefined) {
198 sets.push(`name = $${params.length + 1}`);
199 params.push(patch.name);
200 }
201 if (patch.config !== undefined) {
202 sets.push(`config = $${params.length + 1}`);
203 params.push(JSON.stringify(patch.config));
204 }
205 if (patch.domains !== undefined) {
206 sets.push(`domains = $${params.length + 1}`);
207 params.push(JSON.stringify(patch.domains));
208 }
209 if (patch.jitEnabled !== undefined) {
210 sets.push(`jit_enabled = $${params.length + 1}`);
211 params.push(patch.jitEnabled);
212 }
213 sets.push(`updated_at = now()`);
214 params.push(connectionId);
215
216 const rows = (await tx.unsafe(
217 `UPDATE "_briven_auth_sso_connections"
218 SET ${sets.join(', ')}
219 WHERE id = $${params.length}
220 RETURNING id, name, provider_type, domains, jit_enabled, deactivated_at, created_at`,
221 params,
222 )) as {
223 id: string; name: string; provider_type: string; domains: unknown;
224 jit_enabled: boolean; deactivated_at: Date | null; created_at: Date;
225 }[];
226 const row = rows[0];
227 if (!row) throw new ValidationError('sso connection not found');
228 return {
229 id: row.id,
230 name: row.name,
231 providerType: row.provider_type as SsoProviderType,
232 domains: Array.isArray(row.domains) ? (row.domains as string[]) : [],
233 jitEnabled: row.jit_enabled,
234 deactivatedAt: row.deactivated_at?.toISOString() ?? null,
235 createdAt: row.created_at.toISOString(),
236 };
237 });
238}
239
240export async function deleteSsoConnection(
241 projectId: string,
242 connectionId: string,
243): Promise<void> {
244 await runInProjectDatabase(projectId, async (tx) => {
245 await tx.unsafe(
246 `UPDATE "_briven_auth_sso_connections"
247 SET deactivated_at = now(), updated_at = now()
248 WHERE id = $1`,
249 [connectionId],
250 );
251 });
252}
253
254// ─── SAML helpers ─────────────────────────────────────────────────────────
255
256function acsUrl(connectionId: string): string {
257 return `${env.BRIVEN_API_ORIGIN}/v1/auth-tenant/sso/saml/${connectionId}/acs`;
258}
259
260function spEntityId(projectId: string, connectionId: string): string {
261 return `${env.BRIVEN_API_ORIGIN}/sso/${projectId}/${connectionId}`;
262}
263
264function buildSamlInstance(
265 projectId: string,
266 connectionId: string,
267 config: SamlConfig,
268): SAML {
269 const cert = config.idpCert ?? '';
270 if (!cert) {
271 throw new ValidationError('SAML connection is missing IdP certificate');
272 }
273 const entryPoint = config.idpSsoUrl ?? '';
274 if (!entryPoint) {
275 throw new ValidationError('SAML connection is missing IdP SSO URL');
276 }
277
278 return new SAML({
279 issuer: config.spEntityId ?? spEntityId(projectId, connectionId),
280 callbackUrl: acsUrl(connectionId),
281 entryPoint,
282 idpCert: cert,
283 wantAssertionsSigned: true,
284 wantAuthnResponseSigned: true,
285 // Disable InResponseTo validation for IdP-initiated flows.
286 // SP-initiated flows set it via RelayState instead.
287 validateInResponseTo: ValidateInResponseTo.never,
288 // Clock drift tolerance — some IdPs have slight clock skew.
289 acceptedClockSkewMs: 300000, // 5 minutes
290 });
291}
292
293export async function generateSamlAuthnRequest(
294 projectId: string,
295 connectionId: string,
296 relayState?: string,
297): Promise<{ redirectUrl: string }> {
298 const conn = await getSsoConnection(projectId, connectionId);
299 if (!conn) throw new ValidationError('sso connection not found');
300 if (conn.providerType !== 'saml') {
301 throw new ValidationError('connection is not a SAML provider');
302 }
303
304 const samlConfig = conn.config as SamlConfig;
305 const saml = buildSamlInstance(projectId, connectionId, samlConfig);
306
307 const url = await saml.getAuthorizeUrlAsync('', '', {});
308 if (relayState) {
309 const u = new URL(url);
310 u.searchParams.set('RelayState', relayState);
311 return { redirectUrl: u.toString() };
312 }
313 return { redirectUrl: url };
314}
315
316export interface SamlAssertionResult {
317 email: string;
318 name?: string;
319 nameId?: string;
320 nameIdFormat?: string;
321}
322
323export async function validateSamlResponse(
324 projectId: string,
325 connectionId: string,
326 samlResponse: string,
327): Promise<SamlAssertionResult> {
328 const conn = await getSsoConnection(projectId, connectionId);
329 if (!conn) throw new ValidationError('sso connection not found');
330 if (conn.providerType !== 'saml') {
331 throw new ValidationError('connection is not a SAML provider');
332 }
333
334 const samlConfig = conn.config as SamlConfig;
335 const saml = buildSamlInstance(projectId, connectionId, samlConfig);
336
337 const result = await saml.validatePostResponseAsync({ SAMLResponse: samlResponse });
338
339 // @node-saml/node-saml returns profile in the result.
340 // eslint-disable-next-line @typescript-eslint/no-explicit-any
341 const profile = (result as any).profile ?? {};
342 const email = profile.email ?? profile.mail ?? profile.nameID;
343 if (!email || typeof email !== 'string') {
344 throw new ValidationError('SAML assertion did not contain an email address');
345 }
346
347 return {
348 email: email.toLowerCase(),
349 name: profile.displayName ?? profile.cn ?? profile.givenName,
350 nameId: profile.nameID,
351 nameIdFormat: profile.nameIDFormat,
352 };
353}
354
355export async function generateSamlMetadata(
356 projectId: string,
357 connectionId: string,
358): Promise<string> {
359 const conn = await getSsoConnection(projectId, connectionId);
360 if (!conn) throw new ValidationError('sso connection not found');
361 if (conn.providerType !== 'saml') {
362 throw new ValidationError('connection is not a SAML provider');
363 }
364
365 const samlConfig = conn.config as SamlConfig;
366 const saml = buildSamlInstance(projectId, connectionId, samlConfig);
367
368 // generateServiceProviderMetadata requires decryptionCert and privateKey
369 // for encryption support. We don't support encrypted assertions yet,
370 // so we pass empty strings.
371 return saml.generateServiceProviderMetadata('', '');
372}
373
374// ─── JIT provisioning ─────────────────────────────────────────────────────
375
376export async function findUserByEmail(
377 projectId: string,
378 email: string,
379): Promise<{ id: string; email: string; name: string | null } | null> {
380 return runInProjectDatabase<{ id: string; email: string; name: string | null } | null>(
381 projectId,
382 async (tx) => {
383 const rows = (await tx.unsafe(
384 `SELECT id, email, name FROM "_briven_auth_users" WHERE lower(email) = lower($1) LIMIT 1`,
385 [email],
386 )) as { id: string; email: string; name: string | null }[];
387 return rows[0] ?? null;
388 },
389 );
390}
391
392export async function createUserFromSso(
393 projectId: string,
394 email: string,
395 name?: string,
396): Promise<{ id: string; email: string; name: string | null }> {
397 return runInProjectDatabase<{ id: string; email: string; name: string | null }>(
398 projectId,
399 async (tx) => {
400 const id = randomBytes(16).toString('hex');
401 const rows = (await tx.unsafe(
402 `INSERT INTO "_briven_auth_users" (id, email, email_verified, name, created_at, updated_at)
403 VALUES ($1, $2, true, $3, now(), now())
404 RETURNING id, email, name`,
405 [id, email.toLowerCase(), name ?? null],
406 )) as { id: string; email: string; name: string | null }[];
407 const row = rows[0];
408 if (!row) throw new Error('user creation failed');
409 return row;
410 },
411 );
412}
413
414export async function findOrCreateSsoUser(
415 projectId: string,
416 email: string,
417 name?: string,
418 jitEnabled = true,
419): Promise<{ id: string; email: string; name: string | null; isNew: boolean }> {
420 const existing = await findUserByEmail(projectId, email);
421 if (existing) return { ...existing, isNew: false };
422 if (!jitEnabled) {
423 throw new ValidationError('user does not exist and JIT provisioning is disabled');
424 }
425 const created = await createUserFromSso(projectId, email, name);
426 return { ...created, isNew: true };
427}
428
429// ─── Session creation ─────────────────────────────────────────────────────
430
431export async function createSsoSession(
432 projectId: string,
433 userId: string,
434 connectionId: string,
435 opts: { userAgent?: string | null; maxLifetimeDays?: number } = {},
436): Promise<{ sessionToken: string; expiresAt: Date }> {
437 const maxLifetimeDays = opts.maxLifetimeDays ?? 30;
438 const sessionToken = randomBytes(32).toString('hex');
439 const expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * maxLifetimeDays);
440
441 await runInProjectDatabase(projectId, async (tx) => {
442 const sessionId = randomBytes(16).toString('hex');
443 await tx.unsafe(
444 `INSERT INTO "_briven_auth_sessions" (id, user_id, token, expires_at, user_agent, created_at, updated_at)
445 VALUES ($1, $2, $3, $4, $5, now(), now())`,
446 [sessionId, userId, sessionToken, expiresAt.toISOString(), opts.userAgent ?? null] as never,
447 );
448 await tx.unsafe(
449 `INSERT INTO "_briven_auth_sso_sessions" (id, session_id, connection_id, created_at, updated_at)
450 VALUES ($1, $2, $3, now(), now())
451 ON CONFLICT (session_id) DO NOTHING`,
452 [randomBytes(16).toString('hex'), sessionId, connectionId] as never,
453 );
454 });
455
456 // Phase 5.7 — billable SSO sign-in event (must not break login).
457 void import('./auth-sso-pricing.js').then((m) =>
458 m.recordSsoSignInUsage(projectId, connectionId),
459 );
460
461 return { sessionToken, expiresAt };
462}
463
464// ─── Deprovisioning ───────────────────────────────────────────────────────
465
466export async function revokeAllSessionsForConnection(
467 projectId: string,
468 connectionId: string,
469): Promise<number> {
470 return runInProjectDatabase<number>(projectId, async (tx) => {
471 const rows = (await tx.unsafe(
472 `SELECT session_id FROM "_briven_auth_sso_sessions" WHERE connection_id = $1`,
473 [connectionId],
474 )) as { session_id: string }[];
475
476 let count = 0;
477 for (const row of rows) {
478 await tx.unsafe(
479 `DELETE FROM "_briven_auth_sessions" WHERE id = $1`,
480 [row.session_id] as never,
481 );
482 count++;
483 }
484 return count;
485 });
486}
487
488// ─── Domain lookup ────────────────────────────────────────────────────────
489
490export async function findConnectionByDomain(
491 projectId: string,
492 domain: string,
493): Promise<SsoConnectionOutput | null> {
494 const connections = await listSsoConnections(projectId);
495 const lowerDomain = domain.toLowerCase();
496 return (
497 connections.find((c) =>
498 c.domains.some((d) => d.toLowerCase() === lowerDomain),
499 ) ?? null
500 );
501}
502
503export async function findConnectionByEmail(
504 projectId: string,
505 email: string,
506): Promise<SsoConnectionOutput | null> {
507 const domain = email.split('@')[1];
508 if (!domain) return null;
509 return findConnectionByDomain(projectId, domain);
510}
511
512// ─── OIDC Enterprise helpers ──────────────────────────────────────────────
513
514function oidcCallbackUrl(connectionId: string): string {
515 return `${env.BRIVEN_API_ORIGIN}/v1/auth-tenant/sso/oidc/${connectionId}/callback`;
516}
517
518function generateOidcState(): string {
519 return randomBytes(32).toString('base64url');
520}
521
522function generateOidcNonce(): string {
523 return randomBytes(16).toString('base64url');
524}
525
526export async function generateOidcAuthUrl(
527 projectId: string,
528 connectionId: string,
529 opts: { redirectTo?: string } = {},
530): Promise<{ redirectUrl: string }> {
531 const conn = await getSsoConnection(projectId, connectionId);
532 if (!conn) throw new ValidationError('sso connection not found');
533 if (conn.providerType !== 'oidc') {
534 throw new ValidationError('connection is not an OIDC provider');
535 }
536
537 const oidcConfig = conn.config as OidcEnterpriseConfig;
538 const authorizationUrl =
539 oidcConfig.authorizationUrl ??
540 (oidcConfig.issuer ? `${oidcConfig.issuer}/oauth/authorize` : '');
541 if (!authorizationUrl) {
542 throw new ValidationError('OIDC connection is missing authorization URL');
543 }
544 if (!oidcConfig.clientId) {
545 throw new ValidationError('OIDC connection is missing client ID');
546 }
547
548 const state = generateOidcState();
549 const nonce = generateOidcNonce();
550 const expiresAt = new Date(Date.now() + 10 * 60 * 1000);
551 const usePkce = oidcConfig.pkce !== false;
552 const codeVerifier = usePkce ? randomBytes(32).toString('base64url') : null;
553 const redirectTo = opts.redirectTo ?? null;
554
555 // Store state+nonce (+ PKCE verifier + post-login redirect) for callback.
556 await runInProjectDatabase(projectId, async (tx) => {
557 // Self-heal PKCE table for tenants provisioned before S7.
558 await tx.unsafe(
559 `CREATE TABLE IF NOT EXISTS "_briven_auth_oidc_pkce" (
560 state text PRIMARY KEY,
561 code_verifier text NOT NULL,
562 redirect_to text,
563 expires_at timestamptz NOT NULL,
564 created_at timestamptz NOT NULL DEFAULT now()
565 )`.replace(/\s+/g, ' '),
566 );
567 await tx.unsafe(
568 `INSERT INTO "_briven_auth_oidc_states" (id, state, nonce, connection_id, expires_at)
569 VALUES ($1, $2, $3, $4, $5)`,
570 [randomBytes(16).toString('hex'), state, nonce, connectionId, expiresAt] as never,
571 );
572 if (codeVerifier || redirectTo) {
573 await tx.unsafe(
574 `INSERT INTO "_briven_auth_oidc_pkce" (state, code_verifier, redirect_to, expires_at)
575 VALUES ($1, $2, $3, $4)`,
576 [state, codeVerifier ?? '', redirectTo, expiresAt] as never,
577 );
578 }
579 });
580
581 const url = new URL(authorizationUrl);
582 url.searchParams.set('client_id', oidcConfig.clientId);
583 url.searchParams.set('response_type', 'code');
584 url.searchParams.set('scope', oidcConfig.scopes ?? 'openid profile email');
585 url.searchParams.set('redirect_uri', oidcCallbackUrl(connectionId));
586 url.searchParams.set('state', state);
587 url.searchParams.set('nonce', nonce);
588
589 if (usePkce && codeVerifier) {
590 // S256: BASE64URL(SHA256(code_verifier))
591 const { createHash } = await import('node:crypto');
592 const challenge = createHash('sha256').update(codeVerifier).digest('base64url');
593 url.searchParams.set('code_challenge', challenge);
594 url.searchParams.set('code_challenge_method', 'S256');
595 }
596
597 return { redirectUrl: url.toString() };
598}
599
600export interface OidcTokenResponse {
601 email: string;
602 name?: string;
603 /** Validated post-login redirect path/URL from start flow (may be null). */
604 redirectTo?: string | null;
605}
606
607export async function exchangeOidcCode(
608 projectId: string,
609 connectionId: string,
610 code: string,
611 state: string,
612): Promise<OidcTokenResponse> {
613 const conn = await getSsoConnection(projectId, connectionId);
614 if (!conn) throw new ValidationError('sso connection not found');
615 if (conn.providerType !== 'oidc') {
616 throw new ValidationError('connection is not an OIDC provider');
617 }
618
619 // Validate state + load PKCE verifier / redirect.
620 const stateBundle = await runInProjectDatabase(projectId, async (tx) => {
621 const rows = (await tx.unsafe(
622 `SELECT nonce, connection_id, expires_at FROM "_briven_auth_oidc_states" WHERE state = $1 LIMIT 1`,
623 [state] as never,
624 )) as Array<{ nonce: string; connection_id: string; expires_at: Date }>;
625 let pkce: { code_verifier: string; redirect_to: string | null } | null = null;
626 if (rows[0]) {
627 await tx.unsafe(`DELETE FROM "_briven_auth_oidc_states" WHERE state = $1`, [state] as never);
628 try {
629 const pkceRows = (await tx.unsafe(
630 `DELETE FROM "_briven_auth_oidc_pkce" WHERE state = $1
631 RETURNING code_verifier, redirect_to`,
632 [state] as never,
633 )) as Array<{ code_verifier: string; redirect_to: string | null }>;
634 pkce = pkceRows[0] ?? null;
635 } catch {
636 // Table may not exist on ancient tenants; PKCE optional then.
637 pkce = null;
638 }
639 }
640 return { stateRow: rows[0] ?? null, pkce };
641 });
642
643 if (!stateBundle.stateRow) throw new ValidationError('invalid or expired OIDC state');
644 if (stateBundle.stateRow.connection_id !== connectionId) {
645 throw new ValidationError('OIDC state does not match connection');
646 }
647 if (stateBundle.stateRow.expires_at < new Date()) {
648 throw new ValidationError('OIDC state has expired');
649 }
650
651 const oidcConfig = conn.config as OidcEnterpriseConfig;
652 const tokenUrl =
653 oidcConfig.tokenUrl ??
654 (oidcConfig.issuer ? `${oidcConfig.issuer}/oauth/token` : '');
655 if (!tokenUrl) {
656 throw new ValidationError('OIDC connection is missing token URL');
657 }
658
659 // Get client secret from tenant secret store.
660 const { getTenantSecret } = await import('./tenant-secrets.js');
661 const clientSecret = await getTenantSecret(projectId, 'auth', `oidc_${connectionId}_client_secret`);
662
663 const codeVerifier = stateBundle.pkce?.code_verifier;
664 // Exchange code for token (include code_verifier when PKCE was used).
665 const tokenRes = await fetch(tokenUrl, {
666 method: 'POST',
667 headers: { 'content-type': 'application/x-www-form-urlencoded' },
668 body: new URLSearchParams({
669 grant_type: 'authorization_code',
670 code,
671 redirect_uri: oidcCallbackUrl(connectionId),
672 client_id: oidcConfig.clientId ?? '',
673 ...(clientSecret ? { client_secret: clientSecret } : {}),
674 ...(codeVerifier ? { code_verifier: codeVerifier } : {}),
675 }),
676 });
677
678 if (!tokenRes.ok) {
679 const body = await tokenRes.text().catch(() => '');
680 throw new ValidationError(`OIDC token exchange failed: ${tokenRes.status} ${body.slice(0, 200)}`);
681 }
682
683 const tokenData = (await tokenRes.json()) as { access_token?: string; id_token?: string };
684 const accessToken = tokenData.access_token;
685 if (!accessToken) {
686 throw new ValidationError('OIDC token response did not contain access_token');
687 }
688
689 // Fetch userinfo.
690 const userinfoUrl =
691 oidcConfig.userinfoUrl ??
692 (oidcConfig.issuer ? `${oidcConfig.issuer}/oauth/userinfo` : '');
693 if (!userinfoUrl) {
694 throw new ValidationError('OIDC connection is missing userinfo URL');
695 }
696
697 const userinfoRes = await fetch(userinfoUrl, {
698 headers: { authorization: `Bearer ${accessToken}` },
699 });
700
701 if (!userinfoRes.ok) {
702 const body = await userinfoRes.text().catch(() => '');
703 throw new ValidationError(`OIDC userinfo fetch failed: ${userinfoRes.status} ${body.slice(0, 200)}`);
704 }
705
706 const userinfo = (await userinfoRes.json()) as {
707 email?: string;
708 name?: string;
709 preferred_username?: string;
710 given_name?: string;
711 family_name?: string;
712 };
713
714 const email = userinfo.email;
715 if (!email || typeof email !== 'string') {
716 throw new ValidationError('OIDC userinfo did not contain an email address');
717 }
718
719 return {
720 email: email.toLowerCase(),
721 name:
722 userinfo.name ??
723 userinfo.preferred_username ??
724 (userinfo.given_name && userinfo.family_name
725 ? `${userinfo.given_name} ${userinfo.family_name}`
726 : undefined),
727 redirectTo: stateBundle.pkce?.redirect_to ?? null,
728 };
729}