Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

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

auth.tsxBlame657 lines · 2 contributors
06d5ffeClaude1/**
2 * Auth routes — register, login, logout (web + API).
3 */
4
5import { Hono } from "hono";
7298a17Claude6import { setCookie, deleteCookie, getCookie } from "hono/cookie";
a4f3e24Claude7import { and, eq, isNull, sql } from "drizzle-orm";
06d5ffeClaude8import { db } from "../db";
7298a17Claude9import {
10 users,
11 sessions,
12 organizations,
13 userTotp,
14 userRecoveryCodes,
15} from "../db/schema";
06d5ffeClaude16import {
17 hashPassword,
18 verifyPassword,
19 generateSessionToken,
20 sessionCookieOptions,
21 sessionExpiry,
22} from "../lib/auth";
7298a17Claude23import { verifyTotpCode, hashRecoveryCode } from "../lib/totp";
46d6165Claude24import { getSsoConfig, getGithubOauthConfig } from "../lib/sso";
06d5ffeClaude25import { Layout } from "../views/layout";
bb0f894Claude26import {
27 Form,
28 FormGroup,
29 Input,
30 Button,
09be7bfClaude31 LinkButton,
bb0f894Claude32 Alert,
33 Text,
34} from "../views/ui";
06d5ffeClaude35import type { AuthEnv } from "../middleware/auth";
36
37const auth = new Hono<AuthEnv>();
38
39// --- Web UI ---
40
41auth.get("/register", (c) => {
42 const error = c.req.query("error");
134750bClaude43 const csrf = c.get("csrfToken") as string | undefined;
06d5ffeClaude44 return c.html(
45 <Layout title="Register">
46 <div class="auth-container">
47 <h2>Create account</h2>
48 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
134750bClaude49 <Form method="post" action="/register" csrfToken={csrf}>
0316dbbClaude50 <FormGroup label="Username" htmlFor="username">
51 <Input
52 id="username"
06d5ffeClaude53 type="text"
54 name="username"
55 required
56 pattern="^[a-zA-Z0-9_-]+$"
57 minLength={2}
58 maxLength={39}
59 placeholder="your-username"
60 autocomplete="username"
61 />
bb0f894Claude62 </FormGroup>
63 <FormGroup label="Email" htmlFor="email">
64 <Input
06d5ffeClaude65 type="email"
66 name="email"
67 required
68 placeholder="you@example.com"
69 autocomplete="email"
2c3ba6ecopilot-swe-agent[bot]70 aria-label="Email"
06d5ffeClaude71 />
bb0f894Claude72 </FormGroup>
73 <FormGroup label="Password" htmlFor="password">
74 <Input
06d5ffeClaude75 type="password"
76 name="password"
77 required
78 minLength={8}
79 placeholder="Min 8 characters"
80 autocomplete="new-password"
2c3ba6ecopilot-swe-agent[bot]81 aria-label="Password"
06d5ffeClaude82 />
bb0f894Claude83 </FormGroup>
84 <Button type="submit" variant="primary">
06d5ffeClaude85 Create account
bb0f894Claude86 </Button>
87 </Form>
06d5ffeClaude88 <p class="auth-switch">
bb0f894Claude89 <Text>Already have an account? <a href="/login">Sign in</a></Text>
06d5ffeClaude90 </p>
91 </div>
92 </Layout>
93 );
94});
95
96auth.post("/register", async (c) => {
97 const body = await c.req.parseBody();
98 const username = String(body.username || "").trim();
99 const email = String(body.email || "").trim();
100 const password = String(body.password || "");
101
102 if (!username || !email || !password) {
103 return c.redirect("/register?error=All+fields+are+required");
104 }
105
106 if (!/^[a-zA-Z0-9_-]+$/.test(username)) {
107 return c.redirect(
108 "/register?error=Username+may+only+contain+letters%2C+numbers%2C+hyphens+and+underscores"
109 );
110 }
111
112 if (password.length < 8) {
113 return c.redirect("/register?error=Password+must+be+at+least+8+characters");
114 }
115
116 // Check existing
117 const [existingUser] = await db
118 .select()
119 .from(users)
120 .where(eq(users.username, username))
121 .limit(1);
122 if (existingUser) {
123 return c.redirect("/register?error=Username+already+taken");
124 }
125
7437605Claude126 // B2: usernames share the URL namespace with org slugs; refuse collisions.
127 const [existingOrg] = await db
128 .select({ id: organizations.id })
129 .from(organizations)
130 .where(eq(organizations.slug, username.toLowerCase()))
131 .limit(1);
132 if (existingOrg) {
133 return c.redirect("/register?error=Username+already+taken");
134 }
135
06d5ffeClaude136 const [existingEmail] = await db
137 .select()
138 .from(users)
139 .where(eq(users.email, email))
140 .limit(1);
141 if (existingEmail) {
142 return c.redirect("/register?error=Email+already+registered");
143 }
144
145 const passwordHash = await hashPassword(password);
146
a4f3e24Claude147 // First user ever registered becomes admin automatically
148 const [userCount] = await db
149 .select({ count: sql`count(*)::int` })
150 .from(users);
151 const isFirstUser = (userCount?.count as number) === 0;
152
06d5ffeClaude153 const [user] = await db
154 .insert(users)
a4f3e24Claude155 .values({ username, email, passwordHash, isAdmin: isFirstUser })
06d5ffeClaude156 .returning();
157
2d985e5Claude158 // If username matches SITE_ADMIN_USERNAME env, grant site admin instantly
159 // so the operator doesn't have to wait for the next boot's bootstrap pass.
160 await import("../lib/admin-bootstrap").then((m) =>
161 m.ensureEnvAdminOnRegister({ userId: user.id, username })
162 ).catch(() => {});
163
06d5ffeClaude164 // Create session
165 const token = generateSessionToken();
166 await db.insert(sessions).values({
167 userId: user.id,
168 token,
169 expiresAt: sessionExpiry(),
170 });
171
172 setCookie(c, "session", token, sessionCookieOptions());
173
174 const redirect = c.req.query("redirect") || "/";
175 return c.redirect(redirect);
176});
177
edf7c36Claude178auth.get("/login", async (c) => {
06d5ffeClaude179 const error = c.req.query("error");
180 const redirect = c.req.query("redirect") || "";
edf7c36Claude181 const ssoCfg = await getSsoConfig();
182 const ssoEnabled =
183 !!ssoCfg?.enabled &&
184 !!ssoCfg.authorizationEndpoint &&
185 !!ssoCfg.tokenEndpoint &&
186 !!ssoCfg.userinfoEndpoint &&
187 !!ssoCfg.clientId &&
188 !!ssoCfg.clientSecret;
fc0ca99Claude189 const ssoLabel =
190 ssoCfg?.providerName || inferSsoProviderName(ssoCfg) || "SSO";
46d6165Claude191 // Block L6 — "Sign in with GitHub" (separate row keyed id='github').
192 const githubCfg = await getGithubOauthConfig();
193 const githubEnabled =
194 !!githubCfg?.enabled && !!githubCfg.clientId && !!githubCfg.clientSecret;
134750bClaude195 const csrf = c.get("csrfToken") as string | undefined;
06d5ffeClaude196 return c.html(
197 <Layout title="Sign in">
198 <div class="auth-container">
199 <h2>Sign in</h2>
200 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
0316dbbClaude201 <Form
b9968e3Claude202 method="post"
06d5ffeClaude203 action={`/login${redirect ? `?redirect=${encodeURIComponent(redirect)}` : ""}`}
134750bClaude204 csrfToken={csrf}
06d5ffeClaude205 >
bb0f894Claude206 <FormGroup label="Username or email" htmlFor="username">
207 <Input
06d5ffeClaude208 type="text"
209 name="username"
210 required
211 placeholder="username or email"
212 autocomplete="username"
2c3ba6ecopilot-swe-agent[bot]213 aria-label="Username or email"
06d5ffeClaude214 />
bb0f894Claude215 </FormGroup>
216 <FormGroup label="Password" htmlFor="password">
217 <Input
06d5ffeClaude218 type="password"
219 name="password"
220 required
221 placeholder="Password"
222 autocomplete="current-password"
2c3ba6ecopilot-swe-agent[bot]223 aria-label="Password"
06d5ffeClaude224 />
bb0f894Claude225 </FormGroup>
226 <Button type="submit" variant="primary">
06d5ffeClaude227 Sign in
bb0f894Claude228 </Button>
229 </Form>
46d6165Claude230 {githubEnabled && (
231 <div class="auth-sso">
232 <div class="auth-divider">or</div>
233 <LinkButton href="/login/github">Sign in with GitHub</LinkButton>
234 </div>
235 )}
09be7bfClaude236 {ssoEnabled && (
237 <div class="auth-sso">
238 <div class="auth-divider">or</div>
239 <LinkButton href="/login/sso">Sign in with {ssoLabel}</LinkButton>
240 </div>
241 )}
fa06ad2Claude242 <div class="auth-passkey">
243 <div class="auth-divider">or</div>
244 <button type="button" id="pk-signin-btn" class="btn">
245 Sign in with passkey
246 </button>
247 <div
248 id="pk-signin-status"
249 class="auth-status"
250 aria-live="polite"
251 ></div>
252 </div>
06d5ffeClaude253 <p class="auth-switch">
bb0f894Claude254 <Text>New to gluecron? <a href="/register">Create an account</a></Text>
06d5ffeClaude255 </p>
2df1f8cClaude256 <script
257 dangerouslySetInnerHTML={{
258 __html: /* js */ `
259 (function () {
260 const btn = document.getElementById('pk-signin-btn');
261 const status = document.getElementById('pk-signin-status');
262 const userInput = document.getElementById('username');
263 const redirect = ${JSON.stringify(redirect || "/")};
264 if (!btn) return;
265 function b64uToBuf(s) {
266 s = s.replace(/-/g,'+').replace(/_/g,'/');
267 while (s.length % 4) s += '=';
268 const bin = atob(s);
269 const buf = new Uint8Array(bin.length);
270 for (let i=0;i<bin.length;i++) buf[i] = bin.charCodeAt(i);
271 return buf.buffer;
272 }
273 function bufToB64u(buf) {
274 const bytes = new Uint8Array(buf);
275 let bin = '';
276 for (let i=0;i<bytes.length;i++) bin += String.fromCharCode(bytes[i]);
277 return btoa(bin).replace(/\\+/g,'-').replace(/\\//g,'_').replace(/=+$/,'');
278 }
279 btn.addEventListener('click', async function () {
280 if (!window.PublicKeyCredential) {
281 status.textContent = 'Passkeys not supported in this browser.';
282 return;
283 }
284 status.textContent = 'Preparing…';
285 try {
286 const username = (userInput && userInput.value || '').trim();
287 const optsRes = await fetch('/api/passkeys/auth/options', {
288 method: 'POST',
289 headers: { 'content-type': 'application/json' },
290 body: JSON.stringify(username ? { username: username } : {})
291 });
292 if (!optsRes.ok) throw new Error('options failed');
293 const { options, sessionKey } = await optsRes.json();
294 options.challenge = b64uToBuf(options.challenge);
295 if (options.allowCredentials) {
296 options.allowCredentials = options.allowCredentials.map(function (c) {
297 return Object.assign({}, c, { id: b64uToBuf(c.id) });
298 });
299 }
300 status.textContent = 'Touch your authenticator…';
301 const cred = await navigator.credentials.get({ publicKey: options });
302 const resp = {
303 id: cred.id,
304 rawId: bufToB64u(cred.rawId),
305 type: cred.type,
306 response: {
307 clientDataJSON: bufToB64u(cred.response.clientDataJSON),
308 authenticatorData: bufToB64u(cred.response.authenticatorData),
309 signature: bufToB64u(cred.response.signature),
310 userHandle: cred.response.userHandle ? bufToB64u(cred.response.userHandle) : null
311 },
312 clientExtensionResults: cred.getClientExtensionResults ? cred.getClientExtensionResults() : {}
313 };
314 const verifyRes = await fetch('/api/passkeys/auth/verify', {
315 method: 'POST',
316 headers: { 'content-type': 'application/json' },
317 body: JSON.stringify({ sessionKey: sessionKey, response: resp })
318 });
319 if (!verifyRes.ok) {
320 const j = await verifyRes.json().catch(function () { return {}; });
321 throw new Error(j.error || 'verify failed');
322 }
323 status.textContent = 'Signed in. Redirecting…';
324 window.location.href = redirect;
325 } catch (e) {
326 status.textContent = 'Error: ' + (e && e.message ? e.message : e);
327 }
328 });
329 })();
330 `,
331 }}
332 />
06d5ffeClaude333 </div>
334 </Layout>
335 );
336});
337
338auth.post("/login", async (c) => {
339 const body = await c.req.parseBody();
340 const identifier = String(body.username || "").trim();
341 const password = String(body.password || "");
342 const redirect = c.req.query("redirect") || "/";
343
344 if (!identifier || !password) {
345 return c.redirect("/login?error=All+fields+are+required");
346 }
347
348 // Find user by username or email
349 const isEmail = identifier.includes("@");
350 const [user] = await db
351 .select()
352 .from(users)
353 .where(
354 isEmail
355 ? eq(users.email, identifier)
356 : eq(users.username, identifier)
357 )
358 .limit(1);
359
360 if (!user) {
361 return c.redirect("/login?error=Invalid+credentials");
362 }
363
364 const valid = await verifyPassword(password, user.passwordHash);
365 if (!valid) {
366 return c.redirect("/login?error=Invalid+credentials");
367 }
368
7298a17Claude369 // B4: if the user has TOTP enabled, issue a pending-2fa session and
370 // redirect to the code prompt.
371 const [totp] = await db
372 .select({ enabledAt: userTotp.enabledAt })
373 .from(userTotp)
374 .where(eq(userTotp.userId, user.id))
375 .limit(1);
376 const needs2fa = !!(totp && totp.enabledAt);
377
06d5ffeClaude378 const token = generateSessionToken();
379 await db.insert(sessions).values({
380 userId: user.id,
381 token,
382 expiresAt: sessionExpiry(),
7298a17Claude383 requires2fa: needs2fa,
06d5ffeClaude384 });
385
386 setCookie(c, "session", token, sessionCookieOptions());
7298a17Claude387 if (needs2fa) {
388 return c.redirect(
389 `/login/2fa?redirect=${encodeURIComponent(redirect)}`
390 );
391 }
06d5ffeClaude392 return c.redirect(redirect);
393});
394
7298a17Claude395// --- 2FA verify (B4) ---
396auth.get("/login/2fa", async (c) => {
397 const token = getCookie(c, "session");
398 if (!token) return c.redirect("/login");
399 const error = c.req.query("error");
400 const redirect = c.req.query("redirect") || "/";
401 return c.html(
402 <Layout title="Two-factor authentication">
403 <div class="auth-container">
404 <h2>Enter your code</h2>
405 <p
406 class="auth-switch"
407 style="margin-bottom: 16px; margin-top: 0"
408 >
409 Open your authenticator app and enter the 6-digit code. Lost your
410 device? Paste a recovery code instead.
411 </p>
412 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
413 <form
b9968e3Claude414 method="post"
7298a17Claude415 action={`/login/2fa?redirect=${encodeURIComponent(redirect)}`}
416 >
134750bClaude417 <input type="hidden" name="_csrf" value={(c.get("csrfToken") as string | undefined) || ""} />
7298a17Claude418 <div class="form-group">
419 <label for="code">Code</label>
420 <input
421 type="text"
422 id="code"
423 name="code"
424 required
425 autocomplete="one-time-code"
426 inputmode="numeric"
427 maxLength={24}
428 placeholder="123456 or xxxx-xxxx-xxxx"
429 />
430 </div>
431 <button type="submit" class="btn btn-primary">
432 Verify
433 </button>
434 </form>
435 <p class="auth-switch">
436 <a href="/logout">Cancel</a>
437 </p>
438 </div>
439 </Layout>
440 );
441});
442
443auth.post("/login/2fa", async (c) => {
444 const token = getCookie(c, "session");
445 if (!token) return c.redirect("/login");
446 const body = await c.req.parseBody();
447 const code = String(body.code || "").trim();
448 const redirect = c.req.query("redirect") || "/";
449
450 if (!code) {
451 return c.redirect(
452 `/login/2fa?error=Code+is+required&redirect=${encodeURIComponent(redirect)}`
453 );
454 }
455
456 try {
457 const [session] = await db
458 .select()
459 .from(sessions)
460 .where(eq(sessions.token, token))
461 .limit(1);
462 if (
463 !session ||
464 new Date(session.expiresAt) < new Date() ||
465 !session.requires2fa
466 ) {
467 return c.redirect("/login");
468 }
469
470 const [totp] = await db
471 .select()
472 .from(userTotp)
473 .where(eq(userTotp.userId, session.userId))
474 .limit(1);
475 if (!totp || !totp.enabledAt) {
476 // User doesn't have 2FA actually enabled — clear the flag and let
477 // them in. This can only happen if 2FA was disabled in another
478 // session between password check and code prompt.
479 await db
480 .update(sessions)
481 .set({ requires2fa: false })
482 .where(eq(sessions.token, token));
483 return c.redirect(redirect);
484 }
485
486 // Try TOTP code first.
487 const isSix = /^\d{6}$/.test(code);
488 let ok = false;
489 if (isSix) {
490 ok = await verifyTotpCode(totp.secret, code);
491 }
492 // Fall through to recovery code.
493 if (!ok) {
494 const hash = await hashRecoveryCode(code);
495 const [rec] = await db
496 .select()
497 .from(userRecoveryCodes)
498 .where(
499 and(
500 eq(userRecoveryCodes.userId, session.userId),
501 eq(userRecoveryCodes.codeHash, hash),
502 isNull(userRecoveryCodes.usedAt)
503 )
504 )
505 .limit(1);
506 if (rec) {
507 await db
508 .update(userRecoveryCodes)
509 .set({ usedAt: new Date() })
510 .where(eq(userRecoveryCodes.id, rec.id));
511 ok = true;
512 }
513 }
514
515 if (!ok) {
516 return c.redirect(
517 `/login/2fa?error=Invalid+code&redirect=${encodeURIComponent(redirect)}`
518 );
519 }
520
521 await db
522 .update(sessions)
523 .set({ requires2fa: false })
524 .where(eq(sessions.token, token));
525 await db
526 .update(userTotp)
527 .set({ lastUsedAt: new Date() })
528 .where(eq(userTotp.userId, session.userId));
529
530 return c.redirect(redirect);
531 } catch (err) {
532 console.error("[auth] 2fa verify:", err);
533 return c.redirect(
534 `/login/2fa?error=Service+unavailable&redirect=${encodeURIComponent(redirect)}`
535 );
536 }
537});
538
06d5ffeClaude539auth.get("/logout", async (c) => {
540 deleteCookie(c, "session", { path: "/" });
541 return c.redirect("/");
542});
543
544// --- API ---
545
546auth.post("/api/auth/register", async (c) => {
547 const body = await c.req.json<{
548 username: string;
549 email: string;
550 password: string;
551 }>();
552
553 if (!body.username || !body.email || !body.password) {
554 return c.json({ error: "username, email, and password are required" }, 400);
555 }
556
557 if (!/^[a-zA-Z0-9_-]+$/.test(body.username)) {
558 return c.json({ error: "Invalid username" }, 400);
559 }
560
561 if (body.password.length < 8) {
562 return c.json({ error: "Password must be at least 8 characters" }, 400);
563 }
564
565 const [existing] = await db
566 .select()
567 .from(users)
568 .where(eq(users.username, body.username))
569 .limit(1);
570 if (existing) {
571 return c.json({ error: "Username already taken" }, 409);
572 }
573
574 const passwordHash = await hashPassword(body.password);
575 const [user] = await db
576 .insert(users)
577 .values({
578 username: body.username,
579 email: body.email,
580 passwordHash,
581 })
582 .returning();
583
584 const token = generateSessionToken();
585 await db.insert(sessions).values({
586 userId: user.id,
587 token,
588 expiresAt: sessionExpiry(),
589 });
590
591 return c.json(
592 {
593 user: { id: user.id, username: user.username, email: user.email },
594 token,
595 },
596 201
597 );
598});
599
600auth.post("/api/auth/login", async (c) => {
601 const body = await c.req.json<{ username: string; password: string }>();
602
603 if (!body.username || !body.password) {
604 return c.json({ error: "username and password are required" }, 400);
605 }
606
607 const isEmail = body.username.includes("@");
608 const [user] = await db
609 .select()
610 .from(users)
611 .where(
612 isEmail
613 ? eq(users.email, body.username)
614 : eq(users.username, body.username)
615 )
616 .limit(1);
617
618 if (!user) return c.json({ error: "Invalid credentials" }, 401);
619
620 const valid = await verifyPassword(body.password, user.passwordHash);
621 if (!valid) return c.json({ error: "Invalid credentials" }, 401);
622
623 const token = generateSessionToken();
624 await db.insert(sessions).values({
625 userId: user.id,
626 token,
627 expiresAt: sessionExpiry(),
628 });
629
630 return c.json({
631 user: { id: user.id, username: user.username, email: user.email },
632 token,
633 });
634});
635
fc0ca99Claude636/**
637 * Pick a friendly provider name for the "Sign in with X" button when the
638 * admin hasn't set one explicitly. Looks at the configured IdP URLs.
639 * Falls back to undefined so the caller can default to a literal "SSO".
640 */
641function inferSsoProviderName(
642 cfg: { issuer?: string | null; authorizationEndpoint?: string | null } | null | undefined
643): string | undefined {
644 const urls = [cfg?.issuer, cfg?.authorizationEndpoint]
645 .filter((s): s is string => !!s)
646 .join(" ")
647 .toLowerCase();
648 if (!urls) return undefined;
649 if (urls.includes("google")) return "Google";
650 if (urls.includes("okta")) return "Okta";
651 if (urls.includes("microsoftonline") || urls.includes("azure")) return "Microsoft";
652 if (urls.includes("auth0.com")) return "Auth0";
653 if (urls.includes("authentik")) return "Authentik";
654 return undefined;
655}
656
06d5ffeClaude657export default auth;