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.tsxBlame1064 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";
c63b860Claude26import { cancelAccountDeletion } from "../lib/account-deletion";
ba9e143Claude27import { audit } from "../lib/notify";
582cdacClaude28import {
29 getSsoConfig,
30 getGithubOauthConfig,
31 getGoogleOauthConfig,
32} from "../lib/sso";
06d5ffeClaude33import { Layout } from "../views/layout";
bb0f894Claude34import {
35 Form,
36 FormGroup,
37 Input,
38 Button,
09be7bfClaude39 LinkButton,
bb0f894Claude40 Alert,
41 Text,
42} from "../views/ui";
36cc17aClaude43import { softAuth } from "../middleware/auth";
06d5ffeClaude44import type { AuthEnv } from "../middleware/auth";
45
46const auth = new Hono<AuthEnv>();
47
976d7f7Claude48// One-shot latch — log the auto-verify warning at most once per process,
49// since the misconfiguration is operator-level (env var) and won't change
50// between requests.
51let _autoVerifyWarned = false;
52
ebe6d64Claude53// ───────────────────────────────────────────────────────────────────────
54// Scoped mobile polish — tightens the existing `.auth-container` shell
55// from layout.tsx for ≤720px viewports. Only adds rules; does not
56// redefine the desktop styling. Kept inline so this file remains the
57// single source of truth for the auth surface.
58// ───────────────────────────────────────────────────────────────────────
59const authMobileCss = `
60 @media (max-width: 720px) {
61 .auth-container {
62 margin: 24px 12px;
63 padding: 24px 20px 22px;
64 max-width: 100%;
65 }
66 .auth-container .btn-primary { min-height: 44px; }
67 .auth-container .oauth-btn { min-height: 44px; }
68 .auth-container input[type="text"],
69 .auth-container input[type="email"],
70 .auth-container input[type="password"] { min-height: 44px; }
71 .auth-forgot { text-align: left !important; }
72 }
73`;
74const AuthMobileStyle = () => (
75 <style dangerouslySetInnerHTML={{ __html: authMobileCss }} />
76);
77
06d5ffeClaude78// --- Web UI ---
79
36cc17aClaude80auth.get("/register", softAuth, (c) => {
81 // If the user is already signed in, drop them on their dashboard rather
82 // than rendering the logged-out sign-up shell over an authed session.
83 const existing = c.get("user");
84 if (existing) return c.redirect("/dashboard");
06d5ffeClaude85 const error = c.req.query("error");
134750bClaude86 const csrf = c.get("csrfToken") as string | undefined;
06d5ffeClaude87 return c.html(
36cc17aClaude88 <Layout title="Register" user={null}>
ebe6d64Claude89 <AuthMobileStyle />
06d5ffeClaude90 <div class="auth-container">
98f45b4Claude91 <h2>Create your account</h2>
92 <p class="auth-subtitle">
93 Get the full AI suite — code review, auto-merge, spec-to-PR — on
94 unlimited public repos. No credit card.
95 </p>
06d5ffeClaude96 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
134750bClaude97 <Form method="post" action="/register" csrfToken={csrf}>
0316dbbClaude98 <FormGroup label="Username" htmlFor="username">
99 <Input
100 id="username"
06d5ffeClaude101 type="text"
102 name="username"
103 required
104 pattern="^[a-zA-Z0-9_-]+$"
105 minLength={2}
106 maxLength={39}
107 placeholder="your-username"
108 autocomplete="username"
109 />
bb0f894Claude110 </FormGroup>
111 <FormGroup label="Email" htmlFor="email">
112 <Input
06d5ffeClaude113 type="email"
114 name="email"
115 required
116 placeholder="you@example.com"
117 autocomplete="email"
2c3ba6ecopilot-swe-agent[bot]118 aria-label="Email"
06d5ffeClaude119 />
bb0f894Claude120 </FormGroup>
121 <FormGroup label="Password" htmlFor="password">
122 <Input
06d5ffeClaude123 type="password"
124 name="password"
125 required
126 minLength={8}
127 placeholder="Min 8 characters"
128 autocomplete="new-password"
2c3ba6ecopilot-swe-agent[bot]129 aria-label="Password"
06d5ffeClaude130 />
bb0f894Claude131 </FormGroup>
c63b860Claude132 {/* P3 — Terms / Privacy acceptance. Required client-side via the
133 `required` attribute; server-side re-checked in POST handler. */}
134 <div class="form-group" style="margin: 12px 0">
135 <label style="display: flex; gap: 8px; align-items: flex-start; font-size: 13px; color: var(--text-muted)">
136 <input
137 type="checkbox"
138 name="accept_terms"
139 value="1"
140 required
141 style="margin-top: 3px"
142 aria-label="Accept Terms of Service and Privacy Policy"
143 />
144 <span>
145 I agree to the{" "}
2e8a4d5Claude146 <a href="/terms" target="_blank" rel="noopener">
c63b860Claude147 Terms of Service
148 </a>{" "}
149 and{" "}
2e8a4d5Claude150 <a href="/privacy" target="_blank" rel="noopener">
c63b860Claude151 Privacy Policy
152 </a>
153 .
154 </span>
155 </label>
156 </div>
bb0f894Claude157 <Button type="submit" variant="primary">
06d5ffeClaude158 Create account
bb0f894Claude159 </Button>
160 </Form>
06d5ffeClaude161 <p class="auth-switch">
bb0f894Claude162 <Text>Already have an account? <a href="/login">Sign in</a></Text>
06d5ffeClaude163 </p>
164 </div>
165 </Layout>
166 );
167});
168
169auth.post("/register", async (c) => {
170 const body = await c.req.parseBody();
171 const username = String(body.username || "").trim();
172 const email = String(body.email || "").trim();
173 const password = String(body.password || "");
174
175 if (!username || !email || !password) {
176 return c.redirect("/register?error=All+fields+are+required");
177 }
178
c63b860Claude179 // Block P3 — Terms acceptance is required. The form's checkbox has
180 // `required` so browsers normally enforce client-side; the server
181 // re-checks for defensive depth (curl, scripted POST, etc.).
182 if (!body.accept_terms) {
183 return c.redirect(
184 "/register?error=Please+accept+the+Terms+of+Service+and+Privacy+Policy"
185 );
186 }
187
06d5ffeClaude188 if (!/^[a-zA-Z0-9_-]+$/.test(username)) {
189 return c.redirect(
190 "/register?error=Username+may+only+contain+letters%2C+numbers%2C+hyphens+and+underscores"
191 );
192 }
193
194 if (password.length < 8) {
195 return c.redirect("/register?error=Password+must+be+at+least+8+characters");
196 }
197
198 // Check existing
199 const [existingUser] = await db
200 .select()
201 .from(users)
202 .where(eq(users.username, username))
203 .limit(1);
204 if (existingUser) {
205 return c.redirect("/register?error=Username+already+taken");
206 }
207
7437605Claude208 // B2: usernames share the URL namespace with org slugs; refuse collisions.
209 const [existingOrg] = await db
210 .select({ id: organizations.id })
211 .from(organizations)
212 .where(eq(organizations.slug, username.toLowerCase()))
213 .limit(1);
214 if (existingOrg) {
215 return c.redirect("/register?error=Username+already+taken");
216 }
217
06d5ffeClaude218 const [existingEmail] = await db
219 .select()
220 .from(users)
221 .where(eq(users.email, email))
222 .limit(1);
223 if (existingEmail) {
224 return c.redirect("/register?error=Email+already+registered");
225 }
226
227 const passwordHash = await hashPassword(password);
228
a4f3e24Claude229 // First user ever registered becomes admin automatically
230 const [userCount] = await db
231 .select({ count: sql`count(*)::int` })
232 .from(users);
233 const isFirstUser = (userCount?.count as number) === 0;
234
06d5ffeClaude235 const [user] = await db
236 .insert(users)
c63b860Claude237 .values({
238 username,
239 email,
240 passwordHash,
241 isAdmin: isFirstUser,
242 // P3 — record terms acceptance now. Version bumps when Terms change.
243 termsAcceptedAt: new Date(),
244 termsVersion: "1.0",
245 })
06d5ffeClaude246 .returning();
247
2d985e5Claude248 // If username matches SITE_ADMIN_USERNAME env, grant site admin instantly
249 // so the operator doesn't have to wait for the next boot's bootstrap pass.
a28cedeClaude250 await import("../lib/admin-bootstrap")
251 .then((m) => m.ensureEnvAdminOnRegister({ userId: user.id, username }))
252 .catch((err) => {
253 console.warn(
254 `[admin-bootstrap] ensureEnvAdminOnRegister failed for ${username}:`,
255 err instanceof Error ? err.message : err
256 );
257 });
2d985e5Claude258
06d5ffeClaude259 // Create session
260 const token = generateSessionToken();
261 await db.insert(sessions).values({
262 userId: user.id,
263 token,
264 expiresAt: sessionExpiry(),
265 });
266
267 setCookie(c, "session", token, sessionCookieOptions());
268
976d7f7Claude269 // Block P2 — email verification. If RESEND_API_KEY is configured the
270 // verification email goes out and the user clicks the link to verify.
271 // If email is NOT configured (EMAIL_PROVIDER=log, no RESEND_API_KEY,
272 // etc.), the email would silently never arrive and the user would be
273 // locked out — AUDIT-v2.md P0 #3. In that case, auto-verify the
274 // account on registration so the user can actually use the site.
275 // Operators who want real verification should set EMAIL_PROVIDER=resend
276 // + RESEND_API_KEY in their environment.
277 const { config: _emailConfig } = await import("../lib/config");
278 const emailConfigured =
279 _emailConfig.emailProvider === "resend" && !!_emailConfig.resendApiKey;
280 if (emailConfigured) {
281 import("../lib/email-verification")
282 .then((m) => m.startEmailVerification(user.id, email))
283 .catch((err) => {
284 console.error(
285 `[auth] startEmailVerification failed for ${user.id}:`,
286 err instanceof Error ? err.message : err
287 );
288 });
289 } else {
290 // Auto-verify immediately so the user isn't trapped in an unverified
291 // state. Log once so operators notice the misconfiguration.
292 if (!_autoVerifyWarned) {
293 _autoVerifyWarned = true;
294 console.warn(
295 "[auth] EMAIL_PROVIDER is not configured (set EMAIL_PROVIDER=resend + RESEND_API_KEY). Auto-verifying new account email addresses to avoid lockout."
296 );
297 }
298 await db
299 .update(users)
300 .set({ emailVerifiedAt: new Date() })
301 .where(eq(users.id, user.id))
302 .catch((err) => {
303 console.error(
304 `[auth] auto-verify failed for ${user.id}:`,
305 err instanceof Error ? err.message : err
306 );
307 });
308 }
c63b860Claude309
f65f600Claude310 // Onboarding drip — T+0 "welcome" email. Fire-and-forget; never blocks
311 // the redirect. Silently skips when email is not configured.
312 import("../lib/onboarding-drip")
313 .then((m) => m.sendWelcomeEmail(user.id))
314 .catch((err) => {
315 console.error(
316 `[auth] onboarding welcome email failed for ${user.id}:`,
317 err instanceof Error ? err.message : err
318 );
319 });
320
c63b860Claude321 // P3 — default landing is /onboarding (the guided first-five-minutes
322 // flow). The `redirect=` query is still honoured for OAuth-style flows.
323 const redirect = c.req.query("redirect") || "/onboarding?welcome=1";
06d5ffeClaude324 return c.redirect(redirect);
325});
326
36cc17aClaude327auth.get("/login", softAuth, async (c) => {
328 // Already-authed users hitting the sign-in page get bounced to their
329 // dashboard (or the `redirect=` target if one was supplied).
330 const existing = c.get("user");
06d5ffeClaude331 const error = c.req.query("error");
c63b860Claude332 const success = c.req.query("success");
06d5ffeClaude333 const redirect = c.req.query("redirect") || "";
36cc17aClaude334 if (existing) return c.redirect(redirect || "/dashboard");
edf7c36Claude335 const ssoCfg = await getSsoConfig();
336 const ssoEnabled =
337 !!ssoCfg?.enabled &&
338 !!ssoCfg.authorizationEndpoint &&
339 !!ssoCfg.tokenEndpoint &&
340 !!ssoCfg.userinfoEndpoint &&
341 !!ssoCfg.clientId &&
342 !!ssoCfg.clientSecret;
fc0ca99Claude343 const ssoLabel =
344 ssoCfg?.providerName || inferSsoProviderName(ssoCfg) || "SSO";
46d6165Claude345 // Block L6 — "Sign in with GitHub" (separate row keyed id='github').
346 const githubCfg = await getGithubOauthConfig();
347 const githubEnabled =
348 !!githubCfg?.enabled && !!githubCfg.clientId && !!githubCfg.clientSecret;
582cdacClaude349 // "Sign in with Google" (separate row keyed id='google'). Same wiring
350 // pattern as GitHub OAuth.
351 const googleCfg = await getGoogleOauthConfig();
352 const googleEnabled =
353 !!googleCfg?.enabled && !!googleCfg.clientId && !!googleCfg.clientSecret;
134750bClaude354 const csrf = c.get("csrfToken") as string | undefined;
06d5ffeClaude355 return c.html(
36cc17aClaude356 <Layout title="Sign in" user={null}>
ebe6d64Claude357 <AuthMobileStyle />
06d5ffeClaude358 <div class="auth-container">
98f45b4Claude359 <h2>Welcome back</h2>
360 <p class="auth-subtitle">
361 Sign in to your gluecron account.
362 </p>
06d5ffeClaude363 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
c63b860Claude364 {success && (
365 <div class="auth-success">{decodeURIComponent(success)}</div>
366 )}
0316dbbClaude367 <Form
b9968e3Claude368 method="post"
06d5ffeClaude369 action={`/login${redirect ? `?redirect=${encodeURIComponent(redirect)}` : ""}`}
134750bClaude370 csrfToken={csrf}
06d5ffeClaude371 >
bb0f894Claude372 <FormGroup label="Username or email" htmlFor="username">
373 <Input
06d5ffeClaude374 type="text"
375 name="username"
376 required
377 placeholder="username or email"
378 autocomplete="username"
2c3ba6ecopilot-swe-agent[bot]379 aria-label="Username or email"
06d5ffeClaude380 />
bb0f894Claude381 </FormGroup>
382 <FormGroup label="Password" htmlFor="password">
383 <Input
06d5ffeClaude384 type="password"
385 name="password"
386 required
387 placeholder="Password"
388 autocomplete="current-password"
2c3ba6ecopilot-swe-agent[bot]389 aria-label="Password"
06d5ffeClaude390 />
bb0f894Claude391 </FormGroup>
c63b860Claude392 <div class="auth-forgot" style="margin:-8px 0 12px;text-align:right;font-size:13px">
393 <a href="/forgot-password">Forgot password?</a>
cd4f63bTest User394 {/* BLOCK Q2 — magic-link sign-in. */}
395 <span style="margin:0 6px;color:var(--text-muted)">·</span>
396 <a href="/login/magic">Sign in with a magic link instead</a>
c63b860Claude397 </div>
bb0f894Claude398 <Button type="submit" variant="primary">
06d5ffeClaude399 Sign in
bb0f894Claude400 </Button>
3a845e4Claude401 {/* SSO domain hint — shown dynamically when the typed email domain
402 matches a known org SSO config. JS fetches /api/sso/domain-hint. */}
403 <div id="sso-domain-hint" style="display:none;margin-top:10px;padding:10px 12px;background:rgba(140,109,255,0.10);border:1px solid rgba(140,109,255,0.30);border-radius:8px;font-size:13px;color:var(--text)" aria-live="polite">
404 Your organization uses SSO. You'll be redirected to sign in.
405 </div>
bb0f894Claude406 </Form>
3a845e4Claude407 <script dangerouslySetInnerHTML={{__html: /* js */`
408 (function(){
409 var inp = document.querySelector('input[name="username"]');
410 var hint = document.getElementById('sso-domain-hint');
411 if(!inp || !hint) return;
412 var last = '';
413 inp.addEventListener('input', function(){
414 var val = inp.value.trim();
415 var at = val.indexOf('@');
416 if(at < 0) { hint.style.display='none'; return; }
417 var domain = val.slice(at+1).toLowerCase();
418 if(!domain || domain === last) return;
419 last = domain;
420 fetch('/api/sso/domain-hint?domain='+encodeURIComponent(domain))
421 .then(function(r){ return r.ok ? r.json() : null; })
422 .then(function(d){ hint.style.display = (d && d.sso) ? '' : 'none'; })
423 .catch(function(){ hint.style.display='none'; });
424 });
425 })();
426 `}} />
582cdacClaude427 {/* Provider buttons (Google + GitHub) — only rendered when the
428 admin has configured + enabled them via /admin/google-oauth or
429 /admin/github-oauth. The "or" divider above the first available
430 provider sets the visual break from the password form. */}
431 {(googleEnabled || githubEnabled) && (
432 <div class="auth-divider">or</div>
433 )}
434 {googleEnabled && (
435 <a
436 href="/login/google"
437 class="btn btn-block oauth-btn oauth-google"
438 aria-label="Sign in with Google"
439 >
440 <svg
441 class="oauth-icon"
442 width="18"
443 height="18"
444 viewBox="0 0 18 18"
445 aria-hidden="true"
446 >
447 <path
448 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"
449 fill="#4285F4"
450 />
451 <path
452 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"
453 fill="#34A853"
454 />
455 <path
456 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"
457 fill="#FBBC05"
458 />
459 <path
460 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"
461 fill="#EA4335"
462 />
463 </svg>
464 <span>Sign in with Google</span>
465 </a>
466 )}
46d6165Claude467 {githubEnabled && (
582cdacClaude468 <a
469 href="/login/github"
470 class="btn btn-block oauth-btn oauth-github"
471 aria-label="Sign in with GitHub"
472 style={googleEnabled ? "margin-top:8px" : undefined}
473 >
474 <svg
475 class="oauth-icon"
476 width="18"
477 height="18"
478 viewBox="0 0 16 16"
479 aria-hidden="true"
480 fill="currentColor"
481 >
482 <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" />
483 </svg>
484 <span>Sign in with GitHub</span>
485 </a>
46d6165Claude486 )}
09be7bfClaude487 {ssoEnabled && (
582cdacClaude488 <a
489 href="/login/sso"
490 class="btn btn-block oauth-btn oauth-sso"
491 aria-label={`Sign in with ${ssoLabel}`}
492 style={(googleEnabled || githubEnabled) ? "margin-top:8px" : undefined}
493 >
494 <span>Sign in with {ssoLabel}</span>
495 </a>
09be7bfClaude496 )}
fa06ad2Claude497 <div class="auth-passkey">
498 <div class="auth-divider">or</div>
499 <button type="button" id="pk-signin-btn" class="btn">
500 Sign in with passkey
501 </button>
502 <div
503 id="pk-signin-status"
504 class="auth-status"
505 aria-live="polite"
506 ></div>
507 </div>
06d5ffeClaude508 <p class="auth-switch">
bb0f894Claude509 <Text>New to gluecron? <a href="/register">Create an account</a></Text>
06d5ffeClaude510 </p>
2df1f8cClaude511 <script
512 dangerouslySetInnerHTML={{
513 __html: /* js */ `
514 (function () {
515 const btn = document.getElementById('pk-signin-btn');
516 const status = document.getElementById('pk-signin-status');
517 const userInput = document.getElementById('username');
518 const redirect = ${JSON.stringify(redirect || "/")};
519 if (!btn) return;
520 function b64uToBuf(s) {
521 s = s.replace(/-/g,'+').replace(/_/g,'/');
522 while (s.length % 4) s += '=';
523 const bin = atob(s);
524 const buf = new Uint8Array(bin.length);
525 for (let i=0;i<bin.length;i++) buf[i] = bin.charCodeAt(i);
526 return buf.buffer;
527 }
528 function bufToB64u(buf) {
529 const bytes = new Uint8Array(buf);
530 let bin = '';
531 for (let i=0;i<bytes.length;i++) bin += String.fromCharCode(bytes[i]);
532 return btoa(bin).replace(/\\+/g,'-').replace(/\\//g,'_').replace(/=+$/,'');
533 }
534 btn.addEventListener('click', async function () {
535 if (!window.PublicKeyCredential) {
536 status.textContent = 'Passkeys not supported in this browser.';
537 return;
538 }
539 status.textContent = 'Preparing…';
540 try {
541 const username = (userInput && userInput.value || '').trim();
542 const optsRes = await fetch('/api/passkeys/auth/options', {
543 method: 'POST',
544 headers: { 'content-type': 'application/json' },
545 body: JSON.stringify(username ? { username: username } : {})
546 });
547 if (!optsRes.ok) throw new Error('options failed');
548 const { options, sessionKey } = await optsRes.json();
549 options.challenge = b64uToBuf(options.challenge);
550 if (options.allowCredentials) {
551 options.allowCredentials = options.allowCredentials.map(function (c) {
552 return Object.assign({}, c, { id: b64uToBuf(c.id) });
553 });
554 }
555 status.textContent = 'Touch your authenticator…';
556 const cred = await navigator.credentials.get({ publicKey: options });
557 const resp = {
558 id: cred.id,
559 rawId: bufToB64u(cred.rawId),
560 type: cred.type,
561 response: {
562 clientDataJSON: bufToB64u(cred.response.clientDataJSON),
563 authenticatorData: bufToB64u(cred.response.authenticatorData),
564 signature: bufToB64u(cred.response.signature),
565 userHandle: cred.response.userHandle ? bufToB64u(cred.response.userHandle) : null
566 },
567 clientExtensionResults: cred.getClientExtensionResults ? cred.getClientExtensionResults() : {}
568 };
569 const verifyRes = await fetch('/api/passkeys/auth/verify', {
570 method: 'POST',
571 headers: { 'content-type': 'application/json' },
572 body: JSON.stringify({ sessionKey: sessionKey, response: resp })
573 });
574 if (!verifyRes.ok) {
575 const j = await verifyRes.json().catch(function () { return {}; });
576 throw new Error(j.error || 'verify failed');
577 }
578 status.textContent = 'Signed in. Redirecting…';
579 window.location.href = redirect;
580 } catch (e) {
581 status.textContent = 'Error: ' + (e && e.message ? e.message : e);
582 }
583 });
584 })();
585 `,
586 }}
587 />
06d5ffeClaude588 </div>
589 </Layout>
590 );
591});
592
ba9e143Claude593// ── Account lockout constants (SOC 2 CC6.1) ─────────────────────────────
594const LOGIN_FAIL_WINDOW_MS = 60 * 60 * 1000; // 1 hour
595const LOGIN_FAIL_LIMIT = 10;
596const LOGIN_LOCKOUT_MS = 15 * 60 * 1000; // 15 minutes
597
598/**
599 * Returns the number of failed login attempts for `email` in the last
600 * `LOGIN_FAIL_WINDOW_MS` milliseconds.
601 */
602async function countRecentFailures(email: string): Promise<number> {
603 const since = new Date(Date.now() - LOGIN_FAIL_WINDOW_MS);
604 const [row] = await db
605 .select({ count: sql<number>`count(*)::int` })
606 .from(loginAttempts)
607 .where(
608 and(
609 eq(loginAttempts.email, email.toLowerCase()),
610 eq(loginAttempts.success, false),
611 gte(loginAttempts.createdAt, since)
612 )
613 );
614 return row?.count ?? 0;
615}
616
06d5ffeClaude617auth.post("/login", async (c) => {
618 const body = await c.req.parseBody();
619 const identifier = String(body.username || "").trim();
620 const password = String(body.password || "");
621 const redirect = c.req.query("redirect") || "/";
ba9e143Claude622 const ip =
623 c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ||
624 c.req.header("x-real-ip") ||
625 "unknown";
626 const ua = c.req.header("user-agent") || "";
06d5ffeClaude627
628 if (!identifier || !password) {
629 return c.redirect("/login?error=All+fields+are+required");
630 }
631
3a845e4Claude632 // Enterprise SSO domain-hint routing: if the identifier is an email and the
633 // domain matches an org's `domain_hint`, redirect to that org's SSO flow
634 // instead of checking the password.
635 // Also: resolve the canonical email for lockout checks regardless of whether
ba9e143Claude636 // the user typed username or email.
06d5ffeClaude637 const isEmail = identifier.includes("@");
3a845e4Claude638 if (isEmail) {
639 const emailDomain = identifier.split("@")[1]?.toLowerCase();
640 if (emailDomain) {
641 const [ssoHint] = await db
642 .select({
643 provider: orgSsoConfigs.provider,
644 orgId: orgSsoConfigs.orgId,
645 })
646 .from(orgSsoConfigs)
647 .where(eq(orgSsoConfigs.domainHint, emailDomain))
648 .limit(1);
649
650 if (ssoHint) {
651 // Resolve org slug from org ID
652 const [orgRow] = await db
653 .select({ slug: organizations.slug })
654 .from(organizations)
655 .where(eq(organizations.id, ssoHint.orgId))
656 .limit(1);
657
658 if (orgRow) {
659 const protocol = ssoHint.provider === "oidc" ? "oidc" : "saml";
660 return c.redirect(`/sso/${protocol}/${orgRow.slug}/login`);
661 }
662 }
663 }
664 }
665
666 // Find user by username or email
06d5ffeClaude667 const [user] = await db
668 .select()
669 .from(users)
670 .where(
671 isEmail
672 ? eq(users.email, identifier)
673 : eq(users.username, identifier)
674 )
675 .limit(1);
676
ba9e143Claude677 // Determine the email key for lockout (use identifier if user not found
678 // so we still record the attempt without leaking account existence).
679 const emailKey = (user?.email ?? identifier).toLowerCase();
680
681 // ── Lockout check ───────────────────────────────────────────────────
682 // Check whether this email is currently locked out (≥ LOGIN_FAIL_LIMIT
683 // failures in the last LOGIN_FAIL_WINDOW_MS). We check before password
684 // verification so brute-forcers can't time-diff their way around it.
685 const recentFailures = await countRecentFailures(emailKey);
686 if (recentFailures >= LOGIN_FAIL_LIMIT) {
687 // Record that we blocked this attempt (success=false) so the window
688 // keeps rolling while the attacker keeps trying.
689 await db
690 .insert(loginAttempts)
691 .values({ email: emailKey, ip, success: false })
692 .catch(() => {});
693 await audit({
694 userId: user?.id ?? null,
695 action: "auth.login.locked",
696 ip,
697 userAgent: ua,
698 metadata: { email: emailKey, recentFailures },
699 });
700 return c.redirect(
701 "/login?error=Account+temporarily+locked+due+to+too+many+failed+login+attempts.+Please+try+again+in+15+minutes."
702 );
703 }
704
06d5ffeClaude705 if (!user) {
ba9e143Claude706 // Record failed attempt (unknown user) and return generic error.
707 await db
708 .insert(loginAttempts)
709 .values({ email: emailKey, ip, success: false })
710 .catch(() => {});
06d5ffeClaude711 return c.redirect("/login?error=Invalid+credentials");
712 }
713
714 const valid = await verifyPassword(password, user.passwordHash);
715 if (!valid) {
ba9e143Claude716 // Record failed attempt.
717 await db
718 .insert(loginAttempts)
719 .values({ email: emailKey, ip, success: false })
720 .catch(() => {});
721 await audit({
722 userId: user.id,
723 action: "auth.login.failed",
724 ip,
725 userAgent: ua,
726 metadata: { email: emailKey, attempt: recentFailures + 1 },
727 });
728 // Check if this failure just crossed the threshold.
729 if (recentFailures + 1 >= LOGIN_FAIL_LIMIT) {
730 await audit({
731 userId: user.id,
732 action: "auth.login.locked",
733 ip,
734 userAgent: ua,
735 metadata: { email: emailKey, recentFailures: recentFailures + 1 },
736 });
737 return c.redirect(
738 "/login?error=Account+temporarily+locked+due+to+too+many+failed+login+attempts.+Please+try+again+in+15+minutes."
739 );
740 }
06d5ffeClaude741 return c.redirect("/login?error=Invalid+credentials");
742 }
743
ba9e143Claude744 // Successful login — record success and clear old failure window.
745 await db
746 .insert(loginAttempts)
747 .values({ email: emailKey, ip, success: true })
748 .catch(() => {});
749
7298a17Claude750 // B4: if the user has TOTP enabled, issue a pending-2fa session and
751 // redirect to the code prompt.
752 const [totp] = await db
753 .select({ enabledAt: userTotp.enabledAt })
754 .from(userTotp)
755 .where(eq(userTotp.userId, user.id))
756 .limit(1);
757 const needs2fa = !!(totp && totp.enabledAt);
758
06d5ffeClaude759 const token = generateSessionToken();
760 await db.insert(sessions).values({
761 userId: user.id,
762 token,
763 expiresAt: sessionExpiry(),
7298a17Claude764 requires2fa: needs2fa,
ba9e143Claude765 ip,
766 userAgent: ua,
767 lastSeenAt: new Date(),
06d5ffeClaude768 });
769
770 setCookie(c, "session", token, sessionCookieOptions());
c63b860Claude771
772 // Block P5 — If account was scheduled for deletion but user signed back
773 // in, cancel the deletion. Safe regardless of 2FA: password was proven.
774 if (user.deletedAt) {
775 await cancelAccountDeletion(user.id);
776 }
777
7298a17Claude778 if (needs2fa) {
779 return c.redirect(
780 `/login/2fa?redirect=${encodeURIComponent(redirect)}`
781 );
782 }
06d5ffeClaude783 return c.redirect(redirect);
784});
785
7298a17Claude786// --- 2FA verify (B4) ---
787auth.get("/login/2fa", async (c) => {
788 const token = getCookie(c, "session");
789 if (!token) return c.redirect("/login");
790 const error = c.req.query("error");
791 const redirect = c.req.query("redirect") || "/";
792 return c.html(
36cc17aClaude793 <Layout title="Two-factor authentication" user={null}>
ebe6d64Claude794 <AuthMobileStyle />
7298a17Claude795 <div class="auth-container">
796 <h2>Enter your code</h2>
797 <p
798 class="auth-switch"
799 style="margin-bottom: 16px; margin-top: 0"
800 >
801 Open your authenticator app and enter the 6-digit code. Lost your
802 device? Paste a recovery code instead.
803 </p>
804 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
805 <form
b9968e3Claude806 method="post"
7298a17Claude807 action={`/login/2fa?redirect=${encodeURIComponent(redirect)}`}
808 >
134750bClaude809 <input type="hidden" name="_csrf" value={(c.get("csrfToken") as string | undefined) || ""} />
7298a17Claude810 <div class="form-group">
811 <label for="code">Code</label>
812 <input
813 type="text"
814 id="code"
815 name="code"
816 required
817 autocomplete="one-time-code"
818 inputmode="numeric"
819 maxLength={24}
820 placeholder="123456 or xxxx-xxxx-xxxx"
821 />
822 </div>
823 <button type="submit" class="btn btn-primary">
824 Verify
825 </button>
826 </form>
827 <p class="auth-switch">
828 <a href="/logout">Cancel</a>
829 </p>
830 </div>
831 </Layout>
832 );
833});
834
835auth.post("/login/2fa", async (c) => {
836 const token = getCookie(c, "session");
837 if (!token) return c.redirect("/login");
838 const body = await c.req.parseBody();
839 const code = String(body.code || "").trim();
840 const redirect = c.req.query("redirect") || "/";
841
842 if (!code) {
843 return c.redirect(
844 `/login/2fa?error=Code+is+required&redirect=${encodeURIComponent(redirect)}`
845 );
846 }
847
848 try {
849 const [session] = await db
850 .select()
851 .from(sessions)
852 .where(eq(sessions.token, token))
853 .limit(1);
854 if (
855 !session ||
856 new Date(session.expiresAt) < new Date() ||
857 !session.requires2fa
858 ) {
859 return c.redirect("/login");
860 }
861
862 const [totp] = await db
863 .select()
864 .from(userTotp)
865 .where(eq(userTotp.userId, session.userId))
866 .limit(1);
867 if (!totp || !totp.enabledAt) {
868 // User doesn't have 2FA actually enabled — clear the flag and let
869 // them in. This can only happen if 2FA was disabled in another
870 // session between password check and code prompt.
871 await db
872 .update(sessions)
873 .set({ requires2fa: false })
874 .where(eq(sessions.token, token));
875 return c.redirect(redirect);
876 }
877
878 // Try TOTP code first.
879 const isSix = /^\d{6}$/.test(code);
880 let ok = false;
881 if (isSix) {
882 ok = await verifyTotpCode(totp.secret, code);
883 }
884 // Fall through to recovery code.
885 if (!ok) {
886 const hash = await hashRecoveryCode(code);
887 const [rec] = await db
888 .select()
889 .from(userRecoveryCodes)
890 .where(
891 and(
892 eq(userRecoveryCodes.userId, session.userId),
893 eq(userRecoveryCodes.codeHash, hash),
894 isNull(userRecoveryCodes.usedAt)
895 )
896 )
897 .limit(1);
898 if (rec) {
899 await db
900 .update(userRecoveryCodes)
901 .set({ usedAt: new Date() })
902 .where(eq(userRecoveryCodes.id, rec.id));
903 ok = true;
904 }
905 }
906
907 if (!ok) {
908 return c.redirect(
909 `/login/2fa?error=Invalid+code&redirect=${encodeURIComponent(redirect)}`
910 );
911 }
912
913 await db
914 .update(sessions)
915 .set({ requires2fa: false })
916 .where(eq(sessions.token, token));
917 await db
918 .update(userTotp)
919 .set({ lastUsedAt: new Date() })
920 .where(eq(userTotp.userId, session.userId));
921
922 return c.redirect(redirect);
923 } catch (err) {
924 console.error("[auth] 2fa verify:", err);
925 return c.redirect(
926 `/login/2fa?error=Service+unavailable&redirect=${encodeURIComponent(redirect)}`
927 );
928 }
929});
930
06d5ffeClaude931auth.get("/logout", async (c) => {
932 deleteCookie(c, "session", { path: "/" });
933 return c.redirect("/");
934});
935
936// --- API ---
937
938auth.post("/api/auth/register", async (c) => {
939 const body = await c.req.json<{
940 username: string;
941 email: string;
942 password: string;
943 }>();
944
945 if (!body.username || !body.email || !body.password) {
946 return c.json({ error: "username, email, and password are required" }, 400);
947 }
948
949 if (!/^[a-zA-Z0-9_-]+$/.test(body.username)) {
950 return c.json({ error: "Invalid username" }, 400);
951 }
952
953 if (body.password.length < 8) {
954 return c.json({ error: "Password must be at least 8 characters" }, 400);
955 }
956
957 const [existing] = await db
958 .select()
959 .from(users)
960 .where(eq(users.username, body.username))
961 .limit(1);
962 if (existing) {
963 return c.json({ error: "Username already taken" }, 409);
964 }
965
966 const passwordHash = await hashPassword(body.password);
967 const [user] = await db
968 .insert(users)
969 .values({
970 username: body.username,
971 email: body.email,
972 passwordHash,
973 })
974 .returning();
975
976 const token = generateSessionToken();
977 await db.insert(sessions).values({
978 userId: user.id,
979 token,
980 expiresAt: sessionExpiry(),
981 });
982
983 return c.json(
984 {
985 user: { id: user.id, username: user.username, email: user.email },
986 token,
987 },
988 201
989 );
990});
991
992auth.post("/api/auth/login", async (c) => {
993 const body = await c.req.json<{ username: string; password: string }>();
994
995 if (!body.username || !body.password) {
996 return c.json({ error: "username and password are required" }, 400);
997 }
998
999 const isEmail = body.username.includes("@");
1000 const [user] = await db
1001 .select()
1002 .from(users)
1003 .where(
1004 isEmail
1005 ? eq(users.email, body.username)
1006 : eq(users.username, body.username)
1007 )
1008 .limit(1);
1009
1010 if (!user) return c.json({ error: "Invalid credentials" }, 401);
1011
1012 const valid = await verifyPassword(body.password, user.passwordHash);
1013 if (!valid) return c.json({ error: "Invalid credentials" }, 401);
1014
1015 const token = generateSessionToken();
1016 await db.insert(sessions).values({
1017 userId: user.id,
1018 token,
1019 expiresAt: sessionExpiry(),
1020 });
1021
1022 return c.json({
1023 user: { id: user.id, username: user.username, email: user.email },
1024 token,
1025 });
1026});
1027
3a845e4Claude1028// --- SSO domain-hint API (used by the login form JS) ---
1029
1030auth.get("/api/sso/domain-hint", async (c) => {
1031 const domain = String(c.req.query("domain") || "").toLowerCase().trim();
1032 if (!domain || domain.length > 253) {
1033 return c.json({ sso: false });
1034 }
1035 const [row] = await db
1036 .select({ orgId: orgSsoConfigs.orgId, provider: orgSsoConfigs.provider })
1037 .from(orgSsoConfigs)
1038 .where(eq(orgSsoConfigs.domainHint, domain))
1039 .limit(1);
1040 return c.json({ sso: !!row, provider: row?.provider ?? null });
1041});
1042
fc0ca99Claude1043/**
1044 * Pick a friendly provider name for the "Sign in with X" button when the
1045 * admin hasn't set one explicitly. Looks at the configured IdP URLs.
1046 * Falls back to undefined so the caller can default to a literal "SSO".
1047 */
1048function inferSsoProviderName(
1049 cfg: { issuer?: string | null; authorizationEndpoint?: string | null } | null | undefined
1050): string | undefined {
1051 const urls = [cfg?.issuer, cfg?.authorizationEndpoint]
1052 .filter((s): s is string => !!s)
1053 .join(" ")
1054 .toLowerCase();
1055 if (!urls) return undefined;
1056 if (urls.includes("google")) return "Google";
1057 if (urls.includes("okta")) return "Okta";
1058 if (urls.includes("microsoftonline") || urls.includes("azure")) return "Microsoft";
1059 if (urls.includes("auth0.com")) return "Auth0";
1060 if (urls.includes("authentik")) return "Authentik";
1061 return undefined;
1062}
1063
06d5ffeClaude1064export default auth;