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