Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

sso.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

sso.tsBlame453 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))
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};