thirdparty.ts892 lines · main
1/**
2 * briven-engine third-party social login on Doltgres.
3 *
4 * Full catalog (Konnos, Google, GitHub, Discord, Apple, Microsoft, Facebook,
5 * X/Twitter, LinkedIn, GitLab, Bitbucket, Spotify).
6 *
7 * Flow:
8 * 1) getAuthorisationUrl → browser → provider
9 * 2) provider redirects with ?code=
10 * 3) exchangeCodeForProfile → email + provider user id
11 * 4) signInUpWithThirdPartyProfile → user + link + session on Doltgres
12 */
13
14import { createSign, randomBytes } from 'node:crypto';
15
16import { newId } from '@briven/shared';
17
18import { env } from '../../env.js';
19import { log } from '../../lib/logger.js';
20import { getEnginePool } from './db.js';
21import { createEngineSession } from './native-session.js';
22import { projectIdToTenantId } from './project-map.js';
23import {
24 loadProjectProviderSecrets,
25} from './project-config.js';
26import type { BrivenSocialProviderId } from './providers.js';
27
28/** All catalog providers that can run OAuth login when secrets are set. */
29export type SupportedSocial = BrivenSocialProviderId;
30
31/**
32 * "Sign in with Konnos" product OAuth host (konnos.org).
33 * Not the Git forge at code.konnos.org — kc_* apps are registered under
34 * konnos.org → Settings → Applications.
35 * Override with BRIVEN_KONNOS_OAUTH_ORIGIN if needed.
36 */
37function konnosOAuthOrigin(): string {
38 const raw =
39 process.env.BRIVEN_KONNOS_OAUTH_ORIGIN ??
40 process.env.BRIVEN_KONNOS_ISSUER ??
41 'https://konnos.org';
42 // Legacy misconfig: code.konnos.org is Gogs/Git, not Sign-in with Konnos.
43 if (/^https?:\/\/code\.konnos\.org\/?$/i.test(raw.replace(/\/$/, ''))) {
44 return 'https://konnos.org';
45 }
46 return raw.replace(/\/$/, '');
47}
48
49type OAuthProviderEndpoints = {
50 authorizeUrl: string;
51 tokenUrl: string;
52 userInfoUrl?: string;
53 /** Space-separated OAuth scopes */
54 scope: string;
55 /** How to POST the token request */
56 tokenBody: 'json' | 'form';
57 /** Extra authorize query params */
58 authorizeExtra?: Record<string, string>;
59 /**
60 * Parse access_token + profile from token/userinfo responses.
61 * Defaults: standard OAuth2 JSON userinfo.
62 */
63 profileFrom?: 'userinfo' | 'apple_id_token' | 'twitter_v2';
64};
65
66const OAUTH_ENDPOINTS: Record<SupportedSocial, OAuthProviderEndpoints> = {
67 google: {
68 authorizeUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
69 tokenUrl: 'https://oauth2.googleapis.com/token',
70 userInfoUrl: 'https://www.googleapis.com/oauth2/v3/userinfo',
71 scope: 'openid email profile',
72 tokenBody: 'form',
73 authorizeExtra: {
74 access_type: 'online',
75 include_granted_scopes: 'true',
76 },
77 },
78 github: {
79 authorizeUrl: 'https://github.com/login/oauth/authorize',
80 tokenUrl: 'https://github.com/login/oauth/access_token',
81 userInfoUrl: 'https://api.github.com/user',
82 scope: 'user:email',
83 tokenBody: 'json',
84 },
85 // Sign in with Konnos (product) — paths from konnos apps/web OAuth provider.
86 konnos: {
87 authorizeUrl: `${konnosOAuthOrigin()}/login/oauth/authorize`,
88 tokenUrl: `${konnosOAuthOrigin()}/login/oauth/access_token`,
89 userInfoUrl: `${konnosOAuthOrigin()}/api/user`,
90 scope: 'read:user',
91 tokenBody: 'json',
92 },
93 discord: {
94 authorizeUrl: 'https://discord.com/api/oauth2/authorize',
95 tokenUrl: 'https://discord.com/api/oauth2/token',
96 userInfoUrl: 'https://discord.com/api/users/@me',
97 scope: 'identify email',
98 tokenBody: 'form',
99 },
100 microsoft: {
101 authorizeUrl:
102 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize',
103 tokenUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/token',
104 userInfoUrl: 'https://graph.microsoft.com/v1.0/me',
105 scope: 'openid email profile User.Read',
106 tokenBody: 'form',
107 },
108 facebook: {
109 authorizeUrl: 'https://www.facebook.com/v18.0/dialog/oauth',
110 tokenUrl: 'https://graph.facebook.com/v18.0/oauth/access_token',
111 userInfoUrl:
112 'https://graph.facebook.com/me?fields=id,name,email',
113 scope: 'email,public_profile',
114 tokenBody: 'form',
115 },
116 twitter: {
117 // X OAuth 2.0
118 authorizeUrl: 'https://twitter.com/i/oauth2/authorize',
119 tokenUrl: 'https://api.twitter.com/2/oauth2/token',
120 userInfoUrl:
121 'https://api.twitter.com/2/users/me?user.fields=id,name,username',
122 scope: 'users.read tweet.read offline.access',
123 tokenBody: 'form',
124 authorizeExtra: { code_challenge_method: 'plain' },
125 profileFrom: 'twitter_v2',
126 },
127 linkedin: {
128 authorizeUrl: 'https://www.linkedin.com/oauth/v2/authorization',
129 tokenUrl: 'https://www.linkedin.com/oauth/v2/accessToken',
130 userInfoUrl: 'https://api.linkedin.com/v2/userinfo',
131 scope: 'openid profile email',
132 tokenBody: 'form',
133 },
134 gitlab: {
135 authorizeUrl: 'https://gitlab.com/oauth/authorize',
136 tokenUrl: 'https://gitlab.com/oauth/token',
137 userInfoUrl: 'https://gitlab.com/api/v4/user',
138 scope: 'read_user',
139 tokenBody: 'form',
140 },
141 bitbucket: {
142 authorizeUrl: 'https://bitbucket.org/site/oauth2/authorize',
143 tokenUrl: 'https://bitbucket.org/site/oauth2/access_token',
144 userInfoUrl: 'https://api.bitbucket.org/2.0/user',
145 scope: 'account email',
146 tokenBody: 'form',
147 },
148 spotify: {
149 authorizeUrl: 'https://accounts.spotify.com/authorize',
150 tokenUrl: 'https://accounts.spotify.com/api/token',
151 userInfoUrl: 'https://api.spotify.com/v1/me',
152 scope: 'user-read-email',
153 tokenBody: 'form',
154 },
155 apple: {
156 authorizeUrl: 'https://appleid.apple.com/auth/authorize',
157 tokenUrl: 'https://appleid.apple.com/auth/token',
158 scope: 'name email',
159 tokenBody: 'form',
160 authorizeExtra: { response_mode: 'form_post' },
161 profileFrom: 'apple_id_token',
162 },
163};
164
165const ALL_SOCIAL = Object.keys(OAUTH_ENDPOINTS) as SupportedSocial[];
166
167/** In-memory fallback when Redis is down (single-node only). Prefer Redis. */
168const OAUTH_STATE = new Map<
169 string,
170 {
171 projectId: string;
172 thirdPartyId: SupportedSocial;
173 createdAt: number;
174 /** PKCE verifier for X/Twitter */
175 codeVerifier?: string;
176 }
177>();
178
179const OAUTH_STATE_TTL_MS = 15 * 60 * 1000;
180const OAUTH_STATE_REDIS_PREFIX = 'oauth:st:';
181
182type OauthStateValue = {
183 projectId: string;
184 thirdPartyId: SupportedSocial;
185 createdAt: number;
186 codeVerifier?: string;
187};
188
189function cleanState(): void {
190 const cutoff = Date.now() - OAUTH_STATE_TTL_MS;
191 for (const [k, v] of OAUTH_STATE) {
192 if (v.createdAt < cutoff) OAUTH_STATE.delete(k);
193 }
194}
195
196async function putOauthState(state: string, value: OauthStateValue): Promise<void> {
197 cleanState();
198 OAUTH_STATE.set(state, value);
199 try {
200 const { getRedis } = await import('../../lib/redis.js');
201 const redis = getRedis();
202 if (redis) {
203 await redis.set(
204 `${OAUTH_STATE_REDIS_PREFIX}${state}`,
205 JSON.stringify(value),
206 'PX',
207 OAUTH_STATE_TTL_MS,
208 );
209 }
210 } catch {
211 /* memory remains */
212 }
213}
214
215async function takeOauthState(state: string): Promise<OauthStateValue | null> {
216 cleanState();
217 try {
218 const { getRedis } = await import('../../lib/redis.js');
219 const redis = getRedis();
220 if (redis) {
221 const key = `${OAUTH_STATE_REDIS_PREFIX}${state}`;
222 const raw = await redis.get(key);
223 if (raw) {
224 await redis.del(key);
225 try {
226 return JSON.parse(raw) as OauthStateValue;
227 } catch {
228 return null;
229 }
230 }
231 }
232 } catch {
233 /* fall through to memory */
234 }
235 const mem = OAUTH_STATE.get(state) ?? null;
236 if (mem) OAUTH_STATE.delete(state);
237 return mem;
238}
239
240function isSupported(id: string): id is SupportedSocial {
241 return ALL_SOCIAL.includes(id as SupportedSocial);
242}
243
244async function ensureTenant(tenantId: string, projectId?: string): Promise<void> {
245 const pool = getEnginePool();
246 const existing = await pool.query(
247 `SELECT tenant_id FROM be_tenants WHERE tenant_id = $1 LIMIT 1`,
248 [tenantId],
249 );
250 if (!existing.rowCount) {
251 await pool.query(
252 `INSERT INTO be_tenants (tenant_id, project_id) VALUES ($1, $2)`,
253 [tenantId, projectId ?? tenantId],
254 );
255 }
256}
257
258/**
259 * Resolve OAuth client id/secret for a **Briven project** (briven-engine).
260 *
261 * Product rule (flndrn 2026-07-27): **per-project only** — SuperTokens-style.
262 * Each project must have its own Konnos/Google/… Client ID + Secret under
263 * Auth → Providers. We do **not** fall back to platform env
264 * (`BRIVEN_KONNOS_*`, `BRIVEN_GOOGLE_*`, …) for customer-project login.
265 *
266 * Platform sign-in for briven.tech itself still uses Better Auth + env in
267 * `apps/api/src/lib/auth.ts` — that path is separate and is not a project.
268 *
269 * Without a projectId, or without saved project secrets → no credentials
270 * (Konnos/Google/… stay off for that project until keys are pasted).
271 */
272export async function resolveProviderCredentials(
273 projectId: string | undefined,
274 thirdPartyId: SupportedSocial,
275): Promise<{ clientId: string; clientSecret: string; source: string } | null> {
276 if (!projectId || !projectId.trim()) {
277 return null;
278 }
279
280 try {
281 const secrets = await loadProjectProviderSecrets(projectId.trim());
282 const hit = secrets.find((s) => s.thirdPartyId === thirdPartyId);
283 if (hit?.clientId && hit?.clientSecret) {
284 return {
285 clientId: hit.clientId,
286 clientSecret: hit.clientSecret,
287 source: 'project_secrets',
288 };
289 }
290 } catch {
291 // secrets table / master key may be unavailable — treat as not configured
292 }
293
294 return null;
295}
296
297export type AuthorisationUrlResult =
298 | {
299 status: 'OK';
300 urlWithQueryParams: string;
301 state: string;
302 thirdPartyId: SupportedSocial;
303 credentialsSource: string;
304 }
305 | { status: 'NO_CREDENTIALS' | 'BAD_REQUEST'; message: string };
306
307/**
308 * Build provider authorisation URL (step 1 of OAuth).
309 */
310export async function getAuthorisationUrl(input: {
311 thirdPartyId: SupportedSocial | string;
312 redirectURI: string;
313 projectId?: string;
314}): Promise<AuthorisationUrlResult> {
315 if (!isSupported(input.thirdPartyId)) {
316 return {
317 status: 'BAD_REQUEST',
318 message: `unsupported provider: ${input.thirdPartyId}`,
319 };
320 }
321 if (!input.redirectURI) {
322 return { status: 'BAD_REQUEST', message: 'redirectURI required' };
323 }
324
325 const thirdPartyId = input.thirdPartyId;
326 const endpoints = OAUTH_ENDPOINTS[thirdPartyId];
327 const creds = await resolveProviderCredentials(
328 input.projectId,
329 thirdPartyId,
330 );
331 if (!creds) {
332 return {
333 status: 'NO_CREDENTIALS',
334 message:
335 `No ${thirdPartyId} Client ID + Secret for this project. ` +
336 `Open Briven Auth → Providers for this project, paste that project’s own OAuth app keys, and save. ` +
337 `Each project needs its own keys (not shared with other projects). ` +
338 `If the dashboard already shows “set”, paste both values again and save — ` +
339 `stale secrets after a key change cannot be read for login.`,
340 };
341 }
342
343 const state = randomBytes(16).toString('hex');
344 let codeVerifier: string | undefined;
345 if (thirdPartyId === 'twitter') {
346 // PKCE plain (simple); production apps may prefer S256 later
347 codeVerifier = randomBytes(32).toString('base64url');
348 }
349 await putOauthState(state, {
350 projectId: input.projectId ?? '',
351 thirdPartyId,
352 createdAt: Date.now(),
353 codeVerifier,
354 });
355
356 const u = new URL(endpoints.authorizeUrl);
357 u.searchParams.set('client_id', creds.clientId);
358 u.searchParams.set('redirect_uri', input.redirectURI);
359 u.searchParams.set('response_type', 'code');
360 u.searchParams.set('scope', endpoints.scope);
361 u.searchParams.set('state', state);
362 if (endpoints.authorizeExtra) {
363 for (const [k, v] of Object.entries(endpoints.authorizeExtra)) {
364 u.searchParams.set(k, v);
365 }
366 }
367 if (codeVerifier) {
368 u.searchParams.set('code_challenge', codeVerifier);
369 }
370
371 return {
372 status: 'OK',
373 urlWithQueryParams: u.toString(),
374 state,
375 thirdPartyId,
376 credentialsSource: creds.source,
377 };
378}
379
380export type OAuthProfile = {
381 thirdPartyId: SupportedSocial;
382 thirdPartyUserId: string;
383 email: string | null;
384 emailVerified: boolean;
385 name?: string | null;
386};
387
388/**
389 * Exchange authorization code for a profile (real provider HTTP).
390 */
391export async function exchangeCodeForProfile(input: {
392 thirdPartyId: SupportedSocial | string;
393 code: string;
394 redirectURI: string;
395 projectId?: string;
396 state?: string;
397}): Promise<
398 | { status: 'OK'; profile: OAuthProfile; projectId?: string }
399 | { status: 'ERROR'; message: string }
400> {
401 if (!isSupported(input.thirdPartyId)) {
402 return { status: 'ERROR', message: `unsupported provider: ${input.thirdPartyId}` };
403 }
404 const thirdPartyId = input.thirdPartyId;
405 let projectId = input.projectId;
406 let codeVerifier: string | undefined;
407 if (input.state) {
408 const st = await takeOauthState(input.state);
409 if (!st || st.thirdPartyId !== thirdPartyId) {
410 return { status: 'ERROR', message: 'invalid or expired OAuth state' };
411 }
412 if (st.projectId) projectId = st.projectId;
413 codeVerifier = st.codeVerifier;
414 }
415
416 const endpoints = OAUTH_ENDPOINTS[thirdPartyId];
417 const creds = await resolveProviderCredentials(projectId, thirdPartyId);
418 if (!creds) {
419 return { status: 'ERROR', message: 'no credentials for provider' };
420 }
421
422 try {
423 // ── token ──
424 let accessToken: string | undefined;
425 let idToken: string | undefined;
426
427 if (thirdPartyId === 'apple') {
428 // Apple client_secret is often a JWT signed with .p8; accept raw secret
429 // from Providers if operator pastes a pre-built JWT secret.
430 const body = new URLSearchParams({
431 client_id: creds.clientId,
432 client_secret: creds.clientSecret,
433 code: input.code,
434 grant_type: 'authorization_code',
435 redirect_uri: input.redirectURI,
436 });
437 const tokenRes = await fetch(endpoints.tokenUrl, {
438 method: 'POST',
439 headers: { 'content-type': 'application/x-www-form-urlencoded' },
440 body,
441 signal: AbortSignal.timeout(15000),
442 });
443 if (!tokenRes.ok) {
444 const t = await tokenRes.text();
445 return {
446 status: 'ERROR',
447 message: `apple token ${tokenRes.status}: ${t.slice(0, 160)}`,
448 };
449 }
450 const tokenJson = (await tokenRes.json()) as {
451 access_token?: string;
452 id_token?: string;
453 };
454 accessToken = tokenJson.access_token;
455 idToken = tokenJson.id_token;
456 } else if (endpoints.tokenBody === 'json') {
457 const tokenRes = await fetch(endpoints.tokenUrl, {
458 method: 'POST',
459 headers: {
460 'content-type': 'application/json',
461 accept: 'application/json',
462 },
463 body: JSON.stringify({
464 client_id: creds.clientId,
465 client_secret: creds.clientSecret,
466 code: input.code,
467 redirect_uri: input.redirectURI,
468 grant_type: 'authorization_code',
469 ...(codeVerifier ? { code_verifier: codeVerifier } : {}),
470 }),
471 signal: AbortSignal.timeout(15000),
472 });
473 if (!tokenRes.ok) {
474 const t = await tokenRes.text();
475 return {
476 status: 'ERROR',
477 message: `${thirdPartyId} token ${tokenRes.status}: ${t.slice(0, 160)}`,
478 };
479 }
480 const tokenJson = (await tokenRes.json()) as {
481 access_token?: string;
482 error?: string;
483 };
484 accessToken = tokenJson.access_token;
485 if (!accessToken) {
486 return {
487 status: 'ERROR',
488 message: tokenJson.error ?? `${thirdPartyId}: no access_token`,
489 };
490 }
491 } else {
492 // form-urlencoded token (most providers)
493 const params = new URLSearchParams({
494 client_id: creds.clientId,
495 client_secret: creds.clientSecret,
496 code: input.code,
497 redirect_uri: input.redirectURI,
498 grant_type: 'authorization_code',
499 });
500 if (codeVerifier) params.set('code_verifier', codeVerifier);
501 const headers: Record<string, string> = {
502 'content-type': 'application/x-www-form-urlencoded',
503 accept: 'application/json',
504 };
505 // Spotify / Bitbucket often want Basic auth for token
506 if (thirdPartyId === 'spotify' || thirdPartyId === 'bitbucket') {
507 headers.authorization = `Basic ${Buffer.from(
508 `${creds.clientId}:${creds.clientSecret}`,
509 ).toString('base64')}`;
510 }
511 const tokenRes = await fetch(endpoints.tokenUrl, {
512 method: 'POST',
513 headers,
514 body: params,
515 signal: AbortSignal.timeout(15000),
516 });
517 if (!tokenRes.ok) {
518 const t = await tokenRes.text();
519 return {
520 status: 'ERROR',
521 message: `${thirdPartyId} token ${tokenRes.status}: ${t.slice(0, 160)}`,
522 };
523 }
524 const tokenJson = (await tokenRes.json()) as {
525 access_token?: string;
526 id_token?: string;
527 error?: string;
528 };
529 accessToken = tokenJson.access_token;
530 idToken = tokenJson.id_token;
531 if (!accessToken && !idToken) {
532 return {
533 status: 'ERROR',
534 message: tokenJson.error ?? `${thirdPartyId}: no access_token`,
535 };
536 }
537 }
538
539 // ── profile ──
540 if (endpoints.profileFrom === 'apple_id_token' && idToken) {
541 const payload = decodeJwtPayload(idToken);
542 const sub = payload.sub as string | undefined;
543 if (!sub) return { status: 'ERROR', message: 'apple: no sub in id_token' };
544 return {
545 status: 'OK',
546 projectId,
547 profile: {
548 thirdPartyId: 'apple',
549 thirdPartyUserId: sub,
550 email: (payload.email as string | undefined) ?? null,
551 emailVerified: payload.email_verified === true || payload.email_verified === 'true',
552 name: null,
553 },
554 };
555 }
556
557 if (!accessToken) {
558 return { status: 'ERROR', message: `${thirdPartyId}: no access_token` };
559 }
560 if (!endpoints.userInfoUrl) {
561 return { status: 'ERROR', message: `${thirdPartyId}: no userInfoUrl` };
562 }
563
564 const userRes = await fetch(endpoints.userInfoUrl, {
565 headers: {
566 Authorization: `Bearer ${accessToken}`,
567 Accept: 'application/json',
568 'User-Agent': 'briven-engine',
569 },
570 signal: AbortSignal.timeout(15000),
571 });
572 if (!userRes.ok) {
573 return {
574 status: 'ERROR',
575 message: `${thirdPartyId} userinfo ${userRes.status}`,
576 };
577 }
578 const user = (await userRes.json()) as Record<string, unknown>;
579
580 // GitHub: fill email from /user/emails if missing
581 if (thirdPartyId === 'github' && !user.email) {
582 try {
583 const emailsRes = await fetch('https://api.github.com/user/emails', {
584 headers: {
585 Authorization: `Bearer ${accessToken}`,
586 Accept: 'application/vnd.github+json',
587 'User-Agent': 'briven-engine',
588 },
589 signal: AbortSignal.timeout(12000),
590 });
591 if (emailsRes.ok) {
592 const emails = (await emailsRes.json()) as Array<{
593 email: string;
594 primary?: boolean;
595 verified?: boolean;
596 }>;
597 const primary =
598 emails.find((e) => e.primary && e.verified) ??
599 emails.find((e) => e.verified) ??
600 emails[0];
601 if (primary?.email) user.email = primary.email;
602 }
603 } catch {
604 /* ignore */
605 }
606 }
607
608 // Bitbucket emails
609 if (thirdPartyId === 'bitbucket' && !user.email) {
610 try {
611 const emailsRes = await fetch(
612 'https://api.bitbucket.org/2.0/user/emails',
613 {
614 headers: { Authorization: `Bearer ${accessToken}` },
615 signal: AbortSignal.timeout(12000),
616 },
617 );
618 if (emailsRes.ok) {
619 const body = (await emailsRes.json()) as {
620 values?: Array<{ email?: string; is_primary?: boolean }>;
621 };
622 const primary =
623 body.values?.find((e) => e.is_primary) ?? body.values?.[0];
624 if (primary?.email) user.email = primary.email;
625 }
626 } catch {
627 /* ignore */
628 }
629 }
630
631 const profile = normalizeProfile(thirdPartyId, user);
632 if (!profile) {
633 return { status: 'ERROR', message: `${thirdPartyId}: could not parse user id` };
634 }
635 return { status: 'OK', projectId, profile };
636 } catch (err) {
637 return {
638 status: 'ERROR',
639 message: err instanceof Error ? err.message : String(err),
640 };
641 }
642}
643
644function decodeJwtPayload(jwt: string): Record<string, unknown> {
645 const parts = jwt.split('.');
646 if (parts.length < 2) return {};
647 try {
648 const json = Buffer.from(parts[1]!, 'base64url').toString('utf8');
649 return JSON.parse(json) as Record<string, unknown>;
650 } catch {
651 return {};
652 }
653}
654
655function normalizeProfile(
656 thirdPartyId: SupportedSocial,
657 user: Record<string, unknown>,
658): OAuthProfile | null {
659 // Twitter v2 wraps data
660 if (thirdPartyId === 'twitter' && user.data && typeof user.data === 'object') {
661 const d = user.data as Record<string, unknown>;
662 const id = d.id != null ? String(d.id) : null;
663 if (!id) return null;
664 return {
665 thirdPartyId,
666 thirdPartyUserId: id,
667 email: null, // X free tier often has no email
668 emailVerified: false,
669 name: (d.name as string | undefined) ?? (d.username as string | undefined) ?? null,
670 };
671 }
672
673 // Microsoft Graph uses id + mail / userPrincipalName
674 if (thirdPartyId === 'microsoft') {
675 const id = user.id != null ? String(user.id) : null;
676 if (!id) return null;
677 const email =
678 (user.mail as string | undefined) ??
679 (user.userPrincipalName as string | undefined) ??
680 null;
681 return {
682 thirdPartyId,
683 thirdPartyUserId: id,
684 email,
685 emailVerified: Boolean(email),
686 name: (user.displayName as string | undefined) ?? null,
687 };
688 }
689
690 // LinkedIn OIDC userinfo
691 if (thirdPartyId === 'linkedin') {
692 const id = (user.sub as string | undefined) ?? null;
693 if (!id) return null;
694 return {
695 thirdPartyId,
696 thirdPartyUserId: id,
697 email: (user.email as string | undefined) ?? null,
698 emailVerified: user.email_verified === true,
699 name: (user.name as string | undefined) ?? null,
700 };
701 }
702
703 // Google / Discord / Facebook / GitLab / Spotify / GitHub / Konnos common shapes
704 const id =
705 user.sub != null
706 ? String(user.sub)
707 : user.id != null
708 ? String(user.id)
709 : null;
710 if (!id) return null;
711 const email =
712 typeof user.email === 'string'
713 ? user.email
714 : null;
715 const name =
716 (typeof user.name === 'string' && user.name) ||
717 (typeof user.login === 'string' && user.login) ||
718 (typeof user.username === 'string' && user.username) ||
719 (typeof user.global_name === 'string' && user.global_name) ||
720 null;
721
722 return {
723 thirdPartyId,
724 thirdPartyUserId: id,
725 email,
726 emailVerified:
727 user.email_verified === true ||
728 user.verified === true ||
729 Boolean(email),
730 name,
731 };
732}
733
734export type SignInUpResult =
735 | {
736 status: 'OK';
737 createdNewUser: boolean;
738 user: {
739 id: string;
740 email: string | null;
741 tenantId: string;
742 thirdPartyId: SupportedSocial;
743 thirdPartyUserId: string;
744 };
745 session: {
746 handle: string;
747 userId: string;
748 accessToken: string;
749 refreshToken: string;
750 };
751 }
752 | { status: 'ERROR'; message: string };
753
754/**
755 * Upsert user + third-party link on Doltgres, create session.
756 */
757export async function signInUpWithThirdPartyProfile(input: {
758 profile: OAuthProfile;
759 projectId?: string;
760 tenantId?: string;
761}): Promise<SignInUpResult> {
762 const tenantId =
763 input.tenantId ??
764 (input.projectId ? projectIdToTenantId(input.projectId) : 'public');
765 await ensureTenant(tenantId, input.projectId);
766
767 const pool = getEnginePool();
768 const { thirdPartyId, thirdPartyUserId } = input.profile;
769 const email = input.profile.email?.trim().toLowerCase() ?? null;
770
771 const link = await pool.query(
772 `SELECT user_id FROM be_third_party_links
773 WHERE tenant_id = $1 AND third_party_id = $2 AND third_party_user_id = $3
774 LIMIT 1`,
775 [tenantId, thirdPartyId, thirdPartyUserId],
776 );
777
778 let userId: string;
779 let createdNewUser = false;
780
781 if (link.rows[0]) {
782 userId = (link.rows[0] as { user_id: string }).user_id;
783 } else {
784 if (email) {
785 const byEmail = await pool.query(
786 `SELECT id FROM be_users WHERE tenant_id = $1 AND email = $2 LIMIT 1`,
787 [tenantId, email],
788 );
789 if (byEmail.rows[0]) {
790 userId = (byEmail.rows[0] as { id: string }).id;
791 } else {
792 userId = newId('beu');
793 createdNewUser = true;
794 await pool.query(
795 `INSERT INTO be_users (id, tenant_id, email, email_verified)
796 VALUES ($1, $2, $3, $4)`,
797 [userId, tenantId, email, input.profile.emailVerified],
798 );
799 }
800 } else {
801 userId = newId('beu');
802 createdNewUser = true;
803 await pool.query(
804 `INSERT INTO be_users (id, tenant_id, email, email_verified)
805 VALUES ($1, $2, NULL, FALSE)`,
806 [userId, tenantId],
807 );
808 }
809
810 await pool.query(
811 `INSERT INTO be_third_party_links
812 (id, user_id, tenant_id, third_party_id, third_party_user_id)
813 VALUES ($1, $2, $3, $4, $5)`,
814 [newId('btp'), userId, tenantId, thirdPartyId, thirdPartyUserId],
815 );
816 }
817
818 const session = await createEngineSession({ userId, tenantId });
819
820 log.info('briven_engine_thirdparty_signin', {
821 engine: 'briven-engine',
822 storage: 'doltgres',
823 thirdPartyId,
824 createdNewUser,
825 tenantId,
826 });
827
828 const { recordBrivenEngineAudit } = await import('./audit.js');
829 void recordBrivenEngineAudit({
830 action: 'signin.social',
831 tenantId,
832 projectId: input.projectId,
833 userId,
834 metadata: {
835 thirdPartyId,
836 createdNewUser,
837 email: email ?? null,
838 },
839 });
840
841 return {
842 status: 'OK',
843 createdNewUser,
844 user: {
845 id: userId,
846 email,
847 tenantId,
848 thirdPartyId,
849 thirdPartyUserId,
850 },
851 session: {
852 handle: session.sessionHandle,
853 userId: session.userId,
854 accessToken: session.accessToken,
855 refreshToken: session.refreshToken,
856 },
857 };
858}
859
860/**
861 * Full OAuth code path: exchange + sign-in/up.
862 */
863export async function signInUpWithCode(input: {
864 thirdPartyId: SupportedSocial;
865 code: string;
866 redirectURI: string;
867 projectId?: string;
868 state?: string;
869}): Promise<SignInUpResult> {
870 const exchanged = await exchangeCodeForProfile(input);
871 if (exchanged.status !== 'OK') {
872 return { status: 'ERROR', message: exchanged.message };
873 }
874 return signInUpWithThirdPartyProfile({
875 profile: exchanged.profile,
876 projectId: exchanged.projectId ?? input.projectId,
877 });
878}
879
880/** @internal test helper */
881export function __oauthStateSizeForTests(): number {
882 cleanState();
883 return OAUTH_STATE.size;
884}
885
886export function listSupportedSocialProviders(): SupportedSocial[] {
887 return [...ALL_SOCIAL];
888}
889
890// keep env referenced for future Apple .p8 JWT minting
891void env;
892void createSign;