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.tsxBlame428 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
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}
e1c0fbecopilot-swe-agent[bot]123 aria-label="Enable SSO sign-in on /login"
edf7c36Claude124 />
125 <span>Enable SSO sign-in on /login</span>
126 </label>
127 <div class="form-group">
128 <label for="provider_name">Button label</label>
129 <input
130 type="text"
131 id="provider_name"
132 name="provider_name"
133 value={cfg?.providerName || "SSO"}
134 maxLength={120}
135 placeholder="Okta"
136 />
137 </div>
138 <div class="form-group">
139 <label for="issuer">Issuer URL</label>
140 <input
141 type="text"
142 id="issuer"
143 name="issuer"
144 value={cfg?.issuer || ""}
145 placeholder="https://example.okta.com"
146 />
147 </div>
148 <div class="form-group">
149 <label for="authorization_endpoint">Authorization endpoint</label>
150 <input
151 type="text"
152 id="authorization_endpoint"
153 name="authorization_endpoint"
154 value={cfg?.authorizationEndpoint || ""}
155 placeholder="https://example.okta.com/oauth2/v1/authorize"
156 />
157 </div>
158 <div class="form-group">
159 <label for="token_endpoint">Token endpoint</label>
160 <input
161 type="text"
162 id="token_endpoint"
163 name="token_endpoint"
164 value={cfg?.tokenEndpoint || ""}
165 placeholder="https://example.okta.com/oauth2/v1/token"
166 />
167 </div>
168 <div class="form-group">
169 <label for="userinfo_endpoint">Userinfo endpoint</label>
170 <input
171 type="text"
172 id="userinfo_endpoint"
173 name="userinfo_endpoint"
174 value={cfg?.userinfoEndpoint || ""}
175 placeholder="https://example.okta.com/oauth2/v1/userinfo"
176 />
177 </div>
178 <div class="form-group">
179 <label for="client_id">Client ID</label>
180 <input
181 type="text"
182 id="client_id"
183 name="client_id"
184 value={cfg?.clientId || ""}
185 autocomplete="off"
186 />
187 </div>
188 <div class="form-group">
189 <label for="client_secret">Client secret</label>
190 <input
191 type="password"
192 id="client_secret"
193 name="client_secret"
194 value={cfg?.clientSecret || ""}
195 autocomplete="off"
196 placeholder={
197 cfg?.clientSecret ? "(stored — leave blank to keep)" : ""
198 }
199 />
200 </div>
201 <div class="form-group">
202 <label for="scopes">OIDC scopes</label>
203 <input
204 type="text"
205 id="scopes"
206 name="scopes"
207 value={cfg?.scopes || "openid profile email"}
208 />
209 </div>
210 <div class="form-group">
211 <label for="allowed_email_domains">
212 Allowed email domains (comma-separated, empty = any)
213 </label>
214 <input
215 type="text"
216 id="allowed_email_domains"
217 name="allowed_email_domains"
218 value={cfg?.allowedEmailDomains || ""}
219 placeholder="example.com, acme.io"
220 />
221 </div>
222 <label
223 style="display:flex;gap:8px;align-items:center;margin:12px 0"
224 >
225 <input
226 type="checkbox"
227 name="auto_create_users"
228 value="1"
229 checked={cfg ? cfg.autoCreateUsers : true}
e1c0fbecopilot-swe-agent[bot]230 aria-label="Auto-create users on first SSO sign-in"
edf7c36Claude231 />
232 <span>
233 Auto-create local accounts on first SSO sign-in (turn off to
234 require admins to pre-provision users)
235 </span>
236 </label>
237 <button type="submit" class="btn btn-primary">
238 Save SSO settings
239 </button>
240 </form>
241 </div>
242 </Layout>
243 );
244});
245
246sso.post("/admin/sso", requireAuth, async (c) => {
247 const g = await adminGate(c);
248 if (g instanceof Response) return g;
249 const { user } = g;
250
251 const body = await c.req.parseBody();
252 const existing = await getSsoConfig();
253 const secretSubmitted = String(body.client_secret || "");
254 const result = await upsertSsoConfig({
255 enabled: String(body.enabled || "") === "1",
256 providerName: String(body.provider_name || "SSO"),
257 issuer: String(body.issuer || ""),
258 authorizationEndpoint: String(body.authorization_endpoint || ""),
259 tokenEndpoint: String(body.token_endpoint || ""),
260 userinfoEndpoint: String(body.userinfo_endpoint || ""),
261 clientId: String(body.client_id || ""),
262 clientSecret:
263 secretSubmitted.trim().length === 0 && existing?.clientSecret
264 ? existing.clientSecret
265 : secretSubmitted,
266 scopes: String(body.scopes || "openid profile email"),
267 allowedEmailDomains: String(body.allowed_email_domains || ""),
268 autoCreateUsers: String(body.auto_create_users || "") === "1",
269 });
270
271 if (!result.ok) {
272 return c.redirect(
273 `/admin/sso?error=${encodeURIComponent(result.error)}`
274 );
275 }
276
277 await audit({
278 userId: user.id,
279 action: "admin.sso.configure",
280 metadata: {
281 enabled: String(body.enabled || "") === "1",
282 provider: String(body.provider_name || "SSO"),
283 autoCreateUsers: String(body.auto_create_users || "") === "1",
284 allowedDomains: String(body.allowed_email_domains || "") || null,
285 },
286 });
287
288 return c.redirect(
289 `/admin/sso?success=${encodeURIComponent("SSO settings saved.")}`
290 );
291});
292
293// ----------------------------------------------------------------------------
294// OIDC flow
295// ----------------------------------------------------------------------------
296
297sso.get("/login/sso", async (c) => {
298 const cfg = await getSsoConfig();
299 if (!cfg || !cfg.enabled) {
300 return c.redirect(
301 `/login?error=${encodeURIComponent("SSO is not enabled")}`
302 );
303 }
304 if (
305 !cfg.authorizationEndpoint ||
306 !cfg.tokenEndpoint ||
307 !cfg.userinfoEndpoint ||
308 !cfg.clientId ||
309 !cfg.clientSecret
310 ) {
311 return c.redirect(
312 `/login?error=${encodeURIComponent("SSO is not fully configured")}`
313 );
314 }
315 const state = randomToken(16);
316 const nonce = randomToken(16);
317 const redirectUri = ssoRedirectUri();
318 let target: string;
319 try {
320 target = buildAuthorizeUrl(cfg, state, nonce, redirectUri);
321 } catch (err) {
322 return c.redirect(
323 `/login?error=${encodeURIComponent(
324 err instanceof Error ? err.message : "SSO misconfigured"
325 )}`
326 );
327 }
328 setCookie(c, "sso_state", state, ssoStateCookieOpts());
329 setCookie(c, "sso_nonce", nonce, ssoStateCookieOpts());
330 return c.redirect(target);
331});
332
333sso.get("/login/sso/callback", async (c) => {
334 const cfg = await getSsoConfig();
335 if (!cfg || !cfg.enabled) {
336 return c.redirect(
337 `/login?error=${encodeURIComponent("SSO is not enabled")}`
338 );
339 }
340
341 const code = c.req.query("code");
342 const state = c.req.query("state");
343 const errCode = c.req.query("error");
344 if (errCode) {
345 return c.redirect(
346 `/login?error=${encodeURIComponent(
347 `SSO provider error: ${errCode}`
348 )}`
349 );
350 }
351 if (!code || !state) {
352 return c.redirect(
353 `/login?error=${encodeURIComponent("Missing code or state")}`
354 );
355 }
356
357 const expectedState = getCookie(c, "sso_state");
358 if (!expectedState || expectedState !== state) {
359 return c.redirect(
360 `/login?error=${encodeURIComponent(
361 "SSO state mismatch. Please try again."
362 )}`
363 );
364 }
365
366 // One-shot cookies — burn them even on failure
367 deleteCookie(c, "sso_state", { path: "/" });
368 deleteCookie(c, "sso_nonce", { path: "/" });
369
370 try {
371 const tokens = await exchangeCode(cfg, code, ssoRedirectUri());
372 const claims = await fetchUserinfo(cfg, tokens.access_token);
373 const result = await findOrCreateUserFromSso(claims, cfg);
374 if (!result.ok) {
375 return c.redirect(`/login?error=${encodeURIComponent(result.error)}`);
376 }
377
378 const token = await issueSsoSession(result.user.id);
379 setCookie(c, "session", token, sessionCookieOptions());
380
381 await audit({
382 userId: result.user.id,
383 action: "auth.sso.login",
384 metadata: {
385 provider: cfg.providerName,
386 sub: claims.sub,
387 email: claims.email || null,
388 },
389 });
390
391 return c.redirect("/");
392 } catch (err) {
393 console.error("[sso] callback error:", err);
394 return c.redirect(
395 `/login?error=${encodeURIComponent(
396 err instanceof Error
397 ? `SSO sign-in failed: ${err.message}`
398 : "SSO sign-in failed"
399 )}`
400 );
401 }
402});
403
404// ----------------------------------------------------------------------------
405// User: unlink SSO
406// ----------------------------------------------------------------------------
407
408sso.post("/settings/sso/unlink", requireAuth, async (c) => {
409 const user = c.get("user")!;
410 const links = await db
411 .select({ id: ssoUserLinks.id })
412 .from(ssoUserLinks)
413 .where(eq(ssoUserLinks.userId, user.id));
414 if (links.length === 0) {
415 return c.redirect("/settings?error=" + encodeURIComponent("No SSO link"));
416 }
417 await db.delete(ssoUserLinks).where(eq(ssoUserLinks.userId, user.id));
418 await audit({
419 userId: user.id,
420 action: "auth.sso.unlink",
421 metadata: { removedLinks: links.length },
422 });
423 return c.redirect(
424 "/settings?success=" + encodeURIComponent("SSO link removed.")
425 );
426});
427
428export default sso;