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.tsxBlame666 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";
46d6165Claude24import { getSsoConfig, getGithubOauthConfig } from "../lib/sso";
06d5ffeClaude25import { Layout } from "../views/layout";
bb0f894Claude26import {
27 Form,
28 FormGroup,
29 Input,
30 Button,
09be7bfClaude31 LinkButton,
bb0f894Claude32 Alert,
33 Text,
34} from "../views/ui";
36cc17aClaude35import { softAuth } from "../middleware/auth";
06d5ffeClaude36import type { AuthEnv } from "../middleware/auth";
37
38const auth = new Hono<AuthEnv>();
39
40// --- Web UI ---
41
36cc17aClaude42auth.get("/register", softAuth, (c) => {
43 // If the user is already signed in, drop them on their dashboard rather
44 // than rendering the logged-out sign-up shell over an authed session.
45 const existing = c.get("user");
46 if (existing) return c.redirect("/dashboard");
06d5ffeClaude47 const error = c.req.query("error");
134750bClaude48 const csrf = c.get("csrfToken") as string | undefined;
06d5ffeClaude49 return c.html(
36cc17aClaude50 <Layout title="Register" user={null}>
06d5ffeClaude51 <div class="auth-container">
52 <h2>Create account</h2>
53 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
134750bClaude54 <Form method="post" action="/register" csrfToken={csrf}>
0316dbbClaude55 <FormGroup label="Username" htmlFor="username">
56 <Input
57 id="username"
06d5ffeClaude58 type="text"
59 name="username"
60 required
61 pattern="^[a-zA-Z0-9_-]+$"
62 minLength={2}
63 maxLength={39}
64 placeholder="your-username"
65 autocomplete="username"
66 />
bb0f894Claude67 </FormGroup>
68 <FormGroup label="Email" htmlFor="email">
69 <Input
06d5ffeClaude70 type="email"
71 name="email"
72 required
73 placeholder="you@example.com"
74 autocomplete="email"
2c3ba6ecopilot-swe-agent[bot]75 aria-label="Email"
06d5ffeClaude76 />
bb0f894Claude77 </FormGroup>
78 <FormGroup label="Password" htmlFor="password">
79 <Input
06d5ffeClaude80 type="password"
81 name="password"
82 required
83 minLength={8}
84 placeholder="Min 8 characters"
85 autocomplete="new-password"
2c3ba6ecopilot-swe-agent[bot]86 aria-label="Password"
06d5ffeClaude87 />
bb0f894Claude88 </FormGroup>
89 <Button type="submit" variant="primary">
06d5ffeClaude90 Create account
bb0f894Claude91 </Button>
92 </Form>
06d5ffeClaude93 <p class="auth-switch">
bb0f894Claude94 <Text>Already have an account? <a href="/login">Sign in</a></Text>
06d5ffeClaude95 </p>
96 </div>
97 </Layout>
98 );
99});
100
101auth.post("/register", async (c) => {
102 const body = await c.req.parseBody();
103 const username = String(body.username || "").trim();
104 const email = String(body.email || "").trim();
105 const password = String(body.password || "");
106
107 if (!username || !email || !password) {
108 return c.redirect("/register?error=All+fields+are+required");
109 }
110
111 if (!/^[a-zA-Z0-9_-]+$/.test(username)) {
112 return c.redirect(
113 "/register?error=Username+may+only+contain+letters%2C+numbers%2C+hyphens+and+underscores"
114 );
115 }
116
117 if (password.length < 8) {
118 return c.redirect("/register?error=Password+must+be+at+least+8+characters");
119 }
120
121 // Check existing
122 const [existingUser] = await db
123 .select()
124 .from(users)
125 .where(eq(users.username, username))
126 .limit(1);
127 if (existingUser) {
128 return c.redirect("/register?error=Username+already+taken");
129 }
130
7437605Claude131 // B2: usernames share the URL namespace with org slugs; refuse collisions.
132 const [existingOrg] = await db
133 .select({ id: organizations.id })
134 .from(organizations)
135 .where(eq(organizations.slug, username.toLowerCase()))
136 .limit(1);
137 if (existingOrg) {
138 return c.redirect("/register?error=Username+already+taken");
139 }
140
06d5ffeClaude141 const [existingEmail] = await db
142 .select()
143 .from(users)
144 .where(eq(users.email, email))
145 .limit(1);
146 if (existingEmail) {
147 return c.redirect("/register?error=Email+already+registered");
148 }
149
150 const passwordHash = await hashPassword(password);
151
a4f3e24Claude152 // First user ever registered becomes admin automatically
153 const [userCount] = await db
154 .select({ count: sql`count(*)::int` })
155 .from(users);
156 const isFirstUser = (userCount?.count as number) === 0;
157
06d5ffeClaude158 const [user] = await db
159 .insert(users)
a4f3e24Claude160 .values({ username, email, passwordHash, isAdmin: isFirstUser })
06d5ffeClaude161 .returning();
162
2d985e5Claude163 // If username matches SITE_ADMIN_USERNAME env, grant site admin instantly
164 // so the operator doesn't have to wait for the next boot's bootstrap pass.
165 await import("../lib/admin-bootstrap").then((m) =>
166 m.ensureEnvAdminOnRegister({ userId: user.id, username })
167 ).catch(() => {});
168
06d5ffeClaude169 // Create session
170 const token = generateSessionToken();
171 await db.insert(sessions).values({
172 userId: user.id,
173 token,
174 expiresAt: sessionExpiry(),
175 });
176
177 setCookie(c, "session", token, sessionCookieOptions());
178
179 const redirect = c.req.query("redirect") || "/";
180 return c.redirect(redirect);
181});
182
36cc17aClaude183auth.get("/login", softAuth, async (c) => {
184 // Already-authed users hitting the sign-in page get bounced to their
185 // dashboard (or the `redirect=` target if one was supplied).
186 const existing = c.get("user");
06d5ffeClaude187 const error = c.req.query("error");
188 const redirect = c.req.query("redirect") || "";
36cc17aClaude189 if (existing) return c.redirect(redirect || "/dashboard");
edf7c36Claude190 const ssoCfg = await getSsoConfig();
191 const ssoEnabled =
192 !!ssoCfg?.enabled &&
193 !!ssoCfg.authorizationEndpoint &&
194 !!ssoCfg.tokenEndpoint &&
195 !!ssoCfg.userinfoEndpoint &&
196 !!ssoCfg.clientId &&
197 !!ssoCfg.clientSecret;
fc0ca99Claude198 const ssoLabel =
199 ssoCfg?.providerName || inferSsoProviderName(ssoCfg) || "SSO";
46d6165Claude200 // Block L6 — "Sign in with GitHub" (separate row keyed id='github').
201 const githubCfg = await getGithubOauthConfig();
202 const githubEnabled =
203 !!githubCfg?.enabled && !!githubCfg.clientId && !!githubCfg.clientSecret;
134750bClaude204 const csrf = c.get("csrfToken") as string | undefined;
06d5ffeClaude205 return c.html(
36cc17aClaude206 <Layout title="Sign in" user={null}>
06d5ffeClaude207 <div class="auth-container">
208 <h2>Sign in</h2>
209 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
0316dbbClaude210 <Form
b9968e3Claude211 method="post"
06d5ffeClaude212 action={`/login${redirect ? `?redirect=${encodeURIComponent(redirect)}` : ""}`}
134750bClaude213 csrfToken={csrf}
06d5ffeClaude214 >
bb0f894Claude215 <FormGroup label="Username or email" htmlFor="username">
216 <Input
06d5ffeClaude217 type="text"
218 name="username"
219 required
220 placeholder="username or email"
221 autocomplete="username"
2c3ba6ecopilot-swe-agent[bot]222 aria-label="Username or email"
06d5ffeClaude223 />
bb0f894Claude224 </FormGroup>
225 <FormGroup label="Password" htmlFor="password">
226 <Input
06d5ffeClaude227 type="password"
228 name="password"
229 required
230 placeholder="Password"
231 autocomplete="current-password"
2c3ba6ecopilot-swe-agent[bot]232 aria-label="Password"
06d5ffeClaude233 />
bb0f894Claude234 </FormGroup>
235 <Button type="submit" variant="primary">
06d5ffeClaude236 Sign in
bb0f894Claude237 </Button>
238 </Form>
46d6165Claude239 {githubEnabled && (
240 <div class="auth-sso">
241 <div class="auth-divider">or</div>
242 <LinkButton href="/login/github">Sign in with GitHub</LinkButton>
243 </div>
244 )}
09be7bfClaude245 {ssoEnabled && (
246 <div class="auth-sso">
247 <div class="auth-divider">or</div>
248 <LinkButton href="/login/sso">Sign in with {ssoLabel}</LinkButton>
249 </div>
250 )}
fa06ad2Claude251 <div class="auth-passkey">
252 <div class="auth-divider">or</div>
253 <button type="button" id="pk-signin-btn" class="btn">
254 Sign in with passkey
255 </button>
256 <div
257 id="pk-signin-status"
258 class="auth-status"
259 aria-live="polite"
260 ></div>
261 </div>
06d5ffeClaude262 <p class="auth-switch">
bb0f894Claude263 <Text>New to gluecron? <a href="/register">Create an account</a></Text>
06d5ffeClaude264 </p>
2df1f8cClaude265 <script
266 dangerouslySetInnerHTML={{
267 __html: /* js */ `
268 (function () {
269 const btn = document.getElementById('pk-signin-btn');
270 const status = document.getElementById('pk-signin-status');
271 const userInput = document.getElementById('username');
272 const redirect = ${JSON.stringify(redirect || "/")};
273 if (!btn) return;
274 function b64uToBuf(s) {
275 s = s.replace(/-/g,'+').replace(/_/g,'/');
276 while (s.length % 4) s += '=';
277 const bin = atob(s);
278 const buf = new Uint8Array(bin.length);
279 for (let i=0;i<bin.length;i++) buf[i] = bin.charCodeAt(i);
280 return buf.buffer;
281 }
282 function bufToB64u(buf) {
283 const bytes = new Uint8Array(buf);
284 let bin = '';
285 for (let i=0;i<bytes.length;i++) bin += String.fromCharCode(bytes[i]);
286 return btoa(bin).replace(/\\+/g,'-').replace(/\\//g,'_').replace(/=+$/,'');
287 }
288 btn.addEventListener('click', async function () {
289 if (!window.PublicKeyCredential) {
290 status.textContent = 'Passkeys not supported in this browser.';
291 return;
292 }
293 status.textContent = 'Preparing…';
294 try {
295 const username = (userInput && userInput.value || '').trim();
296 const optsRes = await fetch('/api/passkeys/auth/options', {
297 method: 'POST',
298 headers: { 'content-type': 'application/json' },
299 body: JSON.stringify(username ? { username: username } : {})
300 });
301 if (!optsRes.ok) throw new Error('options failed');
302 const { options, sessionKey } = await optsRes.json();
303 options.challenge = b64uToBuf(options.challenge);
304 if (options.allowCredentials) {
305 options.allowCredentials = options.allowCredentials.map(function (c) {
306 return Object.assign({}, c, { id: b64uToBuf(c.id) });
307 });
308 }
309 status.textContent = 'Touch your authenticator…';
310 const cred = await navigator.credentials.get({ publicKey: options });
311 const resp = {
312 id: cred.id,
313 rawId: bufToB64u(cred.rawId),
314 type: cred.type,
315 response: {
316 clientDataJSON: bufToB64u(cred.response.clientDataJSON),
317 authenticatorData: bufToB64u(cred.response.authenticatorData),
318 signature: bufToB64u(cred.response.signature),
319 userHandle: cred.response.userHandle ? bufToB64u(cred.response.userHandle) : null
320 },
321 clientExtensionResults: cred.getClientExtensionResults ? cred.getClientExtensionResults() : {}
322 };
323 const verifyRes = await fetch('/api/passkeys/auth/verify', {
324 method: 'POST',
325 headers: { 'content-type': 'application/json' },
326 body: JSON.stringify({ sessionKey: sessionKey, response: resp })
327 });
328 if (!verifyRes.ok) {
329 const j = await verifyRes.json().catch(function () { return {}; });
330 throw new Error(j.error || 'verify failed');
331 }
332 status.textContent = 'Signed in. Redirecting…';
333 window.location.href = redirect;
334 } catch (e) {
335 status.textContent = 'Error: ' + (e && e.message ? e.message : e);
336 }
337 });
338 })();
339 `,
340 }}
341 />
06d5ffeClaude342 </div>
343 </Layout>
344 );
345});
346
347auth.post("/login", async (c) => {
348 const body = await c.req.parseBody();
349 const identifier = String(body.username || "").trim();
350 const password = String(body.password || "");
351 const redirect = c.req.query("redirect") || "/";
352
353 if (!identifier || !password) {
354 return c.redirect("/login?error=All+fields+are+required");
355 }
356
357 // Find user by username or email
358 const isEmail = identifier.includes("@");
359 const [user] = await db
360 .select()
361 .from(users)
362 .where(
363 isEmail
364 ? eq(users.email, identifier)
365 : eq(users.username, identifier)
366 )
367 .limit(1);
368
369 if (!user) {
370 return c.redirect("/login?error=Invalid+credentials");
371 }
372
373 const valid = await verifyPassword(password, user.passwordHash);
374 if (!valid) {
375 return c.redirect("/login?error=Invalid+credentials");
376 }
377
7298a17Claude378 // B4: if the user has TOTP enabled, issue a pending-2fa session and
379 // redirect to the code prompt.
380 const [totp] = await db
381 .select({ enabledAt: userTotp.enabledAt })
382 .from(userTotp)
383 .where(eq(userTotp.userId, user.id))
384 .limit(1);
385 const needs2fa = !!(totp && totp.enabledAt);
386
06d5ffeClaude387 const token = generateSessionToken();
388 await db.insert(sessions).values({
389 userId: user.id,
390 token,
391 expiresAt: sessionExpiry(),
7298a17Claude392 requires2fa: needs2fa,
06d5ffeClaude393 });
394
395 setCookie(c, "session", token, sessionCookieOptions());
7298a17Claude396 if (needs2fa) {
397 return c.redirect(
398 `/login/2fa?redirect=${encodeURIComponent(redirect)}`
399 );
400 }
06d5ffeClaude401 return c.redirect(redirect);
402});
403
7298a17Claude404// --- 2FA verify (B4) ---
405auth.get("/login/2fa", async (c) => {
406 const token = getCookie(c, "session");
407 if (!token) return c.redirect("/login");
408 const error = c.req.query("error");
409 const redirect = c.req.query("redirect") || "/";
410 return c.html(
36cc17aClaude411 <Layout title="Two-factor authentication" user={null}>
7298a17Claude412 <div class="auth-container">
413 <h2>Enter your code</h2>
414 <p
415 class="auth-switch"
416 style="margin-bottom: 16px; margin-top: 0"
417 >
418 Open your authenticator app and enter the 6-digit code. Lost your
419 device? Paste a recovery code instead.
420 </p>
421 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
422 <form
b9968e3Claude423 method="post"
7298a17Claude424 action={`/login/2fa?redirect=${encodeURIComponent(redirect)}`}
425 >
134750bClaude426 <input type="hidden" name="_csrf" value={(c.get("csrfToken") as string | undefined) || ""} />
7298a17Claude427 <div class="form-group">
428 <label for="code">Code</label>
429 <input
430 type="text"
431 id="code"
432 name="code"
433 required
434 autocomplete="one-time-code"
435 inputmode="numeric"
436 maxLength={24}
437 placeholder="123456 or xxxx-xxxx-xxxx"
438 />
439 </div>
440 <button type="submit" class="btn btn-primary">
441 Verify
442 </button>
443 </form>
444 <p class="auth-switch">
445 <a href="/logout">Cancel</a>
446 </p>
447 </div>
448 </Layout>
449 );
450});
451
452auth.post("/login/2fa", async (c) => {
453 const token = getCookie(c, "session");
454 if (!token) return c.redirect("/login");
455 const body = await c.req.parseBody();
456 const code = String(body.code || "").trim();
457 const redirect = c.req.query("redirect") || "/";
458
459 if (!code) {
460 return c.redirect(
461 `/login/2fa?error=Code+is+required&redirect=${encodeURIComponent(redirect)}`
462 );
463 }
464
465 try {
466 const [session] = await db
467 .select()
468 .from(sessions)
469 .where(eq(sessions.token, token))
470 .limit(1);
471 if (
472 !session ||
473 new Date(session.expiresAt) < new Date() ||
474 !session.requires2fa
475 ) {
476 return c.redirect("/login");
477 }
478
479 const [totp] = await db
480 .select()
481 .from(userTotp)
482 .where(eq(userTotp.userId, session.userId))
483 .limit(1);
484 if (!totp || !totp.enabledAt) {
485 // User doesn't have 2FA actually enabled — clear the flag and let
486 // them in. This can only happen if 2FA was disabled in another
487 // session between password check and code prompt.
488 await db
489 .update(sessions)
490 .set({ requires2fa: false })
491 .where(eq(sessions.token, token));
492 return c.redirect(redirect);
493 }
494
495 // Try TOTP code first.
496 const isSix = /^\d{6}$/.test(code);
497 let ok = false;
498 if (isSix) {
499 ok = await verifyTotpCode(totp.secret, code);
500 }
501 // Fall through to recovery code.
502 if (!ok) {
503 const hash = await hashRecoveryCode(code);
504 const [rec] = await db
505 .select()
506 .from(userRecoveryCodes)
507 .where(
508 and(
509 eq(userRecoveryCodes.userId, session.userId),
510 eq(userRecoveryCodes.codeHash, hash),
511 isNull(userRecoveryCodes.usedAt)
512 )
513 )
514 .limit(1);
515 if (rec) {
516 await db
517 .update(userRecoveryCodes)
518 .set({ usedAt: new Date() })
519 .where(eq(userRecoveryCodes.id, rec.id));
520 ok = true;
521 }
522 }
523
524 if (!ok) {
525 return c.redirect(
526 `/login/2fa?error=Invalid+code&redirect=${encodeURIComponent(redirect)}`
527 );
528 }
529
530 await db
531 .update(sessions)
532 .set({ requires2fa: false })
533 .where(eq(sessions.token, token));
534 await db
535 .update(userTotp)
536 .set({ lastUsedAt: new Date() })
537 .where(eq(userTotp.userId, session.userId));
538
539 return c.redirect(redirect);
540 } catch (err) {
541 console.error("[auth] 2fa verify:", err);
542 return c.redirect(
543 `/login/2fa?error=Service+unavailable&redirect=${encodeURIComponent(redirect)}`
544 );
545 }
546});
547
06d5ffeClaude548auth.get("/logout", async (c) => {
549 deleteCookie(c, "session", { path: "/" });
550 return c.redirect("/");
551});
552
553// --- API ---
554
555auth.post("/api/auth/register", async (c) => {
556 const body = await c.req.json<{
557 username: string;
558 email: string;
559 password: string;
560 }>();
561
562 if (!body.username || !body.email || !body.password) {
563 return c.json({ error: "username, email, and password are required" }, 400);
564 }
565
566 if (!/^[a-zA-Z0-9_-]+$/.test(body.username)) {
567 return c.json({ error: "Invalid username" }, 400);
568 }
569
570 if (body.password.length < 8) {
571 return c.json({ error: "Password must be at least 8 characters" }, 400);
572 }
573
574 const [existing] = await db
575 .select()
576 .from(users)
577 .where(eq(users.username, body.username))
578 .limit(1);
579 if (existing) {
580 return c.json({ error: "Username already taken" }, 409);
581 }
582
583 const passwordHash = await hashPassword(body.password);
584 const [user] = await db
585 .insert(users)
586 .values({
587 username: body.username,
588 email: body.email,
589 passwordHash,
590 })
591 .returning();
592
593 const token = generateSessionToken();
594 await db.insert(sessions).values({
595 userId: user.id,
596 token,
597 expiresAt: sessionExpiry(),
598 });
599
600 return c.json(
601 {
602 user: { id: user.id, username: user.username, email: user.email },
603 token,
604 },
605 201
606 );
607});
608
609auth.post("/api/auth/login", async (c) => {
610 const body = await c.req.json<{ username: string; password: string }>();
611
612 if (!body.username || !body.password) {
613 return c.json({ error: "username and password are required" }, 400);
614 }
615
616 const isEmail = body.username.includes("@");
617 const [user] = await db
618 .select()
619 .from(users)
620 .where(
621 isEmail
622 ? eq(users.email, body.username)
623 : eq(users.username, body.username)
624 )
625 .limit(1);
626
627 if (!user) return c.json({ error: "Invalid credentials" }, 401);
628
629 const valid = await verifyPassword(body.password, user.passwordHash);
630 if (!valid) return c.json({ error: "Invalid credentials" }, 401);
631
632 const token = generateSessionToken();
633 await db.insert(sessions).values({
634 userId: user.id,
635 token,
636 expiresAt: sessionExpiry(),
637 });
638
639 return c.json({
640 user: { id: user.id, username: user.username, email: user.email },
641 token,
642 });
643});
644
fc0ca99Claude645/**
646 * Pick a friendly provider name for the "Sign in with X" button when the
647 * admin hasn't set one explicitly. Looks at the configured IdP URLs.
648 * Falls back to undefined so the caller can default to a literal "SSO".
649 */
650function inferSsoProviderName(
651 cfg: { issuer?: string | null; authorizationEndpoint?: string | null } | null | undefined
652): string | undefined {
653 const urls = [cfg?.issuer, cfg?.authorizationEndpoint]
654 .filter((s): s is string => !!s)
655 .join(" ")
656 .toLowerCase();
657 if (!urls) return undefined;
658 if (urls.includes("google")) return "Google";
659 if (urls.includes("okta")) return "Okta";
660 if (urls.includes("microsoftonline") || urls.includes("azure")) return "Microsoft";
661 if (urls.includes("auth0.com")) return "Auth0";
662 if (urls.includes("authentik")) return "Authentik";
663 return undefined;
664}
665
06d5ffeClaude666export default auth;