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.tsxBlame426 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
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
109 <form
001af43Claude110 method="post"
edf7c36Claude111 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
244sso.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
295sso.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
331sso.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
406sso.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
426export default sso;