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.tsxBlame1157 lines · 1 contributor
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
f0b5874Claude9 *
10 * Visual polish (2026): gradient hairline + orb hero + provider preset cards
11 * + status pills + section forms. Scoped under `.sso-*` so it can't bleed
12 * into other admin pages. Every OIDC flow, redirect URI, state/nonce, and
13 * session issuance path is preserved EXACTLY — this is security-critical.
edf7c36Claude14 */
15
16import { Hono } from "hono";
17import { eq } from "drizzle-orm";
18import { getCookie, setCookie, deleteCookie } from "hono/cookie";
19import { db } from "../db";
20import { ssoUserLinks } from "../db/schema";
21import { Layout } from "../views/layout";
22import { softAuth, requireAuth } from "../middleware/auth";
23import type { AuthEnv } from "../middleware/auth";
24import { isSiteAdmin } from "../lib/admin";
25import { audit } from "../lib/notify";
26import {
27 buildAuthorizeUrl,
28 exchangeCode,
29 fetchUserinfo,
30 findOrCreateUserFromSso,
31 getSsoConfig,
32 issueSsoSession,
33 randomToken,
34 ssoRedirectUri,
35 upsertSsoConfig,
36} from "../lib/sso";
37import { sessionCookieOptions } from "../lib/auth";
38
39const sso = new Hono<AuthEnv>();
40sso.use("*", softAuth);
41
42// Re-export the shared cookie options under the SSO namespace (buildable types)
43// — defined here so we don't double-import under the same name.
44function ssoStateCookieOpts(): {
45 httpOnly: boolean;
46 secure: boolean;
47 sameSite: "Lax";
48 path: string;
49 maxAge: number;
50} {
51 return {
52 httpOnly: true,
53 secure: process.env.NODE_ENV === "production",
54 sameSite: "Lax",
55 path: "/",
56 maxAge: 600, // 10 min to complete the flow
57 };
58}
59
f0b5874Claude60// ----------------------------------------------------------------------------
61// Scoped styles — every class prefixed `.sso-` so this surface can't bleed
62// into other admin pages.
63// ----------------------------------------------------------------------------
64const ssoStyles = `
eed4684Claude65 .sso-wrap { max-width: 1320px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
f0b5874Claude66
67 /* ─── Hero ─── */
68 .sso-hero {
69 position: relative;
70 margin-bottom: var(--space-5);
71 padding: var(--space-5) var(--space-6);
72 background: var(--bg-elevated);
73 border: 1px solid var(--border);
74 border-radius: 16px;
75 overflow: hidden;
76 }
77 .sso-hero::before {
78 content: '';
79 position: absolute;
80 top: 0; left: 0; right: 0;
81 height: 2px;
82 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
83 opacity: 0.7;
84 pointer-events: none;
85 }
86 .sso-hero-orb {
87 position: absolute;
88 inset: -20% -10% auto auto;
89 width: 380px; height: 380px;
90 background: radial-gradient(circle, rgba(140,109,255,0.20), rgba(54,197,214,0.10) 45%, transparent 70%);
91 filter: blur(80px);
92 opacity: 0.7;
93 pointer-events: none;
94 z-index: 0;
95 }
96 .sso-hero-inner { position: relative; z-index: 1; max-width: 680px; }
97 .sso-eyebrow {
98 font-size: 11px;
99 text-transform: uppercase;
100 letter-spacing: 0.18em;
101 color: var(--text-muted);
102 margin-bottom: var(--space-2);
103 display: inline-flex;
104 align-items: center;
105 gap: 8px;
106 font-family: var(--font-mono);
107 font-weight: 600;
108 }
109 .sso-eyebrow-dot {
110 width: 8px; height: 8px;
111 border-radius: 9999px;
112 background: linear-gradient(135deg, #8c6dff, #36c5d6);
113 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
114 }
115 .sso-title {
116 font-size: clamp(26px, 3.5vw, 38px);
117 font-family: var(--font-display);
118 font-weight: 800;
119 letter-spacing: -0.028em;
120 line-height: 1.06;
121 margin: 0 0 var(--space-2);
122 color: var(--text-strong);
123 }
124 .sso-title-grad {
125 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
126 -webkit-background-clip: text;
127 background-clip: text;
128 -webkit-text-fill-color: transparent;
129 color: transparent;
130 }
131 .sso-sub {
132 font-size: 14.5px;
133 color: var(--text-muted);
134 margin: 0;
135 line-height: 1.55;
136 }
137 .sso-sub code {
138 font-family: var(--font-mono);
139 font-size: 12.5px;
140 background: var(--bg-tertiary);
141 padding: 1px 5px;
142 border-radius: 4px;
143 color: var(--text);
144 }
145
146 /* ─── Status pill (hero corner) ─── */
147 .sso-status-pill {
148 display: inline-flex;
149 align-items: center;
150 gap: 6px;
151 margin-top: var(--space-2);
152 padding: 4px 12px;
153 border-radius: 9999px;
154 font-size: 11.5px;
155 font-weight: 700;
156 letter-spacing: 0.05em;
157 text-transform: uppercase;
158 }
159 .sso-status-pill.is-on {
160 background: rgba(52,211,153,0.14);
161 color: #6ee7b7;
162 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.36);
163 }
164 .sso-status-pill.is-off {
165 background: rgba(251,191,36,0.10);
166 color: #fde68a;
167 box-shadow: inset 0 0 0 1px rgba(251,191,36,0.32);
168 }
169 .sso-status-pill.is-missing {
170 background: rgba(148,163,184,0.10);
171 color: #cbd5e1;
172 box-shadow: inset 0 0 0 1px rgba(148,163,184,0.28);
173 }
174 .sso-status-pill .dot { width: 6px; height: 6px; border-radius: 9999px; background: currentColor; }
175
176 /* ─── Banner ─── */
177 .sso-banner {
178 margin-bottom: var(--space-4);
179 padding: 10px 14px;
180 border-radius: 10px;
181 font-size: 13.5px;
182 border: 1px solid var(--border);
183 background: rgba(255,255,255,0.025);
184 color: var(--text);
185 }
186 .sso-banner.is-ok {
187 border-color: rgba(52,211,153,0.40);
188 background: rgba(52,211,153,0.08);
189 color: #bbf7d0;
190 }
191 .sso-banner.is-error {
192 border-color: rgba(248,113,113,0.40);
193 background: rgba(248,113,113,0.08);
194 color: #fecaca;
195 }
196
197 /* ─── Section card ─── */
198 .sso-section {
199 margin-bottom: var(--space-5);
200 background: var(--bg-elevated);
201 border: 1px solid var(--border);
202 border-radius: 14px;
203 overflow: hidden;
204 }
205 .sso-section-head {
206 padding: var(--space-4) var(--space-5);
207 border-bottom: 1px solid var(--border);
208 display: flex;
209 align-items: flex-start;
210 justify-content: space-between;
211 gap: var(--space-3);
212 flex-wrap: wrap;
213 }
214 .sso-section-head-text { flex: 1; min-width: 240px; }
215 .sso-section-title {
216 margin: 0;
217 font-family: var(--font-display);
218 font-size: 16px;
219 font-weight: 700;
220 letter-spacing: -0.012em;
221 color: var(--text-strong);
222 }
223 .sso-section-sub {
224 margin: 4px 0 0;
225 font-size: 12.5px;
226 color: var(--text-muted);
227 line-height: 1.45;
228 }
229 .sso-section-body { padding: var(--space-4) var(--space-5); }
230
231 /* ─── Redirect-URI callout (copyable) ─── */
232 .sso-callout {
233 display: flex;
234 gap: var(--space-3);
235 align-items: center;
236 padding: 12px 14px;
237 background: rgba(140,109,255,0.05);
238 border: 1px dashed rgba(140,109,255,0.30);
239 border-radius: 12px;
240 flex-wrap: wrap;
241 }
242 .sso-callout-label {
243 font-size: 11px;
244 text-transform: uppercase;
245 letter-spacing: 0.10em;
246 color: var(--text-muted);
247 font-weight: 700;
248 flex-shrink: 0;
249 }
250 .sso-callout code {
251 flex: 1;
252 min-width: 0;
253 font-family: var(--font-mono);
254 font-size: 12.5px;
255 color: var(--text);
256 background: rgba(255,255,255,0.04);
257 border: 1px solid var(--border);
258 padding: 6px 10px;
259 border-radius: 8px;
260 word-break: break-all;
261 overflow-wrap: anywhere;
262 }
263
264 /* ─── Provider preset cards ─── */
265 .sso-providers {
266 display: grid;
267 grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
268 gap: var(--space-2);
269 }
270 .sso-provider {
271 display: flex;
272 align-items: center;
273 gap: 10px;
274 padding: 12px;
275 border: 1px solid var(--border-strong);
276 border-radius: 12px;
277 background: rgba(255,255,255,0.02);
278 color: var(--text);
279 cursor: pointer;
280 font: inherit;
281 text-align: left;
282 transition: transform 120ms ease, border-color 120ms ease, background 120ms ease;
283 }
284 .sso-provider:hover {
285 transform: translateY(-1px);
286 border-color: rgba(140,109,255,0.45);
287 background: rgba(140,109,255,0.06);
288 }
289 .sso-provider-icon {
290 flex-shrink: 0;
291 width: 36px; height: 36px;
292 border-radius: 10px;
293 display: flex;
294 align-items: center;
295 justify-content: center;
296 background: linear-gradient(135deg, rgba(140,109,255,0.22), rgba(54,197,214,0.14));
297 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.30);
298 font-family: var(--font-display);
299 font-size: 16px;
300 font-weight: 800;
301 color: #e9d5ff;
302 }
303 .sso-provider-name {
304 font-size: 13px;
305 font-weight: 600;
306 color: var(--text-strong);
307 line-height: 1.2;
308 }
309 .sso-provider-kind {
310 font-size: 11px;
311 color: var(--text-muted);
312 margin-top: 1px;
313 }
314
315 /* ─── Form group ─── */
316 .sso-field { margin-bottom: var(--space-4); }
317 .sso-field:last-child { margin-bottom: 0; }
318 .sso-field-row {
319 display: flex;
320 align-items: center;
321 justify-content: space-between;
322 gap: var(--space-2);
323 margin-bottom: 6px;
324 }
325 .sso-field label {
326 display: block;
327 font-family: var(--font-mono);
328 font-size: 12.5px;
329 font-weight: 600;
330 color: var(--text-strong);
331 letter-spacing: -0.005em;
332 }
333 .sso-input {
334 width: 100%;
335 padding: 9px 12px;
336 font-size: 13.5px;
337 color: var(--text);
338 background: var(--bg);
339 border: 1px solid var(--border-strong);
340 border-radius: 8px;
341 outline: none;
342 font-family: var(--font-mono);
343 transition: border-color 120ms ease, box-shadow 120ms ease;
344 box-sizing: border-box;
345 }
346 .sso-input:focus {
347 border-color: var(--border-focus);
348 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
349 }
350 .sso-hint {
351 font-size: 11.5px;
352 color: var(--text-muted);
353 margin-top: 6px;
354 line-height: 1.45;
355 }
356 .sso-toggle-row {
357 display: flex;
358 align-items: flex-start;
359 gap: 10px;
360 padding: 10px 12px;
361 background: rgba(255,255,255,0.025);
362 border: 1px solid var(--border);
363 border-radius: 10px;
364 margin-bottom: var(--space-4);
365 }
366 .sso-toggle-row input[type="checkbox"] { margin-top: 2px; flex-shrink: 0; }
367 .sso-toggle-row span {
368 font-size: 13px;
369 color: var(--text);
370 line-height: 1.45;
371 }
372
373 /* ─── Buttons ─── */
374 .sso-btn {
375 display: inline-flex;
376 align-items: center;
377 justify-content: center;
378 gap: 8px;
379 padding: 11px 18px;
380 border-radius: 10px;
381 font-size: 13.5px;
382 font-weight: 600;
383 text-decoration: none;
384 border: 1px solid transparent;
385 cursor: pointer;
386 font: inherit;
387 line-height: 1;
388 transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease, border-color 120ms ease, color 120ms ease;
389 }
390 .sso-btn-primary {
391 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
392 color: #ffffff;
393 box-shadow: 0 6px 18px -6px rgba(140,109,255,0.50), inset 0 1px 0 rgba(255,255,255,0.16);
394 }
395 .sso-btn-primary:hover {
396 transform: translateY(-1px);
397 box-shadow: 0 10px 24px -8px rgba(140,109,255,0.60), inset 0 1px 0 rgba(255,255,255,0.20);
398 color: #ffffff;
399 text-decoration: none;
400 }
401 .sso-btn-ghost {
402 background: transparent;
403 color: var(--text);
404 border-color: var(--border-strong);
405 }
406 .sso-btn-ghost:hover {
407 background: rgba(140,109,255,0.06);
408 border-color: rgba(140,109,255,0.45);
409 color: var(--text-strong);
410 text-decoration: none;
411 }
412
413 /* ─── Section foot bar (save button) ─── */
414 .sso-foot {
415 padding: var(--space-3) var(--space-5);
416 border-top: 1px solid var(--border);
417 background: rgba(255,255,255,0.012);
418 display: flex;
419 justify-content: space-between;
420 gap: var(--space-2);
421 align-items: center;
422 flex-wrap: wrap;
423 }
424 .sso-foot-hint {
425 font-size: 12.5px;
426 color: var(--text-muted);
427 }
428
429 /* ─── 403 ─── */
430 .sso-403 {
431 max-width: 540px;
432 margin: var(--space-12) auto;
433 padding: var(--space-6);
434 text-align: center;
435 background: var(--bg-elevated);
436 border: 1px solid var(--border);
437 border-radius: 16px;
438 }
439 .sso-403 h2 {
440 font-family: var(--font-display);
441 font-size: 22px;
442 margin: 0 0 8px;
443 color: var(--text-strong);
444 }
445 .sso-403 p { color: var(--text-muted); margin: 0; font-size: 14px; }
446`;
447
edf7c36Claude448// ----------------------------------------------------------------------------
449// Admin config page
450// ----------------------------------------------------------------------------
451
452async function adminGate(c: any): Promise<{ user: any } | Response> {
453 const user = c.get("user");
454 if (!user) return c.redirect("/login?next=/admin/sso");
455 if (!(await isSiteAdmin(user.id))) {
456 return c.html(
457 <Layout title="Forbidden" user={user}>
f0b5874Claude458 <div class="sso-wrap">
459 <div class="sso-403">
460 <h2>403 — Not a site admin</h2>
461 <p>You don't have permission to configure SSO.</p>
462 </div>
edf7c36Claude463 </div>
f0b5874Claude464 <style dangerouslySetInnerHTML={{ __html: ssoStyles }} />
edf7c36Claude465 </Layout>,
466 403
467 );
468 }
469 return { user };
470}
471
f0b5874Claude472/** Compute the per-card status — used for the provider list + the hero pill. */
473function ssoStatus(cfg: Awaited<ReturnType<typeof getSsoConfig>>): {
474 kind: "configured" | "incomplete" | "missing";
475 label: string;
476} {
477 if (!cfg) return { kind: "missing", label: "Not configured" };
478 const hasAll =
479 cfg.issuer &&
480 cfg.authorizationEndpoint &&
481 cfg.tokenEndpoint &&
482 cfg.userinfoEndpoint &&
483 cfg.clientId &&
484 cfg.clientSecret;
485 if (!hasAll) return { kind: "incomplete", label: "Incomplete" };
486 if (!cfg.enabled) return { kind: "incomplete", label: "Configured · disabled" };
487 return { kind: "configured", label: "Live · accepting sign-ins" };
488}
489
edf7c36Claude490sso.get("/admin/sso", requireAuth, async (c) => {
491 const g = await adminGate(c);
492 if (g instanceof Response) return g;
493 const { user } = g;
494
495 const cfg = await getSsoConfig();
496 const success = c.req.query("success");
497 const error = c.req.query("error");
498 const redirectUri = ssoRedirectUri();
f0b5874Claude499 const status = ssoStatus(cfg);
500 const pillClass =
501 status.kind === "configured"
502 ? "is-on"
503 : status.kind === "incomplete"
504 ? "is-off"
505 : "is-missing";
edf7c36Claude506
507 return c.html(
508 <Layout title="SSO — Admin" user={user}>
f0b5874Claude509 <div class="sso-wrap">
510 <section class="sso-hero">
511 <div class="sso-hero-orb" aria-hidden="true" />
512 <div class="sso-hero-inner">
513 <div class="sso-eyebrow">
514 <span class="sso-eyebrow-dot" aria-hidden="true" />
515 Enterprise SSO · Site admin · {user.username}
516 </div>
517 <h1 class="sso-title">
518 <span class="sso-title-grad">Single sign-on.</span>
519 </h1>
520 <p class="sso-sub">
521 Configure one site-wide OpenID Connect provider. Users see a
522 "Sign in with <code>{cfg?.providerName || "SSO"}</code>" button
523 on the login page and land in Gluecron without a password.
524 </p>
525 <span class={"sso-status-pill " + pillClass}>
526 <span class="dot" aria-hidden="true" />
527 {status.label}
528 </span>
edf7c36Claude529 </div>
f0b5874Claude530 </section>
edf7c36Claude531
532 {success && (
f0b5874Claude533 <div class="sso-banner is-ok">{decodeURIComponent(success)}</div>
edf7c36Claude534 )}
f0b5874Claude535 {error && (
536 <div class="sso-banner is-error">{decodeURIComponent(error)}</div>
537 )}
538
539 {/* ─── Redirect URI callout ─── */}
540 <section class="sso-section">
541 <header class="sso-section-head">
542 <div class="sso-section-head-text">
543 <h3 class="sso-section-title">Redirect URI</h3>
544 <p class="sso-section-sub">
545 Paste this into your IdP's "Authorized redirect URIs"
546 field — exact match, no trailing slash.
547 </p>
548 </div>
549 </header>
550 <div class="sso-section-body">
551 <div class="sso-callout">
552 <span class="sso-callout-label">Callback</span>
553 <code>{redirectUri}</code>
554 </div>
555 </div>
556 </section>
557
558 {/* ─── Provider preset cards ─── */}
559 <section class="sso-section">
560 <header class="sso-section-head">
561 <div class="sso-section-head-text">
562 <h3 class="sso-section-title">Provider presets</h3>
563 <p class="sso-section-sub">
564 Quick-fill the OIDC endpoints below for a common IdP — then
565 paste your client ID + secret.
566 </p>
567 </div>
568 </header>
569 <div class="sso-section-body">
570 <div class="sso-providers">
571 <button
572 type="button"
573 class="sso-provider"
574 onclick="window.gluecronSsoPreset('google')"
575 aria-label="Use Google Workspace preset"
576 >
577 <span class="sso-provider-icon" aria-hidden="true">G</span>
578 <div>
579 <div class="sso-provider-name">Google</div>
580 <div class="sso-provider-kind">Workspace · OIDC</div>
581 </div>
582 </button>
583 <button
584 type="button"
585 class="sso-provider"
586 onclick="window.gluecronSsoPreset('okta')"
587 aria-label="Use Okta preset"
588 >
589 <span class="sso-provider-icon" aria-hidden="true">O</span>
590 <div>
591 <div class="sso-provider-name">Okta</div>
592 <div class="sso-provider-kind">OIDC</div>
593 </div>
594 </button>
595 <button
596 type="button"
597 class="sso-provider"
598 onclick="window.gluecronSsoPreset('auth0')"
599 aria-label="Use Auth0 preset"
600 >
601 <span class="sso-provider-icon" aria-hidden="true">A</span>
602 <div>
603 <div class="sso-provider-name">Auth0</div>
604 <div class="sso-provider-kind">OIDC</div>
605 </div>
606 </button>
607 <button
608 type="button"
609 class="sso-provider"
610 onclick="window.gluecronSsoPreset('azure')"
611 aria-label="Use Microsoft Entra preset"
612 >
613 <span class="sso-provider-icon" aria-hidden="true">M</span>
614 <div>
615 <div class="sso-provider-name">Microsoft</div>
616 <div class="sso-provider-kind">Entra ID · OIDC</div>
617 </div>
618 </button>
619 <button
620 type="button"
621 class="sso-provider"
622 onclick="window.gluecronSsoPreset('github')"
623 aria-label="Use GitHub Enterprise preset"
624 >
625 <span class="sso-provider-icon" aria-hidden="true">H</span>
626 <div>
627 <div class="sso-provider-name">GitHub</div>
628 <div class="sso-provider-kind">Enterprise · OIDC</div>
629 </div>
630 </button>
631 <button
632 type="button"
633 class="sso-provider"
634 onclick="window.gluecronSsoPreset('saml')"
635 aria-label="SAML not supported — use OIDC"
636 title="SAML is not currently supported. Use the equivalent OIDC endpoint from your IdP."
637 >
638 <span class="sso-provider-icon" aria-hidden="true">S</span>
639 <div>
640 <div class="sso-provider-name">SAML</div>
641 <div class="sso-provider-kind">Use OIDC instead</div>
642 </div>
643 </button>
644 </div>
645 </div>
646 </section>
647
fc0ca99Claude648 <script
649 dangerouslySetInnerHTML={{
650 __html: /* js */ `
651 window.gluecronSsoPreset = function (provider) {
652 var P = {
653 google: {
654 provider_name: 'Google',
655 issuer: 'https://accounts.google.com',
656 authorization_endpoint: 'https://accounts.google.com/o/oauth2/v2/auth',
657 token_endpoint: 'https://oauth2.googleapis.com/token',
658 userinfo_endpoint: 'https://openidconnect.googleapis.com/v1/userinfo',
659 scopes: 'openid email profile',
660 },
661 okta: {
662 provider_name: 'Okta',
663 issuer: 'https://YOUR-TENANT.okta.com',
664 authorization_endpoint: 'https://YOUR-TENANT.okta.com/oauth2/v1/authorize',
665 token_endpoint: 'https://YOUR-TENANT.okta.com/oauth2/v1/token',
666 userinfo_endpoint: 'https://YOUR-TENANT.okta.com/oauth2/v1/userinfo',
667 scopes: 'openid email profile',
668 },
669 auth0: {
670 provider_name: 'Auth0',
671 issuer: 'https://YOUR-TENANT.auth0.com',
672 authorization_endpoint: 'https://YOUR-TENANT.auth0.com/authorize',
673 token_endpoint: 'https://YOUR-TENANT.auth0.com/oauth/token',
674 userinfo_endpoint: 'https://YOUR-TENANT.auth0.com/userinfo',
675 scopes: 'openid email profile',
676 },
677 azure: {
678 provider_name: 'Microsoft',
679 issuer: 'https://login.microsoftonline.com/YOUR-TENANT-ID/v2.0',
680 authorization_endpoint: 'https://login.microsoftonline.com/YOUR-TENANT-ID/oauth2/v2.0/authorize',
681 token_endpoint: 'https://login.microsoftonline.com/YOUR-TENANT-ID/oauth2/v2.0/token',
682 userinfo_endpoint: 'https://graph.microsoft.com/oidc/userinfo',
683 scopes: 'openid email profile',
684 },
f0b5874Claude685 github: {
686 provider_name: 'GitHub Enterprise',
687 issuer: 'https://github.example.com',
688 authorization_endpoint: 'https://github.example.com/login/oauth/authorize',
689 token_endpoint: 'https://github.example.com/login/oauth/access_token',
690 userinfo_endpoint: 'https://github.example.com/api/v3/user',
691 scopes: 'openid read:user user:email',
692 },
693 saml: {
694 provider_name: 'SAML',
695 issuer: '',
696 authorization_endpoint: '',
697 token_endpoint: '',
698 userinfo_endpoint: '',
699 scopes: 'openid email profile',
700 },
fc0ca99Claude701 };
702 var p = P[provider];
703 if (!p) return;
704 for (var k in p) {
705 var el = document.getElementById(k);
706 if (el) el.value = p[k];
707 }
f0b5874Claude708 if (provider === 'saml') {
709 alert('SAML is not supported. Use your IdP\\'s OIDC equivalent — most SAML IdPs (Okta, Auth0, Azure) also expose OIDC endpoints.');
710 }
fc0ca99Claude711 };
712 `,
713 }}
714 />
715
f0b5874Claude716 {/* ─── Main config form ─── */}
717 <form method="post" action="/admin/sso">
718 <section class="sso-section">
719 <header class="sso-section-head">
720 <div class="sso-section-head-text">
721 <h3 class="sso-section-title">OIDC configuration</h3>
722 <p class="sso-section-sub">
723 Fill these from your IdP's OIDC discovery document
724 (usually at <code style="font-family:var(--font-mono);font-size:12px;background:var(--bg-tertiary);padding:1px 5px;border-radius:4px">.well-known/openid-configuration</code>).
725 </p>
726 </div>
727 </header>
728 <div class="sso-section-body">
729 <div class="sso-toggle-row">
730 <input
731 type="checkbox"
732 name="enabled"
733 value="1"
734 checked={!!cfg?.enabled}
735 aria-label="Enable SSO sign-in on /login"
736 id="enabled"
737 />
738 <span>
739 <strong style="color:var(--text-strong)">Enable SSO sign-in on /login.</strong>
740 {" "}When off, the config is saved but the button doesn't render
741 and the callback endpoint refuses requests.
742 </span>
743 </div>
744
745 <div class="sso-field">
746 <div class="sso-field-row">
747 <label for="provider_name">Button label</label>
748 </div>
749 <input
750 type="text"
751 id="provider_name"
752 name="provider_name"
753 value={cfg?.providerName || "SSO"}
754 maxLength={120}
755 placeholder="Okta"
756 class="sso-input"
757 />
758 <div class="sso-hint">
759 Shown on the login page: "Sign in with <code>{cfg?.providerName || "SSO"}</code>".
760 </div>
761 </div>
762
763 <div class="sso-field">
764 <div class="sso-field-row">
765 <label for="issuer">Issuer URL</label>
766 </div>
767 <input
768 type="text"
769 id="issuer"
770 name="issuer"
771 value={cfg?.issuer || ""}
772 placeholder="https://example.okta.com"
773 class="sso-input"
774 />
775 </div>
776
777 <div class="sso-field">
778 <div class="sso-field-row">
779 <label for="authorization_endpoint">Authorization endpoint</label>
780 </div>
781 <input
782 type="text"
783 id="authorization_endpoint"
784 name="authorization_endpoint"
785 value={cfg?.authorizationEndpoint || ""}
786 placeholder="https://example.okta.com/oauth2/v1/authorize"
787 class="sso-input"
788 />
789 </div>
790
791 <div class="sso-field">
792 <div class="sso-field-row">
793 <label for="token_endpoint">Token endpoint</label>
794 </div>
795 <input
796 type="text"
797 id="token_endpoint"
798 name="token_endpoint"
799 value={cfg?.tokenEndpoint || ""}
800 placeholder="https://example.okta.com/oauth2/v1/token"
801 class="sso-input"
802 />
803 </div>
804
805 <div class="sso-field">
806 <div class="sso-field-row">
807 <label for="userinfo_endpoint">Userinfo endpoint</label>
808 </div>
809 <input
810 type="text"
811 id="userinfo_endpoint"
812 name="userinfo_endpoint"
813 value={cfg?.userinfoEndpoint || ""}
814 placeholder="https://example.okta.com/oauth2/v1/userinfo"
815 class="sso-input"
816 />
817 </div>
818 </div>
819 </section>
820
821 <section class="sso-section">
822 <header class="sso-section-head">
823 <div class="sso-section-head-text">
824 <h3 class="sso-section-title">Client credentials</h3>
825 <p class="sso-section-sub">
826 Issued by your IdP when you register Gluecron as an
827 application. The secret never leaves the server.
828 </p>
829 </div>
830 </header>
831 <div class="sso-section-body">
832 <div class="sso-field">
833 <div class="sso-field-row">
834 <label for="client_id">Client ID</label>
835 </div>
836 <input
837 type="text"
838 id="client_id"
839 name="client_id"
840 value={cfg?.clientId || ""}
841 autocomplete="off"
842 class="sso-input"
843 />
844 </div>
845
846 <div class="sso-field">
847 <div class="sso-field-row">
848 <label for="client_secret">Client secret</label>
849 {cfg?.clientSecret && (
850 <span class="sso-status-pill is-on" style="margin-top:0">
851 <span class="dot" aria-hidden="true" />
852 Stored
853 </span>
854 )}
855 </div>
856 <input
857 type="password"
858 id="client_secret"
859 name="client_secret"
860 value={cfg?.clientSecret || ""}
861 autocomplete="off"
862 placeholder={
863 cfg?.clientSecret ? "(stored — leave blank to keep)" : ""
864 }
865 class="sso-input"
866 />
867 </div>
868 </div>
869 </section>
870
871 <section class="sso-section">
872 <header class="sso-section-head">
873 <div class="sso-section-head-text">
874 <h3 class="sso-section-title">Scopes &amp; access control</h3>
875 <p class="sso-section-sub">
876 Restrict who can sign in and what happens on first login.
877 </p>
878 </div>
879 </header>
880 <div class="sso-section-body">
881 <div class="sso-field">
882 <div class="sso-field-row">
883 <label for="scopes">OIDC scopes</label>
884 </div>
885 <input
886 type="text"
887 id="scopes"
888 name="scopes"
889 value={cfg?.scopes || "openid profile email"}
890 class="sso-input"
891 />
892 <div class="sso-hint">
893 Space-separated. Defaults to <code style="font-family:var(--font-mono);font-size:11.5px;background:var(--bg-tertiary);padding:1px 4px;border-radius:4px">openid profile email</code> — enough for username + display name.
894 </div>
895 </div>
896
897 <div class="sso-field">
898 <div class="sso-field-row">
899 <label for="allowed_email_domains">Allowed email domains</label>
900 </div>
901 <input
902 type="text"
903 id="allowed_email_domains"
904 name="allowed_email_domains"
905 value={cfg?.allowedEmailDomains || ""}
906 placeholder="example.com, acme.io"
907 class="sso-input"
908 />
909 <div class="sso-hint">
910 Comma-separated. Empty = any domain accepted. Enforced
911 case-insensitively against the email claim.
912 </div>
913 </div>
914
915 <div class="sso-toggle-row">
916 <input
917 type="checkbox"
918 name="auto_create_users"
919 value="1"
920 checked={cfg ? cfg.autoCreateUsers : true}
921 aria-label="Auto-create users on first SSO sign-in"
922 id="auto_create_users"
923 />
924 <span>
925 <strong style="color:var(--text-strong)">Auto-create local accounts on first SSO sign-in.</strong>
926 {" "}Turn off to require admins to pre-provision users — first
927 login then fails with "user not found".
928 </span>
929 </div>
930 </div>
931 <div class="sso-foot">
932 <span class="sso-foot-hint">
933 Changes apply immediately. Test from <a href="/login" style="color:var(--accent);text-decoration:none">/login</a>.
934 </span>
935 <button type="submit" class="sso-btn sso-btn-primary">
936 Save SSO settings
937 </button>
938 </div>
939 </section>
edf7c36Claude940 </form>
941 </div>
f0b5874Claude942 <style dangerouslySetInnerHTML={{ __html: ssoStyles }} />
edf7c36Claude943 </Layout>
944 );
945});
946
947sso.post("/admin/sso", requireAuth, async (c) => {
948 const g = await adminGate(c);
949 if (g instanceof Response) return g;
950 const { user } = g;
951
952 const body = await c.req.parseBody();
953 const existing = await getSsoConfig();
954 const secretSubmitted = String(body.client_secret || "");
955 const result = await upsertSsoConfig({
956 enabled: String(body.enabled || "") === "1",
957 providerName: String(body.provider_name || "SSO"),
958 issuer: String(body.issuer || ""),
959 authorizationEndpoint: String(body.authorization_endpoint || ""),
960 tokenEndpoint: String(body.token_endpoint || ""),
961 userinfoEndpoint: String(body.userinfo_endpoint || ""),
962 clientId: String(body.client_id || ""),
963 clientSecret:
964 secretSubmitted.trim().length === 0 && existing?.clientSecret
965 ? existing.clientSecret
966 : secretSubmitted,
967 scopes: String(body.scopes || "openid profile email"),
968 allowedEmailDomains: String(body.allowed_email_domains || ""),
969 autoCreateUsers: String(body.auto_create_users || "") === "1",
970 });
971
972 if (!result.ok) {
973 return c.redirect(
974 `/admin/sso?error=${encodeURIComponent(result.error)}`
975 );
976 }
977
978 await audit({
979 userId: user.id,
980 action: "admin.sso.configure",
981 metadata: {
982 enabled: String(body.enabled || "") === "1",
983 provider: String(body.provider_name || "SSO"),
984 autoCreateUsers: String(body.auto_create_users || "") === "1",
985 allowedDomains: String(body.allowed_email_domains || "") || null,
986 },
987 });
988
989 return c.redirect(
990 `/admin/sso?success=${encodeURIComponent("SSO settings saved.")}`
991 );
992});
993
994// ----------------------------------------------------------------------------
995// OIDC flow
996// ----------------------------------------------------------------------------
997
998sso.get("/login/sso", async (c) => {
999 const cfg = await getSsoConfig();
1000 if (!cfg || !cfg.enabled) {
1001 return c.redirect(
1002 `/login?error=${encodeURIComponent("SSO is not enabled")}`
1003 );
1004 }
1005 if (
1006 !cfg.authorizationEndpoint ||
1007 !cfg.tokenEndpoint ||
1008 !cfg.userinfoEndpoint ||
1009 !cfg.clientId ||
1010 !cfg.clientSecret
1011 ) {
1012 return c.redirect(
1013 `/login?error=${encodeURIComponent("SSO is not fully configured")}`
1014 );
1015 }
1016 const state = randomToken(16);
1017 const nonce = randomToken(16);
1018 const redirectUri = ssoRedirectUri();
1019 let target: string;
1020 try {
1021 target = buildAuthorizeUrl(cfg, state, nonce, redirectUri);
1022 } catch (err) {
1023 return c.redirect(
1024 `/login?error=${encodeURIComponent(
1025 err instanceof Error ? err.message : "SSO misconfigured"
1026 )}`
1027 );
1028 }
1029 setCookie(c, "sso_state", state, ssoStateCookieOpts());
1030 setCookie(c, "sso_nonce", nonce, ssoStateCookieOpts());
1031 return c.redirect(target);
1032});
1033
1034sso.get("/login/sso/callback", async (c) => {
1035 const cfg = await getSsoConfig();
1036 if (!cfg || !cfg.enabled) {
1037 return c.redirect(
1038 `/login?error=${encodeURIComponent("SSO is not enabled")}`
1039 );
1040 }
1041
1042 const code = c.req.query("code");
1043 const state = c.req.query("state");
1044 const errCode = c.req.query("error");
1045 if (errCode) {
1046 return c.redirect(
1047 `/login?error=${encodeURIComponent(
1048 `SSO provider error: ${errCode}`
1049 )}`
1050 );
1051 }
1052 if (!code || !state) {
1053 return c.redirect(
1054 `/login?error=${encodeURIComponent("Missing code or state")}`
1055 );
1056 }
1057
1058 const expectedState = getCookie(c, "sso_state");
1059 if (!expectedState || expectedState !== state) {
1060 return c.redirect(
1061 `/login?error=${encodeURIComponent(
1062 "SSO state mismatch. Please try again."
1063 )}`
1064 );
1065 }
1066
1067 // One-shot cookies — burn them even on failure
1068 deleteCookie(c, "sso_state", { path: "/" });
1069 deleteCookie(c, "sso_nonce", { path: "/" });
1070
1071 try {
1072 const tokens = await exchangeCode(cfg, code, ssoRedirectUri());
1073 const claims = await fetchUserinfo(cfg, tokens.access_token);
1074 const result = await findOrCreateUserFromSso(claims, cfg);
1075 if (!result.ok) {
1076 return c.redirect(`/login?error=${encodeURIComponent(result.error)}`);
1077 }
1078
1079 const token = await issueSsoSession(result.user.id);
1080 setCookie(c, "session", token, sessionCookieOptions());
1081
1082 await audit({
1083 userId: result.user.id,
1084 action: "auth.sso.login",
1085 metadata: {
1086 provider: cfg.providerName,
1087 sub: claims.sub,
1088 email: claims.email || null,
1089 },
1090 });
1091
1092 return c.redirect("/");
1093 } catch (err) {
1094 console.error("[sso] callback error:", err);
fc0ca99Claude1095 const friendly = friendlySsoError(err);
1096 return c.redirect(`/login?error=${encodeURIComponent(friendly)}`);
edf7c36Claude1097 }
1098});
1099
fc0ca99Claude1100/**
1101 * Map the raw OIDC failure shape to a one-sentence message safe to render
1102 * inside an HTML <div>. We never surface the IdP's response body — those
1103 * have been Google 404 HTML pages, Azure JSON blobs full of object IDs,
1104 * etc. The raw `err.message` is logged via console.error above so admins
1105 * can still diagnose from server logs.
1106 */
1107function friendlySsoError(err: unknown): string {
1108 const raw = err instanceof Error ? err.message : String(err ?? "");
1109 if (raw.includes("token_endpoint")) {
1110 if (/\b40[01]\b/.test(raw)) {
1111 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.";
1112 }
1113 if (/\b404\b/.test(raw)) {
1114 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.";
1115 }
1116 if (/\b5\d\d\b/.test(raw)) {
1117 return "SSO sign-in failed: the identity provider returned a server error. Try again, or check the IdP's status page.";
1118 }
1119 return "SSO sign-in failed at the token exchange step. Check /admin/sso configuration.";
1120 }
1121 if (raw.includes("userinfo_endpoint")) {
1122 return "SSO sign-in failed while fetching profile info. Verify the Userinfo endpoint URL at /admin/sso.";
1123 }
1124 if (raw.includes("state cookie") || raw.includes("nonce")) {
1125 return "SSO sign-in expired before you returned to the site. Please try again.";
1126 }
1127 if (raw.includes("email") && raw.includes("not allowed")) {
1128 return "SSO sign-in failed: your email domain is not on the allowlist for this site.";
1129 }
1130 return "SSO sign-in failed. Check /admin/sso configuration or try again.";
1131}
1132
edf7c36Claude1133// ----------------------------------------------------------------------------
1134// User: unlink SSO
1135// ----------------------------------------------------------------------------
1136
1137sso.post("/settings/sso/unlink", requireAuth, async (c) => {
1138 const user = c.get("user")!;
1139 const links = await db
1140 .select({ id: ssoUserLinks.id })
1141 .from(ssoUserLinks)
1142 .where(eq(ssoUserLinks.userId, user.id));
1143 if (links.length === 0) {
1144 return c.redirect("/settings?error=" + encodeURIComponent("No SSO link"));
1145 }
1146 await db.delete(ssoUserLinks).where(eq(ssoUserLinks.userId, user.id));
1147 await audit({
1148 userId: user.id,
1149 action: "auth.sso.unlink",
1150 metadata: { removedLinks: links.length },
1151 });
1152 return c.redirect(
1153 "/settings?success=" + encodeURIComponent("SSO link removed.")
1154 );
1155});
1156
1157export default sso;