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.tsxBlame593 lines · 1 contributor
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"
68 />
bb0f894Claude69 </FormGroup>
70 <FormGroup label="Password" htmlFor="password">
71 <Input
06d5ffeClaude72 type="password"
73 name="password"
74 required
75 minLength={8}
76 placeholder="Min 8 characters"
77 autocomplete="new-password"
78 />
bb0f894Claude79 </FormGroup>
80 <Button type="submit" variant="primary">
06d5ffeClaude81 Create account
bb0f894Claude82 </Button>
83 </Form>
06d5ffeClaude84 <p class="auth-switch">
bb0f894Claude85 <Text>Already have an account? <a href="/login">Sign in</a></Text>
06d5ffeClaude86 </p>
87 </div>
88 </Layout>
89 );
90});
91
92auth.post("/register", async (c) => {
93 const body = await c.req.parseBody();
94 const username = String(body.username || "").trim();
95 const email = String(body.email || "").trim();
96 const password = String(body.password || "");
97
98 if (!username || !email || !password) {
99 return c.redirect("/register?error=All+fields+are+required");
100 }
101
102 if (!/^[a-zA-Z0-9_-]+$/.test(username)) {
103 return c.redirect(
104 "/register?error=Username+may+only+contain+letters%2C+numbers%2C+hyphens+and+underscores"
105 );
106 }
107
108 if (password.length < 8) {
109 return c.redirect("/register?error=Password+must+be+at+least+8+characters");
110 }
111
112 // Check existing
113 const [existingUser] = await db
114 .select()
115 .from(users)
116 .where(eq(users.username, username))
117 .limit(1);
118 if (existingUser) {
119 return c.redirect("/register?error=Username+already+taken");
120 }
121
7437605Claude122 // B2: usernames share the URL namespace with org slugs; refuse collisions.
123 const [existingOrg] = await db
124 .select({ id: organizations.id })
125 .from(organizations)
126 .where(eq(organizations.slug, username.toLowerCase()))
127 .limit(1);
128 if (existingOrg) {
129 return c.redirect("/register?error=Username+already+taken");
130 }
131
06d5ffeClaude132 const [existingEmail] = await db
133 .select()
134 .from(users)
135 .where(eq(users.email, email))
136 .limit(1);
137 if (existingEmail) {
138 return c.redirect("/register?error=Email+already+registered");
139 }
140
141 const passwordHash = await hashPassword(password);
142
a4f3e24Claude143 // First user ever registered becomes admin automatically
144 const [userCount] = await db
145 .select({ count: sql`count(*)::int` })
146 .from(users);
147 const isFirstUser = (userCount?.count as number) === 0;
148
06d5ffeClaude149 const [user] = await db
150 .insert(users)
a4f3e24Claude151 .values({ username, email, passwordHash, isAdmin: isFirstUser })
06d5ffeClaude152 .returning();
153
154 // Create session
155 const token = generateSessionToken();
156 await db.insert(sessions).values({
157 userId: user.id,
158 token,
159 expiresAt: sessionExpiry(),
160 });
161
162 setCookie(c, "session", token, sessionCookieOptions());
163
164 const redirect = c.req.query("redirect") || "/";
165 return c.redirect(redirect);
166});
167
edf7c36Claude168auth.get("/login", async (c) => {
06d5ffeClaude169 const error = c.req.query("error");
170 const redirect = c.req.query("redirect") || "";
edf7c36Claude171 const ssoCfg = await getSsoConfig();
172 const ssoEnabled =
173 !!ssoCfg?.enabled &&
174 !!ssoCfg.authorizationEndpoint &&
175 !!ssoCfg.tokenEndpoint &&
176 !!ssoCfg.userinfoEndpoint &&
177 !!ssoCfg.clientId &&
178 !!ssoCfg.clientSecret;
179 const ssoLabel = ssoCfg?.providerName || "SSO";
06d5ffeClaude180 return c.html(
181 <Layout title="Sign in">
182 <div class="auth-container">
183 <h2>Sign in</h2>
184 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
0316dbbClaude185 <Form
b9968e3Claude186 method="post"
06d5ffeClaude187 action={`/login${redirect ? `?redirect=${encodeURIComponent(redirect)}` : ""}`}
188 >
bb0f894Claude189 <FormGroup label="Username or email" htmlFor="username">
190 <Input
06d5ffeClaude191 type="text"
192 name="username"
193 required
194 placeholder="username or email"
195 autocomplete="username"
196 />
bb0f894Claude197 </FormGroup>
198 <FormGroup label="Password" htmlFor="password">
199 <Input
06d5ffeClaude200 type="password"
201 name="password"
202 required
203 placeholder="Password"
204 autocomplete="current-password"
205 />
bb0f894Claude206 </FormGroup>
207 <Button type="submit" variant="primary">
06d5ffeClaude208 Sign in
bb0f894Claude209 </Button>
210 </Form>
06d5ffeClaude211 <p class="auth-switch">
bb0f894Claude212 <Text>New to gluecron? <a href="/register">Create an account</a></Text>
06d5ffeClaude213 </p>
2df1f8cClaude214 <script
215 dangerouslySetInnerHTML={{
216 __html: /* js */ `
217 (function () {
218 const btn = document.getElementById('pk-signin-btn');
219 const status = document.getElementById('pk-signin-status');
220 const userInput = document.getElementById('username');
221 const redirect = ${JSON.stringify(redirect || "/")};
222 if (!btn) return;
223 function b64uToBuf(s) {
224 s = s.replace(/-/g,'+').replace(/_/g,'/');
225 while (s.length % 4) s += '=';
226 const bin = atob(s);
227 const buf = new Uint8Array(bin.length);
228 for (let i=0;i<bin.length;i++) buf[i] = bin.charCodeAt(i);
229 return buf.buffer;
230 }
231 function bufToB64u(buf) {
232 const bytes = new Uint8Array(buf);
233 let bin = '';
234 for (let i=0;i<bytes.length;i++) bin += String.fromCharCode(bytes[i]);
235 return btoa(bin).replace(/\\+/g,'-').replace(/\\//g,'_').replace(/=+$/,'');
236 }
237 btn.addEventListener('click', async function () {
238 if (!window.PublicKeyCredential) {
239 status.textContent = 'Passkeys not supported in this browser.';
240 return;
241 }
242 status.textContent = 'Preparing…';
243 try {
244 const username = (userInput && userInput.value || '').trim();
245 const optsRes = await fetch('/api/passkeys/auth/options', {
246 method: 'POST',
247 headers: { 'content-type': 'application/json' },
248 body: JSON.stringify(username ? { username: username } : {})
249 });
250 if (!optsRes.ok) throw new Error('options failed');
251 const { options, sessionKey } = await optsRes.json();
252 options.challenge = b64uToBuf(options.challenge);
253 if (options.allowCredentials) {
254 options.allowCredentials = options.allowCredentials.map(function (c) {
255 return Object.assign({}, c, { id: b64uToBuf(c.id) });
256 });
257 }
258 status.textContent = 'Touch your authenticator…';
259 const cred = await navigator.credentials.get({ publicKey: options });
260 const resp = {
261 id: cred.id,
262 rawId: bufToB64u(cred.rawId),
263 type: cred.type,
264 response: {
265 clientDataJSON: bufToB64u(cred.response.clientDataJSON),
266 authenticatorData: bufToB64u(cred.response.authenticatorData),
267 signature: bufToB64u(cred.response.signature),
268 userHandle: cred.response.userHandle ? bufToB64u(cred.response.userHandle) : null
269 },
270 clientExtensionResults: cred.getClientExtensionResults ? cred.getClientExtensionResults() : {}
271 };
272 const verifyRes = await fetch('/api/passkeys/auth/verify', {
273 method: 'POST',
274 headers: { 'content-type': 'application/json' },
275 body: JSON.stringify({ sessionKey: sessionKey, response: resp })
276 });
277 if (!verifyRes.ok) {
278 const j = await verifyRes.json().catch(function () { return {}; });
279 throw new Error(j.error || 'verify failed');
280 }
281 status.textContent = 'Signed in. Redirecting…';
282 window.location.href = redirect;
283 } catch (e) {
284 status.textContent = 'Error: ' + (e && e.message ? e.message : e);
285 }
286 });
287 })();
288 `,
289 }}
290 />
06d5ffeClaude291 </div>
292 </Layout>
293 );
294});
295
296auth.post("/login", async (c) => {
297 const body = await c.req.parseBody();
298 const identifier = String(body.username || "").trim();
299 const password = String(body.password || "");
300 const redirect = c.req.query("redirect") || "/";
301
302 if (!identifier || !password) {
303 return c.redirect("/login?error=All+fields+are+required");
304 }
305
306 // Find user by username or email
307 const isEmail = identifier.includes("@");
308 const [user] = await db
309 .select()
310 .from(users)
311 .where(
312 isEmail
313 ? eq(users.email, identifier)
314 : eq(users.username, identifier)
315 )
316 .limit(1);
317
318 if (!user) {
319 return c.redirect("/login?error=Invalid+credentials");
320 }
321
322 const valid = await verifyPassword(password, user.passwordHash);
323 if (!valid) {
324 return c.redirect("/login?error=Invalid+credentials");
325 }
326
7298a17Claude327 // B4: if the user has TOTP enabled, issue a pending-2fa session and
328 // redirect to the code prompt.
329 const [totp] = await db
330 .select({ enabledAt: userTotp.enabledAt })
331 .from(userTotp)
332 .where(eq(userTotp.userId, user.id))
333 .limit(1);
334 const needs2fa = !!(totp && totp.enabledAt);
335
06d5ffeClaude336 const token = generateSessionToken();
337 await db.insert(sessions).values({
338 userId: user.id,
339 token,
340 expiresAt: sessionExpiry(),
7298a17Claude341 requires2fa: needs2fa,
06d5ffeClaude342 });
343
344 setCookie(c, "session", token, sessionCookieOptions());
7298a17Claude345 if (needs2fa) {
346 return c.redirect(
347 `/login/2fa?redirect=${encodeURIComponent(redirect)}`
348 );
349 }
06d5ffeClaude350 return c.redirect(redirect);
351});
352
7298a17Claude353// --- 2FA verify (B4) ---
354auth.get("/login/2fa", async (c) => {
355 const token = getCookie(c, "session");
356 if (!token) return c.redirect("/login");
357 const error = c.req.query("error");
358 const redirect = c.req.query("redirect") || "/";
359 return c.html(
360 <Layout title="Two-factor authentication">
361 <div class="auth-container">
362 <h2>Enter your code</h2>
363 <p
364 class="auth-switch"
365 style="margin-bottom: 16px; margin-top: 0"
366 >
367 Open your authenticator app and enter the 6-digit code. Lost your
368 device? Paste a recovery code instead.
369 </p>
370 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
371 <form
b9968e3Claude372 method="post"
7298a17Claude373 action={`/login/2fa?redirect=${encodeURIComponent(redirect)}`}
374 >
375 <div class="form-group">
376 <label for="code">Code</label>
377 <input
378 type="text"
379 id="code"
380 name="code"
381 required
382 autocomplete="one-time-code"
383 inputmode="numeric"
384 maxLength={24}
385 placeholder="123456 or xxxx-xxxx-xxxx"
386 />
387 </div>
388 <button type="submit" class="btn btn-primary">
389 Verify
390 </button>
391 </form>
392 <p class="auth-switch">
393 <a href="/logout">Cancel</a>
394 </p>
395 </div>
396 </Layout>
397 );
398});
399
400auth.post("/login/2fa", async (c) => {
401 const token = getCookie(c, "session");
402 if (!token) return c.redirect("/login");
403 const body = await c.req.parseBody();
404 const code = String(body.code || "").trim();
405 const redirect = c.req.query("redirect") || "/";
406
407 if (!code) {
408 return c.redirect(
409 `/login/2fa?error=Code+is+required&redirect=${encodeURIComponent(redirect)}`
410 );
411 }
412
413 try {
414 const [session] = await db
415 .select()
416 .from(sessions)
417 .where(eq(sessions.token, token))
418 .limit(1);
419 if (
420 !session ||
421 new Date(session.expiresAt) < new Date() ||
422 !session.requires2fa
423 ) {
424 return c.redirect("/login");
425 }
426
427 const [totp] = await db
428 .select()
429 .from(userTotp)
430 .where(eq(userTotp.userId, session.userId))
431 .limit(1);
432 if (!totp || !totp.enabledAt) {
433 // User doesn't have 2FA actually enabled — clear the flag and let
434 // them in. This can only happen if 2FA was disabled in another
435 // session between password check and code prompt.
436 await db
437 .update(sessions)
438 .set({ requires2fa: false })
439 .where(eq(sessions.token, token));
440 return c.redirect(redirect);
441 }
442
443 // Try TOTP code first.
444 const isSix = /^\d{6}$/.test(code);
445 let ok = false;
446 if (isSix) {
447 ok = await verifyTotpCode(totp.secret, code);
448 }
449 // Fall through to recovery code.
450 if (!ok) {
451 const hash = await hashRecoveryCode(code);
452 const [rec] = await db
453 .select()
454 .from(userRecoveryCodes)
455 .where(
456 and(
457 eq(userRecoveryCodes.userId, session.userId),
458 eq(userRecoveryCodes.codeHash, hash),
459 isNull(userRecoveryCodes.usedAt)
460 )
461 )
462 .limit(1);
463 if (rec) {
464 await db
465 .update(userRecoveryCodes)
466 .set({ usedAt: new Date() })
467 .where(eq(userRecoveryCodes.id, rec.id));
468 ok = true;
469 }
470 }
471
472 if (!ok) {
473 return c.redirect(
474 `/login/2fa?error=Invalid+code&redirect=${encodeURIComponent(redirect)}`
475 );
476 }
477
478 await db
479 .update(sessions)
480 .set({ requires2fa: false })
481 .where(eq(sessions.token, token));
482 await db
483 .update(userTotp)
484 .set({ lastUsedAt: new Date() })
485 .where(eq(userTotp.userId, session.userId));
486
487 return c.redirect(redirect);
488 } catch (err) {
489 console.error("[auth] 2fa verify:", err);
490 return c.redirect(
491 `/login/2fa?error=Service+unavailable&redirect=${encodeURIComponent(redirect)}`
492 );
493 }
494});
495
06d5ffeClaude496auth.get("/logout", async (c) => {
497 deleteCookie(c, "session", { path: "/" });
498 return c.redirect("/");
499});
500
501// --- API ---
502
503auth.post("/api/auth/register", async (c) => {
504 const body = await c.req.json<{
505 username: string;
506 email: string;
507 password: string;
508 }>();
509
510 if (!body.username || !body.email || !body.password) {
511 return c.json({ error: "username, email, and password are required" }, 400);
512 }
513
514 if (!/^[a-zA-Z0-9_-]+$/.test(body.username)) {
515 return c.json({ error: "Invalid username" }, 400);
516 }
517
518 if (body.password.length < 8) {
519 return c.json({ error: "Password must be at least 8 characters" }, 400);
520 }
521
522 const [existing] = await db
523 .select()
524 .from(users)
525 .where(eq(users.username, body.username))
526 .limit(1);
527 if (existing) {
528 return c.json({ error: "Username already taken" }, 409);
529 }
530
531 const passwordHash = await hashPassword(body.password);
532 const [user] = await db
533 .insert(users)
534 .values({
535 username: body.username,
536 email: body.email,
537 passwordHash,
538 })
539 .returning();
540
541 const token = generateSessionToken();
542 await db.insert(sessions).values({
543 userId: user.id,
544 token,
545 expiresAt: sessionExpiry(),
546 });
547
548 return c.json(
549 {
550 user: { id: user.id, username: user.username, email: user.email },
551 token,
552 },
553 201
554 );
555});
556
557auth.post("/api/auth/login", async (c) => {
558 const body = await c.req.json<{ username: string; password: string }>();
559
560 if (!body.username || !body.password) {
561 return c.json({ error: "username and password are required" }, 400);
562 }
563
564 const isEmail = body.username.includes("@");
565 const [user] = await db
566 .select()
567 .from(users)
568 .where(
569 isEmail
570 ? eq(users.email, body.username)
571 : eq(users.username, body.username)
572 )
573 .limit(1);
574
575 if (!user) return c.json({ error: "Invalid credentials" }, 401);
576
577 const valid = await verifyPassword(body.password, user.passwordHash);
578 if (!valid) return c.json({ error: "Invalid credentials" }, 401);
579
580 const token = generateSessionToken();
581 await db.insert(sessions).values({
582 userId: user.id,
583 token,
584 expiresAt: sessionExpiry(),
585 });
586
587 return c.json({
588 user: { id: user.id, username: user.username, email: user.email },
589 token,
590 });
591});
592
593export default auth;