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.tsxBlame971 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";
e9a4574ccantynz-alt54import { safeRedirect } from "../lib/safe-redirect";
06d5ffeClaude55
56const auth = new Hono<AuthEnv>();
57
976d7f7Claude58// One-shot latch — log the auto-verify warning at most once per process,
59// since the misconfiguration is operator-level (env var) and won't change
60// between requests.
61let _autoVerifyWarned = false;
62
ebe6d64Claude63// ───────────────────────────────────────────────────────────────────────
64// Scoped mobile polish — tightens the existing `.auth-container` shell
65// from layout.tsx for ≤720px viewports. Only adds rules; does not
66// redefine the desktop styling. Kept inline so this file remains the
67// single source of truth for the auth surface.
68// ───────────────────────────────────────────────────────────────────────
69const authMobileCss = `
70 @media (max-width: 720px) {
71 .auth-container {
72 margin: 24px 12px;
73 padding: 24px 20px 22px;
74 max-width: 100%;
75 }
76 .auth-container .btn-primary { min-height: 44px; }
77 .auth-container .oauth-btn { min-height: 44px; }
78 .auth-container input[type="text"],
79 .auth-container input[type="email"],
80 .auth-container input[type="password"] { min-height: 44px; }
81 .auth-forgot { text-align: left !important; }
82 }
83`;
84const AuthMobileStyle = () => (
85 <style dangerouslySetInnerHTML={{ __html: authMobileCss }} />
86);
87
06d5ffeClaude88// --- Web UI ---
89
36cc17aClaude90auth.get("/register", softAuth, (c) => {
91 // If the user is already signed in, drop them on their dashboard rather
92 // than rendering the logged-out sign-up shell over an authed session.
93 const existing = c.get("user");
94 if (existing) return c.redirect("/dashboard");
06d5ffeClaude95 const error = c.req.query("error");
134750bClaude96 const csrf = c.get("csrfToken") as string | undefined;
06d5ffeClaude97 return c.html(
36cc17aClaude98 <Layout title="Register" user={null}>
ebe6d64Claude99 <AuthMobileStyle />
06d5ffeClaude100 <div class="auth-container">
a48f839ccantynz-alt101 <h1>Create your account</h1>
98f45b4Claude102 <p class="auth-subtitle">
103 Get the full AI suite — code review, auto-merge, spec-to-PR — on
104 unlimited public repos. No credit card.
105 </p>
06d5ffeClaude106 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
134750bClaude107 <Form method="post" action="/register" csrfToken={csrf}>
0316dbbClaude108 <FormGroup label="Username" htmlFor="username">
109 <Input
110 id="username"
06d5ffeClaude111 type="text"
112 name="username"
113 required
114 pattern="^[a-zA-Z0-9_-]+$"
115 minLength={2}
116 maxLength={39}
117 placeholder="your-username"
118 autocomplete="username"
119 />
bb0f894Claude120 </FormGroup>
121 <FormGroup label="Email" htmlFor="email">
122 <Input
06d5ffeClaude123 type="email"
124 name="email"
125 required
126 placeholder="you@example.com"
127 autocomplete="email"
2c3ba6ecopilot-swe-agent[bot]128 aria-label="Email"
06d5ffeClaude129 />
bb0f894Claude130 </FormGroup>
131 <FormGroup label="Password" htmlFor="password">
132 <Input
06d5ffeClaude133 type="password"
134 name="password"
135 required
136 minLength={8}
137 placeholder="Min 8 characters"
138 autocomplete="new-password"
2c3ba6ecopilot-swe-agent[bot]139 aria-label="Password"
06d5ffeClaude140 />
bb0f894Claude141 </FormGroup>
c63b860Claude142 {/* P3 — Terms / Privacy acceptance. Required client-side via the
143 `required` attribute; server-side re-checked in POST handler. */}
144 <div class="form-group" style="margin: 12px 0">
145 <label style="display: flex; gap: 8px; align-items: flex-start; font-size: 13px; color: var(--text-muted)">
146 <input
147 type="checkbox"
148 name="accept_terms"
149 value="1"
150 required
151 style="margin-top: 3px"
152 aria-label="Accept Terms of Service and Privacy Policy"
153 />
154 <span>
155 I agree to the{" "}
2e8a4d5Claude156 <a href="/terms" target="_blank" rel="noopener">
c63b860Claude157 Terms of Service
158 </a>{" "}
159 and{" "}
2e8a4d5Claude160 <a href="/privacy" target="_blank" rel="noopener">
c63b860Claude161 Privacy Policy
162 </a>
163 .
164 </span>
165 </label>
166 </div>
bb0f894Claude167 <Button type="submit" variant="primary">
06d5ffeClaude168 Create account
bb0f894Claude169 </Button>
170 </Form>
06d5ffeClaude171 <p class="auth-switch">
bb0f894Claude172 <Text>Already have an account? <a href="/login">Sign in</a></Text>
06d5ffeClaude173 </p>
174 </div>
175 </Layout>
176 );
177});
178
179auth.post("/register", async (c) => {
180 const body = await c.req.parseBody();
181 const username = String(body.username || "").trim();
182 const email = String(body.email || "").trim();
183 const password = String(body.password || "");
184
185 if (!username || !email || !password) {
186 return c.redirect("/register?error=All+fields+are+required");
187 }
188
c63b860Claude189 // Block P3 — Terms acceptance is required. The form's checkbox has
190 // `required` so browsers normally enforce client-side; the server
191 // re-checks for defensive depth (curl, scripted POST, etc.).
192 if (!body.accept_terms) {
193 return c.redirect(
194 "/register?error=Please+accept+the+Terms+of+Service+and+Privacy+Policy"
195 );
196 }
197
06d5ffeClaude198 if (!/^[a-zA-Z0-9_-]+$/.test(username)) {
199 return c.redirect(
200 "/register?error=Username+may+only+contain+letters%2C+numbers%2C+hyphens+and+underscores"
201 );
202 }
203
204 if (password.length < 8) {
205 return c.redirect("/register?error=Password+must+be+at+least+8+characters");
206 }
207
208 // Check existing
209 const [existingUser] = await db
210 .select()
211 .from(users)
212 .where(eq(users.username, username))
213 .limit(1);
214 if (existingUser) {
215 return c.redirect("/register?error=Username+already+taken");
216 }
217
7437605Claude218 // B2: usernames share the URL namespace with org slugs; refuse collisions.
219 const [existingOrg] = await db
220 .select({ id: organizations.id })
221 .from(organizations)
222 .where(eq(organizations.slug, username.toLowerCase()))
223 .limit(1);
224 if (existingOrg) {
225 return c.redirect("/register?error=Username+already+taken");
226 }
227
06d5ffeClaude228 const [existingEmail] = await db
229 .select()
230 .from(users)
231 .where(eq(users.email, email))
232 .limit(1);
233 if (existingEmail) {
234 return c.redirect("/register?error=Email+already+registered");
235 }
236
237 const passwordHash = await hashPassword(password);
238
a4f3e24Claude239 // First user ever registered becomes admin automatically
240 const [userCount] = await db
241 .select({ count: sql`count(*)::int` })
242 .from(users);
243 const isFirstUser = (userCount?.count as number) === 0;
244
06d5ffeClaude245 const [user] = await db
246 .insert(users)
c63b860Claude247 .values({
248 username,
249 email,
250 passwordHash,
251 isAdmin: isFirstUser,
252 // P3 — record terms acceptance now. Version bumps when Terms change.
253 termsAcceptedAt: new Date(),
254 termsVersion: "1.0",
255 })
06d5ffeClaude256 .returning();
257
2d985e5Claude258 // If username matches SITE_ADMIN_USERNAME env, grant site admin instantly
259 // so the operator doesn't have to wait for the next boot's bootstrap pass.
a28cedeClaude260 await import("../lib/admin-bootstrap")
261 .then((m) => m.ensureEnvAdminOnRegister({ userId: user.id, username }))
262 .catch((err) => {
263 console.warn(
264 `[admin-bootstrap] ensureEnvAdminOnRegister failed for ${username}:`,
265 err instanceof Error ? err.message : err
266 );
267 });
2d985e5Claude268
06d5ffeClaude269 // Create session
270 const token = generateSessionToken();
271 await db.insert(sessions).values({
272 userId: user.id,
273 token,
274 expiresAt: sessionExpiry(),
275 });
276
277 setCookie(c, "session", token, sessionCookieOptions());
278
976d7f7Claude279 // Block P2 — email verification. If RESEND_API_KEY is configured the
280 // verification email goes out and the user clicks the link to verify.
281 // If email is NOT configured (EMAIL_PROVIDER=log, no RESEND_API_KEY,
282 // etc.), the email would silently never arrive and the user would be
283 // locked out — AUDIT-v2.md P0 #3. In that case, auto-verify the
284 // account on registration so the user can actually use the site.
285 // Operators who want real verification should set EMAIL_PROVIDER=resend
286 // + RESEND_API_KEY in their environment.
287 const { config: _emailConfig } = await import("../lib/config");
288 const emailConfigured =
289 _emailConfig.emailProvider === "resend" && !!_emailConfig.resendApiKey;
290 if (emailConfigured) {
291 import("../lib/email-verification")
292 .then((m) => m.startEmailVerification(user.id, email))
293 .catch((err) => {
294 console.error(
295 `[auth] startEmailVerification failed for ${user.id}:`,
296 err instanceof Error ? err.message : err
297 );
298 });
299 } else {
300 // Auto-verify immediately so the user isn't trapped in an unverified
301 // state. Log once so operators notice the misconfiguration.
302 if (!_autoVerifyWarned) {
303 _autoVerifyWarned = true;
304 console.warn(
305 "[auth] EMAIL_PROVIDER is not configured (set EMAIL_PROVIDER=resend + RESEND_API_KEY). Auto-verifying new account email addresses to avoid lockout."
306 );
307 }
308 await db
309 .update(users)
310 .set({ emailVerifiedAt: new Date() })
311 .where(eq(users.id, user.id))
312 .catch((err) => {
313 console.error(
314 `[auth] auto-verify failed for ${user.id}:`,
315 err instanceof Error ? err.message : err
316 );
317 });
318 }
c63b860Claude319
f65f600Claude320 // Onboarding drip — T+0 "welcome" email. Fire-and-forget; never blocks
321 // the redirect. Silently skips when email is not configured.
322 import("../lib/onboarding-drip")
323 .then((m) => m.sendWelcomeEmail(user.id))
324 .catch((err) => {
325 console.error(
326 `[auth] onboarding welcome email failed for ${user.id}:`,
327 err instanceof Error ? err.message : err
328 );
329 });
330
c63b860Claude331 // P3 — default landing is /onboarding (the guided first-five-minutes
332 // flow). The `redirect=` query is still honoured for OAuth-style flows.
e9a4574ccantynz-alt333 const redirect = safeRedirect(
334 c.req.query("redirect"),
335 "/onboarding?welcome=1"
336 );
06d5ffeClaude337 return c.redirect(redirect);
338});
339
36cc17aClaude340auth.get("/login", softAuth, async (c) => {
341 // Already-authed users hitting the sign-in page get bounced to their
342 // dashboard (or the `redirect=` target if one was supplied).
343 const existing = c.get("user");
06d5ffeClaude344 const error = c.req.query("error");
c63b860Claude345 const success = c.req.query("success");
e9a4574ccantynz-alt346 // Empty fallback is deliberate: the sign-in form needs to know whether a
347 // target was supplied at all. An unsafe one is dropped to "" so it is
348 // neither followed here nor carried into the form below.
349 const redirect = safeRedirect(c.req.query("redirect"), "");
36cc17aClaude350 if (existing) return c.redirect(redirect || "/dashboard");
edf7c36Claude351 const ssoCfg = await getSsoConfig();
352 const ssoEnabled =
353 !!ssoCfg?.enabled &&
354 !!ssoCfg.authorizationEndpoint &&
355 !!ssoCfg.tokenEndpoint &&
356 !!ssoCfg.userinfoEndpoint &&
357 !!ssoCfg.clientId &&
358 !!ssoCfg.clientSecret;
fc0ca99Claude359 const ssoLabel =
360 ssoCfg?.providerName || inferSsoProviderName(ssoCfg) || "SSO";
46d6165Claude361 // Block L6 — "Sign in with GitHub" (separate row keyed id='github').
362 const githubCfg = await getGithubOauthConfig();
363 const githubEnabled =
364 !!githubCfg?.enabled && !!githubCfg.clientId && !!githubCfg.clientSecret;
582cdacClaude365 // "Sign in with Google" (separate row keyed id='google'). Same wiring
366 // pattern as GitHub OAuth.
367 const googleCfg = await getGoogleOauthConfig();
368 const googleEnabled =
369 !!googleCfg?.enabled && !!googleCfg.clientId && !!googleCfg.clientSecret;
06d5ffeClaude370 return c.html(
cf21786ccanty labs371 <SignInV2
372 redirect={redirect}
373 error={error ? decodeURIComponent(error) : ""}
9374d58ccanty labs374 googleEnabled={googleEnabled}
375 githubEnabled={githubEnabled}
cf21786ccanty labs376 />
06d5ffeClaude377 );
378});
379
ba9e143Claude380/**
27d5fd3Claude381 * Loads the failure aggregate for `email` and evaluates the lockout policy
382 * (see src/lib/login-lockout.ts for the semantics).
383 *
384 * Fails OPEN: if the `login_attempts` table is unreachable (missing
385 * migration, transient DB error) login must still work — a broken lockout
386 * ledger must never lock every user out of the site. That failure class
387 * is exactly what the 0087 migration blockade caused in production.
ba9e143Claude388 */
27d5fd3Claude389async function getLockoutState(email: string): Promise<LockoutState> {
390 try {
391 const since = new Date(Date.now() - LOGIN_FAIL_WINDOW_MS);
392 const [row] = await db
393 .select({
394 count: sql<number>`count(*)::int`,
395 newest: sql<string | null>`max(${loginAttempts.createdAt})`,
396 })
397 .from(loginAttempts)
398 .where(
399 and(
400 eq(loginAttempts.email, email.toLowerCase()),
401 eq(loginAttempts.success, false),
402 gte(loginAttempts.createdAt, since)
403 )
404 );
405 return evaluateLockout({
406 failureCount: row?.count ?? 0,
407 newestFailureAt: row?.newest ? new Date(row.newest) : null,
408 });
409 } catch (err) {
410 console.error(
411 "[auth] lockout check failed (failing open):",
412 err instanceof Error ? err.message : err
ba9e143Claude413 );
27d5fd3Claude414 return { locked: false, failureCount: 0, retryAfterMs: 0 };
415 }
ba9e143Claude416}
417
06d5ffeClaude418auth.post("/login", async (c) => {
419 const body = await c.req.parseBody();
420 const identifier = String(body.username || "").trim();
421 const password = String(body.password || "");
e9a4574ccantynz-alt422 const redirect = safeRedirect(c.req.query("redirect"), "/");
ba9e143Claude423 const ip =
424 c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ||
425 c.req.header("x-real-ip") ||
426 "unknown";
427 const ua = c.req.header("user-agent") || "";
06d5ffeClaude428
429 if (!identifier || !password) {
430 return c.redirect("/login?error=All+fields+are+required");
431 }
432
3a845e4Claude433 // Enterprise SSO domain-hint routing: if the identifier is an email and the
434 // domain matches an org's `domain_hint`, redirect to that org's SSO flow
435 // instead of checking the password.
436 // Also: resolve the canonical email for lockout checks regardless of whether
ba9e143Claude437 // the user typed username or email.
06d5ffeClaude438 const isEmail = identifier.includes("@");
3a845e4Claude439 if (isEmail) {
440 const emailDomain = identifier.split("@")[1]?.toLowerCase();
441 if (emailDomain) {
442 const [ssoHint] = await db
443 .select({
444 provider: orgSsoConfigs.provider,
445 orgId: orgSsoConfigs.orgId,
446 })
447 .from(orgSsoConfigs)
448 .where(eq(orgSsoConfigs.domainHint, emailDomain))
449 .limit(1);
450
451 if (ssoHint) {
452 // Resolve org slug from org ID
453 const [orgRow] = await db
454 .select({ slug: organizations.slug })
455 .from(organizations)
456 .where(eq(organizations.id, ssoHint.orgId))
457 .limit(1);
458
459 if (orgRow) {
460 const protocol = ssoHint.provider === "oidc" ? "oidc" : "saml";
461 return c.redirect(`/sso/${protocol}/${orgRow.slug}/login`);
462 }
463 }
464 }
465 }
466
467 // Find user by username or email
06d5ffeClaude468 const [user] = await db
469 .select()
470 .from(users)
471 .where(
472 isEmail
473 ? eq(users.email, identifier)
474 : eq(users.username, identifier)
475 )
476 .limit(1);
477
ba9e143Claude478 // Determine the email key for lockout (use identifier if user not found
479 // so we still record the attempt without leaking account existence).
480 const emailKey = (user?.email ?? identifier).toLowerCase();
481
482 // ── Lockout check ───────────────────────────────────────────────────
27d5fd3Claude483 // Locked when ≥ LOGIN_FAIL_LIMIT failures in the trailing window AND the
484 // newest failure is younger than LOGIN_LOCKOUT_MS. We check before
485 // password verification so brute-forcers can't time-diff their way
486 // around it. Blocked attempts are deliberately NOT recorded as failures:
487 // recording them rolled the window forward forever, so a user retrying
488 // their correct password stayed locked out permanently.
489 const lockout = await getLockoutState(emailKey);
490 if (lockout.locked) {
ba9e143Claude491 await audit({
492 userId: user?.id ?? null,
493 action: "auth.login.locked",
494 ip,
495 userAgent: ua,
27d5fd3Claude496 metadata: { email: emailKey, recentFailures: lockout.failureCount },
ba9e143Claude497 });
27d5fd3Claude498 const mins = retryAfterMinutes(lockout);
ba9e143Claude499 return c.redirect(
27d5fd3Claude500 `/login?error=${encodeURIComponent(
501 `Account temporarily locked due to too many failed login attempts. Please try again in ${mins} minute${mins === 1 ? "" : "s"}.`
502 )}`
ba9e143Claude503 );
504 }
27d5fd3Claude505 const recentFailures = lockout.failureCount;
ba9e143Claude506
06d5ffeClaude507 if (!user) {
ba9e143Claude508 // Record failed attempt (unknown user) and return generic error.
509 await db
510 .insert(loginAttempts)
511 .values({ email: emailKey, ip, success: false })
512 .catch(() => {});
06d5ffeClaude513 return c.redirect("/login?error=Invalid+credentials");
514 }
515
516 const valid = await verifyPassword(password, user.passwordHash);
517 if (!valid) {
ba9e143Claude518 // Record failed attempt.
519 await db
520 .insert(loginAttempts)
521 .values({ email: emailKey, ip, success: false })
522 .catch(() => {});
523 await audit({
524 userId: user.id,
525 action: "auth.login.failed",
526 ip,
527 userAgent: ua,
528 metadata: { email: emailKey, attempt: recentFailures + 1 },
529 });
530 // Check if this failure just crossed the threshold.
531 if (recentFailures + 1 >= LOGIN_FAIL_LIMIT) {
532 await audit({
533 userId: user.id,
534 action: "auth.login.locked",
535 ip,
536 userAgent: ua,
537 metadata: { email: emailKey, recentFailures: recentFailures + 1 },
538 });
539 return c.redirect(
540 "/login?error=Account+temporarily+locked+due+to+too+many+failed+login+attempts.+Please+try+again+in+15+minutes."
541 );
542 }
06d5ffeClaude543 return c.redirect("/login?error=Invalid+credentials");
544 }
545
27d5fd3Claude546 // Successful login — record success and clear the failure history so a
547 // stale window can't combine with one future typo to re-trip the lock.
ba9e143Claude548 await db
549 .insert(loginAttempts)
550 .values({ email: emailKey, ip, success: true })
551 .catch(() => {});
27d5fd3Claude552 await db
553 .delete(loginAttempts)
554 .where(
555 and(eq(loginAttempts.email, emailKey), eq(loginAttempts.success, false))
556 )
557 .catch(() => {});
ba9e143Claude558
7298a17Claude559 // B4: if the user has TOTP enabled, issue a pending-2fa session and
560 // redirect to the code prompt.
561 const [totp] = await db
562 .select({ enabledAt: userTotp.enabledAt })
563 .from(userTotp)
564 .where(eq(userTotp.userId, user.id))
565 .limit(1);
566 const needs2fa = !!(totp && totp.enabledAt);
567
06d5ffeClaude568 const token = generateSessionToken();
569 await db.insert(sessions).values({
570 userId: user.id,
571 token,
572 expiresAt: sessionExpiry(),
7298a17Claude573 requires2fa: needs2fa,
ba9e143Claude574 ip,
575 userAgent: ua,
576 lastSeenAt: new Date(),
06d5ffeClaude577 });
578
579 setCookie(c, "session", token, sessionCookieOptions());
c63b860Claude580
581 // Block P5 — If account was scheduled for deletion but user signed back
582 // in, cancel the deletion. Safe regardless of 2FA: password was proven.
583 if (user.deletedAt) {
584 await cancelAccountDeletion(user.id);
585 }
586
7298a17Claude587 if (needs2fa) {
588 return c.redirect(
589 `/login/2fa?redirect=${encodeURIComponent(redirect)}`
590 );
591 }
06d5ffeClaude592 return c.redirect(redirect);
593});
594
7298a17Claude595// --- 2FA verify (B4) ---
596auth.get("/login/2fa", async (c) => {
597 const token = getCookie(c, "session");
598 if (!token) return c.redirect("/login");
599 const error = c.req.query("error");
e9a4574ccantynz-alt600 const redirect = safeRedirect(c.req.query("redirect"), "/");
7298a17Claude601 return c.html(
36cc17aClaude602 <Layout title="Two-factor authentication" user={null}>
ebe6d64Claude603 <AuthMobileStyle />
7298a17Claude604 <div class="auth-container">
a48f839ccantynz-alt605 <h1>Enter your code</h1>
7298a17Claude606 <p
607 class="auth-switch"
608 style="margin-bottom: 16px; margin-top: 0"
609 >
610 Open your authenticator app and enter the 6-digit code. Lost your
611 device? Paste a recovery code instead.
612 </p>
613 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
614 <form
b9968e3Claude615 method="post"
7298a17Claude616 action={`/login/2fa?redirect=${encodeURIComponent(redirect)}`}
617 >
134750bClaude618 <input type="hidden" name="_csrf" value={(c.get("csrfToken") as string | undefined) || ""} />
7298a17Claude619 <div class="form-group">
620 <label for="code">Code</label>
621 <input
622 type="text"
623 id="code"
624 name="code"
625 required
626 autocomplete="one-time-code"
627 inputmode="numeric"
628 maxLength={24}
629 placeholder="123456 or xxxx-xxxx-xxxx"
630 />
631 </div>
632 <button type="submit" class="btn btn-primary">
633 Verify
634 </button>
635 </form>
636 <p class="auth-switch">
637 <a href="/logout">Cancel</a>
638 </p>
639 </div>
640 </Layout>
641 );
642});
643
644auth.post("/login/2fa", async (c) => {
645 const token = getCookie(c, "session");
646 if (!token) return c.redirect("/login");
647 const body = await c.req.parseBody();
648 const code = String(body.code || "").trim();
e9a4574ccantynz-alt649 const redirect = safeRedirect(c.req.query("redirect"), "/");
7298a17Claude650
651 if (!code) {
652 return c.redirect(
653 `/login/2fa?error=Code+is+required&redirect=${encodeURIComponent(redirect)}`
654 );
655 }
656
657 try {
658 const [session] = await db
659 .select()
660 .from(sessions)
661 .where(eq(sessions.token, token))
662 .limit(1);
663 if (
664 !session ||
665 new Date(session.expiresAt) < new Date() ||
666 !session.requires2fa
667 ) {
668 return c.redirect("/login");
669 }
670
671 const [totp] = await db
672 .select()
673 .from(userTotp)
674 .where(eq(userTotp.userId, session.userId))
675 .limit(1);
676 if (!totp || !totp.enabledAt) {
677 // User doesn't have 2FA actually enabled — clear the flag and let
678 // them in. This can only happen if 2FA was disabled in another
679 // session between password check and code prompt.
680 await db
681 .update(sessions)
682 .set({ requires2fa: false })
683 .where(eq(sessions.token, token));
684 return c.redirect(redirect);
685 }
686
1d3a220ccantynz-alt687 // Account-scoped lockout, checked BEFORE any code is verified so a locked
688 // account costs an attacker nothing to discover and gains them nothing.
689 // Reuses the same evaluateLockout()/loginAttempts machinery the password
690 // step uses, so both factors share one threshold and one window.
691 try {
692 const [u] = await db
693 .select({ email: users.email })
694 .from(users)
695 .where(eq(users.id, session.userId))
696 .limit(1);
697 if (u?.email) {
698 const since = new Date(Date.now() - LOGIN_FAIL_WINDOW_MS);
699 const [agg] = await db
700 .select({
701 failures: sql<number>`count(*)::int`,
702 newest: sql<string | null>`max(${loginAttempts.createdAt})`,
703 })
704 .from(loginAttempts)
705 .where(
706 and(
707 eq(loginAttempts.email, u.email.toLowerCase()),
708 eq(loginAttempts.success, false),
709 gte(loginAttempts.createdAt, since)
710 )
711 );
712 const state = evaluateLockout({
713 failureCount: Number(agg?.failures ?? 0),
714 newestFailureAt: agg?.newest ? new Date(agg.newest) : null,
715 });
716 if (state.locked) {
717 return c.redirect(
718 `/login/2fa?error=${encodeURIComponent(
719 `Too many attempts. Try again in ${retryAfterMinutes(state)} minutes.`
720 )}&redirect=${encodeURIComponent(redirect)}`
721 );
722 }
723 }
724 } catch (err) {
725 // Fail OPEN on a lockout-lookup error: the per-IP rate limit still
726 // applies, and locking every 2FA user out because one query failed
727 // would be a worse outage than the risk it mitigates.
728 console.error("[auth] 2fa lockout check:", err);
729 }
730
7298a17Claude731 // Try TOTP code first.
732 const isSix = /^\d{6}$/.test(code);
733 let ok = false;
734 if (isSix) {
735 ok = await verifyTotpCode(totp.secret, code);
736 }
737 // Fall through to recovery code.
738 if (!ok) {
739 const hash = await hashRecoveryCode(code);
740 const [rec] = await db
741 .select()
742 .from(userRecoveryCodes)
743 .where(
744 and(
745 eq(userRecoveryCodes.userId, session.userId),
746 eq(userRecoveryCodes.codeHash, hash),
747 isNull(userRecoveryCodes.usedAt)
748 )
749 )
750 .limit(1);
751 if (rec) {
752 await db
753 .update(userRecoveryCodes)
754 .set({ usedAt: new Date() })
755 .where(eq(userRecoveryCodes.id, rec.id));
756 ok = true;
757 }
758 }
759
760 if (!ok) {
1d3a220ccantynz-alt761 // Record the failure and lock out after the same threshold the password
762 // step uses. The per-IP rate limit added in app.tsx throttles a single
763 // source; this is the part an attacker cannot rotate around, because it
764 // keys on the ACCOUNT. Without it, someone who already has the password
765 // holds a valid half-authenticated session and can grind a 6-digit TOTP
766 // from as many addresses as they like.
767 try {
768 const [u] = await db
769 .select({ email: users.email })
770 .from(users)
771 .where(eq(users.id, session.userId))
772 .limit(1);
773 if (u?.email) {
774 await db.insert(loginAttempts).values({
775 email: u.email.toLowerCase(),
776 success: false,
777 // Same derivation the password step uses — `ip` is NOT NULL, and
778 // "unknown" keeps an attempt behind a proxy that strips headers
779 // countable rather than silently dropping the row.
780 ip:
781 c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ||
782 c.req.header("x-real-ip") ||
783 "unknown",
784 });
785 }
786 } catch (err) {
787 // A failed audit write must not become a free retry, but it also
788 // must not 500 the sign-in — log and fall through to the refusal.
789 console.error("[auth] 2fa failure record:", err);
790 }
7298a17Claude791 return c.redirect(
792 `/login/2fa?error=Invalid+code&redirect=${encodeURIComponent(redirect)}`
793 );
794 }
795
796 await db
797 .update(sessions)
798 .set({ requires2fa: false })
799 .where(eq(sessions.token, token));
800 await db
801 .update(userTotp)
802 .set({ lastUsedAt: new Date() })
803 .where(eq(userTotp.userId, session.userId));
804
805 return c.redirect(redirect);
806 } catch (err) {
807 console.error("[auth] 2fa verify:", err);
808 return c.redirect(
809 `/login/2fa?error=Service+unavailable&redirect=${encodeURIComponent(redirect)}`
810 );
811 }
812});
813
06d5ffeClaude814auth.get("/logout", async (c) => {
1d3a220ccantynz-alt815 // Dropping the cookie only clears the CLIENT's copy. The `sessions` row
816 // survived its full 30-day expiry and `sessionCache` kept serving the user
817 // for the cache TTL, so anyone holding a copy of the token — a shared
818 // machine, a synced browser profile, a logged proxy — stayed signed in
819 // after the user had explicitly signed out. Logging out has to revoke the
820 // credential, not just forget it.
821 const token = getCookie(c, "session");
822 if (token) {
823 try {
824 await db.delete(sessions).where(eq(sessions.token, token));
825 } catch (err) {
826 // Never leave the user with a live cookie because the delete failed —
827 // fall through and still clear it, and log so this is visible.
828 console.error("[logout] session row delete failed:", err);
829 }
830 // softAuth/requireAuth read this cache before the DB, so without an
831 // explicit invalidation the deleted session keeps authenticating until
832 // the entry expires on its own.
833 try {
834 sessionCache.invalidate(token);
835 } catch {
836 /* cache is best-effort */
837 }
838 }
06d5ffeClaude839 deleteCookie(c, "session", { path: "/" });
840 return c.redirect("/");
841});
842
843// --- API ---
844
845auth.post("/api/auth/register", async (c) => {
846 const body = await c.req.json<{
847 username: string;
848 email: string;
849 password: string;
850 }>();
851
852 if (!body.username || !body.email || !body.password) {
853 return c.json({ error: "username, email, and password are required" }, 400);
854 }
855
856 if (!/^[a-zA-Z0-9_-]+$/.test(body.username)) {
857 return c.json({ error: "Invalid username" }, 400);
858 }
859
860 if (body.password.length < 8) {
861 return c.json({ error: "Password must be at least 8 characters" }, 400);
862 }
863
864 const [existing] = await db
865 .select()
866 .from(users)
867 .where(eq(users.username, body.username))
868 .limit(1);
869 if (existing) {
870 return c.json({ error: "Username already taken" }, 409);
871 }
872
873 const passwordHash = await hashPassword(body.password);
874 const [user] = await db
875 .insert(users)
876 .values({
877 username: body.username,
878 email: body.email,
879 passwordHash,
880 })
881 .returning();
882
883 const token = generateSessionToken();
884 await db.insert(sessions).values({
885 userId: user.id,
886 token,
887 expiresAt: sessionExpiry(),
888 });
889
890 return c.json(
891 {
892 user: { id: user.id, username: user.username, email: user.email },
893 token,
894 },
895 201
896 );
897});
898
899auth.post("/api/auth/login", async (c) => {
900 const body = await c.req.json<{ username: string; password: string }>();
901
902 if (!body.username || !body.password) {
903 return c.json({ error: "username and password are required" }, 400);
904 }
905
906 const isEmail = body.username.includes("@");
907 const [user] = await db
908 .select()
909 .from(users)
910 .where(
911 isEmail
912 ? eq(users.email, body.username)
913 : eq(users.username, body.username)
914 )
915 .limit(1);
916
917 if (!user) return c.json({ error: "Invalid credentials" }, 401);
918
919 const valid = await verifyPassword(body.password, user.passwordHash);
920 if (!valid) return c.json({ error: "Invalid credentials" }, 401);
921
922 const token = generateSessionToken();
923 await db.insert(sessions).values({
924 userId: user.id,
925 token,
926 expiresAt: sessionExpiry(),
927 });
928
929 return c.json({
930 user: { id: user.id, username: user.username, email: user.email },
931 token,
932 });
933});
934
3a845e4Claude935// --- SSO domain-hint API (used by the login form JS) ---
936
937auth.get("/api/sso/domain-hint", async (c) => {
938 const domain = String(c.req.query("domain") || "").toLowerCase().trim();
939 if (!domain || domain.length > 253) {
940 return c.json({ sso: false });
941 }
942 const [row] = await db
943 .select({ orgId: orgSsoConfigs.orgId, provider: orgSsoConfigs.provider })
944 .from(orgSsoConfigs)
945 .where(eq(orgSsoConfigs.domainHint, domain))
946 .limit(1);
947 return c.json({ sso: !!row, provider: row?.provider ?? null });
948});
949
fc0ca99Claude950/**
951 * Pick a friendly provider name for the "Sign in with X" button when the
952 * admin hasn't set one explicitly. Looks at the configured IdP URLs.
953 * Falls back to undefined so the caller can default to a literal "SSO".
954 */
955function inferSsoProviderName(
956 cfg: { issuer?: string | null; authorizationEndpoint?: string | null } | null | undefined
957): string | undefined {
958 const urls = [cfg?.issuer, cfg?.authorizationEndpoint]
959 .filter((s): s is string => !!s)
960 .join(" ")
961 .toLowerCase();
962 if (!urls) return undefined;
963 if (urls.includes("google")) return "Google";
964 if (urls.includes("okta")) return "Okta";
965 if (urls.includes("microsoftonline") || urls.includes("azure")) return "Microsoft";
966 if (urls.includes("auth0.com")) return "Auth0";
967 if (urls.includes("authentik")) return "Authentik";
968 return undefined;
969}
970
06d5ffeClaude971export default auth;