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.
| edf7c36 | 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 | ||
| 22 | import { eq } from "drizzle-orm"; | |
| 23 | import { db } from "../db"; | |
| 24 | import { | |
| 25 | ssoConfig, | |
| 26 | ssoUserLinks, | |
| 27 | users, | |
| 28 | sessions, | |
| 29 | type SsoConfig, | |
| 30 | type SsoUserLink, | |
| 31 | type User, | |
| 32 | } from "../db/schema"; | |
| 33 | import { | |
| 34 | generateSessionToken, | |
| 35 | sessionExpiry, | |
| 36 | } from "./auth"; | |
| 37 | import { config } from "./config"; | |
| 38 | ||
| 39 | // ---------------------------------------------------------------------------- | |
| 40 | // Types | |
| 41 | // ---------------------------------------------------------------------------- | |
| 42 | ||
| 43 | export 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 | ||
| 57 | export 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 | ||
| 67 | export 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 | ||
| 80 | const SSO_CONFIG_ID = "default"; | |
| 81 | ||
| 82 | /** Returns the singleton SSO config, or null if never configured. */ | |
| 83 | export 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. */ | |
| 97 | export 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 | ||
| 146 | function 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 | */ | |
| 160 | export 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. */ | |
| 180 | export 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 | */ | |
| 193 | export 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. */ | |
| 230 | export 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 | */ | |
| 261 | export 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 | ||
| 280 | export 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 | */ | |
| 303 | export 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. */ | |
| 402 | export 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. */ | |
| 412 | async 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. */ | |
| 431 | export 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. */ | |
| 442 | export function ssoRedirectUri(): string { | |
| 443 | return `${config.appBaseUrl}/login/sso/callback`; | |
| 444 | } | |
| 445 | ||
| 446 | // ---------------------------------------------------------------------------- | |
| 447 | // Test-only exports | |
| 448 | // ---------------------------------------------------------------------------- | |
| 449 | ||
| 450 | export const __internal = { | |
| 451 | emptyToNull, | |
| 452 | normalizeUsername, | |
| 453 | }; |