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