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.tsxBlame964 lines · 4 contributors
06d5ffeClaude1/**
2 * Auth routes — register, login, logout (web + API).
3 */
4
5import { Hono } from "hono";
7298a17Claude6import { setCookie, deleteCookie, getCookie } from "hono/cookie";
ba9e143Claude7import { and, eq, gte, isNull, sql } from "drizzle-orm";
06d5ffeClaude8import { db } from "../db";
7298a17Claude9import {
10 users,
11 sessions,
12 organizations,
3a845e4Claude13 orgSsoConfigs,
7298a17Claude14 userTotp,
15 userRecoveryCodes,
ba9e143Claude16 loginAttempts,
7298a17Claude17} from "../db/schema";
06d5ffeClaude18import {
19 hashPassword,
20 verifyPassword,
21 generateSessionToken,
22 sessionCookieOptions,
23 sessionExpiry,
24} from "../lib/auth";
7298a17Claude25import { verifyTotpCode, hashRecoveryCode } from "../lib/totp";
27d5fd3Claude26import {
27 evaluateLockout,
28 retryAfterMinutes,
29 LOGIN_FAIL_WINDOW_MS,
30 LOGIN_FAIL_LIMIT,
31 type LockoutState,
32} from "../lib/login-lockout";
c63b860Claude33import { cancelAccountDeletion } from "../lib/account-deletion";
ba9e143Claude34import { audit } from "../lib/notify";
582cdacClaude35import {
36 getSsoConfig,
37 getGithubOauthConfig,
38 getGoogleOauthConfig,
39} from "../lib/sso";
1d3a220ccantynz-alt40import { sessionCache } from "../lib/cache";
06d5ffeClaude41import { Layout } from "../views/layout";
cf21786ccanty labs42import { SignInV2 } from "../views/signin-v2";
bb0f894Claude43import {
44 Form,
45 FormGroup,
46 Input,
47 Button,
09be7bfClaude48 LinkButton,
bb0f894Claude49 Alert,
50 Text,
51} from "../views/ui";
36cc17aClaude52import { softAuth } from "../middleware/auth";
06d5ffeClaude53import type { AuthEnv } from "../middleware/auth";
54
55const auth = new Hono<AuthEnv>();
56
976d7f7Claude57// One-shot latch — log the auto-verify warning at most once per process,
58// since the misconfiguration is operator-level (env var) and won't change
59// between requests.
60let _autoVerifyWarned = false;
61
ebe6d64Claude62// ───────────────────────────────────────────────────────────────────────
63// Scoped mobile polish — tightens the existing `.auth-container` shell
64// from layout.tsx for ≤720px viewports. Only adds rules; does not
65// redefine the desktop styling. Kept inline so this file remains the
66// single source of truth for the auth surface.
67// ───────────────────────────────────────────────────────────────────────
68const authMobileCss = `
69 @media (max-width: 720px) {
70 .auth-container {
71 margin: 24px 12px;
72 padding: 24px 20px 22px;
73 max-width: 100%;
74 }
75 .auth-container .btn-primary { min-height: 44px; }
76 .auth-container .oauth-btn { min-height: 44px; }
77 .auth-container input[type="text"],
78 .auth-container input[type="email"],
79 .auth-container input[type="password"] { min-height: 44px; }
80 .auth-forgot { text-align: left !important; }
81 }
82`;
83const AuthMobileStyle = () => (
84 <style dangerouslySetInnerHTML={{ __html: authMobileCss }} />
85);
86
06d5ffeClaude87// --- Web UI ---
88
36cc17aClaude89auth.get("/register", softAuth, (c) => {
90 // If the user is already signed in, drop them on their dashboard rather
91 // than rendering the logged-out sign-up shell over an authed session.
92 const existing = c.get("user");
93 if (existing) return c.redirect("/dashboard");
06d5ffeClaude94 const error = c.req.query("error");
134750bClaude95 const csrf = c.get("csrfToken") as string | undefined;
06d5ffeClaude96 return c.html(
36cc17aClaude97 <Layout title="Register" user={null}>
ebe6d64Claude98 <AuthMobileStyle />
06d5ffeClaude99 <div class="auth-container">
a48f839ccantynz-alt100 <h1>Create your account</h1>
98f45b4Claude101 <p class="auth-subtitle">
102 Get the full AI suite — code review, auto-merge, spec-to-PR — on
103 unlimited public repos. No credit card.
104 </p>
06d5ffeClaude105 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
134750bClaude106 <Form method="post" action="/register" csrfToken={csrf}>
0316dbbClaude107 <FormGroup label="Username" htmlFor="username">
108 <Input
109 id="username"
06d5ffeClaude110 type="text"
111 name="username"
112 required
113 pattern="^[a-zA-Z0-9_-]+$"
114 minLength={2}
115 maxLength={39}
116 placeholder="your-username"
117 autocomplete="username"
118 />
bb0f894Claude119 </FormGroup>
120 <FormGroup label="Email" htmlFor="email">
121 <Input
06d5ffeClaude122 type="email"
123 name="email"
124 required
125 placeholder="you@example.com"
126 autocomplete="email"
2c3ba6ecopilot-swe-agent[bot]127 aria-label="Email"
06d5ffeClaude128 />
bb0f894Claude129 </FormGroup>
130 <FormGroup label="Password" htmlFor="password">
131 <Input
06d5ffeClaude132 type="password"
133 name="password"
134 required
135 minLength={8}
136 placeholder="Min 8 characters"
137 autocomplete="new-password"
2c3ba6ecopilot-swe-agent[bot]138 aria-label="Password"
06d5ffeClaude139 />
bb0f894Claude140 </FormGroup>
c63b860Claude141 {/* P3 — Terms / Privacy acceptance. Required client-side via the
142 `required` attribute; server-side re-checked in POST handler. */}
143 <div class="form-group" style="margin: 12px 0">
144 <label style="display: flex; gap: 8px; align-items: flex-start; font-size: 13px; color: var(--text-muted)">
145 <input
146 type="checkbox"
147 name="accept_terms"
148 value="1"
149 required
150 style="margin-top: 3px"
151 aria-label="Accept Terms of Service and Privacy Policy"
152 />
153 <span>
154 I agree to the{" "}
2e8a4d5Claude155 <a href="/terms" target="_blank" rel="noopener">
c63b860Claude156 Terms of Service
157 </a>{" "}
158 and{" "}
2e8a4d5Claude159 <a href="/privacy" target="_blank" rel="noopener">
c63b860Claude160 Privacy Policy
161 </a>
162 .
163 </span>
164 </label>
165 </div>
bb0f894Claude166 <Button type="submit" variant="primary">
06d5ffeClaude167 Create account
bb0f894Claude168 </Button>
169 </Form>
06d5ffeClaude170 <p class="auth-switch">
bb0f894Claude171 <Text>Already have an account? <a href="/login">Sign in</a></Text>
06d5ffeClaude172 </p>
173 </div>
174 </Layout>
175 );
176});
177
178auth.post("/register", async (c) => {
179 const body = await c.req.parseBody();
180 const username = String(body.username || "").trim();
181 const email = String(body.email || "").trim();
182 const password = String(body.password || "");
183
184 if (!username || !email || !password) {
185 return c.redirect("/register?error=All+fields+are+required");
186 }
187
c63b860Claude188 // Block P3 — Terms acceptance is required. The form's checkbox has
189 // `required` so browsers normally enforce client-side; the server
190 // re-checks for defensive depth (curl, scripted POST, etc.).
191 if (!body.accept_terms) {
192 return c.redirect(
193 "/register?error=Please+accept+the+Terms+of+Service+and+Privacy+Policy"
194 );
195 }
196
06d5ffeClaude197 if (!/^[a-zA-Z0-9_-]+$/.test(username)) {
198 return c.redirect(
199 "/register?error=Username+may+only+contain+letters%2C+numbers%2C+hyphens+and+underscores"
200 );
201 }
202
203 if (password.length < 8) {
204 return c.redirect("/register?error=Password+must+be+at+least+8+characters");
205 }
206
207 // Check existing
208 const [existingUser] = await db
209 .select()
210 .from(users)
211 .where(eq(users.username, username))
212 .limit(1);
213 if (existingUser) {
214 return c.redirect("/register?error=Username+already+taken");
215 }
216
7437605Claude217 // B2: usernames share the URL namespace with org slugs; refuse collisions.
218 const [existingOrg] = await db
219 .select({ id: organizations.id })
220 .from(organizations)
221 .where(eq(organizations.slug, username.toLowerCase()))
222 .limit(1);
223 if (existingOrg) {
224 return c.redirect("/register?error=Username+already+taken");
225 }
226
06d5ffeClaude227 const [existingEmail] = await db
228 .select()
229 .from(users)
230 .where(eq(users.email, email))
231 .limit(1);
232 if (existingEmail) {
233 return c.redirect("/register?error=Email+already+registered");
234 }
235
236 const passwordHash = await hashPassword(password);
237
a4f3e24Claude238 // First user ever registered becomes admin automatically
239 const [userCount] = await db
240 .select({ count: sql`count(*)::int` })
241 .from(users);
242 const isFirstUser = (userCount?.count as number) === 0;
243
06d5ffeClaude244 const [user] = await db
245 .insert(users)
c63b860Claude246 .values({
247 username,
248 email,
249 passwordHash,
250 isAdmin: isFirstUser,
251 // P3 — record terms acceptance now. Version bumps when Terms change.
252 termsAcceptedAt: new Date(),
253 termsVersion: "1.0",
254 })
06d5ffeClaude255 .returning();
256
2d985e5Claude257 // If username matches SITE_ADMIN_USERNAME env, grant site admin instantly
258 // so the operator doesn't have to wait for the next boot's bootstrap pass.
a28cedeClaude259 await import("../lib/admin-bootstrap")
260 .then((m) => m.ensureEnvAdminOnRegister({ userId: user.id, username }))
261 .catch((err) => {
262 console.warn(
263 `[admin-bootstrap] ensureEnvAdminOnRegister failed for ${username}:`,
264 err instanceof Error ? err.message : err
265 );
266 });
2d985e5Claude267
06d5ffeClaude268 // Create session
269 const token = generateSessionToken();
270 await db.insert(sessions).values({
271 userId: user.id,
272 token,
273 expiresAt: sessionExpiry(),
274 });
275
276 setCookie(c, "session", token, sessionCookieOptions());
277
976d7f7Claude278 // Block P2 — email verification. If RESEND_API_KEY is configured the
279 // verification email goes out and the user clicks the link to verify.
280 // If email is NOT configured (EMAIL_PROVIDER=log, no RESEND_API_KEY,
281 // etc.), the email would silently never arrive and the user would be
282 // locked out — AUDIT-v2.md P0 #3. In that case, auto-verify the
283 // account on registration so the user can actually use the site.
284 // Operators who want real verification should set EMAIL_PROVIDER=resend
285 // + RESEND_API_KEY in their environment.
286 const { config: _emailConfig } = await import("../lib/config");
287 const emailConfigured =
288 _emailConfig.emailProvider === "resend" && !!_emailConfig.resendApiKey;
289 if (emailConfigured) {
290 import("../lib/email-verification")
291 .then((m) => m.startEmailVerification(user.id, email))
292 .catch((err) => {
293 console.error(
294 `[auth] startEmailVerification failed for ${user.id}:`,
295 err instanceof Error ? err.message : err
296 );
297 });
298 } else {
299 // Auto-verify immediately so the user isn't trapped in an unverified
300 // state. Log once so operators notice the misconfiguration.
301 if (!_autoVerifyWarned) {
302 _autoVerifyWarned = true;
303 console.warn(
304 "[auth] EMAIL_PROVIDER is not configured (set EMAIL_PROVIDER=resend + RESEND_API_KEY). Auto-verifying new account email addresses to avoid lockout."
305 );
306 }
307 await db
308 .update(users)
309 .set({ emailVerifiedAt: new Date() })
310 .where(eq(users.id, user.id))
311 .catch((err) => {
312 console.error(
313 `[auth] auto-verify failed for ${user.id}:`,
314 err instanceof Error ? err.message : err
315 );
316 });
317 }
c63b860Claude318
f65f600Claude319 // Onboarding drip — T+0 "welcome" email. Fire-and-forget; never blocks
320 // the redirect. Silently skips when email is not configured.
321 import("../lib/onboarding-drip")
322 .then((m) => m.sendWelcomeEmail(user.id))
323 .catch((err) => {
324 console.error(
325 `[auth] onboarding welcome email failed for ${user.id}:`,
326 err instanceof Error ? err.message : err
327 );
328 });
329
c63b860Claude330 // P3 — default landing is /onboarding (the guided first-five-minutes
331 // flow). The `redirect=` query is still honoured for OAuth-style flows.
332 const redirect = c.req.query("redirect") || "/onboarding?welcome=1";
06d5ffeClaude333 return c.redirect(redirect);
334});
335
36cc17aClaude336auth.get("/login", softAuth, async (c) => {
337 // Already-authed users hitting the sign-in page get bounced to their
338 // dashboard (or the `redirect=` target if one was supplied).
339 const existing = c.get("user");
06d5ffeClaude340 const error = c.req.query("error");
c63b860Claude341 const success = c.req.query("success");
06d5ffeClaude342 const redirect = c.req.query("redirect") || "";
36cc17aClaude343 if (existing) return c.redirect(redirect || "/dashboard");
edf7c36Claude344 const ssoCfg = await getSsoConfig();
345 const ssoEnabled =
346 !!ssoCfg?.enabled &&
347 !!ssoCfg.authorizationEndpoint &&
348 !!ssoCfg.tokenEndpoint &&
349 !!ssoCfg.userinfoEndpoint &&
350 !!ssoCfg.clientId &&
351 !!ssoCfg.clientSecret;
fc0ca99Claude352 const ssoLabel =
353 ssoCfg?.providerName || inferSsoProviderName(ssoCfg) || "SSO";
46d6165Claude354 // Block L6 — "Sign in with GitHub" (separate row keyed id='github').
355 const githubCfg = await getGithubOauthConfig();
356 const githubEnabled =
357 !!githubCfg?.enabled && !!githubCfg.clientId && !!githubCfg.clientSecret;
582cdacClaude358 // "Sign in with Google" (separate row keyed id='google'). Same wiring
359 // pattern as GitHub OAuth.
360 const googleCfg = await getGoogleOauthConfig();
361 const googleEnabled =
362 !!googleCfg?.enabled && !!googleCfg.clientId && !!googleCfg.clientSecret;
06d5ffeClaude363 return c.html(
cf21786ccanty labs364 <SignInV2
365 redirect={redirect}
366 error={error ? decodeURIComponent(error) : ""}
9374d58ccanty labs367 googleEnabled={googleEnabled}
368 githubEnabled={githubEnabled}
cf21786ccanty labs369 />
06d5ffeClaude370 );
371});
372
ba9e143Claude373/**
27d5fd3Claude374 * Loads the failure aggregate for `email` and evaluates the lockout policy
375 * (see src/lib/login-lockout.ts for the semantics).
376 *
377 * Fails OPEN: if the `login_attempts` table is unreachable (missing
378 * migration, transient DB error) login must still work — a broken lockout
379 * ledger must never lock every user out of the site. That failure class
380 * is exactly what the 0087 migration blockade caused in production.
ba9e143Claude381 */
27d5fd3Claude382async function getLockoutState(email: string): Promise<LockoutState> {
383 try {
384 const since = new Date(Date.now() - LOGIN_FAIL_WINDOW_MS);
385 const [row] = await db
386 .select({
387 count: sql<number>`count(*)::int`,
388 newest: sql<string | null>`max(${loginAttempts.createdAt})`,
389 })
390 .from(loginAttempts)
391 .where(
392 and(
393 eq(loginAttempts.email, email.toLowerCase()),
394 eq(loginAttempts.success, false),
395 gte(loginAttempts.createdAt, since)
396 )
397 );
398 return evaluateLockout({
399 failureCount: row?.count ?? 0,
400 newestFailureAt: row?.newest ? new Date(row.newest) : null,
401 });
402 } catch (err) {
403 console.error(
404 "[auth] lockout check failed (failing open):",
405 err instanceof Error ? err.message : err
ba9e143Claude406 );
27d5fd3Claude407 return { locked: false, failureCount: 0, retryAfterMs: 0 };
408 }
ba9e143Claude409}
410
06d5ffeClaude411auth.post("/login", async (c) => {
412 const body = await c.req.parseBody();
413 const identifier = String(body.username || "").trim();
414 const password = String(body.password || "");
415 const redirect = c.req.query("redirect") || "/";
ba9e143Claude416 const ip =
417 c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ||
418 c.req.header("x-real-ip") ||
419 "unknown";
420 const ua = c.req.header("user-agent") || "";
06d5ffeClaude421
422 if (!identifier || !password) {
423 return c.redirect("/login?error=All+fields+are+required");
424 }
425
3a845e4Claude426 // Enterprise SSO domain-hint routing: if the identifier is an email and the
427 // domain matches an org's `domain_hint`, redirect to that org's SSO flow
428 // instead of checking the password.
429 // Also: resolve the canonical email for lockout checks regardless of whether
ba9e143Claude430 // the user typed username or email.
06d5ffeClaude431 const isEmail = identifier.includes("@");
3a845e4Claude432 if (isEmail) {
433 const emailDomain = identifier.split("@")[1]?.toLowerCase();
434 if (emailDomain) {
435 const [ssoHint] = await db
436 .select({
437 provider: orgSsoConfigs.provider,
438 orgId: orgSsoConfigs.orgId,
439 })
440 .from(orgSsoConfigs)
441 .where(eq(orgSsoConfigs.domainHint, emailDomain))
442 .limit(1);
443
444 if (ssoHint) {
445 // Resolve org slug from org ID
446 const [orgRow] = await db
447 .select({ slug: organizations.slug })
448 .from(organizations)
449 .where(eq(organizations.id, ssoHint.orgId))
450 .limit(1);
451
452 if (orgRow) {
453 const protocol = ssoHint.provider === "oidc" ? "oidc" : "saml";
454 return c.redirect(`/sso/${protocol}/${orgRow.slug}/login`);
455 }
456 }
457 }
458 }
459
460 // Find user by username or email
06d5ffeClaude461 const [user] = await db
462 .select()
463 .from(users)
464 .where(
465 isEmail
466 ? eq(users.email, identifier)
467 : eq(users.username, identifier)
468 )
469 .limit(1);
470
ba9e143Claude471 // Determine the email key for lockout (use identifier if user not found
472 // so we still record the attempt without leaking account existence).
473 const emailKey = (user?.email ?? identifier).toLowerCase();
474
475 // ── Lockout check ───────────────────────────────────────────────────
27d5fd3Claude476 // Locked when ≥ LOGIN_FAIL_LIMIT failures in the trailing window AND the
477 // newest failure is younger than LOGIN_LOCKOUT_MS. We check before
478 // password verification so brute-forcers can't time-diff their way
479 // around it. Blocked attempts are deliberately NOT recorded as failures:
480 // recording them rolled the window forward forever, so a user retrying
481 // their correct password stayed locked out permanently.
482 const lockout = await getLockoutState(emailKey);
483 if (lockout.locked) {
ba9e143Claude484 await audit({
485 userId: user?.id ?? null,
486 action: "auth.login.locked",
487 ip,
488 userAgent: ua,
27d5fd3Claude489 metadata: { email: emailKey, recentFailures: lockout.failureCount },
ba9e143Claude490 });
27d5fd3Claude491 const mins = retryAfterMinutes(lockout);
ba9e143Claude492 return c.redirect(
27d5fd3Claude493 `/login?error=${encodeURIComponent(
494 `Account temporarily locked due to too many failed login attempts. Please try again in ${mins} minute${mins === 1 ? "" : "s"}.`
495 )}`
ba9e143Claude496 );
497 }
27d5fd3Claude498 const recentFailures = lockout.failureCount;
ba9e143Claude499
06d5ffeClaude500 if (!user) {
ba9e143Claude501 // Record failed attempt (unknown user) and return generic error.
502 await db
503 .insert(loginAttempts)
504 .values({ email: emailKey, ip, success: false })
505 .catch(() => {});
06d5ffeClaude506 return c.redirect("/login?error=Invalid+credentials");
507 }
508
509 const valid = await verifyPassword(password, user.passwordHash);
510 if (!valid) {
ba9e143Claude511 // Record failed attempt.
512 await db
513 .insert(loginAttempts)
514 .values({ email: emailKey, ip, success: false })
515 .catch(() => {});
516 await audit({
517 userId: user.id,
518 action: "auth.login.failed",
519 ip,
520 userAgent: ua,
521 metadata: { email: emailKey, attempt: recentFailures + 1 },
522 });
523 // Check if this failure just crossed the threshold.
524 if (recentFailures + 1 >= LOGIN_FAIL_LIMIT) {
525 await audit({
526 userId: user.id,
527 action: "auth.login.locked",
528 ip,
529 userAgent: ua,
530 metadata: { email: emailKey, recentFailures: recentFailures + 1 },
531 });
532 return c.redirect(
533 "/login?error=Account+temporarily+locked+due+to+too+many+failed+login+attempts.+Please+try+again+in+15+minutes."
534 );
535 }
06d5ffeClaude536 return c.redirect("/login?error=Invalid+credentials");
537 }
538
27d5fd3Claude539 // Successful login — record success and clear the failure history so a
540 // stale window can't combine with one future typo to re-trip the lock.
ba9e143Claude541 await db
542 .insert(loginAttempts)
543 .values({ email: emailKey, ip, success: true })
544 .catch(() => {});
27d5fd3Claude545 await db
546 .delete(loginAttempts)
547 .where(
548 and(eq(loginAttempts.email, emailKey), eq(loginAttempts.success, false))
549 )
550 .catch(() => {});
ba9e143Claude551
7298a17Claude552 // B4: if the user has TOTP enabled, issue a pending-2fa session and
553 // redirect to the code prompt.
554 const [totp] = await db
555 .select({ enabledAt: userTotp.enabledAt })
556 .from(userTotp)
557 .where(eq(userTotp.userId, user.id))
558 .limit(1);
559 const needs2fa = !!(totp && totp.enabledAt);
560
06d5ffeClaude561 const token = generateSessionToken();
562 await db.insert(sessions).values({
563 userId: user.id,
564 token,
565 expiresAt: sessionExpiry(),
7298a17Claude566 requires2fa: needs2fa,
ba9e143Claude567 ip,
568 userAgent: ua,
569 lastSeenAt: new Date(),
06d5ffeClaude570 });
571
572 setCookie(c, "session", token, sessionCookieOptions());
c63b860Claude573
574 // Block P5 — If account was scheduled for deletion but user signed back
575 // in, cancel the deletion. Safe regardless of 2FA: password was proven.
576 if (user.deletedAt) {
577 await cancelAccountDeletion(user.id);
578 }
579
7298a17Claude580 if (needs2fa) {
581 return c.redirect(
582 `/login/2fa?redirect=${encodeURIComponent(redirect)}`
583 );
584 }
06d5ffeClaude585 return c.redirect(redirect);
586});
587
7298a17Claude588// --- 2FA verify (B4) ---
589auth.get("/login/2fa", async (c) => {
590 const token = getCookie(c, "session");
591 if (!token) return c.redirect("/login");
592 const error = c.req.query("error");
593 const redirect = c.req.query("redirect") || "/";
594 return c.html(
36cc17aClaude595 <Layout title="Two-factor authentication" user={null}>
ebe6d64Claude596 <AuthMobileStyle />
7298a17Claude597 <div class="auth-container">
a48f839ccantynz-alt598 <h1>Enter your code</h1>
7298a17Claude599 <p
600 class="auth-switch"
601 style="margin-bottom: 16px; margin-top: 0"
602 >
603 Open your authenticator app and enter the 6-digit code. Lost your
604 device? Paste a recovery code instead.
605 </p>
606 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
607 <form
b9968e3Claude608 method="post"
7298a17Claude609 action={`/login/2fa?redirect=${encodeURIComponent(redirect)}`}
610 >
134750bClaude611 <input type="hidden" name="_csrf" value={(c.get("csrfToken") as string | undefined) || ""} />
7298a17Claude612 <div class="form-group">
613 <label for="code">Code</label>
614 <input
615 type="text"
616 id="code"
617 name="code"
618 required
619 autocomplete="one-time-code"
620 inputmode="numeric"
621 maxLength={24}
622 placeholder="123456 or xxxx-xxxx-xxxx"
623 />
624 </div>
625 <button type="submit" class="btn btn-primary">
626 Verify
627 </button>
628 </form>
629 <p class="auth-switch">
630 <a href="/logout">Cancel</a>
631 </p>
632 </div>
633 </Layout>
634 );
635});
636
637auth.post("/login/2fa", async (c) => {
638 const token = getCookie(c, "session");
639 if (!token) return c.redirect("/login");
640 const body = await c.req.parseBody();
641 const code = String(body.code || "").trim();
642 const redirect = c.req.query("redirect") || "/";
643
644 if (!code) {
645 return c.redirect(
646 `/login/2fa?error=Code+is+required&redirect=${encodeURIComponent(redirect)}`
647 );
648 }
649
650 try {
651 const [session] = await db
652 .select()
653 .from(sessions)
654 .where(eq(sessions.token, token))
655 .limit(1);
656 if (
657 !session ||
658 new Date(session.expiresAt) < new Date() ||
659 !session.requires2fa
660 ) {
661 return c.redirect("/login");
662 }
663
664 const [totp] = await db
665 .select()
666 .from(userTotp)
667 .where(eq(userTotp.userId, session.userId))
668 .limit(1);
669 if (!totp || !totp.enabledAt) {
670 // User doesn't have 2FA actually enabled — clear the flag and let
671 // them in. This can only happen if 2FA was disabled in another
672 // session between password check and code prompt.
673 await db
674 .update(sessions)
675 .set({ requires2fa: false })
676 .where(eq(sessions.token, token));
677 return c.redirect(redirect);
678 }
679
1d3a220ccantynz-alt680 // Account-scoped lockout, checked BEFORE any code is verified so a locked
681 // account costs an attacker nothing to discover and gains them nothing.
682 // Reuses the same evaluateLockout()/loginAttempts machinery the password
683 // step uses, so both factors share one threshold and one window.
684 try {
685 const [u] = await db
686 .select({ email: users.email })
687 .from(users)
688 .where(eq(users.id, session.userId))
689 .limit(1);
690 if (u?.email) {
691 const since = new Date(Date.now() - LOGIN_FAIL_WINDOW_MS);
692 const [agg] = await db
693 .select({
694 failures: sql<number>`count(*)::int`,
695 newest: sql<string | null>`max(${loginAttempts.createdAt})`,
696 })
697 .from(loginAttempts)
698 .where(
699 and(
700 eq(loginAttempts.email, u.email.toLowerCase()),
701 eq(loginAttempts.success, false),
702 gte(loginAttempts.createdAt, since)
703 )
704 );
705 const state = evaluateLockout({
706 failureCount: Number(agg?.failures ?? 0),
707 newestFailureAt: agg?.newest ? new Date(agg.newest) : null,
708 });
709 if (state.locked) {
710 return c.redirect(
711 `/login/2fa?error=${encodeURIComponent(
712 `Too many attempts. Try again in ${retryAfterMinutes(state)} minutes.`
713 )}&redirect=${encodeURIComponent(redirect)}`
714 );
715 }
716 }
717 } catch (err) {
718 // Fail OPEN on a lockout-lookup error: the per-IP rate limit still
719 // applies, and locking every 2FA user out because one query failed
720 // would be a worse outage than the risk it mitigates.
721 console.error("[auth] 2fa lockout check:", err);
722 }
723
7298a17Claude724 // Try TOTP code first.
725 const isSix = /^\d{6}$/.test(code);
726 let ok = false;
727 if (isSix) {
728 ok = await verifyTotpCode(totp.secret, code);
729 }
730 // Fall through to recovery code.
731 if (!ok) {
732 const hash = await hashRecoveryCode(code);
733 const [rec] = await db
734 .select()
735 .from(userRecoveryCodes)
736 .where(
737 and(
738 eq(userRecoveryCodes.userId, session.userId),
739 eq(userRecoveryCodes.codeHash, hash),
740 isNull(userRecoveryCodes.usedAt)
741 )
742 )
743 .limit(1);
744 if (rec) {
745 await db
746 .update(userRecoveryCodes)
747 .set({ usedAt: new Date() })
748 .where(eq(userRecoveryCodes.id, rec.id));
749 ok = true;
750 }
751 }
752
753 if (!ok) {
1d3a220ccantynz-alt754 // Record the failure and lock out after the same threshold the password
755 // step uses. The per-IP rate limit added in app.tsx throttles a single
756 // source; this is the part an attacker cannot rotate around, because it
757 // keys on the ACCOUNT. Without it, someone who already has the password
758 // holds a valid half-authenticated session and can grind a 6-digit TOTP
759 // from as many addresses as they like.
760 try {
761 const [u] = await db
762 .select({ email: users.email })
763 .from(users)
764 .where(eq(users.id, session.userId))
765 .limit(1);
766 if (u?.email) {
767 await db.insert(loginAttempts).values({
768 email: u.email.toLowerCase(),
769 success: false,
770 // Same derivation the password step uses — `ip` is NOT NULL, and
771 // "unknown" keeps an attempt behind a proxy that strips headers
772 // countable rather than silently dropping the row.
773 ip:
774 c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ||
775 c.req.header("x-real-ip") ||
776 "unknown",
777 });
778 }
779 } catch (err) {
780 // A failed audit write must not become a free retry, but it also
781 // must not 500 the sign-in — log and fall through to the refusal.
782 console.error("[auth] 2fa failure record:", err);
783 }
7298a17Claude784 return c.redirect(
785 `/login/2fa?error=Invalid+code&redirect=${encodeURIComponent(redirect)}`
786 );
787 }
788
789 await db
790 .update(sessions)
791 .set({ requires2fa: false })
792 .where(eq(sessions.token, token));
793 await db
794 .update(userTotp)
795 .set({ lastUsedAt: new Date() })
796 .where(eq(userTotp.userId, session.userId));
797
798 return c.redirect(redirect);
799 } catch (err) {
800 console.error("[auth] 2fa verify:", err);
801 return c.redirect(
802 `/login/2fa?error=Service+unavailable&redirect=${encodeURIComponent(redirect)}`
803 );
804 }
805});
806
06d5ffeClaude807auth.get("/logout", async (c) => {
1d3a220ccantynz-alt808 // Dropping the cookie only clears the CLIENT's copy. The `sessions` row
809 // survived its full 30-day expiry and `sessionCache` kept serving the user
810 // for the cache TTL, so anyone holding a copy of the token — a shared
811 // machine, a synced browser profile, a logged proxy — stayed signed in
812 // after the user had explicitly signed out. Logging out has to revoke the
813 // credential, not just forget it.
814 const token = getCookie(c, "session");
815 if (token) {
816 try {
817 await db.delete(sessions).where(eq(sessions.token, token));
818 } catch (err) {
819 // Never leave the user with a live cookie because the delete failed —
820 // fall through and still clear it, and log so this is visible.
821 console.error("[logout] session row delete failed:", err);
822 }
823 // softAuth/requireAuth read this cache before the DB, so without an
824 // explicit invalidation the deleted session keeps authenticating until
825 // the entry expires on its own.
826 try {
827 sessionCache.invalidate(token);
828 } catch {
829 /* cache is best-effort */
830 }
831 }
06d5ffeClaude832 deleteCookie(c, "session", { path: "/" });
833 return c.redirect("/");
834});
835
836// --- API ---
837
838auth.post("/api/auth/register", async (c) => {
839 const body = await c.req.json<{
840 username: string;
841 email: string;
842 password: string;
843 }>();
844
845 if (!body.username || !body.email || !body.password) {
846 return c.json({ error: "username, email, and password are required" }, 400);
847 }
848
849 if (!/^[a-zA-Z0-9_-]+$/.test(body.username)) {
850 return c.json({ error: "Invalid username" }, 400);
851 }
852
853 if (body.password.length < 8) {
854 return c.json({ error: "Password must be at least 8 characters" }, 400);
855 }
856
857 const [existing] = await db
858 .select()
859 .from(users)
860 .where(eq(users.username, body.username))
861 .limit(1);
862 if (existing) {
863 return c.json({ error: "Username already taken" }, 409);
864 }
865
866 const passwordHash = await hashPassword(body.password);
867 const [user] = await db
868 .insert(users)
869 .values({
870 username: body.username,
871 email: body.email,
872 passwordHash,
873 })
874 .returning();
875
876 const token = generateSessionToken();
877 await db.insert(sessions).values({
878 userId: user.id,
879 token,
880 expiresAt: sessionExpiry(),
881 });
882
883 return c.json(
884 {
885 user: { id: user.id, username: user.username, email: user.email },
886 token,
887 },
888 201
889 );
890});
891
892auth.post("/api/auth/login", async (c) => {
893 const body = await c.req.json<{ username: string; password: string }>();
894
895 if (!body.username || !body.password) {
896 return c.json({ error: "username and password are required" }, 400);
897 }
898
899 const isEmail = body.username.includes("@");
900 const [user] = await db
901 .select()
902 .from(users)
903 .where(
904 isEmail
905 ? eq(users.email, body.username)
906 : eq(users.username, body.username)
907 )
908 .limit(1);
909
910 if (!user) return c.json({ error: "Invalid credentials" }, 401);
911
912 const valid = await verifyPassword(body.password, user.passwordHash);
913 if (!valid) return c.json({ error: "Invalid credentials" }, 401);
914
915 const token = generateSessionToken();
916 await db.insert(sessions).values({
917 userId: user.id,
918 token,
919 expiresAt: sessionExpiry(),
920 });
921
922 return c.json({
923 user: { id: user.id, username: user.username, email: user.email },
924 token,
925 });
926});
927
3a845e4Claude928// --- SSO domain-hint API (used by the login form JS) ---
929
930auth.get("/api/sso/domain-hint", async (c) => {
931 const domain = String(c.req.query("domain") || "").toLowerCase().trim();
932 if (!domain || domain.length > 253) {
933 return c.json({ sso: false });
934 }
935 const [row] = await db
936 .select({ orgId: orgSsoConfigs.orgId, provider: orgSsoConfigs.provider })
937 .from(orgSsoConfigs)
938 .where(eq(orgSsoConfigs.domainHint, domain))
939 .limit(1);
940 return c.json({ sso: !!row, provider: row?.provider ?? null });
941});
942
fc0ca99Claude943/**
944 * Pick a friendly provider name for the "Sign in with X" button when the
945 * admin hasn't set one explicitly. Looks at the configured IdP URLs.
946 * Falls back to undefined so the caller can default to a literal "SSO".
947 */
948function inferSsoProviderName(
949 cfg: { issuer?: string | null; authorizationEndpoint?: string | null } | null | undefined
950): string | undefined {
951 const urls = [cfg?.issuer, cfg?.authorizationEndpoint]
952 .filter((s): s is string => !!s)
953 .join(" ")
954 .toLowerCase();
955 if (!urls) return undefined;
956 if (urls.includes("google")) return "Google";
957 if (urls.includes("okta")) return "Okta";
958 if (urls.includes("microsoftonline") || urls.includes("azure")) return "Microsoft";
959 if (urls.includes("auth0.com")) return "Auth0";
960 if (urls.includes("authentik")) return "Authentik";
961 return undefined;
962}
963
06d5ffeClaude964export default auth;