sso.ts680 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): Promise<{ redirectUrl: string; state: string }> {
438 const conn = await getEngineSsoConnection(connectionId);
439 if (!conn || conn.deactivatedAt) throw new Error('connection not found');
440 if (conn.providerType !== 'oidc') throw new Error('not an OIDC connection');
441 if (!conn.ready) throw new Error('OIDC connection not fully configured');
442 const config = conn.config as OidcConfig;
443 const { authorizationUrl } = await resolveOidcUrls(config);
444 const state = randomBytes(24).toString('base64url');
445 const codeVerifier = randomBytes(32).toString('base64url');
446 const codeChallenge = createHash('sha256')
447 .update(codeVerifier)
448 .digest('base64url');
449 const callback =
450 redirectUri ||
451 `${env.BRIVEN_API_ORIGIN}/v1/auth-core/sso/oidc/${connectionId}/callback`;
452 const pool = getEnginePool();
453 await pool.query(
454 `INSERT INTO be_sso_states
455 (state_id, connection_id, project_id, provider_type, code_verifier, redirect_uri, expires_at)
456 VALUES ($1,$2,$3,'oidc',$4,$5,$6)`,
457 [
458 state,
459 connectionId,
460 conn.projectId,
461 codeVerifier,
462 callback,
463 new Date(Date.now() + 15 * 60 * 1000).toISOString(),
464 ],
465 );
466 const u = new URL(authorizationUrl);
467 u.searchParams.set('response_type', 'code');
468 u.searchParams.set('client_id', config.clientId!);
469 u.searchParams.set('redirect_uri', callback);
470 u.searchParams.set('scope', config.scopes || 'openid email profile');
471 u.searchParams.set('state', state);
472 u.searchParams.set('code_challenge', codeChallenge);
473 u.searchParams.set('code_challenge_method', 'S256');
474 return { redirectUrl: u.toString(), state };
475}
476
477export async function completeOidcLogin(input: {
478 connectionId: string;
479 code: string;
480 state: string;
481}): Promise<{
482 sessionHandle: string;
483 accessToken: string;
484 userId: string;
485 email: string;
486 projectId: string;
487 tenantId: string;
488}> {
489 const conn = await getEngineSsoConnection(input.connectionId);
490 if (!conn || conn.deactivatedAt) throw new Error('connection not found');
491 if (conn.providerType !== 'oidc') throw new Error('not an OIDC connection');
492 const pool = getEnginePool();
493 const st = await pool.query(
494 `SELECT * FROM be_sso_states WHERE state_id = $1 AND connection_id = $2 LIMIT 1`,
495 [input.state, input.connectionId],
496 );
497 const stateRow = st.rows[0] as
498 | {
499 code_verifier: string | null;
500 redirect_uri: string | null;
501 expires_at: Date | string;
502 }
503 | undefined;
504 if (!stateRow) throw new Error('invalid or expired OIDC state');
505 if (new Date(stateRow.expires_at).getTime() < Date.now()) {
506 throw new Error('OIDC state expired');
507 }
508 await pool.query(`DELETE FROM be_sso_states WHERE state_id = $1`, [
509 input.state,
510 ]);
511
512 const config = conn.config as OidcConfig;
513 const { tokenUrl, userinfoUrl } = await resolveOidcUrls(config);
514 const redirectUri =
515 stateRow.redirect_uri ||
516 `${env.BRIVEN_API_ORIGIN}/v1/auth-core/sso/oidc/${input.connectionId}/callback`;
517
518 const body = new URLSearchParams({
519 grant_type: 'authorization_code',
520 code: input.code,
521 redirect_uri: redirectUri,
522 client_id: config.clientId!,
523 client_secret: config.clientSecret!,
524 });
525 if (stateRow.code_verifier) {
526 body.set('code_verifier', stateRow.code_verifier);
527 }
528
529 const tokenRes = await fetch(tokenUrl, {
530 method: 'POST',
531 headers: { 'content-type': 'application/x-www-form-urlencoded' },
532 body,
533 });
534 if (!tokenRes.ok) {
535 const t = await tokenRes.text().catch(() => '');
536 throw new Error(`OIDC token exchange failed: ${tokenRes.status} ${t}`);
537 }
538 const tokenJson = (await tokenRes.json()) as {
539 access_token?: string;
540 id_token?: string;
541 };
542 if (!tokenJson.access_token) throw new Error('OIDC token response missing access_token');
543
544 let email = '';
545 let name: string | undefined;
546 if (userinfoUrl) {
547 const ui = await fetch(userinfoUrl, {
548 headers: { authorization: `Bearer ${tokenJson.access_token}` },
549 });
550 if (ui.ok) {
551 const profile = (await ui.json()) as {
552 email?: string;
553 name?: string;
554 preferred_username?: string;
555 };
556 email = (profile.email || profile.preferred_username || '').toLowerCase();
557 name = profile.name;
558 }
559 }
560 if (!email && tokenJson.id_token) {
561 try {
562 const payload = JSON.parse(
563 Buffer.from(tokenJson.id_token.split('.')[1]!, 'base64url').toString(
564 'utf8',
565 ),
566 ) as { email?: string; name?: string };
567 email = (payload.email || '').toLowerCase();
568 name = name || payload.name;
569 } catch {
570 /* ignore */
571 }
572 }
573 if (!email) throw new Error('OIDC profile missing email');
574
575 if (conn.domains.length > 0) {
576 const domain = email.split('@')[1] ?? '';
577 if (!conn.domains.includes(domain)) {
578 throw new Error('email domain not allowed for this SSO connection');
579 }
580 }
581
582 const user = await findOrCreateSsoUser({
583 tenantId: conn.tenantId,
584 projectId: conn.projectId,
585 email,
586 name,
587 jitEnabled: conn.jitEnabled,
588 linkId: `oidc:${conn.id}`,
589 });
590 const session = await createEngineSession({
591 userId: user.id,
592 tenantId: conn.tenantId,
593 });
594 log.info('briven_engine_sso_oidc_ok', {
595 connectionId: conn.id,
596 projectId: conn.projectId,
597 userId: user.id,
598 isNew: user.isNew,
599 });
600 return {
601 sessionHandle: session.sessionHandle,
602 accessToken: session.accessToken,
603 userId: user.id,
604 email,
605 projectId: conn.projectId,
606 tenantId: conn.tenantId,
607 };
608}
609
610async function findOrCreateSsoUser(input: {
611 tenantId: string;
612 projectId: string;
613 email: string;
614 name?: string;
615 jitEnabled: boolean;
616 linkId: string;
617}): Promise<{ id: string; isNew: boolean }> {
618 const pool = getEnginePool();
619 const existing = await pool.query(
620 `SELECT id FROM be_users WHERE tenant_id = $1 AND lower(email) = lower($2) LIMIT 1`,
621 [input.tenantId, input.email],
622 );
623 if (existing.rowCount) {
624 return {
625 id: (existing.rows[0] as { id: string }).id,
626 isNew: false,
627 };
628 }
629 if (!input.jitEnabled) {
630 throw new Error('user not found and JIT provisioning disabled');
631 }
632 const userId = newId('beu');
633 await pool.query(
634 `INSERT INTO be_users (id, tenant_id, email, email_verified, metadata_json)
635 VALUES ($1, $2, $3, true, $4)`,
636 [
637 userId,
638 input.tenantId,
639 input.email,
640 JSON.stringify({
641 name: input.name ?? null,
642 sso: input.linkId,
643 projectId: input.projectId,
644 }),
645 ],
646 );
647 // Link row for uniqueness / audit (reuse third_party_links)
648 try {
649 await pool.query(
650 `INSERT INTO be_third_party_links
651 (id, user_id, tenant_id, third_party_id, third_party_user_id)
652 VALUES ($1, $2, $3, $4, $5)`,
653 [
654 newId('btp'),
655 userId,
656 input.tenantId,
657 input.linkId.split(':')[0],
658 input.linkId,
659 ],
660 );
661 } catch {
662 /* optional */
663 }
664 return { id: userId, isNew: true };
665}
666
667/** Public summary for dashboard (no secrets). */
668export function publicSsoConnection(c: SsoConnection): Omit<SsoConnection, 'config'> & {
669 configKeys: string[];
670 productionReady: boolean;
671} {
672 const { config, ...rest } = c;
673 return {
674 ...rest,
675 configKeys: Object.keys(config).filter(
676 (k) => config[k] != null && String(config[k]).length > 0,
677 ),
678 productionReady: c.ready && !c.deactivatedAt,
679 };
680}