Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
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.

sso.tsBlame943 lines · 1 contributor
edf7c36Claude1/**
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
22import { eq } from "drizzle-orm";
23import { db } from "../db";
24import {
25 ssoConfig,
26 ssoUserLinks,
27 users,
28 sessions,
29 type SsoConfig,
30 type SsoUserLink,
31 type User,
32} from "../db/schema";
33import {
34 generateSessionToken,
35 sessionExpiry,
36} from "./auth";
37import { config } from "./config";
38
39// ----------------------------------------------------------------------------
40// Types
41// ----------------------------------------------------------------------------
42
43export 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
57export 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
67export 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
80const SSO_CONFIG_ID = "default";
81
82/** Returns the singleton SSO config, or null if never configured. */
83export 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. */
97export 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
146function 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 */
160export 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. */
180export 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 */
193export 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. */
230export 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 */
261export 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
280export 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 */
303export 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))
a28cedeClaude323 .catch((err) => {
324 console.warn(
325 "[sso] stale link cleanup failed:",
326 err instanceof Error ? err.message : err
327 );
328 });
edf7c36Claude329 }
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. */
407export 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. */
417async 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. */
436export 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. */
447export function ssoRedirectUri(): string {
448 return `${config.appBaseUrl}/login/sso/callback`;
449}
450
46d6165Claude451// ----------------------------------------------------------------------------
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
464const GITHUB_OAUTH_CONFIG_ID = "github";
465
466/** Returns the GitHub-OAuth singleton config row, or null if never seeded. */
467export 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 */
485export 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. */
535export 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 */
556export 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))
a28cedeClaude577 .catch((err) => {
578 console.warn(
579 "[sso] orphan link cleanup failed:",
580 err instanceof Error ? err.message : err
581 );
582 });
46d6165Claude583 }
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. */
658export function githubOauthRedirectUri(): string {
659 return `${config.appBaseUrl}/login/github/callback`;
660}
661
582cdacClaude662// ----------------------------------------------------------------------------
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
670const GOOGLE_OAUTH_CONFIG_ID = "google";
671
27d5fd3Claude672/**
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 */
686export 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/**
9887dd0Claude712 * 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 */
731export 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}.
27d5fd3Claude743 */
582cdacClaude744export async function getGoogleOauthConfig(): Promise<SsoConfig | null> {
27d5fd3Claude745 let row: SsoConfig | null = null;
582cdacClaude746 try {
27d5fd3Claude747 const [r] = await db
582cdacClaude748 .select()
749 .from(ssoConfig)
750 .where(eq(ssoConfig.id, GOOGLE_OAUTH_CONFIG_ID))
751 .limit(1);
27d5fd3Claude752 row = r || null;
582cdacClaude753 } catch {
27d5fd3Claude754 row = null;
582cdacClaude755 }
9887dd0Claude756 return resolveGoogleOauthConfig(row, googleOauthConfigFromEnv());
582cdacClaude757}
758
759export 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
813export 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 */
827export 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. */
932export function googleOauthRedirectUri(): string {
933 return `${config.appBaseUrl}/login/google/callback`;
934}
935
edf7c36Claude936// ----------------------------------------------------------------------------
937// Test-only exports
938// ----------------------------------------------------------------------------
939
940export const __internal = {
941 emptyToNull,
942 normalizeUsername,
943};