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 | |
| 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 | /** |
| 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 | */ |
| 37 | function 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 | |
| 49 | type 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 | |
| 66 | const 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 | |
| 165 | const ALL_SOCIAL = Object.keys(OAUTH_ENDPOINTS) as SupportedSocial[]; |
| 166 | |
| 167 | /** In-memory fallback when Redis is down (single-node only). Prefer Redis. */ |
| 168 | const 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 | |
| 179 | const OAUTH_STATE_TTL_MS = 15 * 60 * 1000; |
| 180 | const OAUTH_STATE_REDIS_PREFIX = 'oauth:st:'; |
| 181 | |
| 182 | type OauthStateValue = { |
| 183 | projectId: string; |
| 184 | thirdPartyId: SupportedSocial; |
| 185 | createdAt: number; |
| 186 | codeVerifier?: string; |
| 187 | }; |
| 188 | |
| 189 | function 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 | |
| 196 | async 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 | |
| 215 | async 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 | |
| 240 | function isSupported(id: string): id is SupportedSocial { |
| 241 | return ALL_SOCIAL.includes(id as SupportedSocial); |
| 242 | } |
| 243 | |
| 244 | async 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 | */ |
| 272 | export 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 | |
| 297 | export 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 | */ |
| 310 | export 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 | |
| 380 | export 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 | */ |
| 391 | export 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 | |
| 644 | function 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 | |
| 655 | function 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 | |
| 734 | export 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 | */ |
| 757 | export 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 | */ |
| 863 | export 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 */ |
| 881 | export function __oauthStateSizeForTests(): number { |
| 882 | cleanState(); |
| 883 | return OAUTH_STATE.size; |
| 884 | } |
| 885 | |
| 886 | export function listSupportedSocialProviders(): SupportedSocial[] { |
| 887 | return [...ALL_SOCIAL]; |
| 888 | } |
| 889 | |
| 890 | // keep env referenced for future Apple .p8 JWT minting |
| 891 | void env; |
| 892 | void createSign; |