Skip to content

Commit 256a4b7

Browse files
fix(plugin-auth): an MFA-held sign-in records the hold on its own login row (#30950)
<!-- claude-projects-attribution: {"github_login":"os-elon-musk"} --> _Requested by **Elon** · [project thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9DNZtDC3xZoX5DtBJm5N9yWS)_ domain:server#3 · os-elon-musk · session_016WV4cwTfQ1ifwQY2EUnSUt Refs #30924 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:因为没开双重验证而被挂起的登录,它自己的那条 `login` 审计记录现在会写上 `reason: MFA_REQUIRED`、`rule: mfa_required`。 - **为什么改**:企业版的「谁、从哪里、因为哪条规则被拦」报表要用这份数据(cloud#3668)。实测下来,这种登录其实是成功的:会话已经签发,`login` 记录也已经写了,所以挂起记在这条记录上,不另写一条互相矛盾的 `login_failed`。 - **风险与回滚**:每次登录在会话创建时多做一次挂起判定,只在装了审计插件、并且开着某项门槛功能时才会多读库。判定失败时不记挂起,登录照常。其余审计记录一律不变。回滚就是 revert。 - **先看**:① `session-policy-hold.ts`(要记哪些挂起、模拟登录的会话怎么判);② `auth-manager-database-hooks.ts` 的 `sessionCreateAfter`;③ `auth-manager.ts` 增补的两行(席位批准)。 - **两分钟验证**:`pnpm --filter @objectstack/plugin-auth exec vitest run src/auth-account-event-audit.test.ts`,预期 `[#30924]` 那组 4 个用例全绿。 - **需要你做的**:无,席位落地。 ## What changes **A sign-in held by ADR-0069 D3 enforced MFA now records the hold on its own `login` row.** The condition: the requirement is on, the account never enrolled, and its grace window has lapsed. The row's `metadata` gains `reason: "MFA_REQUIRED"` (the posture's machine code, the same vocabulary a refused sign-in's `reason` uses) and `rule: "mfa_required"` (the policy's name), beside `endpoint`. - **Where it is asked:** in `session.create.after` (`auth-manager-database-hooks.ts`), the hook that already writes the `login` row, once per session created. It asks the same posture computation the session gate asks: `AuthManager.resolveUserAuthGates`, for the session's user in the session's active organization. - **Which postures hold which session:** `session-policy-hold.ts` follows the split `computeAuthGate` makes. Every posture holds a session the user signed in to themselves; only an `account`-scope posture holds an impersonation session. - **What is recorded:** only the postures in `AUDITED_SESSION_HOLD_RULES`, which today is just `MFA_REQUIRED` → `mfa_required`. Adding another posture is one more entry. A user held by both the forced reset and MFA has the MFA hold recorded. - **Failure and cost:** a posture read that fails records no hold, which is the direction the gate itself fails open in. A deployment without the audit plugin makes no posture read. - **What does not change:** every other `login`, `logout`, `login_failed` and `account_locked` row. None of them carries the attempted credential. - `session-policy-hold.ts` is package-internal, and `loginEventFor`'s signature is unchanged, so the published surface of `auth-session-audit.ts` does not move (`Clause-②: no`). **Why the `login` row, not a `login_failed`** (seat ruling, fork 2 option B). Measured on `origin/main` `0e882086`, an MFA-held `/sign-in/email` answers 200 with a session token and writes a `login` row. The hold is computed on every session read: `resolveUserAuthGates` ran 0 times during the sign-in and once for each of 3 `get-session` calls. So: - a `login_failed` beside the `login` would contradict it, and a brute-force reader counting `login_failed` would count users who are signed in; - a row written where the gate is computed would be one row per request, not one per sign-in. **For the cloud#3668 consumer:** - The MFA hold is read from the `login` row: `action = 'login'`, `metadata.reason = 'MFA_REQUIRED'`, `metadata.rule = 'mfa_required'`, with `user_id`, `tenant_id`, `ip_address`, `user_agent` and `created_at`. - The IP allow-list refusal will be read from `login_failed` rows with `metadata.rule = 'ip_allow_list'`. They arrive in PR 2 of this series. **Seat-approved surface addendum:** in `auth-manager.ts`, only the `composeDatabaseHooks` host literal changes. One member joins the `DatabaseHooksHost` Pick union and one forwarding closure is added for `resolveUserAuthGates`. Nothing else in the file is touched. **Overlap with #30922:** that card's WIP commit `15ec27e3` on `claude/issue-30922-per-org-access-policy` adds `judgeSessionScoping` to the same Pick union line, plus a closure beside `linkingContext`, in `auth-manager.ts`. It does the same in the `DatabaseHooksHost` interface and the `composeDatabaseHooks` `this` Pick in `auth-manager-database-hooks.ts`. Whichever PR lands second gets a one-line textual conflict, and the resolution keeps both members. **Changeset:** `'@objectstack/plugin-auth': patch`. ## Why #30924: plugin-auth writes no audit row when policy holds or refuses a sign-in, so the Enterprise enforcement report (cloud#3668) has nothing to read. This is PR 1 of the two-PR series in the card's Claim. PR 2 carries the IP allow-list rows: the deployment gate and #30922's per-organization refusal. It also carries the plugin-audit change that admits a row with no resolvable account, and the flood bound. PR 2 opens after #30922 merges. ## Tests - `auth-session-audit.test.ts`, new top-level describe, at the composition seam (the real `composeDatabaseHooks`, with the posture answer stubbed on the manager): - an MFA-held session: the event carries `reason` + `rule`, and the posture is asked once with `('usr_1', { organizationId: 'org_1' })`; - controls: no posture, and a posture the table does not record (password expiry), both leave the row exactly as before; - reset + MFA records MFA; - an impersonation session records MFA (account scope) beside `impersonated_by` and `actor`; - a throwing posture read still writes the row, with no hold; - no audit plugin means no posture read; - no subject means no read and no row. - `auth-account-event-audit.test.ts`, new top-level describe, on a real `AuthManager` over the in-memory engine and real better-auth: - an MFA-held sign-in writes one `login` event carrying the hold, no account event, and no password material; - 3 `get-session` reads, each answered `authGate: MFA_REQUIRED`, still leave exactly one event; - control: inside the grace window, the row is as before; - control: a wrong password on a held account is still one `login_failed` with `INVALID_EMAIL_OR_PASSWORD` and no hold. - Dogfood, `auth-session-audit-trail.dogfood.test.ts`, new top-level describe on a booted showcase with `AuditPlugin`. `auth.mfa_required` is turned on through the settings service and the account's grace is lapsed. Then: - `get-session` answers `MFA_REQUIRED`; - exactly one `login` row in `sys_audit_log` names the hold (`metadata` has `endpoint`, `reason`, `rule`), with no `login_failed` and no credential anywhere in the member's rows. ## Acceptance notes - **Boot window on the per-organization requirement.** `resolveUserAuthGates` sees an organization's own `require_mfa` through the lazily refreshed "does any org require MFA" cache. The session gate reads the same cache. A sign-in in the moment after boot, before the first refresh lands, records no hold for a per-org requirement, exactly as the gate does not hold that first read. The global requirement has no such window. - **The table records MFA alone.** Password expiry and the forced reset are holds too, but they were not asked for. Adding either is one entry in `AUDITED_SESSION_HOLD_RULES`; a `credential`-scope entry would then be skipped on impersonation sessions by the scope filter already in place. ## Verification All readings on head `60164041` (merge base `4d56914a` on `main`), in this checkout. - **Pins:** `auth-session-audit.test.ts` + `auth-account-event-audit.test.ts` → 45 passed (45). - **Unit ablation** (`scripts/ablation-replace.mjs`, `withSessionPolicyHold(login, hold)` → `login` in `sessionCreateAfter`): 4 failed | 41 passed. The 4 are the MFA-held, reset+MFA, impersonation and real-sign-in cases; every control stayed green. Restored, blob back, tree clean. - **Dogfood:** `auth-session-audit-trail.dogfood.test.ts` → 6 passed (6). Same ablation, rebuilt, with `ablation-dist-preflight.mjs --absent` proving the marker left `dist/`: the `#30924` case failed (1 failed | 5 skipped under `-t 30924`). Restored, rebuilt, marker back in `dist/`, `git status --porcelain` empty. - **Package suites:** `@objectstack/plugin-auth` full suite 228 files, 4108 passed | 10 skipped. `@objectstack/plugin-audit` `auth-event-audit.test.ts` 17 passed (17). - **Types / lint:** `@objectstack/plugin-auth` typecheck (source + tests) exit 0. `eslint --no-inline-config` on the 7 changed source and test files exit 0. - **Build:** whole tree under `os-verify-lock`, `turbo run build` → 119 successful, 119 total. - **Dispatch gates:** `scripts/pm/dispatch-gates.mjs --ran` → 67 derived, 67 run, 0 NOT-MEASURED; all 67 exit 0. These include `check:auth-mount-ledger`, `check:tenant-audit-census`, `check:durability-log-level`, `check:dts-closure`, `check:published-files`, `check:issue-citations` and the changeset gates. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (No spec change.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. (CI owns the repo-wide lint run.) - [x] 4. One issue, one branch off an up-to-date `main`, one pull request (PR 1 of the claimed two-PR series). - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: not touched. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. --- _Generated by [Claude Code](https://claude.ai/code/session_016WV4cwTfQ1ifwQY2EUnSUt)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b58beb8 commit 256a4b7

8 files changed

Lines changed: 420 additions & 6 deletions
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/plugin-auth': patch
3+
---
4+
5+
fix(plugin-auth): a sign-in that enforced MFA holds records the hold on its own `login` row
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
A sign-in held by the ADR-0069 D3 enforced-MFA requirement (the requirement is on, the account never enrolled, its grace window has lapsed) used to leave the compliance ledger nothing to say about the hold: the sign-in succeeds, a session is minted and its `login` row is written exactly like any other, while every request on that session is answered `MFA_REQUIRED`. The `login` row of such a sign-in now carries `reason: "MFA_REQUIRED"` and `rule: "mfa_required"` in its `metadata`, beside `endpoint`, so an enforcement report can list who was held by the MFA policy, from which address and when.
11+
12+
The hold is asked once, when the session is created, of the same posture computation the session gate asks. It is not asked on every session read, so a held sign-in has exactly one row, and no `login_failed` row is written for it: the session exists. A posture read that fails records no hold, which is the gate's own fail-open direction. A deployment without the audit plugin makes no extra read. Every other `login`, `logout`, `login_failed` and `account_locked` row is unchanged, and no row carries the attempted credential.

‎packages/plugins/plugin-auth/src/auth-account-event-audit.test.ts‎

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,3 +268,90 @@ describe('a successful sign-in is recorded as before', () => {
268268
expectNoPasswordMaterial(events);
269269
});
270270
});
271+
272+
describe('[#30924] an MFA-held sign-in records the hold on its own login row', () => {
273+
// ADR-0069 D3: a global MFA requirement, a lapsed grace window, an account
274+
// that never enrolled. The sign-in itself succeeds (a session is minted and
275+
// the user can still reach `/two-factor/*` to enroll); the session is held.
276+
const MFA_POLICY = { mfaRequired: true, mfaGracePeriodDays: 0, plugins: { twoFactor: true } };
277+
278+
/** Start the account's grace clock a day ago, so the hold applies now. */
279+
function lapseGrace(engine: any): void {
280+
const row = ((engine.tables.get('sys_user') ?? []) as any[]).find((r) => r.email === EMAIL);
281+
if (!row) throw new Error(`no sys_user row for ${EMAIL}`);
282+
row.two_factor_enabled = false;
283+
row.mfa_required_at = new Date(Date.now() - 86_400_000).toISOString();
284+
}
285+
286+
const getSession = (manager: AuthManager, token: string) =>
287+
manager.handleRequest(
288+
new Request(`${BASE}/get-session`, {
289+
method: 'GET',
290+
headers: { authorization: `Bearer ${token}`, 'user-agent': CLIENT_UA },
291+
}),
292+
{ remoteAddress: CLIENT_IP },
293+
);
294+
295+
it('one login event carries reason MFA_REQUIRED and rule mfa_required; no login_failed, no credential', async () => {
296+
const { engine, manager, events } = deployment(MFA_POLICY);
297+
await signUp(manager);
298+
lapseGrace(engine);
299+
events.length = 0;
300+
301+
const res = await signIn(manager, EMAIL, PASSWORD);
302+
expect(res.status, await res.clone().text()).toBe(200);
303+
304+
expect(accountEvents(events), 'a held sign-in is not a refused one').toEqual([]);
305+
const logins = events.filter((e) => e.action === 'login');
306+
expect(logins).toHaveLength(1);
307+
expect(logins[0]!.userId).toBe(userIdFor(engine));
308+
expect(logins[0]!.ipAddress).toBe(CLIENT_IP);
309+
expect(logins[0]!.context).toEqual({ endpoint: '/sign-in/email', reason: 'MFA_REQUIRED', rule: 'mfa_required' });
310+
expectNoPasswordMaterial(events);
311+
});
312+
313+
it('the hold is recorded once per sign-in, never once per session read', async () => {
314+
const { engine, manager, events } = deployment(MFA_POLICY);
315+
await signUp(manager);
316+
lapseGrace(engine);
317+
events.length = 0;
318+
319+
const res = await signIn(manager, EMAIL, PASSWORD);
320+
expect(res.status).toBe(200);
321+
const { token } = (await res.json()) as { token: string };
322+
323+
// The gate is computed on every read, and holds every one of them…
324+
for (let i = 0; i < 3; i++) {
325+
const read = await getSession(manager, token);
326+
expect(read.status).toBe(200);
327+
expect(((await read.json()) as any)?.user?.authGate?.code).toBe('MFA_REQUIRED');
328+
}
329+
// …and the sign-in still has exactly the one row.
330+
expect(events.map((e) => e.action)).toEqual(['login']);
331+
});
332+
333+
it('control: inside the grace window the session is not held, and the login row is as before', async () => {
334+
const { manager, events } = deployment({ ...MFA_POLICY, mfaGracePeriodDays: 7 });
335+
await signUp(manager);
336+
events.length = 0;
337+
338+
expect((await signIn(manager, EMAIL, PASSWORD)).status).toBe(200);
339+
340+
const logins = events.filter((e) => e.action === 'login');
341+
expect(logins).toHaveLength(1);
342+
expect(logins[0]!.context).toEqual({ endpoint: '/sign-in/email' });
343+
});
344+
345+
it('control: a refused password on a held account is still one login_failed with its own code, and no hold', async () => {
346+
const { engine, manager, events } = deployment({ ...MFA_POLICY, lockoutThreshold: 5 });
347+
await signUp(manager);
348+
lapseGrace(engine);
349+
events.length = 0;
350+
351+
expect((await signIn(manager, EMAIL, WRONG_PASSWORD)).status).not.toBe(200);
352+
353+
expect(events.map((e) => e.action)).toEqual(['login_failed']);
354+
expect(events[0]!.context).toEqual({ endpoint: '/sign-in/email', reason: 'INVALID_EMAIL_OR_PASSWORD' });
355+
expectNoPasswordMaterial(events);
356+
});
357+
});

‎packages/plugins/plugin-auth/src/auth-manager-database-hooks.ts‎

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ import {
5555
} from './implicit-account-linking.js';
5656
import { withSystemReadContext } from './objectql-adapter.js';
5757
import { emitAuthSessionAuditEvent, loginEventFor, logoutEventFor } from './auth-session-audit.js';
58+
import { sessionPolicyHoldFor, withSessionPolicyHold } from './session-policy-hold.js';
5859
import { holdPrincipalSessionExpiry } from './principal-session-mint.js';
5960
import { canonicalClientAddress } from './client-address-canonical.js';
6061
import { readRequestClientAddress } from './request-client-address.js';
@@ -75,6 +76,7 @@ export interface DatabaseHooksHost {
7576
isCreatedInThisRequest: AuthManager['isCreatedInThisRequest'];
7677
linkingContext: AuthManager['linkingContext'];
7778
logDurabilityDegradation: AuthManager['logDurabilityDegradation'];
79+
resolveUserAuthGates: AuthManager['resolveUserAuthGates'];
7880
settleMembership: AuthManager['settleMembership'];
7981
settleSelfRegistrationGrant: AuthManager['settleSelfRegistrationGrant'];
8082
stampIdentitySource: AuthManager['stampIdentitySource'];
@@ -83,7 +85,7 @@ export interface DatabaseHooksHost {
8385

8486
/** The body of `AuthManager#composeDatabaseHooks`; the method's docblock there is its contract. */
8587
export function composeDatabaseHooks(
86-
this: Pick<DatabaseHooksHost, 'audienceLogError' | 'config' | 'idpLocale' | 'isCreatedInThisRequest' | 'linkingContext' | 'settleMembership' | 'settleSelfRegistrationGrant' | 'stampIdentitySource' | 'takeOwnerVerifiedStamp'>,
88+
this: Pick<DatabaseHooksHost, 'audienceLogError' | 'config' | 'idpLocale' | 'isCreatedInThisRequest' | 'linkingContext' | 'resolveUserAuthGates' | 'settleMembership' | 'settleSelfRegistrationGrant' | 'stampIdentitySource' | 'takeOwnerVerifiedStamp'>,
8789
host?: BetterAuthOptions['databaseHooks'],
8890
): BetterAuthOptions['databaseHooks'] {
8991
// A landed link ends a standing unlink record (implicit-account-linking.ts,
@@ -319,13 +321,26 @@ export function composeDatabaseHooks(
319321
// better-auth decides. Both emitters are awaited but cannot throw
320322
// (`emitAuthSessionAuditEvent` swallows), so neither can turn a valid
321323
// sign-in or sign-out into an error.
324+
//
325+
// [#30924] A session an authentication policy holds (ADR-0069 D3 enforced
326+
// MFA) records that hold on its own `login` row: `context.reason` +
327+
// `context.rule` (`session-policy-hold.ts`; why the `login` row and why here
328+
// is `auth-session-audit.ts`'s module note). Asked HERE, once per session
329+
// created, because the gate itself is computed on every session read; and
330+
// only when a sink is registered, so a deployment without the audit plugin
331+
// pays no posture read for a row nobody writes. The posture read fails open
332+
// (no hold recorded), never failing the sign-in.
322333
const hostSessionCreateAfter = (host as any)?.session?.create?.after;
323334
const sessionCreateAfter = async (session: any, ctx: any) => {
324335
const result = hostSessionCreateAfter ? await hostSessionCreateAfter(session, ctx) : undefined;
325-
await emitAuthSessionAuditEvent(
326-
this.config.getAuditSink?.(),
327-
loginEventFor(session, typeof ctx?.path === 'string' ? ctx.path : undefined),
328-
);
336+
const sink = this.config.getAuditSink?.();
337+
if (sink) {
338+
const login = loginEventFor(session, typeof ctx?.path === 'string' ? ctx.path : undefined);
339+
const hold = login
340+
? await sessionPolicyHoldFor(session, (userId, options) => this.resolveUserAuthGates(userId, options))
341+
: undefined;
342+
await emitAuthSessionAuditEvent(sink, withSessionPolicyHold(login, hold));
343+
}
329344
// An external sign-in's staged `locale` claim lands on the user the
330345
// session names, new or returning (`idp-locale-claim.ts`). Never throws.
331346
await this.idpLocale.settle(session, ctx);

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5306,12 +5306,13 @@ export class AuthManager {
53065306
// [#25224 cut 4] The body moved to `auth-manager-database-hooks.ts` (see `DatabaseHooksHost`).
53075307
const readConfig = () => this.config;
53085308
const readIdpLocale = () => this.idpLocale;
5309-
const hookHost: Pick<DatabaseHooksHost, 'audienceLogError' | 'config' | 'idpLocale' | 'isCreatedInThisRequest' | 'linkingContext' | 'settleMembership' | 'settleSelfRegistrationGrant' | 'stampIdentitySource' | 'takeOwnerVerifiedStamp'> = {
5309+
const hookHost: Pick<DatabaseHooksHost, 'audienceLogError' | 'config' | 'idpLocale' | 'isCreatedInThisRequest' | 'linkingContext' | 'resolveUserAuthGates' | 'settleMembership' | 'settleSelfRegistrationGrant' | 'stampIdentitySource' | 'takeOwnerVerifiedStamp'> = {
53105310
audienceLogError: (...a) => this.audienceLogError(...a),
53115311
get config() { return readConfig(); },
53125312
get idpLocale() { return readIdpLocale(); },
53135313
isCreatedInThisRequest: (...a) => this.isCreatedInThisRequest(...a),
53145314
linkingContext: (...a) => this.linkingContext(...a),
5315+
resolveUserAuthGates: (...a) => this.resolveUserAuthGates(...a),
53155316
settleMembership: (...a) => this.settleMembership(...a),
53165317
settleSelfRegistrationGrant: (...a) => this.settleSelfRegistrationGrant(...a),
53175318
stampIdentitySource: (...a) => this.stampIdentitySource(...a),

‎packages/plugins/plugin-auth/src/auth-session-audit.test.ts‎

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,3 +287,100 @@ describe('[#8144] the mapping refuses to invent an actor', () => {
287287
expect(event).toEqual({ action: 'login', userId: 'usr_1' });
288288
});
289289
});
290+
291+
describe('[#30924] a session an authentication policy holds records the hold on its own login row', () => {
292+
// The posture computation is the manager's own (`resolveUserAuthGates`, the
293+
// one the session gate asks); these cases stub its ANSWER and read what the
294+
// composed hook hands the sink, so what is pinned is the wiring and the
295+
// mapping. The real computation over a real better-auth sign-in is pinned in
296+
// `auth-account-event-audit.test.ts`.
297+
const MFA = { code: 'MFA_REQUIRED', message: 'Set up an authenticator app.' };
298+
const EXPIRED = { code: 'PASSWORD_EXPIRED', message: 'Change your password.' };
299+
const RESET = { code: 'PASSWORD_RESET_REQUIRED', message: 'Change your temporary password.' };
300+
301+
function hooksWithGates(answer: (userId: string, options: unknown) => Promise<unknown[]>) {
302+
const recordAuthEvent = vi.fn(async (_event: AuthSessionAuditEventInput) => undefined);
303+
const manager = new AuthManager({
304+
secret: SECRET,
305+
baseUrl: 'http://localhost:3000',
306+
getAuditSink: () => ({ recordAuthEvent }),
307+
} as any);
308+
const resolveUserAuthGates = vi.spyOn(manager, 'resolveUserAuthGates').mockImplementation(answer as any);
309+
const hooks = (manager as any).composeDatabaseHooks(undefined) as any;
310+
return { hooks, recordAuthEvent, resolveUserAuthGates };
311+
}
312+
313+
it('an MFA-held session: the login row carries reason MFA_REQUIRED and rule mfa_required, asked once for its user in its organization', async () => {
314+
const { hooks, recordAuthEvent, resolveUserAuthGates } = hooksWithGates(async () => [MFA]);
315+
316+
await hooks.session.create.after(SESSION, { path: '/sign-in/email' });
317+
318+
expect(resolveUserAuthGates).toHaveBeenCalledTimes(1);
319+
expect(resolveUserAuthGates).toHaveBeenCalledWith('usr_1', { organizationId: 'org_1' });
320+
expect(recordAuthEvent).toHaveBeenCalledTimes(1);
321+
expect(firstEvent(recordAuthEvent)).toEqual({
322+
action: 'login',
323+
userId: 'usr_1',
324+
sessionId: 'ses_1',
325+
organizationId: 'org_1',
326+
ipAddress: '203.0.113.7',
327+
userAgent: 'Mozilla/5.0 (probe)',
328+
context: { endpoint: '/sign-in/email', reason: 'MFA_REQUIRED', rule: 'mfa_required' },
329+
});
330+
});
331+
332+
it('control: no posture holds the session, so the login row is exactly as before', async () => {
333+
const { hooks, recordAuthEvent } = hooksWithGates(async () => []);
334+
await hooks.session.create.after(SESSION, { path: '/sign-in/email' });
335+
expect(firstEvent(recordAuthEvent).context).toEqual({ endpoint: '/sign-in/email' });
336+
});
337+
338+
it('control: a posture the table does not record (password expiry) leaves the row as before', async () => {
339+
const { hooks, recordAuthEvent } = hooksWithGates(async () => [EXPIRED]);
340+
await hooks.session.create.after(SESSION, { path: '/sign-in/email' });
341+
expect(firstEvent(recordAuthEvent).context).toEqual({ endpoint: '/sign-in/email' });
342+
});
343+
344+
it('a user held by the forced reset AND enforced MFA has the MFA hold recorded', async () => {
345+
const { hooks, recordAuthEvent } = hooksWithGates(async () => [RESET, MFA]);
346+
await hooks.session.create.after(SESSION, { path: '/sign-in/email' });
347+
expect(firstEvent(recordAuthEvent).context).toMatchObject({ reason: 'MFA_REQUIRED', rule: 'mfa_required' });
348+
});
349+
350+
it('an impersonation session is held by an account-scope posture, so enforced MFA is recorded there too', async () => {
351+
const { hooks, recordAuthEvent } = hooksWithGates(async () => [RESET, MFA]);
352+
await hooks.session.create.after({ ...SESSION, impersonatedBy: 'usr_admin' }, { path: '/admin/impersonate-user' });
353+
const event = firstEvent(recordAuthEvent);
354+
expect(event.actor).toBe('usr_admin');
355+
expect(event.context).toEqual({
356+
endpoint: '/admin/impersonate-user',
357+
impersonated_by: 'usr_admin',
358+
reason: 'MFA_REQUIRED',
359+
rule: 'mfa_required',
360+
});
361+
});
362+
363+
it('a posture read that throws records no hold, and the login row is still written', async () => {
364+
const { hooks, recordAuthEvent } = hooksWithGates(async () => {
365+
throw new Error('sys_user unreachable');
366+
});
367+
await expect(hooks.session.create.after(SESSION, { path: '/sign-in/email' })).resolves.toBeUndefined();
368+
expect(recordAuthEvent).toHaveBeenCalledTimes(1);
369+
expect(firstEvent(recordAuthEvent).context).toEqual({ endpoint: '/sign-in/email' });
370+
});
371+
372+
it('no audit plugin installed: no posture read at all', async () => {
373+
const manager = new AuthManager({ secret: SECRET, baseUrl: 'http://localhost:3000' } as any);
374+
const resolveUserAuthGates = vi.spyOn(manager, 'resolveUserAuthGates');
375+
const hooks = (manager as any).composeDatabaseHooks(undefined) as any;
376+
await hooks.session.create.after(SESSION, { path: '/sign-in/email' });
377+
expect(resolveUserAuthGates).not.toHaveBeenCalled();
378+
});
379+
380+
it('a session with no subject: no posture read and no row', async () => {
381+
const { hooks, recordAuthEvent, resolveUserAuthGates } = hooksWithGates(async () => [MFA]);
382+
await hooks.session.create.after({ id: 'ses_x' }, { path: '/sign-in/email' });
383+
expect(resolveUserAuthGates).not.toHaveBeenCalled();
384+
expect(recordAuthEvent).not.toHaveBeenCalled();
385+
});
386+
});

‎packages/plugins/plugin-auth/src/auth-session-audit.ts‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,31 @@
7878
* names the RESOLVED account's id, and an identifier that resolves to no
7979
* account produces no row (an auth row without a subject is the shape this
8080
* module already refuses to write, above).
81+
*
82+
* ## A sign-in an authentication policy holds (#30924)
83+
*
84+
* An ADR-0069 D3 enforced-MFA hold is NOT a refused sign-in: the credential
85+
* check passes, better-auth mints the session, and the hold is a posture of
86+
* that session, computed per session read (`customSession` → `user.authGate`)
87+
* and enforced at the transport seams, so a held user still reaches
88+
* `/two-factor/*` to enroll. The sign-in therefore already has its row, the
89+
* `login` above, and that row is where the hold is recorded:
90+
* `context.reason` names the posture's machine code (`MFA_REQUIRED`, the same
91+
* vocabulary a refused sign-in's `reason` uses) and `context.rule` the policy
92+
* that holds it (`mfa_required`), from `AUDITED_SESSION_HOLD_RULES` in
93+
* `session-policy-hold.ts`, a package-internal module so the published surface
94+
* of this one does not move.
95+
*
96+
* Why the `login` row and not a `login_failed` beside it: the session exists,
97+
* so a `login_failed` would contradict the `login` the same sign-in wrote, and
98+
* anyone counting `login_failed` rows for brute-force detection would count
99+
* users who are signed in. Why at session creation and not where the gate is
100+
* computed: that computation runs on every session read, so a row written
101+
* there would be one row per request instead of one per sign-in. The hook
102+
* asks the same one posture computation the gate asks
103+
* (`AuthManager.resolveUserAuthGates`), so the row records a posture the
104+
* session is really held by, and fails open exactly where the gate does: no
105+
* posture read, no hold recorded.
81106
*/
82107

83108
import { getIP } from '@better-auth/core/utils/ip';

0 commit comments

Comments
 (0)