Commit27d5fd3unknown_key
fix(auth): login lockout never expired; add Google sign-in env bootstrap
fix(auth): login lockout never expired; add Google sign-in env bootstrap The SOC 2 account lockout was self-perpetuating: LOGIN_LOCKOUT_MS was declared but never used, and attempts made while locked were recorded as new failures, rolling the 1-hour window forward forever. A user retrying their correct password — or any bot hammering a known email — kept the account locked indefinitely. Lockout policy now lives in a pure, tested helper (src/lib/login-lockout.ts): locked iff >=10 failures in the trailing hour AND the newest failure is under 15 minutes old; blocked attempts are not recorded; success clears the failure history; and the lockout query fails open if login_attempts is unreachable so a broken ledger can never take down login platform-wide. Also: getGoogleOauthConfig() falls back to GOOGLE_OAUTH_CLIENT_ID + GOOGLE_OAUTH_CLIENT_SECRET env vars when no credentialed admin DB row exists, so "Sign in with Google" can be enabled via deploy secrets without first reaching /admin/google-oauth (bootstrap deadlock when the admin is locked out). DB config still wins. Documented in .env.example; BUILD_BIBLE reconciled. https://claude.ai/code/session_01RHwR7NK4HawRujV4dJmtab
7 files changed+330−3627d5fd33dc5368616349497fc26ad7009edf9b21
7 changed files+330−36
Modified.env.example+12−0View fileUnifiedSplit
@@ -117,3 +117,15 @@ PREVIEW_DOMAIN=
117117# broadcaster (single-node only). Either name is accepted.
118118REDIS_URL=
119119VALKEY_URL=
120
121# ── "Sign in with Google" bootstrap (2026-06-12) ─────────────────────────
122# Setting this pair enables the Google sign-in button without touching the
123# /admin/google-oauth page (a DB config saved there takes precedence).
124# Create a Web OAuth client at console.cloud.google.com/apis/credentials and
125# add <APP_BASE_URL>/login/google/callback as an authorised redirect URI.
126GOOGLE_OAUTH_CLIENT_ID=
127GOOGLE_OAUTH_CLIENT_SECRET=
128# Optional: set to 0 to refuse auto-creating accounts on first Google login.
129GOOGLE_OAUTH_AUTO_CREATE=
130# Optional: comma-separated email domains allowed to sign in with Google.
131GOOGLE_OAUTH_ALLOWED_DOMAINS=
ModifiedBUILD_BIBLE.md+6−0View fileUnifiedSplit
@@ -859,3 +859,9 @@ What was verified, not changed:
859859- Tests: `src/__tests__/crontech-deploy.test.ts` rewritten — 19 tests covering endpoint, HMAC signature, payload shape, branch-case carry-through (`Main`), retry-on-5xx, give-up-after-N, no-retry-on-4xx, retry-on-408/429, retry-on-network-error, default backoff schedule. `triggerCrontechDeploy` now takes a single `args` object and an optional `opts: {fetchImpl, sleep, retryDelaysMs, now}` for injectability.
860860- Removed dead `fanoutWebhooks` helper (defined-but-uncalled, per audit).
861861- **Live verification (the BLK-016 done-criterion) is out of scope for this session** — it requires SSH to the Vultr box, a real test push, and confirming the deploy-agent log + GitHub Actions silence. Do not flip `BLK-016 → ✅ SHIPPED` in the Crontech BUILD_BIBLE without owner authorisation per Crontech CLAUDE.md §0.7.
862
863**2026-06-12 session — login lockout fixed + Google sign-in env bootstrap:**
864- **P0 bug fixed:** the SOC 2 lockout in `src/routes/auth.tsx` never expired. `LOGIN_LOCKOUT_MS` (15 min) was declared but unused, and every blocked attempt was recorded as a *new* failure, so the rolling 1-hour window renewed itself indefinitely — a user retrying their correct password (or any anonymous bot hammering their public email) kept the account locked forever. Combined with the 0087 migration blockade (2026-06-07→10, every /login 500'd, seeding failure rows), this is why the owner could not sign in.
865- New policy lives in `src/lib/login-lockout.ts` (`evaluateLockout`, pure, tested in `src/__tests__/login-lockout.test.ts`): locked iff ≥10 failures in the trailing hour AND the newest failure is <15 min old; blocked attempts are NOT recorded; successful login deletes the failure history. The route's lockout query now also **fails open** if `login_attempts` is unreachable — a broken lockout ledger must never take down login for everyone (the 0087 failure class).
866- **Google sign-in env bootstrap:** `getGoogleOauthConfig()` in `src/lib/sso.ts` now falls back to `GOOGLE_OAUTH_CLIENT_ID` + `GOOGLE_OAUTH_CLIENT_SECRET` env vars (optional `GOOGLE_OAUTH_AUTO_CREATE=0`, `GOOGLE_OAUTH_ALLOWED_DOMAINS=`) when no credentialed `/admin/google-oauth` DB row exists. Removes the bootstrap deadlock where Google login could only be enabled by an admin who could already log in. Pure helper `googleOauthConfigFromEnv` tested in `src/__tests__/google-oauth-env.test.ts`. `.env.example` documents the vars. A DB row with credentials still wins.
867- Locked-file note: `src/routes/auth.tsx` (§4) was modified **correctively only** — same limits, same audit actions; the change makes the already-shipped 15-minute lockout behave as documented.
Addedsrc/__tests__/google-oauth-env.test.ts+53−0View fileUnifiedSplit
@@ -0,0 +1,53 @@
1import { describe, expect, test } from "bun:test";
2import { googleOauthConfigFromEnv } from "../lib/sso";
3
4describe("googleOauthConfigFromEnv", () => {
5 test("returns null when credentials are absent", () => {
6 expect(googleOauthConfigFromEnv({})).toBeNull();
7 expect(
8 googleOauthConfigFromEnv({ GOOGLE_OAUTH_CLIENT_ID: "id-only" })
9 ).toBeNull();
10 expect(
11 googleOauthConfigFromEnv({ GOOGLE_OAUTH_CLIENT_SECRET: "secret-only" })
12 ).toBeNull();
13 expect(
14 googleOauthConfigFromEnv({
15 GOOGLE_OAUTH_CLIENT_ID: " ",
16 GOOGLE_OAUTH_CLIENT_SECRET: "s",
17 })
18 ).toBeNull();
19 });
20
21 test("builds an enabled config from the env pair", () => {
22 const cfg = googleOauthConfigFromEnv({
23 GOOGLE_OAUTH_CLIENT_ID: "abc.apps.googleusercontent.com",
24 GOOGLE_OAUTH_CLIENT_SECRET: "shh",
25 });
26 expect(cfg).not.toBeNull();
27 expect(cfg!.enabled).toBe(true);
28 expect(cfg!.id).toBe("google");
29 expect(cfg!.clientId).toBe("abc.apps.googleusercontent.com");
30 expect(cfg!.clientSecret).toBe("shh");
31 expect(cfg!.authorizationEndpoint).toBe(
32 "https://accounts.google.com/o/oauth2/v2/auth"
33 );
34 expect(cfg!.tokenEndpoint).toBe("https://oauth2.googleapis.com/token");
35 expect(cfg!.userinfoEndpoint).toBe(
36 "https://openidconnect.googleapis.com/v1/userinfo"
37 );
38 expect(cfg!.scopes).toBe("openid email profile");
39 expect(cfg!.autoCreateUsers).toBe(true);
40 expect(cfg!.allowedEmailDomains).toBeNull();
41 });
42
43 test("honours optional knobs", () => {
44 const cfg = googleOauthConfigFromEnv({
45 GOOGLE_OAUTH_CLIENT_ID: "id",
46 GOOGLE_OAUTH_CLIENT_SECRET: "secret",
47 GOOGLE_OAUTH_AUTO_CREATE: "0",
48 GOOGLE_OAUTH_ALLOWED_DOMAINS: "example.com,corp.example.com",
49 });
50 expect(cfg!.autoCreateUsers).toBe(false);
51 expect(cfg!.allowedEmailDomains).toBe("example.com,corp.example.com");
52 });
53});
Addedsrc/__tests__/login-lockout.test.ts+84−0View fileUnifiedSplit
@@ -0,0 +1,84 @@
1import { describe, expect, test } from "bun:test";
2import {
3 evaluateLockout,
4 retryAfterMinutes,
5 LOGIN_FAIL_LIMIT,
6 LOGIN_LOCKOUT_MS,
7} from "../lib/login-lockout";
8
9const NOW = new Date("2026-06-12T12:00:00Z");
10
11function minutesAgo(min: number): Date {
12 return new Date(NOW.getTime() - min * 60_000);
13}
14
15describe("evaluateLockout", () => {
16 test("not locked with zero failures", () => {
17 const s = evaluateLockout({ failureCount: 0, newestFailureAt: null, now: NOW });
18 expect(s.locked).toBe(false);
19 expect(s.retryAfterMs).toBe(0);
20 });
21
22 test("not locked below the failure limit", () => {
23 const s = evaluateLockout({
24 failureCount: LOGIN_FAIL_LIMIT - 1,
25 newestFailureAt: minutesAgo(1),
26 now: NOW,
27 });
28 expect(s.locked).toBe(false);
29 });
30
31 test("locked at the limit with a fresh failure", () => {
32 const s = evaluateLockout({
33 failureCount: LOGIN_FAIL_LIMIT,
34 newestFailureAt: minutesAgo(1),
35 now: NOW,
36 });
37 expect(s.locked).toBe(true);
38 expect(s.retryAfterMs).toBe(LOGIN_LOCKOUT_MS - 60_000);
39 });
40
41 test("lockout EXPIRES 15 minutes after the newest failure", () => {
42 // This is the regression case: previously the lockout never expired
43 // because blocked attempts were recorded as new failures.
44 const s = evaluateLockout({
45 failureCount: LOGIN_FAIL_LIMIT + 5,
46 newestFailureAt: minutesAgo(16),
47 now: NOW,
48 });
49 expect(s.locked).toBe(false);
50 expect(s.retryAfterMs).toBe(0);
51 });
52
53 test("boundary: exactly LOCKOUT_MS after newest failure is unlocked", () => {
54 const s = evaluateLockout({
55 failureCount: LOGIN_FAIL_LIMIT,
56 newestFailureAt: new Date(NOW.getTime() - LOGIN_LOCKOUT_MS),
57 now: NOW,
58 });
59 expect(s.locked).toBe(false);
60 });
61
62 test("many failures but missing newest timestamp fails open", () => {
63 const s = evaluateLockout({
64 failureCount: 100,
65 newestFailureAt: null,
66 now: NOW,
67 });
68 expect(s.locked).toBe(false);
69 });
70});
71
72describe("retryAfterMinutes", () => {
73 test("rounds up and is at least 1", () => {
74 expect(
75 retryAfterMinutes({ locked: true, failureCount: 10, retryAfterMs: 61_000 })
76 ).toBe(2);
77 expect(
78 retryAfterMinutes({ locked: true, failureCount: 10, retryAfterMs: 1 })
79 ).toBe(1);
80 expect(
81 retryAfterMinutes({ locked: false, failureCount: 0, retryAfterMs: 0 })
82 ).toBe(1);
83 });
84});
Addedsrc/lib/login-lockout.ts+62−0View fileUnifiedSplit
@@ -0,0 +1,62 @@
1/**
2 * Login lockout policy (SOC 2 CC6.1) — pure evaluator.
3 *
4 * Semantics:
5 * - An account email is locked when it has accumulated at least
6 * `LOGIN_FAIL_LIMIT` failed attempts inside the trailing
7 * `LOGIN_FAIL_WINDOW_MS`, AND the most recent failure is younger than
8 * `LOGIN_LOCKOUT_MS`. The lockout therefore expires 15 minutes after
9 * the last *genuine* failed password attempt.
10 * - Attempts made while locked must NOT be recorded as failures —
11 * otherwise the lockout window rolls forward forever and a user
12 * retrying their (correct) password can never get back in. This was
13 * the production bug that made "Account temporarily locked … try again
14 * in 15 minutes" permanent.
15 * - A successful login clears the failure history for that email so a
16 * later single typo can't instantly re-trip a stale window.
17 *
18 * Kept pure (no DB access) so the policy is unit-testable; the route layer
19 * supplies the aggregate counts.
20 */
21
22export const LOGIN_FAIL_WINDOW_MS = 60 * 60 * 1000; // 1 hour
23export const LOGIN_FAIL_LIMIT = 10;
24export const LOGIN_LOCKOUT_MS = 15 * 60 * 1000; // 15 minutes
25
26export interface LockoutInput {
27 /** Failed attempts for this email inside the trailing window. */
28 failureCount: number;
29 /** Timestamp of the most recent failed attempt, or null if none. */
30 newestFailureAt: Date | null;
31 /** Injectable clock for tests; defaults to now. */
32 now?: Date;
33}
34
35export interface LockoutState {
36 locked: boolean;
37 failureCount: number;
38 /** Milliseconds until the lockout lifts (0 when not locked). */
39 retryAfterMs: number;
40}
41
42export function evaluateLockout(input: LockoutInput): LockoutState {
43 const now = input.now ?? new Date();
44 const count = Math.max(0, input.failureCount);
45 if (count < LOGIN_FAIL_LIMIT || !input.newestFailureAt) {
46 return { locked: false, failureCount: count, retryAfterMs: 0 };
47 }
48 const sinceNewest = now.getTime() - input.newestFailureAt.getTime();
49 if (sinceNewest >= LOGIN_LOCKOUT_MS) {
50 return { locked: false, failureCount: count, retryAfterMs: 0 };
51 }
52 return {
53 locked: true,
54 failureCount: count,
55 retryAfterMs: LOGIN_LOCKOUT_MS - sinceNewest,
56 };
57}
58
59/** Human-friendly "try again in N minutes" figure, always at least 1. */
60export function retryAfterMinutes(state: LockoutState): number {
61 return Math.max(1, Math.ceil(state.retryAfterMs / 60_000));
62}
Modifiedsrc/lib/sso.ts+51−3View fileUnifiedSplit
@@ -669,17 +669,65 @@ export function githubOauthRedirectUri(): string {
669669
670670const GOOGLE_OAUTH_CONFIG_ID = "google";
671671
672/**
673 * Builds a Google OAuth config from environment variables, or null when the
674 * required pair isn't set. This is the zero-DB bootstrap path: operators set
675 * `GOOGLE_OAUTH_CLIENT_ID` + `GOOGLE_OAUTH_CLIENT_SECRET` (e.g. as Fly
676 * secrets) and "Sign in with Google" turns on without anyone needing to
677 * reach the /admin/google-oauth page first — which matters when the admin
678 * is themselves locked out of password login.
679 *
680 * Optional knobs:
681 * GOOGLE_OAUTH_AUTO_CREATE=0 — disable auto-creating accounts
682 * GOOGLE_OAUTH_ALLOWED_DOMAINS=a,b — restrict sign-in to email domains
683 *
684 * Pure (env passed in) so it's unit-testable.
685 */
686export function googleOauthConfigFromEnv(
687 env: Record<string, string | undefined> = process.env
688): SsoConfig | null {
689 const clientId = (env.GOOGLE_OAUTH_CLIENT_ID || "").trim();
690 const clientSecret = (env.GOOGLE_OAUTH_CLIENT_SECRET || "").trim();
691 if (!clientId || !clientSecret) return null;
692 const now = new Date();
693 return {
694 id: GOOGLE_OAUTH_CONFIG_ID,
695 enabled: true,
696 providerName: "Google",
697 issuer: "https://accounts.google.com",
698 authorizationEndpoint: "https://accounts.google.com/o/oauth2/v2/auth",
699 tokenEndpoint: "https://oauth2.googleapis.com/token",
700 userinfoEndpoint: "https://openidconnect.googleapis.com/v1/userinfo",
701 clientId,
702 clientSecret,
703 scopes: "openid email profile",
704 allowedEmailDomains: (env.GOOGLE_OAUTH_ALLOWED_DOMAINS || "").trim() || null,
705 autoCreateUsers: env.GOOGLE_OAUTH_AUTO_CREATE !== "0",
706 createdAt: now,
707 updatedAt: now,
708 };
709}
710
711/**
712 * Resolution order: a DB row saved via /admin/google-oauth that actually
713 * carries credentials wins (it's the admin's explicit choice, including its
714 * enabled flag); otherwise fall back to env-var configuration; otherwise
715 * whatever partial row exists (or null).
716 */
672717export async function getGoogleOauthConfig(): Promise<SsoConfig | null> {
718 let row: SsoConfig | null = null;
673719 try {
674 const [row] = await db
720 const [r] = await db
675721 .select()
676722 .from(ssoConfig)
677723 .where(eq(ssoConfig.id, GOOGLE_OAUTH_CONFIG_ID))
678724 .limit(1);
679 return row || null;
725 row = r || null;
680726 } catch {
681 return null;
727 row = null;
682728 }
729 if (row?.clientId && row?.clientSecret) return row;
730 return googleOauthConfigFromEnv() ?? row;
683731}
684732
685733export async function upsertGoogleOauthConfig(
Modifiedsrc/routes/auth.tsx+62−33View fileUnifiedSplit
@@ -23,6 +23,13 @@ import {
2323 sessionExpiry,
2424} from "../lib/auth";
2525import { verifyTotpCode, hashRecoveryCode } from "../lib/totp";
26import {
27 evaluateLockout,
28 retryAfterMinutes,
29 LOGIN_FAIL_WINDOW_MS,
30 LOGIN_FAIL_LIMIT,
31 type LockoutState,
32} from "../lib/login-lockout";
2633import { cancelAccountDeletion } from "../lib/account-deletion";
2734import { audit } from "../lib/notify";
2835import {
@@ -590,28 +597,42 @@ auth.get("/login", softAuth, async (c) => {
590597 );
591598});
592599
593// ── 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
598600/**
599 * Returns the number of failed login attempts for `email` in the last
600 * `LOGIN_FAIL_WINDOW_MS` milliseconds.
601 * Loads the failure aggregate for `email` and evaluates the lockout policy
602 * (see src/lib/login-lockout.ts for the semantics).
603 *
604 * Fails OPEN: if the `login_attempts` table is unreachable (missing
605 * migration, transient DB error) login must still work — a broken lockout
606 * ledger must never lock every user out of the site. That failure class
607 * is exactly what the 0087 migration blockade caused in production.
601608 */
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 )
609async function getLockoutState(email: string): Promise<LockoutState> {
610 try {
611 const since = new Date(Date.now() - LOGIN_FAIL_WINDOW_MS);
612 const [row] = await db
613 .select({
614 count: sql<number>`count(*)::int`,
615 newest: sql<string | null>`max(${loginAttempts.createdAt})`,
616 })
617 .from(loginAttempts)
618 .where(
619 and(
620 eq(loginAttempts.email, email.toLowerCase()),
621 eq(loginAttempts.success, false),
622 gte(loginAttempts.createdAt, since)
623 )
624 );
625 return evaluateLockout({
626 failureCount: row?.count ?? 0,
627 newestFailureAt: row?.newest ? new Date(row.newest) : null,
628 });
629 } catch (err) {
630 console.error(
631 "[auth] lockout check failed (failing open):",
632 err instanceof Error ? err.message : err
613633 );
614 return row?.count ?? 0;
634 return { locked: false, failureCount: 0, retryAfterMs: 0 };
635 }
615636}
616637
617638auth.post("/login", async (c) => {
@@ -679,28 +700,29 @@ auth.post("/login", async (c) => {
679700 const emailKey = (user?.email ?? identifier).toLowerCase();
680701
681702 // ── 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(() => {});
703 // Locked when ≥ LOGIN_FAIL_LIMIT failures in the trailing window AND the
704 // newest failure is younger than LOGIN_LOCKOUT_MS. We check before
705 // password verification so brute-forcers can't time-diff their way
706 // around it. Blocked attempts are deliberately NOT recorded as failures:
707 // recording them rolled the window forward forever, so a user retrying
708 // their correct password stayed locked out permanently.
709 const lockout = await getLockoutState(emailKey);
710 if (lockout.locked) {
693711 await audit({
694712 userId: user?.id ?? null,
695713 action: "auth.login.locked",
696714 ip,
697715 userAgent: ua,
698 metadata: { email: emailKey, recentFailures },
716 metadata: { email: emailKey, recentFailures: lockout.failureCount },
699717 });
718 const mins = retryAfterMinutes(lockout);
700719 return c.redirect(
701 "/login?error=Account+temporarily+locked+due+to+too+many+failed+login+attempts.+Please+try+again+in+15+minutes."
720 `/login?error=${encodeURIComponent(
721 `Account temporarily locked due to too many failed login attempts. Please try again in ${mins} minute${mins === 1 ? "" : "s"}.`
722 )}`
702723 );
703724 }
725 const recentFailures = lockout.failureCount;
704726
705727 if (!user) {
706728 // Record failed attempt (unknown user) and return generic error.
@@ -741,11 +763,18 @@ auth.post("/login", async (c) => {
741763 return c.redirect("/login?error=Invalid+credentials");
742764 }
743765
744 // Successful login — record success and clear old failure window.
766 // Successful login — record success and clear the failure history so a
767 // stale window can't combine with one future typo to re-trip the lock.
745768 await db
746769 .insert(loginAttempts)
747770 .values({ email: emailKey, ip, success: true })
748771 .catch(() => {});
772 await db
773 .delete(loginAttempts)
774 .where(
775 and(eq(loginAttempts.email, emailKey), eq(loginAttempts.success, false))
776 )
777 .catch(() => {});
749778
750779 // B4: if the user has TOTP enabled, issue a pending-2fa session and
751780 // redirect to the code prompt.
752781