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.tsxBlame1039 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,
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";
06d5ffeClaude40import { Layout } from "../views/layout";
cf21786ccanty labs41import { SignInV2 } from "../views/signin-v2";
bb0f894Claude42import {
43 Form,
44 FormGroup,
45 Input,
46 Button,
09be7bfClaude47 LinkButton,
bb0f894Claude48 Alert,
49 Text,
50} from "../views/ui";
36cc17aClaude51import { softAuth } from "../middleware/auth";
06d5ffeClaude52import type { AuthEnv } from "../middleware/auth";
53
54const auth = new Hono<AuthEnv>();
55
976d7f7Claude56// One-shot latch — log the auto-verify warning at most once per process,
57// since the misconfiguration is operator-level (env var) and won't change
58// between requests.
59let _autoVerifyWarned = false;
60
ebe6d64Claude61// ───────────────────────────────────────────────────────────────────────
62// Scoped mobile polish — tightens the existing `.auth-container` shell
63// from layout.tsx for ≤720px viewports. Only adds rules; does not
64// redefine the desktop styling. Kept inline so this file remains the
65// single source of truth for the auth surface.
66// ───────────────────────────────────────────────────────────────────────
67const authMobileCss = `
68 @media (max-width: 720px) {
69 .auth-container {
70 margin: 24px 12px;
71 padding: 24px 20px 22px;
72 max-width: 100%;
73 }
74 .auth-container .btn-primary { min-height: 44px; }
75 .auth-container .oauth-btn { min-height: 44px; }
76 .auth-container input[type="text"],
77 .auth-container input[type="email"],
78 .auth-container input[type="password"] { min-height: 44px; }
79 .auth-forgot { text-align: left !important; }
80 }
81`;
82const AuthMobileStyle = () => (
83 <style dangerouslySetInnerHTML={{ __html: authMobileCss }} />
84);
85
06d5ffeClaude86// --- Web UI ---
87
36cc17aClaude88auth.get("/register", softAuth, (c) => {
89 // If the user is already signed in, drop them on their dashboard rather
90 // than rendering the logged-out sign-up shell over an authed session.
91 const existing = c.get("user");
92 if (existing) return c.redirect("/dashboard");
06d5ffeClaude93 const error = c.req.query("error");
134750bClaude94 const csrf = c.get("csrfToken") as string | undefined;
06d5ffeClaude95 return c.html(
36cc17aClaude96 <Layout title="Register" user={null}>
ebe6d64Claude97 <AuthMobileStyle />
06d5ffeClaude98 <div class="auth-container">
98f45b4Claude99 <h2>Create your account</h2>
100 <p class="auth-subtitle">
101 Get the full AI suite — code review, auto-merge, spec-to-PR — on
102 unlimited public repos. No credit card.
103 </p>
06d5ffeClaude104 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
134750bClaude105 <Form method="post" action="/register" csrfToken={csrf}>
0316dbbClaude106 <FormGroup label="Username" htmlFor="username">
107 <Input
108 id="username"
06d5ffeClaude109 type="text"
110 name="username"
111 required
112 pattern="^[a-zA-Z0-9_-]+$"
113 minLength={2}
114 maxLength={39}
115 placeholder="your-username"
116 autocomplete="username"
117 />
bb0f894Claude118 </FormGroup>
119 <FormGroup label="Email" htmlFor="email">
120 <Input
06d5ffeClaude121 type="email"
122 name="email"
123 required
124 placeholder="you@example.com"
125 autocomplete="email"
2c3ba6ecopilot-swe-agent[bot]126 aria-label="Email"
06d5ffeClaude127 />
bb0f894Claude128 </FormGroup>
129 <FormGroup label="Password" htmlFor="password">
130 <Input
06d5ffeClaude131 type="password"
132 name="password"
133 required
134 minLength={8}
135 placeholder="Min 8 characters"
136 autocomplete="new-password"
2c3ba6ecopilot-swe-agent[bot]137 aria-label="Password"
06d5ffeClaude138 />
bb0f894Claude139 </FormGroup>
c63b860Claude140 {/* P3 — Terms / Privacy acceptance. Required client-side via the
141 `required` attribute; server-side re-checked in POST handler. */}
142 <div class="form-group" style="margin: 12px 0">
143 <label style="display: flex; gap: 8px; align-items: flex-start; font-size: 13px; color: var(--text-muted)">
144 <input
145 type="checkbox"
146 name="accept_terms"
147 value="1"
148 required
149 style="margin-top: 3px"
150 aria-label="Accept Terms of Service and Privacy Policy"
151 />
152 <span>
153 I agree to the{" "}
2e8a4d5Claude154 <a href="/terms" target="_blank" rel="noopener">
c63b860Claude155 Terms of Service
156 </a>{" "}
157 and{" "}
2e8a4d5Claude158 <a href="/privacy" target="_blank" rel="noopener">
c63b860Claude159 Privacy Policy
160 </a>
161 .
162 </span>
163 </label>
164 </div>
bb0f894Claude165 <Button type="submit" variant="primary">
06d5ffeClaude166 Create account
bb0f894Claude167 </Button>
168 </Form>
06d5ffeClaude169 <p class="auth-switch">
bb0f894Claude170 <Text>Already have an account? <a href="/login">Sign in</a></Text>
06d5ffeClaude171 </p>
172 </div>
173 </Layout>
174 );
175});
176
177auth.post("/register", async (c) => {
178 const body = await c.req.parseBody();
179 const username = String(body.username || "").trim();
180 const email = String(body.email || "").trim();
181 const password = String(body.password || "");
182
183 if (!username || !email || !password) {
184 return c.redirect("/register?error=All+fields+are+required");
185 }
186
c63b860Claude187 // Block P3 — Terms acceptance is required. The form's checkbox has
188 // `required` so browsers normally enforce client-side; the server
189 // re-checks for defensive depth (curl, scripted POST, etc.).
190 if (!body.accept_terms) {
191 return c.redirect(
192 "/register?error=Please+accept+the+Terms+of+Service+and+Privacy+Policy"
193 );
194 }
195
06d5ffeClaude196 if (!/^[a-zA-Z0-9_-]+$/.test(username)) {
197 return c.redirect(
198 "/register?error=Username+may+only+contain+letters%2C+numbers%2C+hyphens+and+underscores"
199 );
200 }
201
202 if (password.length < 8) {
203 return c.redirect("/register?error=Password+must+be+at+least+8+characters");
204 }
205
206 // Check existing
207 const [existingUser] = await db
208 .select()
209 .from(users)
210 .where(eq(users.username, username))
211 .limit(1);
212 if (existingUser) {
213 return c.redirect("/register?error=Username+already+taken");
214 }
215
7437605Claude216 // B2: usernames share the URL namespace with org slugs; refuse collisions.
217 const [existingOrg] = await db
218 .select({ id: organizations.id })
219 .from(organizations)
220 .where(eq(organizations.slug, username.toLowerCase()))
221 .limit(1);
222 if (existingOrg) {
223 return c.redirect("/register?error=Username+already+taken");
224 }
225
06d5ffeClaude226 const [existingEmail] = await db
227 .select()
228 .from(users)
229 .where(eq(users.email, email))
230 .limit(1);
231 if (existingEmail) {
232 return c.redirect("/register?error=Email+already+registered");
233 }
234
235 const passwordHash = await hashPassword(password);
236
a4f3e24Claude237 // First user ever registered becomes admin automatically
238 const [userCount] = await db
239 .select({ count: sql`count(*)::int` })
240 .from(users);
241 const isFirstUser = (userCount?.count as number) === 0;
242
06d5ffeClaude243 const [user] = await db
244 .insert(users)
c63b860Claude245 .values({
246 username,
247 email,
248 passwordHash,
249 isAdmin: isFirstUser,
250 // P3 — record terms acceptance now. Version bumps when Terms change.
251 termsAcceptedAt: new Date(),
252 termsVersion: "1.0",
253 })
06d5ffeClaude254 .returning();
255
2d985e5Claude256 // If username matches SITE_ADMIN_USERNAME env, grant site admin instantly
257 // so the operator doesn't have to wait for the next boot's bootstrap pass.
a28cedeClaude258 await import("../lib/admin-bootstrap")
259 .then((m) => m.ensureEnvAdminOnRegister({ userId: user.id, username }))
260 .catch((err) => {
261 console.warn(
262 `[admin-bootstrap] ensureEnvAdminOnRegister failed for ${username}:`,
263 err instanceof Error ? err.message : err
264 );
265 });
2d985e5Claude266
06d5ffeClaude267 // Create session
268 const token = generateSessionToken();
269 await db.insert(sessions).values({
270 userId: user.id,
271 token,
272 expiresAt: sessionExpiry(),
273 });
274
275 setCookie(c, "session", token, sessionCookieOptions());
276
976d7f7Claude277 // Block P2 — email verification. If RESEND_API_KEY is configured the
278 // verification email goes out and the user clicks the link to verify.
279 // If email is NOT configured (EMAIL_PROVIDER=log, no RESEND_API_KEY,
280 // etc.), the email would silently never arrive and the user would be
281 // locked out — AUDIT-v2.md P0 #3. In that case, auto-verify the
282 // account on registration so the user can actually use the site.
283 // Operators who want real verification should set EMAIL_PROVIDER=resend
284 // + RESEND_API_KEY in their environment.
285 const { config: _emailConfig } = await import("../lib/config");
286 const emailConfigured =
287 _emailConfig.emailProvider === "resend" && !!_emailConfig.resendApiKey;
288 if (emailConfigured) {
289 import("../lib/email-verification")
290 .then((m) => m.startEmailVerification(user.id, email))
291 .catch((err) => {
292 console.error(
293 `[auth] startEmailVerification failed for ${user.id}:`,
294 err instanceof Error ? err.message : err
295 );
296 });
297 } else {
298 // Auto-verify immediately so the user isn't trapped in an unverified
299 // state. Log once so operators notice the misconfiguration.
300 if (!_autoVerifyWarned) {
301 _autoVerifyWarned = true;
302 console.warn(
303 "[auth] EMAIL_PROVIDER is not configured (set EMAIL_PROVIDER=resend + RESEND_API_KEY). Auto-verifying new account email addresses to avoid lockout."
304 );
305 }
306 await db
307 .update(users)
308 .set({ emailVerifiedAt: new Date() })
309 .where(eq(users.id, user.id))
310 .catch((err) => {
311 console.error(
312 `[auth] auto-verify failed for ${user.id}:`,
313 err instanceof Error ? err.message : err
314 );
315 });
316 }
c63b860Claude317
f65f600Claude318 // Onboarding drip — T+0 "welcome" email. Fire-and-forget; never blocks
319 // the redirect. Silently skips when email is not configured.
320 import("../lib/onboarding-drip")
321 .then((m) => m.sendWelcomeEmail(user.id))
322 .catch((err) => {
323 console.error(
324 `[auth] onboarding welcome email failed for ${user.id}:`,
325 err instanceof Error ? err.message : err
326 );
327 });
328
c63b860Claude329 // P3 — default landing is /onboarding (the guided first-five-minutes
330 // flow). The `redirect=` query is still honoured for OAuth-style flows.
331 const redirect = c.req.query("redirect") || "/onboarding?welcome=1";
06d5ffeClaude332 return c.redirect(redirect);
333});
334
36cc17aClaude335auth.get("/login", softAuth, async (c) => {
336 // Already-authed users hitting the sign-in page get bounced to their
337 // dashboard (or the `redirect=` target if one was supplied).
338 const existing = c.get("user");
06d5ffeClaude339 const error = c.req.query("error");
c63b860Claude340 const success = c.req.query("success");
06d5ffeClaude341 const redirect = c.req.query("redirect") || "";
36cc17aClaude342 if (existing) return c.redirect(redirect || "/dashboard");
edf7c36Claude343 const ssoCfg = await getSsoConfig();
344 const ssoEnabled =
345 !!ssoCfg?.enabled &&
346 !!ssoCfg.authorizationEndpoint &&
347 !!ssoCfg.tokenEndpoint &&
348 !!ssoCfg.userinfoEndpoint &&
349 !!ssoCfg.clientId &&
350 !!ssoCfg.clientSecret;
fc0ca99Claude351 const ssoLabel =
352 ssoCfg?.providerName || inferSsoProviderName(ssoCfg) || "SSO";
46d6165Claude353 // Block L6 — "Sign in with GitHub" (separate row keyed id='github').
354 const githubCfg = await getGithubOauthConfig();
355 const githubEnabled =
356 !!githubCfg?.enabled && !!githubCfg.clientId && !!githubCfg.clientSecret;
582cdacClaude357 // "Sign in with Google" (separate row keyed id='google'). Same wiring
358 // pattern as GitHub OAuth.
359 const googleCfg = await getGoogleOauthConfig();
360 const googleEnabled =
361 !!googleCfg?.enabled && !!googleCfg.clientId && !!googleCfg.clientSecret;
134750bClaude362 const csrf = c.get("csrfToken") as string | undefined;
06d5ffeClaude363 return c.html(
cf21786ccanty labs364 <SignInV2
3a845e4Claude365 <script dangerouslySetInnerHTML={{__html: /* js */`
366 (function(){
367 var inp = document.querySelector('input[name="username"]');
368 var hint = document.getElementById('sso-domain-hint');
369 if(!inp || !hint) return;
370 var last = '';
371 inp.addEventListener('input', function(){
372 var val = inp.value.trim();
373 var at = val.indexOf('@');
374 if(at < 0) { hint.style.display='none'; return; }
375 var domain = val.slice(at+1).toLowerCase();
376 if(!domain || domain === last) return;
377 last = domain;
378 fetch('/api/sso/domain-hint?domain='+encodeURIComponent(domain))
379 .then(function(r){ return r.ok ? r.json() : null; })
380 .then(function(d){ hint.style.display = (d && d.sso) ? '' : 'none'; })
381 .catch(function(){ hint.style.display='none'; });
382 });
383 })();
384 `}} />
582cdacClaude385 {/* Provider buttons (Google + GitHub) — only rendered when the
386 admin has configured + enabled them via /admin/google-oauth or
387 /admin/github-oauth. The "or" divider above the first available
388 provider sets the visual break from the password form. */}
389 {(googleEnabled || githubEnabled) && (
390 <div class="auth-divider">or</div>
391 )}
392 {googleEnabled && (
393 <a
394 href="/login/google"
395 class="btn btn-block oauth-btn oauth-google"
396 aria-label="Sign in with Google"
397 >
398 <svg
399 class="oauth-icon"
400 width="18"
401 height="18"
402 viewBox="0 0 18 18"
403 aria-hidden="true"
404 >
405 <path
406 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"
407 fill="#4285F4"
408 />
409 <path
410 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"
411 fill="#34A853"
412 />
413 <path
414 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"
415 fill="#FBBC05"
416 />
417 <path
418 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"
419 fill="#EA4335"
420 />
421 </svg>
422 <span>Sign in with Google</span>
423 </a>
424 )}
46d6165Claude425 {githubEnabled && (
582cdacClaude426 <a
427 href="/login/github"
428 class="btn btn-block oauth-btn oauth-github"
429 aria-label="Sign in with GitHub"
430 style={googleEnabled ? "margin-top:8px" : undefined}
431 >
432 <svg
433 class="oauth-icon"
434 width="18"
435 height="18"
436 viewBox="0 0 16 16"
437 aria-hidden="true"
438 fill="currentColor"
439 >
440 <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" />
441 </svg>
442 <span>Sign in with GitHub</span>
443 </a>
46d6165Claude444 )}
09be7bfClaude445 {ssoEnabled && (
582cdacClaude446 <a
447 href="/login/sso"
448 class="btn btn-block oauth-btn oauth-sso"
449 aria-label={`Sign in with ${ssoLabel}`}
450 style={(googleEnabled || githubEnabled) ? "margin-top:8px" : undefined}
451 >
452 <span>Sign in with {ssoLabel}</span>
453 </a>
09be7bfClaude454 )}
fa06ad2Claude455 <div class="auth-passkey">
456 <div class="auth-divider">or</div>
457 <button type="button" id="pk-signin-btn" class="btn">
458 Sign in with passkey
459 </button>
460 <div
461 id="pk-signin-status"
462 class="auth-status"
463 aria-live="polite"
464 ></div>
465 </div>
06d5ffeClaude466 <p class="auth-switch">
bb0f894Claude467 <Text>New to gluecron? <a href="/register">Create an account</a></Text>
06d5ffeClaude468 </p>
2df1f8cClaude469 <script
470 dangerouslySetInnerHTML={{
471 __html: /* js */ `
472 (function () {
473 const btn = document.getElementById('pk-signin-btn');
474 const status = document.getElementById('pk-signin-status');
475 const userInput = document.getElementById('username');
476 const redirect = ${JSON.stringify(redirect || "/")};
477 if (!btn) return;
478 function b64uToBuf(s) {
479 s = s.replace(/-/g,'+').replace(/_/g,'/');
480 while (s.length % 4) s += '=';
481 const bin = atob(s);
482 const buf = new Uint8Array(bin.length);
483 for (let i=0;i<bin.length;i++) buf[i] = bin.charCodeAt(i);
484 return buf.buffer;
485 }
486 function bufToB64u(buf) {
487 const bytes = new Uint8Array(buf);
488 let bin = '';
489 for (let i=0;i<bytes.length;i++) bin += String.fromCharCode(bytes[i]);
490 return btoa(bin).replace(/\\+/g,'-').replace(/\\//g,'_').replace(/=+$/,'');
491 }
492 btn.addEventListener('click', async function () {
493 if (!window.PublicKeyCredential) {
494 status.textContent = 'Passkeys not supported in this browser.';
495 return;
496 }
497 status.textContent = 'Preparing…';
498 try {
499 const username = (userInput && userInput.value || '').trim();
500 const optsRes = await fetch('/api/passkeys/auth/options', {
501 method: 'POST',
502 headers: { 'content-type': 'application/json' },
503 body: JSON.stringify(username ? { username: username } : {})
504 });
505 if (!optsRes.ok) throw new Error('options failed');
506 const { options, sessionKey } = await optsRes.json();
507 options.challenge = b64uToBuf(options.challenge);
508 if (options.allowCredentials) {
509 options.allowCredentials = options.allowCredentials.map(function (c) {
510 return Object.assign({}, c, { id: b64uToBuf(c.id) });
511 });
512 }
513 status.textContent = 'Touch your authenticator…';
514 const cred = await navigator.credentials.get({ publicKey: options });
515 const resp = {
516 id: cred.id,
517 rawId: bufToB64u(cred.rawId),
518 type: cred.type,
519 response: {
520 clientDataJSON: bufToB64u(cred.response.clientDataJSON),
521 authenticatorData: bufToB64u(cred.response.authenticatorData),
522 signature: bufToB64u(cred.response.signature),
523 userHandle: cred.response.userHandle ? bufToB64u(cred.response.userHandle) : null
524 },
525 clientExtensionResults: cred.getClientExtensionResults ? cred.getClientExtensionResults() : {}
526 };
527 const verifyRes = await fetch('/api/passkeys/auth/verify', {
528 method: 'POST',
529 headers: { 'content-type': 'application/json' },
530 body: JSON.stringify({ sessionKey: sessionKey, response: resp })
531 });
532 if (!verifyRes.ok) {
533 const j = await verifyRes.json().catch(function () { return {}; });
534 throw new Error(j.error || 'verify failed');
535 }
536 status.textContent = 'Signed in. Redirecting…';
537 window.location.href = redirect;
538 } catch (e) {
539 status.textContent = 'Error: ' + (e && e.message ? e.message : e);
cf21786ccanty labs540 redirect={redirect}
541 error={error ? decodeURIComponent(error) : ""}
542 />
06d5ffeClaude543 );
544});
545
ba9e143Claude546/**
27d5fd3Claude547 * Loads the failure aggregate for `email` and evaluates the lockout policy
548 * (see src/lib/login-lockout.ts for the semantics).
549 *
550 * Fails OPEN: if the `login_attempts` table is unreachable (missing
551 * migration, transient DB error) login must still work — a broken lockout
552 * ledger must never lock every user out of the site. That failure class
553 * is exactly what the 0087 migration blockade caused in production.
ba9e143Claude554 */
27d5fd3Claude555async function getLockoutState(email: string): Promise<LockoutState> {
556 try {
557 const since = new Date(Date.now() - LOGIN_FAIL_WINDOW_MS);
558 const [row] = await db
559 .select({
560 count: sql<number>`count(*)::int`,
561 newest: sql<string | null>`max(${loginAttempts.createdAt})`,
562 })
563 .from(loginAttempts)
564 .where(
565 and(
566 eq(loginAttempts.email, email.toLowerCase()),
567 eq(loginAttempts.success, false),
568 gte(loginAttempts.createdAt, since)
569 )
570 );
571 return evaluateLockout({
572 failureCount: row?.count ?? 0,
573 newestFailureAt: row?.newest ? new Date(row.newest) : null,
574 });
575 } catch (err) {
576 console.error(
577 "[auth] lockout check failed (failing open):",
578 err instanceof Error ? err.message : err
ba9e143Claude579 );
27d5fd3Claude580 return { locked: false, failureCount: 0, retryAfterMs: 0 };
581 }
ba9e143Claude582}
583
06d5ffeClaude584auth.post("/login", async (c) => {
585 const body = await c.req.parseBody();
586 const identifier = String(body.username || "").trim();
587 const password = String(body.password || "");
588 const redirect = c.req.query("redirect") || "/";
ba9e143Claude589 const ip =
590 c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ||
591 c.req.header("x-real-ip") ||
592 "unknown";
593 const ua = c.req.header("user-agent") || "";
06d5ffeClaude594
595 if (!identifier || !password) {
596 return c.redirect("/login?error=All+fields+are+required");
597 }
598
3a845e4Claude599 // Enterprise SSO domain-hint routing: if the identifier is an email and the
600 // domain matches an org's `domain_hint`, redirect to that org's SSO flow
601 // instead of checking the password.
602 // Also: resolve the canonical email for lockout checks regardless of whether
ba9e143Claude603 // the user typed username or email.
06d5ffeClaude604 const isEmail = identifier.includes("@");
3a845e4Claude605 if (isEmail) {
606 const emailDomain = identifier.split("@")[1]?.toLowerCase();
607 if (emailDomain) {
608 const [ssoHint] = await db
609 .select({
610 provider: orgSsoConfigs.provider,
611 orgId: orgSsoConfigs.orgId,
612 })
613 .from(orgSsoConfigs)
614 .where(eq(orgSsoConfigs.domainHint, emailDomain))
615 .limit(1);
616
617 if (ssoHint) {
618 // Resolve org slug from org ID
619 const [orgRow] = await db
620 .select({ slug: organizations.slug })
621 .from(organizations)
622 .where(eq(organizations.id, ssoHint.orgId))
623 .limit(1);
624
625 if (orgRow) {
626 const protocol = ssoHint.provider === "oidc" ? "oidc" : "saml";
627 return c.redirect(`/sso/${protocol}/${orgRow.slug}/login`);
628 }
629 }
630 }
631 }
632
633 // Find user by username or email
06d5ffeClaude634 const [user] = await db
635 .select()
636 .from(users)
637 .where(
638 isEmail
639 ? eq(users.email, identifier)
640 : eq(users.username, identifier)
641 )
642 .limit(1);
643
ba9e143Claude644 // Determine the email key for lockout (use identifier if user not found
645 // so we still record the attempt without leaking account existence).
646 const emailKey = (user?.email ?? identifier).toLowerCase();
647
648 // ── Lockout check ───────────────────────────────────────────────────
27d5fd3Claude649 // Locked when ≥ LOGIN_FAIL_LIMIT failures in the trailing window AND the
650 // newest failure is younger than LOGIN_LOCKOUT_MS. We check before
651 // password verification so brute-forcers can't time-diff their way
652 // around it. Blocked attempts are deliberately NOT recorded as failures:
653 // recording them rolled the window forward forever, so a user retrying
654 // their correct password stayed locked out permanently.
655 const lockout = await getLockoutState(emailKey);
656 if (lockout.locked) {
ba9e143Claude657 await audit({
658 userId: user?.id ?? null,
659 action: "auth.login.locked",
660 ip,
661 userAgent: ua,
27d5fd3Claude662 metadata: { email: emailKey, recentFailures: lockout.failureCount },
ba9e143Claude663 });
27d5fd3Claude664 const mins = retryAfterMinutes(lockout);
ba9e143Claude665 return c.redirect(
27d5fd3Claude666 `/login?error=${encodeURIComponent(
667 `Account temporarily locked due to too many failed login attempts. Please try again in ${mins} minute${mins === 1 ? "" : "s"}.`
668 )}`
ba9e143Claude669 );
670 }
27d5fd3Claude671 const recentFailures = lockout.failureCount;
ba9e143Claude672
06d5ffeClaude673 if (!user) {
ba9e143Claude674 // Record failed attempt (unknown user) and return generic error.
675 await db
676 .insert(loginAttempts)
677 .values({ email: emailKey, ip, success: false })
678 .catch(() => {});
06d5ffeClaude679 return c.redirect("/login?error=Invalid+credentials");
680 }
681
682 const valid = await verifyPassword(password, user.passwordHash);
683 if (!valid) {
ba9e143Claude684 // Record failed attempt.
685 await db
686 .insert(loginAttempts)
687 .values({ email: emailKey, ip, success: false })
688 .catch(() => {});
689 await audit({
690 userId: user.id,
691 action: "auth.login.failed",
692 ip,
693 userAgent: ua,
694 metadata: { email: emailKey, attempt: recentFailures + 1 },
695 });
696 // Check if this failure just crossed the threshold.
697 if (recentFailures + 1 >= LOGIN_FAIL_LIMIT) {
698 await audit({
699 userId: user.id,
700 action: "auth.login.locked",
701 ip,
702 userAgent: ua,
703 metadata: { email: emailKey, recentFailures: recentFailures + 1 },
704 });
705 return c.redirect(
706 "/login?error=Account+temporarily+locked+due+to+too+many+failed+login+attempts.+Please+try+again+in+15+minutes."
707 );
708 }
06d5ffeClaude709 return c.redirect("/login?error=Invalid+credentials");
710 }
711
27d5fd3Claude712 // Successful login — record success and clear the failure history so a
713 // stale window can't combine with one future typo to re-trip the lock.
ba9e143Claude714 await db
715 .insert(loginAttempts)
716 .values({ email: emailKey, ip, success: true })
717 .catch(() => {});
27d5fd3Claude718 await db
719 .delete(loginAttempts)
720 .where(
721 and(eq(loginAttempts.email, emailKey), eq(loginAttempts.success, false))
722 )
723 .catch(() => {});
ba9e143Claude724
7298a17Claude725 // B4: if the user has TOTP enabled, issue a pending-2fa session and
726 // redirect to the code prompt.
727 const [totp] = await db
728 .select({ enabledAt: userTotp.enabledAt })
729 .from(userTotp)
730 .where(eq(userTotp.userId, user.id))
731 .limit(1);
732 const needs2fa = !!(totp && totp.enabledAt);
733
06d5ffeClaude734 const token = generateSessionToken();
735 await db.insert(sessions).values({
736 userId: user.id,
737 token,
738 expiresAt: sessionExpiry(),
7298a17Claude739 requires2fa: needs2fa,
ba9e143Claude740 ip,
741 userAgent: ua,
742 lastSeenAt: new Date(),
06d5ffeClaude743 });
744
745 setCookie(c, "session", token, sessionCookieOptions());
c63b860Claude746
747 // Block P5 — If account was scheduled for deletion but user signed back
748 // in, cancel the deletion. Safe regardless of 2FA: password was proven.
749 if (user.deletedAt) {
750 await cancelAccountDeletion(user.id);
751 }
752
7298a17Claude753 if (needs2fa) {
754 return c.redirect(
755 `/login/2fa?redirect=${encodeURIComponent(redirect)}`
756 );
757 }
06d5ffeClaude758 return c.redirect(redirect);
759});
760
7298a17Claude761// --- 2FA verify (B4) ---
762auth.get("/login/2fa", async (c) => {
763 const token = getCookie(c, "session");
764 if (!token) return c.redirect("/login");
765 const error = c.req.query("error");
766 const redirect = c.req.query("redirect") || "/";
767 return c.html(
36cc17aClaude768 <Layout title="Two-factor authentication" user={null}>
ebe6d64Claude769 <AuthMobileStyle />
7298a17Claude770 <div class="auth-container">
771 <h2>Enter your code</h2>
772 <p
773 class="auth-switch"
774 style="margin-bottom: 16px; margin-top: 0"
775 >
776 Open your authenticator app and enter the 6-digit code. Lost your
777 device? Paste a recovery code instead.
778 </p>
779 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
780 <form
b9968e3Claude781 method="post"
7298a17Claude782 action={`/login/2fa?redirect=${encodeURIComponent(redirect)}`}
783 >
134750bClaude784 <input type="hidden" name="_csrf" value={(c.get("csrfToken") as string | undefined) || ""} />
7298a17Claude785 <div class="form-group">
786 <label for="code">Code</label>
787 <input
788 type="text"
789 id="code"
790 name="code"
791 required
792 autocomplete="one-time-code"
793 inputmode="numeric"
794 maxLength={24}
795 placeholder="123456 or xxxx-xxxx-xxxx"
796 />
797 </div>
798 <button type="submit" class="btn btn-primary">
799 Verify
800 </button>
801 </form>
802 <p class="auth-switch">
803 <a href="/logout">Cancel</a>
804 </p>
805 </div>
806 </Layout>
807 );
808});
809
810auth.post("/login/2fa", async (c) => {
811 const token = getCookie(c, "session");
812 if (!token) return c.redirect("/login");
813 const body = await c.req.parseBody();
814 const code = String(body.code || "").trim();
815 const redirect = c.req.query("redirect") || "/";
816
817 if (!code) {
818 return c.redirect(
819 `/login/2fa?error=Code+is+required&redirect=${encodeURIComponent(redirect)}`
820 );
821 }
822
823 try {
824 const [session] = await db
825 .select()
826 .from(sessions)
827 .where(eq(sessions.token, token))
828 .limit(1);
829 if (
830 !session ||
831 new Date(session.expiresAt) < new Date() ||
832 !session.requires2fa
833 ) {
834 return c.redirect("/login");
835 }
836
837 const [totp] = await db
838 .select()
839 .from(userTotp)
840 .where(eq(userTotp.userId, session.userId))
841 .limit(1);
842 if (!totp || !totp.enabledAt) {
843 // User doesn't have 2FA actually enabled — clear the flag and let
844 // them in. This can only happen if 2FA was disabled in another
845 // session between password check and code prompt.
846 await db
847 .update(sessions)
848 .set({ requires2fa: false })
849 .where(eq(sessions.token, token));
850 return c.redirect(redirect);
851 }
852
853 // Try TOTP code first.
854 const isSix = /^\d{6}$/.test(code);
855 let ok = false;
856 if (isSix) {
857 ok = await verifyTotpCode(totp.secret, code);
858 }
859 // Fall through to recovery code.
860 if (!ok) {
861 const hash = await hashRecoveryCode(code);
862 const [rec] = await db
863 .select()
864 .from(userRecoveryCodes)
865 .where(
866 and(
867 eq(userRecoveryCodes.userId, session.userId),
868 eq(userRecoveryCodes.codeHash, hash),
869 isNull(userRecoveryCodes.usedAt)
870 )
871 )
872 .limit(1);
873 if (rec) {
874 await db
875 .update(userRecoveryCodes)
876 .set({ usedAt: new Date() })
877 .where(eq(userRecoveryCodes.id, rec.id));
878 ok = true;
879 }
880 }
881
882 if (!ok) {
883 return c.redirect(
884 `/login/2fa?error=Invalid+code&redirect=${encodeURIComponent(redirect)}`
885 );
886 }
887
888 await db
889 .update(sessions)
890 .set({ requires2fa: false })
891 .where(eq(sessions.token, token));
892 await db
893 .update(userTotp)
894 .set({ lastUsedAt: new Date() })
895 .where(eq(userTotp.userId, session.userId));
896
897 return c.redirect(redirect);
898 } catch (err) {
899 console.error("[auth] 2fa verify:", err);
900 return c.redirect(
901 `/login/2fa?error=Service+unavailable&redirect=${encodeURIComponent(redirect)}`
902 );
903 }
904});
905
06d5ffeClaude906auth.get("/logout", async (c) => {
907 deleteCookie(c, "session", { path: "/" });
908 return c.redirect("/");
909});
910
911// --- API ---
912
913auth.post("/api/auth/register", async (c) => {
914 const body = await c.req.json<{
915 username: string;
916 email: string;
917 password: string;
918 }>();
919
920 if (!body.username || !body.email || !body.password) {
921 return c.json({ error: "username, email, and password are required" }, 400);
922 }
923
924 if (!/^[a-zA-Z0-9_-]+$/.test(body.username)) {
925 return c.json({ error: "Invalid username" }, 400);
926 }
927
928 if (body.password.length < 8) {
929 return c.json({ error: "Password must be at least 8 characters" }, 400);
930 }
931
932 const [existing] = await db
933 .select()
934 .from(users)
935 .where(eq(users.username, body.username))
936 .limit(1);
937 if (existing) {
938 return c.json({ error: "Username already taken" }, 409);
939 }
940
941 const passwordHash = await hashPassword(body.password);
942 const [user] = await db
943 .insert(users)
944 .values({
945 username: body.username,
946 email: body.email,
947 passwordHash,
948 })
949 .returning();
950
951 const token = generateSessionToken();
952 await db.insert(sessions).values({
953 userId: user.id,
954 token,
955 expiresAt: sessionExpiry(),
956 });
957
958 return c.json(
959 {
960 user: { id: user.id, username: user.username, email: user.email },
961 token,
962 },
963 201
964 );
965});
966
967auth.post("/api/auth/login", async (c) => {
968 const body = await c.req.json<{ username: string; password: string }>();
969
970 if (!body.username || !body.password) {
971 return c.json({ error: "username and password are required" }, 400);
972 }
973
974 const isEmail = body.username.includes("@");
975 const [user] = await db
976 .select()
977 .from(users)
978 .where(
979 isEmail
980 ? eq(users.email, body.username)
981 : eq(users.username, body.username)
982 )
983 .limit(1);
984
985 if (!user) return c.json({ error: "Invalid credentials" }, 401);
986
987 const valid = await verifyPassword(body.password, user.passwordHash);
988 if (!valid) return c.json({ error: "Invalid credentials" }, 401);
989
990 const token = generateSessionToken();
991 await db.insert(sessions).values({
992 userId: user.id,
993 token,
994 expiresAt: sessionExpiry(),
995 });
996
997 return c.json({
998 user: { id: user.id, username: user.username, email: user.email },
999 token,
1000 });
1001});
1002
3a845e4Claude1003// --- SSO domain-hint API (used by the login form JS) ---
1004
1005auth.get("/api/sso/domain-hint", async (c) => {
1006 const domain = String(c.req.query("domain") || "").toLowerCase().trim();
1007 if (!domain || domain.length > 253) {
1008 return c.json({ sso: false });
1009 }
1010 const [row] = await db
1011 .select({ orgId: orgSsoConfigs.orgId, provider: orgSsoConfigs.provider })
1012 .from(orgSsoConfigs)
1013 .where(eq(orgSsoConfigs.domainHint, domain))
1014 .limit(1);
1015 return c.json({ sso: !!row, provider: row?.provider ?? null });
1016});
1017
fc0ca99Claude1018/**
1019 * Pick a friendly provider name for the "Sign in with X" button when the
1020 * admin hasn't set one explicitly. Looks at the configured IdP URLs.
1021 * Falls back to undefined so the caller can default to a literal "SSO".
1022 */
1023function inferSsoProviderName(
1024 cfg: { issuer?: string | null; authorizationEndpoint?: string | null } | null | undefined
1025): string | undefined {
1026 const urls = [cfg?.issuer, cfg?.authorizationEndpoint]
1027 .filter((s): s is string => !!s)
1028 .join(" ")
1029 .toLowerCase();
1030 if (!urls) return undefined;
1031 if (urls.includes("google")) return "Google";
1032 if (urls.includes("okta")) return "Okta";
1033 if (urls.includes("microsoftonline") || urls.includes("azure")) return "Microsoft";
1034 if (urls.includes("auth0.com")) return "Auth0";
1035 if (urls.includes("authentik")) return "Authentik";
1036 return undefined;
1037}
1038
06d5ffeClaude1039export default auth;