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