sso.ts699 lines · main
1/**
2 * briven-engine enterprise SSO (SAML 2.0 + OIDC) on Doltgres.
3 *
4 * Connections live in be_sso_connections (not per-project Better Auth tables).
5 * Successful login creates be_users + be_sessions like other FDI methods.
6 */
7
8import { createHash, randomBytes } from 'node:crypto';
9
10import { SAML, ValidateInResponseTo } from '@node-saml/node-saml';
11import { newId } from '@briven/shared';
12
13import { env } from '../../env.js';
14import { log } from '../../lib/logger.js';
15import { getEnginePool } from './db.js';
16import { isAuthCoreInitialized } from './engine.js';
17import { createEngineSession } from './native-session.js';
18import { projectIdToTenantId } from './project-map.js';
19
20export type SsoProviderType = 'saml' | 'oidc';
21
22export type SamlConfig = {
23 idpSsoUrl?: string;
24 idpCert?: string;
25 idpLogoutUrl?: string;
26 idpMetadataXml?: string;
27 idpMetadataUrl?: string;
28 spEntityId?: string;
29};
30
31export type OidcConfig = {
32 issuer?: string;
33 authorizationUrl?: string;
34 tokenUrl?: string;
35 userinfoUrl?: string;
36 clientId?: string;
37 /** Optional if stored encrypted separately later */
38 clientSecret?: string;
39 scopes?: string;
40};
41
42export type SsoConnection = {
43 id: string;
44 projectId: string;
45 tenantId: string;
46 name: string;
47 providerType: SsoProviderType;
48 domains: string[];
49 config: Record<string, unknown>;
50 jitEnabled: boolean;
51 deactivatedAt: string | null;
52 createdAt: string;
53 /** True when required IdP fields are present for login */
54 ready: boolean;
55};
56
57function parseJsonArray(raw: string): string[] {
58 try {
59 const v = JSON.parse(raw) as unknown;
60 return Array.isArray(v) ? v.map(String) : [];
61 } catch {
62 return [];
63 }
64}
65
66function parseJsonObject(raw: string): Record<string, unknown> {
67 try {
68 const v = JSON.parse(raw) as unknown;
69 return v && typeof v === 'object' && !Array.isArray(v)
70 ? (v as Record<string, unknown>)
71 : {};
72 } catch {
73 return {};
74 }
75}
76
77function isSamlReady(config: SamlConfig): boolean {
78 return Boolean(config.idpSsoUrl?.trim() && config.idpCert?.trim());
79}
80
81function isOidcReady(config: OidcConfig): boolean {
82 return Boolean(
83 config.clientId?.trim() &&
84 config.clientSecret?.trim() &&
85 (config.authorizationUrl?.trim() || config.issuer?.trim()) &&
86 (config.tokenUrl?.trim() || config.issuer?.trim()),
87 );
88}
89
90function connectionReady(
91 providerType: SsoProviderType,
92 config: Record<string, unknown>,
93): boolean {
94 if (providerType === 'saml') return isSamlReady(config as SamlConfig);
95 return isOidcReady(config as OidcConfig);
96}
97
98function mapRow(r: {
99 id: string;
100 project_id: string;
101 tenant_id: string;
102 name: string;
103 provider_type: string;
104 domains_json: string;
105 config_json: string;
106 jit_enabled: boolean;
107 deactivated_at: Date | string | null;
108 created_at: Date | string;
109}): SsoConnection {
110 const config = parseJsonObject(r.config_json);
111 const providerType = r.provider_type as SsoProviderType;
112 return {
113 id: r.id,
114 projectId: r.project_id,
115 tenantId: r.tenant_id,
116 name: r.name,
117 providerType,
118 domains: parseJsonArray(r.domains_json),
119 config,
120 jitEnabled: Boolean(r.jit_enabled),
121 deactivatedAt: r.deactivated_at
122 ? new Date(r.deactivated_at).toISOString()
123 : null,
124 createdAt: new Date(r.created_at).toISOString(),
125 ready: connectionReady(providerType, config),
126 };
127}
128
129async function ensureTenant(projectId: string): Promise<string> {
130 const tenantId = projectIdToTenantId(projectId);
131 const pool = getEnginePool();
132 const existing = await pool.query(
133 `SELECT tenant_id FROM be_tenants WHERE tenant_id = $1 LIMIT 1`,
134 [tenantId],
135 );
136 if (!existing.rowCount) {
137 await pool.query(
138 `INSERT INTO be_tenants (tenant_id, project_id) VALUES ($1, $2)`,
139 [tenantId, projectId],
140 );
141 }
142 return tenantId;
143}
144
145export async function listEngineSsoConnections(
146 projectId: string,
147): Promise<SsoConnection[]> {
148 if (!isAuthCoreInitialized()) return [];
149 const pool = getEnginePool();
150 const res = await pool.query(
151 `SELECT * FROM be_sso_connections
152 WHERE project_id = $1 AND deactivated_at IS NULL
153 ORDER BY created_at`,
154 [projectId],
155 );
156 return (res.rows as Parameters<typeof mapRow>[0][]).map(mapRow);
157}
158
159export async function getEngineSsoConnection(
160 connectionId: string,
161): Promise<SsoConnection | null> {
162 if (!isAuthCoreInitialized()) return null;
163 const pool = getEnginePool();
164 const res = await pool.query(
165 `SELECT * FROM be_sso_connections WHERE id = $1 LIMIT 1`,
166 [connectionId],
167 );
168 const row = res.rows[0] as Parameters<typeof mapRow>[0] | undefined;
169 return row ? mapRow(row) : null;
170}
171
172export async function createEngineSsoConnection(input: {
173 projectId: string;
174 name: string;
175 providerType: SsoProviderType;
176 domains?: string[];
177 config?: Record<string, unknown>;
178 jitEnabled?: boolean;
179}): Promise<SsoConnection> {
180 if (!isAuthCoreInitialized()) {
181 throw new Error('engine not ready');
182 }
183 const name = input.name.trim();
184 if (!name) throw new Error('name required');
185 if (!['saml', 'oidc'].includes(input.providerType)) {
186 throw new Error('providerType must be saml or oidc');
187 }
188 const tenantId = await ensureTenant(input.projectId);
189 const id = newId('bsc');
190 const domains = (input.domains ?? [])
191 .map((d) => d.trim().toLowerCase())
192 .filter(Boolean);
193 const config = input.config ?? {};
194 const pool = getEnginePool();
195 await pool.query(
196 `INSERT INTO be_sso_connections
197 (id, project_id, tenant_id, name, provider_type, domains_json, config_json, jit_enabled)
198 VALUES ($1,$2,$3,$4,$5,$6,$7,$8)`,
199 [
200 id,
201 input.projectId,
202 tenantId,
203 name,
204 input.providerType,
205 JSON.stringify(domains),
206 JSON.stringify(config),
207 input.jitEnabled ?? true,
208 ],
209 );
210 const created = await getEngineSsoConnection(id);
211 if (!created) throw new Error('create failed');
212 log.info('briven_engine_sso_connection_created', {
213 id,
214 projectId: input.projectId,
215 providerType: input.providerType,
216 ready: created.ready,
217 });
218 return created;
219}
220
221export async function updateEngineSsoConnection(
222 connectionId: string,
223 patch: {
224 name?: string;
225 domains?: string[];
226 config?: Record<string, unknown>;
227 jitEnabled?: boolean;
228 },
229): Promise<SsoConnection | null> {
230 const existing = await getEngineSsoConnection(connectionId);
231 if (!existing || existing.deactivatedAt) return null;
232 const name = patch.name?.trim() || existing.name;
233 const domains =
234 patch.domains?.map((d) => d.trim().toLowerCase()).filter(Boolean) ??
235 existing.domains;
236 const config = patch.config
237 ? { ...existing.config, ...patch.config }
238 : existing.config;
239 const jitEnabled = patch.jitEnabled ?? existing.jitEnabled;
240 const pool = getEnginePool();
241 await pool.query(
242 `UPDATE be_sso_connections
243 SET name = $2, domains_json = $3, config_json = $4, jit_enabled = $5
244 WHERE id = $1`,
245 [
246 connectionId,
247 name,
248 JSON.stringify(domains),
249 JSON.stringify(config),
250 jitEnabled,
251 ],
252 );
253 return getEngineSsoConnection(connectionId);
254}
255
256export async function deactivateEngineSsoConnection(
257 connectionId: string,
258): Promise<boolean> {
259 if (!isAuthCoreInitialized()) return false;
260 const pool = getEnginePool();
261 const res = await pool.query(
262 `UPDATE be_sso_connections SET deactivated_at = NOW()
263 WHERE id = $1 AND deactivated_at IS NULL`,
264 [connectionId],
265 );
266 return (res.rowCount ?? 0) > 0;
267}
268
269function acsUrl(connectionId: string): string {
270 return `${env.BRIVEN_API_ORIGIN}/v1/auth-core/sso/saml/${connectionId}/acs`;
271}
272
273function spEntityId(projectId: string, connectionId: string): string {
274 return `${env.BRIVEN_API_ORIGIN}/sso/${projectId}/${connectionId}`;
275}
276
277function buildSaml(conn: SsoConnection): SAML {
278 const config = conn.config as SamlConfig;
279 if (!config.idpCert?.trim() || !config.idpSsoUrl?.trim()) {
280 throw new Error('SAML connection missing idpSsoUrl or idpCert');
281 }
282 return new SAML({
283 issuer: config.spEntityId ?? spEntityId(conn.projectId, conn.id),
284 callbackUrl: acsUrl(conn.id),
285 entryPoint: config.idpSsoUrl,
286 idpCert: config.idpCert,
287 wantAssertionsSigned: true,
288 wantAuthnResponseSigned: true,
289 validateInResponseTo: ValidateInResponseTo.never,
290 acceptedClockSkewMs: 300_000,
291 });
292}
293
294export async function startSamlLogin(
295 connectionId: string,
296 relayState?: string,
297): Promise<{ redirectUrl: string }> {
298 const conn = await getEngineSsoConnection(connectionId);
299 if (!conn || conn.deactivatedAt) throw new Error('connection not found');
300 if (conn.providerType !== 'saml') throw new Error('not a SAML connection');
301 if (!conn.ready) throw new Error('SAML connection not fully configured');
302 const saml = buildSaml(conn);
303 const url = await saml.getAuthorizeUrlAsync('', '', {});
304 if (relayState) {
305 const u = new URL(url);
306 u.searchParams.set('RelayState', relayState);
307 return { redirectUrl: u.toString() };
308 }
309 return { redirectUrl: url };
310}
311
312export async function generateSamlMetadataXml(
313 connectionId: string,
314): Promise<string> {
315 const conn = await getEngineSsoConnection(connectionId);
316 if (!conn) throw new Error('connection not found');
317 if (conn.providerType !== 'saml') throw new Error('not a SAML connection');
318 const saml = buildSaml(conn);
319 return saml.generateServiceProviderMetadata('', '');
320}
321
322export async function completeSamlLogin(input: {
323 connectionId: string;
324 samlResponse: string;
325}): Promise<{
326 sessionHandle: string;
327 accessToken: string;
328 userId: string;
329 email: string;
330 projectId: string;
331 tenantId: string;
332}> {
333 const conn = await getEngineSsoConnection(input.connectionId);
334 if (!conn || conn.deactivatedAt) throw new Error('connection not found');
335 if (conn.providerType !== 'saml') throw new Error('not a SAML connection');
336 const saml = buildSaml(conn);
337 const result = await saml.validatePostResponseAsync({
338 SAMLResponse: input.samlResponse,
339 });
340 // eslint-disable-next-line @typescript-eslint/no-explicit-any
341 const profile = (result as any).profile ?? {};
342 const emailRaw = profile.email ?? profile.mail ?? profile.nameID;
343 if (!emailRaw || typeof emailRaw !== 'string') {
344 throw new Error('SAML assertion missing email');
345 }
346 const email = emailRaw.toLowerCase();
347 const name =
348 typeof profile.displayName === 'string'
349 ? profile.displayName
350 : typeof profile.cn === 'string'
351 ? profile.cn
352 : undefined;
353
354 if (conn.domains.length > 0) {
355 const domain = email.split('@')[1] ?? '';
356 if (!conn.domains.includes(domain)) {
357 throw new Error(`email domain not allowed for this SSO connection`);
358 }
359 }
360
361 const user = await findOrCreateSsoUser({
362 tenantId: conn.tenantId,
363 projectId: conn.projectId,
364 email,
365 name,
366 jitEnabled: conn.jitEnabled,
367 linkId: `saml:${conn.id}`,
368 });
369 const session = await createEngineSession({
370 userId: user.id,
371 tenantId: conn.tenantId,
372 });
373 log.info('briven_engine_sso_saml_ok', {
374 connectionId: conn.id,
375 projectId: conn.projectId,
376 userId: user.id,
377 isNew: user.isNew,
378 });
379 return {
380 sessionHandle: session.sessionHandle,
381 accessToken: session.accessToken,
382 userId: user.id,
383 email,
384 projectId: conn.projectId,
385 tenantId: conn.tenantId,
386 };
387}
388
389async function resolveOidcUrls(config: OidcConfig): Promise<{
390 authorizationUrl: string;
391 tokenUrl: string;
392 userinfoUrl: string;
393}> {
394 let authorizationUrl = config.authorizationUrl?.trim() || '';
395 let tokenUrl = config.tokenUrl?.trim() || '';
396 let userinfoUrl = config.userinfoUrl?.trim() || '';
397 const issuer = config.issuer?.replace(/\/$/, '');
398
399 if ((!authorizationUrl || !tokenUrl) && issuer) {
400 try {
401 const disco = await fetch(
402 `${issuer}/.well-known/openid-configuration`,
403 );
404 if (disco.ok) {
405 const doc = (await disco.json()) as {
406 authorization_endpoint?: string;
407 token_endpoint?: string;
408 userinfo_endpoint?: string;
409 };
410 authorizationUrl = authorizationUrl || doc.authorization_endpoint || '';
411 tokenUrl = tokenUrl || doc.token_endpoint || '';
412 userinfoUrl = userinfoUrl || doc.userinfo_endpoint || '';
413 }
414 } catch {
415 /* fall through to path heuristics */
416 }
417 }
418
419 if (!authorizationUrl && issuer) {
420 authorizationUrl = `${issuer}/oauth/authorize`;
421 }
422 if (!tokenUrl && issuer) {
423 tokenUrl = `${issuer}/oauth/token`;
424 }
425 if (!userinfoUrl && issuer) {
426 userinfoUrl = `${issuer}/userinfo`;
427 }
428 if (!authorizationUrl || !tokenUrl) {
429 throw new Error('OIDC missing authorizationUrl/tokenUrl (or issuer)');
430 }
431 return { authorizationUrl, tokenUrl, userinfoUrl };
432}
433
434export async function startOidcLogin(
435 connectionId: string,
436 redirectUri?: string,
437 /** App URL to send the browser after login (sanitized like SAML RelayState). */
438 returnTo?: string | null,
439): Promise<{ redirectUrl: string; state: string }> {
440 const conn = await getEngineSsoConnection(connectionId);
441 if (!conn || conn.deactivatedAt) throw new Error('connection not found');
442 if (conn.providerType !== 'oidc') throw new Error('not an OIDC connection');
443 if (!conn.ready) throw new Error('OIDC connection not fully configured');
444 const config = conn.config as OidcConfig;
445 const { authorizationUrl } = await resolveOidcUrls(config);
446 const state = randomBytes(24).toString('base64url');
447 const codeVerifier = randomBytes(32).toString('base64url');
448 const codeChallenge = createHash('sha256')
449 .update(codeVerifier)
450 .digest('base64url');
451 const callback =
452 redirectUri ||
453 `${env.BRIVEN_API_ORIGIN}/v1/auth-core/sso/oidc/${connectionId}/callback`;
454 // Sanitize return URL against project allowed origins (same idea as SAML RelayState).
455 let safeReturn: string | null = null;
456 if (returnTo?.trim()) {
457 try {
458 const { sanitizeRelayState } = await import('../auth-hardening.js');
459 const { getBrivenEngineAppOrigins } = await import('./project-config.js');
460 const origins = await getBrivenEngineAppOrigins(conn.projectId);
461 safeReturn = sanitizeRelayState(returnTo.trim(), origins) ?? null;
462 } catch {
463 safeReturn = null;
464 }
465 }
466 const pool = getEnginePool();
467 await pool.query(
468 `INSERT INTO be_sso_states
469 (state_id, connection_id, project_id, provider_type, code_verifier, redirect_uri, return_to, expires_at)
470 VALUES ($1,$2,$3,'oidc',$4,$5,$6,$7)`,
471 [
472 state,
473 connectionId,
474 conn.projectId,
475 codeVerifier,
476 callback,
477 safeReturn,
478 new Date(Date.now() + 15 * 60 * 1000).toISOString(),
479 ],
480 );
481 const u = new URL(authorizationUrl);
482 u.searchParams.set('response_type', 'code');
483 u.searchParams.set('client_id', config.clientId!);
484 u.searchParams.set('redirect_uri', callback);
485 u.searchParams.set('scope', config.scopes || 'openid email profile');
486 u.searchParams.set('state', state);
487 u.searchParams.set('code_challenge', codeChallenge);
488 u.searchParams.set('code_challenge_method', 'S256');
489 return { redirectUrl: u.toString(), state };
490}
491
492export async function completeOidcLogin(input: {
493 connectionId: string;
494 code: string;
495 state: string;
496}): Promise<{
497 sessionHandle: string;
498 accessToken: string;
499 userId: string;
500 email: string;
501 projectId: string;
502 tenantId: string;
503 /** Safe app return URL when startOidcLogin stored one */
504 returnTo: string | null;
505}> {
506 const conn = await getEngineSsoConnection(input.connectionId);
507 if (!conn || conn.deactivatedAt) throw new Error('connection not found');
508 if (conn.providerType !== 'oidc') throw new Error('not an OIDC connection');
509 const pool = getEnginePool();
510 const st = await pool.query(
511 `SELECT * FROM be_sso_states WHERE state_id = $1 AND connection_id = $2 LIMIT 1`,
512 [input.state, input.connectionId],
513 );
514 const stateRow = st.rows[0] as
515 | {
516 code_verifier: string | null;
517 redirect_uri: string | null;
518 return_to?: string | null;
519 expires_at: Date | string;
520 }
521 | undefined;
522 if (!stateRow) throw new Error('invalid or expired OIDC state');
523 if (new Date(stateRow.expires_at).getTime() < Date.now()) {
524 throw new Error('OIDC state expired');
525 }
526 await pool.query(`DELETE FROM be_sso_states WHERE state_id = $1`, [
527 input.state,
528 ]);
529
530 const config = conn.config as OidcConfig;
531 const { tokenUrl, userinfoUrl } = await resolveOidcUrls(config);
532 const redirectUri =
533 stateRow.redirect_uri ||
534 `${env.BRIVEN_API_ORIGIN}/v1/auth-core/sso/oidc/${input.connectionId}/callback`;
535
536 const body = new URLSearchParams({
537 grant_type: 'authorization_code',
538 code: input.code,
539 redirect_uri: redirectUri,
540 client_id: config.clientId!,
541 client_secret: config.clientSecret!,
542 });
543 if (stateRow.code_verifier) {
544 body.set('code_verifier', stateRow.code_verifier);
545 }
546
547 const tokenRes = await fetch(tokenUrl, {
548 method: 'POST',
549 headers: { 'content-type': 'application/x-www-form-urlencoded' },
550 body,
551 });
552 if (!tokenRes.ok) {
553 const t = await tokenRes.text().catch(() => '');
554 throw new Error(`OIDC token exchange failed: ${tokenRes.status} ${t}`);
555 }
556 const tokenJson = (await tokenRes.json()) as {
557 access_token?: string;
558 id_token?: string;
559 };
560 if (!tokenJson.access_token) throw new Error('OIDC token response missing access_token');
561
562 let email = '';
563 let name: string | undefined;
564 if (userinfoUrl) {
565 const ui = await fetch(userinfoUrl, {
566 headers: { authorization: `Bearer ${tokenJson.access_token}` },
567 });
568 if (ui.ok) {
569 const profile = (await ui.json()) as {
570 email?: string;
571 name?: string;
572 preferred_username?: string;
573 };
574 email = (profile.email || profile.preferred_username || '').toLowerCase();
575 name = profile.name;
576 }
577 }
578 if (!email && tokenJson.id_token) {
579 try {
580 const payload = JSON.parse(
581 Buffer.from(tokenJson.id_token.split('.')[1]!, 'base64url').toString(
582 'utf8',
583 ),
584 ) as { email?: string; name?: string };
585 email = (payload.email || '').toLowerCase();
586 name = name || payload.name;
587 } catch {
588 /* ignore */
589 }
590 }
591 if (!email) throw new Error('OIDC profile missing email');
592
593 if (conn.domains.length > 0) {
594 const domain = email.split('@')[1] ?? '';
595 if (!conn.domains.includes(domain)) {
596 throw new Error('email domain not allowed for this SSO connection');
597 }
598 }
599
600 const user = await findOrCreateSsoUser({
601 tenantId: conn.tenantId,
602 projectId: conn.projectId,
603 email,
604 name,
605 jitEnabled: conn.jitEnabled,
606 linkId: `oidc:${conn.id}`,
607 });
608 const session = await createEngineSession({
609 userId: user.id,
610 tenantId: conn.tenantId,
611 });
612 log.info('briven_engine_sso_oidc_ok', {
613 connectionId: conn.id,
614 projectId: conn.projectId,
615 userId: user.id,
616 isNew: user.isNew,
617 });
618 return {
619 sessionHandle: session.sessionHandle,
620 accessToken: session.accessToken,
621 userId: user.id,
622 email,
623 projectId: conn.projectId,
624 tenantId: conn.tenantId,
625 returnTo: stateRow.return_to?.trim() || null,
626 };
627}
628
629async function findOrCreateSsoUser(input: {
630 tenantId: string;
631 projectId: string;
632 email: string;
633 name?: string;
634 jitEnabled: boolean;
635 linkId: string;
636}): Promise<{ id: string; isNew: boolean }> {
637 const pool = getEnginePool();
638 const existing = await pool.query(
639 `SELECT id FROM be_users WHERE tenant_id = $1 AND lower(email) = lower($2) LIMIT 1`,
640 [input.tenantId, input.email],
641 );
642 if (existing.rowCount) {
643 return {
644 id: (existing.rows[0] as { id: string }).id,
645 isNew: false,
646 };
647 }
648 if (!input.jitEnabled) {
649 throw new Error('user not found and JIT provisioning disabled');
650 }
651 const userId = newId('beu');
652 await pool.query(
653 `INSERT INTO be_users (id, tenant_id, email, email_verified, metadata_json)
654 VALUES ($1, $2, $3, true, $4)`,
655 [
656 userId,
657 input.tenantId,
658 input.email,
659 JSON.stringify({
660 name: input.name ?? null,
661 sso: input.linkId,
662 projectId: input.projectId,
663 }),
664 ],
665 );
666 // Link row for uniqueness / audit (reuse third_party_links)
667 try {
668 await pool.query(
669 `INSERT INTO be_third_party_links
670 (id, user_id, tenant_id, third_party_id, third_party_user_id)
671 VALUES ($1, $2, $3, $4, $5)`,
672 [
673 newId('btp'),
674 userId,
675 input.tenantId,
676 input.linkId.split(':')[0],
677 input.linkId,
678 ],
679 );
680 } catch {
681 /* optional */
682 }
683 return { id: userId, isNew: true };
684}
685
686/** Public summary for dashboard (no secrets). */
687export function publicSsoConnection(c: SsoConnection): Omit<SsoConnection, 'config'> & {
688 configKeys: string[];
689 productionReady: boolean;
690} {
691 const { config, ...rest } = c;
692 return {
693 ...rest,
694 configKeys: Object.keys(config).filter(
695 (k) => config[k] != null && String(config[k]).length > 0,
696 ),
697 productionReady: c.ready && !c.deactivatedAt,
698 };
699}