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