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

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.tsxBlame607 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";
edf7c36Claude24import { getSsoConfig } from "../lib/sso";
06d5ffeClaude25import { Layout } from "../views/layout";
bb0f894Claude26import {
27 Form,
28 FormGroup,
29 Input,
30 Button,
31 Alert,
32 Text,
33} from "../views/ui";
06d5ffeClaude34import type { AuthEnv } from "../middleware/auth";
35
36const auth = new Hono<AuthEnv>();
37
38// --- Web UI ---
39
40auth.get("/register", (c) => {
41 const error = c.req.query("error");
134750bClaude42 const csrf = c.get("csrfToken") as string | undefined;
06d5ffeClaude43 return c.html(
44 <Layout title="Register">
45 <div class="auth-container">
46 <h2>Create account</h2>
47 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
134750bClaude48 <Form method="post" action="/register" csrfToken={csrf}>
0316dbbClaude49 <FormGroup label="Username" htmlFor="username">
50 <Input
51 id="username"
06d5ffeClaude52 type="text"
53 name="username"
54 required
55 pattern="^[a-zA-Z0-9_-]+$"
56 minLength={2}
57 maxLength={39}
58 placeholder="your-username"
59 autocomplete="username"
60 />
bb0f894Claude61 </FormGroup>
62 <FormGroup label="Email" htmlFor="email">
63 <Input
06d5ffeClaude64 type="email"
65 name="email"
66 required
67 placeholder="you@example.com"
68 autocomplete="email"
2c3ba6ecopilot-swe-agent[bot]69 aria-label="Email"
06d5ffeClaude70 />
bb0f894Claude71 </FormGroup>
72 <FormGroup label="Password" htmlFor="password">
73 <Input
06d5ffeClaude74 type="password"
75 name="password"
76 required
77 minLength={8}
78 placeholder="Min 8 characters"
79 autocomplete="new-password"
2c3ba6ecopilot-swe-agent[bot]80 aria-label="Password"
06d5ffeClaude81 />
bb0f894Claude82 </FormGroup>
83 <Button type="submit" variant="primary">
06d5ffeClaude84 Create account
bb0f894Claude85 </Button>
86 </Form>
06d5ffeClaude87 <p class="auth-switch">
bb0f894Claude88 <Text>Already have an account? <a href="/login">Sign in</a></Text>
06d5ffeClaude89 </p>
90 </div>
91 </Layout>
92 );
93});
94
95auth.post("/register", async (c) => {
96 const body = await c.req.parseBody();
97 const username = String(body.username || "").trim();
98 const email = String(body.email || "").trim();
99 const password = String(body.password || "");
100
101 if (!username || !email || !password) {
102 return c.redirect("/register?error=All+fields+are+required");
103 }
104
105 if (!/^[a-zA-Z0-9_-]+$/.test(username)) {
106 return c.redirect(
107 "/register?error=Username+may+only+contain+letters%2C+numbers%2C+hyphens+and+underscores"
108 );
109 }
110
111 if (password.length < 8) {
112 return c.redirect("/register?error=Password+must+be+at+least+8+characters");
113 }
114
115 // Check existing
116 const [existingUser] = await db
117 .select()
118 .from(users)
119 .where(eq(users.username, username))
120 .limit(1);
121 if (existingUser) {
122 return c.redirect("/register?error=Username+already+taken");
123 }
124
7437605Claude125 // B2: usernames share the URL namespace with org slugs; refuse collisions.
126 const [existingOrg] = await db
127 .select({ id: organizations.id })
128 .from(organizations)
129 .where(eq(organizations.slug, username.toLowerCase()))
130 .limit(1);
131 if (existingOrg) {
132 return c.redirect("/register?error=Username+already+taken");
133 }
134
06d5ffeClaude135 const [existingEmail] = await db
136 .select()
137 .from(users)
138 .where(eq(users.email, email))
139 .limit(1);
140 if (existingEmail) {
141 return c.redirect("/register?error=Email+already+registered");
142 }
143
144 const passwordHash = await hashPassword(password);
145
a4f3e24Claude146 // First user ever registered becomes admin automatically
147 const [userCount] = await db
148 .select({ count: sql`count(*)::int` })
149 .from(users);
150 const isFirstUser = (userCount?.count as number) === 0;
151
06d5ffeClaude152 const [user] = await db
153 .insert(users)
a4f3e24Claude154 .values({ username, email, passwordHash, isAdmin: isFirstUser })
06d5ffeClaude155 .returning();
156
2d985e5Claude157 // If username matches SITE_ADMIN_USERNAME env, grant site admin instantly
158 // so the operator doesn't have to wait for the next boot's bootstrap pass.
159 await import("../lib/admin-bootstrap").then((m) =>
160 m.ensureEnvAdminOnRegister({ userId: user.id, username })
161 ).catch(() => {});
162
06d5ffeClaude163 // Create session
164 const token = generateSessionToken();
165 await db.insert(sessions).values({
166 userId: user.id,
167 token,
168 expiresAt: sessionExpiry(),
169 });
170
171 setCookie(c, "session", token, sessionCookieOptions());
172
173 const redirect = c.req.query("redirect") || "/";
174 return c.redirect(redirect);
175});
176
edf7c36Claude177auth.get("/login", async (c) => {
06d5ffeClaude178 const error = c.req.query("error");
179 const redirect = c.req.query("redirect") || "";
edf7c36Claude180 const ssoCfg = await getSsoConfig();
181 const ssoEnabled =
182 !!ssoCfg?.enabled &&
183 !!ssoCfg.authorizationEndpoint &&
184 !!ssoCfg.tokenEndpoint &&
185 !!ssoCfg.userinfoEndpoint &&
186 !!ssoCfg.clientId &&
187 !!ssoCfg.clientSecret;
188 const ssoLabel = ssoCfg?.providerName || "SSO";
134750bClaude189 const csrf = c.get("csrfToken") as string | undefined;
06d5ffeClaude190 return c.html(
191 <Layout title="Sign in">
192 <div class="auth-container">
193 <h2>Sign in</h2>
194 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
0316dbbClaude195 <Form
b9968e3Claude196 method="post"
06d5ffeClaude197 action={`/login${redirect ? `?redirect=${encodeURIComponent(redirect)}` : ""}`}
134750bClaude198 csrfToken={csrf}
06d5ffeClaude199 >
bb0f894Claude200 <FormGroup label="Username or email" htmlFor="username">
201 <Input
06d5ffeClaude202 type="text"
203 name="username"
204 required
205 placeholder="username or email"
206 autocomplete="username"
2c3ba6ecopilot-swe-agent[bot]207 aria-label="Username or email"
06d5ffeClaude208 />
bb0f894Claude209 </FormGroup>
210 <FormGroup label="Password" htmlFor="password">
211 <Input
06d5ffeClaude212 type="password"
213 name="password"
214 required
215 placeholder="Password"
216 autocomplete="current-password"
2c3ba6ecopilot-swe-agent[bot]217 aria-label="Password"
06d5ffeClaude218 />
bb0f894Claude219 </FormGroup>
220 <Button type="submit" variant="primary">
06d5ffeClaude221 Sign in
bb0f894Claude222 </Button>
223 </Form>
06d5ffeClaude224 <p class="auth-switch">
bb0f894Claude225 <Text>New to gluecron? <a href="/register">Create an account</a></Text>
06d5ffeClaude226 </p>
2df1f8cClaude227 <script
228 dangerouslySetInnerHTML={{
229 __html: /* js */ `
230 (function () {
231 const btn = document.getElementById('pk-signin-btn');
232 const status = document.getElementById('pk-signin-status');
233 const userInput = document.getElementById('username');
234 const redirect = ${JSON.stringify(redirect || "/")};
235 if (!btn) return;
236 function b64uToBuf(s) {
237 s = s.replace(/-/g,'+').replace(/_/g,'/');
238 while (s.length % 4) s += '=';
239 const bin = atob(s);
240 const buf = new Uint8Array(bin.length);
241 for (let i=0;i<bin.length;i++) buf[i] = bin.charCodeAt(i);
242 return buf.buffer;
243 }
244 function bufToB64u(buf) {
245 const bytes = new Uint8Array(buf);
246 let bin = '';
247 for (let i=0;i<bytes.length;i++) bin += String.fromCharCode(bytes[i]);
248 return btoa(bin).replace(/\\+/g,'-').replace(/\\//g,'_').replace(/=+$/,'');
249 }
250 btn.addEventListener('click', async function () {
251 if (!window.PublicKeyCredential) {
252 status.textContent = 'Passkeys not supported in this browser.';
253 return;
254 }
255 status.textContent = 'Preparing…';
256 try {
257 const username = (userInput && userInput.value || '').trim();
258 const optsRes = await fetch('/api/passkeys/auth/options', {
259 method: 'POST',
260 headers: { 'content-type': 'application/json' },
261 body: JSON.stringify(username ? { username: username } : {})
262 });
263 if (!optsRes.ok) throw new Error('options failed');
264 const { options, sessionKey } = await optsRes.json();
265 options.challenge = b64uToBuf(options.challenge);
266 if (options.allowCredentials) {
267 options.allowCredentials = options.allowCredentials.map(function (c) {
268 return Object.assign({}, c, { id: b64uToBuf(c.id) });
269 });
270 }
271 status.textContent = 'Touch your authenticator…';
272 const cred = await navigator.credentials.get({ publicKey: options });
273 const resp = {
274 id: cred.id,
275 rawId: bufToB64u(cred.rawId),
276 type: cred.type,
277 response: {
278 clientDataJSON: bufToB64u(cred.response.clientDataJSON),
279 authenticatorData: bufToB64u(cred.response.authenticatorData),
280 signature: bufToB64u(cred.response.signature),
281 userHandle: cred.response.userHandle ? bufToB64u(cred.response.userHandle) : null
282 },
283 clientExtensionResults: cred.getClientExtensionResults ? cred.getClientExtensionResults() : {}
284 };
285 const verifyRes = await fetch('/api/passkeys/auth/verify', {
286 method: 'POST',
287 headers: { 'content-type': 'application/json' },
288 body: JSON.stringify({ sessionKey: sessionKey, response: resp })
289 });
290 if (!verifyRes.ok) {
291 const j = await verifyRes.json().catch(function () { return {}; });
292 throw new Error(j.error || 'verify failed');
293 }
294 status.textContent = 'Signed in. Redirecting…';
295 window.location.href = redirect;
296 } catch (e) {
297 status.textContent = 'Error: ' + (e && e.message ? e.message : e);
298 }
299 });
300 })();
301 `,
302 }}
303 />
06d5ffeClaude304 </div>
305 </Layout>
306 );
307});
308
309auth.post("/login", async (c) => {
310 const body = await c.req.parseBody();
311 const identifier = String(body.username || "").trim();
312 const password = String(body.password || "");
313 const redirect = c.req.query("redirect") || "/";
314
315 if (!identifier || !password) {
316 return c.redirect("/login?error=All+fields+are+required");
317 }
318
319 // Find user by username or email
320 const isEmail = identifier.includes("@");
321 const [user] = await db
322 .select()
323 .from(users)
324 .where(
325 isEmail
326 ? eq(users.email, identifier)
327 : eq(users.username, identifier)
328 )
329 .limit(1);
330
331 if (!user) {
332 return c.redirect("/login?error=Invalid+credentials");
333 }
334
335 const valid = await verifyPassword(password, user.passwordHash);
336 if (!valid) {
337 return c.redirect("/login?error=Invalid+credentials");
338 }
339
7298a17Claude340 // B4: if the user has TOTP enabled, issue a pending-2fa session and
341 // redirect to the code prompt.
342 const [totp] = await db
343 .select({ enabledAt: userTotp.enabledAt })
344 .from(userTotp)
345 .where(eq(userTotp.userId, user.id))
346 .limit(1);
347 const needs2fa = !!(totp && totp.enabledAt);
348
06d5ffeClaude349 const token = generateSessionToken();
350 await db.insert(sessions).values({
351 userId: user.id,
352 token,
353 expiresAt: sessionExpiry(),
7298a17Claude354 requires2fa: needs2fa,
06d5ffeClaude355 });
356
357 setCookie(c, "session", token, sessionCookieOptions());
7298a17Claude358 if (needs2fa) {
359 return c.redirect(
360 `/login/2fa?redirect=${encodeURIComponent(redirect)}`
361 );
362 }
06d5ffeClaude363 return c.redirect(redirect);
364});
365
7298a17Claude366// --- 2FA verify (B4) ---
367auth.get("/login/2fa", async (c) => {
368 const token = getCookie(c, "session");
369 if (!token) return c.redirect("/login");
370 const error = c.req.query("error");
371 const redirect = c.req.query("redirect") || "/";
372 return c.html(
373 <Layout title="Two-factor authentication">
374 <div class="auth-container">
375 <h2>Enter your code</h2>
376 <p
377 class="auth-switch"
378 style="margin-bottom: 16px; margin-top: 0"
379 >
380 Open your authenticator app and enter the 6-digit code. Lost your
381 device? Paste a recovery code instead.
382 </p>
383 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
384 <form
b9968e3Claude385 method="post"
7298a17Claude386 action={`/login/2fa?redirect=${encodeURIComponent(redirect)}`}
387 >
134750bClaude388 <input type="hidden" name="_csrf" value={(c.get("csrfToken") as string | undefined) || ""} />
7298a17Claude389 <div class="form-group">
390 <label for="code">Code</label>
391 <input
392 type="text"
393 id="code"
394 name="code"
395 required
396 autocomplete="one-time-code"
397 inputmode="numeric"
398 maxLength={24}
399 placeholder="123456 or xxxx-xxxx-xxxx"
400 />
401 </div>
402 <button type="submit" class="btn btn-primary">
403 Verify
404 </button>
405 </form>
406 <p class="auth-switch">
407 <a href="/logout">Cancel</a>
408 </p>
409 </div>
410 </Layout>
411 );
412});
413
414auth.post("/login/2fa", async (c) => {
415 const token = getCookie(c, "session");
416 if (!token) return c.redirect("/login");
417 const body = await c.req.parseBody();
418 const code = String(body.code || "").trim();
419 const redirect = c.req.query("redirect") || "/";
420
421 if (!code) {
422 return c.redirect(
423 `/login/2fa?error=Code+is+required&redirect=${encodeURIComponent(redirect)}`
424 );
425 }
426
427 try {
428 const [session] = await db
429 .select()
430 .from(sessions)
431 .where(eq(sessions.token, token))
432 .limit(1);
433 if (
434 !session ||
435 new Date(session.expiresAt) < new Date() ||
436 !session.requires2fa
437 ) {
438 return c.redirect("/login");
439 }
440
441 const [totp] = await db
442 .select()
443 .from(userTotp)
444 .where(eq(userTotp.userId, session.userId))
445 .limit(1);
446 if (!totp || !totp.enabledAt) {
447 // User doesn't have 2FA actually enabled — clear the flag and let
448 // them in. This can only happen if 2FA was disabled in another
449 // session between password check and code prompt.
450 await db
451 .update(sessions)
452 .set({ requires2fa: false })
453 .where(eq(sessions.token, token));
454 return c.redirect(redirect);
455 }
456
457 // Try TOTP code first.
458 const isSix = /^\d{6}$/.test(code);
459 let ok = false;
460 if (isSix) {
461 ok = await verifyTotpCode(totp.secret, code);
462 }
463 // Fall through to recovery code.
464 if (!ok) {
465 const hash = await hashRecoveryCode(code);
466 const [rec] = await db
467 .select()
468 .from(userRecoveryCodes)
469 .where(
470 and(
471 eq(userRecoveryCodes.userId, session.userId),
472 eq(userRecoveryCodes.codeHash, hash),
473 isNull(userRecoveryCodes.usedAt)
474 )
475 )
476 .limit(1);
477 if (rec) {
478 await db
479 .update(userRecoveryCodes)
480 .set({ usedAt: new Date() })
481 .where(eq(userRecoveryCodes.id, rec.id));
482 ok = true;
483 }
484 }
485
486 if (!ok) {
487 return c.redirect(
488 `/login/2fa?error=Invalid+code&redirect=${encodeURIComponent(redirect)}`
489 );
490 }
491
492 await db
493 .update(sessions)
494 .set({ requires2fa: false })
495 .where(eq(sessions.token, token));
496 await db
497 .update(userTotp)
498 .set({ lastUsedAt: new Date() })
499 .where(eq(userTotp.userId, session.userId));
500
501 return c.redirect(redirect);
502 } catch (err) {
503 console.error("[auth] 2fa verify:", err);
504 return c.redirect(
505 `/login/2fa?error=Service+unavailable&redirect=${encodeURIComponent(redirect)}`
506 );
507 }
508});
509
06d5ffeClaude510auth.get("/logout", async (c) => {
511 deleteCookie(c, "session", { path: "/" });
512 return c.redirect("/");
513});
514
515// --- API ---
516
517auth.post("/api/auth/register", async (c) => {
518 const body = await c.req.json<{
519 username: string;
520 email: string;
521 password: string;
522 }>();
523
524 if (!body.username || !body.email || !body.password) {
525 return c.json({ error: "username, email, and password are required" }, 400);
526 }
527
528 if (!/^[a-zA-Z0-9_-]+$/.test(body.username)) {
529 return c.json({ error: "Invalid username" }, 400);
530 }
531
532 if (body.password.length < 8) {
533 return c.json({ error: "Password must be at least 8 characters" }, 400);
534 }
535
536 const [existing] = await db
537 .select()
538 .from(users)
539 .where(eq(users.username, body.username))
540 .limit(1);
541 if (existing) {
542 return c.json({ error: "Username already taken" }, 409);
543 }
544
545 const passwordHash = await hashPassword(body.password);
546 const [user] = await db
547 .insert(users)
548 .values({
549 username: body.username,
550 email: body.email,
551 passwordHash,
552 })
553 .returning();
554
555 const token = generateSessionToken();
556 await db.insert(sessions).values({
557 userId: user.id,
558 token,
559 expiresAt: sessionExpiry(),
560 });
561
562 return c.json(
563 {
564 user: { id: user.id, username: user.username, email: user.email },
565 token,
566 },
567 201
568 );
569});
570
571auth.post("/api/auth/login", async (c) => {
572 const body = await c.req.json<{ username: string; password: string }>();
573
574 if (!body.username || !body.password) {
575 return c.json({ error: "username and password are required" }, 400);
576 }
577
578 const isEmail = body.username.includes("@");
579 const [user] = await db
580 .select()
581 .from(users)
582 .where(
583 isEmail
584 ? eq(users.email, body.username)
585 : eq(users.username, body.username)
586 )
587 .limit(1);
588
589 if (!user) return c.json({ error: "Invalid credentials" }, 401);
590
591 const valid = await verifyPassword(body.password, user.passwordHash);
592 if (!valid) return c.json({ error: "Invalid credentials" }, 401);
593
594 const token = generateSessionToken();
595 await db.insert(sessions).values({
596 userId: user.id,
597 token,
598 expiresAt: sessionExpiry(),
599 });
600
601 return c.json({
602 user: { id: user.id, username: user.username, email: user.email },
603 token,
604 });
605});
606
607export default auth;