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.tsxBlame587 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";
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>}
b9968e3Claude47 <form method="post" action="/register">
06d5ffeClaude48 <div class="form-group">
49 <label for="username">Username</label>
50 <input
51 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
143 const [user] = await db
144 .insert(users)
145 .values({ username, email, passwordHash })
146 .returning();
147
148 // Create session
149 const token = generateSessionToken();
150 await db.insert(sessions).values({
151 userId: user.id,
152 token,
153 expiresAt: sessionExpiry(),
154 });
155
156 setCookie(c, "session", token, sessionCookieOptions());
157
158 const redirect = c.req.query("redirect") || "/";
159 return c.redirect(redirect);
160});
161
edf7c36Claude162auth.get("/login", async (c) => {
06d5ffeClaude163 const error = c.req.query("error");
164 const redirect = c.req.query("redirect") || "";
edf7c36Claude165 const ssoCfg = await getSsoConfig();
166 const ssoEnabled =
167 !!ssoCfg?.enabled &&
168 !!ssoCfg.authorizationEndpoint &&
169 !!ssoCfg.tokenEndpoint &&
170 !!ssoCfg.userinfoEndpoint &&
171 !!ssoCfg.clientId &&
172 !!ssoCfg.clientSecret;
173 const ssoLabel = ssoCfg?.providerName || "SSO";
06d5ffeClaude174 return c.html(
175 <Layout title="Sign in">
176 <div class="auth-container">
177 <h2>Sign in</h2>
178 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
179 <form
b9968e3Claude180 method="post"
06d5ffeClaude181 action={`/login${redirect ? `?redirect=${encodeURIComponent(redirect)}` : ""}`}
182 >
bb0f894Claude183 <FormGroup label="Username or email" htmlFor="username">
184 <Input
06d5ffeClaude185 type="text"
186 name="username"
187 required
188 placeholder="username or email"
189 autocomplete="username"
190 />
bb0f894Claude191 </FormGroup>
192 <FormGroup label="Password" htmlFor="password">
193 <Input
06d5ffeClaude194 type="password"
195 name="password"
196 required
197 placeholder="Password"
198 autocomplete="current-password"
199 />
bb0f894Claude200 </FormGroup>
201 <Button type="submit" variant="primary">
06d5ffeClaude202 Sign in
bb0f894Claude203 </Button>
204 </Form>
06d5ffeClaude205 <p class="auth-switch">
bb0f894Claude206 <Text>New to gluecron? <a href="/register">Create an account</a></Text>
06d5ffeClaude207 </p>
2df1f8cClaude208 <script
209 dangerouslySetInnerHTML={{
210 __html: /* js */ `
211 (function () {
212 const btn = document.getElementById('pk-signin-btn');
213 const status = document.getElementById('pk-signin-status');
214 const userInput = document.getElementById('username');
215 const redirect = ${JSON.stringify(redirect || "/")};
216 if (!btn) return;
217 function b64uToBuf(s) {
218 s = s.replace(/-/g,'+').replace(/_/g,'/');
219 while (s.length % 4) s += '=';
220 const bin = atob(s);
221 const buf = new Uint8Array(bin.length);
222 for (let i=0;i<bin.length;i++) buf[i] = bin.charCodeAt(i);
223 return buf.buffer;
224 }
225 function bufToB64u(buf) {
226 const bytes = new Uint8Array(buf);
227 let bin = '';
228 for (let i=0;i<bytes.length;i++) bin += String.fromCharCode(bytes[i]);
229 return btoa(bin).replace(/\\+/g,'-').replace(/\\//g,'_').replace(/=+$/,'');
230 }
231 btn.addEventListener('click', async function () {
232 if (!window.PublicKeyCredential) {
233 status.textContent = 'Passkeys not supported in this browser.';
234 return;
235 }
236 status.textContent = 'Preparing…';
237 try {
238 const username = (userInput && userInput.value || '').trim();
239 const optsRes = await fetch('/api/passkeys/auth/options', {
240 method: 'POST',
241 headers: { 'content-type': 'application/json' },
242 body: JSON.stringify(username ? { username: username } : {})
243 });
244 if (!optsRes.ok) throw new Error('options failed');
245 const { options, sessionKey } = await optsRes.json();
246 options.challenge = b64uToBuf(options.challenge);
247 if (options.allowCredentials) {
248 options.allowCredentials = options.allowCredentials.map(function (c) {
249 return Object.assign({}, c, { id: b64uToBuf(c.id) });
250 });
251 }
252 status.textContent = 'Touch your authenticator…';
253 const cred = await navigator.credentials.get({ publicKey: options });
254 const resp = {
255 id: cred.id,
256 rawId: bufToB64u(cred.rawId),
257 type: cred.type,
258 response: {
259 clientDataJSON: bufToB64u(cred.response.clientDataJSON),
260 authenticatorData: bufToB64u(cred.response.authenticatorData),
261 signature: bufToB64u(cred.response.signature),
262 userHandle: cred.response.userHandle ? bufToB64u(cred.response.userHandle) : null
263 },
264 clientExtensionResults: cred.getClientExtensionResults ? cred.getClientExtensionResults() : {}
265 };
266 const verifyRes = await fetch('/api/passkeys/auth/verify', {
267 method: 'POST',
268 headers: { 'content-type': 'application/json' },
269 body: JSON.stringify({ sessionKey: sessionKey, response: resp })
270 });
271 if (!verifyRes.ok) {
272 const j = await verifyRes.json().catch(function () { return {}; });
273 throw new Error(j.error || 'verify failed');
274 }
275 status.textContent = 'Signed in. Redirecting…';
276 window.location.href = redirect;
277 } catch (e) {
278 status.textContent = 'Error: ' + (e && e.message ? e.message : e);
279 }
280 });
281 })();
282 `,
283 }}
284 />
06d5ffeClaude285 </div>
286 </Layout>
287 );
288});
289
290auth.post("/login", async (c) => {
291 const body = await c.req.parseBody();
292 const identifier = String(body.username || "").trim();
293 const password = String(body.password || "");
294 const redirect = c.req.query("redirect") || "/";
295
296 if (!identifier || !password) {
297 return c.redirect("/login?error=All+fields+are+required");
298 }
299
300 // Find user by username or email
301 const isEmail = identifier.includes("@");
302 const [user] = await db
303 .select()
304 .from(users)
305 .where(
306 isEmail
307 ? eq(users.email, identifier)
308 : eq(users.username, identifier)
309 )
310 .limit(1);
311
312 if (!user) {
313 return c.redirect("/login?error=Invalid+credentials");
314 }
315
316 const valid = await verifyPassword(password, user.passwordHash);
317 if (!valid) {
318 return c.redirect("/login?error=Invalid+credentials");
319 }
320
7298a17Claude321 // B4: if the user has TOTP enabled, issue a pending-2fa session and
322 // redirect to the code prompt.
323 const [totp] = await db
324 .select({ enabledAt: userTotp.enabledAt })
325 .from(userTotp)
326 .where(eq(userTotp.userId, user.id))
327 .limit(1);
328 const needs2fa = !!(totp && totp.enabledAt);
329
06d5ffeClaude330 const token = generateSessionToken();
331 await db.insert(sessions).values({
332 userId: user.id,
333 token,
334 expiresAt: sessionExpiry(),
7298a17Claude335 requires2fa: needs2fa,
06d5ffeClaude336 });
337
338 setCookie(c, "session", token, sessionCookieOptions());
7298a17Claude339 if (needs2fa) {
340 return c.redirect(
341 `/login/2fa?redirect=${encodeURIComponent(redirect)}`
342 );
343 }
06d5ffeClaude344 return c.redirect(redirect);
345});
346
7298a17Claude347// --- 2FA verify (B4) ---
348auth.get("/login/2fa", async (c) => {
349 const token = getCookie(c, "session");
350 if (!token) return c.redirect("/login");
351 const error = c.req.query("error");
352 const redirect = c.req.query("redirect") || "/";
353 return c.html(
354 <Layout title="Two-factor authentication">
355 <div class="auth-container">
356 <h2>Enter your code</h2>
357 <p
358 class="auth-switch"
359 style="margin-bottom: 16px; margin-top: 0"
360 >
361 Open your authenticator app and enter the 6-digit code. Lost your
362 device? Paste a recovery code instead.
363 </p>
364 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
365 <form
b9968e3Claude366 method="post"
7298a17Claude367 action={`/login/2fa?redirect=${encodeURIComponent(redirect)}`}
368 >
369 <div class="form-group">
370 <label for="code">Code</label>
371 <input
372 type="text"
373 id="code"
374 name="code"
375 required
376 autocomplete="one-time-code"
377 inputmode="numeric"
378 maxLength={24}
379 placeholder="123456 or xxxx-xxxx-xxxx"
380 />
381 </div>
382 <button type="submit" class="btn btn-primary">
383 Verify
384 </button>
385 </form>
386 <p class="auth-switch">
387 <a href="/logout">Cancel</a>
388 </p>
389 </div>
390 </Layout>
391 );
392});
393
394auth.post("/login/2fa", async (c) => {
395 const token = getCookie(c, "session");
396 if (!token) return c.redirect("/login");
397 const body = await c.req.parseBody();
398 const code = String(body.code || "").trim();
399 const redirect = c.req.query("redirect") || "/";
400
401 if (!code) {
402 return c.redirect(
403 `/login/2fa?error=Code+is+required&redirect=${encodeURIComponent(redirect)}`
404 );
405 }
406
407 try {
408 const [session] = await db
409 .select()
410 .from(sessions)
411 .where(eq(sessions.token, token))
412 .limit(1);
413 if (
414 !session ||
415 new Date(session.expiresAt) < new Date() ||
416 !session.requires2fa
417 ) {
418 return c.redirect("/login");
419 }
420
421 const [totp] = await db
422 .select()
423 .from(userTotp)
424 .where(eq(userTotp.userId, session.userId))
425 .limit(1);
426 if (!totp || !totp.enabledAt) {
427 // User doesn't have 2FA actually enabled — clear the flag and let
428 // them in. This can only happen if 2FA was disabled in another
429 // session between password check and code prompt.
430 await db
431 .update(sessions)
432 .set({ requires2fa: false })
433 .where(eq(sessions.token, token));
434 return c.redirect(redirect);
435 }
436
437 // Try TOTP code first.
438 const isSix = /^\d{6}$/.test(code);
439 let ok = false;
440 if (isSix) {
441 ok = await verifyTotpCode(totp.secret, code);
442 }
443 // Fall through to recovery code.
444 if (!ok) {
445 const hash = await hashRecoveryCode(code);
446 const [rec] = await db
447 .select()
448 .from(userRecoveryCodes)
449 .where(
450 and(
451 eq(userRecoveryCodes.userId, session.userId),
452 eq(userRecoveryCodes.codeHash, hash),
453 isNull(userRecoveryCodes.usedAt)
454 )
455 )
456 .limit(1);
457 if (rec) {
458 await db
459 .update(userRecoveryCodes)
460 .set({ usedAt: new Date() })
461 .where(eq(userRecoveryCodes.id, rec.id));
462 ok = true;
463 }
464 }
465
466 if (!ok) {
467 return c.redirect(
468 `/login/2fa?error=Invalid+code&redirect=${encodeURIComponent(redirect)}`
469 );
470 }
471
472 await db
473 .update(sessions)
474 .set({ requires2fa: false })
475 .where(eq(sessions.token, token));
476 await db
477 .update(userTotp)
478 .set({ lastUsedAt: new Date() })
479 .where(eq(userTotp.userId, session.userId));
480
481 return c.redirect(redirect);
482 } catch (err) {
483 console.error("[auth] 2fa verify:", err);
484 return c.redirect(
485 `/login/2fa?error=Service+unavailable&redirect=${encodeURIComponent(redirect)}`
486 );
487 }
488});
489
06d5ffeClaude490auth.get("/logout", async (c) => {
491 deleteCookie(c, "session", { path: "/" });
492 return c.redirect("/");
493});
494
495// --- API ---
496
497auth.post("/api/auth/register", async (c) => {
498 const body = await c.req.json<{
499 username: string;
500 email: string;
501 password: string;
502 }>();
503
504 if (!body.username || !body.email || !body.password) {
505 return c.json({ error: "username, email, and password are required" }, 400);
506 }
507
508 if (!/^[a-zA-Z0-9_-]+$/.test(body.username)) {
509 return c.json({ error: "Invalid username" }, 400);
510 }
511
512 if (body.password.length < 8) {
513 return c.json({ error: "Password must be at least 8 characters" }, 400);
514 }
515
516 const [existing] = await db
517 .select()
518 .from(users)
519 .where(eq(users.username, body.username))
520 .limit(1);
521 if (existing) {
522 return c.json({ error: "Username already taken" }, 409);
523 }
524
525 const passwordHash = await hashPassword(body.password);
526 const [user] = await db
527 .insert(users)
528 .values({
529 username: body.username,
530 email: body.email,
531 passwordHash,
532 })
533 .returning();
534
535 const token = generateSessionToken();
536 await db.insert(sessions).values({
537 userId: user.id,
538 token,
539 expiresAt: sessionExpiry(),
540 });
541
542 return c.json(
543 {
544 user: { id: user.id, username: user.username, email: user.email },
545 token,
546 },
547 201
548 );
549});
550
551auth.post("/api/auth/login", async (c) => {
552 const body = await c.req.json<{ username: string; password: string }>();
553
554 if (!body.username || !body.password) {
555 return c.json({ error: "username and password are required" }, 400);
556 }
557
558 const isEmail = body.username.includes("@");
559 const [user] = await db
560 .select()
561 .from(users)
562 .where(
563 isEmail
564 ? eq(users.email, body.username)
565 : eq(users.username, body.username)
566 )
567 .limit(1);
568
569 if (!user) return c.json({ error: "Invalid credentials" }, 401);
570
571 const valid = await verifyPassword(body.password, user.passwordHash);
572 if (!valid) return c.json({ error: "Invalid credentials" }, 401);
573
574 const token = generateSessionToken();
575 await db.insert(sessions).values({
576 userId: user.id,
577 token,
578 expiresAt: sessionExpiry(),
579 });
580
581 return c.json({
582 user: { id: user.id, username: user.username, email: user.email },
583 token,
584 });
585});
586
587export default auth;