Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

auth.tsx

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

auth.tsxBlame853 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
06d5ffeClaude50// --- Web UI ---
51
36cc17aClaude52auth.get("/register", softAuth, (c) => {
53 // If the user is already signed in, drop them on their dashboard rather
54 // than rendering the logged-out sign-up shell over an authed session.
55 const existing = c.get("user");
56 if (existing) return c.redirect("/dashboard");
06d5ffeClaude57 const error = c.req.query("error");
134750bClaude58 const csrf = c.get("csrfToken") as string | undefined;
06d5ffeClaude59 return c.html(
36cc17aClaude60 <Layout title="Register" user={null}>
06d5ffeClaude61 <div class="auth-container">
98f45b4Claude62 <h2>Create your account</h2>
63 <p class="auth-subtitle">
64 Get the full AI suite — code review, auto-merge, spec-to-PR — on
65 unlimited public repos. No credit card.
66 </p>
06d5ffeClaude67 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
134750bClaude68 <Form method="post" action="/register" csrfToken={csrf}>
0316dbbClaude69 <FormGroup label="Username" htmlFor="username">
70 <Input
71 id="username"
06d5ffeClaude72 type="text"
73 name="username"
74 required
75 pattern="^[a-zA-Z0-9_-]+$"
76 minLength={2}
77 maxLength={39}
78 placeholder="your-username"
79 autocomplete="username"
80 />
bb0f894Claude81 </FormGroup>
82 <FormGroup label="Email" htmlFor="email">
83 <Input
06d5ffeClaude84 type="email"
85 name="email"
86 required
87 placeholder="you@example.com"
88 autocomplete="email"
2c3ba6ecopilot-swe-agent[bot]89 aria-label="Email"
06d5ffeClaude90 />
bb0f894Claude91 </FormGroup>
92 <FormGroup label="Password" htmlFor="password">
93 <Input
06d5ffeClaude94 type="password"
95 name="password"
96 required
97 minLength={8}
98 placeholder="Min 8 characters"
99 autocomplete="new-password"
2c3ba6ecopilot-swe-agent[bot]100 aria-label="Password"
06d5ffeClaude101 />
bb0f894Claude102 </FormGroup>
c63b860Claude103 {/* P3 — Terms / Privacy acceptance. Required client-side via the
104 `required` attribute; server-side re-checked in POST handler. */}
105 <div class="form-group" style="margin: 12px 0">
106 <label style="display: flex; gap: 8px; align-items: flex-start; font-size: 13px; color: var(--text-muted)">
107 <input
108 type="checkbox"
109 name="accept_terms"
110 value="1"
111 required
112 style="margin-top: 3px"
113 aria-label="Accept Terms of Service and Privacy Policy"
114 />
115 <span>
116 I agree to the{" "}
2e8a4d5Claude117 <a href="/terms" target="_blank" rel="noopener">
c63b860Claude118 Terms of Service
119 </a>{" "}
120 and{" "}
2e8a4d5Claude121 <a href="/privacy" target="_blank" rel="noopener">
c63b860Claude122 Privacy Policy
123 </a>
124 .
125 </span>
126 </label>
127 </div>
bb0f894Claude128 <Button type="submit" variant="primary">
06d5ffeClaude129 Create account
bb0f894Claude130 </Button>
131 </Form>
06d5ffeClaude132 <p class="auth-switch">
bb0f894Claude133 <Text>Already have an account? <a href="/login">Sign in</a></Text>
06d5ffeClaude134 </p>
135 </div>
136 </Layout>
137 );
138});
139
140auth.post("/register", async (c) => {
141 const body = await c.req.parseBody();
142 const username = String(body.username || "").trim();
143 const email = String(body.email || "").trim();
144 const password = String(body.password || "");
145
146 if (!username || !email || !password) {
147 return c.redirect("/register?error=All+fields+are+required");
148 }
149
c63b860Claude150 // Block P3 — Terms acceptance is required. The form's checkbox has
151 // `required` so browsers normally enforce client-side; the server
152 // re-checks for defensive depth (curl, scripted POST, etc.).
153 if (!body.accept_terms) {
154 return c.redirect(
155 "/register?error=Please+accept+the+Terms+of+Service+and+Privacy+Policy"
156 );
157 }
158
06d5ffeClaude159 if (!/^[a-zA-Z0-9_-]+$/.test(username)) {
160 return c.redirect(
161 "/register?error=Username+may+only+contain+letters%2C+numbers%2C+hyphens+and+underscores"
162 );
163 }
164
165 if (password.length < 8) {
166 return c.redirect("/register?error=Password+must+be+at+least+8+characters");
167 }
168
169 // Check existing
170 const [existingUser] = await db
171 .select()
172 .from(users)
173 .where(eq(users.username, username))
174 .limit(1);
175 if (existingUser) {
176 return c.redirect("/register?error=Username+already+taken");
177 }
178
7437605Claude179 // B2: usernames share the URL namespace with org slugs; refuse collisions.
180 const [existingOrg] = await db
181 .select({ id: organizations.id })
182 .from(organizations)
183 .where(eq(organizations.slug, username.toLowerCase()))
184 .limit(1);
185 if (existingOrg) {
186 return c.redirect("/register?error=Username+already+taken");
187 }
188
06d5ffeClaude189 const [existingEmail] = await db
190 .select()
191 .from(users)
192 .where(eq(users.email, email))
193 .limit(1);
194 if (existingEmail) {
195 return c.redirect("/register?error=Email+already+registered");
196 }
197
198 const passwordHash = await hashPassword(password);
199
a4f3e24Claude200 // First user ever registered becomes admin automatically
201 const [userCount] = await db
202 .select({ count: sql`count(*)::int` })
203 .from(users);
204 const isFirstUser = (userCount?.count as number) === 0;
205
06d5ffeClaude206 const [user] = await db
207 .insert(users)
c63b860Claude208 .values({
209 username,
210 email,
211 passwordHash,
212 isAdmin: isFirstUser,
213 // P3 — record terms acceptance now. Version bumps when Terms change.
214 termsAcceptedAt: new Date(),
215 termsVersion: "1.0",
216 })
06d5ffeClaude217 .returning();
218
2d985e5Claude219 // If username matches SITE_ADMIN_USERNAME env, grant site admin instantly
220 // so the operator doesn't have to wait for the next boot's bootstrap pass.
a28cedeClaude221 await import("../lib/admin-bootstrap")
222 .then((m) => m.ensureEnvAdminOnRegister({ userId: user.id, username }))
223 .catch((err) => {
224 console.warn(
225 `[admin-bootstrap] ensureEnvAdminOnRegister failed for ${username}:`,
226 err instanceof Error ? err.message : err
227 );
228 });
2d985e5Claude229
06d5ffeClaude230 // Create session
231 const token = generateSessionToken();
232 await db.insert(sessions).values({
233 userId: user.id,
234 token,
235 expiresAt: sessionExpiry(),
236 });
237
238 setCookie(c, "session", token, sessionCookieOptions());
239
976d7f7Claude240 // Block P2 — email verification. If RESEND_API_KEY is configured the
241 // verification email goes out and the user clicks the link to verify.
242 // If email is NOT configured (EMAIL_PROVIDER=log, no RESEND_API_KEY,
243 // etc.), the email would silently never arrive and the user would be
244 // locked out — AUDIT-v2.md P0 #3. In that case, auto-verify the
245 // account on registration so the user can actually use the site.
246 // Operators who want real verification should set EMAIL_PROVIDER=resend
247 // + RESEND_API_KEY in their environment.
248 const { config: _emailConfig } = await import("../lib/config");
249 const emailConfigured =
250 _emailConfig.emailProvider === "resend" && !!_emailConfig.resendApiKey;
251 if (emailConfigured) {
252 import("../lib/email-verification")
253 .then((m) => m.startEmailVerification(user.id, email))
254 .catch((err) => {
255 console.error(
256 `[auth] startEmailVerification failed for ${user.id}:`,
257 err instanceof Error ? err.message : err
258 );
259 });
260 } else {
261 // Auto-verify immediately so the user isn't trapped in an unverified
262 // state. Log once so operators notice the misconfiguration.
263 if (!_autoVerifyWarned) {
264 _autoVerifyWarned = true;
265 console.warn(
266 "[auth] EMAIL_PROVIDER is not configured (set EMAIL_PROVIDER=resend + RESEND_API_KEY). Auto-verifying new account email addresses to avoid lockout."
267 );
268 }
269 await db
270 .update(users)
271 .set({ emailVerifiedAt: new Date() })
272 .where(eq(users.id, user.id))
273 .catch((err) => {
274 console.error(
275 `[auth] auto-verify failed for ${user.id}:`,
276 err instanceof Error ? err.message : err
277 );
278 });
279 }
c63b860Claude280
281 // P3 — default landing is /onboarding (the guided first-five-minutes
282 // flow). The `redirect=` query is still honoured for OAuth-style flows.
283 const redirect = c.req.query("redirect") || "/onboarding?welcome=1";
06d5ffeClaude284 return c.redirect(redirect);
285});
286
36cc17aClaude287auth.get("/login", softAuth, async (c) => {
288 // Already-authed users hitting the sign-in page get bounced to their
289 // dashboard (or the `redirect=` target if one was supplied).
290 const existing = c.get("user");
06d5ffeClaude291 const error = c.req.query("error");
c63b860Claude292 const success = c.req.query("success");
06d5ffeClaude293 const redirect = c.req.query("redirect") || "";
36cc17aClaude294 if (existing) return c.redirect(redirect || "/dashboard");
edf7c36Claude295 const ssoCfg = await getSsoConfig();
296 const ssoEnabled =
297 !!ssoCfg?.enabled &&
298 !!ssoCfg.authorizationEndpoint &&
299 !!ssoCfg.tokenEndpoint &&
300 !!ssoCfg.userinfoEndpoint &&
301 !!ssoCfg.clientId &&
302 !!ssoCfg.clientSecret;
fc0ca99Claude303 const ssoLabel =
304 ssoCfg?.providerName || inferSsoProviderName(ssoCfg) || "SSO";
46d6165Claude305 // Block L6 — "Sign in with GitHub" (separate row keyed id='github').
306 const githubCfg = await getGithubOauthConfig();
307 const githubEnabled =
308 !!githubCfg?.enabled && !!githubCfg.clientId && !!githubCfg.clientSecret;
582cdacClaude309 // "Sign in with Google" (separate row keyed id='google'). Same wiring
310 // pattern as GitHub OAuth.
311 const googleCfg = await getGoogleOauthConfig();
312 const googleEnabled =
313 !!googleCfg?.enabled && !!googleCfg.clientId && !!googleCfg.clientSecret;
134750bClaude314 const csrf = c.get("csrfToken") as string | undefined;
06d5ffeClaude315 return c.html(
36cc17aClaude316 <Layout title="Sign in" user={null}>
06d5ffeClaude317 <div class="auth-container">
98f45b4Claude318 <h2>Welcome back</h2>
319 <p class="auth-subtitle">
320 Sign in to your gluecron account.
321 </p>
06d5ffeClaude322 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
c63b860Claude323 {success && (
324 <div class="auth-success">{decodeURIComponent(success)}</div>
325 )}
0316dbbClaude326 <Form
b9968e3Claude327 method="post"
06d5ffeClaude328 action={`/login${redirect ? `?redirect=${encodeURIComponent(redirect)}` : ""}`}
134750bClaude329 csrfToken={csrf}
06d5ffeClaude330 >
bb0f894Claude331 <FormGroup label="Username or email" htmlFor="username">
332 <Input
06d5ffeClaude333 type="text"
334 name="username"
335 required
336 placeholder="username or email"
337 autocomplete="username"
2c3ba6ecopilot-swe-agent[bot]338 aria-label="Username or email"
06d5ffeClaude339 />
bb0f894Claude340 </FormGroup>
341 <FormGroup label="Password" htmlFor="password">
342 <Input
06d5ffeClaude343 type="password"
344 name="password"
345 required
346 placeholder="Password"
347 autocomplete="current-password"
2c3ba6ecopilot-swe-agent[bot]348 aria-label="Password"
06d5ffeClaude349 />
bb0f894Claude350 </FormGroup>
c63b860Claude351 <div class="auth-forgot" style="margin:-8px 0 12px;text-align:right;font-size:13px">
352 <a href="/forgot-password">Forgot password?</a>
cd4f63bTest User353 {/* BLOCK Q2 — magic-link sign-in. */}
354 <span style="margin:0 6px;color:var(--text-muted)">·</span>
355 <a href="/login/magic">Sign in with a magic link instead</a>
c63b860Claude356 </div>
bb0f894Claude357 <Button type="submit" variant="primary">
06d5ffeClaude358 Sign in
bb0f894Claude359 </Button>
360 </Form>
582cdacClaude361 {/* Provider buttons (Google + GitHub) — only rendered when the
362 admin has configured + enabled them via /admin/google-oauth or
363 /admin/github-oauth. The "or" divider above the first available
364 provider sets the visual break from the password form. */}
365 {(googleEnabled || githubEnabled) && (
366 <div class="auth-divider">or</div>
367 )}
368 {googleEnabled && (
369 <a
370 href="/login/google"
371 class="btn btn-block oauth-btn oauth-google"
372 aria-label="Sign in with Google"
373 >
374 <svg
375 class="oauth-icon"
376 width="18"
377 height="18"
378 viewBox="0 0 18 18"
379 aria-hidden="true"
380 >
381 <path
382 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"
383 fill="#4285F4"
384 />
385 <path
386 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"
387 fill="#34A853"
388 />
389 <path
390 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"
391 fill="#FBBC05"
392 />
393 <path
394 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"
395 fill="#EA4335"
396 />
397 </svg>
398 <span>Sign in with Google</span>
399 </a>
400 )}
46d6165Claude401 {githubEnabled && (
582cdacClaude402 <a
403 href="/login/github"
404 class="btn btn-block oauth-btn oauth-github"
405 aria-label="Sign in with GitHub"
406 style={googleEnabled ? "margin-top:8px" : undefined}
407 >
408 <svg
409 class="oauth-icon"
410 width="18"
411 height="18"
412 viewBox="0 0 16 16"
413 aria-hidden="true"
414 fill="currentColor"
415 >
416 <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" />
417 </svg>
418 <span>Sign in with GitHub</span>
419 </a>
46d6165Claude420 )}
09be7bfClaude421 {ssoEnabled && (
582cdacClaude422 <a
423 href="/login/sso"
424 class="btn btn-block oauth-btn oauth-sso"
425 aria-label={`Sign in with ${ssoLabel}`}
426 style={(googleEnabled || githubEnabled) ? "margin-top:8px" : undefined}
427 >
428 <span>Sign in with {ssoLabel}</span>
429 </a>
09be7bfClaude430 )}
fa06ad2Claude431 <div class="auth-passkey">
432 <div class="auth-divider">or</div>
433 <button type="button" id="pk-signin-btn" class="btn">
434 Sign in with passkey
435 </button>
436 <div
437 id="pk-signin-status"
438 class="auth-status"
439 aria-live="polite"
440 ></div>
441 </div>
06d5ffeClaude442 <p class="auth-switch">
bb0f894Claude443 <Text>New to gluecron? <a href="/register">Create an account</a></Text>
06d5ffeClaude444 </p>
2df1f8cClaude445 <script
446 dangerouslySetInnerHTML={{
447 __html: /* js */ `
448 (function () {
449 const btn = document.getElementById('pk-signin-btn');
450 const status = document.getElementById('pk-signin-status');
451 const userInput = document.getElementById('username');
452 const redirect = ${JSON.stringify(redirect || "/")};
453 if (!btn) return;
454 function b64uToBuf(s) {
455 s = s.replace(/-/g,'+').replace(/_/g,'/');
456 while (s.length % 4) s += '=';
457 const bin = atob(s);
458 const buf = new Uint8Array(bin.length);
459 for (let i=0;i<bin.length;i++) buf[i] = bin.charCodeAt(i);
460 return buf.buffer;
461 }
462 function bufToB64u(buf) {
463 const bytes = new Uint8Array(buf);
464 let bin = '';
465 for (let i=0;i<bytes.length;i++) bin += String.fromCharCode(bytes[i]);
466 return btoa(bin).replace(/\\+/g,'-').replace(/\\//g,'_').replace(/=+$/,'');
467 }
468 btn.addEventListener('click', async function () {
469 if (!window.PublicKeyCredential) {
470 status.textContent = 'Passkeys not supported in this browser.';
471 return;
472 }
473 status.textContent = 'Preparing…';
474 try {
475 const username = (userInput && userInput.value || '').trim();
476 const optsRes = await fetch('/api/passkeys/auth/options', {
477 method: 'POST',
478 headers: { 'content-type': 'application/json' },
479 body: JSON.stringify(username ? { username: username } : {})
480 });
481 if (!optsRes.ok) throw new Error('options failed');
482 const { options, sessionKey } = await optsRes.json();
483 options.challenge = b64uToBuf(options.challenge);
484 if (options.allowCredentials) {
485 options.allowCredentials = options.allowCredentials.map(function (c) {
486 return Object.assign({}, c, { id: b64uToBuf(c.id) });
487 });
488 }
489 status.textContent = 'Touch your authenticator…';
490 const cred = await navigator.credentials.get({ publicKey: options });
491 const resp = {
492 id: cred.id,
493 rawId: bufToB64u(cred.rawId),
494 type: cred.type,
495 response: {
496 clientDataJSON: bufToB64u(cred.response.clientDataJSON),
497 authenticatorData: bufToB64u(cred.response.authenticatorData),
498 signature: bufToB64u(cred.response.signature),
499 userHandle: cred.response.userHandle ? bufToB64u(cred.response.userHandle) : null
500 },
501 clientExtensionResults: cred.getClientExtensionResults ? cred.getClientExtensionResults() : {}
502 };
503 const verifyRes = await fetch('/api/passkeys/auth/verify', {
504 method: 'POST',
505 headers: { 'content-type': 'application/json' },
506 body: JSON.stringify({ sessionKey: sessionKey, response: resp })
507 });
508 if (!verifyRes.ok) {
509 const j = await verifyRes.json().catch(function () { return {}; });
510 throw new Error(j.error || 'verify failed');
511 }
512 status.textContent = 'Signed in. Redirecting…';
513 window.location.href = redirect;
514 } catch (e) {
515 status.textContent = 'Error: ' + (e && e.message ? e.message : e);
516 }
517 });
518 })();
519 `,
520 }}
521 />
06d5ffeClaude522 </div>
523 </Layout>
524 );
525});
526
527auth.post("/login", async (c) => {
528 const body = await c.req.parseBody();
529 const identifier = String(body.username || "").trim();
530 const password = String(body.password || "");
531 const redirect = c.req.query("redirect") || "/";
532
533 if (!identifier || !password) {
534 return c.redirect("/login?error=All+fields+are+required");
535 }
536
537 // Find user by username or email
538 const isEmail = identifier.includes("@");
539 const [user] = await db
540 .select()
541 .from(users)
542 .where(
543 isEmail
544 ? eq(users.email, identifier)
545 : eq(users.username, identifier)
546 )
547 .limit(1);
548
549 if (!user) {
550 return c.redirect("/login?error=Invalid+credentials");
551 }
552
553 const valid = await verifyPassword(password, user.passwordHash);
554 if (!valid) {
555 return c.redirect("/login?error=Invalid+credentials");
556 }
557
7298a17Claude558 // B4: if the user has TOTP enabled, issue a pending-2fa session and
559 // redirect to the code prompt.
560 const [totp] = await db
561 .select({ enabledAt: userTotp.enabledAt })
562 .from(userTotp)
563 .where(eq(userTotp.userId, user.id))
564 .limit(1);
565 const needs2fa = !!(totp && totp.enabledAt);
566
06d5ffeClaude567 const token = generateSessionToken();
568 await db.insert(sessions).values({
569 userId: user.id,
570 token,
571 expiresAt: sessionExpiry(),
7298a17Claude572 requires2fa: needs2fa,
06d5ffeClaude573 });
574
575 setCookie(c, "session", token, sessionCookieOptions());
c63b860Claude576
577 // Block P5 — If account was scheduled for deletion but user signed back
578 // in, cancel the deletion. Safe regardless of 2FA: password was proven.
579 if (user.deletedAt) {
580 await cancelAccountDeletion(user.id);
581 }
582
7298a17Claude583 if (needs2fa) {
584 return c.redirect(
585 `/login/2fa?redirect=${encodeURIComponent(redirect)}`
586 );
587 }
06d5ffeClaude588 return c.redirect(redirect);
589});
590
7298a17Claude591// --- 2FA verify (B4) ---
592auth.get("/login/2fa", async (c) => {
593 const token = getCookie(c, "session");
594 if (!token) return c.redirect("/login");
595 const error = c.req.query("error");
596 const redirect = c.req.query("redirect") || "/";
597 return c.html(
36cc17aClaude598 <Layout title="Two-factor authentication" user={null}>
7298a17Claude599 <div class="auth-container">
600 <h2>Enter your code</h2>
601 <p
602 class="auth-switch"
603 style="margin-bottom: 16px; margin-top: 0"
604 >
605 Open your authenticator app and enter the 6-digit code. Lost your
606 device? Paste a recovery code instead.
607 </p>
608 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
609 <form
b9968e3Claude610 method="post"
7298a17Claude611 action={`/login/2fa?redirect=${encodeURIComponent(redirect)}`}
612 >
134750bClaude613 <input type="hidden" name="_csrf" value={(c.get("csrfToken") as string | undefined) || ""} />
7298a17Claude614 <div class="form-group">
615 <label for="code">Code</label>
616 <input
617 type="text"
618 id="code"
619 name="code"
620 required
621 autocomplete="one-time-code"
622 inputmode="numeric"
623 maxLength={24}
624 placeholder="123456 or xxxx-xxxx-xxxx"
625 />
626 </div>
627 <button type="submit" class="btn btn-primary">
628 Verify
629 </button>
630 </form>
631 <p class="auth-switch">
632 <a href="/logout">Cancel</a>
633 </p>
634 </div>
635 </Layout>
636 );
637});
638
639auth.post("/login/2fa", async (c) => {
640 const token = getCookie(c, "session");
641 if (!token) return c.redirect("/login");
642 const body = await c.req.parseBody();
643 const code = String(body.code || "").trim();
644 const redirect = c.req.query("redirect") || "/";
645
646 if (!code) {
647 return c.redirect(
648 `/login/2fa?error=Code+is+required&redirect=${encodeURIComponent(redirect)}`
649 );
650 }
651
652 try {
653 const [session] = await db
654 .select()
655 .from(sessions)
656 .where(eq(sessions.token, token))
657 .limit(1);
658 if (
659 !session ||
660 new Date(session.expiresAt) < new Date() ||
661 !session.requires2fa
662 ) {
663 return c.redirect("/login");
664 }
665
666 const [totp] = await db
667 .select()
668 .from(userTotp)
669 .where(eq(userTotp.userId, session.userId))
670 .limit(1);
671 if (!totp || !totp.enabledAt) {
672 // User doesn't have 2FA actually enabled — clear the flag and let
673 // them in. This can only happen if 2FA was disabled in another
674 // session between password check and code prompt.
675 await db
676 .update(sessions)
677 .set({ requires2fa: false })
678 .where(eq(sessions.token, token));
679 return c.redirect(redirect);
680 }
681
682 // Try TOTP code first.
683 const isSix = /^\d{6}$/.test(code);
684 let ok = false;
685 if (isSix) {
686 ok = await verifyTotpCode(totp.secret, code);
687 }
688 // Fall through to recovery code.
689 if (!ok) {
690 const hash = await hashRecoveryCode(code);
691 const [rec] = await db
692 .select()
693 .from(userRecoveryCodes)
694 .where(
695 and(
696 eq(userRecoveryCodes.userId, session.userId),
697 eq(userRecoveryCodes.codeHash, hash),
698 isNull(userRecoveryCodes.usedAt)
699 )
700 )
701 .limit(1);
702 if (rec) {
703 await db
704 .update(userRecoveryCodes)
705 .set({ usedAt: new Date() })
706 .where(eq(userRecoveryCodes.id, rec.id));
707 ok = true;
708 }
709 }
710
711 if (!ok) {
712 return c.redirect(
713 `/login/2fa?error=Invalid+code&redirect=${encodeURIComponent(redirect)}`
714 );
715 }
716
717 await db
718 .update(sessions)
719 .set({ requires2fa: false })
720 .where(eq(sessions.token, token));
721 await db
722 .update(userTotp)
723 .set({ lastUsedAt: new Date() })
724 .where(eq(userTotp.userId, session.userId));
725
726 return c.redirect(redirect);
727 } catch (err) {
728 console.error("[auth] 2fa verify:", err);
729 return c.redirect(
730 `/login/2fa?error=Service+unavailable&redirect=${encodeURIComponent(redirect)}`
731 );
732 }
733});
734
06d5ffeClaude735auth.get("/logout", async (c) => {
736 deleteCookie(c, "session", { path: "/" });
737 return c.redirect("/");
738});
739
740// --- API ---
741
742auth.post("/api/auth/register", async (c) => {
743 const body = await c.req.json<{
744 username: string;
745 email: string;
746 password: string;
747 }>();
748
749 if (!body.username || !body.email || !body.password) {
750 return c.json({ error: "username, email, and password are required" }, 400);
751 }
752
753 if (!/^[a-zA-Z0-9_-]+$/.test(body.username)) {
754 return c.json({ error: "Invalid username" }, 400);
755 }
756
757 if (body.password.length < 8) {
758 return c.json({ error: "Password must be at least 8 characters" }, 400);
759 }
760
761 const [existing] = await db
762 .select()
763 .from(users)
764 .where(eq(users.username, body.username))
765 .limit(1);
766 if (existing) {
767 return c.json({ error: "Username already taken" }, 409);
768 }
769
770 const passwordHash = await hashPassword(body.password);
771 const [user] = await db
772 .insert(users)
773 .values({
774 username: body.username,
775 email: body.email,
776 passwordHash,
777 })
778 .returning();
779
780 const token = generateSessionToken();
781 await db.insert(sessions).values({
782 userId: user.id,
783 token,
784 expiresAt: sessionExpiry(),
785 });
786
787 return c.json(
788 {
789 user: { id: user.id, username: user.username, email: user.email },
790 token,
791 },
792 201
793 );
794});
795
796auth.post("/api/auth/login", async (c) => {
797 const body = await c.req.json<{ username: string; password: string }>();
798
799 if (!body.username || !body.password) {
800 return c.json({ error: "username and password are required" }, 400);
801 }
802
803 const isEmail = body.username.includes("@");
804 const [user] = await db
805 .select()
806 .from(users)
807 .where(
808 isEmail
809 ? eq(users.email, body.username)
810 : eq(users.username, body.username)
811 )
812 .limit(1);
813
814 if (!user) return c.json({ error: "Invalid credentials" }, 401);
815
816 const valid = await verifyPassword(body.password, user.passwordHash);
817 if (!valid) return c.json({ error: "Invalid credentials" }, 401);
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 user: { id: user.id, username: user.username, email: user.email },
828 token,
829 });
830});
831
fc0ca99Claude832/**
833 * Pick a friendly provider name for the "Sign in with X" button when the
834 * admin hasn't set one explicitly. Looks at the configured IdP URLs.
835 * Falls back to undefined so the caller can default to a literal "SSO".
836 */
837function inferSsoProviderName(
838 cfg: { issuer?: string | null; authorizationEndpoint?: string | null } | null | undefined
839): string | undefined {
840 const urls = [cfg?.issuer, cfg?.authorizationEndpoint]
841 .filter((s): s is string => !!s)
842 .join(" ")
843 .toLowerCase();
844 if (!urls) return undefined;
845 if (urls.includes("google")) return "Google";
846 if (urls.includes("okta")) return "Okta";
847 if (urls.includes("microsoftonline") || urls.includes("azure")) return "Microsoft";
848 if (urls.includes("auth0.com")) return "Auth0";
849 if (urls.includes("authentik")) return "Authentik";
850 return undefined;
851}
852
06d5ffeClaude853export default auth;