Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
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.tsxBlame1093 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";
bb0f894Claude41import {
42 Form,
43 FormGroup,
44 Input,
45 Button,
09be7bfClaude46 LinkButton,
bb0f894Claude47 Alert,
48 Text,
49} from "../views/ui";
36cc17aClaude50import { softAuth } from "../middleware/auth";
06d5ffeClaude51import type { AuthEnv } from "../middleware/auth";
52
53const auth = new Hono<AuthEnv>();
54
976d7f7Claude55// One-shot latch — log the auto-verify warning at most once per process,
56// since the misconfiguration is operator-level (env var) and won't change
57// between requests.
58let _autoVerifyWarned = false;
59
ebe6d64Claude60// ───────────────────────────────────────────────────────────────────────
61// Scoped mobile polish — tightens the existing `.auth-container` shell
62// from layout.tsx for ≤720px viewports. Only adds rules; does not
63// redefine the desktop styling. Kept inline so this file remains the
64// single source of truth for the auth surface.
65// ───────────────────────────────────────────────────────────────────────
66const authMobileCss = `
67 @media (max-width: 720px) {
68 .auth-container {
69 margin: 24px 12px;
70 padding: 24px 20px 22px;
71 max-width: 100%;
72 }
73 .auth-container .btn-primary { min-height: 44px; }
74 .auth-container .oauth-btn { min-height: 44px; }
75 .auth-container input[type="text"],
76 .auth-container input[type="email"],
77 .auth-container input[type="password"] { min-height: 44px; }
78 .auth-forgot { text-align: left !important; }
79 }
80`;
81const AuthMobileStyle = () => (
82 <style dangerouslySetInnerHTML={{ __html: authMobileCss }} />
83);
84
06d5ffeClaude85// --- Web UI ---
86
36cc17aClaude87auth.get("/register", softAuth, (c) => {
88 // If the user is already signed in, drop them on their dashboard rather
89 // than rendering the logged-out sign-up shell over an authed session.
90 const existing = c.get("user");
91 if (existing) return c.redirect("/dashboard");
06d5ffeClaude92 const error = c.req.query("error");
134750bClaude93 const csrf = c.get("csrfToken") as string | undefined;
06d5ffeClaude94 return c.html(
36cc17aClaude95 <Layout title="Register" user={null}>
ebe6d64Claude96 <AuthMobileStyle />
06d5ffeClaude97 <div class="auth-container">
98f45b4Claude98 <h2>Create your account</h2>
99 <p class="auth-subtitle">
100 Get the full AI suite — code review, auto-merge, spec-to-PR — on
101 unlimited public repos. No credit card.
102 </p>
06d5ffeClaude103 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
134750bClaude104 <Form method="post" action="/register" csrfToken={csrf}>
0316dbbClaude105 <FormGroup label="Username" htmlFor="username">
106 <Input
107 id="username"
06d5ffeClaude108 type="text"
109 name="username"
110 required
111 pattern="^[a-zA-Z0-9_-]+$"
112 minLength={2}
113 maxLength={39}
114 placeholder="your-username"
115 autocomplete="username"
116 />
bb0f894Claude117 </FormGroup>
118 <FormGroup label="Email" htmlFor="email">
119 <Input
06d5ffeClaude120 type="email"
121 name="email"
122 required
123 placeholder="you@example.com"
124 autocomplete="email"
2c3ba6ecopilot-swe-agent[bot]125 aria-label="Email"
06d5ffeClaude126 />
bb0f894Claude127 </FormGroup>
128 <FormGroup label="Password" htmlFor="password">
129 <Input
06d5ffeClaude130 type="password"
131 name="password"
132 required
133 minLength={8}
134 placeholder="Min 8 characters"
135 autocomplete="new-password"
2c3ba6ecopilot-swe-agent[bot]136 aria-label="Password"
06d5ffeClaude137 />
bb0f894Claude138 </FormGroup>
c63b860Claude139 {/* P3 — Terms / Privacy acceptance. Required client-side via the
140 `required` attribute; server-side re-checked in POST handler. */}
141 <div class="form-group" style="margin: 12px 0">
142 <label style="display: flex; gap: 8px; align-items: flex-start; font-size: 13px; color: var(--text-muted)">
143 <input
144 type="checkbox"
145 name="accept_terms"
146 value="1"
147 required
148 style="margin-top: 3px"
149 aria-label="Accept Terms of Service and Privacy Policy"
150 />
151 <span>
152 I agree to the{" "}
2e8a4d5Claude153 <a href="/terms" target="_blank" rel="noopener">
c63b860Claude154 Terms of Service
155 </a>{" "}
156 and{" "}
2e8a4d5Claude157 <a href="/privacy" target="_blank" rel="noopener">
c63b860Claude158 Privacy Policy
159 </a>
160 .
161 </span>
162 </label>
163 </div>
bb0f894Claude164 <Button type="submit" variant="primary">
06d5ffeClaude165 Create account
bb0f894Claude166 </Button>
167 </Form>
06d5ffeClaude168 <p class="auth-switch">
bb0f894Claude169 <Text>Already have an account? <a href="/login">Sign in</a></Text>
06d5ffeClaude170 </p>
171 </div>
172 </Layout>
173 );
174});
175
176auth.post("/register", async (c) => {
177 const body = await c.req.parseBody();
178 const username = String(body.username || "").trim();
179 const email = String(body.email || "").trim();
180 const password = String(body.password || "");
181
182 if (!username || !email || !password) {
183 return c.redirect("/register?error=All+fields+are+required");
184 }
185
c63b860Claude186 // Block P3 — Terms acceptance is required. The form's checkbox has
187 // `required` so browsers normally enforce client-side; the server
188 // re-checks for defensive depth (curl, scripted POST, etc.).
189 if (!body.accept_terms) {
190 return c.redirect(
191 "/register?error=Please+accept+the+Terms+of+Service+and+Privacy+Policy"
192 );
193 }
194
06d5ffeClaude195 if (!/^[a-zA-Z0-9_-]+$/.test(username)) {
196 return c.redirect(
197 "/register?error=Username+may+only+contain+letters%2C+numbers%2C+hyphens+and+underscores"
198 );
199 }
200
201 if (password.length < 8) {
202 return c.redirect("/register?error=Password+must+be+at+least+8+characters");
203 }
204
205 // Check existing
206 const [existingUser] = await db
207 .select()
208 .from(users)
209 .where(eq(users.username, username))
210 .limit(1);
211 if (existingUser) {
212 return c.redirect("/register?error=Username+already+taken");
213 }
214
7437605Claude215 // B2: usernames share the URL namespace with org slugs; refuse collisions.
216 const [existingOrg] = await db
217 .select({ id: organizations.id })
218 .from(organizations)
219 .where(eq(organizations.slug, username.toLowerCase()))
220 .limit(1);
221 if (existingOrg) {
222 return c.redirect("/register?error=Username+already+taken");
223 }
224
06d5ffeClaude225 const [existingEmail] = await db
226 .select()
227 .from(users)
228 .where(eq(users.email, email))
229 .limit(1);
230 if (existingEmail) {
231 return c.redirect("/register?error=Email+already+registered");
232 }
233
234 const passwordHash = await hashPassword(password);
235
a4f3e24Claude236 // First user ever registered becomes admin automatically
237 const [userCount] = await db
238 .select({ count: sql`count(*)::int` })
239 .from(users);
240 const isFirstUser = (userCount?.count as number) === 0;
241
06d5ffeClaude242 const [user] = await db
243 .insert(users)
c63b860Claude244 .values({
245 username,
246 email,
247 passwordHash,
248 isAdmin: isFirstUser,
249 // P3 — record terms acceptance now. Version bumps when Terms change.
250 termsAcceptedAt: new Date(),
251 termsVersion: "1.0",
252 })
06d5ffeClaude253 .returning();
254
2d985e5Claude255 // If username matches SITE_ADMIN_USERNAME env, grant site admin instantly
256 // so the operator doesn't have to wait for the next boot's bootstrap pass.
a28cedeClaude257 await import("../lib/admin-bootstrap")
258 .then((m) => m.ensureEnvAdminOnRegister({ userId: user.id, username }))
259 .catch((err) => {
260 console.warn(
261 `[admin-bootstrap] ensureEnvAdminOnRegister failed for ${username}:`,
262 err instanceof Error ? err.message : err
263 );
264 });
2d985e5Claude265
06d5ffeClaude266 // Create session
267 const token = generateSessionToken();
268 await db.insert(sessions).values({
269 userId: user.id,
270 token,
271 expiresAt: sessionExpiry(),
272 });
273
274 setCookie(c, "session", token, sessionCookieOptions());
275
976d7f7Claude276 // Block P2 — email verification. If RESEND_API_KEY is configured the
277 // verification email goes out and the user clicks the link to verify.
278 // If email is NOT configured (EMAIL_PROVIDER=log, no RESEND_API_KEY,
279 // etc.), the email would silently never arrive and the user would be
280 // locked out — AUDIT-v2.md P0 #3. In that case, auto-verify the
281 // account on registration so the user can actually use the site.
282 // Operators who want real verification should set EMAIL_PROVIDER=resend
283 // + RESEND_API_KEY in their environment.
284 const { config: _emailConfig } = await import("../lib/config");
285 const emailConfigured =
286 _emailConfig.emailProvider === "resend" && !!_emailConfig.resendApiKey;
287 if (emailConfigured) {
288 import("../lib/email-verification")
289 .then((m) => m.startEmailVerification(user.id, email))
290 .catch((err) => {
291 console.error(
292 `[auth] startEmailVerification failed for ${user.id}:`,
293 err instanceof Error ? err.message : err
294 );
295 });
296 } else {
297 // Auto-verify immediately so the user isn't trapped in an unverified
298 // state. Log once so operators notice the misconfiguration.
299 if (!_autoVerifyWarned) {
300 _autoVerifyWarned = true;
301 console.warn(
302 "[auth] EMAIL_PROVIDER is not configured (set EMAIL_PROVIDER=resend + RESEND_API_KEY). Auto-verifying new account email addresses to avoid lockout."
303 );
304 }
305 await db
306 .update(users)
307 .set({ emailVerifiedAt: new Date() })
308 .where(eq(users.id, user.id))
309 .catch((err) => {
310 console.error(
311 `[auth] auto-verify failed for ${user.id}:`,
312 err instanceof Error ? err.message : err
313 );
314 });
315 }
c63b860Claude316
f65f600Claude317 // Onboarding drip — T+0 "welcome" email. Fire-and-forget; never blocks
318 // the redirect. Silently skips when email is not configured.
319 import("../lib/onboarding-drip")
320 .then((m) => m.sendWelcomeEmail(user.id))
321 .catch((err) => {
322 console.error(
323 `[auth] onboarding welcome email failed for ${user.id}:`,
324 err instanceof Error ? err.message : err
325 );
326 });
327
c63b860Claude328 // P3 — default landing is /onboarding (the guided first-five-minutes
329 // flow). The `redirect=` query is still honoured for OAuth-style flows.
330 const redirect = c.req.query("redirect") || "/onboarding?welcome=1";
06d5ffeClaude331 return c.redirect(redirect);
332});
333
36cc17aClaude334auth.get("/login", softAuth, async (c) => {
335 // Already-authed users hitting the sign-in page get bounced to their
336 // dashboard (or the `redirect=` target if one was supplied).
337 const existing = c.get("user");
06d5ffeClaude338 const error = c.req.query("error");
c63b860Claude339 const success = c.req.query("success");
06d5ffeClaude340 const redirect = c.req.query("redirect") || "";
36cc17aClaude341 if (existing) return c.redirect(redirect || "/dashboard");
edf7c36Claude342 const ssoCfg = await getSsoConfig();
343 const ssoEnabled =
344 !!ssoCfg?.enabled &&
345 !!ssoCfg.authorizationEndpoint &&
346 !!ssoCfg.tokenEndpoint &&
347 !!ssoCfg.userinfoEndpoint &&
348 !!ssoCfg.clientId &&
349 !!ssoCfg.clientSecret;
fc0ca99Claude350 const ssoLabel =
351 ssoCfg?.providerName || inferSsoProviderName(ssoCfg) || "SSO";
46d6165Claude352 // Block L6 — "Sign in with GitHub" (separate row keyed id='github').
353 const githubCfg = await getGithubOauthConfig();
354 const githubEnabled =
355 !!githubCfg?.enabled && !!githubCfg.clientId && !!githubCfg.clientSecret;
582cdacClaude356 // "Sign in with Google" (separate row keyed id='google'). Same wiring
357 // pattern as GitHub OAuth.
358 const googleCfg = await getGoogleOauthConfig();
359 const googleEnabled =
360 !!googleCfg?.enabled && !!googleCfg.clientId && !!googleCfg.clientSecret;
134750bClaude361 const csrf = c.get("csrfToken") as string | undefined;
06d5ffeClaude362 return c.html(
36cc17aClaude363 <Layout title="Sign in" user={null}>
ebe6d64Claude364 <AuthMobileStyle />
06d5ffeClaude365 <div class="auth-container">
98f45b4Claude366 <h2>Welcome back</h2>
367 <p class="auth-subtitle">
368 Sign in to your gluecron account.
369 </p>
06d5ffeClaude370 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
c63b860Claude371 {success && (
372 <div class="auth-success">{decodeURIComponent(success)}</div>
373 )}
0316dbbClaude374 <Form
b9968e3Claude375 method="post"
06d5ffeClaude376 action={`/login${redirect ? `?redirect=${encodeURIComponent(redirect)}` : ""}`}
134750bClaude377 csrfToken={csrf}
06d5ffeClaude378 >
bb0f894Claude379 <FormGroup label="Username or email" htmlFor="username">
380 <Input
06d5ffeClaude381 type="text"
382 name="username"
383 required
384 placeholder="username or email"
385 autocomplete="username"
2c3ba6ecopilot-swe-agent[bot]386 aria-label="Username or email"
06d5ffeClaude387 />
bb0f894Claude388 </FormGroup>
389 <FormGroup label="Password" htmlFor="password">
390 <Input
06d5ffeClaude391 type="password"
392 name="password"
393 required
394 placeholder="Password"
395 autocomplete="current-password"
2c3ba6ecopilot-swe-agent[bot]396 aria-label="Password"
06d5ffeClaude397 />
bb0f894Claude398 </FormGroup>
c63b860Claude399 <div class="auth-forgot" style="margin:-8px 0 12px;text-align:right;font-size:13px">
400 <a href="/forgot-password">Forgot password?</a>
cd4f63bTest User401 {/* BLOCK Q2 — magic-link sign-in. */}
402 <span style="margin:0 6px;color:var(--text-muted)">·</span>
403 <a href="/login/magic">Sign in with a magic link instead</a>
c63b860Claude404 </div>
bb0f894Claude405 <Button type="submit" variant="primary">
06d5ffeClaude406 Sign in
bb0f894Claude407 </Button>
3a845e4Claude408 {/* SSO domain hint — shown dynamically when the typed email domain
409 matches a known org SSO config. JS fetches /api/sso/domain-hint. */}
6fd5915Claude410 <div id="sso-domain-hint" style="display:none;margin-top:10px;padding:10px 12px;background:rgba(91,110,232,0.10);border:1px solid rgba(91,110,232,0.30);border-radius:8px;font-size:13px;color:var(--text)" aria-live="polite">
3a845e4Claude411 Your organization uses SSO. You'll be redirected to sign in.
412 </div>
bb0f894Claude413 </Form>
3a845e4Claude414 <script dangerouslySetInnerHTML={{__html: /* js */`
415 (function(){
416 var inp = document.querySelector('input[name="username"]');
417 var hint = document.getElementById('sso-domain-hint');
418 if(!inp || !hint) return;
419 var last = '';
420 inp.addEventListener('input', function(){
421 var val = inp.value.trim();
422 var at = val.indexOf('@');
423 if(at < 0) { hint.style.display='none'; return; }
424 var domain = val.slice(at+1).toLowerCase();
425 if(!domain || domain === last) return;
426 last = domain;
427 fetch('/api/sso/domain-hint?domain='+encodeURIComponent(domain))
428 .then(function(r){ return r.ok ? r.json() : null; })
429 .then(function(d){ hint.style.display = (d && d.sso) ? '' : 'none'; })
430 .catch(function(){ hint.style.display='none'; });
431 });
432 })();
433 `}} />
582cdacClaude434 {/* Provider buttons (Google + GitHub) — only rendered when the
435 admin has configured + enabled them via /admin/google-oauth or
436 /admin/github-oauth. The "or" divider above the first available
437 provider sets the visual break from the password form. */}
438 {(googleEnabled || githubEnabled) && (
439 <div class="auth-divider">or</div>
440 )}
441 {googleEnabled && (
442 <a
443 href="/login/google"
444 class="btn btn-block oauth-btn oauth-google"
445 aria-label="Sign in with Google"
446 >
447 <svg
448 class="oauth-icon"
449 width="18"
450 height="18"
451 viewBox="0 0 18 18"
452 aria-hidden="true"
453 >
454 <path
455 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"
456 fill="#4285F4"
457 />
458 <path
459 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"
460 fill="#34A853"
461 />
462 <path
463 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"
464 fill="#FBBC05"
465 />
466 <path
467 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"
468 fill="#EA4335"
469 />
470 </svg>
471 <span>Sign in with Google</span>
472 </a>
473 )}
46d6165Claude474 {githubEnabled && (
582cdacClaude475 <a
476 href="/login/github"
477 class="btn btn-block oauth-btn oauth-github"
478 aria-label="Sign in with GitHub"
479 style={googleEnabled ? "margin-top:8px" : undefined}
480 >
481 <svg
482 class="oauth-icon"
483 width="18"
484 height="18"
485 viewBox="0 0 16 16"
486 aria-hidden="true"
487 fill="currentColor"
488 >
489 <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" />
490 </svg>
491 <span>Sign in with GitHub</span>
492 </a>
46d6165Claude493 )}
09be7bfClaude494 {ssoEnabled && (
582cdacClaude495 <a
496 href="/login/sso"
497 class="btn btn-block oauth-btn oauth-sso"
498 aria-label={`Sign in with ${ssoLabel}`}
499 style={(googleEnabled || githubEnabled) ? "margin-top:8px" : undefined}
500 >
501 <span>Sign in with {ssoLabel}</span>
502 </a>
09be7bfClaude503 )}
fa06ad2Claude504 <div class="auth-passkey">
505 <div class="auth-divider">or</div>
506 <button type="button" id="pk-signin-btn" class="btn">
507 Sign in with passkey
508 </button>
509 <div
510 id="pk-signin-status"
511 class="auth-status"
512 aria-live="polite"
513 ></div>
514 </div>
06d5ffeClaude515 <p class="auth-switch">
bb0f894Claude516 <Text>New to gluecron? <a href="/register">Create an account</a></Text>
06d5ffeClaude517 </p>
2df1f8cClaude518 <script
519 dangerouslySetInnerHTML={{
520 __html: /* js */ `
521 (function () {
522 const btn = document.getElementById('pk-signin-btn');
523 const status = document.getElementById('pk-signin-status');
524 const userInput = document.getElementById('username');
525 const redirect = ${JSON.stringify(redirect || "/")};
526 if (!btn) return;
527 function b64uToBuf(s) {
528 s = s.replace(/-/g,'+').replace(/_/g,'/');
529 while (s.length % 4) s += '=';
530 const bin = atob(s);
531 const buf = new Uint8Array(bin.length);
532 for (let i=0;i<bin.length;i++) buf[i] = bin.charCodeAt(i);
533 return buf.buffer;
534 }
535 function bufToB64u(buf) {
536 const bytes = new Uint8Array(buf);
537 let bin = '';
538 for (let i=0;i<bytes.length;i++) bin += String.fromCharCode(bytes[i]);
539 return btoa(bin).replace(/\\+/g,'-').replace(/\\//g,'_').replace(/=+$/,'');
540 }
541 btn.addEventListener('click', async function () {
542 if (!window.PublicKeyCredential) {
543 status.textContent = 'Passkeys not supported in this browser.';
544 return;
545 }
546 status.textContent = 'Preparing…';
547 try {
548 const username = (userInput && userInput.value || '').trim();
549 const optsRes = await fetch('/api/passkeys/auth/options', {
550 method: 'POST',
551 headers: { 'content-type': 'application/json' },
552 body: JSON.stringify(username ? { username: username } : {})
553 });
554 if (!optsRes.ok) throw new Error('options failed');
555 const { options, sessionKey } = await optsRes.json();
556 options.challenge = b64uToBuf(options.challenge);
557 if (options.allowCredentials) {
558 options.allowCredentials = options.allowCredentials.map(function (c) {
559 return Object.assign({}, c, { id: b64uToBuf(c.id) });
560 });
561 }
562 status.textContent = 'Touch your authenticator…';
563 const cred = await navigator.credentials.get({ publicKey: options });
564 const resp = {
565 id: cred.id,
566 rawId: bufToB64u(cred.rawId),
567 type: cred.type,
568 response: {
569 clientDataJSON: bufToB64u(cred.response.clientDataJSON),
570 authenticatorData: bufToB64u(cred.response.authenticatorData),
571 signature: bufToB64u(cred.response.signature),
572 userHandle: cred.response.userHandle ? bufToB64u(cred.response.userHandle) : null
573 },
574 clientExtensionResults: cred.getClientExtensionResults ? cred.getClientExtensionResults() : {}
575 };
576 const verifyRes = await fetch('/api/passkeys/auth/verify', {
577 method: 'POST',
578 headers: { 'content-type': 'application/json' },
579 body: JSON.stringify({ sessionKey: sessionKey, response: resp })
580 });
581 if (!verifyRes.ok) {
582 const j = await verifyRes.json().catch(function () { return {}; });
583 throw new Error(j.error || 'verify failed');
584 }
585 status.textContent = 'Signed in. Redirecting…';
586 window.location.href = redirect;
587 } catch (e) {
588 status.textContent = 'Error: ' + (e && e.message ? e.message : e);
589 }
590 });
591 })();
592 `,
593 }}
594 />
06d5ffeClaude595 </div>
596 </Layout>
597 );
598});
599
ba9e143Claude600/**
27d5fd3Claude601 * Loads the failure aggregate for `email` and evaluates the lockout policy
602 * (see src/lib/login-lockout.ts for the semantics).
603 *
604 * Fails OPEN: if the `login_attempts` table is unreachable (missing
605 * migration, transient DB error) login must still work — a broken lockout
606 * ledger must never lock every user out of the site. That failure class
607 * is exactly what the 0087 migration blockade caused in production.
ba9e143Claude608 */
27d5fd3Claude609async function getLockoutState(email: string): Promise<LockoutState> {
610 try {
611 const since = new Date(Date.now() - LOGIN_FAIL_WINDOW_MS);
612 const [row] = await db
613 .select({
614 count: sql<number>`count(*)::int`,
615 newest: sql<string | null>`max(${loginAttempts.createdAt})`,
616 })
617 .from(loginAttempts)
618 .where(
619 and(
620 eq(loginAttempts.email, email.toLowerCase()),
621 eq(loginAttempts.success, false),
622 gte(loginAttempts.createdAt, since)
623 )
624 );
625 return evaluateLockout({
626 failureCount: row?.count ?? 0,
627 newestFailureAt: row?.newest ? new Date(row.newest) : null,
628 });
629 } catch (err) {
630 console.error(
631 "[auth] lockout check failed (failing open):",
632 err instanceof Error ? err.message : err
ba9e143Claude633 );
27d5fd3Claude634 return { locked: false, failureCount: 0, retryAfterMs: 0 };
635 }
ba9e143Claude636}
637
06d5ffeClaude638auth.post("/login", async (c) => {
639 const body = await c.req.parseBody();
640 const identifier = String(body.username || "").trim();
641 const password = String(body.password || "");
642 const redirect = c.req.query("redirect") || "/";
ba9e143Claude643 const ip =
644 c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ||
645 c.req.header("x-real-ip") ||
646 "unknown";
647 const ua = c.req.header("user-agent") || "";
06d5ffeClaude648
649 if (!identifier || !password) {
650 return c.redirect("/login?error=All+fields+are+required");
651 }
652
3a845e4Claude653 // Enterprise SSO domain-hint routing: if the identifier is an email and the
654 // domain matches an org's `domain_hint`, redirect to that org's SSO flow
655 // instead of checking the password.
656 // Also: resolve the canonical email for lockout checks regardless of whether
ba9e143Claude657 // the user typed username or email.
06d5ffeClaude658 const isEmail = identifier.includes("@");
3a845e4Claude659 if (isEmail) {
660 const emailDomain = identifier.split("@")[1]?.toLowerCase();
661 if (emailDomain) {
662 const [ssoHint] = await db
663 .select({
664 provider: orgSsoConfigs.provider,
665 orgId: orgSsoConfigs.orgId,
666 })
667 .from(orgSsoConfigs)
668 .where(eq(orgSsoConfigs.domainHint, emailDomain))
669 .limit(1);
670
671 if (ssoHint) {
672 // Resolve org slug from org ID
673 const [orgRow] = await db
674 .select({ slug: organizations.slug })
675 .from(organizations)
676 .where(eq(organizations.id, ssoHint.orgId))
677 .limit(1);
678
679 if (orgRow) {
680 const protocol = ssoHint.provider === "oidc" ? "oidc" : "saml";
681 return c.redirect(`/sso/${protocol}/${orgRow.slug}/login`);
682 }
683 }
684 }
685 }
686
687 // Find user by username or email
06d5ffeClaude688 const [user] = await db
689 .select()
690 .from(users)
691 .where(
692 isEmail
693 ? eq(users.email, identifier)
694 : eq(users.username, identifier)
695 )
696 .limit(1);
697
ba9e143Claude698 // Determine the email key for lockout (use identifier if user not found
699 // so we still record the attempt without leaking account existence).
700 const emailKey = (user?.email ?? identifier).toLowerCase();
701
702 // ── Lockout check ───────────────────────────────────────────────────
27d5fd3Claude703 // Locked when ≥ LOGIN_FAIL_LIMIT failures in the trailing window AND the
704 // newest failure is younger than LOGIN_LOCKOUT_MS. We check before
705 // password verification so brute-forcers can't time-diff their way
706 // around it. Blocked attempts are deliberately NOT recorded as failures:
707 // recording them rolled the window forward forever, so a user retrying
708 // their correct password stayed locked out permanently.
709 const lockout = await getLockoutState(emailKey);
710 if (lockout.locked) {
ba9e143Claude711 await audit({
712 userId: user?.id ?? null,
713 action: "auth.login.locked",
714 ip,
715 userAgent: ua,
27d5fd3Claude716 metadata: { email: emailKey, recentFailures: lockout.failureCount },
ba9e143Claude717 });
27d5fd3Claude718 const mins = retryAfterMinutes(lockout);
ba9e143Claude719 return c.redirect(
27d5fd3Claude720 `/login?error=${encodeURIComponent(
721 `Account temporarily locked due to too many failed login attempts. Please try again in ${mins} minute${mins === 1 ? "" : "s"}.`
722 )}`
ba9e143Claude723 );
724 }
27d5fd3Claude725 const recentFailures = lockout.failureCount;
ba9e143Claude726
06d5ffeClaude727 if (!user) {
ba9e143Claude728 // Record failed attempt (unknown user) and return generic error.
729 await db
730 .insert(loginAttempts)
731 .values({ email: emailKey, ip, success: false })
732 .catch(() => {});
06d5ffeClaude733 return c.redirect("/login?error=Invalid+credentials");
734 }
735
736 const valid = await verifyPassword(password, user.passwordHash);
737 if (!valid) {
ba9e143Claude738 // Record failed attempt.
739 await db
740 .insert(loginAttempts)
741 .values({ email: emailKey, ip, success: false })
742 .catch(() => {});
743 await audit({
744 userId: user.id,
745 action: "auth.login.failed",
746 ip,
747 userAgent: ua,
748 metadata: { email: emailKey, attempt: recentFailures + 1 },
749 });
750 // Check if this failure just crossed the threshold.
751 if (recentFailures + 1 >= LOGIN_FAIL_LIMIT) {
752 await audit({
753 userId: user.id,
754 action: "auth.login.locked",
755 ip,
756 userAgent: ua,
757 metadata: { email: emailKey, recentFailures: recentFailures + 1 },
758 });
759 return c.redirect(
760 "/login?error=Account+temporarily+locked+due+to+too+many+failed+login+attempts.+Please+try+again+in+15+minutes."
761 );
762 }
06d5ffeClaude763 return c.redirect("/login?error=Invalid+credentials");
764 }
765
27d5fd3Claude766 // Successful login — record success and clear the failure history so a
767 // stale window can't combine with one future typo to re-trip the lock.
ba9e143Claude768 await db
769 .insert(loginAttempts)
770 .values({ email: emailKey, ip, success: true })
771 .catch(() => {});
27d5fd3Claude772 await db
773 .delete(loginAttempts)
774 .where(
775 and(eq(loginAttempts.email, emailKey), eq(loginAttempts.success, false))
776 )
777 .catch(() => {});
ba9e143Claude778
7298a17Claude779 // B4: if the user has TOTP enabled, issue a pending-2fa session and
780 // redirect to the code prompt.
781 const [totp] = await db
782 .select({ enabledAt: userTotp.enabledAt })
783 .from(userTotp)
784 .where(eq(userTotp.userId, user.id))
785 .limit(1);
786 const needs2fa = !!(totp && totp.enabledAt);
787
06d5ffeClaude788 const token = generateSessionToken();
789 await db.insert(sessions).values({
790 userId: user.id,
791 token,
792 expiresAt: sessionExpiry(),
7298a17Claude793 requires2fa: needs2fa,
ba9e143Claude794 ip,
795 userAgent: ua,
796 lastSeenAt: new Date(),
06d5ffeClaude797 });
798
799 setCookie(c, "session", token, sessionCookieOptions());
c63b860Claude800
801 // Block P5 — If account was scheduled for deletion but user signed back
802 // in, cancel the deletion. Safe regardless of 2FA: password was proven.
803 if (user.deletedAt) {
804 await cancelAccountDeletion(user.id);
805 }
806
7298a17Claude807 if (needs2fa) {
808 return c.redirect(
809 `/login/2fa?redirect=${encodeURIComponent(redirect)}`
810 );
811 }
06d5ffeClaude812 return c.redirect(redirect);
813});
814
7298a17Claude815// --- 2FA verify (B4) ---
816auth.get("/login/2fa", async (c) => {
817 const token = getCookie(c, "session");
818 if (!token) return c.redirect("/login");
819 const error = c.req.query("error");
820 const redirect = c.req.query("redirect") || "/";
821 return c.html(
36cc17aClaude822 <Layout title="Two-factor authentication" user={null}>
ebe6d64Claude823 <AuthMobileStyle />
7298a17Claude824 <div class="auth-container">
825 <h2>Enter your code</h2>
826 <p
827 class="auth-switch"
828 style="margin-bottom: 16px; margin-top: 0"
829 >
830 Open your authenticator app and enter the 6-digit code. Lost your
831 device? Paste a recovery code instead.
832 </p>
833 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
834 <form
b9968e3Claude835 method="post"
7298a17Claude836 action={`/login/2fa?redirect=${encodeURIComponent(redirect)}`}
837 >
134750bClaude838 <input type="hidden" name="_csrf" value={(c.get("csrfToken") as string | undefined) || ""} />
7298a17Claude839 <div class="form-group">
840 <label for="code">Code</label>
841 <input
842 type="text"
843 id="code"
844 name="code"
845 required
846 autocomplete="one-time-code"
847 inputmode="numeric"
848 maxLength={24}
849 placeholder="123456 or xxxx-xxxx-xxxx"
850 />
851 </div>
852 <button type="submit" class="btn btn-primary">
853 Verify
854 </button>
855 </form>
856 <p class="auth-switch">
857 <a href="/logout">Cancel</a>
858 </p>
859 </div>
860 </Layout>
861 );
862});
863
864auth.post("/login/2fa", async (c) => {
865 const token = getCookie(c, "session");
866 if (!token) return c.redirect("/login");
867 const body = await c.req.parseBody();
868 const code = String(body.code || "").trim();
869 const redirect = c.req.query("redirect") || "/";
870
871 if (!code) {
872 return c.redirect(
873 `/login/2fa?error=Code+is+required&redirect=${encodeURIComponent(redirect)}`
874 );
875 }
876
877 try {
878 const [session] = await db
879 .select()
880 .from(sessions)
881 .where(eq(sessions.token, token))
882 .limit(1);
883 if (
884 !session ||
885 new Date(session.expiresAt) < new Date() ||
886 !session.requires2fa
887 ) {
888 return c.redirect("/login");
889 }
890
891 const [totp] = await db
892 .select()
893 .from(userTotp)
894 .where(eq(userTotp.userId, session.userId))
895 .limit(1);
896 if (!totp || !totp.enabledAt) {
897 // User doesn't have 2FA actually enabled — clear the flag and let
898 // them in. This can only happen if 2FA was disabled in another
899 // session between password check and code prompt.
900 await db
901 .update(sessions)
902 .set({ requires2fa: false })
903 .where(eq(sessions.token, token));
904 return c.redirect(redirect);
905 }
906
907 // Try TOTP code first.
908 const isSix = /^\d{6}$/.test(code);
909 let ok = false;
910 if (isSix) {
911 ok = await verifyTotpCode(totp.secret, code);
912 }
913 // Fall through to recovery code.
914 if (!ok) {
915 const hash = await hashRecoveryCode(code);
916 const [rec] = await db
917 .select()
918 .from(userRecoveryCodes)
919 .where(
920 and(
921 eq(userRecoveryCodes.userId, session.userId),
922 eq(userRecoveryCodes.codeHash, hash),
923 isNull(userRecoveryCodes.usedAt)
924 )
925 )
926 .limit(1);
927 if (rec) {
928 await db
929 .update(userRecoveryCodes)
930 .set({ usedAt: new Date() })
931 .where(eq(userRecoveryCodes.id, rec.id));
932 ok = true;
933 }
934 }
935
936 if (!ok) {
937 return c.redirect(
938 `/login/2fa?error=Invalid+code&redirect=${encodeURIComponent(redirect)}`
939 );
940 }
941
942 await db
943 .update(sessions)
944 .set({ requires2fa: false })
945 .where(eq(sessions.token, token));
946 await db
947 .update(userTotp)
948 .set({ lastUsedAt: new Date() })
949 .where(eq(userTotp.userId, session.userId));
950
951 return c.redirect(redirect);
952 } catch (err) {
953 console.error("[auth] 2fa verify:", err);
954 return c.redirect(
955 `/login/2fa?error=Service+unavailable&redirect=${encodeURIComponent(redirect)}`
956 );
957 }
958});
959
06d5ffeClaude960auth.get("/logout", async (c) => {
961 deleteCookie(c, "session", { path: "/" });
962 return c.redirect("/");
963});
964
965// --- API ---
966
967auth.post("/api/auth/register", async (c) => {
968 const body = await c.req.json<{
969 username: string;
970 email: string;
971 password: string;
972 }>();
973
974 if (!body.username || !body.email || !body.password) {
975 return c.json({ error: "username, email, and password are required" }, 400);
976 }
977
978 if (!/^[a-zA-Z0-9_-]+$/.test(body.username)) {
979 return c.json({ error: "Invalid username" }, 400);
980 }
981
982 if (body.password.length < 8) {
983 return c.json({ error: "Password must be at least 8 characters" }, 400);
984 }
985
986 const [existing] = await db
987 .select()
988 .from(users)
989 .where(eq(users.username, body.username))
990 .limit(1);
991 if (existing) {
992 return c.json({ error: "Username already taken" }, 409);
993 }
994
995 const passwordHash = await hashPassword(body.password);
996 const [user] = await db
997 .insert(users)
998 .values({
999 username: body.username,
1000 email: body.email,
1001 passwordHash,
1002 })
1003 .returning();
1004
1005 const token = generateSessionToken();
1006 await db.insert(sessions).values({
1007 userId: user.id,
1008 token,
1009 expiresAt: sessionExpiry(),
1010 });
1011
1012 return c.json(
1013 {
1014 user: { id: user.id, username: user.username, email: user.email },
1015 token,
1016 },
1017 201
1018 );
1019});
1020
1021auth.post("/api/auth/login", async (c) => {
1022 const body = await c.req.json<{ username: string; password: string }>();
1023
1024 if (!body.username || !body.password) {
1025 return c.json({ error: "username and password are required" }, 400);
1026 }
1027
1028 const isEmail = body.username.includes("@");
1029 const [user] = await db
1030 .select()
1031 .from(users)
1032 .where(
1033 isEmail
1034 ? eq(users.email, body.username)
1035 : eq(users.username, body.username)
1036 )
1037 .limit(1);
1038
1039 if (!user) return c.json({ error: "Invalid credentials" }, 401);
1040
1041 const valid = await verifyPassword(body.password, user.passwordHash);
1042 if (!valid) return c.json({ error: "Invalid credentials" }, 401);
1043
1044 const token = generateSessionToken();
1045 await db.insert(sessions).values({
1046 userId: user.id,
1047 token,
1048 expiresAt: sessionExpiry(),
1049 });
1050
1051 return c.json({
1052 user: { id: user.id, username: user.username, email: user.email },
1053 token,
1054 });
1055});
1056
3a845e4Claude1057// --- SSO domain-hint API (used by the login form JS) ---
1058
1059auth.get("/api/sso/domain-hint", async (c) => {
1060 const domain = String(c.req.query("domain") || "").toLowerCase().trim();
1061 if (!domain || domain.length > 253) {
1062 return c.json({ sso: false });
1063 }
1064 const [row] = await db
1065 .select({ orgId: orgSsoConfigs.orgId, provider: orgSsoConfigs.provider })
1066 .from(orgSsoConfigs)
1067 .where(eq(orgSsoConfigs.domainHint, domain))
1068 .limit(1);
1069 return c.json({ sso: !!row, provider: row?.provider ?? null });
1070});
1071
fc0ca99Claude1072/**
1073 * Pick a friendly provider name for the "Sign in with X" button when the
1074 * admin hasn't set one explicitly. Looks at the configured IdP URLs.
1075 * Falls back to undefined so the caller can default to a literal "SSO".
1076 */
1077function inferSsoProviderName(
1078 cfg: { issuer?: string | null; authorizationEndpoint?: string | null } | null | undefined
1079): string | undefined {
1080 const urls = [cfg?.issuer, cfg?.authorizationEndpoint]
1081 .filter((s): s is string => !!s)
1082 .join(" ")
1083 .toLowerCase();
1084 if (!urls) return undefined;
1085 if (urls.includes("google")) return "Google";
1086 if (urls.includes("okta")) return "Okta";
1087 if (urls.includes("microsoftonline") || urls.includes("azure")) return "Microsoft";
1088 if (urls.includes("auth0.com")) return "Auth0";
1089 if (urls.includes("authentik")) return "Authentik";
1090 return undefined;
1091}
1092
06d5ffeClaude1093export default auth;