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.tsBlame917 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/**
712 * Resolution order: a DB row saved via /admin/google-oauth that actually
713 * carries credentials wins (it's the admin's explicit choice, including its
714 * enabled flag); otherwise fall back to env-var configuration; otherwise
715 * whatever partial row exists (or null).
716 */
582cdacClaude717export async function getGoogleOauthConfig(): Promise<SsoConfig | null> {
27d5fd3Claude718 let row: SsoConfig | null = null;
582cdacClaude719 try {
27d5fd3Claude720 const [r] = await db
582cdacClaude721 .select()
722 .from(ssoConfig)
723 .where(eq(ssoConfig.id, GOOGLE_OAUTH_CONFIG_ID))
724 .limit(1);
27d5fd3Claude725 row = r || null;
582cdacClaude726 } catch {
27d5fd3Claude727 row = null;
582cdacClaude728 }
27d5fd3Claude729 if (row?.clientId && row?.clientSecret) return row;
730 return googleOauthConfigFromEnv() ?? row;
582cdacClaude731}
732
733export async function upsertGoogleOauthConfig(
734 input: Partial<
735 Pick<
736 SsoConfigInput,
737 "enabled" | "clientId" | "clientSecret" | "autoCreateUsers" | "allowedEmailDomains"
738 >
739 >
740): Promise<{ ok: true } | { ok: false; error: string }> {
741 try {
742 const now = new Date();
743 const values = {
744 id: GOOGLE_OAUTH_CONFIG_ID,
745 enabled: !!input.enabled,
746 providerName: "Google",
747 issuer: "https://accounts.google.com",
748 authorizationEndpoint: "https://accounts.google.com/o/oauth2/v2/auth",
749 tokenEndpoint: "https://oauth2.googleapis.com/token",
750 userinfoEndpoint: "https://openidconnect.googleapis.com/v1/userinfo",
751 clientId: emptyToNull(input.clientId),
752 clientSecret: emptyToNull(input.clientSecret),
753 scopes: "openid email profile",
754 allowedEmailDomains: emptyToNull(input.allowedEmailDomains ?? null),
755 autoCreateUsers: input.autoCreateUsers !== false,
756 updatedAt: now,
757 };
758 await db
759 .insert(ssoConfig)
760 .values(values)
761 .onConflictDoUpdate({
762 target: ssoConfig.id,
763 set: {
764 enabled: values.enabled,
765 providerName: values.providerName,
766 issuer: values.issuer,
767 authorizationEndpoint: values.authorizationEndpoint,
768 tokenEndpoint: values.tokenEndpoint,
769 userinfoEndpoint: values.userinfoEndpoint,
770 clientId: values.clientId,
771 clientSecret: values.clientSecret,
772 scopes: values.scopes,
773 allowedEmailDomains: values.allowedEmailDomains,
774 autoCreateUsers: values.autoCreateUsers,
775 updatedAt: now,
776 },
777 });
778 return { ok: true };
779 } catch (err) {
780 return {
781 ok: false,
782 error: err instanceof Error ? err.message : "Failed to save",
783 };
784 }
785}
786
787export interface GoogleProfile {
788 sub: string;
789 email: string | null;
790 emailVerified: boolean;
791 name: string | null;
792 picture: string | null;
793}
794
795/**
796 * Find-or-create a user from a Google profile. Same flow as the GitHub
797 * variant: existing link → match by email → auto-create (if enabled and
798 * email is verified). Refuses to auto-create on unverified emails (Google
799 * users can have unverified addresses on some legacy account states).
800 */
801export async function findOrCreateUserFromGoogle(
802 profile: GoogleProfile,
803 cfg: SsoConfig
804): Promise<{ ok: true; user: User } | { ok: false; error: string }> {
805 const subject = `google:${profile.sub}`;
806
807 // 1. Existing link
808 const link = await findSsoLinkBySubject(subject);
809 if (link) {
810 const [user] = await db
811 .select()
812 .from(users)
813 .where(eq(users.id, link.userId))
814 .limit(1);
815 if (user) return { ok: true, user };
816 await db
817 .delete(ssoUserLinks)
818 .where(eq(ssoUserLinks.subject, subject))
819 .catch((err) => {
820 console.warn(
821 "[google-oauth] orphan link cleanup failed:",
822 err instanceof Error ? err.message : err
823 );
824 });
825 }
826
827 // 2. Domain gate
828 if (!emailDomainAllowed(profile.email, cfg.allowedEmailDomains)) {
829 return {
830 ok: false,
831 error: "Your email domain is not permitted for Google sign-in.",
832 };
833 }
834
835 // 3. Match by email (only if Google says the email is verified)
836 if (profile.email && profile.emailVerified) {
837 const [existing] = await db
838 .select()
839 .from(users)
840 .where(eq(users.email, profile.email))
841 .limit(1);
842 if (existing) {
843 await db
844 .insert(ssoUserLinks)
845 .values({
846 userId: existing.id,
847 subject,
848 emailAtLink: profile.email,
849 })
850 .onConflictDoNothing();
851 return { ok: true, user: existing };
852 }
853 }
854
855 // 4. Auto-create
856 if (!cfg.autoCreateUsers) {
857 return {
858 ok: false,
859 error:
860 "No matching account, and the administrator has disabled Google account creation.",
861 };
862 }
863
864 if (!profile.email) {
865 return {
866 ok: false,
867 error: "Google did not return an email address. Try signing up manually.",
868 };
869 }
870 if (!profile.emailVerified) {
871 return {
872 ok: false,
873 error:
874 "Google reports this email as unverified. Verify it in your Google account and retry.",
875 };
876 }
877
878 const usernameSeed =
879 profile.email.split("@")[0] || profile.name || "user";
880 const username = await pickAvailableUsername(usernameSeed);
881
882 const fakeHash = "sso-only:" + randomToken(32);
883
884 const [user] = await db
885 .insert(users)
886 .values({
887 username,
888 email: profile.email,
889 passwordHash: fakeHash,
890 })
891 .returning();
892
893 await db
894 .insert(ssoUserLinks)
895 .values({
896 userId: user.id,
897 subject,
898 emailAtLink: profile.email,
899 })
900 .onConflictDoNothing();
901
902 return { ok: true, user };
903}
904
905/** Compute the Google OAuth redirect URI for this deployment. */
906export function googleOauthRedirectUri(): string {
907 return `${config.appBaseUrl}/login/google/callback`;
908}
909
edf7c36Claude910// ----------------------------------------------------------------------------
911// Test-only exports
912// ----------------------------------------------------------------------------
913
914export const __internal = {
915 emptyToNull,
916 normalizeUsername,
917};