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