CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
sso.tsx
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 (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 | ||
| 11 | import { Hono } from "hono"; | |
| 12 | import { eq } from "drizzle-orm"; | |
| 13 | import { getCookie, setCookie, deleteCookie } from "hono/cookie"; | |
| 14 | import { db } from "../db"; | |
| 15 | import { ssoUserLinks } from "../db/schema"; | |
| 16 | import { Layout } from "../views/layout"; | |
| 17 | import { softAuth, requireAuth } from "../middleware/auth"; | |
| 18 | import type { AuthEnv } from "../middleware/auth"; | |
| 19 | import { isSiteAdmin } from "../lib/admin"; | |
| 20 | import { audit } from "../lib/notify"; | |
| 21 | import { | |
| 22 | buildAuthorizeUrl, | |
| 23 | exchangeCode, | |
| 24 | fetchUserinfo, | |
| 25 | findOrCreateUserFromSso, | |
| 26 | getSsoConfig, | |
| 27 | issueSsoSession, | |
| 28 | randomToken, | |
| 29 | ssoRedirectUri, | |
| 30 | upsertSsoConfig, | |
| 31 | } from "../lib/sso"; | |
| 32 | import { sessionCookieOptions } from "../lib/auth"; | |
| 33 | ||
| 34 | const sso = new Hono<AuthEnv>(); | |
| 35 | sso.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. | |
| 39 | function 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 | ||
| 59 | async 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 | ||
| 76 | sso.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 | |
| 001af43 | 110 | method="post" |
| edf7c36 | 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 | ||
| 244 | sso.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 | ||
| 295 | sso.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 | ||
| 331 | sso.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 | ||
| 406 | sso.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 | ||
| 426 | export default sso; |