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.tsxBlame625 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";
7import { and, eq, isNull } 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";
26import type { AuthEnv } from "../middleware/auth";
27
28const auth = new Hono<AuthEnv>();
29
9837657Claude30function safeRedirect(url: string): string {
31 if (!url || typeof url !== "string") return "/";
32 const trimmed = url.trim();
33 if (trimmed.startsWith("/") && !trimmed.startsWith("//")) return trimmed;
34 return "/";
35}
36
06d5ffeClaude37// --- Web UI ---
38
39auth.get("/register", (c) => {
40 const error = c.req.query("error");
41 return c.html(
42 <Layout title="Register">
43 <div class="auth-container">
44 <h2>Create account</h2>
45 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
9837657Claude46 <form method="post" action="/register">
06d5ffeClaude47 <div class="form-group">
48 <label for="username">Username</label>
49 <input
50 type="text"
51 id="username"
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 />
60 </div>
61 <div class="form-group">
62 <label for="email">Email</label>
63 <input
64 type="email"
65 id="email"
66 name="email"
67 required
68 placeholder="you@example.com"
69 autocomplete="email"
70 />
71 </div>
72 <div class="form-group">
73 <label for="password">Password</label>
74 <input
75 type="password"
76 id="password"
77 name="password"
78 required
79 minLength={8}
80 placeholder="Min 8 characters"
81 autocomplete="new-password"
82 />
83 </div>
84 <button type="submit" class="btn btn-primary">
85 Create account
86 </button>
87 </form>
88 <p class="auth-switch">
89 Already have an account? <a href="/login">Sign in</a>
90 </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
147 const [user] = await db
148 .insert(users)
149 .values({ username, email, passwordHash })
150 .returning();
151
152 // Create session
153 const token = generateSessionToken();
154 await db.insert(sessions).values({
155 userId: user.id,
156 token,
157 expiresAt: sessionExpiry(),
158 });
159
160 setCookie(c, "session", token, sessionCookieOptions());
161
9837657Claude162 const redirect = safeRedirect(c.req.query("redirect") || "/");
06d5ffeClaude163 return c.redirect(redirect);
164});
165
edf7c36Claude166auth.get("/login", async (c) => {
06d5ffeClaude167 const error = c.req.query("error");
9837657Claude168 const redirect = safeRedirect(c.req.query("redirect") || "");
edf7c36Claude169 const ssoCfg = await getSsoConfig();
170 const ssoEnabled =
171 !!ssoCfg?.enabled &&
172 !!ssoCfg.authorizationEndpoint &&
173 !!ssoCfg.tokenEndpoint &&
174 !!ssoCfg.userinfoEndpoint &&
175 !!ssoCfg.clientId &&
176 !!ssoCfg.clientSecret;
177 const ssoLabel = ssoCfg?.providerName || "SSO";
06d5ffeClaude178 return c.html(
179 <Layout title="Sign in">
180 <div class="auth-container">
181 <h2>Sign in</h2>
182 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
183 <form
9837657Claude184 method="post"
06d5ffeClaude185 action={`/login${redirect ? `?redirect=${encodeURIComponent(redirect)}` : ""}`}
186 >
187 <div class="form-group">
188 <label for="username">Username or email</label>
189 <input
190 type="text"
191 id="username"
192 name="username"
193 required
194 placeholder="username or email"
195 autocomplete="username"
196 />
197 </div>
198 <div class="form-group">
199 <label for="password">Password</label>
200 <input
201 type="password"
202 id="password"
203 name="password"
204 required
205 placeholder="Password"
206 autocomplete="current-password"
207 />
208 </div>
209 <button type="submit" class="btn btn-primary">
210 Sign in
211 </button>
212 </form>
2df1f8cClaude213 <div
214 style="margin: 16px 0; text-align: center; color: var(--text-muted); font-size: 12px"
215 >
216 — or —
217 </div>
218 <div style="text-align: center">
219 <button
220 type="button"
221 id="pk-signin-btn"
222 class="btn"
223 style="width: 100%"
224 >
225 Sign in with passkey
226 </button>
227 <div
228 id="pk-signin-status"
229 style="margin-top: 8px; color: var(--text-muted); font-size: 12px; min-height: 16px"
230 />
231 </div>
edf7c36Claude232 {ssoEnabled && (
233 <div style="margin-top: 8px; text-align: center">
234 <a
235 href="/login/sso"
236 class="btn"
237 style="width: 100%; display: block"
238 >
239 Sign in with {ssoLabel}
240 </a>
241 </div>
242 )}
06d5ffeClaude243 <p class="auth-switch">
244 New to gluecron? <a href="/register">Create an account</a>
245 </p>
2df1f8cClaude246 <script
247 dangerouslySetInnerHTML={{
248 __html: /* js */ `
249 (function () {
250 const btn = document.getElementById('pk-signin-btn');
251 const status = document.getElementById('pk-signin-status');
252 const userInput = document.getElementById('username');
253 const redirect = ${JSON.stringify(redirect || "/")};
254 if (!btn) return;
255 function b64uToBuf(s) {
256 s = s.replace(/-/g,'+').replace(/_/g,'/');
257 while (s.length % 4) s += '=';
258 const bin = atob(s);
259 const buf = new Uint8Array(bin.length);
260 for (let i=0;i<bin.length;i++) buf[i] = bin.charCodeAt(i);
261 return buf.buffer;
262 }
263 function bufToB64u(buf) {
264 const bytes = new Uint8Array(buf);
265 let bin = '';
266 for (let i=0;i<bytes.length;i++) bin += String.fromCharCode(bytes[i]);
267 return btoa(bin).replace(/\\+/g,'-').replace(/\\//g,'_').replace(/=+$/,'');
268 }
269 btn.addEventListener('click', async function () {
270 if (!window.PublicKeyCredential) {
271 status.textContent = 'Passkeys not supported in this browser.';
272 return;
273 }
274 status.textContent = 'Preparing…';
275 try {
276 const username = (userInput && userInput.value || '').trim();
277 const optsRes = await fetch('/api/passkeys/auth/options', {
278 method: 'POST',
279 headers: { 'content-type': 'application/json' },
280 body: JSON.stringify(username ? { username: username } : {})
281 });
282 if (!optsRes.ok) throw new Error('options failed');
283 const { options, sessionKey } = await optsRes.json();
284 options.challenge = b64uToBuf(options.challenge);
285 if (options.allowCredentials) {
286 options.allowCredentials = options.allowCredentials.map(function (c) {
287 return Object.assign({}, c, { id: b64uToBuf(c.id) });
288 });
289 }
290 status.textContent = 'Touch your authenticator…';
291 const cred = await navigator.credentials.get({ publicKey: options });
292 const resp = {
293 id: cred.id,
294 rawId: bufToB64u(cred.rawId),
295 type: cred.type,
296 response: {
297 clientDataJSON: bufToB64u(cred.response.clientDataJSON),
298 authenticatorData: bufToB64u(cred.response.authenticatorData),
299 signature: bufToB64u(cred.response.signature),
300 userHandle: cred.response.userHandle ? bufToB64u(cred.response.userHandle) : null
301 },
302 clientExtensionResults: cred.getClientExtensionResults ? cred.getClientExtensionResults() : {}
303 };
304 const verifyRes = await fetch('/api/passkeys/auth/verify', {
305 method: 'POST',
306 headers: { 'content-type': 'application/json' },
307 body: JSON.stringify({ sessionKey: sessionKey, response: resp })
308 });
309 if (!verifyRes.ok) {
310 const j = await verifyRes.json().catch(function () { return {}; });
311 throw new Error(j.error || 'verify failed');
312 }
313 status.textContent = 'Signed in. Redirecting…';
314 window.location.href = redirect;
315 } catch (e) {
316 status.textContent = 'Error: ' + (e && e.message ? e.message : e);
317 }
318 });
319 })();
320 `,
321 }}
322 />
06d5ffeClaude323 </div>
324 </Layout>
325 );
326});
327
328auth.post("/login", async (c) => {
329 const body = await c.req.parseBody();
330 const identifier = String(body.username || "").trim();
331 const password = String(body.password || "");
9837657Claude332 const redirect = safeRedirect(c.req.query("redirect") || "/");
06d5ffeClaude333
334 if (!identifier || !password) {
335 return c.redirect("/login?error=All+fields+are+required");
336 }
337
338 // Find user by username or email
339 const isEmail = identifier.includes("@");
340 const [user] = await db
341 .select()
342 .from(users)
343 .where(
344 isEmail
345 ? eq(users.email, identifier)
346 : eq(users.username, identifier)
347 )
348 .limit(1);
349
350 if (!user) {
351 return c.redirect("/login?error=Invalid+credentials");
352 }
353
354 const valid = await verifyPassword(password, user.passwordHash);
355 if (!valid) {
356 return c.redirect("/login?error=Invalid+credentials");
357 }
358
7298a17Claude359 // B4: if the user has TOTP enabled, issue a pending-2fa session and
360 // redirect to the code prompt.
361 const [totp] = await db
362 .select({ enabledAt: userTotp.enabledAt })
363 .from(userTotp)
364 .where(eq(userTotp.userId, user.id))
365 .limit(1);
366 const needs2fa = !!(totp && totp.enabledAt);
367
06d5ffeClaude368 const token = generateSessionToken();
369 await db.insert(sessions).values({
370 userId: user.id,
371 token,
372 expiresAt: sessionExpiry(),
7298a17Claude373 requires2fa: needs2fa,
06d5ffeClaude374 });
375
376 setCookie(c, "session", token, sessionCookieOptions());
7298a17Claude377 if (needs2fa) {
378 return c.redirect(
379 `/login/2fa?redirect=${encodeURIComponent(redirect)}`
380 );
381 }
06d5ffeClaude382 return c.redirect(redirect);
383});
384
7298a17Claude385// --- 2FA verify (B4) ---
386auth.get("/login/2fa", async (c) => {
387 const token = getCookie(c, "session");
388 if (!token) return c.redirect("/login");
389 const error = c.req.query("error");
9837657Claude390 const redirect = safeRedirect(c.req.query("redirect") || "/");
7298a17Claude391 return c.html(
392 <Layout title="Two-factor authentication">
393 <div class="auth-container">
394 <h2>Enter your code</h2>
395 <p
396 class="auth-switch"
397 style="margin-bottom: 16px; margin-top: 0"
398 >
399 Open your authenticator app and enter the 6-digit code. Lost your
400 device? Paste a recovery code instead.
401 </p>
402 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
403 <form
9837657Claude404 method="post"
7298a17Claude405 action={`/login/2fa?redirect=${encodeURIComponent(redirect)}`}
406 >
407 <div class="form-group">
408 <label for="code">Code</label>
409 <input
410 type="text"
411 id="code"
412 name="code"
413 required
414 autocomplete="one-time-code"
415 inputmode="numeric"
416 maxLength={24}
417 placeholder="123456 or xxxx-xxxx-xxxx"
418 />
419 </div>
420 <button type="submit" class="btn btn-primary">
421 Verify
422 </button>
423 </form>
424 <p class="auth-switch">
425 <a href="/logout">Cancel</a>
426 </p>
427 </div>
428 </Layout>
429 );
430});
431
432auth.post("/login/2fa", async (c) => {
433 const token = getCookie(c, "session");
434 if (!token) return c.redirect("/login");
435 const body = await c.req.parseBody();
436 const code = String(body.code || "").trim();
9837657Claude437 const redirect = safeRedirect(c.req.query("redirect") || "/");
7298a17Claude438
439 if (!code) {
440 return c.redirect(
441 `/login/2fa?error=Code+is+required&redirect=${encodeURIComponent(redirect)}`
442 );
443 }
444
445 try {
446 const [session] = await db
447 .select()
448 .from(sessions)
449 .where(eq(sessions.token, token))
450 .limit(1);
451 if (
452 !session ||
453 new Date(session.expiresAt) < new Date() ||
454 !session.requires2fa
455 ) {
456 return c.redirect("/login");
457 }
458
459 const [totp] = await db
460 .select()
461 .from(userTotp)
462 .where(eq(userTotp.userId, session.userId))
463 .limit(1);
464 if (!totp || !totp.enabledAt) {
465 // User doesn't have 2FA actually enabled — clear the flag and let
466 // them in. This can only happen if 2FA was disabled in another
467 // session between password check and code prompt.
468 await db
469 .update(sessions)
470 .set({ requires2fa: false })
471 .where(eq(sessions.token, token));
472 return c.redirect(redirect);
473 }
474
475 // Try TOTP code first.
476 const isSix = /^\d{6}$/.test(code);
477 let ok = false;
478 if (isSix) {
479 ok = await verifyTotpCode(totp.secret, code);
480 }
481 // Fall through to recovery code.
482 if (!ok) {
483 const hash = await hashRecoveryCode(code);
484 const [rec] = await db
485 .select()
486 .from(userRecoveryCodes)
487 .where(
488 and(
489 eq(userRecoveryCodes.userId, session.userId),
490 eq(userRecoveryCodes.codeHash, hash),
491 isNull(userRecoveryCodes.usedAt)
492 )
493 )
494 .limit(1);
495 if (rec) {
496 await db
497 .update(userRecoveryCodes)
498 .set({ usedAt: new Date() })
499 .where(eq(userRecoveryCodes.id, rec.id));
500 ok = true;
501 }
502 }
503
504 if (!ok) {
505 return c.redirect(
506 `/login/2fa?error=Invalid+code&redirect=${encodeURIComponent(redirect)}`
507 );
508 }
509
510 await db
511 .update(sessions)
512 .set({ requires2fa: false })
513 .where(eq(sessions.token, token));
514 await db
515 .update(userTotp)
516 .set({ lastUsedAt: new Date() })
517 .where(eq(userTotp.userId, session.userId));
518
519 return c.redirect(redirect);
520 } catch (err) {
521 console.error("[auth] 2fa verify:", err);
522 return c.redirect(
523 `/login/2fa?error=Service+unavailable&redirect=${encodeURIComponent(redirect)}`
524 );
525 }
526});
527
06d5ffeClaude528auth.get("/logout", async (c) => {
529 deleteCookie(c, "session", { path: "/" });
530 return c.redirect("/");
531});
532
533// --- API ---
534
535auth.post("/api/auth/register", async (c) => {
536 const body = await c.req.json<{
537 username: string;
538 email: string;
539 password: string;
540 }>();
541
542 if (!body.username || !body.email || !body.password) {
543 return c.json({ error: "username, email, and password are required" }, 400);
544 }
545
546 if (!/^[a-zA-Z0-9_-]+$/.test(body.username)) {
547 return c.json({ error: "Invalid username" }, 400);
548 }
549
550 if (body.password.length < 8) {
551 return c.json({ error: "Password must be at least 8 characters" }, 400);
552 }
553
554 const [existing] = await db
555 .select()
556 .from(users)
557 .where(eq(users.username, body.username))
558 .limit(1);
559 if (existing) {
560 return c.json({ error: "Username already taken" }, 409);
561 }
562
563 const passwordHash = await hashPassword(body.password);
564 const [user] = await db
565 .insert(users)
566 .values({
567 username: body.username,
568 email: body.email,
569 passwordHash,
570 })
571 .returning();
572
573 const token = generateSessionToken();
574 await db.insert(sessions).values({
575 userId: user.id,
576 token,
577 expiresAt: sessionExpiry(),
578 });
579
580 return c.json(
581 {
582 user: { id: user.id, username: user.username, email: user.email },
583 token,
584 },
585 201
586 );
587});
588
589auth.post("/api/auth/login", async (c) => {
590 const body = await c.req.json<{ username: string; password: string }>();
591
592 if (!body.username || !body.password) {
593 return c.json({ error: "username and password are required" }, 400);
594 }
595
596 const isEmail = body.username.includes("@");
597 const [user] = await db
598 .select()
599 .from(users)
600 .where(
601 isEmail
602 ? eq(users.email, body.username)
603 : eq(users.username, body.username)
604 )
605 .limit(1);
606
607 if (!user) return c.json({ error: "Invalid credentials" }, 401);
608
609 const valid = await verifyPassword(body.password, user.passwordHash);
610 if (!valid) return c.json({ error: "Invalid credentials" }, 401);
611
612 const token = generateSessionToken();
613 await db.insert(sessions).values({
614 userId: user.id,
615 token,
616 expiresAt: sessionExpiry(),
617 });
618
619 return c.json({
620 user: { id: user.id, username: user.username, email: user.email },
621 token,
622 });
623});
624
625export default auth;