Skip to content

Commit 2f369cd

Browse files
fix(plugin-auth): a correct-password sign-in refused by an organization IP list no longer counts toward the account lockout (#31262)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1X2yt1mWbT9wcdVqxqkNbGab)_ Fixes #31251 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:`auth-manager-after-hook.ts` 的 ADR-0069 D2 锁定计数器,遇到三种「密码已通过、之后才被拒」的登录时不再计数,也不清零:组织 IP 名单拒绝(`403 IP_NOT_ALLOWED`)、账号被封禁(`403 BANNED_USER`)、邮箱未验证(`403 EMAIL_NOT_VERIFIED`)。其余拒绝照旧计数,成功照旧清零。 - 为什么改:密码正确的本人会把自己锁住:在组织网络外登录三次,回到网络内被拒 `ACCOUNT_LOCKED`;封禁解除后仍被锁;等验证邮件期间登录三次,验证后仍被锁。 - 风险与回滚:只少计「密码已正确」的三种拒绝,错误密码在任何情况下仍然计数,所以不给猜密码者多留尝试次数;调用方看到的 403 状态、错误码、文字和审计行都不变。回滚即还原本 PR。 - 先看:`packages/plugins/plugin-auth/src/auth-manager-after-hook.ts`(`refusedAfterTheCredential`、`VENDOR_BANNED_USER_CODE` 与计数调用处),再看 `account-lockout-resolved-account.test.ts` 末尾新增的两个 describe。 - 两分钟验证:`pnpm --filter @objectstack/plugin-auth exec vitest run src/account-lockout-resolved-account.test.ts`,预期 11 个用例全绿。 - 需要你做的:无,席位落地。 ## What changes - `packages/plugins/plugin-auth/src/auth-manager-after-hook.ts`: in the `SIGN_IN_EMAIL_PATH` branch, a refused sign-in whose code `refusedAfterTheCredential` names skips `recordSignInOutcome`: no increment, no reset. The set, per the claim and its amendment, each code named by a constant: `SESSION_SCOPING_REFUSAL.code` (imported from `organization-access-policy.ts`); `BASE_ERROR_CODES.EMAIL_NOT_VERIFIED.code` (`@better-auth/core/error`, loaded lazily as the before-hook does); and `VENDOR_BANNED_USER_CODE`, one module-level constant whose comment names its vendor source (`ADMIN_ERROR_CODES.BANNED_USER` in better-auth's admin plugin, which the package exports on no public path). `auditRefusedSignIn` still runs, so the refusal's `login_failed` row is written exactly as before (and as #31249 then shapes it). The status, code, message and timing the caller sees are untouched: the after-hook only ever observed the returned error. - The predicate is an async function evaluated per sign-in, not a module-level `Set`: `organization-access-policy.ts` imports `auth-manager.ts`, which reaches the after-hook through `auth-manager-auth-instance.ts`, so on that import order a module-level constant built from `SESSION_SCOPING_REFUSAL` would read it before it is initialised. - Why these codes can only arrive after the password passed: better-auth 1.7.12's `signInEmail` throws `INVALID_EMAIL_OR_PASSWORD` for a wrong password, then `EMAIL_NOT_VERIFIED` (when the deployment requires a verified address), then calls `createSession`, whose `session.create.before` hooks raise the organization list's `IP_NOT_ALLOWED` (`refuseUnadmittedScoping`) and the admin plugin's `BANNED_USER`. A wrong password never reaches any of the three. The deployment's own list (`allowed_ip_ranges` on the deployment) refuses at the auth-route middleware in `auth-plugin.ts`, before better-auth runs, so it never reached this counter. - `.changeset/31251-org-ip-refusal-lockout-counter.md`: `@objectstack/plugin-auth` patch (the after-hook ships in the package's `dist/`), naming all three refusals. ## Tests New top-level `describe` in `src/account-lockout-resolved-account.test.ts` (the lockout's shared harness: real `AuthManager.handleRequest` over the in-memory engine; the file's `post` gains an optional `remoteAddress`). The account is a member of an organization whose own list admits only 10.1.0.0/16; deployment threshold 3. 1. Three correct-password sign-ins from 10.2.0.7 each answer `403 IP_NOT_ALLOWED` (code + status) and leave `failed_login_count` at 0; no `account_locked` event; the next sign-in from 10.1.0.7 answers 200 and the count is 0. 2. Two wrong passwords from inside raise the count to 2; a correct password from outside (`403 IP_NOT_ALLOWED`) leaves it at 2 and the account unlocked: the refusal neither counts nor clears. 3. Controls: a wrong password from outside answers `401 INVALID_EMAIL_OR_PASSWORD` and counts (the credential refuses first, the organization list is never reached); a wrong password from inside counts; a correct password from inside clears to 0; three wrong passwords from outside lock, and the correct password from inside is then `403 ACCOUNT_LOCKED`. A second new `describe` on the same harness (`deployment` takes config extras): 4. Ban (`plugins.admin: true`, the account's `banned` set): two wrong passwords count to 2 (`401 INVALID_EMAIL_OR_PASSWORD`, the wrong-password control); three correct passwords each answer `403 BANNED_USER` (code + status) and leave the count at 2, no lock, no `account_locked` event; the ban lifted, the correct password answers 200 and clears to 0. 5. Unverified address (`emailAndPassword.requireEmailVerification`, `email_verified` false): two wrong passwords count to 2; three correct passwords each answer `403 EMAIL_NOT_VERIFIED` and leave it at 2, no lock; one more wrong password reaches the threshold and locks. <details><summary>Red first</summary> - Cases 1–3 on origin/main 0d0c377 (fix absent): 2 failed / 7 passed. Case 1: `attempt 1: expected 1 to be +0`; case 2: `expected 3 to be 2`. Case 3 (controls) green on main, as intended. - Cases 4–5 against the first head's after-hook (`c38cc4cf`, skip set = IP list only; file restored to the tree only, then `git checkout HEAD`, `git diff HEAD` empty): 2 failed / 9 passed, both `attempt 1: expected 3 to be 2`. On main the same two refusals count too (sweep below). - The card's sequence, probed on main with the same harness (throwaway file, deleted): `[403 IP_NOT_ALLOWED count 1, 403 IP_NOT_ALLOWED count 2, 403 IP_NOT_ALLOWED count 3, then from inside 403 ACCOUNT_LOCKED count 3]`, matching the card. </details> <details><summary>Ablation, one leg per code</summary> `node scripts/ablation-replace.mjs --delete` on `auth-manager-after-hook.ts` (blob e06b13d at the fix commit), one list entry at a time; each leg restored to blob == HEAD with `git diff HEAD` empty: - `VENDOR_BANNED_USER_CODE,` deleted (→ 46ae4dc78472): case 4 red only, 1 failed / 10 passed. - `BASE_ERROR_CODES.EMAIL_NOT_VERIFIED.code,` deleted (→ 92ed2737beda): case 5 red only, 1 failed / 10 passed. - `SESSION_SCOPING_REFUSAL.code,` deleted (→ 117bb456302b): cases 1 and 2 red, 2 failed / 9 passed. The first head's whole-skip ablation (c38cc4c, the skip never firing) read cases 1 and 2 red. The test resolves the after-hook from `src/` through relative imports inside the package, so no `dist/` leg applies. </details> ## Acceptance notes Sweep of the other refusals that can reach this counter on `/sign-in/email` after the password passed (measured with throwaway probes on the same harness, deleted; better-auth 1.7.12): - **Ban (`403 BANNED_USER`)**, with the admin plugin on (`plugins.admin`, or forced on by SCIM): thrown by the vendor admin plugin's `session.create.before`, the same path as the organization list. On main it counted: three correct-password sign-ins of a banned account locked it, and after the ban was lifted the next correct sign-in answered `403 ACCOUNT_LOCKED`. **Fixed here** (claim amendment on #31251, cases 4). - **Email not verified (`403 EMAIL_NOT_VERIFIED`)**, with `emailAndPassword.requireEmailVerification`: thrown by `signInEmail` right after `password.verify` succeeded. On main it counted: count 2 → 3 and locked on one correct-password attempt. **Fixed here** (same amendment, case 5). - **MFA hold** (`plugins.twoFactor` on, the account enrolled): the sign-in answers 200 `{ twoFactorRedirect: true }`; this hook runs before the two-factor plugin's after-hook and sees the session payload, so it **clears** the count (2 → 0). Not a refusal; the password passed and the second factor has its own lockout projection. Correct as is. - **Concurrent-session cap**: `enforceConcurrentCap` runs on the success branch and ends the oldest sessions; it refuses no sign-in, so nothing reaches the counter (by reading). - **Organization idle / absolute limits** are judged on session reads and on an organization switch, not at sign-in; an organization's MFA requirement is a hold recorded on the `login` row with a 200 answer (by reading). Neither reaches the counter as a refusal. - **Deployment IP list**: refused at the auth-route middleware before better-auth, never reaching the counter (by reading). Unchanged. - PR #31249 (in review) touches `auth-session-audit.ts` and `auth-manager-database-hooks.ts`; this PR only imports `refusalCodeOf` / `SIGN_IN_EMAIL_PATH` from the former and changes neither. The new pins assert no `login_failed` row content, so they hold whichever lands first. - Published answers: the `403 IP_NOT_ALLOWED`, `403 BANNED_USER` and `403 EMAIL_NOT_VERIFIED` answers are unchanged (the after-hook only observes them); the only answer that moves is a later sign-in that main refused `403 ACCOUNT_LOCKED` and now admits, which is the defect. Hence `Clause-②: no`. ## Verification All on head `f2bb01d5` (base `origin/main` 0d0c377; `origin/main` has since moved, with no commit under `packages/plugins/plugin-auth`, so no merge was taken; PR #31249 had not landed when this was read). - `pnpm --filter @objectstack/plugin-auth test` under the verification lock: VERDICT command-exit 0, Test Files 237 passed (237), Tests 4209 passed / 10 skipped. - `pnpm --filter @objectstack/plugin-auth typecheck` (tsc, examples, `check:test-typecheck`) exit 0, after the package's own build. - Scoped lint: `pnpm exec eslint --no-inline-config` on the two touched `.ts` files, exit 0. - Gate union derived by `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (62 families; the widened diff adds `check:dispatcher-error-vocabulary`, exit 0), each run with stdin closed and its exit captured before any pipe, reconciled with `--ran`: "62 derived famil(ies) accounted for — 61 run, 1 NOT-MEASURED". 61 exit 0. - **NOT MEASURED: `pnpm check:dual-build-cjs-loads`**, reason: exit 3 PREREQUISITE NOT MET (it loads every package's `dist/`; only the plugin-auth closure was built). CI's `Lint & Repo Gates` runs it on a full build. - `node scripts/check-plugin-teardown-shape.mjs --self-test` exit 0 after `git fetch --depth=1 origin 621a487` (its pinned fixture, on this shallow clone). - CI families are read from this PR's check-runs, not re-run here. --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5mSU6a4NFvNfFHukWv)_ <!-- signed: domain:server#6 · os-project-manager · session_01FEkQ5p3T5YWhLjurajudnQ --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 07559ce commit 2f369cd

3 files changed

Lines changed: 195 additions & 4 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/plugin-auth': patch
3+
---
4+
5+
fix(plugin-auth): a correct-password sign-in refused afterwards, by an organization's allowed IP ranges, a ban or an unverified address, no longer counts toward the account lockout
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
The ADR-0069 D2 lockout counter read every refused `/sign-in/email` as a wrong password. Three refusals come only after the password verified: an organization's own IP list (`403 IP_NOT_ALLOWED`, judged when the new session is scoped to it), a ban (`403 BANNED_USER`, judged when the session is created) and an address the deployment requires verified (`403 EMAIL_NOT_VERIFIED`). Each raised the count, so an account holder with the right password could lock their own account: a member signing in from outside the organization's network was then refused `ACCOUNT_LOCKED` from inside it, a lifted ban left a lock behind, and a person waiting for their verification mail was locked out once it arrived. These three refusals now leave the counter as it stands, neither counting nor clearing it. A wrong password still counts in every case, and a successful sign-in still clears the count. The refusals' status, code and message and their `login_failed` audit rows are unchanged.

‎packages/plugins/plugin-auth/src/account-lockout-resolved-account.test.ts‎

Lines changed: 135 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@
2121
// The account events (`login_failed` / `account_locked`) name the account the
2222
// same resolution names, so the ledger and the lock never disagree.
2323
//
24+
// Only a refused CREDENTIAL moves the counter. A sign-in whose password passed
25+
// and that a policy then refused (the organization's allowed IP ranges, judged
26+
// when the session is scoped to it; a ban; an address the deployment requires
27+
// verified) neither counts nor clears: the last two `describe`s below.
28+
//
2429
// ## Why the server here is the real one
2530
//
2631
// "Which account does this identifier name" is answered by the credential
@@ -56,7 +61,8 @@ const PASSWORD = 'Corr3ct!Horse-Battery';
5661
const WRONG_PASSWORD = 'Wr0ng!Guess-Zebra-7731';
5762
const THRESHOLD = 3;
5863

59-
function deployment() {
64+
/** `extra` is merged into the manager's config (a plugin switch, an e-mail rule). */
65+
function deployment(extra: Record<string, unknown> = {}) {
6066
const engine = createMemoryEngine();
6167
const events: AuthSessionAuditEventInput[] = [];
6268
const manager = new AuthManager({
@@ -65,6 +71,7 @@ function deployment() {
6571
dataEngine: engine,
6672
lockoutThreshold: THRESHOLD,
6773
lockoutDurationMinutes: 15,
74+
...extra,
6875
getAuditSink: () => ({
6976
recordAuthEvent: async (event: AuthSessionAuditEventInput) => {
7077
events.push(event);
@@ -74,13 +81,14 @@ function deployment() {
7481
return { engine, manager, events };
7582
}
7683

77-
const post = (manager: AuthManager, path: string, body: unknown) =>
84+
const post = (manager: AuthManager, path: string, body: unknown, remoteAddress?: string) =>
7885
manager.handleRequest(
7986
new Request(`${BASE}${path}`, {
8087
method: 'POST',
8188
headers: { 'Content-Type': 'application/json' },
8289
body: JSON.stringify(body),
8390
}),
91+
remoteAddress ? { remoteAddress } : undefined,
8492
);
8593

8694
async function signUp(manager: AuthManager): Promise<void> {
@@ -226,3 +234,128 @@ describe('ADR-0069 D2: the lockout counts and holds the account the credential c
226234
expect((await signIn(manager, EMAIL, PASSWORD)).status).toBe(200);
227235
});
228236
});
237+
238+
/** The organization the account belongs to, and the one network its own list admits. */
239+
const ORGANIZATION = 'org_lockout_policy';
240+
const INSIDE = '10.1.0.7';
241+
const OUTSIDE = '10.2.0.7';
242+
const NOT_ADMITTED = { status: 403, code: 'IP_NOT_ALLOWED' };
243+
244+
/**
245+
* {@link deployment}, with the account a member of an organization whose own
246+
* allowed IP ranges admit only 10.1.0.0/16. The deployment sets no list of its
247+
* own, so the organization's list is judged where the sign-in's session is
248+
* scoped to it — after the password passed.
249+
*/
250+
async function organizationListDeployment() {
251+
const { engine, manager, events } = deployment();
252+
await signUp(manager);
253+
const userId = String(accountRow(engine).id);
254+
// Whatever membership the sign-up settled is replaced by the one under test.
255+
engine.tables.set('sys_member', (engine.tables.get('sys_member') ?? []).filter((m: any) => m.user_id !== userId));
256+
await engine.insert('sys_member', { user_id: userId, organization_id: ORGANIZATION, role: 'member', created_at: new Date() });
257+
manager.setOrganizationAccessPolicySource({
258+
read: async (id) => (id === ORGANIZATION ? { allowed_ip_ranges: '10.1.0.0/16' } : {}),
259+
anyOrganizationRequiresMfa: async () => false,
260+
});
261+
return { engine, manager, events };
262+
}
263+
264+
const signInFrom = (manager: AuthManager, password: string, remoteAddress: string) =>
265+
post(manager, '/sign-in/email', { email: EMAIL, password }, remoteAddress);
266+
267+
describe('ADR-0069 D2: a correct password the organization\'s IP list refuses neither counts nor clears', () => {
268+
it('refusals past the threshold lock nothing, and the account signs in from inside the list', async () => {
269+
const { engine, manager, events } = await organizationListDeployment();
270+
events.length = 0;
271+
272+
for (let i = 0; i < THRESHOLD; i++) {
273+
expect(await outcome(await signInFrom(manager, PASSWORD, OUTSIDE)), `attempt ${i + 1}`).toEqual(NOT_ADMITTED);
274+
expect(Number(accountRow(engine).failed_login_count ?? 0), `attempt ${i + 1}`).toBe(0);
275+
}
276+
expect(isLocked(accountRow(engine))).toBe(false);
277+
expect(events.some((e) => e.action === 'account_locked')).toBe(false);
278+
279+
const home = await signInFrom(manager, PASSWORD, INSIDE);
280+
expect(home.status, await home.clone().text()).toBe(200);
281+
expect(Number(accountRow(engine).failed_login_count ?? 0)).toBe(0);
282+
});
283+
284+
it('the refusal leaves a count earned by wrong passwords where it stands', async () => {
285+
const { engine, manager } = await organizationListDeployment();
286+
for (let i = 0; i < THRESHOLD - 1; i++) {
287+
expect(await outcome(await signInFrom(manager, WRONG_PASSWORD, INSIDE))).toEqual(REFUSED);
288+
}
289+
expect(Number(accountRow(engine).failed_login_count)).toBe(THRESHOLD - 1);
290+
291+
expect(await outcome(await signInFrom(manager, PASSWORD, OUTSIDE))).toEqual(NOT_ADMITTED);
292+
expect(Number(accountRow(engine).failed_login_count)).toBe(THRESHOLD - 1);
293+
expect(isLocked(accountRow(engine))).toBe(false);
294+
});
295+
296+
it('a wrong password counts from either side of the list, and a correct one from inside clears', async () => {
297+
const { engine, manager } = await organizationListDeployment();
298+
299+
// From outside the credential refuses first: the list is judged only once a session is created.
300+
expect(await outcome(await signInFrom(manager, WRONG_PASSWORD, OUTSIDE))).toEqual(REFUSED);
301+
expect(Number(accountRow(engine).failed_login_count)).toBe(1);
302+
expect(await outcome(await signInFrom(manager, WRONG_PASSWORD, INSIDE))).toEqual(REFUSED);
303+
expect(Number(accountRow(engine).failed_login_count)).toBe(2);
304+
305+
expect((await signInFrom(manager, PASSWORD, INSIDE)).status).toBe(200);
306+
expect(Number(accountRow(engine).failed_login_count)).toBe(0);
307+
308+
for (let i = 0; i < THRESHOLD; i++) {
309+
expect(await outcome(await signInFrom(manager, WRONG_PASSWORD, OUTSIDE))).toEqual(REFUSED);
310+
}
311+
expect(isLocked(accountRow(engine))).toBe(true);
312+
expect(await outcome(await signInFrom(manager, PASSWORD, INSIDE))).toEqual(LOCKED);
313+
});
314+
});
315+
316+
describe('ADR-0069 D2: a correct password refused for a ban or an unverified address neither counts nor clears', () => {
317+
it('a banned account: wrong passwords count, the correct one is refused BANNED_USER and moves nothing', async () => {
318+
const { engine, manager, events } = deployment({ plugins: { admin: true } });
319+
await signUp(manager);
320+
accountRow(engine).banned = true;
321+
for (let i = 0; i < THRESHOLD - 1; i++) {
322+
expect(await outcome(await signIn(manager, EMAIL, WRONG_PASSWORD))).toEqual(REFUSED);
323+
}
324+
expect(Number(accountRow(engine).failed_login_count)).toBe(THRESHOLD - 1);
325+
events.length = 0;
326+
327+
for (let i = 0; i < THRESHOLD; i++) {
328+
expect(await outcome(await signIn(manager, EMAIL, PASSWORD)), `attempt ${i + 1}`)
329+
.toEqual({ status: 403, code: 'BANNED_USER' });
330+
expect(Number(accountRow(engine).failed_login_count), `attempt ${i + 1}`).toBe(THRESHOLD - 1);
331+
}
332+
expect(isLocked(accountRow(engine))).toBe(false);
333+
expect(events.some((e) => e.action === 'account_locked')).toBe(false);
334+
335+
// Lifted, the ban leaves no lock behind: the correct password signs in and clears the count.
336+
accountRow(engine).banned = false;
337+
expect((await signIn(manager, EMAIL, PASSWORD)).status).toBe(200);
338+
expect(Number(accountRow(engine).failed_login_count)).toBe(0);
339+
});
340+
341+
it('an unverified address: wrong passwords count, the correct one is refused EMAIL_NOT_VERIFIED and moves nothing', async () => {
342+
const { engine, manager } = deployment({ emailAndPassword: { enabled: true, requireEmailVerification: true } });
343+
await signUp(manager);
344+
accountRow(engine).email_verified = false;
345+
for (let i = 0; i < THRESHOLD - 1; i++) {
346+
expect(await outcome(await signIn(manager, EMAIL, WRONG_PASSWORD))).toEqual(REFUSED);
347+
}
348+
expect(Number(accountRow(engine).failed_login_count)).toBe(THRESHOLD - 1);
349+
350+
for (let i = 0; i < THRESHOLD; i++) {
351+
expect(await outcome(await signIn(manager, EMAIL, PASSWORD)), `attempt ${i + 1}`)
352+
.toEqual({ status: 403, code: 'EMAIL_NOT_VERIFIED' });
353+
expect(Number(accountRow(engine).failed_login_count), `attempt ${i + 1}`).toBe(THRESHOLD - 1);
354+
}
355+
expect(isLocked(accountRow(engine))).toBe(false);
356+
357+
// One more wrong password still reaches the threshold and locks.
358+
expect(await outcome(await signIn(manager, EMAIL, WRONG_PASSWORD))).toEqual(REFUSED);
359+
expect(isLocked(accountRow(engine))).toBe(true);
360+
});
361+
});

‎packages/plugins/plugin-auth/src/auth-manager-after-hook.ts‎

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,9 @@
4242

4343
import { codeSelfServiceRefusal } from './account-self-service-refusal-code.js';
4444
import type { AuthManager } from './auth-manager.js';
45-
import { SIGN_IN_EMAIL_PATH } from './auth-session-audit.js';
45+
import { refusalCodeOf, SIGN_IN_EMAIL_PATH } from './auth-session-audit.js';
4646
import { narrowDirectoryForExternalCaller } from './external-directory-door.js';
47+
import { SESSION_SCOPING_REFUSAL } from './organization-access-policy.js';
4748
import { refuseIntrospectionForBannedSubject } from './oauth-token-read-ban-refusal.js';
4849
import { rotateCallerBearerOnImpersonation } from './impersonation-bearer-rotation.js';
4950
import { stampInvitationDeliveryOutcome } from './invitation-delivery-outcome.js';
@@ -89,6 +90,48 @@ function changeEmailFrom(user: unknown): { email: string; name?: string; id?: st
8990
};
9091
}
9192

93+
/**
94+
* better-auth's admin plugin refuses a banned user's new session with this
95+
* code, from its `session.create.before` hook (`ADMIN_ERROR_CODES.BANNED_USER`
96+
* in `better-auth/dist/plugins/admin/error-codes.mjs`). The package exports no
97+
* public path to that table, so the code is named here, once.
98+
*/
99+
const VENDOR_BANNED_USER_CODE = 'BANNED_USER';
100+
101+
/**
102+
* ADR-0069 D2 — the `/sign-in/email` refusals that come AFTER the password
103+
* passed, which the lockout counter neither counts nor clears: only a refused
104+
* credential counts toward a lock. better-auth's `signInEmail` throws
105+
* `INVALID_EMAIL_OR_PASSWORD` for a wrong password before it reaches either
106+
* place these are raised, so a wrong password never carries one of them to
107+
* the counter:
108+
*
109+
* - `EMAIL_NOT_VERIFIED` (`BASE_ERROR_CODES`), thrown by `signInEmail` itself
110+
* right after the password verified, when the deployment requires a
111+
* verified address;
112+
* - the organization's own allowed IP ranges (`SESSION_SCOPING_REFUSAL`),
113+
* judged where the new session is scoped to it (`refuseUnadmittedScoping`,
114+
* at session creation);
115+
* - a ban ({@link VENDOR_BANNED_USER_CODE}), judged at session creation too.
116+
*
117+
* A function, read when a sign-in is judged, rather than a set built when this
118+
* module loads: `organization-access-policy.ts` imports `auth-manager.ts`,
119+
* which reaches this module, so on that import order its constant is not yet
120+
* initialised while this module evaluates; and better-auth is loaded lazily,
121+
* as everywhere in this package.
122+
*/
123+
async function refusedAfterTheCredential(returned: unknown): Promise<boolean> {
124+
const code = refusalCodeOf(returned);
125+
if (code === undefined) return false;
126+
const { BASE_ERROR_CODES } = await import('@better-auth/core/error');
127+
const after: readonly string[] = [
128+
BASE_ERROR_CODES.EMAIL_NOT_VERIFIED.code,
129+
SESSION_SCOPING_REFUSAL.code,
130+
VENDOR_BANNED_USER_CODE,
131+
];
132+
return after.includes(code);
133+
}
134+
92135
/** The body of `createAuthInstance`'s `hooks.after` arrow; the comments there and in it are its contract. */
93136
export async function globalAfterHook(this: AfterHookHost, ctx: any) {
94137
// ── #8243: impersonation must actually take effect for a bearer ──
@@ -149,6 +192,9 @@ export async function globalAfterHook(this: AfterHookHost, ctx: any) {
149192
// better-auth catches an INVALID_EMAIL_OR_PASSWORD APIError and runs
150193
// the after-hook with it on `ctx.context.returned`; a success leaves
151194
// the session payload there. Count failures, reset on success.
195+
// A refusal `refusedAfterTheCredential` names is neither: the password
196+
// passed and a policy then refused the sign-in, so the counter is left
197+
// as it stands. Its `login_failed` row is still written below.
152198
if (ctx?.path === SIGN_IN_EMAIL_PATH) {
153199
const email = typeof ctx?.body?.email === 'string' ? ctx.body.email : '';
154200
if (email) {
@@ -159,7 +205,9 @@ export async function globalAfterHook(this: AfterHookHost, ctx: any) {
159205
} catch {
160206
succeeded = !(ctx?.context?.returned instanceof Error);
161207
}
162-
const lockStamped = await this.recordSignInOutcome(email, succeeded);
208+
const lockStamped = !succeeded && await refusedAfterTheCredential(ctx?.context?.returned)
209+
? undefined
210+
: await this.recordSignInOutcome(email, succeeded);
163211
// A refused sign-in writes `login_failed`, and the one that
164212
// stamped the lockout also writes `account_locked`. A successful
165213
// one is recorded as `login` by the session hook, unchanged.

0 commit comments

Comments
 (0)