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 | |
| 14 | import { createSign, randomBytes } from 'node:crypto'; |
| 15 | |
| 16 | import { newId } from '@briven/shared'; |
| 17 | |
| 18 | import { env } from '../../env.js'; |
| 19 | import { log } from '../../lib/logger.js'; |
| 20 | import { getEnginePool } from './db.js'; |
| 21 | import { createEngineSession } from './native-session.js'; |
| 22 | import { projectIdToTenantId } from './project-map.js'; |
| 23 | import { |
| 24 | loadProjectProviderSecrets, |
| 25 | } from './project-config.js'; |
| 26 | import type { BrivenSocialProviderId } from './providers.js'; |
| 27 | |
| 28 | /** All catalog providers that can run OAuth login when secrets are set. */ |
| 29 | export type SupportedSocial = BrivenSocialProviderId; |
| 30 | |
| 31 | type 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 | |
| 48 | const 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 | |
| 146 | const ALL_SOCIAL = Object.keys(OAUTH_ENDPOINTS) as SupportedSocial[]; |
| 147 | |
| 148 | const 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 | |
| 159 | function 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 | |
| 166 | function isSupported(id: string): id is SupportedSocial { |
| 167 | return ALL_SOCIAL.includes(id as SupportedSocial); |
| 168 | } |
| 169 | |
| 170 | async 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 | */ |
| 187 | export 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 | |
| 238 | export 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 | */ |
| 251 | export 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 | |
| 317 | export 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 | */ |
| 328 | export 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 | |
| 582 | function 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 | |
| 593 | function 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 | |
| 672 | export 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 | */ |
| 695 | export 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 | */ |
| 801 | export 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 */ |
| 819 | export function __oauthStateSizeForTests(): number { |
| 820 | cleanState(); |
| 821 | return OAUTH_STATE.size; |
| 822 | } |
| 823 | |
| 824 | export function listSupportedSocialProviders(): SupportedSocial[] { |
| 825 | return [...ALL_SOCIAL]; |
| 826 | } |
| 827 | |
| 828 | // keep env referenced for future Apple .p8 JWT minting |
| 829 | void env; |
| 830 | void createSign; |