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.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.

sso.tsxBlame541 lines · 2 contributors
edf7c36Claude1/**
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
fc0ca99Claude109 <div
110 class="panel"
111 style="padding:14px 16px;margin-bottom:14px;display:flex;gap:10px;flex-wrap:wrap;align-items:center"
112 >
113 <span style="font-size:13px;color:var(--text-muted)">
114 Quick fill from a preset:
115 </span>
116 <button
117 type="button"
118 class="btn btn-sm"
119 onclick="window.gluecronSsoPreset('google')"
120 >
121 Google Workspace
122 </button>
123 <button
124 type="button"
125 class="btn btn-sm"
126 onclick="window.gluecronSsoPreset('okta')"
127 >
128 Okta
129 </button>
130 <button
131 type="button"
132 class="btn btn-sm"
133 onclick="window.gluecronSsoPreset('auth0')"
134 >
135 Auth0
136 </button>
137 <button
138 type="button"
139 class="btn btn-sm"
140 onclick="window.gluecronSsoPreset('azure')"
141 >
142 Microsoft Entra
143 </button>
144 </div>
145 <script
146 dangerouslySetInnerHTML={{
147 __html: /* js */ `
148 window.gluecronSsoPreset = function (provider) {
149 var P = {
150 google: {
151 provider_name: 'Google',
152 issuer: 'https://accounts.google.com',
153 authorization_endpoint: 'https://accounts.google.com/o/oauth2/v2/auth',
154 token_endpoint: 'https://oauth2.googleapis.com/token',
155 userinfo_endpoint: 'https://openidconnect.googleapis.com/v1/userinfo',
156 scopes: 'openid email profile',
157 },
158 okta: {
159 provider_name: 'Okta',
160 issuer: 'https://YOUR-TENANT.okta.com',
161 authorization_endpoint: 'https://YOUR-TENANT.okta.com/oauth2/v1/authorize',
162 token_endpoint: 'https://YOUR-TENANT.okta.com/oauth2/v1/token',
163 userinfo_endpoint: 'https://YOUR-TENANT.okta.com/oauth2/v1/userinfo',
164 scopes: 'openid email profile',
165 },
166 auth0: {
167 provider_name: 'Auth0',
168 issuer: 'https://YOUR-TENANT.auth0.com',
169 authorization_endpoint: 'https://YOUR-TENANT.auth0.com/authorize',
170 token_endpoint: 'https://YOUR-TENANT.auth0.com/oauth/token',
171 userinfo_endpoint: 'https://YOUR-TENANT.auth0.com/userinfo',
172 scopes: 'openid email profile',
173 },
174 azure: {
175 provider_name: 'Microsoft',
176 issuer: 'https://login.microsoftonline.com/YOUR-TENANT-ID/v2.0',
177 authorization_endpoint: 'https://login.microsoftonline.com/YOUR-TENANT-ID/oauth2/v2.0/authorize',
178 token_endpoint: 'https://login.microsoftonline.com/YOUR-TENANT-ID/oauth2/v2.0/token',
179 userinfo_endpoint: 'https://graph.microsoft.com/oidc/userinfo',
180 scopes: 'openid email profile',
181 },
182 };
183 var p = P[provider];
184 if (!p) return;
185 for (var k in p) {
186 var el = document.getElementById(k);
187 if (el) el.value = p[k];
188 }
189 };
190 `,
191 }}
192 />
193
edf7c36Claude194 <form
001af43Claude195 method="post"
edf7c36Claude196 action="/admin/sso"
197 class="panel"
198 style="padding:16px"
199 >
200 <label
201 style="display:flex;gap:8px;align-items:center;margin-bottom:12px"
202 >
203 <input
204 type="checkbox"
205 name="enabled"
206 value="1"
207 checked={!!cfg?.enabled}
e1c0fbecopilot-swe-agent[bot]208 aria-label="Enable SSO sign-in on /login"
edf7c36Claude209 />
210 <span>Enable SSO sign-in on /login</span>
211 </label>
212 <div class="form-group">
213 <label for="provider_name">Button label</label>
214 <input
215 type="text"
216 id="provider_name"
217 name="provider_name"
218 value={cfg?.providerName || "SSO"}
219 maxLength={120}
220 placeholder="Okta"
221 />
222 </div>
223 <div class="form-group">
224 <label for="issuer">Issuer URL</label>
225 <input
226 type="text"
227 id="issuer"
228 name="issuer"
229 value={cfg?.issuer || ""}
230 placeholder="https://example.okta.com"
231 />
232 </div>
233 <div class="form-group">
234 <label for="authorization_endpoint">Authorization endpoint</label>
235 <input
236 type="text"
237 id="authorization_endpoint"
238 name="authorization_endpoint"
239 value={cfg?.authorizationEndpoint || ""}
240 placeholder="https://example.okta.com/oauth2/v1/authorize"
241 />
242 </div>
243 <div class="form-group">
244 <label for="token_endpoint">Token endpoint</label>
245 <input
246 type="text"
247 id="token_endpoint"
248 name="token_endpoint"
249 value={cfg?.tokenEndpoint || ""}
250 placeholder="https://example.okta.com/oauth2/v1/token"
251 />
252 </div>
253 <div class="form-group">
254 <label for="userinfo_endpoint">Userinfo endpoint</label>
255 <input
256 type="text"
257 id="userinfo_endpoint"
258 name="userinfo_endpoint"
259 value={cfg?.userinfoEndpoint || ""}
260 placeholder="https://example.okta.com/oauth2/v1/userinfo"
261 />
262 </div>
263 <div class="form-group">
264 <label for="client_id">Client ID</label>
265 <input
266 type="text"
267 id="client_id"
268 name="client_id"
269 value={cfg?.clientId || ""}
270 autocomplete="off"
271 />
272 </div>
273 <div class="form-group">
274 <label for="client_secret">Client secret</label>
275 <input
276 type="password"
277 id="client_secret"
278 name="client_secret"
279 value={cfg?.clientSecret || ""}
280 autocomplete="off"
281 placeholder={
282 cfg?.clientSecret ? "(stored — leave blank to keep)" : ""
283 }
284 />
285 </div>
286 <div class="form-group">
287 <label for="scopes">OIDC scopes</label>
288 <input
289 type="text"
290 id="scopes"
291 name="scopes"
292 value={cfg?.scopes || "openid profile email"}
293 />
294 </div>
295 <div class="form-group">
296 <label for="allowed_email_domains">
297 Allowed email domains (comma-separated, empty = any)
298 </label>
299 <input
300 type="text"
301 id="allowed_email_domains"
302 name="allowed_email_domains"
303 value={cfg?.allowedEmailDomains || ""}
304 placeholder="example.com, acme.io"
305 />
306 </div>
307 <label
308 style="display:flex;gap:8px;align-items:center;margin:12px 0"
309 >
310 <input
311 type="checkbox"
312 name="auto_create_users"
313 value="1"
314 checked={cfg ? cfg.autoCreateUsers : true}
e1c0fbecopilot-swe-agent[bot]315 aria-label="Auto-create users on first SSO sign-in"
edf7c36Claude316 />
317 <span>
318 Auto-create local accounts on first SSO sign-in (turn off to
319 require admins to pre-provision users)
320 </span>
321 </label>
322 <button type="submit" class="btn btn-primary">
323 Save SSO settings
324 </button>
325 </form>
326 </div>
327 </Layout>
328 );
329});
330
331sso.post("/admin/sso", requireAuth, async (c) => {
332 const g = await adminGate(c);
333 if (g instanceof Response) return g;
334 const { user } = g;
335
336 const body = await c.req.parseBody();
337 const existing = await getSsoConfig();
338 const secretSubmitted = String(body.client_secret || "");
339 const result = await upsertSsoConfig({
340 enabled: String(body.enabled || "") === "1",
341 providerName: String(body.provider_name || "SSO"),
342 issuer: String(body.issuer || ""),
343 authorizationEndpoint: String(body.authorization_endpoint || ""),
344 tokenEndpoint: String(body.token_endpoint || ""),
345 userinfoEndpoint: String(body.userinfo_endpoint || ""),
346 clientId: String(body.client_id || ""),
347 clientSecret:
348 secretSubmitted.trim().length === 0 && existing?.clientSecret
349 ? existing.clientSecret
350 : secretSubmitted,
351 scopes: String(body.scopes || "openid profile email"),
352 allowedEmailDomains: String(body.allowed_email_domains || ""),
353 autoCreateUsers: String(body.auto_create_users || "") === "1",
354 });
355
356 if (!result.ok) {
357 return c.redirect(
358 `/admin/sso?error=${encodeURIComponent(result.error)}`
359 );
360 }
361
362 await audit({
363 userId: user.id,
364 action: "admin.sso.configure",
365 metadata: {
366 enabled: String(body.enabled || "") === "1",
367 provider: String(body.provider_name || "SSO"),
368 autoCreateUsers: String(body.auto_create_users || "") === "1",
369 allowedDomains: String(body.allowed_email_domains || "") || null,
370 },
371 });
372
373 return c.redirect(
374 `/admin/sso?success=${encodeURIComponent("SSO settings saved.")}`
375 );
376});
377
378// ----------------------------------------------------------------------------
379// OIDC flow
380// ----------------------------------------------------------------------------
381
382sso.get("/login/sso", async (c) => {
383 const cfg = await getSsoConfig();
384 if (!cfg || !cfg.enabled) {
385 return c.redirect(
386 `/login?error=${encodeURIComponent("SSO is not enabled")}`
387 );
388 }
389 if (
390 !cfg.authorizationEndpoint ||
391 !cfg.tokenEndpoint ||
392 !cfg.userinfoEndpoint ||
393 !cfg.clientId ||
394 !cfg.clientSecret
395 ) {
396 return c.redirect(
397 `/login?error=${encodeURIComponent("SSO is not fully configured")}`
398 );
399 }
400 const state = randomToken(16);
401 const nonce = randomToken(16);
402 const redirectUri = ssoRedirectUri();
403 let target: string;
404 try {
405 target = buildAuthorizeUrl(cfg, state, nonce, redirectUri);
406 } catch (err) {
407 return c.redirect(
408 `/login?error=${encodeURIComponent(
409 err instanceof Error ? err.message : "SSO misconfigured"
410 )}`
411 );
412 }
413 setCookie(c, "sso_state", state, ssoStateCookieOpts());
414 setCookie(c, "sso_nonce", nonce, ssoStateCookieOpts());
415 return c.redirect(target);
416});
417
418sso.get("/login/sso/callback", async (c) => {
419 const cfg = await getSsoConfig();
420 if (!cfg || !cfg.enabled) {
421 return c.redirect(
422 `/login?error=${encodeURIComponent("SSO is not enabled")}`
423 );
424 }
425
426 const code = c.req.query("code");
427 const state = c.req.query("state");
428 const errCode = c.req.query("error");
429 if (errCode) {
430 return c.redirect(
431 `/login?error=${encodeURIComponent(
432 `SSO provider error: ${errCode}`
433 )}`
434 );
435 }
436 if (!code || !state) {
437 return c.redirect(
438 `/login?error=${encodeURIComponent("Missing code or state")}`
439 );
440 }
441
442 const expectedState = getCookie(c, "sso_state");
443 if (!expectedState || expectedState !== state) {
444 return c.redirect(
445 `/login?error=${encodeURIComponent(
446 "SSO state mismatch. Please try again."
447 )}`
448 );
449 }
450
451 // One-shot cookies — burn them even on failure
452 deleteCookie(c, "sso_state", { path: "/" });
453 deleteCookie(c, "sso_nonce", { path: "/" });
454
455 try {
456 const tokens = await exchangeCode(cfg, code, ssoRedirectUri());
457 const claims = await fetchUserinfo(cfg, tokens.access_token);
458 const result = await findOrCreateUserFromSso(claims, cfg);
459 if (!result.ok) {
460 return c.redirect(`/login?error=${encodeURIComponent(result.error)}`);
461 }
462
463 const token = await issueSsoSession(result.user.id);
464 setCookie(c, "session", token, sessionCookieOptions());
465
466 await audit({
467 userId: result.user.id,
468 action: "auth.sso.login",
469 metadata: {
470 provider: cfg.providerName,
471 sub: claims.sub,
472 email: claims.email || null,
473 },
474 });
475
476 return c.redirect("/");
477 } catch (err) {
478 console.error("[sso] callback error:", err);
fc0ca99Claude479 const friendly = friendlySsoError(err);
480 return c.redirect(`/login?error=${encodeURIComponent(friendly)}`);
edf7c36Claude481 }
482});
483
fc0ca99Claude484/**
485 * Map the raw OIDC failure shape to a one-sentence message safe to render
486 * inside an HTML <div>. We never surface the IdP's response body — those
487 * have been Google 404 HTML pages, Azure JSON blobs full of object IDs,
488 * etc. The raw `err.message` is logged via console.error above so admins
489 * can still diagnose from server logs.
490 */
491function friendlySsoError(err: unknown): string {
492 const raw = err instanceof Error ? err.message : String(err ?? "");
493 if (raw.includes("token_endpoint")) {
494 if (/\b40[01]\b/.test(raw)) {
495 return "SSO sign-in failed: the identity provider rejected our token request (HTTP 4xx). Check the Token endpoint URL and Client Secret at /admin/sso.";
496 }
497 if (/\b404\b/.test(raw)) {
498 return "SSO sign-in failed: the Token endpoint URL returned 404. Verify the URL at /admin/sso — for Google it's https://oauth2.googleapis.com/token.";
499 }
500 if (/\b5\d\d\b/.test(raw)) {
501 return "SSO sign-in failed: the identity provider returned a server error. Try again, or check the IdP's status page.";
502 }
503 return "SSO sign-in failed at the token exchange step. Check /admin/sso configuration.";
504 }
505 if (raw.includes("userinfo_endpoint")) {
506 return "SSO sign-in failed while fetching profile info. Verify the Userinfo endpoint URL at /admin/sso.";
507 }
508 if (raw.includes("state cookie") || raw.includes("nonce")) {
509 return "SSO sign-in expired before you returned to the site. Please try again.";
510 }
511 if (raw.includes("email") && raw.includes("not allowed")) {
512 return "SSO sign-in failed: your email domain is not on the allowlist for this site.";
513 }
514 return "SSO sign-in failed. Check /admin/sso configuration or try again.";
515}
516
edf7c36Claude517// ----------------------------------------------------------------------------
518// User: unlink SSO
519// ----------------------------------------------------------------------------
520
521sso.post("/settings/sso/unlink", requireAuth, async (c) => {
522 const user = c.get("user")!;
523 const links = await db
524 .select({ id: ssoUserLinks.id })
525 .from(ssoUserLinks)
526 .where(eq(ssoUserLinks.userId, user.id));
527 if (links.length === 0) {
528 return c.redirect("/settings?error=" + encodeURIComponent("No SSO link"));
529 }
530 await db.delete(ssoUserLinks).where(eq(ssoUserLinks.userId, user.id));
531 await audit({
532 userId: user.id,
533 action: "auth.sso.unlink",
534 metadata: { removedLinks: links.length },
535 });
536 return c.redirect(
537 "/settings?success=" + encodeURIComponent("SSO link removed.")
538 );
539});
540
541export default sso;