CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
sso.ts
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| edf7c36 | 1 | /** |
| 2 | * Block I10 — Enterprise SSO via OpenID Connect. | |
| 3 | * | |
| 4 | * We chose OIDC over SAML because every modern IdP (Okta, Azure AD, Auth0, | |
| 5 | * Google Workspace, Keycloak, Okta-on-prem) speaks OIDC natively, and OIDC | |
| 6 | * only requires HTTP JSON / redirect flows — no XML signature verification. | |
| 7 | * | |
| 8 | * Flow: | |
| 9 | * 1. User clicks "Sign in with SSO" → GET /login/sso | |
| 10 | * 2. We redirect to the IdP's `authorization_endpoint` with a `state` + | |
| 11 | * `nonce` cookie-bound to the browser session. | |
| 12 | * 3. IdP sends the user back to /login/sso/callback?code=...&state=... | |
| 13 | * 4. We exchange the code for an access_token + id_token at | |
| 14 | * `token_endpoint`, then hit `userinfo_endpoint` to fetch the claims. | |
| 15 | * 5. Find (or auto-create, if enabled) a local user by `sub`, create a | |
| 16 | * session cookie, and redirect home. | |
| 17 | * | |
| 18 | * Admin configures the provider at /admin/sso. There is a single site-wide | |
| 19 | * provider identified by `id = 'default'`; we don't do multi-tenant IdP. | |
| 20 | */ | |
| 21 | ||
| 22 | import { eq } from "drizzle-orm"; | |
| 23 | import { db } from "../db"; | |
| 24 | import { | |
| 25 | ssoConfig, | |
| 26 | ssoUserLinks, | |
| 27 | users, | |
| 28 | sessions, | |
| 29 | type SsoConfig, | |
| 30 | type SsoUserLink, | |
| 31 | type User, | |
| 32 | } from "../db/schema"; | |
| 33 | import { | |
| 34 | generateSessionToken, | |
| 35 | sessionExpiry, | |
| 36 | } from "./auth"; | |
| 37 | import { config } from "./config"; | |
| 38 | ||
| 39 | // ---------------------------------------------------------------------------- | |
| 40 | // Types | |
| 41 | // ---------------------------------------------------------------------------- | |
| 42 | ||
| 43 | export interface SsoConfigInput { | |
| 44 | enabled: boolean; | |
| 45 | providerName: string; | |
| 46 | issuer: string; | |
| 47 | authorizationEndpoint: string; | |
| 48 | tokenEndpoint: string; | |
| 49 | userinfoEndpoint: string; | |
| 50 | clientId: string; | |
| 51 | clientSecret: string; | |
| 52 | scopes: string; | |
| 53 | allowedEmailDomains: string | null; | |
| 54 | autoCreateUsers: boolean; | |
| 55 | } | |
| 56 | ||
| 57 | export interface OidcClaims { | |
| 58 | sub: string; | |
| 59 | email?: string; | |
| 60 | email_verified?: boolean; | |
| 61 | name?: string; | |
| 62 | preferred_username?: string; | |
| 63 | given_name?: string; | |
| 64 | family_name?: string; | |
| 65 | } | |
| 66 | ||
| 67 | export interface TokenResponse { | |
| 68 | access_token: string; | |
| 69 | id_token?: string; | |
| 70 | token_type?: string; | |
| 71 | expires_in?: number; | |
| 72 | refresh_token?: string; | |
| 73 | scope?: string; | |
| 74 | } | |
| 75 | ||
| 76 | // ---------------------------------------------------------------------------- | |
| 77 | // Config CRUD | |
| 78 | // ---------------------------------------------------------------------------- | |
| 79 | ||
| 80 | const SSO_CONFIG_ID = "default"; | |
| 81 | ||
| 82 | /** Returns the singleton SSO config, or null if never configured. */ | |
| 83 | export async function getSsoConfig(): Promise<SsoConfig | null> { | |
| 84 | try { | |
| 85 | const [row] = await db | |
| 86 | .select() | |
| 87 | .from(ssoConfig) | |
| 88 | .where(eq(ssoConfig.id, SSO_CONFIG_ID)) | |
| 89 | .limit(1); | |
| 90 | return row || null; | |
| 91 | } catch { | |
| 92 | return null; | |
| 93 | } | |
| 94 | } | |
| 95 | ||
| 96 | /** Upsert config. Empty strings become nulls so partial configs are visible. */ | |
| 97 | export async function upsertSsoConfig( | |
| 98 | input: Partial<SsoConfigInput> | |
| 99 | ): Promise<{ ok: true } | { ok: false; error: string }> { | |
| 100 | try { | |
| 101 | const now = new Date(); | |
| 102 | const values = { | |
| 103 | id: SSO_CONFIG_ID, | |
| 104 | enabled: !!input.enabled, | |
| 105 | providerName: (input.providerName || "SSO").slice(0, 120), | |
| 106 | issuer: emptyToNull(input.issuer), | |
| 107 | authorizationEndpoint: emptyToNull(input.authorizationEndpoint), | |
| 108 | tokenEndpoint: emptyToNull(input.tokenEndpoint), | |
| 109 | userinfoEndpoint: emptyToNull(input.userinfoEndpoint), | |
| 110 | clientId: emptyToNull(input.clientId), | |
| 111 | clientSecret: emptyToNull(input.clientSecret), | |
| 112 | scopes: (input.scopes || "openid profile email").slice(0, 256), | |
| 113 | allowedEmailDomains: emptyToNull(input.allowedEmailDomains), | |
| 114 | autoCreateUsers: input.autoCreateUsers !== false, | |
| 115 | updatedAt: now, | |
| 116 | }; | |
| 117 | await db | |
| 118 | .insert(ssoConfig) | |
| 119 | .values(values) | |
| 120 | .onConflictDoUpdate({ | |
| 121 | target: ssoConfig.id, | |
| 122 | set: { | |
| 123 | enabled: values.enabled, | |
| 124 | providerName: values.providerName, | |
| 125 | issuer: values.issuer, | |
| 126 | authorizationEndpoint: values.authorizationEndpoint, | |
| 127 | tokenEndpoint: values.tokenEndpoint, | |
| 128 | userinfoEndpoint: values.userinfoEndpoint, | |
| 129 | clientId: values.clientId, | |
| 130 | clientSecret: values.clientSecret, | |
| 131 | scopes: values.scopes, | |
| 132 | allowedEmailDomains: values.allowedEmailDomains, | |
| 133 | autoCreateUsers: values.autoCreateUsers, | |
| 134 | updatedAt: values.updatedAt, | |
| 135 | }, | |
| 136 | }); | |
| 137 | return { ok: true }; | |
| 138 | } catch (err) { | |
| 139 | return { | |
| 140 | ok: false, | |
| 141 | error: err instanceof Error ? err.message : "Failed to save config", | |
| 142 | }; | |
| 143 | } | |
| 144 | } | |
| 145 | ||
| 146 | function emptyToNull(v: string | null | undefined): string | null { | |
| 147 | if (v == null) return null; | |
| 148 | const s = String(v).trim(); | |
| 149 | return s.length === 0 ? null : s; | |
| 150 | } | |
| 151 | ||
| 152 | // ---------------------------------------------------------------------------- | |
| 153 | // OIDC flow helpers (pure, no DB) | |
| 154 | // ---------------------------------------------------------------------------- | |
| 155 | ||
| 156 | /** | |
| 157 | * Build the authorization-endpoint URL the browser should be redirected to. | |
| 158 | * Adds client_id, redirect_uri, response_type=code, scope, state, nonce. | |
| 159 | */ | |
| 160 | export function buildAuthorizeUrl( | |
| 161 | cfg: Pick<SsoConfig, "authorizationEndpoint" | "clientId" | "scopes">, | |
| 162 | state: string, | |
| 163 | nonce: string, | |
| 164 | redirectUri: string | |
| 165 | ): string { | |
| 166 | if (!cfg.authorizationEndpoint || !cfg.clientId) { | |
| 167 | throw new Error("SSO config missing authorization_endpoint or client_id"); | |
| 168 | } | |
| 169 | const u = new URL(cfg.authorizationEndpoint); | |
| 170 | u.searchParams.set("client_id", cfg.clientId); | |
| 171 | u.searchParams.set("redirect_uri", redirectUri); | |
| 172 | u.searchParams.set("response_type", "code"); | |
| 173 | u.searchParams.set("scope", cfg.scopes || "openid profile email"); | |
| 174 | u.searchParams.set("state", state); | |
| 175 | u.searchParams.set("nonce", nonce); | |
| 176 | return u.toString(); | |
| 177 | } | |
| 178 | ||
| 179 | /** Crypto-random hex string for state + nonce + link-subject-collision retries. */ | |
| 180 | export function randomToken(bytes = 16): string { | |
| 181 | const arr = crypto.getRandomValues(new Uint8Array(bytes)); | |
| 182 | return Array.from(arr) | |
| 183 | .map((b) => b.toString(16).padStart(2, "0")) | |
| 184 | .join(""); | |
| 185 | } | |
| 186 | ||
| 187 | /** | |
| 188 | * Exchange the authorization code for tokens. IdP is trusted; we don't | |
| 189 | * verify the id_token signature here because we immediately turn around | |
| 190 | * and hit userinfo over HTTPS with the access_token, which has the same | |
| 191 | * integrity guarantee. | |
| 192 | */ | |
| 193 | export async function exchangeCode( | |
| 194 | cfg: Pick<SsoConfig, "tokenEndpoint" | "clientId" | "clientSecret">, | |
| 195 | code: string, | |
| 196 | redirectUri: string | |
| 197 | ): Promise<TokenResponse> { | |
| 198 | if (!cfg.tokenEndpoint || !cfg.clientId || !cfg.clientSecret) { | |
| 199 | throw new Error("SSO config missing token_endpoint or client credentials"); | |
| 200 | } | |
| 201 | const body = new URLSearchParams({ | |
| 202 | grant_type: "authorization_code", | |
| 203 | code, | |
| 204 | redirect_uri: redirectUri, | |
| 205 | client_id: cfg.clientId, | |
| 206 | client_secret: cfg.clientSecret, | |
| 207 | }); | |
| 208 | const res = await fetch(cfg.tokenEndpoint, { | |
| 209 | method: "POST", | |
| 210 | headers: { | |
| 211 | "content-type": "application/x-www-form-urlencoded", | |
| 212 | accept: "application/json", | |
| 213 | }, | |
| 214 | body: body.toString(), | |
| 215 | }); | |
| 216 | if (!res.ok) { | |
| 217 | const text = await res.text().catch(() => ""); | |
| 218 | throw new Error( | |
| 219 | `token_endpoint ${res.status}: ${text.slice(0, 200) || "no body"}` | |
| 220 | ); | |
| 221 | } | |
| 222 | const json = (await res.json()) as TokenResponse; | |
| 223 | if (!json.access_token) { | |
| 224 | throw new Error("token_endpoint response missing access_token"); | |
| 225 | } | |
| 226 | return json; | |
| 227 | } | |
| 228 | ||
| 229 | /** Fetch userinfo claims using the access_token. */ | |
| 230 | export async function fetchUserinfo( | |
| 231 | cfg: Pick<SsoConfig, "userinfoEndpoint">, | |
| 232 | accessToken: string | |
| 233 | ): Promise<OidcClaims> { | |
| 234 | if (!cfg.userinfoEndpoint) { | |
| 235 | throw new Error("SSO config missing userinfo_endpoint"); | |
| 236 | } | |
| 237 | const res = await fetch(cfg.userinfoEndpoint, { | |
| 238 | headers: { | |
| 239 | authorization: `Bearer ${accessToken}`, | |
| 240 | accept: "application/json", | |
| 241 | }, | |
| 242 | }); | |
| 243 | if (!res.ok) { | |
| 244 | const text = await res.text().catch(() => ""); | |
| 245 | throw new Error( | |
| 246 | `userinfo_endpoint ${res.status}: ${text.slice(0, 200) || "no body"}` | |
| 247 | ); | |
| 248 | } | |
| 249 | const claims = (await res.json()) as OidcClaims; | |
| 250 | if (!claims.sub) { | |
| 251 | throw new Error("userinfo response missing sub claim"); | |
| 252 | } | |
| 253 | return claims; | |
| 254 | } | |
| 255 | ||
| 256 | /** | |
| 257 | * Check whether the given email is allowed by the admin's domain restriction. | |
| 258 | * `allowed` is a comma-separated list of domains (e.g. "example.com,acme.io"). | |
| 259 | * null or empty = allow any. | |
| 260 | */ | |
| 261 | export function emailDomainAllowed( | |
| 262 | email: string | undefined | null, | |
| 263 | allowed: string | null | undefined | |
| 264 | ): boolean { | |
| 265 | if (!allowed || !allowed.trim()) return true; | |
| 266 | if (!email) return false; | |
| 267 | const domain = email.split("@")[1]?.toLowerCase().trim(); | |
| 268 | if (!domain) return false; | |
| 269 | const list = allowed | |
| 270 | .split(",") | |
| 271 | .map((d) => d.trim().toLowerCase()) | |
| 272 | .filter(Boolean); | |
| 273 | return list.includes(domain); | |
| 274 | } | |
| 275 | ||
| 276 | // ---------------------------------------------------------------------------- | |
| 277 | // User linkage + provisioning | |
| 278 | // ---------------------------------------------------------------------------- | |
| 279 | ||
| 280 | export async function findSsoLinkBySubject( | |
| 281 | subject: string | |
| 282 | ): Promise<SsoUserLink | null> { | |
| 283 | try { | |
| 284 | const [row] = await db | |
| 285 | .select() | |
| 286 | .from(ssoUserLinks) | |
| 287 | .where(eq(ssoUserLinks.subject, subject)) | |
| 288 | .limit(1); | |
| 289 | return row || null; | |
| 290 | } catch { | |
| 291 | return null; | |
| 292 | } | |
| 293 | } | |
| 294 | ||
| 295 | /** | |
| 296 | * Given OIDC claims, find the linked local user, or auto-create one when | |
| 297 | * the admin has enabled `autoCreateUsers`. Returns the User row, or null if | |
| 298 | * no match and auto-creation is off. | |
| 299 | * | |
| 300 | * This also creates an `sso_user_links` row on first sign-in so subsequent | |
| 301 | * logins short-circuit on the `sub` lookup. | |
| 302 | */ | |
| 303 | export async function findOrCreateUserFromSso( | |
| 304 | claims: OidcClaims, | |
| 305 | cfg: SsoConfig | |
| 306 | ): Promise< | |
| 307 | | { ok: true; user: User } | |
| 308 | | { ok: false; error: string } | |
| 309 | > { | |
| 310 | // 1. Existing link by subject | |
| 311 | const link = await findSsoLinkBySubject(claims.sub); | |
| 312 | if (link) { | |
| 313 | const [user] = await db | |
| 314 | .select() | |
| 315 | .from(users) | |
| 316 | .where(eq(users.id, link.userId)) | |
| 317 | .limit(1); | |
| 318 | if (user) return { ok: true, user }; | |
| 319 | // Orphaned link (user deleted) — drop it and fall through. | |
| 320 | await db | |
| 321 | .delete(ssoUserLinks) | |
| 322 | .where(eq(ssoUserLinks.subject, claims.sub)) | |
| a28cede | 323 | .catch((err) => { |
| 324 | console.warn( | |
| 325 | "[sso] stale link cleanup failed:", | |
| 326 | err instanceof Error ? err.message : err | |
| 327 | ); | |
| 328 | }); | |
| edf7c36 | 329 | } |
| 330 | ||
| 331 | // 2. Domain gate | |
| 332 | if (!emailDomainAllowed(claims.email, cfg.allowedEmailDomains)) { | |
| 333 | return { | |
| 334 | ok: false, | |
| 335 | error: "Your email domain is not permitted for SSO sign-in.", | |
| 336 | }; | |
| 337 | } | |
| 338 | ||
| 339 | // 3. Match by email when present | |
| 340 | if (claims.email) { | |
| 341 | const [existing] = await db | |
| 342 | .select() | |
| 343 | .from(users) | |
| 344 | .where(eq(users.email, claims.email)) | |
| 345 | .limit(1); | |
| 346 | if (existing) { | |
| 347 | await db | |
| 348 | .insert(ssoUserLinks) | |
| 349 | .values({ | |
| 350 | userId: existing.id, | |
| 351 | subject: claims.sub, | |
| 352 | emailAtLink: claims.email, | |
| 353 | }) | |
| 354 | .onConflictDoNothing(); | |
| 355 | return { ok: true, user: existing }; | |
| 356 | } | |
| 357 | } | |
| 358 | ||
| 359 | // 4. Auto-create | |
| 360 | if (!cfg.autoCreateUsers) { | |
| 361 | return { | |
| 362 | ok: false, | |
| 363 | error: | |
| 364 | "No matching account, and the administrator has disabled SSO account creation.", | |
| 365 | }; | |
| 366 | } | |
| 367 | ||
| 368 | const email = claims.email; | |
| 369 | if (!email) { | |
| 370 | return { | |
| 371 | ok: false, | |
| 372 | error: "SSO provider did not return an email claim.", | |
| 373 | }; | |
| 374 | } | |
| 375 | ||
| 376 | const username = await pickAvailableUsername( | |
| 377 | claims.preferred_username || claims.name || email.split("@")[0] || "user" | |
| 378 | ); | |
| 379 | ||
| 380 | // SSO users don't have a local password — store a random unusable hash. | |
| 381 | // The login form requires a password match against bcrypt so random bytes | |
| 382 | // here mean the account is SSO-only unless they set a password later. | |
| 383 | const fakeHash = "sso-only:" + randomToken(32); | |
| 384 | ||
| 385 | const [user] = await db | |
| 386 | .insert(users) | |
| 387 | .values({ | |
| 388 | username, | |
| 389 | email, | |
| 390 | passwordHash: fakeHash, | |
| 391 | }) | |
| 392 | .returning(); | |
| 393 | ||
| 394 | await db | |
| 395 | .insert(ssoUserLinks) | |
| 396 | .values({ | |
| 397 | userId: user.id, | |
| 398 | subject: claims.sub, | |
| 399 | emailAtLink: email, | |
| 400 | }) | |
| 401 | .onConflictDoNothing(); | |
| 402 | ||
| 403 | return { ok: true, user }; | |
| 404 | } | |
| 405 | ||
| 406 | /** Normalize an IdP-provided name into a valid gluecron username. */ | |
| 407 | export function normalizeUsername(raw: string): string { | |
| 408 | const base = raw | |
| 409 | .toLowerCase() | |
| 410 | .replace(/[^a-z0-9_-]+/g, "-") | |
| 411 | .replace(/^-+|-+$/g, "") | |
| 412 | .slice(0, 32); | |
| 413 | return base || "user"; | |
| 414 | } | |
| 415 | ||
| 416 | /** Pick a username not already taken. Appends a random suffix on collision. */ | |
| 417 | async function pickAvailableUsername(raw: string): Promise<string> { | |
| 418 | const base = normalizeUsername(raw); | |
| 419 | for (let i = 0; i < 5; i++) { | |
| 420 | const candidate = i === 0 ? base : `${base}-${randomToken(3)}`; | |
| 421 | try { | |
| 422 | const [row] = await db | |
| 423 | .select({ id: users.id }) | |
| 424 | .from(users) | |
| 425 | .where(eq(users.username, candidate)) | |
| 426 | .limit(1); | |
| 427 | if (!row) return candidate; | |
| 428 | } catch { | |
| 429 | return `${base}-${randomToken(3)}`; | |
| 430 | } | |
| 431 | } | |
| 432 | return `${base}-${randomToken(4)}`; | |
| 433 | } | |
| 434 | ||
| 435 | /** Issue a session cookie token for a user. Caller sets the cookie. */ | |
| 436 | export async function issueSsoSession(userId: string): Promise<string> { | |
| 437 | const token = generateSessionToken(); | |
| 438 | await db.insert(sessions).values({ | |
| 439 | userId, | |
| 440 | token, | |
| 441 | expiresAt: sessionExpiry(), | |
| 442 | }); | |
| 443 | return token; | |
| 444 | } | |
| 445 | ||
| 446 | /** Compute the fully-qualified OIDC redirect URI for this deployment. */ | |
| 447 | export function ssoRedirectUri(): string { | |
| 448 | return `${config.appBaseUrl}/login/sso/callback`; | |
| 449 | } | |
| 450 | ||
| 46d6165 | 451 | // ---------------------------------------------------------------------------- |
| 452 | // Block L6 — GitHub OAuth sign-in (one-click) | |
| 453 | // | |
| 454 | // GitHub is OAuth 2.0, not OIDC: no id_token, no /userinfo, no nonce. | |
| 455 | // We reuse the `sso_config` schema as a row-keyed store (id='github') | |
| 456 | // alongside the enterprise IdP (id='default'). The actual network shape | |
| 457 | // is implemented in src/lib/github-oauth.ts; this section adds: | |
| 458 | // - a getter for the github-specific config row | |
| 459 | // - a Github-specific upsert that defaults to GitHub endpoints | |
| 460 | // - findOrCreateUserFromGithub() that prefixes the subject with "github:" | |
| 461 | // so we never collide with id='default' subjects. | |
| 462 | // ---------------------------------------------------------------------------- | |
| 463 | ||
| 464 | const GITHUB_OAUTH_CONFIG_ID = "github"; | |
| 465 | ||
| 466 | /** Returns the GitHub-OAuth singleton config row, or null if never seeded. */ | |
| 467 | export async function getGithubOauthConfig(): Promise<SsoConfig | null> { | |
| 468 | try { | |
| 469 | const [row] = await db | |
| 470 | .select() | |
| 471 | .from(ssoConfig) | |
| 472 | .where(eq(ssoConfig.id, GITHUB_OAUTH_CONFIG_ID)) | |
| 473 | .limit(1); | |
| 474 | return row || null; | |
| 475 | } catch { | |
| 476 | return null; | |
| 477 | } | |
| 478 | } | |
| 479 | ||
| 480 | /** | |
| 481 | * Upsert GitHub OAuth credentials. Same row-shape as `upsertSsoConfig` but | |
| 482 | * defaults the URLs to github.com endpoints so admins only have to paste | |
| 483 | * Client ID + Secret. | |
| 484 | */ | |
| 485 | export async function upsertGithubOauthConfig( | |
| 486 | input: Partial<Pick<SsoConfigInput, "enabled" | "clientId" | "clientSecret" | "autoCreateUsers" | "allowedEmailDomains">> | |
| 487 | ): Promise<{ ok: true } | { ok: false; error: string }> { | |
| 488 | try { | |
| 489 | const now = new Date(); | |
| 490 | const values = { | |
| 491 | id: GITHUB_OAUTH_CONFIG_ID, | |
| 492 | enabled: !!input.enabled, | |
| 493 | providerName: "GitHub", | |
| 494 | issuer: "https://github.com", | |
| 495 | authorizationEndpoint: "https://github.com/login/oauth/authorize", | |
| 496 | tokenEndpoint: "https://github.com/login/oauth/access_token", | |
| 497 | userinfoEndpoint: "https://api.github.com/user", | |
| 498 | clientId: emptyToNull(input.clientId), | |
| 499 | clientSecret: emptyToNull(input.clientSecret), | |
| 500 | scopes: "read:user user:email", | |
| 501 | allowedEmailDomains: emptyToNull(input.allowedEmailDomains ?? null), | |
| 502 | autoCreateUsers: input.autoCreateUsers !== false, | |
| 503 | updatedAt: now, | |
| 504 | }; | |
| 505 | await db | |
| 506 | .insert(ssoConfig) | |
| 507 | .values(values) | |
| 508 | .onConflictDoUpdate({ | |
| 509 | target: ssoConfig.id, | |
| 510 | set: { | |
| 511 | enabled: values.enabled, | |
| 512 | providerName: values.providerName, | |
| 513 | issuer: values.issuer, | |
| 514 | authorizationEndpoint: values.authorizationEndpoint, | |
| 515 | tokenEndpoint: values.tokenEndpoint, | |
| 516 | userinfoEndpoint: values.userinfoEndpoint, | |
| 517 | clientId: values.clientId, | |
| 518 | clientSecret: values.clientSecret, | |
| 519 | scopes: values.scopes, | |
| 520 | allowedEmailDomains: values.allowedEmailDomains, | |
| 521 | autoCreateUsers: values.autoCreateUsers, | |
| 522 | updatedAt: values.updatedAt, | |
| 523 | }, | |
| 524 | }); | |
| 525 | return { ok: true }; | |
| 526 | } catch (err) { | |
| 527 | return { | |
| 528 | ok: false, | |
| 529 | error: err instanceof Error ? err.message : "Failed to save config", | |
| 530 | }; | |
| 531 | } | |
| 532 | } | |
| 533 | ||
| 534 | /** Shape we receive after the GitHub network round-trip. */ | |
| 535 | export interface GithubProfile { | |
| 536 | id: number; | |
| 537 | login: string; | |
| 538 | name: string | null; | |
| 539 | email: string | null; | |
| 540 | avatarUrl: string | null; | |
| 541 | } | |
| 542 | ||
| 543 | /** | |
| 544 | * Like findOrCreateUserFromSso, but specialised for the GitHub flow. | |
| 545 | * | |
| 546 | * Differences from the OIDC version: | |
| 547 | * - subject is prefixed with "github:" so we run multiple IdPs alongside | |
| 548 | * each other without ID collisions (the IdP `sub` namespace is global, | |
| 549 | * not per-provider). | |
| 550 | * - We use `login` as the username fallback and `name` for display. | |
| 551 | * - We still match by email when GitHub returns one; otherwise we | |
| 552 | * auto-create using the `login` as the username seed. | |
| 553 | * | |
| 554 | * Keeps `findOrCreateUserFromSso` totally untouched. | |
| 555 | */ | |
| 556 | export async function findOrCreateUserFromGithub( | |
| 557 | profile: GithubProfile, | |
| 558 | cfg: SsoConfig | |
| 559 | ): Promise< | |
| 560 | | { ok: true; user: User } | |
| 561 | | { ok: false; error: string } | |
| 562 | > { | |
| 563 | const subject = `github:${profile.id}`; | |
| 564 | ||
| 565 | // 1. Existing link | |
| 566 | const link = await findSsoLinkBySubject(subject); | |
| 567 | if (link) { | |
| 568 | const [user] = await db | |
| 569 | .select() | |
| 570 | .from(users) | |
| 571 | .where(eq(users.id, link.userId)) | |
| 572 | .limit(1); | |
| 573 | if (user) return { ok: true, user }; | |
| 574 | await db | |
| 575 | .delete(ssoUserLinks) | |
| 576 | .where(eq(ssoUserLinks.subject, subject)) | |
| a28cede | 577 | .catch((err) => { |
| 578 | console.warn( | |
| 579 | "[sso] orphan link cleanup failed:", | |
| 580 | err instanceof Error ? err.message : err | |
| 581 | ); | |
| 582 | }); | |
| 46d6165 | 583 | } |
| 584 | ||
| 585 | // 2. Domain gate (only meaningful if admin set one) | |
| 586 | if (!emailDomainAllowed(profile.email, cfg.allowedEmailDomains)) { | |
| 587 | return { | |
| 588 | ok: false, | |
| 589 | error: "Your email domain is not permitted for GitHub sign-in.", | |
| 590 | }; | |
| 591 | } | |
| 592 | ||
| 593 | // 3. Match by email when present | |
| 594 | if (profile.email) { | |
| 595 | const [existing] = await db | |
| 596 | .select() | |
| 597 | .from(users) | |
| 598 | .where(eq(users.email, profile.email)) | |
| 599 | .limit(1); | |
| 600 | if (existing) { | |
| 601 | await db | |
| 602 | .insert(ssoUserLinks) | |
| 603 | .values({ | |
| 604 | userId: existing.id, | |
| 605 | subject, | |
| 606 | emailAtLink: profile.email, | |
| 607 | }) | |
| 608 | .onConflictDoNothing(); | |
| 609 | return { ok: true, user: existing }; | |
| 610 | } | |
| 611 | } | |
| 612 | ||
| 613 | // 4. Auto-create | |
| 614 | if (!cfg.autoCreateUsers) { | |
| 615 | return { | |
| 616 | ok: false, | |
| 617 | error: | |
| 618 | "No matching account, and the administrator has disabled GitHub account creation.", | |
| 619 | }; | |
| 620 | } | |
| 621 | ||
| 622 | const email = profile.email; | |
| 623 | if (!email) { | |
| 624 | return { | |
| 625 | ok: false, | |
| 626 | error: | |
| 627 | "GitHub did not return a verified email. Mark a primary email as verified on github.com and try again.", | |
| 628 | }; | |
| 629 | } | |
| 630 | ||
| 631 | const usernameSeed = profile.login || profile.name || email.split("@")[0] || "user"; | |
| 632 | const username = await pickAvailableUsername(usernameSeed); | |
| 633 | ||
| 634 | const fakeHash = "sso-only:" + randomToken(32); | |
| 635 | ||
| 636 | const [user] = await db | |
| 637 | .insert(users) | |
| 638 | .values({ | |
| 639 | username, | |
| 640 | email, | |
| 641 | passwordHash: fakeHash, | |
| 642 | }) | |
| 643 | .returning(); | |
| 644 | ||
| 645 | await db | |
| 646 | .insert(ssoUserLinks) | |
| 647 | .values({ | |
| 648 | userId: user.id, | |
| 649 | subject, | |
| 650 | emailAtLink: email, | |
| 651 | }) | |
| 652 | .onConflictDoNothing(); | |
| 653 | ||
| 654 | return { ok: true, user }; | |
| 655 | } | |
| 656 | ||
| 657 | /** Compute the GitHub OAuth redirect URI for this deployment. */ | |
| 658 | export function githubOauthRedirectUri(): string { | |
| 659 | return `${config.appBaseUrl}/login/github/callback`; | |
| 660 | } | |
| 661 | ||
| 582cdac | 662 | // ---------------------------------------------------------------------------- |
| 663 | // "Sign in with Google" — mirrors the GitHub OAuth surface above. | |
| 664 | // | |
| 665 | // Storage: a separate row in `sso_config` keyed by id='google'. Subject in | |
| 666 | // `sso_user_links` is prefixed `google:` so it never collides with | |
| 667 | // `github:` or the default OIDC `default:`. | |
| 668 | // ---------------------------------------------------------------------------- | |
| 669 | ||
| 670 | const GOOGLE_OAUTH_CONFIG_ID = "google"; | |
| 671 | ||
| 27d5fd3 | 672 | /** |
| 673 | * Builds a Google OAuth config from environment variables, or null when the | |
| 674 | * required pair isn't set. This is the zero-DB bootstrap path: operators set | |
| 675 | * `GOOGLE_OAUTH_CLIENT_ID` + `GOOGLE_OAUTH_CLIENT_SECRET` (e.g. as Fly | |
| 676 | * secrets) and "Sign in with Google" turns on without anyone needing to | |
| 677 | * reach the /admin/google-oauth page first — which matters when the admin | |
| 678 | * is themselves locked out of password login. | |
| 679 | * | |
| 680 | * Optional knobs: | |
| 681 | * GOOGLE_OAUTH_AUTO_CREATE=0 — disable auto-creating accounts | |
| 682 | * GOOGLE_OAUTH_ALLOWED_DOMAINS=a,b — restrict sign-in to email domains | |
| 683 | * | |
| 684 | * Pure (env passed in) so it's unit-testable. | |
| 685 | */ | |
| 686 | export function googleOauthConfigFromEnv( | |
| 687 | env: Record<string, string | undefined> = process.env | |
| 688 | ): SsoConfig | null { | |
| 689 | const clientId = (env.GOOGLE_OAUTH_CLIENT_ID || "").trim(); | |
| 690 | const clientSecret = (env.GOOGLE_OAUTH_CLIENT_SECRET || "").trim(); | |
| 691 | if (!clientId || !clientSecret) return null; | |
| 692 | const now = new Date(); | |
| 693 | return { | |
| 694 | id: GOOGLE_OAUTH_CONFIG_ID, | |
| 695 | enabled: true, | |
| 696 | providerName: "Google", | |
| 697 | issuer: "https://accounts.google.com", | |
| 698 | authorizationEndpoint: "https://accounts.google.com/o/oauth2/v2/auth", | |
| 699 | tokenEndpoint: "https://oauth2.googleapis.com/token", | |
| 700 | userinfoEndpoint: "https://openidconnect.googleapis.com/v1/userinfo", | |
| 701 | clientId, | |
| 702 | clientSecret, | |
| 703 | scopes: "openid email profile", | |
| 704 | allowedEmailDomains: (env.GOOGLE_OAUTH_ALLOWED_DOMAINS || "").trim() || null, | |
| 705 | autoCreateUsers: env.GOOGLE_OAUTH_AUTO_CREATE !== "0", | |
| 706 | createdAt: now, | |
| 707 | updatedAt: now, | |
| 708 | }; | |
| 709 | } | |
| 710 | ||
| 711 | /** | |
| 9887dd0 | 712 | * Pure precedence resolver between the persisted `id='google'` row and the |
| 713 | * env-var bootstrap. Split out from `getGoogleOauthConfig` so the ordering is | |
| 714 | * unit-testable without a database. | |
| 715 | * | |
| 716 | * Order: | |
| 717 | * 1. An admin row that is BOTH enabled and fully credentialed is the | |
| 718 | * explicit operator choice — it wins outright (even over different env | |
| 719 | * credentials). | |
| 720 | * 2. Otherwise a complete env-var bootstrap (always enabled when present) | |
| 721 | * is the live config. Critically, it must NOT be shadowed by a | |
| 722 | * saved-but-disabled or half-filled DB row. That shadowing was a real | |
| 723 | * foot-gun: an earlier half-finished /admin/google-oauth save (e.g. | |
| 724 | * credentials entered but the Enable box left unticked) left a disabled | |
| 725 | * row behind, which then suppressed a perfectly good GOOGLE_OAUTH_* | |
| 726 | * bootstrap — so "Sign in with Google" stayed dark with a misleading | |
| 727 | * "not enabled". | |
| 728 | * 3. Otherwise fall back to whatever row exists (possibly disabled/partial, | |
| 729 | * possibly null). Callers still gate on `enabled` + credentials. | |
| 730 | */ | |
| 731 | export function resolveGoogleOauthConfig( | |
| 732 | row: SsoConfig | null, | |
| 733 | envCfg: SsoConfig | null | |
| 734 | ): SsoConfig | null { | |
| 735 | if (row?.enabled && row.clientId && row.clientSecret) return row; | |
| 736 | if (envCfg) return envCfg; | |
| 737 | return row; | |
| 738 | } | |
| 739 | ||
| 740 | /** | |
| 741 | * The live "Sign in with Google" config, merging the persisted admin row with | |
| 742 | * the env-var bootstrap via {@link resolveGoogleOauthConfig}. | |
| 27d5fd3 | 743 | */ |
| 582cdac | 744 | export async function getGoogleOauthConfig(): Promise<SsoConfig | null> { |
| 27d5fd3 | 745 | let row: SsoConfig | null = null; |
| 582cdac | 746 | try { |
| 27d5fd3 | 747 | const [r] = await db |
| 582cdac | 748 | .select() |
| 749 | .from(ssoConfig) | |
| 750 | .where(eq(ssoConfig.id, GOOGLE_OAUTH_CONFIG_ID)) | |
| 751 | .limit(1); | |
| 27d5fd3 | 752 | row = r || null; |
| 582cdac | 753 | } catch { |
| 27d5fd3 | 754 | row = null; |
| 582cdac | 755 | } |
| 9887dd0 | 756 | return resolveGoogleOauthConfig(row, googleOauthConfigFromEnv()); |
| 582cdac | 757 | } |
| 758 | ||
| 759 | export async function upsertGoogleOauthConfig( | |
| 760 | input: Partial< | |
| 761 | Pick< | |
| 762 | SsoConfigInput, | |
| 763 | "enabled" | "clientId" | "clientSecret" | "autoCreateUsers" | "allowedEmailDomains" | |
| 764 | > | |
| 765 | > | |
| 766 | ): Promise<{ ok: true } | { ok: false; error: string }> { | |
| 767 | try { | |
| 768 | const now = new Date(); | |
| 769 | const values = { | |
| 770 | id: GOOGLE_OAUTH_CONFIG_ID, | |
| 771 | enabled: !!input.enabled, | |
| 772 | providerName: "Google", | |
| 773 | issuer: "https://accounts.google.com", | |
| 774 | authorizationEndpoint: "https://accounts.google.com/o/oauth2/v2/auth", | |
| 775 | tokenEndpoint: "https://oauth2.googleapis.com/token", | |
| 776 | userinfoEndpoint: "https://openidconnect.googleapis.com/v1/userinfo", | |
| 777 | clientId: emptyToNull(input.clientId), | |
| 778 | clientSecret: emptyToNull(input.clientSecret), | |
| 779 | scopes: "openid email profile", | |
| 780 | allowedEmailDomains: emptyToNull(input.allowedEmailDomains ?? null), | |
| 781 | autoCreateUsers: input.autoCreateUsers !== false, | |
| 782 | updatedAt: now, | |
| 783 | }; | |
| 784 | await db | |
| 785 | .insert(ssoConfig) | |
| 786 | .values(values) | |
| 787 | .onConflictDoUpdate({ | |
| 788 | target: ssoConfig.id, | |
| 789 | set: { | |
| 790 | enabled: values.enabled, | |
| 791 | providerName: values.providerName, | |
| 792 | issuer: values.issuer, | |
| 793 | authorizationEndpoint: values.authorizationEndpoint, | |
| 794 | tokenEndpoint: values.tokenEndpoint, | |
| 795 | userinfoEndpoint: values.userinfoEndpoint, | |
| 796 | clientId: values.clientId, | |
| 797 | clientSecret: values.clientSecret, | |
| 798 | scopes: values.scopes, | |
| 799 | allowedEmailDomains: values.allowedEmailDomains, | |
| 800 | autoCreateUsers: values.autoCreateUsers, | |
| 801 | updatedAt: now, | |
| 802 | }, | |
| 803 | }); | |
| 804 | return { ok: true }; | |
| 805 | } catch (err) { | |
| 806 | return { | |
| 807 | ok: false, | |
| 808 | error: err instanceof Error ? err.message : "Failed to save", | |
| 809 | }; | |
| 810 | } | |
| 811 | } | |
| 812 | ||
| 813 | export interface GoogleProfile { | |
| 814 | sub: string; | |
| 815 | email: string | null; | |
| 816 | emailVerified: boolean; | |
| 817 | name: string | null; | |
| 818 | picture: string | null; | |
| 819 | } | |
| 820 | ||
| 821 | /** | |
| 822 | * Find-or-create a user from a Google profile. Same flow as the GitHub | |
| 823 | * variant: existing link → match by email → auto-create (if enabled and | |
| 824 | * email is verified). Refuses to auto-create on unverified emails (Google | |
| 825 | * users can have unverified addresses on some legacy account states). | |
| 826 | */ | |
| 827 | export async function findOrCreateUserFromGoogle( | |
| 828 | profile: GoogleProfile, | |
| 829 | cfg: SsoConfig | |
| 830 | ): Promise<{ ok: true; user: User } | { ok: false; error: string }> { | |
| 831 | const subject = `google:${profile.sub}`; | |
| 832 | ||
| 833 | // 1. Existing link | |
| 834 | const link = await findSsoLinkBySubject(subject); | |
| 835 | if (link) { | |
| 836 | const [user] = await db | |
| 837 | .select() | |
| 838 | .from(users) | |
| 839 | .where(eq(users.id, link.userId)) | |
| 840 | .limit(1); | |
| 841 | if (user) return { ok: true, user }; | |
| 842 | await db | |
| 843 | .delete(ssoUserLinks) | |
| 844 | .where(eq(ssoUserLinks.subject, subject)) | |
| 845 | .catch((err) => { | |
| 846 | console.warn( | |
| 847 | "[google-oauth] orphan link cleanup failed:", | |
| 848 | err instanceof Error ? err.message : err | |
| 849 | ); | |
| 850 | }); | |
| 851 | } | |
| 852 | ||
| 853 | // 2. Domain gate | |
| 854 | if (!emailDomainAllowed(profile.email, cfg.allowedEmailDomains)) { | |
| 855 | return { | |
| 856 | ok: false, | |
| 857 | error: "Your email domain is not permitted for Google sign-in.", | |
| 858 | }; | |
| 859 | } | |
| 860 | ||
| 861 | // 3. Match by email (only if Google says the email is verified) | |
| 862 | if (profile.email && profile.emailVerified) { | |
| 863 | const [existing] = await db | |
| 864 | .select() | |
| 865 | .from(users) | |
| 866 | .where(eq(users.email, profile.email)) | |
| 867 | .limit(1); | |
| 868 | if (existing) { | |
| 869 | await db | |
| 870 | .insert(ssoUserLinks) | |
| 871 | .values({ | |
| 872 | userId: existing.id, | |
| 873 | subject, | |
| 874 | emailAtLink: profile.email, | |
| 875 | }) | |
| 876 | .onConflictDoNothing(); | |
| 877 | return { ok: true, user: existing }; | |
| 878 | } | |
| 879 | } | |
| 880 | ||
| 881 | // 4. Auto-create | |
| 882 | if (!cfg.autoCreateUsers) { | |
| 883 | return { | |
| 884 | ok: false, | |
| 885 | error: | |
| 886 | "No matching account, and the administrator has disabled Google account creation.", | |
| 887 | }; | |
| 888 | } | |
| 889 | ||
| 890 | if (!profile.email) { | |
| 891 | return { | |
| 892 | ok: false, | |
| 893 | error: "Google did not return an email address. Try signing up manually.", | |
| 894 | }; | |
| 895 | } | |
| 896 | if (!profile.emailVerified) { | |
| 897 | return { | |
| 898 | ok: false, | |
| 899 | error: | |
| 900 | "Google reports this email as unverified. Verify it in your Google account and retry.", | |
| 901 | }; | |
| 902 | } | |
| 903 | ||
| 904 | const usernameSeed = | |
| 905 | profile.email.split("@")[0] || profile.name || "user"; | |
| 906 | const username = await pickAvailableUsername(usernameSeed); | |
| 907 | ||
| 908 | const fakeHash = "sso-only:" + randomToken(32); | |
| 909 | ||
| 910 | const [user] = await db | |
| 911 | .insert(users) | |
| 912 | .values({ | |
| 913 | username, | |
| 914 | email: profile.email, | |
| 915 | passwordHash: fakeHash, | |
| 916 | }) | |
| 917 | .returning(); | |
| 918 | ||
| 919 | await db | |
| 920 | .insert(ssoUserLinks) | |
| 921 | .values({ | |
| 922 | userId: user.id, | |
| 923 | subject, | |
| 924 | emailAtLink: profile.email, | |
| 925 | }) | |
| 926 | .onConflictDoNothing(); | |
| 927 | ||
| 928 | return { ok: true, user }; | |
| 929 | } | |
| 930 | ||
| 931 | /** Compute the Google OAuth redirect URI for this deployment. */ | |
| 932 | export function googleOauthRedirectUri(): string { | |
| 933 | return `${config.appBaseUrl}/login/google/callback`; | |
| 934 | } | |
| 935 | ||
| edf7c36 | 936 | // ---------------------------------------------------------------------------- |
| 937 | // Test-only exports | |
| 938 | // ---------------------------------------------------------------------------- | |
| 939 | ||
| 940 | export const __internal = { | |
| 941 | emptyToNull, | |
| 942 | normalizeUsername, | |
| 943 | }; |