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.tsxBlame865 lines · 3 contributors
06d5ffeClaude1/**
2 * Auth routes — register, login, logout (web + API).
3 */
4
5import { Hono } from "hono";
7298a17Claude6import { setCookie, deleteCookie, getCookie } from "hono/cookie";
ba9e143Claude7import { and, eq, gte, isNull, sql } from "drizzle-orm";
06d5ffeClaude8import { db } from "../db";
7298a17Claude9import {
10 users,
11 sessions,
12 organizations,
3a845e4Claude13 orgSsoConfigs,
7298a17Claude14 userTotp,
15 userRecoveryCodes,
ba9e143Claude16 loginAttempts,
7298a17Claude17} from "../db/schema";
06d5ffeClaude18import {
19 hashPassword,
20 verifyPassword,
21 generateSessionToken,
22 sessionCookieOptions,
23 sessionExpiry,
24} from "../lib/auth";
7298a17Claude25import { verifyTotpCode, hashRecoveryCode } from "../lib/totp";
27d5fd3Claude26import {
27 evaluateLockout,
28 retryAfterMinutes,
29 LOGIN_FAIL_WINDOW_MS,
30 LOGIN_FAIL_LIMIT,
31 type LockoutState,
32} from "../lib/login-lockout";
c63b860Claude33import { cancelAccountDeletion } from "../lib/account-deletion";
ba9e143Claude34import { audit } from "../lib/notify";
582cdacClaude35import {
36 getSsoConfig,
37 getGithubOauthConfig,
38 getGoogleOauthConfig,
39} from "../lib/sso";
06d5ffeClaude40import { Layout } from "../views/layout";
cf21786ccanty labs41import { SignInV2 } from "../views/signin-v2";
bb0f894Claude42import {
43 Form,
44 FormGroup,
45 Input,
46 Button,
09be7bfClaude47 LinkButton,
bb0f894Claude48 Alert,
49 Text,
50} from "../views/ui";
36cc17aClaude51import { softAuth } from "../middleware/auth";
06d5ffeClaude52import type { AuthEnv } from "../middleware/auth";
53
54const auth = new Hono<AuthEnv>();
55
976d7f7Claude56// One-shot latch — log the auto-verify warning at most once per process,
57// since the misconfiguration is operator-level (env var) and won't change
58// between requests.
59let _autoVerifyWarned = false;
60
ebe6d64Claude61// ───────────────────────────────────────────────────────────────────────
62// Scoped mobile polish — tightens the existing `.auth-container` shell
63// from layout.tsx for ≤720px viewports. Only adds rules; does not
64// redefine the desktop styling. Kept inline so this file remains the
65// single source of truth for the auth surface.
66// ───────────────────────────────────────────────────────────────────────
67const authMobileCss = `
68 @media (max-width: 720px) {
69 .auth-container {
70 margin: 24px 12px;
71 padding: 24px 20px 22px;
72 max-width: 100%;
73 }
74 .auth-container .btn-primary { min-height: 44px; }
75 .auth-container .oauth-btn { min-height: 44px; }
76 .auth-container input[type="text"],
77 .auth-container input[type="email"],
78 .auth-container input[type="password"] { min-height: 44px; }
79 .auth-forgot { text-align: left !important; }
80 }
81`;
82const AuthMobileStyle = () => (
83 <style dangerouslySetInnerHTML={{ __html: authMobileCss }} />
84);
85
06d5ffeClaude86// --- Web UI ---
87
36cc17aClaude88auth.get("/register", softAuth, (c) => {
89 // If the user is already signed in, drop them on their dashboard rather
90 // than rendering the logged-out sign-up shell over an authed session.
91 const existing = c.get("user");
92 if (existing) return c.redirect("/dashboard");
06d5ffeClaude93 const error = c.req.query("error");
134750bClaude94 const csrf = c.get("csrfToken") as string | undefined;
06d5ffeClaude95 return c.html(
36cc17aClaude96 <Layout title="Register" user={null}>
ebe6d64Claude97 <AuthMobileStyle />
06d5ffeClaude98 <div class="auth-container">
98f45b4Claude99 <h2>Create your account</h2>
100 <p class="auth-subtitle">
101 Get the full AI suite — code review, auto-merge, spec-to-PR — on
102 unlimited public repos. No credit card.
103 </p>
06d5ffeClaude104 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
134750bClaude105 <Form method="post" action="/register" csrfToken={csrf}>
0316dbbClaude106 <FormGroup label="Username" htmlFor="username">
107 <Input
108 id="username"
06d5ffeClaude109 type="text"
110 name="username"
111 required
112 pattern="^[a-zA-Z0-9_-]+$"
113 minLength={2}
114 maxLength={39}
115 placeholder="your-username"
116 autocomplete="username"
117 />
bb0f894Claude118 </FormGroup>
119 <FormGroup label="Email" htmlFor="email">
120 <Input
06d5ffeClaude121 type="email"
122 name="email"
123 required
124 placeholder="you@example.com"
125 autocomplete="email"
2c3ba6ecopilot-swe-agent[bot]126 aria-label="Email"
06d5ffeClaude127 />
bb0f894Claude128 </FormGroup>
129 <FormGroup label="Password" htmlFor="password">
130 <Input
06d5ffeClaude131 type="password"
132 name="password"
133 required
134 minLength={8}
135 placeholder="Min 8 characters"
136 autocomplete="new-password"
2c3ba6ecopilot-swe-agent[bot]137 aria-label="Password"
06d5ffeClaude138 />
bb0f894Claude139 </FormGroup>
c63b860Claude140 {/* P3 — Terms / Privacy acceptance. Required client-side via the
141 `required` attribute; server-side re-checked in POST handler. */}
142 <div class="form-group" style="margin: 12px 0">
143 <label style="display: flex; gap: 8px; align-items: flex-start; font-size: 13px; color: var(--text-muted)">
144 <input
145 type="checkbox"
146 name="accept_terms"
147 value="1"
148 required
149 style="margin-top: 3px"
150 aria-label="Accept Terms of Service and Privacy Policy"
151 />
152 <span>
153 I agree to the{" "}
2e8a4d5Claude154 <a href="/terms" target="_blank" rel="noopener">
c63b860Claude155 Terms of Service
156 </a>{" "}
157 and{" "}
2e8a4d5Claude158 <a href="/privacy" target="_blank" rel="noopener">
c63b860Claude159 Privacy Policy
160 </a>
161 .
162 </span>
163 </label>
164 </div>
bb0f894Claude165 <Button type="submit" variant="primary">
06d5ffeClaude166 Create account
bb0f894Claude167 </Button>
168 </Form>
06d5ffeClaude169 <p class="auth-switch">
bb0f894Claude170 <Text>Already have an account? <a href="/login">Sign in</a></Text>
06d5ffeClaude171 </p>
172 </div>
173 </Layout>
174 );
175});
176
177auth.post("/register", async (c) => {
178 const body = await c.req.parseBody();
179 const username = String(body.username || "").trim();
180 const email = String(body.email || "").trim();
181 const password = String(body.password || "");
182
183 if (!username || !email || !password) {
184 return c.redirect("/register?error=All+fields+are+required");
185 }
186
c63b860Claude187 // Block P3 — Terms acceptance is required. The form's checkbox has
188 // `required` so browsers normally enforce client-side; the server
189 // re-checks for defensive depth (curl, scripted POST, etc.).
190 if (!body.accept_terms) {
191 return c.redirect(
192 "/register?error=Please+accept+the+Terms+of+Service+and+Privacy+Policy"
193 );
194 }
195
06d5ffeClaude196 if (!/^[a-zA-Z0-9_-]+$/.test(username)) {
197 return c.redirect(
198 "/register?error=Username+may+only+contain+letters%2C+numbers%2C+hyphens+and+underscores"
199 );
200 }
201
202 if (password.length < 8) {
203 return c.redirect("/register?error=Password+must+be+at+least+8+characters");
204 }
205
206 // Check existing
207 const [existingUser] = await db
208 .select()
209 .from(users)
210 .where(eq(users.username, username))
211 .limit(1);
212 if (existingUser) {
213 return c.redirect("/register?error=Username+already+taken");
214 }
215
7437605Claude216 // B2: usernames share the URL namespace with org slugs; refuse collisions.
217 const [existingOrg] = await db
218 .select({ id: organizations.id })
219 .from(organizations)
220 .where(eq(organizations.slug, username.toLowerCase()))
221 .limit(1);
222 if (existingOrg) {
223 return c.redirect("/register?error=Username+already+taken");
224 }
225
06d5ffeClaude226 const [existingEmail] = await db
227 .select()
228 .from(users)
229 .where(eq(users.email, email))
230 .limit(1);
231 if (existingEmail) {
232 return c.redirect("/register?error=Email+already+registered");
233 }
234
235 const passwordHash = await hashPassword(password);
236
a4f3e24Claude237 // First user ever registered becomes admin automatically
238 const [userCount] = await db
239 .select({ count: sql`count(*)::int` })
240 .from(users);
241 const isFirstUser = (userCount?.count as number) === 0;
242
06d5ffeClaude243 const [user] = await db
244 .insert(users)
c63b860Claude245 .values({
246 username,
247 email,
248 passwordHash,
249 isAdmin: isFirstUser,
250 // P3 — record terms acceptance now. Version bumps when Terms change.
251 termsAcceptedAt: new Date(),
252 termsVersion: "1.0",
253 })
06d5ffeClaude254 .returning();
255
2d985e5Claude256 // If username matches SITE_ADMIN_USERNAME env, grant site admin instantly
257 // so the operator doesn't have to wait for the next boot's bootstrap pass.
a28cedeClaude258 await import("../lib/admin-bootstrap")
259 .then((m) => m.ensureEnvAdminOnRegister({ userId: user.id, username }))
260 .catch((err) => {
261 console.warn(
262 `[admin-bootstrap] ensureEnvAdminOnRegister failed for ${username}:`,
263 err instanceof Error ? err.message : err
264 );
265 });
2d985e5Claude266
06d5ffeClaude267 // Create session
268 const token = generateSessionToken();
269 await db.insert(sessions).values({
270 userId: user.id,
271 token,
272 expiresAt: sessionExpiry(),
273 });
274
275 setCookie(c, "session", token, sessionCookieOptions());
276
976d7f7Claude277 // Block P2 — email verification. If RESEND_API_KEY is configured the
278 // verification email goes out and the user clicks the link to verify.
279 // If email is NOT configured (EMAIL_PROVIDER=log, no RESEND_API_KEY,
280 // etc.), the email would silently never arrive and the user would be
281 // locked out — AUDIT-v2.md P0 #3. In that case, auto-verify the
282 // account on registration so the user can actually use the site.
283 // Operators who want real verification should set EMAIL_PROVIDER=resend
284 // + RESEND_API_KEY in their environment.
285 const { config: _emailConfig } = await import("../lib/config");
286 const emailConfigured =
287 _emailConfig.emailProvider === "resend" && !!_emailConfig.resendApiKey;
288 if (emailConfigured) {
289 import("../lib/email-verification")
290 .then((m) => m.startEmailVerification(user.id, email))
291 .catch((err) => {
292 console.error(
293 `[auth] startEmailVerification failed for ${user.id}:`,
294 err instanceof Error ? err.message : err
295 );
296 });
297 } else {
298 // Auto-verify immediately so the user isn't trapped in an unverified
299 // state. Log once so operators notice the misconfiguration.
300 if (!_autoVerifyWarned) {
301 _autoVerifyWarned = true;
302 console.warn(
303 "[auth] EMAIL_PROVIDER is not configured (set EMAIL_PROVIDER=resend + RESEND_API_KEY). Auto-verifying new account email addresses to avoid lockout."
304 );
305 }
306 await db
307 .update(users)
308 .set({ emailVerifiedAt: new Date() })
309 .where(eq(users.id, user.id))
310 .catch((err) => {
311 console.error(
312 `[auth] auto-verify failed for ${user.id}:`,
313 err instanceof Error ? err.message : err
314 );
315 });
316 }
c63b860Claude317
f65f600Claude318 // Onboarding drip — T+0 "welcome" email. Fire-and-forget; never blocks
319 // the redirect. Silently skips when email is not configured.
320 import("../lib/onboarding-drip")
321 .then((m) => m.sendWelcomeEmail(user.id))
322 .catch((err) => {
323 console.error(
324 `[auth] onboarding welcome email failed for ${user.id}:`,
325 err instanceof Error ? err.message : err
326 );
327 });
328
c63b860Claude329 // P3 — default landing is /onboarding (the guided first-five-minutes
330 // flow). The `redirect=` query is still honoured for OAuth-style flows.
331 const redirect = c.req.query("redirect") || "/onboarding?welcome=1";
06d5ffeClaude332 return c.redirect(redirect);
333});
334
36cc17aClaude335auth.get("/login", softAuth, async (c) => {
336 // Already-authed users hitting the sign-in page get bounced to their
337 // dashboard (or the `redirect=` target if one was supplied).
338 const existing = c.get("user");
06d5ffeClaude339 const error = c.req.query("error");
c63b860Claude340 const success = c.req.query("success");
06d5ffeClaude341 const redirect = c.req.query("redirect") || "";
36cc17aClaude342 if (existing) return c.redirect(redirect || "/dashboard");
edf7c36Claude343 const ssoCfg = await getSsoConfig();
344 const ssoEnabled =
345 !!ssoCfg?.enabled &&
346 !!ssoCfg.authorizationEndpoint &&
347 !!ssoCfg.tokenEndpoint &&
348 !!ssoCfg.userinfoEndpoint &&
349 !!ssoCfg.clientId &&
350 !!ssoCfg.clientSecret;
fc0ca99Claude351 const ssoLabel =
352 ssoCfg?.providerName || inferSsoProviderName(ssoCfg) || "SSO";
46d6165Claude353 // Block L6 — "Sign in with GitHub" (separate row keyed id='github').
354 const githubCfg = await getGithubOauthConfig();
355 const githubEnabled =
356 !!githubCfg?.enabled && !!githubCfg.clientId && !!githubCfg.clientSecret;
582cdacClaude357 // "Sign in with Google" (separate row keyed id='google'). Same wiring
358 // pattern as GitHub OAuth.
359 const googleCfg = await getGoogleOauthConfig();
360 const googleEnabled =
361 !!googleCfg?.enabled && !!googleCfg.clientId && !!googleCfg.clientSecret;
06d5ffeClaude362 return c.html(
cf21786ccanty labs363 <SignInV2
364 redirect={redirect}
365 error={error ? decodeURIComponent(error) : ""}
9374d58ccanty labs366 googleEnabled={googleEnabled}
367 githubEnabled={githubEnabled}
cf21786ccanty labs368 />
06d5ffeClaude369 );
370});
371
ba9e143Claude372/**
27d5fd3Claude373 * Loads the failure aggregate for `email` and evaluates the lockout policy
374 * (see src/lib/login-lockout.ts for the semantics).
375 *
376 * Fails OPEN: if the `login_attempts` table is unreachable (missing
377 * migration, transient DB error) login must still work — a broken lockout
378 * ledger must never lock every user out of the site. That failure class
379 * is exactly what the 0087 migration blockade caused in production.
ba9e143Claude380 */
27d5fd3Claude381async function getLockoutState(email: string): Promise<LockoutState> {
382 try {
383 const since = new Date(Date.now() - LOGIN_FAIL_WINDOW_MS);
384 const [row] = await db
385 .select({
386 count: sql<number>`count(*)::int`,
387 newest: sql<string | null>`max(${loginAttempts.createdAt})`,
388 })
389 .from(loginAttempts)
390 .where(
391 and(
392 eq(loginAttempts.email, email.toLowerCase()),
393 eq(loginAttempts.success, false),
394 gte(loginAttempts.createdAt, since)
395 )
396 );
397 return evaluateLockout({
398 failureCount: row?.count ?? 0,
399 newestFailureAt: row?.newest ? new Date(row.newest) : null,
400 });
401 } catch (err) {
402 console.error(
403 "[auth] lockout check failed (failing open):",
404 err instanceof Error ? err.message : err
ba9e143Claude405 );
27d5fd3Claude406 return { locked: false, failureCount: 0, retryAfterMs: 0 };
407 }
ba9e143Claude408}
409
06d5ffeClaude410auth.post("/login", async (c) => {
411 const body = await c.req.parseBody();
412 const identifier = String(body.username || "").trim();
413 const password = String(body.password || "");
414 const redirect = c.req.query("redirect") || "/";
ba9e143Claude415 const ip =
416 c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ||
417 c.req.header("x-real-ip") ||
418 "unknown";
419 const ua = c.req.header("user-agent") || "";
06d5ffeClaude420
421 if (!identifier || !password) {
422 return c.redirect("/login?error=All+fields+are+required");
423 }
424
3a845e4Claude425 // Enterprise SSO domain-hint routing: if the identifier is an email and the
426 // domain matches an org's `domain_hint`, redirect to that org's SSO flow
427 // instead of checking the password.
428 // Also: resolve the canonical email for lockout checks regardless of whether
ba9e143Claude429 // the user typed username or email.
06d5ffeClaude430 const isEmail = identifier.includes("@");
3a845e4Claude431 if (isEmail) {
432 const emailDomain = identifier.split("@")[1]?.toLowerCase();
433 if (emailDomain) {
434 const [ssoHint] = await db
435 .select({
436 provider: orgSsoConfigs.provider,
437 orgId: orgSsoConfigs.orgId,
438 })
439 .from(orgSsoConfigs)
440 .where(eq(orgSsoConfigs.domainHint, emailDomain))
441 .limit(1);
442
443 if (ssoHint) {
444 // Resolve org slug from org ID
445 const [orgRow] = await db
446 .select({ slug: organizations.slug })
447 .from(organizations)
448 .where(eq(organizations.id, ssoHint.orgId))
449 .limit(1);
450
451 if (orgRow) {
452 const protocol = ssoHint.provider === "oidc" ? "oidc" : "saml";
453 return c.redirect(`/sso/${protocol}/${orgRow.slug}/login`);
454 }
455 }
456 }
457 }
458
459 // Find user by username or email
06d5ffeClaude460 const [user] = await db
461 .select()
462 .from(users)
463 .where(
464 isEmail
465 ? eq(users.email, identifier)
466 : eq(users.username, identifier)
467 )
468 .limit(1);
469
ba9e143Claude470 // Determine the email key for lockout (use identifier if user not found
471 // so we still record the attempt without leaking account existence).
472 const emailKey = (user?.email ?? identifier).toLowerCase();
473
474 // ── Lockout check ───────────────────────────────────────────────────
27d5fd3Claude475 // Locked when ≥ LOGIN_FAIL_LIMIT failures in the trailing window AND the
476 // newest failure is younger than LOGIN_LOCKOUT_MS. We check before
477 // password verification so brute-forcers can't time-diff their way
478 // around it. Blocked attempts are deliberately NOT recorded as failures:
479 // recording them rolled the window forward forever, so a user retrying
480 // their correct password stayed locked out permanently.
481 const lockout = await getLockoutState(emailKey);
482 if (lockout.locked) {
ba9e143Claude483 await audit({
484 userId: user?.id ?? null,
485 action: "auth.login.locked",
486 ip,
487 userAgent: ua,
27d5fd3Claude488 metadata: { email: emailKey, recentFailures: lockout.failureCount },
ba9e143Claude489 });
27d5fd3Claude490 const mins = retryAfterMinutes(lockout);
ba9e143Claude491 return c.redirect(
27d5fd3Claude492 `/login?error=${encodeURIComponent(
493 `Account temporarily locked due to too many failed login attempts. Please try again in ${mins} minute${mins === 1 ? "" : "s"}.`
494 )}`
ba9e143Claude495 );
496 }
27d5fd3Claude497 const recentFailures = lockout.failureCount;
ba9e143Claude498
06d5ffeClaude499 if (!user) {
ba9e143Claude500 // Record failed attempt (unknown user) and return generic error.
501 await db
502 .insert(loginAttempts)
503 .values({ email: emailKey, ip, success: false })
504 .catch(() => {});
06d5ffeClaude505 return c.redirect("/login?error=Invalid+credentials");
506 }
507
508 const valid = await verifyPassword(password, user.passwordHash);
509 if (!valid) {
ba9e143Claude510 // Record failed attempt.
511 await db
512 .insert(loginAttempts)
513 .values({ email: emailKey, ip, success: false })
514 .catch(() => {});
515 await audit({
516 userId: user.id,
517 action: "auth.login.failed",
518 ip,
519 userAgent: ua,
520 metadata: { email: emailKey, attempt: recentFailures + 1 },
521 });
522 // Check if this failure just crossed the threshold.
523 if (recentFailures + 1 >= LOGIN_FAIL_LIMIT) {
524 await audit({
525 userId: user.id,
526 action: "auth.login.locked",
527 ip,
528 userAgent: ua,
529 metadata: { email: emailKey, recentFailures: recentFailures + 1 },
530 });
531 return c.redirect(
532 "/login?error=Account+temporarily+locked+due+to+too+many+failed+login+attempts.+Please+try+again+in+15+minutes."
533 );
534 }
06d5ffeClaude535 return c.redirect("/login?error=Invalid+credentials");
536 }
537
27d5fd3Claude538 // Successful login — record success and clear the failure history so a
539 // stale window can't combine with one future typo to re-trip the lock.
ba9e143Claude540 await db
541 .insert(loginAttempts)
542 .values({ email: emailKey, ip, success: true })
543 .catch(() => {});
27d5fd3Claude544 await db
545 .delete(loginAttempts)
546 .where(
547 and(eq(loginAttempts.email, emailKey), eq(loginAttempts.success, false))
548 )
549 .catch(() => {});
ba9e143Claude550
7298a17Claude551 // B4: if the user has TOTP enabled, issue a pending-2fa session and
552 // redirect to the code prompt.
553 const [totp] = await db
554 .select({ enabledAt: userTotp.enabledAt })
555 .from(userTotp)
556 .where(eq(userTotp.userId, user.id))
557 .limit(1);
558 const needs2fa = !!(totp && totp.enabledAt);
559
06d5ffeClaude560 const token = generateSessionToken();
561 await db.insert(sessions).values({
562 userId: user.id,
563 token,
564 expiresAt: sessionExpiry(),
7298a17Claude565 requires2fa: needs2fa,
ba9e143Claude566 ip,
567 userAgent: ua,
568 lastSeenAt: new Date(),
06d5ffeClaude569 });
570
571 setCookie(c, "session", token, sessionCookieOptions());
c63b860Claude572
573 // Block P5 — If account was scheduled for deletion but user signed back
574 // in, cancel the deletion. Safe regardless of 2FA: password was proven.
575 if (user.deletedAt) {
576 await cancelAccountDeletion(user.id);
577 }
578
7298a17Claude579 if (needs2fa) {
580 return c.redirect(
581 `/login/2fa?redirect=${encodeURIComponent(redirect)}`
582 );
583 }
06d5ffeClaude584 return c.redirect(redirect);
585});
586
7298a17Claude587// --- 2FA verify (B4) ---
588auth.get("/login/2fa", async (c) => {
589 const token = getCookie(c, "session");
590 if (!token) return c.redirect("/login");
591 const error = c.req.query("error");
592 const redirect = c.req.query("redirect") || "/";
593 return c.html(
36cc17aClaude594 <Layout title="Two-factor authentication" user={null}>
ebe6d64Claude595 <AuthMobileStyle />
7298a17Claude596 <div class="auth-container">
597 <h2>Enter your code</h2>
598 <p
599 class="auth-switch"
600 style="margin-bottom: 16px; margin-top: 0"
601 >
602 Open your authenticator app and enter the 6-digit code. Lost your
603 device? Paste a recovery code instead.
604 </p>
605 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
606 <form
b9968e3Claude607 method="post"
7298a17Claude608 action={`/login/2fa?redirect=${encodeURIComponent(redirect)}`}
609 >
134750bClaude610 <input type="hidden" name="_csrf" value={(c.get("csrfToken") as string | undefined) || ""} />
7298a17Claude611 <div class="form-group">
612 <label for="code">Code</label>
613 <input
614 type="text"
615 id="code"
616 name="code"
617 required
618 autocomplete="one-time-code"
619 inputmode="numeric"
620 maxLength={24}
621 placeholder="123456 or xxxx-xxxx-xxxx"
622 />
623 </div>
624 <button type="submit" class="btn btn-primary">
625 Verify
626 </button>
627 </form>
628 <p class="auth-switch">
629 <a href="/logout">Cancel</a>
630 </p>
631 </div>
632 </Layout>
633 );
634});
635
636auth.post("/login/2fa", async (c) => {
637 const token = getCookie(c, "session");
638 if (!token) return c.redirect("/login");
639 const body = await c.req.parseBody();
640 const code = String(body.code || "").trim();
641 const redirect = c.req.query("redirect") || "/";
642
643 if (!code) {
644 return c.redirect(
645 `/login/2fa?error=Code+is+required&redirect=${encodeURIComponent(redirect)}`
646 );
647 }
648
649 try {
650 const [session] = await db
651 .select()
652 .from(sessions)
653 .where(eq(sessions.token, token))
654 .limit(1);
655 if (
656 !session ||
657 new Date(session.expiresAt) < new Date() ||
658 !session.requires2fa
659 ) {
660 return c.redirect("/login");
661 }
662
663 const [totp] = await db
664 .select()
665 .from(userTotp)
666 .where(eq(userTotp.userId, session.userId))
667 .limit(1);
668 if (!totp || !totp.enabledAt) {
669 // User doesn't have 2FA actually enabled — clear the flag and let
670 // them in. This can only happen if 2FA was disabled in another
671 // session between password check and code prompt.
672 await db
673 .update(sessions)
674 .set({ requires2fa: false })
675 .where(eq(sessions.token, token));
676 return c.redirect(redirect);
677 }
678
679 // Try TOTP code first.
680 const isSix = /^\d{6}$/.test(code);
681 let ok = false;
682 if (isSix) {
683 ok = await verifyTotpCode(totp.secret, code);
684 }
685 // Fall through to recovery code.
686 if (!ok) {
687 const hash = await hashRecoveryCode(code);
688 const [rec] = await db
689 .select()
690 .from(userRecoveryCodes)
691 .where(
692 and(
693 eq(userRecoveryCodes.userId, session.userId),
694 eq(userRecoveryCodes.codeHash, hash),
695 isNull(userRecoveryCodes.usedAt)
696 )
697 )
698 .limit(1);
699 if (rec) {
700 await db
701 .update(userRecoveryCodes)
702 .set({ usedAt: new Date() })
703 .where(eq(userRecoveryCodes.id, rec.id));
704 ok = true;
705 }
706 }
707
708 if (!ok) {
709 return c.redirect(
710 `/login/2fa?error=Invalid+code&redirect=${encodeURIComponent(redirect)}`
711 );
712 }
713
714 await db
715 .update(sessions)
716 .set({ requires2fa: false })
717 .where(eq(sessions.token, token));
718 await db
719 .update(userTotp)
720 .set({ lastUsedAt: new Date() })
721 .where(eq(userTotp.userId, session.userId));
722
723 return c.redirect(redirect);
724 } catch (err) {
725 console.error("[auth] 2fa verify:", err);
726 return c.redirect(
727 `/login/2fa?error=Service+unavailable&redirect=${encodeURIComponent(redirect)}`
728 );
729 }
730});
731
06d5ffeClaude732auth.get("/logout", async (c) => {
733 deleteCookie(c, "session", { path: "/" });
734 return c.redirect("/");
735});
736
737// --- API ---
738
739auth.post("/api/auth/register", async (c) => {
740 const body = await c.req.json<{
741 username: string;
742 email: string;
743 password: string;
744 }>();
745
746 if (!body.username || !body.email || !body.password) {
747 return c.json({ error: "username, email, and password are required" }, 400);
748 }
749
750 if (!/^[a-zA-Z0-9_-]+$/.test(body.username)) {
751 return c.json({ error: "Invalid username" }, 400);
752 }
753
754 if (body.password.length < 8) {
755 return c.json({ error: "Password must be at least 8 characters" }, 400);
756 }
757
758 const [existing] = await db
759 .select()
760 .from(users)
761 .where(eq(users.username, body.username))
762 .limit(1);
763 if (existing) {
764 return c.json({ error: "Username already taken" }, 409);
765 }
766
767 const passwordHash = await hashPassword(body.password);
768 const [user] = await db
769 .insert(users)
770 .values({
771 username: body.username,
772 email: body.email,
773 passwordHash,
774 })
775 .returning();
776
777 const token = generateSessionToken();
778 await db.insert(sessions).values({
779 userId: user.id,
780 token,
781 expiresAt: sessionExpiry(),
782 });
783
784 return c.json(
785 {
786 user: { id: user.id, username: user.username, email: user.email },
787 token,
788 },
789 201
790 );
791});
792
793auth.post("/api/auth/login", async (c) => {
794 const body = await c.req.json<{ username: string; password: string }>();
795
796 if (!body.username || !body.password) {
797 return c.json({ error: "username and password are required" }, 400);
798 }
799
800 const isEmail = body.username.includes("@");
801 const [user] = await db
802 .select()
803 .from(users)
804 .where(
805 isEmail
806 ? eq(users.email, body.username)
807 : eq(users.username, body.username)
808 )
809 .limit(1);
810
811 if (!user) return c.json({ error: "Invalid credentials" }, 401);
812
813 const valid = await verifyPassword(body.password, user.passwordHash);
814 if (!valid) return c.json({ error: "Invalid credentials" }, 401);
815
816 const token = generateSessionToken();
817 await db.insert(sessions).values({
818 userId: user.id,
819 token,
820 expiresAt: sessionExpiry(),
821 });
822
823 return c.json({
824 user: { id: user.id, username: user.username, email: user.email },
825 token,
826 });
827});
828
3a845e4Claude829// --- SSO domain-hint API (used by the login form JS) ---
830
831auth.get("/api/sso/domain-hint", async (c) => {
832 const domain = String(c.req.query("domain") || "").toLowerCase().trim();
833 if (!domain || domain.length > 253) {
834 return c.json({ sso: false });
835 }
836 const [row] = await db
837 .select({ orgId: orgSsoConfigs.orgId, provider: orgSsoConfigs.provider })
838 .from(orgSsoConfigs)
839 .where(eq(orgSsoConfigs.domainHint, domain))
840 .limit(1);
841 return c.json({ sso: !!row, provider: row?.provider ?? null });
842});
843
fc0ca99Claude844/**
845 * Pick a friendly provider name for the "Sign in with X" button when the
846 * admin hasn't set one explicitly. Looks at the configured IdP URLs.
847 * Falls back to undefined so the caller can default to a literal "SSO".
848 */
849function inferSsoProviderName(
850 cfg: { issuer?: string | null; authorizationEndpoint?: string | null } | null | undefined
851): string | undefined {
852 const urls = [cfg?.issuer, cfg?.authorizationEndpoint]
853 .filter((s): s is string => !!s)
854 .join(" ")
855 .toLowerCase();
856 if (!urls) return undefined;
857 if (urls.includes("google")) return "Google";
858 if (urls.includes("okta")) return "Okta";
859 if (urls.includes("microsoftonline") || urls.includes("azure")) return "Microsoft";
860 if (urls.includes("auth0.com")) return "Auth0";
861 if (urls.includes("authentik")) return "Authentik";
862 return undefined;
863}
864
06d5ffeClaude865export default auth;