Commitedf7c36unknown_key
feat(BLOCK-I): I10 enterprise SSO via OIDC
feat(BLOCK-I): I10 enterprise SSO via OIDC Adds drizzle/0027_sso_oidc.sql (sso_config singleton + sso_user_links), src/lib/sso.ts (buildAuthorizeUrl, exchangeCode, fetchUserinfo, findOrCreateUserFromSso, emailDomainAllowed), src/routes/sso.tsx with /admin/sso config + /login/sso + /login/sso/callback + /settings/sso/unlink. Login page renders "Sign in with <provider>" when enabled. 625 tests pass.
9 files changed+1220−4edf7c36557f6660da2e8b760680d42227ddc9a88
9 changed files+1220−4
ModifiedBUILD_BIBLE.md+4−2View fileUnifiedSplit
@@ -146,7 +146,7 @@ Legend: ✅ shipped · 🟡 partial · ❌ not built
146146| GitHub Apps equivalent | ✅ | H2 — `src/lib/marketplace.ts` `generateBearerToken`/`verifyInstallToken` (1h TTL, `ghi_` prefix, sha256 hashed). Each app gets a `<slug>[bot]` identity (`app_bots`). Permissions enforced via `hasPermission` (write implies read). |
147147| GraphQL API | ✅ | G2 — see 2.6 |
148148| Organizations + teams | ✅ | B1+B2+B3 shipped: `src/routes/orgs.tsx`, `src/lib/orgs.ts`; org-owned repos (`repositories.orgId`); team-based CODEOWNERS (`@org/team` resolution) |
149| Enterprise SAML / SSO | ❌ | |
149| Enterprise SAML / SSO | ✅ | I10 — OIDC (Okta / Azure AD / Auth0 / Google Workspace). `src/lib/sso.ts` + `src/routes/sso.tsx`, `drizzle/0027_sso_oidc.sql` (tables `sso_config` singleton + `sso_user_links`). Admin config at `/admin/sso`; `/login/sso` starts auth-code flow with state+nonce cookies; `/login/sso/callback` exchanges code, fetches userinfo, links by `sub` (or by email, or auto-creates). Optional email-domain allow-list + auto-create toggle. |
150150| 2FA / TOTP | ✅ | `src/routes/settings-2fa.tsx`, `src/lib/totp.ts`; `user_totp` + `user_recovery_codes` tables |
151151| Passkeys / WebAuthn | ✅ | `src/routes/passkeys.tsx`, `src/lib/webauthn.ts`; `user_passkeys` + `webauthn_challenges` tables |
152152| Packages registry (npm / docker / etc) | ✅ | `src/lib/packages.ts`, `src/routes/packages-api.ts`, `src/routes/packages.tsx`; npm protocol (packument, tarball, publish, yank); PAT (`glc_`) auth via Authorization header; container registry deferred |
@@ -276,6 +276,7 @@ This is where GlueCron beats GitHub outright. **Priority: ship these loud.**
276276- **I7** — Weekly email digest → ✅ shipped. `drizzle/0024_email_digest.sql` adds `users.notify_email_digest_weekly` + `last_digest_sent_at`. `src/lib/email-digest.ts` exposes `composeDigest`/`sendDigestForUser`/`sendDigestsToAll` (never-throws). Pulls notifications + failed/repaired gate_runs + merged PRs from the last 7d, composes escaped HTML + plaintext, and sends via the shared email provider. `/settings/digest/preview` renders the digest inline for self-preview; `/admin/digests` gives site admins a "Send now" trigger + single-user preview, audit-logged as `admin.digests.run`/`admin.digests.preview`.
277277- **I8** — Symbol / xref navigation → ✅ shipped. `drizzle/0025_code_symbols.sql` adds a `code_symbols` table. `src/lib/symbols.ts` provides a regex-based top-level extractor for ts/js/py/rs/go/rb/java/kt/swift. On-demand indexing via `POST /:owner/:repo/symbols/reindex` walks the default-branch tree, caps at 2000 files/1MB each, replaces the prior set. Browse at `/:owner/:repo/symbols` (A–Z + per-kind counts), search via `/symbols/search?q=`, inspect at `/symbols/:name`. 14 new tests.
278278- **I9** — Repository mirroring → ✅ shipped. `drizzle/0026_repo_mirrors.sql` adds `repo_mirrors` (one-per-repo config) + `repo_mirror_runs` (audit log). `src/lib/mirrors.ts` provides URL validation (https/http/git only, no ssh/file/paths/shell metas), credentials-redaction for logs, and `runMirrorSync` that shells out to `git fetch --prune --tags` with a 5-min timeout and `GIT_TERMINAL_PROMPT=0`. `src/routes/mirrors.tsx` serves owner-only `/:owner/:repo/settings/mirror` + site-admin `/admin/mirrors/sync-all`. 17 new tests.
279- **I10** — Enterprise SSO via OIDC → ✅ shipped. `drizzle/0027_sso_oidc.sql` adds `sso_config` (singleton `id='default'` row with issuer + authorize/token/userinfo endpoints + client credentials + scopes + optional email-domain allow-list + `auto_create_users` toggle) and `sso_user_links` (maps local user to IdP `sub`, unique per-subject). `src/lib/sso.ts` exposes `buildAuthorizeUrl`/`exchangeCode`/`fetchUserinfo`/`findOrCreateUserFromSso` pure helpers — plain OIDC auth-code flow, no XML / no signature verification dep. `src/routes/sso.tsx` serves site-admin `/admin/sso` config page, `/login/sso` initiator (state + nonce cookies, 10-min TTL), `/login/sso/callback` exchanger + session issuer, plus `POST /settings/sso/unlink` for users. `/login` renders "Sign in with <provider name>" when enabled. 24 new tests (pure helpers + route-auth smokes).
279280
280281### BLOCK H — Marketplace
281282- **H1** — App marketplace → ✅ shipped. `src/routes/marketplace.tsx` + `src/lib/marketplace.ts` + `drizzle/0021_marketplace_and_apps.sql` (5 tables: `apps`, `app_installations`, `app_bots`, `app_install_tokens`, `app_events`). Routes: `GET /marketplace` (public directory with search), `GET /marketplace/:slug` (detail + install CTA), `POST /marketplace/:slug/install` (user-target install in v1), `POST /marketplace/installations/:id/uninstall`, `GET /settings/apps` (personal list), `GET+POST /developer/apps-new` (register), `GET /developer/apps/:slug/manage` (event log + install count), `POST /developer/apps/:slug/tokens/new` (show-once token). Install idempotent via soft-update on existing non-uninstalled row.
@@ -291,7 +292,7 @@ Everything below is committed, tested, and load-bearing. **Do not delete, rename
291292- `src/app.tsx` — route composition, middleware order, error handlers
292293- `src/index.ts` — Bun server entry
293294- `src/lib/config.ts` — env getters (late-binding)
294- `src/db/schema.ts` — 84 tables. New tables only via new migration.
295- `src/db/schema.ts` — 86 tables. New tables only via new migration.
295296- `src/db/index.ts` — lazy proxy DB connection
296297- `src/db/migrate.ts` — migration runner
297298- `drizzle/0000_initial.sql`, `drizzle/0001_green_ecosystem.sql` — migrations
@@ -318,6 +319,7 @@ Everything below is committed, tested, and load-bearing. **Do not delete, rename
318319- `drizzle/0024_email_digest.sql` (Block I7) — migration, never edited in place. Adds `users.notify_email_digest_weekly` + `users.last_digest_sent_at`.
319320- `drizzle/0025_code_symbols.sql` (Block I8) — migration, never edited in place. Adds `code_symbols` table with indexes on `(repository_id, name)` + `(repository_id, path)`.
320321- `drizzle/0026_repo_mirrors.sql` (Block I9) — migration, never edited in place. Adds `repo_mirrors` (unique on `repository_id`) + `repo_mirror_runs`.
322- `drizzle/0027_sso_oidc.sql` (Block I10) — migration, never edited in place. Adds `sso_config` singleton (`id='default'`) + `sso_user_links` (`subject` unique, FK to `users` with ON DELETE CASCADE).
321323
322324### 4.2 Git layer (locked)
323325- `src/git/repository.ts` — tree / blob / commits / diff / branches / blame / search / raw / tags / commitsBetween
Addeddrizzle/0027_sso_oidc.sql+40−0View fileUnifiedSplit
@@ -0,0 +1,40 @@
1-- Gluecron migration 0027: OIDC-based enterprise SSO.
2--
3-- I10 — A single site-wide OIDC provider (Okta / Azure AD / Auth0 / Google
4-- Workspace). Identified by an `id = 'default'` singleton row. Admin fills
5-- in issuer + endpoint URLs + client credentials. Users then see a
6-- "Sign in with SSO" button on /login.
7--
8-- `sso_user_links` maps a local user to the provider's `sub` claim, so
9-- repeat sign-ins find the existing account.
10
11--> statement-breakpoint
12CREATE TABLE IF NOT EXISTS "sso_config" (
13 "id" text PRIMARY KEY,
14 "enabled" boolean NOT NULL DEFAULT false,
15 "provider_name" text NOT NULL DEFAULT 'SSO',
16 "issuer" text,
17 "authorization_endpoint" text,
18 "token_endpoint" text,
19 "userinfo_endpoint" text,
20 "client_id" text,
21 "client_secret" text,
22 "scopes" text NOT NULL DEFAULT 'openid profile email',
23 "allowed_email_domains" text, -- comma-separated, null = any
24 "auto_create_users" boolean NOT NULL DEFAULT true,
25 "created_at" timestamp NOT NULL DEFAULT now(),
26 "updated_at" timestamp NOT NULL DEFAULT now()
27);
28
29--> statement-breakpoint
30CREATE TABLE IF NOT EXISTS "sso_user_links" (
31 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
32 "user_id" uuid NOT NULL REFERENCES "users"("id") ON DELETE CASCADE,
33 "subject" text NOT NULL UNIQUE,
34 "email_at_link" text NOT NULL,
35 "linked_at" timestamp NOT NULL DEFAULT now()
36);
37
38--> statement-breakpoint
39CREATE INDEX IF NOT EXISTS "sso_user_links_user_id_idx"
40 ON "sso_user_links" ("user_id");
Addedsrc/__tests__/sso.test.ts+226−0View fileUnifiedSplit
@@ -0,0 +1,226 @@
1/**
2 * Block I10 — Enterprise SSO (OIDC) tests.
3 *
4 * Covers pure helpers (URL building, domain gating, username normalization)
5 * and route authorization smokes. The full OIDC dance against a real IdP is
6 * exercised by live integration — we don't mock fetch here.
7 */
8
9import { describe, it, expect } from "bun:test";
10import app from "../app";
11import {
12 buildAuthorizeUrl,
13 emailDomainAllowed,
14 randomToken,
15 ssoRedirectUri,
16 __internal,
17} from "../lib/sso";
18
19const { emptyToNull, normalizeUsername } = __internal;
20
21describe("sso — buildAuthorizeUrl", () => {
22 const cfg = {
23 authorizationEndpoint: "https://idp.example.com/authorize",
24 clientId: "abc123",
25 scopes: "openid profile email",
26 };
27
28 it("includes all OIDC required params", () => {
29 const url = buildAuthorizeUrl(
30 cfg,
31 "state-xyz",
32 "nonce-abc",
33 "https://app.example.com/login/sso/callback"
34 );
35 const u = new URL(url);
36 expect(u.origin + u.pathname).toBe("https://idp.example.com/authorize");
37 expect(u.searchParams.get("client_id")).toBe("abc123");
38 expect(u.searchParams.get("response_type")).toBe("code");
39 expect(u.searchParams.get("scope")).toBe("openid profile email");
40 expect(u.searchParams.get("state")).toBe("state-xyz");
41 expect(u.searchParams.get("nonce")).toBe("nonce-abc");
42 expect(u.searchParams.get("redirect_uri")).toBe(
43 "https://app.example.com/login/sso/callback"
44 );
45 });
46
47 it("preserves existing query params on the endpoint", () => {
48 const url = buildAuthorizeUrl(
49 {
50 authorizationEndpoint: "https://idp.example.com/authorize?ext=1",
51 clientId: "abc",
52 scopes: "openid",
53 },
54 "s",
55 "n",
56 "https://app/cb"
57 );
58 const u = new URL(url);
59 expect(u.searchParams.get("ext")).toBe("1");
60 expect(u.searchParams.get("client_id")).toBe("abc");
61 });
62
63 it("throws when endpoint or client_id is missing", () => {
64 expect(() =>
65 buildAuthorizeUrl(
66 { authorizationEndpoint: null, clientId: "x", scopes: "openid" } as any,
67 "s",
68 "n",
69 "https://app/cb"
70 )
71 ).toThrow();
72 expect(() =>
73 buildAuthorizeUrl(
74 {
75 authorizationEndpoint: "https://i/a",
76 clientId: null,
77 scopes: "openid",
78 } as any,
79 "s",
80 "n",
81 "https://app/cb"
82 )
83 ).toThrow();
84 });
85
86 it("falls back to default scopes when empty", () => {
87 const url = buildAuthorizeUrl(
88 {
89 authorizationEndpoint: "https://idp/a",
90 clientId: "c",
91 scopes: "" as any,
92 },
93 "s",
94 "n",
95 "https://app/cb"
96 );
97 expect(new URL(url).searchParams.get("scope")).toBe(
98 "openid profile email"
99 );
100 });
101});
102
103describe("sso — emailDomainAllowed", () => {
104 it("allows any when domains list is null", () => {
105 expect(emailDomainAllowed("a@example.com", null)).toBe(true);
106 });
107
108 it("allows any when list is empty", () => {
109 expect(emailDomainAllowed("a@example.com", "")).toBe(true);
110 expect(emailDomainAllowed("a@example.com", " ")).toBe(true);
111 });
112
113 it("accepts matching domain (case-insensitive)", () => {
114 expect(emailDomainAllowed("a@EXAMPLE.COM", "example.com")).toBe(true);
115 expect(emailDomainAllowed("a@example.com", "acme.io, example.com")).toBe(
116 true
117 );
118 });
119
120 it("rejects unmatched domain", () => {
121 expect(emailDomainAllowed("a@evil.com", "example.com")).toBe(false);
122 });
123
124 it("rejects missing email when list is set", () => {
125 expect(emailDomainAllowed(null, "example.com")).toBe(false);
126 expect(emailDomainAllowed("", "example.com")).toBe(false);
127 });
128
129 it("rejects malformed email", () => {
130 expect(emailDomainAllowed("not-an-email", "example.com")).toBe(false);
131 });
132});
133
134describe("sso — normalizeUsername", () => {
135 it("lowercases and slugifies", () => {
136 expect(normalizeUsername("Alice Smith")).toBe("alice-smith");
137 expect(normalizeUsername("BOB@acme.IO")).toBe("bob-acme-io");
138 });
139
140 it("strips leading/trailing dashes", () => {
141 expect(normalizeUsername("---foo---")).toBe("foo");
142 });
143
144 it("falls back to 'user' for empty input", () => {
145 expect(normalizeUsername("")).toBe("user");
146 expect(normalizeUsername("@@@")).toBe("user");
147 });
148
149 it("caps at 32 chars", () => {
150 const out = normalizeUsername("a".repeat(80));
151 expect(out.length).toBeLessThanOrEqual(32);
152 });
153});
154
155describe("sso — emptyToNull", () => {
156 it("returns null for empty or whitespace-only", () => {
157 expect(emptyToNull("")).toBeNull();
158 expect(emptyToNull(" ")).toBeNull();
159 expect(emptyToNull(null)).toBeNull();
160 expect(emptyToNull(undefined)).toBeNull();
161 });
162
163 it("trims and returns non-empty strings", () => {
164 expect(emptyToNull(" hello ")).toBe("hello");
165 expect(emptyToNull("x")).toBe("x");
166 });
167});
168
169describe("sso — randomToken", () => {
170 it("returns hex of expected length", () => {
171 expect(randomToken(8)).toMatch(/^[0-9a-f]{16}$/);
172 expect(randomToken(16)).toMatch(/^[0-9a-f]{32}$/);
173 });
174
175 it("returns distinct values across calls", () => {
176 expect(randomToken(16)).not.toBe(randomToken(16));
177 });
178});
179
180describe("sso — ssoRedirectUri", () => {
181 it("ends with /login/sso/callback", () => {
182 expect(ssoRedirectUri().endsWith("/login/sso/callback")).toBe(true);
183 });
184});
185
186describe("sso — route auth", () => {
187 it("GET /admin/sso without auth → 302 /login", async () => {
188 const res = await app.request("/admin/sso");
189 expect(res.status).toBe(302);
190 expect(res.headers.get("location") || "").toContain("/login");
191 });
192
193 it("POST /admin/sso without auth → 302 /login", async () => {
194 const res = await app.request("/admin/sso", {
195 method: "POST",
196 body: new URLSearchParams({ provider_name: "x" }),
197 headers: { "content-type": "application/x-www-form-urlencoded" },
198 });
199 expect(res.status).toBe(302);
200 expect(res.headers.get("location") || "").toContain("/login");
201 });
202
203 it("POST /settings/sso/unlink without auth → 302 /login", async () => {
204 const res = await app.request("/settings/sso/unlink", { method: "POST" });
205 expect(res.status).toBe(302);
206 expect(res.headers.get("location") || "").toContain("/login");
207 });
208
209 it("GET /login/sso when SSO not configured → 302 /login with error", async () => {
210 const res = await app.request("/login/sso");
211 expect(res.status).toBe(302);
212 const loc = res.headers.get("location") || "";
213 expect(loc).toContain("/login");
214 // Either "not enabled" or "not fully configured" — either proves the
215 // route is mounted and SSO guard fires.
216 expect(loc).toContain("error=");
217 });
218
219 it("GET /login/sso/callback without state cookie → 302 /login", async () => {
220 const res = await app.request(
221 "/login/sso/callback?code=abc&state=xyz"
222 );
223 expect(res.status).toBe(302);
224 expect(res.headers.get("location") || "").toContain("/login");
225 });
226});
Modifiedsrc/app.tsx+4−0View fileUnifiedSplit
@@ -68,6 +68,7 @@ import codeScanningRoutes from "./routes/code-scanning";
6868import sponsorsRoutes from "./routes/sponsors";
6969import symbolsRoutes from "./routes/symbols";
7070import mirrorsRoutes from "./routes/mirrors";
71import ssoRoutes from "./routes/sso";
7172import webRoutes from "./routes/web";
7273
7374const app = new Hono();
@@ -234,6 +235,9 @@ app.route("/", symbolsRoutes);
234235// Repository mirroring — /:owner/:repo/settings/mirror (Block I9)
235236app.route("/", mirrorsRoutes);
236237
238// Enterprise SSO via OIDC — /admin/sso + /login/sso (Block I10)
239app.route("/", ssoRoutes);
240
237241// Insights + milestones
238242app.route("/", insightsRoutes);
239243
Modifiedsrc/db/schema.ts+41−0View fileUnifiedSplit
@@ -2034,3 +2034,44 @@ export const repoMirrorRuns = pgTable(
20342034);
20352035
20362036export type RepoMirrorRun = typeof repoMirrorRuns.$inferSelect;
2037
2038// ----------------------------------------------------------------------------
2039// Block I10 — Enterprise SSO (OIDC)
2040// ----------------------------------------------------------------------------
2041
2042/** Site-wide SSO provider. Singleton row with id = 'default'. */
2043export const ssoConfig = pgTable("sso_config", {
2044 id: text("id").primaryKey(),
2045 enabled: boolean("enabled").default(false).notNull(),
2046 providerName: text("provider_name").default("SSO").notNull(),
2047 issuer: text("issuer"),
2048 authorizationEndpoint: text("authorization_endpoint"),
2049 tokenEndpoint: text("token_endpoint"),
2050 userinfoEndpoint: text("userinfo_endpoint"),
2051 clientId: text("client_id"),
2052 clientSecret: text("client_secret"),
2053 scopes: text("scopes").default("openid profile email").notNull(),
2054 allowedEmailDomains: text("allowed_email_domains"),
2055 autoCreateUsers: boolean("auto_create_users").default(true).notNull(),
2056 createdAt: timestamp("created_at").defaultNow().notNull(),
2057 updatedAt: timestamp("updated_at").defaultNow().notNull(),
2058});
2059
2060export type SsoConfig = typeof ssoConfig.$inferSelect;
2061
2062/** Maps a local user to an IdP `sub` claim. */
2063export const ssoUserLinks = pgTable(
2064 "sso_user_links",
2065 {
2066 id: uuid("id").primaryKey().defaultRandom(),
2067 userId: uuid("user_id")
2068 .notNull()
2069 .references(() => users.id, { onDelete: "cascade" }),
2070 subject: text("subject").notNull().unique(),
2071 emailAtLink: text("email_at_link").notNull(),
2072 linkedAt: timestamp("linked_at").defaultNow().notNull(),
2073 },
2074 (table) => [index("sso_user_links_user_id_idx").on(table.userId)]
2075);
2076
2077export type SsoUserLink = typeof ssoUserLinks.$inferSelect;
Addedsrc/lib/sso.ts+453−0View fileUnifiedSplit
@@ -0,0 +1,453 @@
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
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))
323 .catch(() => {});
324 }
325
326 // 2. Domain gate
327 if (!emailDomainAllowed(claims.email, cfg.allowedEmailDomains)) {
328 return {
329 ok: false,
330 error: "Your email domain is not permitted for SSO sign-in.",
331 };
332 }
333
334 // 3. Match by email when present
335 if (claims.email) {
336 const [existing] = await db
337 .select()
338 .from(users)
339 .where(eq(users.email, claims.email))
340 .limit(1);
341 if (existing) {
342 await db
343 .insert(ssoUserLinks)
344 .values({
345 userId: existing.id,
346 subject: claims.sub,
347 emailAtLink: claims.email,
348 })
349 .onConflictDoNothing();
350 return { ok: true, user: existing };
351 }
352 }
353
354 // 4. Auto-create
355 if (!cfg.autoCreateUsers) {
356 return {
357 ok: false,
358 error:
359 "No matching account, and the administrator has disabled SSO account creation.",
360 };
361 }
362
363 const email = claims.email;
364 if (!email) {
365 return {
366 ok: false,
367 error: "SSO provider did not return an email claim.",
368 };
369 }
370
371 const username = await pickAvailableUsername(
372 claims.preferred_username || claims.name || email.split("@")[0] || "user"
373 );
374
375 // SSO users don't have a local password — store a random unusable hash.
376 // The login form requires a password match against bcrypt so random bytes
377 // here mean the account is SSO-only unless they set a password later.
378 const fakeHash = "sso-only:" + randomToken(32);
379
380 const [user] = await db
381 .insert(users)
382 .values({
383 username,
384 email,
385 passwordHash: fakeHash,
386 })
387 .returning();
388
389 await db
390 .insert(ssoUserLinks)
391 .values({
392 userId: user.id,
393 subject: claims.sub,
394 emailAtLink: email,
395 })
396 .onConflictDoNothing();
397
398 return { ok: true, user };
399}
400
401/** Normalize an IdP-provided name into a valid gluecron username. */
402export function normalizeUsername(raw: string): string {
403 const base = raw
404 .toLowerCase()
405 .replace(/[^a-z0-9_-]+/g, "-")
406 .replace(/^-+|-+$/g, "")
407 .slice(0, 32);
408 return base || "user";
409}
410
411/** Pick a username not already taken. Appends a random suffix on collision. */
412async function pickAvailableUsername(raw: string): Promise<string> {
413 const base = normalizeUsername(raw);
414 for (let i = 0; i < 5; i++) {
415 const candidate = i === 0 ? base : `${base}-${randomToken(3)}`;
416 try {
417 const [row] = await db
418 .select({ id: users.id })
419 .from(users)
420 .where(eq(users.username, candidate))
421 .limit(1);
422 if (!row) return candidate;
423 } catch {
424 return `${base}-${randomToken(3)}`;
425 }
426 }
427 return `${base}-${randomToken(4)}`;
428}
429
430/** Issue a session cookie token for a user. Caller sets the cookie. */
431export async function issueSsoSession(userId: string): Promise<string> {
432 const token = generateSessionToken();
433 await db.insert(sessions).values({
434 userId,
435 token,
436 expiresAt: sessionExpiry(),
437 });
438 return token;
439}
440
441/** Compute the fully-qualified OIDC redirect URI for this deployment. */
442export function ssoRedirectUri(): string {
443 return `${config.appBaseUrl}/login/sso/callback`;
444}
445
446// ----------------------------------------------------------------------------
447// Test-only exports
448// ----------------------------------------------------------------------------
449
450export const __internal = {
451 emptyToNull,
452 normalizeUsername,
453};
Modifiedsrc/routes/admin.tsx+4−1View fileUnifiedSplit
@@ -100,7 +100,7 @@ admin.get("/admin", async (c) => {
100100 </div>
101101 </div>
102102
103 <div style="display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-bottom:20px">
103 <div style="display:grid;grid-template-columns:repeat(5,1fr);gap:8px;margin-bottom:20px">
104104 <a href="/admin/users" class="btn">
105105 Manage users
106106 </a>
@@ -113,6 +113,9 @@ admin.get("/admin", async (c) => {
113113 <a href="/admin/digests" class="btn">
114114 Email digests
115115 </a>
116 <a href="/admin/sso" class="btn">
117 Enterprise SSO
118 </a>
116119 </div>
117120
118121 <h3>Recent signups</h3>
Modifiedsrc/routes/auth.tsx+22−1View fileUnifiedSplit
@@ -21,6 +21,7 @@ import {
2121 sessionExpiry,
2222} from "../lib/auth";
2323import { verifyTotpCode, hashRecoveryCode } from "../lib/totp";
24import { getSsoConfig } from "../lib/sso";
2425import { Layout } from "../views/layout";
2526import type { AuthEnv } from "../middleware/auth";
2627
@@ -155,9 +156,18 @@ auth.post("/register", async (c) => {
155156 return c.redirect(redirect);
156157});
157158
158auth.get("/login", (c) => {
159auth.get("/login", async (c) => {
159160 const error = c.req.query("error");
160161 const redirect = c.req.query("redirect") || "";
162 const ssoCfg = await getSsoConfig();
163 const ssoEnabled =
164 !!ssoCfg?.enabled &&
165 !!ssoCfg.authorizationEndpoint &&
166 !!ssoCfg.tokenEndpoint &&
167 !!ssoCfg.userinfoEndpoint &&
168 !!ssoCfg.clientId &&
169 !!ssoCfg.clientSecret;
170 const ssoLabel = ssoCfg?.providerName || "SSO";
161171 return c.html(
162172 <Layout title="Sign in">
163173 <div class="auth-container">
@@ -212,6 +222,17 @@ auth.get("/login", (c) => {
212222 style="margin-top: 8px; color: var(--text-muted); font-size: 12px; min-height: 16px"
213223 />
214224 </div>
225 {ssoEnabled && (
226 <div style="margin-top: 8px; text-align: center">
227 <a
228 href="/login/sso"
229 class="btn"
230 style="width: 100%; display: block"
231 >
232 Sign in with {ssoLabel}
233 </a>
234 </div>
235 )}
215236 <p class="auth-switch">
216237 New to gluecron? <a href="/register">Create an account</a>
217238 </p>
Addedsrc/routes/sso.tsx+426−0View fileUnifiedSplit
@@ -0,0 +1,426 @@
1/**
2 * Block I10 — Enterprise SSO (OIDC) routes.
3 *
4 * GET /admin/sso — site-admin config page
5 * POST /admin/sso — save config
6 * GET /login/sso — begin OIDC flow (redirect to IdP)
7 * GET /login/sso/callback — handle IdP redirect, create session
8 * POST /settings/sso/unlink — user drops their SSO link
9 */
10
11import { Hono } from "hono";
12import { eq } from "drizzle-orm";
13import { getCookie, setCookie, deleteCookie } from "hono/cookie";
14import { db } from "../db";
15import { ssoUserLinks } from "../db/schema";
16import { Layout } from "../views/layout";
17import { softAuth, requireAuth } from "../middleware/auth";
18import type { AuthEnv } from "../middleware/auth";
19import { isSiteAdmin } from "../lib/admin";
20import { audit } from "../lib/notify";
21import {
22 buildAuthorizeUrl,
23 exchangeCode,
24 fetchUserinfo,
25 findOrCreateUserFromSso,
26 getSsoConfig,
27 issueSsoSession,
28 randomToken,
29 ssoRedirectUri,
30 upsertSsoConfig,
31} from "../lib/sso";
32import { sessionCookieOptions } from "../lib/auth";
33
34const sso = new Hono<AuthEnv>();
35sso.use("*", softAuth);
36
37// Re-export the shared cookie options under the SSO namespace (buildable types)
38// — defined here so we don't double-import under the same name.
39function ssoStateCookieOpts(): {
40 httpOnly: boolean;
41 secure: boolean;
42 sameSite: "Lax";
43 path: string;
44 maxAge: number;
45} {
46 return {
47 httpOnly: true,
48 secure: process.env.NODE_ENV === "production",
49 sameSite: "Lax",
50 path: "/",
51 maxAge: 600, // 10 min to complete the flow
52 };
53}
54
55// ----------------------------------------------------------------------------
56// Admin config page
57// ----------------------------------------------------------------------------
58
59async function adminGate(c: any): Promise<{ user: any } | Response> {
60 const user = c.get("user");
61 if (!user) return c.redirect("/login?next=/admin/sso");
62 if (!(await isSiteAdmin(user.id))) {
63 return c.html(
64 <Layout title="Forbidden" user={user}>
65 <div class="empty-state">
66 <h2>403 — Not a site admin</h2>
67 <p>You don't have permission to configure SSO.</p>
68 </div>
69 </Layout>,
70 403
71 );
72 }
73 return { user };
74}
75
76sso.get("/admin/sso", requireAuth, async (c) => {
77 const g = await adminGate(c);
78 if (g instanceof Response) return g;
79 const { user } = g;
80
81 const cfg = await getSsoConfig();
82 const success = c.req.query("success");
83 const error = c.req.query("error");
84 const redirectUri = ssoRedirectUri();
85
86 return c.html(
87 <Layout title="SSO — Admin" user={user}>
88 <div class="settings-container" style="max-width:780px">
89 <h2>Enterprise SSO (OpenID Connect)</h2>
90 <p style="color:var(--text-muted)">
91 Configure a single site-wide OIDC provider. Users will see a
92 "Sign in with{" "}
93 <code>{cfg?.providerName || "SSO"}</code>" button on /login.
94 </p>
95 <div class="panel" style="padding:12px;margin-bottom:16px">
96 <div
97 style="font-size:12px;text-transform:uppercase;color:var(--text-muted)"
98 >
99 Redirect URI — paste this into your IdP
100 </div>
101 <code style="font-size:13px">{redirectUri}</code>
102 </div>
103
104 {success && (
105 <div class="auth-success">{decodeURIComponent(success)}</div>
106 )}
107 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
108
109 <form
110 method="POST"
111 action="/admin/sso"
112 class="panel"
113 style="padding:16px"
114 >
115 <label
116 style="display:flex;gap:8px;align-items:center;margin-bottom:12px"
117 >
118 <input
119 type="checkbox"
120 name="enabled"
121 value="1"
122 checked={!!cfg?.enabled}
123 />
124 <span>Enable SSO sign-in on /login</span>
125 </label>
126 <div class="form-group">
127 <label for="provider_name">Button label</label>
128 <input
129 type="text"
130 id="provider_name"
131 name="provider_name"
132 value={cfg?.providerName || "SSO"}
133 maxLength={120}
134 placeholder="Okta"
135 />
136 </div>
137 <div class="form-group">
138 <label for="issuer">Issuer URL</label>
139 <input
140 type="text"
141 id="issuer"
142 name="issuer"
143 value={cfg?.issuer || ""}
144 placeholder="https://example.okta.com"
145 />
146 </div>
147 <div class="form-group">
148 <label for="authorization_endpoint">Authorization endpoint</label>
149 <input
150 type="text"
151 id="authorization_endpoint"
152 name="authorization_endpoint"
153 value={cfg?.authorizationEndpoint || ""}
154 placeholder="https://example.okta.com/oauth2/v1/authorize"
155 />
156 </div>
157 <div class="form-group">
158 <label for="token_endpoint">Token endpoint</label>
159 <input
160 type="text"
161 id="token_endpoint"
162 name="token_endpoint"
163 value={cfg?.tokenEndpoint || ""}
164 placeholder="https://example.okta.com/oauth2/v1/token"
165 />
166 </div>
167 <div class="form-group">
168 <label for="userinfo_endpoint">Userinfo endpoint</label>
169 <input
170 type="text"
171 id="userinfo_endpoint"
172 name="userinfo_endpoint"
173 value={cfg?.userinfoEndpoint || ""}
174 placeholder="https://example.okta.com/oauth2/v1/userinfo"
175 />
176 </div>
177 <div class="form-group">
178 <label for="client_id">Client ID</label>
179 <input
180 type="text"
181 id="client_id"
182 name="client_id"
183 value={cfg?.clientId || ""}
184 autocomplete="off"
185 />
186 </div>
187 <div class="form-group">
188 <label for="client_secret">Client secret</label>
189 <input
190 type="password"
191 id="client_secret"
192 name="client_secret"
193 value={cfg?.clientSecret || ""}
194 autocomplete="off"
195 placeholder={
196 cfg?.clientSecret ? "(stored — leave blank to keep)" : ""
197 }
198 />
199 </div>
200 <div class="form-group">
201 <label for="scopes">OIDC scopes</label>
202 <input
203 type="text"
204 id="scopes"
205 name="scopes"
206 value={cfg?.scopes || "openid profile email"}
207 />
208 </div>
209 <div class="form-group">
210 <label for="allowed_email_domains">
211 Allowed email domains (comma-separated, empty = any)
212 </label>
213 <input
214 type="text"
215 id="allowed_email_domains"
216 name="allowed_email_domains"
217 value={cfg?.allowedEmailDomains || ""}
218 placeholder="example.com, acme.io"
219 />
220 </div>
221 <label
222 style="display:flex;gap:8px;align-items:center;margin:12px 0"
223 >
224 <input
225 type="checkbox"
226 name="auto_create_users"
227 value="1"
228 checked={cfg ? cfg.autoCreateUsers : true}
229 />
230 <span>
231 Auto-create local accounts on first SSO sign-in (turn off to
232 require admins to pre-provision users)
233 </span>
234 </label>
235 <button type="submit" class="btn btn-primary">
236 Save SSO settings
237 </button>
238 </form>
239 </div>
240 </Layout>
241 );
242});
243
244sso.post("/admin/sso", requireAuth, async (c) => {
245 const g = await adminGate(c);
246 if (g instanceof Response) return g;
247 const { user } = g;
248
249 const body = await c.req.parseBody();
250 const existing = await getSsoConfig();
251 const secretSubmitted = String(body.client_secret || "");
252 const result = await upsertSsoConfig({
253 enabled: String(body.enabled || "") === "1",
254 providerName: String(body.provider_name || "SSO"),
255 issuer: String(body.issuer || ""),
256 authorizationEndpoint: String(body.authorization_endpoint || ""),
257 tokenEndpoint: String(body.token_endpoint || ""),
258 userinfoEndpoint: String(body.userinfo_endpoint || ""),
259 clientId: String(body.client_id || ""),
260 clientSecret:
261 secretSubmitted.trim().length === 0 && existing?.clientSecret
262 ? existing.clientSecret
263 : secretSubmitted,
264 scopes: String(body.scopes || "openid profile email"),
265 allowedEmailDomains: String(body.allowed_email_domains || ""),
266 autoCreateUsers: String(body.auto_create_users || "") === "1",
267 });
268
269 if (!result.ok) {
270 return c.redirect(
271 `/admin/sso?error=${encodeURIComponent(result.error)}`
272 );
273 }
274
275 await audit({
276 userId: user.id,
277 action: "admin.sso.configure",
278 metadata: {
279 enabled: String(body.enabled || "") === "1",
280 provider: String(body.provider_name || "SSO"),
281 autoCreateUsers: String(body.auto_create_users || "") === "1",
282 allowedDomains: String(body.allowed_email_domains || "") || null,
283 },
284 });
285
286 return c.redirect(
287 `/admin/sso?success=${encodeURIComponent("SSO settings saved.")}`
288 );
289});
290
291// ----------------------------------------------------------------------------
292// OIDC flow
293// ----------------------------------------------------------------------------
294
295sso.get("/login/sso", async (c) => {
296 const cfg = await getSsoConfig();
297 if (!cfg || !cfg.enabled) {
298 return c.redirect(
299 `/login?error=${encodeURIComponent("SSO is not enabled")}`
300 );
301 }
302 if (
303 !cfg.authorizationEndpoint ||
304 !cfg.tokenEndpoint ||
305 !cfg.userinfoEndpoint ||
306 !cfg.clientId ||
307 !cfg.clientSecret
308 ) {
309 return c.redirect(
310 `/login?error=${encodeURIComponent("SSO is not fully configured")}`
311 );
312 }
313 const state = randomToken(16);
314 const nonce = randomToken(16);
315 const redirectUri = ssoRedirectUri();
316 let target: string;
317 try {
318 target = buildAuthorizeUrl(cfg, state, nonce, redirectUri);
319 } catch (err) {
320 return c.redirect(
321 `/login?error=${encodeURIComponent(
322 err instanceof Error ? err.message : "SSO misconfigured"
323 )}`
324 );
325 }
326 setCookie(c, "sso_state", state, ssoStateCookieOpts());
327 setCookie(c, "sso_nonce", nonce, ssoStateCookieOpts());
328 return c.redirect(target);
329});
330
331sso.get("/login/sso/callback", async (c) => {
332 const cfg = await getSsoConfig();
333 if (!cfg || !cfg.enabled) {
334 return c.redirect(
335 `/login?error=${encodeURIComponent("SSO is not enabled")}`
336 );
337 }
338
339 const code = c.req.query("code");
340 const state = c.req.query("state");
341 const errCode = c.req.query("error");
342 if (errCode) {
343 return c.redirect(
344 `/login?error=${encodeURIComponent(
345 `SSO provider error: ${errCode}`
346 )}`
347 );
348 }
349 if (!code || !state) {
350 return c.redirect(
351 `/login?error=${encodeURIComponent("Missing code or state")}`
352 );
353 }
354
355 const expectedState = getCookie(c, "sso_state");
356 if (!expectedState || expectedState !== state) {
357 return c.redirect(
358 `/login?error=${encodeURIComponent(
359 "SSO state mismatch. Please try again."
360 )}`
361 );
362 }
363
364 // One-shot cookies — burn them even on failure
365 deleteCookie(c, "sso_state", { path: "/" });
366 deleteCookie(c, "sso_nonce", { path: "/" });
367
368 try {
369 const tokens = await exchangeCode(cfg, code, ssoRedirectUri());
370 const claims = await fetchUserinfo(cfg, tokens.access_token);
371 const result = await findOrCreateUserFromSso(claims, cfg);
372 if (!result.ok) {
373 return c.redirect(`/login?error=${encodeURIComponent(result.error)}`);
374 }
375
376 const token = await issueSsoSession(result.user.id);
377 setCookie(c, "session", token, sessionCookieOptions());
378
379 await audit({
380 userId: result.user.id,
381 action: "auth.sso.login",
382 metadata: {
383 provider: cfg.providerName,
384 sub: claims.sub,
385 email: claims.email || null,
386 },
387 });
388
389 return c.redirect("/");
390 } catch (err) {
391 console.error("[sso] callback error:", err);
392 return c.redirect(
393 `/login?error=${encodeURIComponent(
394 err instanceof Error
395 ? `SSO sign-in failed: ${err.message}`
396 : "SSO sign-in failed"
397 )}`
398 );
399 }
400});
401
402// ----------------------------------------------------------------------------
403// User: unlink SSO
404// ----------------------------------------------------------------------------
405
406sso.post("/settings/sso/unlink", requireAuth, async (c) => {
407 const user = c.get("user")!;
408 const links = await db
409 .select({ id: ssoUserLinks.id })
410 .from(ssoUserLinks)
411 .where(eq(ssoUserLinks.userId, user.id));
412 if (links.length === 0) {
413 return c.redirect("/settings?error=" + encodeURIComponent("No SSO link"));
414 }
415 await db.delete(ssoUserLinks).where(eq(ssoUserLinks.userId, user.id));
416 await audit({
417 userId: user.id,
418 action: "auth.sso.unlink",
419 metadata: { removedLinks: links.length },
420 });
421 return c.redirect(
422 "/settings?success=" + encodeURIComponent("SSO link removed.")
423 );
424});
425
426export default sso;
0427