Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
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.tsBlame952 lines · 2 contributors
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;
889e0c5ccanty labs540 /**
541 * Whether GitHub confirmed this email is verified. MUST be true before we
542 * match `email` against an existing account — otherwise anyone who sets an
543 * unverified address matching a victim's email can take over their account.
544 * Optional/absent is treated as NOT verified (fail closed). Mirrors the
545 * Google flow's `emailVerified` gate.
546 */
547 emailVerified?: boolean;
46d6165Claude548 avatarUrl: string | null;
549}
550
551/**
552 * Like findOrCreateUserFromSso, but specialised for the GitHub flow.
553 *
554 * Differences from the OIDC version:
555 * - subject is prefixed with "github:" so we run multiple IdPs alongside
556 * each other without ID collisions (the IdP `sub` namespace is global,
557 * not per-provider).
558 * - We use `login` as the username fallback and `name` for display.
559 * - We still match by email when GitHub returns one; otherwise we
560 * auto-create using the `login` as the username seed.
561 *
562 * Keeps `findOrCreateUserFromSso` totally untouched.
563 */
564export async function findOrCreateUserFromGithub(
565 profile: GithubProfile,
566 cfg: SsoConfig
567): Promise<
568 | { ok: true; user: User }
569 | { ok: false; error: string }
570> {
571 const subject = `github:${profile.id}`;
572
573 // 1. Existing link
574 const link = await findSsoLinkBySubject(subject);
575 if (link) {
576 const [user] = await db
577 .select()
578 .from(users)
579 .where(eq(users.id, link.userId))
580 .limit(1);
581 if (user) return { ok: true, user };
582 await db
583 .delete(ssoUserLinks)
584 .where(eq(ssoUserLinks.subject, subject))
a28cedeClaude585 .catch((err) => {
586 console.warn(
587 "[sso] orphan link cleanup failed:",
588 err instanceof Error ? err.message : err
589 );
590 });
46d6165Claude591 }
592
593 // 2. Domain gate (only meaningful if admin set one)
594 if (!emailDomainAllowed(profile.email, cfg.allowedEmailDomains)) {
595 return {
596 ok: false,
597 error: "Your email domain is not permitted for GitHub sign-in.",
598 };
599 }
600
889e0c5ccanty labs601 // 3. Match by email — ONLY when GitHub verified it. An unverified email
602 // must never link into an existing account (account-takeover vector).
603 if (profile.email && profile.emailVerified) {
46d6165Claude604 const [existing] = await db
605 .select()
606 .from(users)
607 .where(eq(users.email, profile.email))
608 .limit(1);
609 if (existing) {
610 await db
611 .insert(ssoUserLinks)
612 .values({
613 userId: existing.id,
614 subject,
615 emailAtLink: profile.email,
616 })
617 .onConflictDoNothing();
618 return { ok: true, user: existing };
619 }
620 }
621
622 // 4. Auto-create
623 if (!cfg.autoCreateUsers) {
624 return {
625 ok: false,
626 error:
627 "No matching account, and the administrator has disabled GitHub account creation.",
628 };
629 }
630
631 const email = profile.email;
632 if (!email) {
633 return {
634 ok: false,
635 error:
636 "GitHub did not return a verified email. Mark a primary email as verified on github.com and try again.",
637 };
638 }
639
640 const usernameSeed = profile.login || profile.name || email.split("@")[0] || "user";
641 const username = await pickAvailableUsername(usernameSeed);
642
643 const fakeHash = "sso-only:" + randomToken(32);
644
645 const [user] = await db
646 .insert(users)
647 .values({
648 username,
649 email,
650 passwordHash: fakeHash,
651 })
652 .returning();
653
654 await db
655 .insert(ssoUserLinks)
656 .values({
657 userId: user.id,
658 subject,
659 emailAtLink: email,
660 })
661 .onConflictDoNothing();
662
663 return { ok: true, user };
664}
665
666/** Compute the GitHub OAuth redirect URI for this deployment. */
667export function githubOauthRedirectUri(): string {
668 return `${config.appBaseUrl}/login/github/callback`;
669}
670
582cdacClaude671// ----------------------------------------------------------------------------
672// "Sign in with Google" — mirrors the GitHub OAuth surface above.
673//
674// Storage: a separate row in `sso_config` keyed by id='google'. Subject in
675// `sso_user_links` is prefixed `google:` so it never collides with
676// `github:` or the default OIDC `default:`.
677// ----------------------------------------------------------------------------
678
679const GOOGLE_OAUTH_CONFIG_ID = "google";
680
27d5fd3Claude681/**
682 * Builds a Google OAuth config from environment variables, or null when the
683 * required pair isn't set. This is the zero-DB bootstrap path: operators set
684 * `GOOGLE_OAUTH_CLIENT_ID` + `GOOGLE_OAUTH_CLIENT_SECRET` (e.g. as Fly
685 * secrets) and "Sign in with Google" turns on without anyone needing to
686 * reach the /admin/google-oauth page first — which matters when the admin
687 * is themselves locked out of password login.
688 *
689 * Optional knobs:
690 * GOOGLE_OAUTH_AUTO_CREATE=0 — disable auto-creating accounts
691 * GOOGLE_OAUTH_ALLOWED_DOMAINS=a,b — restrict sign-in to email domains
692 *
693 * Pure (env passed in) so it's unit-testable.
694 */
695export function googleOauthConfigFromEnv(
696 env: Record<string, string | undefined> = process.env
697): SsoConfig | null {
698 const clientId = (env.GOOGLE_OAUTH_CLIENT_ID || "").trim();
699 const clientSecret = (env.GOOGLE_OAUTH_CLIENT_SECRET || "").trim();
700 if (!clientId || !clientSecret) return null;
701 const now = new Date();
702 return {
703 id: GOOGLE_OAUTH_CONFIG_ID,
704 enabled: true,
705 providerName: "Google",
706 issuer: "https://accounts.google.com",
707 authorizationEndpoint: "https://accounts.google.com/o/oauth2/v2/auth",
708 tokenEndpoint: "https://oauth2.googleapis.com/token",
709 userinfoEndpoint: "https://openidconnect.googleapis.com/v1/userinfo",
710 clientId,
711 clientSecret,
712 scopes: "openid email profile",
713 allowedEmailDomains: (env.GOOGLE_OAUTH_ALLOWED_DOMAINS || "").trim() || null,
714 autoCreateUsers: env.GOOGLE_OAUTH_AUTO_CREATE !== "0",
715 createdAt: now,
716 updatedAt: now,
717 };
718}
719
720/**
9887dd0Claude721 * Pure precedence resolver between the persisted `id='google'` row and the
722 * env-var bootstrap. Split out from `getGoogleOauthConfig` so the ordering is
723 * unit-testable without a database.
724 *
725 * Order:
726 * 1. An admin row that is BOTH enabled and fully credentialed is the
727 * explicit operator choice — it wins outright (even over different env
728 * credentials).
729 * 2. Otherwise a complete env-var bootstrap (always enabled when present)
730 * is the live config. Critically, it must NOT be shadowed by a
731 * saved-but-disabled or half-filled DB row. That shadowing was a real
732 * foot-gun: an earlier half-finished /admin/google-oauth save (e.g.
733 * credentials entered but the Enable box left unticked) left a disabled
734 * row behind, which then suppressed a perfectly good GOOGLE_OAUTH_*
735 * bootstrap — so "Sign in with Google" stayed dark with a misleading
736 * "not enabled".
737 * 3. Otherwise fall back to whatever row exists (possibly disabled/partial,
738 * possibly null). Callers still gate on `enabled` + credentials.
739 */
740export function resolveGoogleOauthConfig(
741 row: SsoConfig | null,
742 envCfg: SsoConfig | null
743): SsoConfig | null {
744 if (row?.enabled && row.clientId && row.clientSecret) return row;
745 if (envCfg) return envCfg;
746 return row;
747}
748
749/**
750 * The live "Sign in with Google" config, merging the persisted admin row with
751 * the env-var bootstrap via {@link resolveGoogleOauthConfig}.
27d5fd3Claude752 */
582cdacClaude753export async function getGoogleOauthConfig(): Promise<SsoConfig | null> {
27d5fd3Claude754 let row: SsoConfig | null = null;
582cdacClaude755 try {
27d5fd3Claude756 const [r] = await db
582cdacClaude757 .select()
758 .from(ssoConfig)
759 .where(eq(ssoConfig.id, GOOGLE_OAUTH_CONFIG_ID))
760 .limit(1);
27d5fd3Claude761 row = r || null;
582cdacClaude762 } catch {
27d5fd3Claude763 row = null;
582cdacClaude764 }
9887dd0Claude765 return resolveGoogleOauthConfig(row, googleOauthConfigFromEnv());
582cdacClaude766}
767
768export async function upsertGoogleOauthConfig(
769 input: Partial<
770 Pick<
771 SsoConfigInput,
772 "enabled" | "clientId" | "clientSecret" | "autoCreateUsers" | "allowedEmailDomains"
773 >
774 >
775): Promise<{ ok: true } | { ok: false; error: string }> {
776 try {
777 const now = new Date();
778 const values = {
779 id: GOOGLE_OAUTH_CONFIG_ID,
780 enabled: !!input.enabled,
781 providerName: "Google",
782 issuer: "https://accounts.google.com",
783 authorizationEndpoint: "https://accounts.google.com/o/oauth2/v2/auth",
784 tokenEndpoint: "https://oauth2.googleapis.com/token",
785 userinfoEndpoint: "https://openidconnect.googleapis.com/v1/userinfo",
786 clientId: emptyToNull(input.clientId),
787 clientSecret: emptyToNull(input.clientSecret),
788 scopes: "openid email profile",
789 allowedEmailDomains: emptyToNull(input.allowedEmailDomains ?? null),
790 autoCreateUsers: input.autoCreateUsers !== false,
791 updatedAt: now,
792 };
793 await db
794 .insert(ssoConfig)
795 .values(values)
796 .onConflictDoUpdate({
797 target: ssoConfig.id,
798 set: {
799 enabled: values.enabled,
800 providerName: values.providerName,
801 issuer: values.issuer,
802 authorizationEndpoint: values.authorizationEndpoint,
803 tokenEndpoint: values.tokenEndpoint,
804 userinfoEndpoint: values.userinfoEndpoint,
805 clientId: values.clientId,
806 clientSecret: values.clientSecret,
807 scopes: values.scopes,
808 allowedEmailDomains: values.allowedEmailDomains,
809 autoCreateUsers: values.autoCreateUsers,
810 updatedAt: now,
811 },
812 });
813 return { ok: true };
814 } catch (err) {
815 return {
816 ok: false,
817 error: err instanceof Error ? err.message : "Failed to save",
818 };
819 }
820}
821
822export interface GoogleProfile {
823 sub: string;
824 email: string | null;
825 emailVerified: boolean;
826 name: string | null;
827 picture: string | null;
828}
829
830/**
831 * Find-or-create a user from a Google profile. Same flow as the GitHub
832 * variant: existing link → match by email → auto-create (if enabled and
833 * email is verified). Refuses to auto-create on unverified emails (Google
834 * users can have unverified addresses on some legacy account states).
835 */
836export async function findOrCreateUserFromGoogle(
837 profile: GoogleProfile,
838 cfg: SsoConfig
839): Promise<{ ok: true; user: User } | { ok: false; error: string }> {
840 const subject = `google:${profile.sub}`;
841
842 // 1. Existing link
843 const link = await findSsoLinkBySubject(subject);
844 if (link) {
845 const [user] = await db
846 .select()
847 .from(users)
848 .where(eq(users.id, link.userId))
849 .limit(1);
850 if (user) return { ok: true, user };
851 await db
852 .delete(ssoUserLinks)
853 .where(eq(ssoUserLinks.subject, subject))
854 .catch((err) => {
855 console.warn(
856 "[google-oauth] orphan link cleanup failed:",
857 err instanceof Error ? err.message : err
858 );
859 });
860 }
861
862 // 2. Domain gate
863 if (!emailDomainAllowed(profile.email, cfg.allowedEmailDomains)) {
864 return {
865 ok: false,
866 error: "Your email domain is not permitted for Google sign-in.",
867 };
868 }
869
870 // 3. Match by email (only if Google says the email is verified)
871 if (profile.email && profile.emailVerified) {
872 const [existing] = await db
873 .select()
874 .from(users)
875 .where(eq(users.email, profile.email))
876 .limit(1);
877 if (existing) {
878 await db
879 .insert(ssoUserLinks)
880 .values({
881 userId: existing.id,
882 subject,
883 emailAtLink: profile.email,
884 })
885 .onConflictDoNothing();
886 return { ok: true, user: existing };
887 }
888 }
889
890 // 4. Auto-create
891 if (!cfg.autoCreateUsers) {
892 return {
893 ok: false,
894 error:
895 "No matching account, and the administrator has disabled Google account creation.",
896 };
897 }
898
899 if (!profile.email) {
900 return {
901 ok: false,
902 error: "Google did not return an email address. Try signing up manually.",
903 };
904 }
905 if (!profile.emailVerified) {
906 return {
907 ok: false,
908 error:
909 "Google reports this email as unverified. Verify it in your Google account and retry.",
910 };
911 }
912
913 const usernameSeed =
914 profile.email.split("@")[0] || profile.name || "user";
915 const username = await pickAvailableUsername(usernameSeed);
916
917 const fakeHash = "sso-only:" + randomToken(32);
918
919 const [user] = await db
920 .insert(users)
921 .values({
922 username,
923 email: profile.email,
924 passwordHash: fakeHash,
925 })
926 .returning();
927
928 await db
929 .insert(ssoUserLinks)
930 .values({
931 userId: user.id,
932 subject,
933 emailAtLink: profile.email,
934 })
935 .onConflictDoNothing();
936
937 return { ok: true, user };
938}
939
940/** Compute the Google OAuth redirect URI for this deployment. */
941export function googleOauthRedirectUri(): string {
942 return `${config.appBaseUrl}/login/google/callback`;
943}
944
edf7c36Claude945// ----------------------------------------------------------------------------
946// Test-only exports
947// ----------------------------------------------------------------------------
948
949export const __internal = {
950 emptyToNull,
951 normalizeUsername,
952};