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.tsxBlame892 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";
a4f3e24Claude7import { and, eq, isNull, sql } from "drizzle-orm";
06d5ffeClaude8import { db } from "../db";
7298a17Claude9import {
10 users,
11 sessions,
12 organizations,
13 userTotp,
14 userRecoveryCodes,
15} from "../db/schema";
06d5ffeClaude16import {
17 hashPassword,
18 verifyPassword,
19 generateSessionToken,
20 sessionCookieOptions,
21 sessionExpiry,
22} from "../lib/auth";
7298a17Claude23import { verifyTotpCode, hashRecoveryCode } from "../lib/totp";
c63b860Claude24import { cancelAccountDeletion } from "../lib/account-deletion";
582cdacClaude25import {
26 getSsoConfig,
27 getGithubOauthConfig,
28 getGoogleOauthConfig,
29} from "../lib/sso";
06d5ffeClaude30import { Layout } from "../views/layout";
bb0f894Claude31import {
32 Form,
33 FormGroup,
34 Input,
35 Button,
09be7bfClaude36 LinkButton,
bb0f894Claude37 Alert,
38 Text,
39} from "../views/ui";
36cc17aClaude40import { softAuth } from "../middleware/auth";
06d5ffeClaude41import type { AuthEnv } from "../middleware/auth";
42
43const auth = new Hono<AuthEnv>();
44
976d7f7Claude45// One-shot latch — log the auto-verify warning at most once per process,
46// since the misconfiguration is operator-level (env var) and won't change
47// between requests.
48let _autoVerifyWarned = false;
49
ebe6d64Claude50// ───────────────────────────────────────────────────────────────────────
51// Scoped mobile polish — tightens the existing `.auth-container` shell
52// from layout.tsx for ≤720px viewports. Only adds rules; does not
53// redefine the desktop styling. Kept inline so this file remains the
54// single source of truth for the auth surface.
55// ───────────────────────────────────────────────────────────────────────
56const authMobileCss = `
57 @media (max-width: 720px) {
58 .auth-container {
59 margin: 24px 12px;
60 padding: 24px 20px 22px;
61 max-width: 100%;
62 }
63 .auth-container .btn-primary { min-height: 44px; }
64 .auth-container .oauth-btn { min-height: 44px; }
65 .auth-container input[type="text"],
66 .auth-container input[type="email"],
67 .auth-container input[type="password"] { min-height: 44px; }
68 .auth-forgot { text-align: left !important; }
69 }
70`;
71const AuthMobileStyle = () => (
72 <style dangerouslySetInnerHTML={{ __html: authMobileCss }} />
73);
74
06d5ffeClaude75// --- Web UI ---
76
36cc17aClaude77auth.get("/register", softAuth, (c) => {
78 // If the user is already signed in, drop them on their dashboard rather
79 // than rendering the logged-out sign-up shell over an authed session.
80 const existing = c.get("user");
81 if (existing) return c.redirect("/dashboard");
06d5ffeClaude82 const error = c.req.query("error");
134750bClaude83 const csrf = c.get("csrfToken") as string | undefined;
06d5ffeClaude84 return c.html(
36cc17aClaude85 <Layout title="Register" user={null}>
ebe6d64Claude86 <AuthMobileStyle />
06d5ffeClaude87 <div class="auth-container">
98f45b4Claude88 <h2>Create your account</h2>
89 <p class="auth-subtitle">
90 Get the full AI suite — code review, auto-merge, spec-to-PR — on
91 unlimited public repos. No credit card.
92 </p>
06d5ffeClaude93 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
134750bClaude94 <Form method="post" action="/register" csrfToken={csrf}>
0316dbbClaude95 <FormGroup label="Username" htmlFor="username">
96 <Input
97 id="username"
06d5ffeClaude98 type="text"
99 name="username"
100 required
101 pattern="^[a-zA-Z0-9_-]+$"
102 minLength={2}
103 maxLength={39}
104 placeholder="your-username"
105 autocomplete="username"
106 />
bb0f894Claude107 </FormGroup>
108 <FormGroup label="Email" htmlFor="email">
109 <Input
06d5ffeClaude110 type="email"
111 name="email"
112 required
113 placeholder="you@example.com"
114 autocomplete="email"
2c3ba6ecopilot-swe-agent[bot]115 aria-label="Email"
06d5ffeClaude116 />
bb0f894Claude117 </FormGroup>
118 <FormGroup label="Password" htmlFor="password">
119 <Input
06d5ffeClaude120 type="password"
121 name="password"
122 required
123 minLength={8}
124 placeholder="Min 8 characters"
125 autocomplete="new-password"
2c3ba6ecopilot-swe-agent[bot]126 aria-label="Password"
06d5ffeClaude127 />
bb0f894Claude128 </FormGroup>
c63b860Claude129 {/* P3 — Terms / Privacy acceptance. Required client-side via the
130 `required` attribute; server-side re-checked in POST handler. */}
131 <div class="form-group" style="margin: 12px 0">
132 <label style="display: flex; gap: 8px; align-items: flex-start; font-size: 13px; color: var(--text-muted)">
133 <input
134 type="checkbox"
135 name="accept_terms"
136 value="1"
137 required
138 style="margin-top: 3px"
139 aria-label="Accept Terms of Service and Privacy Policy"
140 />
141 <span>
142 I agree to the{" "}
2e8a4d5Claude143 <a href="/terms" target="_blank" rel="noopener">
c63b860Claude144 Terms of Service
145 </a>{" "}
146 and{" "}
2e8a4d5Claude147 <a href="/privacy" target="_blank" rel="noopener">
c63b860Claude148 Privacy Policy
149 </a>
150 .
151 </span>
152 </label>
153 </div>
bb0f894Claude154 <Button type="submit" variant="primary">
06d5ffeClaude155 Create account
bb0f894Claude156 </Button>
157 </Form>
06d5ffeClaude158 <p class="auth-switch">
bb0f894Claude159 <Text>Already have an account? <a href="/login">Sign in</a></Text>
06d5ffeClaude160 </p>
161 </div>
162 </Layout>
163 );
164});
165
166auth.post("/register", async (c) => {
167 const body = await c.req.parseBody();
168 const username = String(body.username || "").trim();
169 const email = String(body.email || "").trim();
170 const password = String(body.password || "");
171
172 if (!username || !email || !password) {
173 return c.redirect("/register?error=All+fields+are+required");
174 }
175
c63b860Claude176 // Block P3 — Terms acceptance is required. The form's checkbox has
177 // `required` so browsers normally enforce client-side; the server
178 // re-checks for defensive depth (curl, scripted POST, etc.).
179 if (!body.accept_terms) {
180 return c.redirect(
181 "/register?error=Please+accept+the+Terms+of+Service+and+Privacy+Policy"
182 );
183 }
184
06d5ffeClaude185 if (!/^[a-zA-Z0-9_-]+$/.test(username)) {
186 return c.redirect(
187 "/register?error=Username+may+only+contain+letters%2C+numbers%2C+hyphens+and+underscores"
188 );
189 }
190
191 if (password.length < 8) {
192 return c.redirect("/register?error=Password+must+be+at+least+8+characters");
193 }
194
195 // Check existing
196 const [existingUser] = await db
197 .select()
198 .from(users)
199 .where(eq(users.username, username))
200 .limit(1);
201 if (existingUser) {
202 return c.redirect("/register?error=Username+already+taken");
203 }
204
7437605Claude205 // B2: usernames share the URL namespace with org slugs; refuse collisions.
206 const [existingOrg] = await db
207 .select({ id: organizations.id })
208 .from(organizations)
209 .where(eq(organizations.slug, username.toLowerCase()))
210 .limit(1);
211 if (existingOrg) {
212 return c.redirect("/register?error=Username+already+taken");
213 }
214
06d5ffeClaude215 const [existingEmail] = await db
216 .select()
217 .from(users)
218 .where(eq(users.email, email))
219 .limit(1);
220 if (existingEmail) {
221 return c.redirect("/register?error=Email+already+registered");
222 }
223
224 const passwordHash = await hashPassword(password);
225
a4f3e24Claude226 // First user ever registered becomes admin automatically
227 const [userCount] = await db
228 .select({ count: sql`count(*)::int` })
229 .from(users);
230 const isFirstUser = (userCount?.count as number) === 0;
231
06d5ffeClaude232 const [user] = await db
233 .insert(users)
c63b860Claude234 .values({
235 username,
236 email,
237 passwordHash,
238 isAdmin: isFirstUser,
239 // P3 — record terms acceptance now. Version bumps when Terms change.
240 termsAcceptedAt: new Date(),
241 termsVersion: "1.0",
242 })
06d5ffeClaude243 .returning();
244
2d985e5Claude245 // If username matches SITE_ADMIN_USERNAME env, grant site admin instantly
246 // so the operator doesn't have to wait for the next boot's bootstrap pass.
a28cedeClaude247 await import("../lib/admin-bootstrap")
248 .then((m) => m.ensureEnvAdminOnRegister({ userId: user.id, username }))
249 .catch((err) => {
250 console.warn(
251 `[admin-bootstrap] ensureEnvAdminOnRegister failed for ${username}:`,
252 err instanceof Error ? err.message : err
253 );
254 });
2d985e5Claude255
06d5ffeClaude256 // Create session
257 const token = generateSessionToken();
258 await db.insert(sessions).values({
259 userId: user.id,
260 token,
261 expiresAt: sessionExpiry(),
262 });
263
264 setCookie(c, "session", token, sessionCookieOptions());
265
976d7f7Claude266 // Block P2 — email verification. If RESEND_API_KEY is configured the
267 // verification email goes out and the user clicks the link to verify.
268 // If email is NOT configured (EMAIL_PROVIDER=log, no RESEND_API_KEY,
269 // etc.), the email would silently never arrive and the user would be
270 // locked out — AUDIT-v2.md P0 #3. In that case, auto-verify the
271 // account on registration so the user can actually use the site.
272 // Operators who want real verification should set EMAIL_PROVIDER=resend
273 // + RESEND_API_KEY in their environment.
274 const { config: _emailConfig } = await import("../lib/config");
275 const emailConfigured =
276 _emailConfig.emailProvider === "resend" && !!_emailConfig.resendApiKey;
277 if (emailConfigured) {
278 import("../lib/email-verification")
279 .then((m) => m.startEmailVerification(user.id, email))
280 .catch((err) => {
281 console.error(
282 `[auth] startEmailVerification failed for ${user.id}:`,
283 err instanceof Error ? err.message : err
284 );
285 });
286 } else {
287 // Auto-verify immediately so the user isn't trapped in an unverified
288 // state. Log once so operators notice the misconfiguration.
289 if (!_autoVerifyWarned) {
290 _autoVerifyWarned = true;
291 console.warn(
292 "[auth] EMAIL_PROVIDER is not configured (set EMAIL_PROVIDER=resend + RESEND_API_KEY). Auto-verifying new account email addresses to avoid lockout."
293 );
294 }
295 await db
296 .update(users)
297 .set({ emailVerifiedAt: new Date() })
298 .where(eq(users.id, user.id))
299 .catch((err) => {
300 console.error(
301 `[auth] auto-verify failed for ${user.id}:`,
302 err instanceof Error ? err.message : err
303 );
304 });
305 }
c63b860Claude306
f65f600Claude307 // Onboarding drip — T+0 "welcome" email. Fire-and-forget; never blocks
308 // the redirect. Silently skips when email is not configured.
309 import("../lib/onboarding-drip")
310 .then((m) => m.sendWelcomeEmail(user.id))
311 .catch((err) => {
312 console.error(
313 `[auth] onboarding welcome email failed for ${user.id}:`,
314 err instanceof Error ? err.message : err
315 );
316 });
317
c63b860Claude318 // P3 — default landing is /onboarding (the guided first-five-minutes
319 // flow). The `redirect=` query is still honoured for OAuth-style flows.
320 const redirect = c.req.query("redirect") || "/onboarding?welcome=1";
06d5ffeClaude321 return c.redirect(redirect);
322});
323
36cc17aClaude324auth.get("/login", softAuth, async (c) => {
325 // Already-authed users hitting the sign-in page get bounced to their
326 // dashboard (or the `redirect=` target if one was supplied).
327 const existing = c.get("user");
06d5ffeClaude328 const error = c.req.query("error");
c63b860Claude329 const success = c.req.query("success");
06d5ffeClaude330 const redirect = c.req.query("redirect") || "";
36cc17aClaude331 if (existing) return c.redirect(redirect || "/dashboard");
edf7c36Claude332 const ssoCfg = await getSsoConfig();
333 const ssoEnabled =
334 !!ssoCfg?.enabled &&
335 !!ssoCfg.authorizationEndpoint &&
336 !!ssoCfg.tokenEndpoint &&
337 !!ssoCfg.userinfoEndpoint &&
338 !!ssoCfg.clientId &&
339 !!ssoCfg.clientSecret;
fc0ca99Claude340 const ssoLabel =
341 ssoCfg?.providerName || inferSsoProviderName(ssoCfg) || "SSO";
46d6165Claude342 // Block L6 — "Sign in with GitHub" (separate row keyed id='github').
343 const githubCfg = await getGithubOauthConfig();
344 const githubEnabled =
345 !!githubCfg?.enabled && !!githubCfg.clientId && !!githubCfg.clientSecret;
582cdacClaude346 // "Sign in with Google" (separate row keyed id='google'). Same wiring
347 // pattern as GitHub OAuth.
348 const googleCfg = await getGoogleOauthConfig();
349 const googleEnabled =
350 !!googleCfg?.enabled && !!googleCfg.clientId && !!googleCfg.clientSecret;
134750bClaude351 const csrf = c.get("csrfToken") as string | undefined;
06d5ffeClaude352 return c.html(
36cc17aClaude353 <Layout title="Sign in" user={null}>
ebe6d64Claude354 <AuthMobileStyle />
06d5ffeClaude355 <div class="auth-container">
98f45b4Claude356 <h2>Welcome back</h2>
357 <p class="auth-subtitle">
358 Sign in to your gluecron account.
359 </p>
06d5ffeClaude360 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
c63b860Claude361 {success && (
362 <div class="auth-success">{decodeURIComponent(success)}</div>
363 )}
0316dbbClaude364 <Form
b9968e3Claude365 method="post"
06d5ffeClaude366 action={`/login${redirect ? `?redirect=${encodeURIComponent(redirect)}` : ""}`}
134750bClaude367 csrfToken={csrf}
06d5ffeClaude368 >
bb0f894Claude369 <FormGroup label="Username or email" htmlFor="username">
370 <Input
06d5ffeClaude371 type="text"
372 name="username"
373 required
374 placeholder="username or email"
375 autocomplete="username"
2c3ba6ecopilot-swe-agent[bot]376 aria-label="Username or email"
06d5ffeClaude377 />
bb0f894Claude378 </FormGroup>
379 <FormGroup label="Password" htmlFor="password">
380 <Input
06d5ffeClaude381 type="password"
382 name="password"
383 required
384 placeholder="Password"
385 autocomplete="current-password"
2c3ba6ecopilot-swe-agent[bot]386 aria-label="Password"
06d5ffeClaude387 />
bb0f894Claude388 </FormGroup>
c63b860Claude389 <div class="auth-forgot" style="margin:-8px 0 12px;text-align:right;font-size:13px">
390 <a href="/forgot-password">Forgot password?</a>
cd4f63bTest User391 {/* BLOCK Q2 — magic-link sign-in. */}
392 <span style="margin:0 6px;color:var(--text-muted)">·</span>
393 <a href="/login/magic">Sign in with a magic link instead</a>
c63b860Claude394 </div>
bb0f894Claude395 <Button type="submit" variant="primary">
06d5ffeClaude396 Sign in
bb0f894Claude397 </Button>
398 </Form>
582cdacClaude399 {/* Provider buttons (Google + GitHub) — only rendered when the
400 admin has configured + enabled them via /admin/google-oauth or
401 /admin/github-oauth. The "or" divider above the first available
402 provider sets the visual break from the password form. */}
403 {(googleEnabled || githubEnabled) && (
404 <div class="auth-divider">or</div>
405 )}
406 {googleEnabled && (
407 <a
408 href="/login/google"
409 class="btn btn-block oauth-btn oauth-google"
410 aria-label="Sign in with Google"
411 >
412 <svg
413 class="oauth-icon"
414 width="18"
415 height="18"
416 viewBox="0 0 18 18"
417 aria-hidden="true"
418 >
419 <path
420 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"
421 fill="#4285F4"
422 />
423 <path
424 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"
425 fill="#34A853"
426 />
427 <path
428 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"
429 fill="#FBBC05"
430 />
431 <path
432 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"
433 fill="#EA4335"
434 />
435 </svg>
436 <span>Sign in with Google</span>
437 </a>
438 )}
46d6165Claude439 {githubEnabled && (
582cdacClaude440 <a
441 href="/login/github"
442 class="btn btn-block oauth-btn oauth-github"
443 aria-label="Sign in with GitHub"
444 style={googleEnabled ? "margin-top:8px" : undefined}
445 >
446 <svg
447 class="oauth-icon"
448 width="18"
449 height="18"
450 viewBox="0 0 16 16"
451 aria-hidden="true"
452 fill="currentColor"
453 >
454 <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" />
455 </svg>
456 <span>Sign in with GitHub</span>
457 </a>
46d6165Claude458 )}
09be7bfClaude459 {ssoEnabled && (
582cdacClaude460 <a
461 href="/login/sso"
462 class="btn btn-block oauth-btn oauth-sso"
463 aria-label={`Sign in with ${ssoLabel}`}
464 style={(googleEnabled || githubEnabled) ? "margin-top:8px" : undefined}
465 >
466 <span>Sign in with {ssoLabel}</span>
467 </a>
09be7bfClaude468 )}
fa06ad2Claude469 <div class="auth-passkey">
470 <div class="auth-divider">or</div>
471 <button type="button" id="pk-signin-btn" class="btn">
472 Sign in with passkey
473 </button>
474 <div
475 id="pk-signin-status"
476 class="auth-status"
477 aria-live="polite"
478 ></div>
479 </div>
06d5ffeClaude480 <p class="auth-switch">
bb0f894Claude481 <Text>New to gluecron? <a href="/register">Create an account</a></Text>
06d5ffeClaude482 </p>
2df1f8cClaude483 <script
484 dangerouslySetInnerHTML={{
485 __html: /* js */ `
486 (function () {
487 const btn = document.getElementById('pk-signin-btn');
488 const status = document.getElementById('pk-signin-status');
489 const userInput = document.getElementById('username');
490 const redirect = ${JSON.stringify(redirect || "/")};
491 if (!btn) return;
492 function b64uToBuf(s) {
493 s = s.replace(/-/g,'+').replace(/_/g,'/');
494 while (s.length % 4) s += '=';
495 const bin = atob(s);
496 const buf = new Uint8Array(bin.length);
497 for (let i=0;i<bin.length;i++) buf[i] = bin.charCodeAt(i);
498 return buf.buffer;
499 }
500 function bufToB64u(buf) {
501 const bytes = new Uint8Array(buf);
502 let bin = '';
503 for (let i=0;i<bytes.length;i++) bin += String.fromCharCode(bytes[i]);
504 return btoa(bin).replace(/\\+/g,'-').replace(/\\//g,'_').replace(/=+$/,'');
505 }
506 btn.addEventListener('click', async function () {
507 if (!window.PublicKeyCredential) {
508 status.textContent = 'Passkeys not supported in this browser.';
509 return;
510 }
511 status.textContent = 'Preparing…';
512 try {
513 const username = (userInput && userInput.value || '').trim();
514 const optsRes = await fetch('/api/passkeys/auth/options', {
515 method: 'POST',
516 headers: { 'content-type': 'application/json' },
517 body: JSON.stringify(username ? { username: username } : {})
518 });
519 if (!optsRes.ok) throw new Error('options failed');
520 const { options, sessionKey } = await optsRes.json();
521 options.challenge = b64uToBuf(options.challenge);
522 if (options.allowCredentials) {
523 options.allowCredentials = options.allowCredentials.map(function (c) {
524 return Object.assign({}, c, { id: b64uToBuf(c.id) });
525 });
526 }
527 status.textContent = 'Touch your authenticator…';
528 const cred = await navigator.credentials.get({ publicKey: options });
529 const resp = {
530 id: cred.id,
531 rawId: bufToB64u(cred.rawId),
532 type: cred.type,
533 response: {
534 clientDataJSON: bufToB64u(cred.response.clientDataJSON),
535 authenticatorData: bufToB64u(cred.response.authenticatorData),
536 signature: bufToB64u(cred.response.signature),
537 userHandle: cred.response.userHandle ? bufToB64u(cred.response.userHandle) : null
538 },
539 clientExtensionResults: cred.getClientExtensionResults ? cred.getClientExtensionResults() : {}
540 };
541 const verifyRes = await fetch('/api/passkeys/auth/verify', {
542 method: 'POST',
543 headers: { 'content-type': 'application/json' },
544 body: JSON.stringify({ sessionKey: sessionKey, response: resp })
545 });
546 if (!verifyRes.ok) {
547 const j = await verifyRes.json().catch(function () { return {}; });
548 throw new Error(j.error || 'verify failed');
549 }
550 status.textContent = 'Signed in. Redirecting…';
551 window.location.href = redirect;
552 } catch (e) {
553 status.textContent = 'Error: ' + (e && e.message ? e.message : e);
554 }
555 });
556 })();
557 `,
558 }}
559 />
06d5ffeClaude560 </div>
561 </Layout>
562 );
563});
564
565auth.post("/login", async (c) => {
566 const body = await c.req.parseBody();
567 const identifier = String(body.username || "").trim();
568 const password = String(body.password || "");
569 const redirect = c.req.query("redirect") || "/";
570
571 if (!identifier || !password) {
572 return c.redirect("/login?error=All+fields+are+required");
573 }
574
575 // Find user by username or email
576 const isEmail = identifier.includes("@");
577 const [user] = await db
578 .select()
579 .from(users)
580 .where(
581 isEmail
582 ? eq(users.email, identifier)
583 : eq(users.username, identifier)
584 )
585 .limit(1);
586
587 if (!user) {
588 return c.redirect("/login?error=Invalid+credentials");
589 }
590
591 const valid = await verifyPassword(password, user.passwordHash);
592 if (!valid) {
593 return c.redirect("/login?error=Invalid+credentials");
594 }
595
7298a17Claude596 // B4: if the user has TOTP enabled, issue a pending-2fa session and
597 // redirect to the code prompt.
598 const [totp] = await db
599 .select({ enabledAt: userTotp.enabledAt })
600 .from(userTotp)
601 .where(eq(userTotp.userId, user.id))
602 .limit(1);
603 const needs2fa = !!(totp && totp.enabledAt);
604
06d5ffeClaude605 const token = generateSessionToken();
606 await db.insert(sessions).values({
607 userId: user.id,
608 token,
609 expiresAt: sessionExpiry(),
7298a17Claude610 requires2fa: needs2fa,
06d5ffeClaude611 });
612
613 setCookie(c, "session", token, sessionCookieOptions());
c63b860Claude614
615 // Block P5 — If account was scheduled for deletion but user signed back
616 // in, cancel the deletion. Safe regardless of 2FA: password was proven.
617 if (user.deletedAt) {
618 await cancelAccountDeletion(user.id);
619 }
620
7298a17Claude621 if (needs2fa) {
622 return c.redirect(
623 `/login/2fa?redirect=${encodeURIComponent(redirect)}`
624 );
625 }
06d5ffeClaude626 return c.redirect(redirect);
627});
628
7298a17Claude629// --- 2FA verify (B4) ---
630auth.get("/login/2fa", async (c) => {
631 const token = getCookie(c, "session");
632 if (!token) return c.redirect("/login");
633 const error = c.req.query("error");
634 const redirect = c.req.query("redirect") || "/";
635 return c.html(
36cc17aClaude636 <Layout title="Two-factor authentication" user={null}>
ebe6d64Claude637 <AuthMobileStyle />
7298a17Claude638 <div class="auth-container">
639 <h2>Enter your code</h2>
640 <p
641 class="auth-switch"
642 style="margin-bottom: 16px; margin-top: 0"
643 >
644 Open your authenticator app and enter the 6-digit code. Lost your
645 device? Paste a recovery code instead.
646 </p>
647 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
648 <form
b9968e3Claude649 method="post"
7298a17Claude650 action={`/login/2fa?redirect=${encodeURIComponent(redirect)}`}
651 >
134750bClaude652 <input type="hidden" name="_csrf" value={(c.get("csrfToken") as string | undefined) || ""} />
7298a17Claude653 <div class="form-group">
654 <label for="code">Code</label>
655 <input
656 type="text"
657 id="code"
658 name="code"
659 required
660 autocomplete="one-time-code"
661 inputmode="numeric"
662 maxLength={24}
663 placeholder="123456 or xxxx-xxxx-xxxx"
664 />
665 </div>
666 <button type="submit" class="btn btn-primary">
667 Verify
668 </button>
669 </form>
670 <p class="auth-switch">
671 <a href="/logout">Cancel</a>
672 </p>
673 </div>
674 </Layout>
675 );
676});
677
678auth.post("/login/2fa", async (c) => {
679 const token = getCookie(c, "session");
680 if (!token) return c.redirect("/login");
681 const body = await c.req.parseBody();
682 const code = String(body.code || "").trim();
683 const redirect = c.req.query("redirect") || "/";
684
685 if (!code) {
686 return c.redirect(
687 `/login/2fa?error=Code+is+required&redirect=${encodeURIComponent(redirect)}`
688 );
689 }
690
691 try {
692 const [session] = await db
693 .select()
694 .from(sessions)
695 .where(eq(sessions.token, token))
696 .limit(1);
697 if (
698 !session ||
699 new Date(session.expiresAt) < new Date() ||
700 !session.requires2fa
701 ) {
702 return c.redirect("/login");
703 }
704
705 const [totp] = await db
706 .select()
707 .from(userTotp)
708 .where(eq(userTotp.userId, session.userId))
709 .limit(1);
710 if (!totp || !totp.enabledAt) {
711 // User doesn't have 2FA actually enabled — clear the flag and let
712 // them in. This can only happen if 2FA was disabled in another
713 // session between password check and code prompt.
714 await db
715 .update(sessions)
716 .set({ requires2fa: false })
717 .where(eq(sessions.token, token));
718 return c.redirect(redirect);
719 }
720
721 // Try TOTP code first.
722 const isSix = /^\d{6}$/.test(code);
723 let ok = false;
724 if (isSix) {
725 ok = await verifyTotpCode(totp.secret, code);
726 }
727 // Fall through to recovery code.
728 if (!ok) {
729 const hash = await hashRecoveryCode(code);
730 const [rec] = await db
731 .select()
732 .from(userRecoveryCodes)
733 .where(
734 and(
735 eq(userRecoveryCodes.userId, session.userId),
736 eq(userRecoveryCodes.codeHash, hash),
737 isNull(userRecoveryCodes.usedAt)
738 )
739 )
740 .limit(1);
741 if (rec) {
742 await db
743 .update(userRecoveryCodes)
744 .set({ usedAt: new Date() })
745 .where(eq(userRecoveryCodes.id, rec.id));
746 ok = true;
747 }
748 }
749
750 if (!ok) {
751 return c.redirect(
752 `/login/2fa?error=Invalid+code&redirect=${encodeURIComponent(redirect)}`
753 );
754 }
755
756 await db
757 .update(sessions)
758 .set({ requires2fa: false })
759 .where(eq(sessions.token, token));
760 await db
761 .update(userTotp)
762 .set({ lastUsedAt: new Date() })
763 .where(eq(userTotp.userId, session.userId));
764
765 return c.redirect(redirect);
766 } catch (err) {
767 console.error("[auth] 2fa verify:", err);
768 return c.redirect(
769 `/login/2fa?error=Service+unavailable&redirect=${encodeURIComponent(redirect)}`
770 );
771 }
772});
773
06d5ffeClaude774auth.get("/logout", async (c) => {
775 deleteCookie(c, "session", { path: "/" });
776 return c.redirect("/");
777});
778
779// --- API ---
780
781auth.post("/api/auth/register", async (c) => {
782 const body = await c.req.json<{
783 username: string;
784 email: string;
785 password: string;
786 }>();
787
788 if (!body.username || !body.email || !body.password) {
789 return c.json({ error: "username, email, and password are required" }, 400);
790 }
791
792 if (!/^[a-zA-Z0-9_-]+$/.test(body.username)) {
793 return c.json({ error: "Invalid username" }, 400);
794 }
795
796 if (body.password.length < 8) {
797 return c.json({ error: "Password must be at least 8 characters" }, 400);
798 }
799
800 const [existing] = await db
801 .select()
802 .from(users)
803 .where(eq(users.username, body.username))
804 .limit(1);
805 if (existing) {
806 return c.json({ error: "Username already taken" }, 409);
807 }
808
809 const passwordHash = await hashPassword(body.password);
810 const [user] = await db
811 .insert(users)
812 .values({
813 username: body.username,
814 email: body.email,
815 passwordHash,
816 })
817 .returning();
818
819 const token = generateSessionToken();
820 await db.insert(sessions).values({
821 userId: user.id,
822 token,
823 expiresAt: sessionExpiry(),
824 });
825
826 return c.json(
827 {
828 user: { id: user.id, username: user.username, email: user.email },
829 token,
830 },
831 201
832 );
833});
834
835auth.post("/api/auth/login", async (c) => {
836 const body = await c.req.json<{ username: string; password: string }>();
837
838 if (!body.username || !body.password) {
839 return c.json({ error: "username and password are required" }, 400);
840 }
841
842 const isEmail = body.username.includes("@");
843 const [user] = await db
844 .select()
845 .from(users)
846 .where(
847 isEmail
848 ? eq(users.email, body.username)
849 : eq(users.username, body.username)
850 )
851 .limit(1);
852
853 if (!user) return c.json({ error: "Invalid credentials" }, 401);
854
855 const valid = await verifyPassword(body.password, user.passwordHash);
856 if (!valid) return c.json({ error: "Invalid credentials" }, 401);
857
858 const token = generateSessionToken();
859 await db.insert(sessions).values({
860 userId: user.id,
861 token,
862 expiresAt: sessionExpiry(),
863 });
864
865 return c.json({
866 user: { id: user.id, username: user.username, email: user.email },
867 token,
868 });
869});
870
fc0ca99Claude871/**
872 * Pick a friendly provider name for the "Sign in with X" button when the
873 * admin hasn't set one explicitly. Looks at the configured IdP URLs.
874 * Falls back to undefined so the caller can default to a literal "SSO".
875 */
876function inferSsoProviderName(
877 cfg: { issuer?: string | null; authorizationEndpoint?: string | null } | null | undefined
878): string | undefined {
879 const urls = [cfg?.issuer, cfg?.authorizationEndpoint]
880 .filter((s): s is string => !!s)
881 .join(" ")
882 .toLowerCase();
883 if (!urls) return undefined;
884 if (urls.includes("google")) return "Google";
885 if (urls.includes("okta")) return "Okta";
886 if (urls.includes("microsoftonline") || urls.includes("azure")) return "Microsoft";
887 if (urls.includes("auth0.com")) return "Auth0";
888 if (urls.includes("authentik")) return "Authentik";
889 return undefined;
890}
891
06d5ffeClaude892export default auth;