|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | +// |
| 3 | +// ADR-0069 D2 — the account lockout belongs to the ACCOUNT, never to the text |
| 4 | +// a sign-in typed. |
| 5 | +// |
| 6 | +// ## The invariant |
| 7 | +// |
| 8 | +// A password sign-in names an account through its credential check |
| 9 | +// (better-auth's `/sign-in/email`). The lockout's counter and its lock gate |
| 10 | +// must name that same account for every identifier the credential check |
| 11 | +// resolves to it: |
| 12 | +// |
| 13 | +// - every refused sign-in that resolves to the account counts toward its one |
| 14 | +// lock, whichever spelling of the address it carried; |
| 15 | +// - an existing lock refuses every sign-in that resolves to the account, the |
| 16 | +// correct password included; |
| 17 | +// - a successful sign-in under any spelling clears the counter, as before; |
| 18 | +// - an identifier that resolves to no account changes no account and writes |
| 19 | +// no account event. |
| 20 | +// |
| 21 | +// The account events (`login_failed` / `account_locked`) name the account the |
| 22 | +// same resolution names, so the ledger and the lock never disagree. |
| 23 | +// |
| 24 | +// ## Why the server here is the real one |
| 25 | +// |
| 26 | +// "Which account does this identifier name" is answered by the credential |
| 27 | +// check, so every case drives a real `AuthManager` through |
| 28 | +// `AuthManager.handleRequest` over the in-memory engine this package's |
| 29 | +// better-auth suites share, whose equality is exact — the comparison a SQL |
| 30 | +// column makes by default. The lock state is read back from the `sys_user` |
| 31 | +// row itself. |
| 32 | + |
| 33 | +import { describe, it, expect, afterAll } from 'vitest'; |
| 34 | +import { AuthManager } from './auth-manager'; |
| 35 | +import { createMemoryEngine } from './impersonation-bearer-rotation.test'; |
| 36 | +import { inviteForAudienceGate } from './audience-gate-test-support'; |
| 37 | +import { memoizeScrypt } from './__tests__/scrypt-memo.testkit'; |
| 38 | +import type { AuthSessionAuditEventInput } from './auth-session-audit'; |
| 39 | + |
| 40 | +const scrypt = memoizeScrypt(); |
| 41 | +afterAll(scrypt.restore); |
| 42 | + |
| 43 | +const SECRET = 'test-secret-at-least-32-chars-long!!'; |
| 44 | +const BASE = 'http://localhost:3000/api/v1/auth'; |
| 45 | +/** The address the account signed up with. */ |
| 46 | +const EMAIL = 'holder.lockout-account@example.com'; |
| 47 | +/** |
| 48 | + * Other spellings of the same address, each of which the credential check |
| 49 | + * resolves to the account above (a correct password under each signs in). |
| 50 | + */ |
| 51 | +const SPELLINGS = [ |
| 52 | + 'Holder.Lockout-Account@Example.com', |
| 53 | + 'HOLDER.LOCKOUT-ACCOUNT@EXAMPLE.COM', |
| 54 | +] as const; |
| 55 | +const PASSWORD = 'Corr3ct!Horse-Battery'; |
| 56 | +const WRONG_PASSWORD = 'Wr0ng!Guess-Zebra-7731'; |
| 57 | +const THRESHOLD = 3; |
| 58 | + |
| 59 | +function deployment() { |
| 60 | + const engine = createMemoryEngine(); |
| 61 | + const events: AuthSessionAuditEventInput[] = []; |
| 62 | + const manager = new AuthManager({ |
| 63 | + secret: SECRET, |
| 64 | + baseUrl: 'http://localhost:3000', |
| 65 | + dataEngine: engine, |
| 66 | + lockoutThreshold: THRESHOLD, |
| 67 | + lockoutDurationMinutes: 15, |
| 68 | + getAuditSink: () => ({ |
| 69 | + recordAuthEvent: async (event: AuthSessionAuditEventInput) => { |
| 70 | + events.push(event); |
| 71 | + }, |
| 72 | + }), |
| 73 | + } as any); |
| 74 | + return { engine, manager, events }; |
| 75 | +} |
| 76 | + |
| 77 | +const post = (manager: AuthManager, path: string, body: unknown) => |
| 78 | + manager.handleRequest( |
| 79 | + new Request(`${BASE}${path}`, { |
| 80 | + method: 'POST', |
| 81 | + headers: { 'Content-Type': 'application/json' }, |
| 82 | + body: JSON.stringify(body), |
| 83 | + }), |
| 84 | + ); |
| 85 | + |
| 86 | +async function signUp(manager: AuthManager): Promise<void> { |
| 87 | + inviteForAudienceGate(manager, EMAIL); |
| 88 | + const res = await post(manager, '/sign-up/email', { email: EMAIL, password: PASSWORD, name: 'Holder' }); |
| 89 | + expect(res.status, await res.clone().text()).toBe(200); |
| 90 | +} |
| 91 | + |
| 92 | +const signIn = (manager: AuthManager, email: string, password: string) => |
| 93 | + post(manager, '/sign-in/email', { email, password }); |
| 94 | + |
| 95 | +/** The status and machine code a sign-in answered. */ |
| 96 | +async function outcome(res: Response): Promise<{ status: number; code: string | undefined }> { |
| 97 | + const body = (await res.clone().json().catch(() => ({}))) as { code?: unknown }; |
| 98 | + return { status: res.status, code: typeof body.code === 'string' ? body.code : undefined }; |
| 99 | +} |
| 100 | + |
| 101 | +const REFUSED = { status: 401, code: 'INVALID_EMAIL_OR_PASSWORD' }; |
| 102 | +const LOCKED = { status: 403, code: 'ACCOUNT_LOCKED' }; |
| 103 | + |
| 104 | +function accountRow(engine: any): any { |
| 105 | + const row = ((engine.tables.get('sys_user') ?? []) as any[]).find((r) => r.email === EMAIL); |
| 106 | + if (!row) throw new Error(`no sys_user row for ${EMAIL}`); |
| 107 | + return row; |
| 108 | +} |
| 109 | + |
| 110 | +const isLocked = (row: any) => !!row.locked_until && new Date(row.locked_until).getTime() > Date.now(); |
| 111 | + |
| 112 | +/** The account events, in order — `login` / `logout` filtered out. */ |
| 113 | +const accountEvents = (events: AuthSessionAuditEventInput[]) => |
| 114 | + events.filter((e) => e.action === 'login_failed' || e.action === 'account_locked'); |
| 115 | + |
| 116 | +describe('ADR-0069 D2: the lockout counts and holds the account the credential check resolves', () => { |
| 117 | + it('each spelling below is one the credential check resolves to the account', async () => { |
| 118 | + const { engine, manager } = deployment(); |
| 119 | + await signUp(manager); |
| 120 | + for (const spelling of SPELLINGS) { |
| 121 | + const res = await signIn(manager, spelling, PASSWORD); |
| 122 | + expect(res.status, spelling).toBe(200); |
| 123 | + const body = (await res.json()) as { user?: { id?: string } }; |
| 124 | + expect(body.user?.id, spelling).toBe(accountRow(engine).id); |
| 125 | + } |
| 126 | + }); |
| 127 | + |
| 128 | + it('refusals under different spellings of one address accumulate toward its one lock', async () => { |
| 129 | + const { engine, manager, events } = deployment(); |
| 130 | + await signUp(manager); |
| 131 | + const userId = String(accountRow(engine).id); |
| 132 | + events.length = 0; |
| 133 | + |
| 134 | + const attempts = [SPELLINGS[0], EMAIL, SPELLINGS[1]]; |
| 135 | + for (const [i, spelling] of attempts.entries()) { |
| 136 | + expect(await outcome(await signIn(manager, spelling, WRONG_PASSWORD)), spelling).toEqual(REFUSED); |
| 137 | + expect(Number(accountRow(engine).failed_login_count), spelling).toBe(i + 1); |
| 138 | + } |
| 139 | + |
| 140 | + expect(isLocked(accountRow(engine))).toBe(true); |
| 141 | + const recorded = accountEvents(events); |
| 142 | + expect(recorded.map((e) => e.action)).toEqual([ |
| 143 | + 'login_failed', |
| 144 | + 'login_failed', |
| 145 | + 'login_failed', |
| 146 | + 'account_locked', |
| 147 | + ]); |
| 148 | + expect(recorded.every((e) => e.userId === userId)).toBe(true); |
| 149 | + expect(recorded[3]!.context?.threshold).toBe(THRESHOLD); |
| 150 | + expect(new Date(String(recorded[3]!.context?.locked_until)).getTime()) |
| 151 | + .toBe(new Date(accountRow(engine).locked_until).getTime()); |
| 152 | + }); |
| 153 | + |
| 154 | + it('a locked account refuses the correct password under every spelling', async () => { |
| 155 | + const { engine, manager, events } = deployment(); |
| 156 | + await signUp(manager); |
| 157 | + const userId = String(accountRow(engine).id); |
| 158 | + for (let i = 0; i < THRESHOLD; i++) { |
| 159 | + expect(await outcome(await signIn(manager, EMAIL, WRONG_PASSWORD))).toEqual(REFUSED); |
| 160 | + } |
| 161 | + expect(isLocked(accountRow(engine))).toBe(true); |
| 162 | + const lockedUntil = new Date(accountRow(engine).locked_until).getTime(); |
| 163 | + events.length = 0; |
| 164 | + |
| 165 | + for (const spelling of [EMAIL, ...SPELLINGS]) { |
| 166 | + expect(await outcome(await signIn(manager, spelling, PASSWORD)), spelling).toEqual(LOCKED); |
| 167 | + } |
| 168 | + |
| 169 | + expect(events.some((e) => e.action === 'login'), 'no session was minted').toBe(false); |
| 170 | + expect(accountEvents(events)).toEqual( |
| 171 | + [EMAIL, ...SPELLINGS].map(() => expect.objectContaining({ |
| 172 | + action: 'login_failed', |
| 173 | + userId, |
| 174 | + context: { endpoint: '/sign-in/email', reason: 'ACCOUNT_LOCKED' }, |
| 175 | + })), |
| 176 | + ); |
| 177 | + // The lock is the one the account already held: the refusals neither lift nor move it. |
| 178 | + expect(new Date(accountRow(engine).locked_until).getTime()).toBe(lockedUntil); |
| 179 | + }); |
| 180 | + |
| 181 | + it('a lock reached under one spelling refuses the correct password under another', async () => { |
| 182 | + const { engine, manager } = deployment(); |
| 183 | + await signUp(manager); |
| 184 | + for (let i = 0; i < THRESHOLD; i++) { |
| 185 | + expect(await outcome(await signIn(manager, SPELLINGS[1], WRONG_PASSWORD))).toEqual(REFUSED); |
| 186 | + } |
| 187 | + expect(isLocked(accountRow(engine))).toBe(true); |
| 188 | + |
| 189 | + expect(await outcome(await signIn(manager, EMAIL, PASSWORD))).toEqual(LOCKED); |
| 190 | + expect(await outcome(await signIn(manager, SPELLINGS[0], PASSWORD))).toEqual(LOCKED); |
| 191 | + }); |
| 192 | + |
| 193 | + it('a successful sign-in under any spelling clears the counter', async () => { |
| 194 | + const { engine, manager } = deployment(); |
| 195 | + await signUp(manager); |
| 196 | + |
| 197 | + for (const spelling of SPELLINGS) { |
| 198 | + for (let i = 0; i < THRESHOLD - 1; i++) { |
| 199 | + expect(await outcome(await signIn(manager, EMAIL, WRONG_PASSWORD))).toEqual(REFUSED); |
| 200 | + } |
| 201 | + expect(Number(accountRow(engine).failed_login_count)).toBe(THRESHOLD - 1); |
| 202 | + |
| 203 | + expect((await signIn(manager, spelling, PASSWORD)).status, spelling).toBe(200); |
| 204 | + expect(Number(accountRow(engine).failed_login_count), spelling).toBe(0); |
| 205 | + expect(isLocked(accountRow(engine)), spelling).toBe(false); |
| 206 | + } |
| 207 | + }); |
| 208 | + |
| 209 | + it('an identifier that resolves to no account changes no account and writes no account event', async () => { |
| 210 | + const { engine, manager, events } = deployment(); |
| 211 | + await signUp(manager); |
| 212 | + const before = structuredClone(engine.tables.get('sys_user')); |
| 213 | + events.length = 0; |
| 214 | + |
| 215 | + const unknown = ['nobody.lockout-account@example.com', 'Nobody.Lockout-Account@Example.com']; |
| 216 | + for (let i = 0; i < THRESHOLD + 1; i++) { |
| 217 | + for (const identifier of unknown) { |
| 218 | + expect(await outcome(await signIn(manager, identifier, WRONG_PASSWORD)), identifier).toEqual(REFUSED); |
| 219 | + } |
| 220 | + } |
| 221 | + |
| 222 | + expect(engine.tables.get('sys_user')).toEqual(before); |
| 223 | + expect(accountEvents(events)).toEqual([]); |
| 224 | + for (const identifier of unknown) expect(JSON.stringify(events)).not.toContain(identifier); |
| 225 | + // …and the real account still signs in. |
| 226 | + expect((await signIn(manager, EMAIL, PASSWORD)).status).toBe(200); |
| 227 | + }); |
| 228 | +}); |
0 commit comments