From a4c2ed45b63b930a94abab65a7b5999dd16c3176 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 13:09:38 +0000 Subject: [PATCH 1/2] test(auth): cover the better-auth 2FA lockout path; regenerate object translation bundles (#3624 follow-up) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #3647, which provisioned four better-auth 1.7 columns that no platform object had. Two loose ends from that PR. ## The 2FA lockout path had no test at all `sys_two_factor.failed_verification_count` / `locked_until` were added blind alongside the team fix — found by the new parity gate, not by anything exercising the behavior. That is why they went missing in the first place: the whole path was untested, so a column better-auth writes on every wrong code could vanish without a single failure. The new dogfood test pins the behavior, not just the schema: a wrong code is COUNTED (the counter advances in the database), and a correct code RESETS the count — which is what makes the budget track *consecutive* failures. It has to go through the mid-sign-in two-factor cookie, not a bearer token: better-auth only runs `assertTwoFactorNotLocked` / `recordTwoFactorFailure` when `isSignIn` is true, and deliberately skips both when the same endpoint is called with a live session. A bearer-token test would have passed while the lockout path stayed entirely unexercised — the exact blind spot that let the columns through. Reverting the `AUTH_TWO_FACTOR_SCHEMA` mapping makes it fail, and shows the breakage was wider than the wrong-code path: `two-factor/enable` itself 500s with `table sys_two_factor has no column named failedVerificationCount`, so enrollment was broken too. TOTP is generated in-test from the otpauth:// secret via RFC 6238 over node:crypto rather than importing `@better-auth/utils/otp` — that is a transitive dependency, and taking a direct one on it to produce six digits would tie the test to an internal package's resolution. ## Translation bundles `os i18n extract` regenerated for the four new fields, which were absent from every locale bundle. Also picks up `sys_organization.parent_organization_id` / `sort_order` from the earlier ADR-0105 D6 work, which had the same gap. The `*.metadata-forms.generated.ts` bundles are deliberately NOT included: the same run rewrites them with unrelated spec drift (agent `visibility` and `capabilities.trash`/`mru` removed, `summaryOperations.*` added). Those are DELETIONS of curated translations for a spec change this branch has nothing to do with, so they are left for whoever owns that change. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UYLC8TfjzHGwatNxZKdX7H --- .../apps/translations/en.objects.generated.ts | 24 ++ .../translations/es-ES.objects.generated.ts | 24 ++ .../translations/ja-JP.objects.generated.ts | 24 ++ .../translations/zh-CN.objects.generated.ts | 24 ++ .../test/two-factor-lockout.dogfood.test.ts | 216 ++++++++++++++++++ 5 files changed, 312 insertions(+) create mode 100644 packages/qa/dogfood/test/two-factor-lockout.dogfood.test.ts diff --git a/packages/platform-objects/src/apps/translations/en.objects.generated.ts b/packages/platform-objects/src/apps/translations/en.objects.generated.ts index ca18bf16a8..5340e98890 100644 --- a/packages/platform-objects/src/apps/translations/en.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.objects.generated.ts @@ -507,6 +507,14 @@ export const enObjects: NonNullable = { label: "Require Multi-Factor Auth", help: "When true, every member of this organization must enroll an authenticator app to access data." }, + parent_organization_id: { + label: "Parent Organization", + help: "Reporting/grouping parent. Grants NOTHING — visibility across organizations comes from membership, never from this reference (ADR-0105 D6)." + }, + sort_order: { + label: "Sort Order", + help: "Display order among sibling organizations. Presentation only." + }, id: { label: "Organization ID" }, @@ -709,6 +717,10 @@ export const enObjects: NonNullable = { label: "Organization", help: "Parent organization for this team" }, + member_count: { + label: "Member Count", + help: "Seat counter maintained by better-auth; do not write directly." + }, id: { label: "Team ID" }, @@ -759,6 +771,10 @@ export const enObjects: NonNullable = { }, user_id: { label: "User" + }, + membership_key: { + label: "Membership Key", + help: "Derived membership digest maintained by better-auth; do not write directly." } }, _actions: { @@ -993,6 +1009,14 @@ export const enObjects: NonNullable = { verified: { label: "Verified", help: "Whether the enrollment was confirmed with a valid TOTP code (managed by better-auth)" + }, + failed_verification_count: { + label: "Failed Verification Count", + help: "Consecutive failed 2FA verifications; reset on success. Maintained by better-auth." + }, + locked_until: { + label: "Locked Until", + help: "Set when failed 2FA verifications cross the lockout threshold. Maintained by better-auth." } }, _views: { diff --git a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts index cd6fbd2920..cab3b73c74 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts @@ -507,6 +507,14 @@ export const esESObjects: NonNullable = { label: "Exigir autenticación multifactor", help: "Si es true, todos los miembros de esta organización deben registrar una aplicación de autenticación para acceder a los datos." }, + parent_organization_id: { + label: "Parent Organization", + help: "Reporting/grouping parent. Grants NOTHING — visibility across organizations comes from membership, never from this reference (ADR-0105 D6)." + }, + sort_order: { + label: "Sort Order", + help: "Display order among sibling organizations. Presentation only." + }, id: { label: "ID de organización" }, @@ -709,6 +717,10 @@ export const esESObjects: NonNullable = { label: "Organización", help: "Organización principal de este equipo." }, + member_count: { + label: "Member Count", + help: "Seat counter maintained by better-auth; do not write directly." + }, id: { label: "ID de equipo" }, @@ -759,6 +771,10 @@ export const esESObjects: NonNullable = { }, user_id: { label: "Usuario" + }, + membership_key: { + label: "Membership Key", + help: "Derived membership digest maintained by better-auth; do not write directly." } }, _actions: { @@ -993,6 +1009,14 @@ export const esESObjects: NonNullable = { verified: { label: "Verified", help: "Whether the enrollment was confirmed with a valid TOTP code (managed by better-auth)" + }, + failed_verification_count: { + label: "Failed Verification Count", + help: "Consecutive failed 2FA verifications; reset on success. Maintained by better-auth." + }, + locked_until: { + label: "Locked Until", + help: "Set when failed 2FA verifications cross the lockout threshold. Maintained by better-auth." } }, _views: { diff --git a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts index 3706f39e16..03b0a9abf5 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts @@ -507,6 +507,14 @@ export const jaJPObjects: NonNullable = { label: "多要素認証を必須化", help: "true の場合、この組織のすべてのメンバーはデータにアクセスするために認証アプリの登録が必要になります。" }, + parent_organization_id: { + label: "Parent Organization", + help: "Reporting/grouping parent. Grants NOTHING — visibility across organizations comes from membership, never from this reference (ADR-0105 D6)." + }, + sort_order: { + label: "Sort Order", + help: "Display order among sibling organizations. Presentation only." + }, id: { label: "組織 ID" }, @@ -709,6 +717,10 @@ export const jaJPObjects: NonNullable = { label: "組織", help: "このチームの親組織" }, + member_count: { + label: "Member Count", + help: "Seat counter maintained by better-auth; do not write directly." + }, id: { label: "チーム ID" }, @@ -759,6 +771,10 @@ export const jaJPObjects: NonNullable = { }, user_id: { label: "ユーザー" + }, + membership_key: { + label: "Membership Key", + help: "Derived membership digest maintained by better-auth; do not write directly." } }, _actions: { @@ -993,6 +1009,14 @@ export const jaJPObjects: NonNullable = { verified: { label: "Verified", help: "Whether the enrollment was confirmed with a valid TOTP code (managed by better-auth)" + }, + failed_verification_count: { + label: "Failed Verification Count", + help: "Consecutive failed 2FA verifications; reset on success. Maintained by better-auth." + }, + locked_until: { + label: "Locked Until", + help: "Set when failed 2FA verifications cross the lockout threshold. Maintained by better-auth." } }, _views: { diff --git a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts index be36a5eb3c..ee87126f72 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts @@ -507,6 +507,14 @@ export const zhCNObjects: NonNullable = { label: "强制多因素认证", help: "为 true 时,该组织的每位成员都必须注册身份验证器 App 才能访问数据。" }, + parent_organization_id: { + label: "Parent Organization", + help: "Reporting/grouping parent. Grants NOTHING — visibility across organizations comes from membership, never from this reference (ADR-0105 D6)." + }, + sort_order: { + label: "Sort Order", + help: "Display order among sibling organizations. Presentation only." + }, id: { label: "组织 ID" }, @@ -709,6 +717,10 @@ export const zhCNObjects: NonNullable = { label: "组织", help: "该团队所属的上级组织" }, + member_count: { + label: "Member Count", + help: "Seat counter maintained by better-auth; do not write directly." + }, id: { label: "团队 ID" }, @@ -759,6 +771,10 @@ export const zhCNObjects: NonNullable = { }, user_id: { label: "用户" + }, + membership_key: { + label: "Membership Key", + help: "Derived membership digest maintained by better-auth; do not write directly." } }, _actions: { @@ -993,6 +1009,14 @@ export const zhCNObjects: NonNullable = { verified: { label: "Verified", help: "Whether the enrollment was confirmed with a valid TOTP code (managed by better-auth)" + }, + failed_verification_count: { + label: "Failed Verification Count", + help: "Consecutive failed 2FA verifications; reset on success. Maintained by better-auth." + }, + locked_until: { + label: "Locked Until", + help: "Set when failed 2FA verifications cross the lockout threshold. Maintained by better-auth." } }, _views: { diff --git a/packages/qa/dogfood/test/two-factor-lockout.dogfood.test.ts b/packages/qa/dogfood/test/two-factor-lockout.dogfood.test.ts new file mode 100644 index 0000000000..2312174539 --- /dev/null +++ b/packages/qa/dogfood/test/two-factor-lockout.dogfood.test.ts @@ -0,0 +1,216 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * better-auth 1.7's 2FA account lockout, end to end (#3624 follow-up). + * + * #3624 was found on the org-create path, but the same dependency bump added + * `twoFactor.failedVerificationCount` / `lockedUntil` — the account-level + * lockout budget better-auth guard-increments on every wrong code (NIST SP + * 800-63B §5.2.2). `sys_two_factor` provisioned neither column and + * `AUTH_TWO_FACTOR_SCHEMA` mapped neither, so the WRONG-CODE path wrote to + * columns that did not exist: entering a bad 2FA code 500'd instead of being + * counted, and the lockout never engaged. Nothing caught it because this path + * had no test at all — the columns were added blind alongside the team fix. + * + * So this pins the behavior, not just the schema: + * + * 1. a wrong code is COUNTED (the counter advances in the database), and + * 2. a correct code RESETS the count — so the budget tracks *consecutive* + * failures, which is the whole point of a lockout counter. + * + * ## Why the sign-in cookie, not a bearer token + * + * better-auth only runs the lockout bookkeeping when `isSignIn` is true — + * i.e. when `/two-factor/verify-totp` is reached through the mid-sign-in + * two-factor cookie, with NO active session. The same endpoint called with a + * live session (the enrollment-confirmation path) deliberately skips + * `assertTwoFactorNotLocked` / `recordTwoFactorFailure`. A bearer-token test + * would therefore pass while the lockout path stayed completely unexercised — + * exactly the blind spot that let the missing columns through. Hence the + * cookie plumbing below: it is what makes this test test anything. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { createHmac } from 'node:crypto'; +import showcaseStack from '@objectstack/example-showcase'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; + +const SYS = { context: { isSystem: true } }; +const ADMIN_PASSWORD = 'admin123'; + +// ── RFC 6238 TOTP ────────────────────────────────────────────────────────── +// Hand-rolled rather than imported: `@better-auth/utils/otp` is a transitive +// dependency, and adding it as a direct one to generate six digits would tie +// this test to an internal package's resolution. better-auth's defaults are +// the RFC's (SHA-1, 6 digits, 30s), asserted by `enable`'s own otpauth:// URI. + +function base32Decode(input: string): Buffer { + const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; + const clean = input.replace(/=+$/, '').toUpperCase(); + let bits = 0; + let value = 0; + const out: number[] = []; + for (const char of clean) { + const idx = ALPHABET.indexOf(char); + if (idx === -1) throw new Error(`invalid base32 character: ${char}`); + value = (value << 5) | idx; + bits += 5; + if (bits >= 8) { + out.push((value >>> (bits - 8)) & 0xff); + bits -= 8; + } + } + return Buffer.from(out); +} + +/** The 6-digit TOTP for `secret` at the current 30-second step. */ +function totp(secret: Buffer): string { + const counter = Math.floor(Date.now() / 30_000); + const buf = Buffer.alloc(8); + buf.writeBigUInt64BE(BigInt(counter)); + const digest = createHmac('sha1', secret).update(buf).digest(); + const offset = digest[digest.length - 1] & 0x0f; + const code = + ((digest[offset] & 0x7f) << 24) | + ((digest[offset + 1] & 0xff) << 16) | + ((digest[offset + 2] & 0xff) << 8) | + (digest[offset + 3] & 0xff); + return String(code % 1_000_000).padStart(6, '0'); +} + +/** Collect a response's Set-Cookie values into a single request Cookie header. */ +function cookieHeader(res: Response): string { + const jar = res.headers.getSetCookie?.() ?? []; + return jar.map((c) => c.split(';')[0]).join('; '); +} + +describe('#3624 follow-up: better-auth 2FA lockout counts wrong codes', () => { + let stack: VerifyStack; + let ql: any; + let priorTwoFactor: string | undefined; + let secret: Buffer; + let adminEmail: string; + + beforeAll(async () => { + // The two-factor plugin is opt-in (`twoFactor: … ?? false`), resolved once + // when the auth manager is constructed — so this must precede bootStack. + priorTwoFactor = process.env.OS_AUTH_TWO_FACTOR; + process.env.OS_AUTH_TWO_FACTOR = 'true'; + + stack = await bootStack(showcaseStack, {}); + ql = await stack.kernel.getServiceAsync('objectql'); + + const token = await stack.signIn(); + const me = await (await stack.apiAs(token, 'GET', '/auth/get-session')).json() as any; + adminEmail = me?.user?.email; + expect(adminEmail, 'could not resolve the seeded admin email').toBeTruthy(); + + // Enrol. `enable` returns the otpauth:// URI carrying the base32 secret; + // the stored copy is symmetrically encrypted, so this is the only place + // the plaintext is available. + const enabled = await stack.apiAs(token, 'POST', '/auth/two-factor/enable', { + password: ADMIN_PASSWORD, + }); + expect(enabled.status, `two-factor/enable: ${await enabled.clone().text()}`).toBe(200); + const { totpURI } = (await enabled.json()) as { totpURI: string }; + const uriSecret = new URL(totpURI.replace('otpauth://', 'https://')).searchParams.get('secret'); + expect(uriSecret, 'no secret in the otpauth URI').toBeTruthy(); + secret = base32Decode(uriSecret as string); + + // better-auth enrols with `verified: false`, and the sign-in path refuses + // an unverified enrolment (TOTP_NOT_ENABLED) before it ever reaches the + // lockout bookkeeping. Confirm it through the SESSION path first — this + // call is `isSignIn: false`, so it deliberately touches no counter. + const confirmed = await stack.apiAs(token, 'POST', '/auth/two-factor/verify-totp', { + code: totp(secret), + }); + expect(confirmed.status, `verify-totp (enrolment): ${await confirmed.clone().text()}`).toBe(200); + }, 120_000); + + afterAll(async () => { + if (priorTwoFactor === undefined) delete process.env.OS_AUTH_TWO_FACTOR; + else process.env.OS_AUTH_TWO_FACTOR = priorTwoFactor; + await stack?.stop?.(); + }); + + /** The live lockout state better-auth keeps for the admin's enrolment. */ + async function lockoutState(): Promise<{ count: number; lockedUntil: unknown }> { + const users = await ql.find('sys_user', { where: { email: adminEmail }, limit: 1 }, SYS); + const rows = await ql.find( + 'sys_two_factor', + { where: { user_id: String(users[0]?.id) }, limit: 1 }, + SYS, + ); + const row = rows[0] ?? {}; + return { count: Number(row.failed_verification_count ?? 0), lockedUntil: row.locked_until ?? null }; + } + + /** Password sign-in that stops at the 2FA challenge; returns its cookie. */ + async function beginChallenge(): Promise { + const res = await stack.api('/auth/sign-in/email', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: adminEmail, password: ADMIN_PASSWORD }), + }); + expect(res.status, `sign-in: ${await res.clone().text()}`).toBe(200); + const body = (await res.json()) as { twoFactorRedirect?: boolean }; + expect( + body.twoFactorRedirect, + 'sign-in did not stop at the 2FA challenge — the rest of this test would silently run on the session path', + ).toBe(true); + const cookie = cookieHeader(res); + expect(cookie, 'sign-in returned no two-factor cookie').toBeTruthy(); + return cookie; + } + + function verifyWithCookie(cookie: string, code: string): Promise { + return stack.api('/auth/two-factor/verify-totp', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Cookie: cookie }, + body: JSON.stringify({ code }), + }); + } + + it('counts each wrong code against the account budget', async () => { + const before = await lockoutState(); + const cookie = await beginChallenge(); + + const first = await verifyWithCookie(cookie, '000000'); + expect(first.status, 'a wrong code must be rejected, not accepted').toBeGreaterThanOrEqual(400); + // The 500 this file exists for: before the columns were provisioned, the + // guarded increment blew up on the way out instead of returning a clean + // rejection. + expect(first.status, `wrong code produced a server error: ${await first.clone().text()}`).toBeLessThan(500); + + const afterFirst = await lockoutState(); + expect( + afterFirst.count, + 'failed_verification_count did not advance — the wrong code was never counted', + ).toBe(before.count + 1); + + const second = await verifyWithCookie(cookie, '111111'); + expect(second.status).toBeGreaterThanOrEqual(400); + expect(second.status).toBeLessThan(500); + + const afterSecond = await lockoutState(); + expect(afterSecond.count).toBe(before.count + 2); + }); + + it('resets the count when a correct code lands', async () => { + // Carries the non-zero count left by the previous test — asserting the + // reset from a clean 0 would prove nothing. + const before = await lockoutState(); + expect(before.count, 'precondition: a non-zero failure count to clear').toBeGreaterThan(0); + + const cookie = await beginChallenge(); + const ok = await verifyWithCookie(cookie, totp(secret)); + expect(ok.status, `verify-totp (correct code): ${await ok.clone().text()}`).toBe(200); + + const after = await lockoutState(); + expect( + after.count, + 'a successful verification must clear the budget — otherwise it counts non-consecutive failures and locks out a legitimate user', + ).toBe(0); + expect(after.lockedUntil ?? null).toBeNull(); + }); +}); From e61b05b3022197ff31d09a8135c34a90839565fb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 13:17:34 +0000 Subject: [PATCH 2/2] chore(changeset): declare the object-translation bundle update (#3624 follow-up) The regenerated bundles ship inside @objectstack/platform-objects, so the change is consumer-visible and takes a real patch changeset rather than an empty one. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UYLC8TfjzHGwatNxZKdX7H --- ...-factor-lockout-and-object-translations.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 .changeset/two-factor-lockout-and-object-translations.md diff --git a/.changeset/two-factor-lockout-and-object-translations.md b/.changeset/two-factor-lockout-and-object-translations.md new file mode 100644 index 0000000000..e432873726 --- /dev/null +++ b/.changeset/two-factor-lockout-and-object-translations.md @@ -0,0 +1,19 @@ +--- +"@objectstack/platform-objects": patch +--- + +fix(i18n): ship the missing object-translation keys for the better-auth 1.7 and ADR-0105 D6 fields (#3624 follow-up) + +The generated object-translation bundles predate two rounds of field additions, +so six fields had no entry in **any** locale and fell back to their raw schema +labels in every UI surface that reads the bundle: + +- `sys_team.member_count`, `sys_team_member.membership_key`, + `sys_two_factor.failed_verification_count` / `locked_until` — the better-auth + 1.7 columns provisioned in #3647. +- `sys_organization.parent_organization_id` / `sort_order` — the same gap left + by the earlier ADR-0105 D6 group-structure work. + +Regenerated with `os i18n extract` (merge mode, so every existing translation is +preserved — the diff is purely additive). No API or schema change; the fields +themselves already shipped.