Skip to content

Commit d79f55f

Browse files
committed
refactor(plugin-auth): move the better-auth global after-hook into auth-manager-after-hook.ts
The body of the arrow createAuthInstance passes to createAuthMiddleware for hooks.after moves unedited into globalAfterHook, re-indented four levels. hooks.after keeps its place in the config literal; its arrow now builds one host of the eight AuthManager methods the body reads, as late-bound closures, and returns globalAfterHook.call(host, ctx). The async moved from the arrow to the function, so the middleware awaits the same promise the body settles. The arrow read no local of createAuthInstance, and the body uses no arguments, new.target or super. Seven imports leave auth-manager.ts with it; SIGN_IN_EMAIL_PATH is imported by both files. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LSXBc2GCBwiAL7vV4kWkyr
1 parent 1ff43b6 commit d79f55f

2 files changed

Lines changed: 274 additions & 193 deletions

File tree

Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#25224 cut 12] The global after-hook of the better-auth instance
5+
* `AuthManager` builds — the one `hooks.after` middleware `createAuthInstance`
6+
* hands better-auth, which every endpoint's response passes through — moved
7+
* out of `auth-manager.ts` whole.
8+
*
9+
* A MOVE, not a redesign. The function below is the body of the arrow
10+
* `createAuthInstance` passes to `createAuthMiddleware` for `hooks.after`, as
11+
* it stood, re-indented four levels and otherwise unedited: the same bearer
12+
* rotation on impersonation, the same echo of the installed session token,
13+
* the same session envelope completion, the same two-factor re-enrolment
14+
* reset, the same sign-in outcome count, refusal audit, concurrent-session cap
15+
* and login stamp, the same password-change stamp and history commit, the
16+
* same old-address notice on an e-mail change, the same directory doors
17+
* narrowed for an external caller, the same invite-role refusal code, and the
18+
* same sign-up stamp and `disableSignUp` restore — in the same order.
19+
*
20+
* ## How the moved body reaches the class
21+
*
22+
* The shape #23050 set for `rest-server.ts` (its `data-action-routes.ts`
23+
* header carries the full reasoning), which the eleven `auth-manager-*.ts`
24+
* modules split out before this one follow, applied to a closure instead of a
25+
* method. `createAuthInstance` keeps its place, its docblock and its
26+
* signature, and `hooks.after` keeps its place in the config literal: the
27+
* arrow there is now one literal of exactly the {@link AfterHookHost} members
28+
* the body reads, plus a `.call`. The `async` moved from that arrow to the
29+
* function below, and the arrow returns the function's own promise, so the
30+
* middleware awaits the same promise the body settles, with no extra wrapper.
31+
*
32+
* The body's `this` was the manager, the arrow's lexical receiver; it is now
33+
* that host. Every member it reads is a method, and each goes as a closure
34+
* that calls it on the manager at call time, so a test double or spy set on
35+
* the manager is still the one the body reaches. The arrow builds the host
36+
* each time better-auth runs the hook, as every method stub in this series
37+
* does, so nothing the body reads is captured early. The arrow read no local
38+
* of `createAuthInstance`, so the function takes the same one parameter.
39+
*
40+
* Nothing re-exports this module, so the published surface does not move.
41+
*/
42+
43+
import type { AuthManager } from './auth-manager.js';
44+
import { SIGN_IN_EMAIL_PATH } from './auth-session-audit.js';
45+
import { narrowDirectoryForExternalCaller } from './external-directory-door.js';
46+
import { rotateCallerBearerOnImpersonation } from './impersonation-bearer-rotation.js';
47+
import { codeInviteRoleNotFound } from './invite-role-not-found-code.js';
48+
import { readRequestClientAddress } from './request-client-address.js';
49+
import { attachSessionToCredentialResponse } from './session-envelope-completion.js';
50+
import { resetVerifiedOnTwoFactorReenrollment } from './two-factor-reenrollment-verified-reset.js';
51+
import { echoInstalledSessionToken } from './two-factor-rotated-token-echo.js';
52+
53+
/**
54+
* The `AuthManager` members the function below reads — every one of them, and
55+
* no other. Each is typed AS the class member (an indexed access on the
56+
* class), so a change to a member's signature on the class is a change here
57+
* too, and naming a member the class does not have is a compile error.
58+
*/
59+
export interface AfterHookHost {
60+
auditRefusedSignIn: AuthManager['auditRefusedSignIn'];
61+
enforceConcurrentCap: AuthManager['enforceConcurrentCap'];
62+
externalDirectoryDoorDeps: AuthManager['externalDirectoryDoorDeps'];
63+
recordPasswordHistory: AuthManager['recordPasswordHistory'];
64+
recordSignInOutcome: AuthManager['recordSignInOutcome'];
65+
sendChangeEmailNotice: AuthManager['sendChangeEmailNotice'];
66+
stampLastLogin: AuthManager['stampLastLogin'];
67+
stampPasswordChangedAt: AuthManager['stampPasswordChangedAt'];
68+
}
69+
70+
/** The body of `createAuthInstance`'s `hooks.after` arrow; the comments there and in it are its contract. */
71+
export async function globalAfterHook(this: AfterHookHost, ctx: any) {
72+
// ── #8243: impersonation must actually take effect for a bearer ──
73+
// FIRST among the after-hooks that touch the response, because the
74+
// bearer plugin's own after-hook runs behind this one and must see
75+
// what we stage: it re-reads `Access-Control-Expose-Headers` to add
76+
// `set-auth-token`, so the recovery header we expose here survives
77+
// only by being merged before it. See
78+
// `impersonation-bearer-rotation.ts` for the mechanism — the short
79+
// version is that `bearer()` converts the caller's token back into
80+
// the ADMIN's session cookie on every later request, so without
81+
// rotating that token, `/admin/impersonate-user` is a 200 no-op.
82+
await rotateCallerBearerOnImpersonation(ctx);
83+
84+
// ── #10701: a 2FA verification must echo the session it INSTALLED ─
85+
// On the enrolment lane `/two-factor/verify-totp` rotates the
86+
// caller's session and then echoes the PRE-rotation token — the row
87+
// it just deleted. A client that stores it (the console's
88+
// `auth-session-token` pattern) is holding a revoked credential, and
89+
// because `bearer()` overwrites the request cookie with whatever the
90+
// Authorization header carries, presenting it destroys the valid
91+
// rotated cookie too. See `two-factor-rotated-token-echo.ts`. This
92+
// corrects the echoed VALUE only; resolver precedence is untouched.
93+
await echoInstalledSessionToken(ctx);
94+
95+
// ── #17234: complete the SessionResponse envelope with the session
96+
// the route just committed ────────────────────────────────────────
97+
// `/sign-in/email` and `/sign-up/email` answer `{ token, user }`
98+
// (plus `redirect` on sign-in) with no `session` member anywhere in
99+
// the body or the headers, so `SessionResponseSchema` never parsed
100+
// either method's return value. The session is not absent — it is
101+
// the row `internalAdapter.createSession` already committed before
102+
// the endpoint returned — so this reads it back by the response's
103+
// own token, the same seam `/get-session` uses, and attaches it.
104+
// See `session-envelope-completion.ts` for the full measurement and
105+
// why this can never fabricate an id or an expiry.
106+
await attachSessionToCredentialResponse(ctx);
107+
108+
// ── #10700: `verified` must describe the secret stored beside it ──
109+
// A second `/two-factor/enable` on an already-confirmed account
110+
// rewrites the TOTP secret on the one `sys_two_factor` row the
111+
// account has and INHERITS `verified` from the enrollment before it,
112+
// so a secret nobody confirmed is honoured at the sign-in challenge.
113+
// better-auth already gates that challenge on the flag — a first
114+
// enrollment is inert until the session-lane verify flips it — so
115+
// restoring the flag restores the gate rather than adding a second
116+
// one. See `two-factor-reenrollment-verified-reset.ts`, whose header
117+
// also states what this deliberately does NOT do.
118+
await resetVerifiedOnTwoFactorReenrollment(ctx);
119+
120+
// ── ADR-0069 D2: account lockout (counter) ──────────────────
121+
// better-auth catches an INVALID_EMAIL_OR_PASSWORD APIError and runs
122+
// the after-hook with it on `ctx.context.returned`; a success leaves
123+
// the session payload there. Count failures, reset on success.
124+
if (ctx?.path === SIGN_IN_EMAIL_PATH) {
125+
const email = typeof ctx?.body?.email === 'string' ? ctx.body.email : '';
126+
if (email) {
127+
let succeeded = true;
128+
try {
129+
const { isAPIError } = await import('better-auth/api');
130+
succeeded = !isAPIError(ctx?.context?.returned);
131+
} catch {
132+
succeeded = !(ctx?.context?.returned instanceof Error);
133+
}
134+
const lockStamped = await this.recordSignInOutcome(email, succeeded);
135+
// A refused sign-in writes `login_failed`, and the one that
136+
// stamped the lockout also writes `account_locked`. A successful
137+
// one is recorded as `login` by the session hook, unchanged.
138+
if (!succeeded) await this.auditRefusedSignIn(ctx, email, lockStamped);
139+
if (succeeded) {
140+
const uid = ctx?.context?.returned?.user?.id;
141+
if (typeof uid === 'string') await this.enforceConcurrentCap(uid);
142+
// ADR-0069 D7 — login audit: stamp last_login_at/ip on success.
143+
// Independent of lockout config (recordSignInOutcome no-ops when
144+
// lockout is off). The IP is the address `handleRequest`
145+
// established for this request by the client-address trust rule,
146+
// the same address the D5 allow-list judged; none when it was
147+
// undetermined. ⛔ Never re-read from a header here.
148+
if (typeof uid === 'string') {
149+
await this.stampLastLogin(uid, await readRequestClientAddress());
150+
}
151+
}
152+
}
153+
return;
154+
}
155+
156+
// ── ADR-0069 D1: on a successful change/reset — stamp
157+
// password_changed_at (expiry) and commit password history.
158+
if (ctx?.path === '/change-password' || ctx?.path === '/reset-password') {
159+
let succeeded: boolean;
160+
try {
161+
const { isAPIError } = await import('better-auth/api');
162+
succeeded = !isAPIError(ctx?.context?.returned);
163+
} catch {
164+
succeeded = !(ctx?.context?.returned instanceof Error);
165+
}
166+
if (succeeded) {
167+
const stampId = ctx?.context?.__osPwChangeUserId;
168+
if (stampId) await this.stampPasswordChangedAt(stampId);
169+
const stash = ctx?.context?.__osPwHistory;
170+
if (stash?.userId) await this.recordPasswordHistory(stash.userId, stash.oldHash);
171+
}
172+
delete ctx.context.__osPwChangeUserId;
173+
delete ctx.context.__osPwHistory;
174+
return;
175+
}
176+
177+
// ── #8019: tell the OLD address, without gating on it ──────────────
178+
// Maintainer ruling 2026-08-12 — 「notify the OLD address — do not
179+
// gate on it」. See the `changeEmail` config site for why this cannot
180+
// be `sendChangeEmailConfirmation` (that option IS the gate).
181+
//
182+
// Why HERE is the whole design:
183+
// • after-hook ⇒ better-auth has already produced its response, so
184+
// nothing this code does can change the endpoint's outcome. The
185+
// "does not gate" property is structural, not a promise;
186+
// • gated on success ⇒ a refused request sends nothing;
187+
// • `sendChangeEmailNotice` swallows its own failures ⇒ a dead
188+
// mailbox, an unseeded template or a missing transport cannot
189+
// turn the notification into a block. That inversion — a security
190+
// notice that takes the flow down with it — is precisely the
191+
// failure mode the ruling exists to avoid.
192+
//
193+
// Sent at REQUEST time, which is also the strongest moment available:
194+
// the owner learns while the attacker still has to prove control of
195+
// the new mailbox, so the warning arrives inside the window where it
196+
// can still be acted on. It is also the only moment that exists
197+
// exactly once per attempt — the apply step (`GET /verify-email`)
198+
// does not carry the old address, and better-auth mints a fresh
199+
// session there when the link is opened without one, so an
200+
// apply-time notice would silently miss that path.
201+
if (ctx?.path === '/change-email') {
202+
const from = ctx?.context?.__osChangeEmailFrom;
203+
delete ctx.context.__osChangeEmailFrom;
204+
let succeeded: boolean;
205+
try {
206+
const { isAPIError } = await import('better-auth/api');
207+
succeeded = !isAPIError(ctx?.context?.returned);
208+
} catch {
209+
succeeded = !(ctx?.context?.returned instanceof Error);
210+
}
211+
const newEmail = typeof ctx?.body?.newEmail === 'string' ? ctx.body.newEmail : '';
212+
if (succeeded && from?.email && newEmail) {
213+
// #14319 — the notice goes to the account owner, who IS the
214+
// caller here, so the request rung applies.
215+
await this.sendChangeEmailNotice(from, newEmail, ctx);
216+
}
217+
return;
218+
}
219+
220+
// ── Ruling 6355206139 E2: the directory doors, narrowed for an external ──
221+
// list-members and get-full-organization check membership only, so an
222+
// `external` caller would read every fellow member (and every pending
223+
// invitation) there. See `external-directory-door.ts`.
224+
await narrowDirectoryForExternalCaller(ctx, this.externalDirectoryDoorDeps());
225+
226+
// ── #24750: invite-member's unknown-role refusal carries its code ──
227+
// better-auth throws it with only a message; the spec promises
228+
// `ROLE_NOT_FOUND` (see `invite-role-not-found-code.ts`). It throws
229+
// on a match, so it stays after every section above.
230+
await codeInviteRoleNotFound(ctx);
231+
232+
if (ctx?.path !== '/sign-up/email') return;
233+
// ADR-0069 D1 — stamp password_changed_at for a newly-created local
234+
// user (expiry clock starts at sign-up). Best-effort.
235+
{
236+
const newUserId = ctx?.context?.returned?.user?.id;
237+
let signupOk: boolean;
238+
try {
239+
const { isAPIError } = await import('better-auth/api');
240+
signupOk = !isAPIError(ctx?.context?.returned);
241+
} catch {
242+
signupOk = !(ctx?.context?.returned instanceof Error);
243+
}
244+
// [#25060] Not for an existing address answered as a success: that
245+
// answer's user id belongs to no row, and nothing was created.
246+
if (signupOk && typeof newUserId === 'string' && ctx?.context?.__osExistingAddressSignUp !== true) {
247+
await this.stampPasswordChangedAt(newUserId);
248+
}
249+
}
250+
const ep = ctx?.context?.options?.emailAndPassword;
251+
if (ep && ctx.context.__osDisableSignUpOrig !== undefined) {
252+
ep.disableSignUp = ctx.context.__osDisableSignUpOrig;
253+
delete ctx.context.__osDisableSignUpOrig;
254+
}
255+
}

0 commit comments

Comments
 (0)