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.tsxBlame991 lines · 3 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,
13 userTotp,
14 userRecoveryCodes,
ba9e143Claude15 loginAttempts,
7298a17Claude16} from "../db/schema";
06d5ffeClaude17import {
18 hashPassword,
19 verifyPassword,
20 generateSessionToken,
21 sessionCookieOptions,
22 sessionExpiry,
23} from "../lib/auth";
7298a17Claude24import { verifyTotpCode, hashRecoveryCode } from "../lib/totp";
c63b860Claude25import { cancelAccountDeletion } from "../lib/account-deletion";
ba9e143Claude26import { audit } from "../lib/notify";
582cdacClaude27import {
28 getSsoConfig,
29 getGithubOauthConfig,
30 getGoogleOauthConfig,
31} from "../lib/sso";
06d5ffeClaude32import { Layout } from "../views/layout";
bb0f894Claude33import {
34 Form,
35 FormGroup,
36 Input,
37 Button,
09be7bfClaude38 LinkButton,
bb0f894Claude39 Alert,
40 Text,
41} from "../views/ui";
36cc17aClaude42import { softAuth } from "../middleware/auth";
06d5ffeClaude43import type { AuthEnv } from "../middleware/auth";
44
45const auth = new Hono<AuthEnv>();
46
976d7f7Claude47// One-shot latch — log the auto-verify warning at most once per process,
48// since the misconfiguration is operator-level (env var) and won't change
49// between requests.
50let _autoVerifyWarned = false;
51
ebe6d64Claude52// ───────────────────────────────────────────────────────────────────────
53// Scoped mobile polish — tightens the existing `.auth-container` shell
54// from layout.tsx for ≤720px viewports. Only adds rules; does not
55// redefine the desktop styling. Kept inline so this file remains the
56// single source of truth for the auth surface.
57// ───────────────────────────────────────────────────────────────────────
58const authMobileCss = `
59 @media (max-width: 720px) {
60 .auth-container {
61 margin: 24px 12px;
62 padding: 24px 20px 22px;
63 max-width: 100%;
64 }
65 .auth-container .btn-primary { min-height: 44px; }
66 .auth-container .oauth-btn { min-height: 44px; }
67 .auth-container input[type="text"],
68 .auth-container input[type="email"],
69 .auth-container input[type="password"] { min-height: 44px; }
70 .auth-forgot { text-align: left !important; }
71 }
72`;
73const AuthMobileStyle = () => (
74 <style dangerouslySetInnerHTML={{ __html: authMobileCss }} />
75);
76
06d5ffeClaude77// --- Web UI ---
78
36cc17aClaude79auth.get("/register", softAuth, (c) => {
80 // If the user is already signed in, drop them on their dashboard rather
81 // than rendering the logged-out sign-up shell over an authed session.
82 const existing = c.get("user");
83 if (existing) return c.redirect("/dashboard");
06d5ffeClaude84 const error = c.req.query("error");
134750bClaude85 const csrf = c.get("csrfToken") as string | undefined;
06d5ffeClaude86 return c.html(
36cc17aClaude87 <Layout title="Register" user={null}>
ebe6d64Claude88 <AuthMobileStyle />
06d5ffeClaude89 <div class="auth-container">
98f45b4Claude90 <h2>Create your account</h2>
91 <p class="auth-subtitle">
92 Get the full AI suite — code review, auto-merge, spec-to-PR — on
93 unlimited public repos. No credit card.
94 </p>
06d5ffeClaude95 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
134750bClaude96 <Form method="post" action="/register" csrfToken={csrf}>
0316dbbClaude97 <FormGroup label="Username" htmlFor="username">
98 <Input
99 id="username"
06d5ffeClaude100 type="text"
101 name="username"
102 required
103 pattern="^[a-zA-Z0-9_-]+$"
104 minLength={2}
105 maxLength={39}
106 placeholder="your-username"
107 autocomplete="username"
108 />
bb0f894Claude109 </FormGroup>
110 <FormGroup label="Email" htmlFor="email">
111 <Input
06d5ffeClaude112 type="email"
113 name="email"
114 required
115 placeholder="you@example.com"
116 autocomplete="email"
2c3ba6ecopilot-swe-agent[bot]117 aria-label="Email"
06d5ffeClaude118 />
bb0f894Claude119 </FormGroup>
120 <FormGroup label="Password" htmlFor="password">
121 <Input
06d5ffeClaude122 type="password"
123 name="password"
124 required
125 minLength={8}
126 placeholder="Min 8 characters"
127 autocomplete="new-password"
2c3ba6ecopilot-swe-agent[bot]128 aria-label="Password"
06d5ffeClaude129 />
bb0f894Claude130 </FormGroup>
c63b860Claude131 {/* P3 — Terms / Privacy acceptance. Required client-side via the
132 `required` attribute; server-side re-checked in POST handler. */}
133 <div class="form-group" style="margin: 12px 0">
134 <label style="display: flex; gap: 8px; align-items: flex-start; font-size: 13px; color: var(--text-muted)">
135 <input
136 type="checkbox"
137 name="accept_terms"
138 value="1"
139 required
140 style="margin-top: 3px"
141 aria-label="Accept Terms of Service and Privacy Policy"
142 />
143 <span>
144 I agree to the{" "}
2e8a4d5Claude145 <a href="/terms" target="_blank" rel="noopener">
c63b860Claude146 Terms of Service
147 </a>{" "}
148 and{" "}
2e8a4d5Claude149 <a href="/privacy" target="_blank" rel="noopener">
c63b860Claude150 Privacy Policy
151 </a>
152 .
153 </span>
154 </label>
155 </div>
bb0f894Claude156 <Button type="submit" variant="primary">
06d5ffeClaude157 Create account
bb0f894Claude158 </Button>
159 </Form>
06d5ffeClaude160 <p class="auth-switch">
bb0f894Claude161 <Text>Already have an account? <a href="/login">Sign in</a></Text>
06d5ffeClaude162 </p>
163 </div>
164 </Layout>
165 );
166});
167
168auth.post("/register", async (c) => {
169 const body = await c.req.parseBody();
170 const username = String(body.username || "").trim();
171 const email = String(body.email || "").trim();
172 const password = String(body.password || "");
173
174 if (!username || !email || !password) {
175 return c.redirect("/register?error=All+fields+are+required");
176 }
177
c63b860Claude178 // Block P3 — Terms acceptance is required. The form's checkbox has
179 // `required` so browsers normally enforce client-side; the server
180 // re-checks for defensive depth (curl, scripted POST, etc.).
181 if (!body.accept_terms) {
182 return c.redirect(
183 "/register?error=Please+accept+the+Terms+of+Service+and+Privacy+Policy"
184 );
185 }
186
06d5ffeClaude187 if (!/^[a-zA-Z0-9_-]+$/.test(username)) {
188 return c.redirect(
189 "/register?error=Username+may+only+contain+letters%2C+numbers%2C+hyphens+and+underscores"
190 );
191 }
192
193 if (password.length < 8) {
194 return c.redirect("/register?error=Password+must+be+at+least+8+characters");
195 }
196
197 // Check existing
198 const [existingUser] = await db
199 .select()
200 .from(users)
201 .where(eq(users.username, username))
202 .limit(1);
203 if (existingUser) {
204 return c.redirect("/register?error=Username+already+taken");
205 }
206
7437605Claude207 // B2: usernames share the URL namespace with org slugs; refuse collisions.
208 const [existingOrg] = await db
209 .select({ id: organizations.id })
210 .from(organizations)
211 .where(eq(organizations.slug, username.toLowerCase()))
212 .limit(1);
213 if (existingOrg) {
214 return c.redirect("/register?error=Username+already+taken");
215 }
216
06d5ffeClaude217 const [existingEmail] = await db
218 .select()
219 .from(users)
220 .where(eq(users.email, email))
221 .limit(1);
222 if (existingEmail) {
223 return c.redirect("/register?error=Email+already+registered");
224 }
225
226 const passwordHash = await hashPassword(password);
227
a4f3e24Claude228 // First user ever registered becomes admin automatically
229 const [userCount] = await db
230 .select({ count: sql`count(*)::int` })
231 .from(users);
232 const isFirstUser = (userCount?.count as number) === 0;
233
06d5ffeClaude234 const [user] = await db
235 .insert(users)
c63b860Claude236 .values({
237 username,
238 email,
239 passwordHash,
240 isAdmin: isFirstUser,
241 // P3 — record terms acceptance now. Version bumps when Terms change.
242 termsAcceptedAt: new Date(),
243 termsVersion: "1.0",
244 })
06d5ffeClaude245 .returning();
246
2d985e5Claude247 // If username matches SITE_ADMIN_USERNAME env, grant site admin instantly
248 // so the operator doesn't have to wait for the next boot's bootstrap pass.
a28cedeClaude249 await import("../lib/admin-bootstrap")
250 .then((m) => m.ensureEnvAdminOnRegister({ userId: user.id, username }))
251 .catch((err) => {
252 console.warn(
253 `[admin-bootstrap] ensureEnvAdminOnRegister failed for ${username}:`,
254 err instanceof Error ? err.message : err
255 );
256 });
2d985e5Claude257
06d5ffeClaude258 // Create session
259 const token = generateSessionToken();
260 await db.insert(sessions).values({
261 userId: user.id,
262 token,
263 expiresAt: sessionExpiry(),
264 });
265
266 setCookie(c, "session", token, sessionCookieOptions());
267
976d7f7Claude268 // Block P2 — email verification. If RESEND_API_KEY is configured the
269 // verification email goes out and the user clicks the link to verify.
270 // If email is NOT configured (EMAIL_PROVIDER=log, no RESEND_API_KEY,
271 // etc.), the email would silently never arrive and the user would be
272 // locked out — AUDIT-v2.md P0 #3. In that case, auto-verify the
273 // account on registration so the user can actually use the site.
274 // Operators who want real verification should set EMAIL_PROVIDER=resend
275 // + RESEND_API_KEY in their environment.
276 const { config: _emailConfig } = await import("../lib/config");
277 const emailConfigured =
278 _emailConfig.emailProvider === "resend" && !!_emailConfig.resendApiKey;
279 if (emailConfigured) {
280 import("../lib/email-verification")
281 .then((m) => m.startEmailVerification(user.id, email))
282 .catch((err) => {
283 console.error(
284 `[auth] startEmailVerification failed for ${user.id}:`,
285 err instanceof Error ? err.message : err
286 );
287 });
288 } else {
289 // Auto-verify immediately so the user isn't trapped in an unverified
290 // state. Log once so operators notice the misconfiguration.
291 if (!_autoVerifyWarned) {
292 _autoVerifyWarned = true;
293 console.warn(
294 "[auth] EMAIL_PROVIDER is not configured (set EMAIL_PROVIDER=resend + RESEND_API_KEY). Auto-verifying new account email addresses to avoid lockout."
295 );
296 }
297 await db
298 .update(users)
299 .set({ emailVerifiedAt: new Date() })
300 .where(eq(users.id, user.id))
301 .catch((err) => {
302 console.error(
303 `[auth] auto-verify failed for ${user.id}:`,
304 err instanceof Error ? err.message : err
305 );
306 });
307 }
c63b860Claude308
f65f600Claude309 // Onboarding drip — T+0 "welcome" email. Fire-and-forget; never blocks
310 // the redirect. Silently skips when email is not configured.
311 import("../lib/onboarding-drip")
312 .then((m) => m.sendWelcomeEmail(user.id))
313 .catch((err) => {
314 console.error(
315 `[auth] onboarding welcome email failed for ${user.id}:`,
316 err instanceof Error ? err.message : err
317 );
318 });
319
c63b860Claude320 // P3 — default landing is /onboarding (the guided first-five-minutes
321 // flow). The `redirect=` query is still honoured for OAuth-style flows.
322 const redirect = c.req.query("redirect") || "/onboarding?welcome=1";
06d5ffeClaude323 return c.redirect(redirect);
324});
325
36cc17aClaude326auth.get("/login", softAuth, async (c) => {
327 // Already-authed users hitting the sign-in page get bounced to their
328 // dashboard (or the `redirect=` target if one was supplied).
329 const existing = c.get("user");
06d5ffeClaude330 const error = c.req.query("error");
c63b860Claude331 const success = c.req.query("success");
06d5ffeClaude332 const redirect = c.req.query("redirect") || "";
36cc17aClaude333 if (existing) return c.redirect(redirect || "/dashboard");
edf7c36Claude334 const ssoCfg = await getSsoConfig();
335 const ssoEnabled =
336 !!ssoCfg?.enabled &&
337 !!ssoCfg.authorizationEndpoint &&
338 !!ssoCfg.tokenEndpoint &&
339 !!ssoCfg.userinfoEndpoint &&
340 !!ssoCfg.clientId &&
341 !!ssoCfg.clientSecret;
fc0ca99Claude342 const ssoLabel =
343 ssoCfg?.providerName || inferSsoProviderName(ssoCfg) || "SSO";
46d6165Claude344 // Block L6 — "Sign in with GitHub" (separate row keyed id='github').
345 const githubCfg = await getGithubOauthConfig();
346 const githubEnabled =
347 !!githubCfg?.enabled && !!githubCfg.clientId && !!githubCfg.clientSecret;
582cdacClaude348 // "Sign in with Google" (separate row keyed id='google'). Same wiring
349 // pattern as GitHub OAuth.
350 const googleCfg = await getGoogleOauthConfig();
351 const googleEnabled =
352 !!googleCfg?.enabled && !!googleCfg.clientId && !!googleCfg.clientSecret;
134750bClaude353 const csrf = c.get("csrfToken") as string | undefined;
06d5ffeClaude354 return c.html(
36cc17aClaude355 <Layout title="Sign in" user={null}>
ebe6d64Claude356 <AuthMobileStyle />
06d5ffeClaude357 <div class="auth-container">
98f45b4Claude358 <h2>Welcome back</h2>
359 <p class="auth-subtitle">
360 Sign in to your gluecron account.
361 </p>
06d5ffeClaude362 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
c63b860Claude363 {success && (
364 <div class="auth-success">{decodeURIComponent(success)}</div>
365 )}
0316dbbClaude366 <Form
b9968e3Claude367 method="post"
06d5ffeClaude368 action={`/login${redirect ? `?redirect=${encodeURIComponent(redirect)}` : ""}`}
134750bClaude369 csrfToken={csrf}
06d5ffeClaude370 >
bb0f894Claude371 <FormGroup label="Username or email" htmlFor="username">
372 <Input
06d5ffeClaude373 type="text"
374 name="username"
375 required
376 placeholder="username or email"
377 autocomplete="username"
2c3ba6ecopilot-swe-agent[bot]378 aria-label="Username or email"
06d5ffeClaude379 />
bb0f894Claude380 </FormGroup>
381 <FormGroup label="Password" htmlFor="password">
382 <Input
06d5ffeClaude383 type="password"
384 name="password"
385 required
386 placeholder="Password"
387 autocomplete="current-password"
2c3ba6ecopilot-swe-agent[bot]388 aria-label="Password"
06d5ffeClaude389 />
bb0f894Claude390 </FormGroup>
c63b860Claude391 <div class="auth-forgot" style="margin:-8px 0 12px;text-align:right;font-size:13px">
392 <a href="/forgot-password">Forgot password?</a>
cd4f63bTest User393 {/* BLOCK Q2 — magic-link sign-in. */}
394 <span style="margin:0 6px;color:var(--text-muted)">·</span>
395 <a href="/login/magic">Sign in with a magic link instead</a>
c63b860Claude396 </div>
bb0f894Claude397 <Button type="submit" variant="primary">
06d5ffeClaude398 Sign in
bb0f894Claude399 </Button>
400 </Form>
582cdacClaude401 {/* Provider buttons (Google + GitHub) — only rendered when the
402 admin has configured + enabled them via /admin/google-oauth or
403 /admin/github-oauth. The "or" divider above the first available
404 provider sets the visual break from the password form. */}
405 {(googleEnabled || githubEnabled) && (
406 <div class="auth-divider">or</div>
407 )}
408 {googleEnabled && (
409 <a
410 href="/login/google"
411 class="btn btn-block oauth-btn oauth-google"
412 aria-label="Sign in with Google"
413 >
414 <svg
415 class="oauth-icon"
416 width="18"
417 height="18"
418 viewBox="0 0 18 18"
419 aria-hidden="true"
420 >
421 <path
422 d="M17.64 9.2c0-.637-.057-1.251-.164-1.84H9v3.481h4.844a4.14 4.14 0 0 1-1.796 2.716v2.259h2.908c1.702-1.567 2.684-3.875 2.684-6.615z"
423 fill="#4285F4"
424 />
425 <path
426 d="M9 18c2.43 0 4.467-.806 5.956-2.18l-2.908-2.259c-.806.54-1.837.86-3.048.86-2.344 0-4.328-1.584-5.036-3.711H.957v2.332A8.997 8.997 0 0 0 9 18z"
427 fill="#34A853"
428 />
429 <path
430 d="M3.964 10.71A5.41 5.41 0 0 1 3.682 9c0-.593.102-1.17.282-1.71V4.958H.957A8.996 8.996 0 0 0 0 9c0 1.452.348 2.827.957 4.042l3.007-2.332z"
431 fill="#FBBC05"
432 />
433 <path
434 d="M9 3.58c1.321 0 2.508.454 3.44 1.345l2.582-2.58C13.463.891 11.426 0 9 0A8.997 8.997 0 0 0 .957 4.958L3.964 7.29C4.672 5.163 6.656 3.58 9 3.58z"
435 fill="#EA4335"
436 />
437 </svg>
438 <span>Sign in with Google</span>
439 </a>
440 )}
46d6165Claude441 {githubEnabled && (
582cdacClaude442 <a
443 href="/login/github"
444 class="btn btn-block oauth-btn oauth-github"
445 aria-label="Sign in with GitHub"
446 style={googleEnabled ? "margin-top:8px" : undefined}
447 >
448 <svg
449 class="oauth-icon"
450 width="18"
451 height="18"
452 viewBox="0 0 16 16"
453 aria-hidden="true"
454 fill="currentColor"
455 >
456 <path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0 0 16 8c0-4.42-3.58-8-8-8z" />
457 </svg>
458 <span>Sign in with GitHub</span>
459 </a>
46d6165Claude460 )}
09be7bfClaude461 {ssoEnabled && (
582cdacClaude462 <a
463 href="/login/sso"
464 class="btn btn-block oauth-btn oauth-sso"
465 aria-label={`Sign in with ${ssoLabel}`}
466 style={(googleEnabled || githubEnabled) ? "margin-top:8px" : undefined}
467 >
468 <span>Sign in with {ssoLabel}</span>
469 </a>
09be7bfClaude470 )}
fa06ad2Claude471 <div class="auth-passkey">
472 <div class="auth-divider">or</div>
473 <button type="button" id="pk-signin-btn" class="btn">
474 Sign in with passkey
475 </button>
476 <div
477 id="pk-signin-status"
478 class="auth-status"
479 aria-live="polite"
480 ></div>
481 </div>
06d5ffeClaude482 <p class="auth-switch">
bb0f894Claude483 <Text>New to gluecron? <a href="/register">Create an account</a></Text>
06d5ffeClaude484 </p>
2df1f8cClaude485 <script
486 dangerouslySetInnerHTML={{
487 __html: /* js */ `
488 (function () {
489 const btn = document.getElementById('pk-signin-btn');
490 const status = document.getElementById('pk-signin-status');
491 const userInput = document.getElementById('username');
492 const redirect = ${JSON.stringify(redirect || "/")};
493 if (!btn) return;
494 function b64uToBuf(s) {
495 s = s.replace(/-/g,'+').replace(/_/g,'/');
496 while (s.length % 4) s += '=';
497 const bin = atob(s);
498 const buf = new Uint8Array(bin.length);
499 for (let i=0;i<bin.length;i++) buf[i] = bin.charCodeAt(i);
500 return buf.buffer;
501 }
502 function bufToB64u(buf) {
503 const bytes = new Uint8Array(buf);
504 let bin = '';
505 for (let i=0;i<bytes.length;i++) bin += String.fromCharCode(bytes[i]);
506 return btoa(bin).replace(/\\+/g,'-').replace(/\\//g,'_').replace(/=+$/,'');
507 }
508 btn.addEventListener('click', async function () {
509 if (!window.PublicKeyCredential) {
510 status.textContent = 'Passkeys not supported in this browser.';
511 return;
512 }
513 status.textContent = 'Preparing…';
514 try {
515 const username = (userInput && userInput.value || '').trim();
516 const optsRes = await fetch('/api/passkeys/auth/options', {
517 method: 'POST',
518 headers: { 'content-type': 'application/json' },
519 body: JSON.stringify(username ? { username: username } : {})
520 });
521 if (!optsRes.ok) throw new Error('options failed');
522 const { options, sessionKey } = await optsRes.json();
523 options.challenge = b64uToBuf(options.challenge);
524 if (options.allowCredentials) {
525 options.allowCredentials = options.allowCredentials.map(function (c) {
526 return Object.assign({}, c, { id: b64uToBuf(c.id) });
527 });
528 }
529 status.textContent = 'Touch your authenticator…';
530 const cred = await navigator.credentials.get({ publicKey: options });
531 const resp = {
532 id: cred.id,
533 rawId: bufToB64u(cred.rawId),
534 type: cred.type,
535 response: {
536 clientDataJSON: bufToB64u(cred.response.clientDataJSON),
537 authenticatorData: bufToB64u(cred.response.authenticatorData),
538 signature: bufToB64u(cred.response.signature),
539 userHandle: cred.response.userHandle ? bufToB64u(cred.response.userHandle) : null
540 },
541 clientExtensionResults: cred.getClientExtensionResults ? cred.getClientExtensionResults() : {}
542 };
543 const verifyRes = await fetch('/api/passkeys/auth/verify', {
544 method: 'POST',
545 headers: { 'content-type': 'application/json' },
546 body: JSON.stringify({ sessionKey: sessionKey, response: resp })
547 });
548 if (!verifyRes.ok) {
549 const j = await verifyRes.json().catch(function () { return {}; });
550 throw new Error(j.error || 'verify failed');
551 }
552 status.textContent = 'Signed in. Redirecting…';
553 window.location.href = redirect;
554 } catch (e) {
555 status.textContent = 'Error: ' + (e && e.message ? e.message : e);
556 }
557 });
558 })();
559 `,
560 }}
561 />
06d5ffeClaude562 </div>
563 </Layout>
564 );
565});
566
ba9e143Claude567// ── Account lockout constants (SOC 2 CC6.1) ─────────────────────────────
568const LOGIN_FAIL_WINDOW_MS = 60 * 60 * 1000; // 1 hour
569const LOGIN_FAIL_LIMIT = 10;
570const LOGIN_LOCKOUT_MS = 15 * 60 * 1000; // 15 minutes
571
572/**
573 * Returns the number of failed login attempts for `email` in the last
574 * `LOGIN_FAIL_WINDOW_MS` milliseconds.
575 */
576async function countRecentFailures(email: string): Promise<number> {
577 const since = new Date(Date.now() - LOGIN_FAIL_WINDOW_MS);
578 const [row] = await db
579 .select({ count: sql<number>`count(*)::int` })
580 .from(loginAttempts)
581 .where(
582 and(
583 eq(loginAttempts.email, email.toLowerCase()),
584 eq(loginAttempts.success, false),
585 gte(loginAttempts.createdAt, since)
586 )
587 );
588 return row?.count ?? 0;
589}
590
06d5ffeClaude591auth.post("/login", async (c) => {
592 const body = await c.req.parseBody();
593 const identifier = String(body.username || "").trim();
594 const password = String(body.password || "");
595 const redirect = c.req.query("redirect") || "/";
ba9e143Claude596 const ip =
597 c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ||
598 c.req.header("x-real-ip") ||
599 "unknown";
600 const ua = c.req.header("user-agent") || "";
06d5ffeClaude601
602 if (!identifier || !password) {
603 return c.redirect("/login?error=All+fields+are+required");
604 }
605
ba9e143Claude606 // Resolve the canonical email for lockout checks regardless of whether
607 // the user typed username or email.
06d5ffeClaude608 const isEmail = identifier.includes("@");
609 const [user] = await db
610 .select()
611 .from(users)
612 .where(
613 isEmail
614 ? eq(users.email, identifier)
615 : eq(users.username, identifier)
616 )
617 .limit(1);
618
ba9e143Claude619 // Determine the email key for lockout (use identifier if user not found
620 // so we still record the attempt without leaking account existence).
621 const emailKey = (user?.email ?? identifier).toLowerCase();
622
623 // ── Lockout check ───────────────────────────────────────────────────
624 // Check whether this email is currently locked out (≥ LOGIN_FAIL_LIMIT
625 // failures in the last LOGIN_FAIL_WINDOW_MS). We check before password
626 // verification so brute-forcers can't time-diff their way around it.
627 const recentFailures = await countRecentFailures(emailKey);
628 if (recentFailures >= LOGIN_FAIL_LIMIT) {
629 // Record that we blocked this attempt (success=false) so the window
630 // keeps rolling while the attacker keeps trying.
631 await db
632 .insert(loginAttempts)
633 .values({ email: emailKey, ip, success: false })
634 .catch(() => {});
635 await audit({
636 userId: user?.id ?? null,
637 action: "auth.login.locked",
638 ip,
639 userAgent: ua,
640 metadata: { email: emailKey, recentFailures },
641 });
642 return c.redirect(
643 "/login?error=Account+temporarily+locked+due+to+too+many+failed+login+attempts.+Please+try+again+in+15+minutes."
644 );
645 }
646
06d5ffeClaude647 if (!user) {
ba9e143Claude648 // Record failed attempt (unknown user) and return generic error.
649 await db
650 .insert(loginAttempts)
651 .values({ email: emailKey, ip, success: false })
652 .catch(() => {});
06d5ffeClaude653 return c.redirect("/login?error=Invalid+credentials");
654 }
655
656 const valid = await verifyPassword(password, user.passwordHash);
657 if (!valid) {
ba9e143Claude658 // Record failed attempt.
659 await db
660 .insert(loginAttempts)
661 .values({ email: emailKey, ip, success: false })
662 .catch(() => {});
663 await audit({
664 userId: user.id,
665 action: "auth.login.failed",
666 ip,
667 userAgent: ua,
668 metadata: { email: emailKey, attempt: recentFailures + 1 },
669 });
670 // Check if this failure just crossed the threshold.
671 if (recentFailures + 1 >= LOGIN_FAIL_LIMIT) {
672 await audit({
673 userId: user.id,
674 action: "auth.login.locked",
675 ip,
676 userAgent: ua,
677 metadata: { email: emailKey, recentFailures: recentFailures + 1 },
678 });
679 return c.redirect(
680 "/login?error=Account+temporarily+locked+due+to+too+many+failed+login+attempts.+Please+try+again+in+15+minutes."
681 );
682 }
06d5ffeClaude683 return c.redirect("/login?error=Invalid+credentials");
684 }
685
ba9e143Claude686 // Successful login — record success and clear old failure window.
687 await db
688 .insert(loginAttempts)
689 .values({ email: emailKey, ip, success: true })
690 .catch(() => {});
691
7298a17Claude692 // B4: if the user has TOTP enabled, issue a pending-2fa session and
693 // redirect to the code prompt.
694 const [totp] = await db
695 .select({ enabledAt: userTotp.enabledAt })
696 .from(userTotp)
697 .where(eq(userTotp.userId, user.id))
698 .limit(1);
699 const needs2fa = !!(totp && totp.enabledAt);
700
06d5ffeClaude701 const token = generateSessionToken();
702 await db.insert(sessions).values({
703 userId: user.id,
704 token,
705 expiresAt: sessionExpiry(),
7298a17Claude706 requires2fa: needs2fa,
ba9e143Claude707 ip,
708 userAgent: ua,
709 lastSeenAt: new Date(),
06d5ffeClaude710 });
711
712 setCookie(c, "session", token, sessionCookieOptions());
c63b860Claude713
714 // Block P5 — If account was scheduled for deletion but user signed back
715 // in, cancel the deletion. Safe regardless of 2FA: password was proven.
716 if (user.deletedAt) {
717 await cancelAccountDeletion(user.id);
718 }
719
7298a17Claude720 if (needs2fa) {
721 return c.redirect(
722 `/login/2fa?redirect=${encodeURIComponent(redirect)}`
723 );
724 }
06d5ffeClaude725 return c.redirect(redirect);
726});
727
7298a17Claude728// --- 2FA verify (B4) ---
729auth.get("/login/2fa", async (c) => {
730 const token = getCookie(c, "session");
731 if (!token) return c.redirect("/login");
732 const error = c.req.query("error");
733 const redirect = c.req.query("redirect") || "/";
734 return c.html(
36cc17aClaude735 <Layout title="Two-factor authentication" user={null}>
ebe6d64Claude736 <AuthMobileStyle />
7298a17Claude737 <div class="auth-container">
738 <h2>Enter your code</h2>
739 <p
740 class="auth-switch"
741 style="margin-bottom: 16px; margin-top: 0"
742 >
743 Open your authenticator app and enter the 6-digit code. Lost your
744 device? Paste a recovery code instead.
745 </p>
746 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
747 <form
b9968e3Claude748 method="post"
7298a17Claude749 action={`/login/2fa?redirect=${encodeURIComponent(redirect)}`}
750 >
134750bClaude751 <input type="hidden" name="_csrf" value={(c.get("csrfToken") as string | undefined) || ""} />
7298a17Claude752 <div class="form-group">
753 <label for="code">Code</label>
754 <input
755 type="text"
756 id="code"
757 name="code"
758 required
759 autocomplete="one-time-code"
760 inputmode="numeric"
761 maxLength={24}
762 placeholder="123456 or xxxx-xxxx-xxxx"
763 />
764 </div>
765 <button type="submit" class="btn btn-primary">
766 Verify
767 </button>
768 </form>
769 <p class="auth-switch">
770 <a href="/logout">Cancel</a>
771 </p>
772 </div>
773 </Layout>
774 );
775});
776
777auth.post("/login/2fa", async (c) => {
778 const token = getCookie(c, "session");
779 if (!token) return c.redirect("/login");
780 const body = await c.req.parseBody();
781 const code = String(body.code || "").trim();
782 const redirect = c.req.query("redirect") || "/";
783
784 if (!code) {
785 return c.redirect(
786 `/login/2fa?error=Code+is+required&redirect=${encodeURIComponent(redirect)}`
787 );
788 }
789
790 try {
791 const [session] = await db
792 .select()
793 .from(sessions)
794 .where(eq(sessions.token, token))
795 .limit(1);
796 if (
797 !session ||
798 new Date(session.expiresAt) < new Date() ||
799 !session.requires2fa
800 ) {
801 return c.redirect("/login");
802 }
803
804 const [totp] = await db
805 .select()
806 .from(userTotp)
807 .where(eq(userTotp.userId, session.userId))
808 .limit(1);
809 if (!totp || !totp.enabledAt) {
810 // User doesn't have 2FA actually enabled — clear the flag and let
811 // them in. This can only happen if 2FA was disabled in another
812 // session between password check and code prompt.
813 await db
814 .update(sessions)
815 .set({ requires2fa: false })
816 .where(eq(sessions.token, token));
817 return c.redirect(redirect);
818 }
819
820 // Try TOTP code first.
821 const isSix = /^\d{6}$/.test(code);
822 let ok = false;
823 if (isSix) {
824 ok = await verifyTotpCode(totp.secret, code);
825 }
826 // Fall through to recovery code.
827 if (!ok) {
828 const hash = await hashRecoveryCode(code);
829 const [rec] = await db
830 .select()
831 .from(userRecoveryCodes)
832 .where(
833 and(
834 eq(userRecoveryCodes.userId, session.userId),
835 eq(userRecoveryCodes.codeHash, hash),
836 isNull(userRecoveryCodes.usedAt)
837 )
838 )
839 .limit(1);
840 if (rec) {
841 await db
842 .update(userRecoveryCodes)
843 .set({ usedAt: new Date() })
844 .where(eq(userRecoveryCodes.id, rec.id));
845 ok = true;
846 }
847 }
848
849 if (!ok) {
850 return c.redirect(
851 `/login/2fa?error=Invalid+code&redirect=${encodeURIComponent(redirect)}`
852 );
853 }
854
855 await db
856 .update(sessions)
857 .set({ requires2fa: false })
858 .where(eq(sessions.token, token));
859 await db
860 .update(userTotp)
861 .set({ lastUsedAt: new Date() })
862 .where(eq(userTotp.userId, session.userId));
863
864 return c.redirect(redirect);
865 } catch (err) {
866 console.error("[auth] 2fa verify:", err);
867 return c.redirect(
868 `/login/2fa?error=Service+unavailable&redirect=${encodeURIComponent(redirect)}`
869 );
870 }
871});
872
06d5ffeClaude873auth.get("/logout", async (c) => {
874 deleteCookie(c, "session", { path: "/" });
875 return c.redirect("/");
876});
877
878// --- API ---
879
880auth.post("/api/auth/register", async (c) => {
881 const body = await c.req.json<{
882 username: string;
883 email: string;
884 password: string;
885 }>();
886
887 if (!body.username || !body.email || !body.password) {
888 return c.json({ error: "username, email, and password are required" }, 400);
889 }
890
891 if (!/^[a-zA-Z0-9_-]+$/.test(body.username)) {
892 return c.json({ error: "Invalid username" }, 400);
893 }
894
895 if (body.password.length < 8) {
896 return c.json({ error: "Password must be at least 8 characters" }, 400);
897 }
898
899 const [existing] = await db
900 .select()
901 .from(users)
902 .where(eq(users.username, body.username))
903 .limit(1);
904 if (existing) {
905 return c.json({ error: "Username already taken" }, 409);
906 }
907
908 const passwordHash = await hashPassword(body.password);
909 const [user] = await db
910 .insert(users)
911 .values({
912 username: body.username,
913 email: body.email,
914 passwordHash,
915 })
916 .returning();
917
918 const token = generateSessionToken();
919 await db.insert(sessions).values({
920 userId: user.id,
921 token,
922 expiresAt: sessionExpiry(),
923 });
924
925 return c.json(
926 {
927 user: { id: user.id, username: user.username, email: user.email },
928 token,
929 },
930 201
931 );
932});
933
934auth.post("/api/auth/login", async (c) => {
935 const body = await c.req.json<{ username: string; password: string }>();
936
937 if (!body.username || !body.password) {
938 return c.json({ error: "username and password are required" }, 400);
939 }
940
941 const isEmail = body.username.includes("@");
942 const [user] = await db
943 .select()
944 .from(users)
945 .where(
946 isEmail
947 ? eq(users.email, body.username)
948 : eq(users.username, body.username)
949 )
950 .limit(1);
951
952 if (!user) return c.json({ error: "Invalid credentials" }, 401);
953
954 const valid = await verifyPassword(body.password, user.passwordHash);
955 if (!valid) return c.json({ error: "Invalid credentials" }, 401);
956
957 const token = generateSessionToken();
958 await db.insert(sessions).values({
959 userId: user.id,
960 token,
961 expiresAt: sessionExpiry(),
962 });
963
964 return c.json({
965 user: { id: user.id, username: user.username, email: user.email },
966 token,
967 });
968});
969
fc0ca99Claude970/**
971 * Pick a friendly provider name for the "Sign in with X" button when the
972 * admin hasn't set one explicitly. Looks at the configured IdP URLs.
973 * Falls back to undefined so the caller can default to a literal "SSO".
974 */
975function inferSsoProviderName(
976 cfg: { issuer?: string | null; authorizationEndpoint?: string | null } | null | undefined
977): string | undefined {
978 const urls = [cfg?.issuer, cfg?.authorizationEndpoint]
979 .filter((s): s is string => !!s)
980 .join(" ")
981 .toLowerCase();
982 if (!urls) return undefined;
983 if (urls.includes("google")) return "Google";
984 if (urls.includes("okta")) return "Okta";
985 if (urls.includes("microsoftonline") || urls.includes("azure")) return "Microsoft";
986 if (urls.includes("auth0.com")) return "Auth0";
987 if (urls.includes("authentik")) return "Authentik";
988 return undefined;
989}
990
06d5ffeClaude991export default auth;