Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/change-email-notice-every-sign-in.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@objectstack/plugin-auth': patch
---

fix(plugin-auth): a change-email request notifies the current address whatever way the caller is signed in

Clause-②: no
Metadata-protocol: none

- **The rule.** Every `POST /api/v1/auth/change-email` request that is accepted mails the account's current address the `auth.email_change_notice`, whatever way the caller is signed in. The notice goes to the account the request changes: its address is read from the session the endpoint itself acted on.
- **Unchanged.** The notice's template and text; the confirmation mail to the requested address; a refused request still mails nothing; the notice still never blocks the change.
4 changes: 2 additions & 2 deletions packages/plugins/plugin-auth/src/auth-email-locale.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ async function driveAllFive(locale: string | undefined, extra: Record<string, un
path: '/change-email',
body: { newEmail: 'new@example.com' },
context: {
__osChangeEmailFrom: { email: 'ada@example.com', name: 'Ada', id: 'u1' },
session: { user: { email: 'ada@example.com', name: 'Ada', id: 'u1' } },
returned: { status: true },
},
});
Expand Down Expand Up @@ -353,7 +353,7 @@ async function driveWithHeader(
body: { newEmail: 'new@example.com' },
request,
context: {
__osChangeEmailFrom: { email: 'ada@example.com', name: 'Ada', id: 'u1' },
session: { user: { email: 'ada@example.com', name: 'Ada', id: 'u1' } },
returned: { status: true },
},
});
Expand Down
27 changes: 25 additions & 2 deletions packages/plugins/plugin-auth/src/auth-manager-after-hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,21 @@ export interface AfterHookHost {
stampPasswordChangedAt: AuthManager['stampPasswordChangedAt'];
}

/**
* #8019 — who the change-email notice goes to: the user of the session the
* endpoint acted on. `undefined` when that user carries no address, and then
* no notice is sent.
*/
function changeEmailFrom(user: unknown): { email: string; name?: string; id?: string } | undefined {
const u = (user ?? {}) as { email?: unknown; name?: unknown; id?: unknown };
if (typeof u.email !== 'string' || !u.email) return undefined;
return {
email: u.email,
...(typeof u.name === 'string' && u.name ? { name: u.name } : {}),
...(typeof u.id === 'string' && u.id ? { id: u.id } : {}),
};
}

/** The body of `createAuthInstance`'s `hooks.after` arrow; the comments there and in it are its contract. */
export async function globalAfterHook(this: AfterHookHost, ctx: any) {
// ── #8243: impersonation must actually take effect for a bearer ──
Expand Down Expand Up @@ -204,9 +219,17 @@ export async function globalAfterHook(this: AfterHookHost, ctx: any) {
// does not carry the old address, and better-auth mints a fresh
// session there when the link is opened without one, so an
// apply-time notice would silently miss that path.
//
// The address the notice goes to is the one the account holds as the
// request arrives, read from the session the endpoint itself acted on:
// better-auth's `sensitiveSessionMiddleware` resolves the caller before
// the handler runs and records that session on `ctx.context.session`,
// whatever credential carried it, and the handler never writes that object.
// So it names the account the request changes, with the address as it
// stood. No before-hook can read it: the endpoint resolves its caller only
// after every before-hook has run.
if (ctx?.path === '/change-email') {
const from = ctx?.context?.__osChangeEmailFrom;
delete ctx.context.__osChangeEmailFrom;
const from = changeEmailFrom(ctx?.context?.session?.user);
let succeeded: boolean;
try {
const { isAPIError } = await import('better-auth/api');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,7 @@ export async function createAuthInstance(this: AuthInstanceHost): Promise<Auth<a
// approval gate the ruling refuses, not a way to notify.
//
// The notice is consequently sent by the framework, from the global
// `after` hook on `/change-email` (search `__osChangeEmailFrom`), using
// `after` hook on `/change-email` (`auth-manager-after-hook.ts`), using
// the `auth.email_change_notice` template. It is failure-isolated by
// construction: it runs after better-auth has already produced the
// response, and its own errors are swallowed, so a dead mailbox cannot
Expand Down
34 changes: 1 addition & 33 deletions packages/plugins/plugin-auth/src/auth-manager-before-hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
* it stood, re-indented four levels and otherwise unedited: the same actor
* resolver handed to the attribution scope, the same refresh skip for a minted
* principal session, the same per-number OTP send guard, the same password
* complexity and reuse checks, the same change-email capture, the same
* complexity and reuse checks, the same
* remove-member, organization-delete and no-record revoke refusals, the same
* held-caller refusal on the admin plugin's role-scalar actions, the same
* no-record admin revoke refusal, the same held-caller refusal on rewriting or
Expand Down Expand Up @@ -206,38 +206,6 @@ export async function globalBeforeHook(
// fall through to the path's own handling below
}

// ── #8019: capture the address a change-email would move AWAY from ──
// The notice goes out from the after-hook, but the OLD address only
// exists BEFORE the endpoint runs — and `ctx.context.session` is not
// it: better-auth's own `sensitiveSessionMiddleware` populates that,
// and endpoint middleware runs AFTER this global before-hook, so the
// field is still empty here. Resolve the session explicitly, the same
// hook-order-independent way `/oauth2/authorize` below has to.
//
// Stashed, never sent from here: at this point the request has not
// been accepted yet (it can still fail on `newEmail` equal to the
// current address, on rate limiting, or on a missing transport), and
// a notice for a request better-auth then refuses would be a false
// alarm about an identity move that never happened.
if (ctx?.path === '/change-email') {
try {
const { getSessionFromCtx } = await import('better-auth/api');
const s: any = await getSessionFromCtx(ctx as any).catch(() => null);
const from = s?.user?.email;
if (typeof from === 'string' && from) {
ctx.context.__osChangeEmailFrom = {
email: from,
...(typeof s?.user?.name === 'string' && s.user.name ? { name: s.user.name } : {}),
...(typeof s?.user?.id === 'string' && s.user.id ? { id: s.user.id } : {}),
};
}
} catch {
// Unresolvable session → no stash → no notice. better-auth's own
// session middleware still rejects the request with 401, so this
// never silently drops a notice for a change that proceeds.
}
}

// ── #8289: remove-member answers its PERMISSION denial itself ──
// better-auth's `removeMember` orders "only an owner may remove an
// owner" AHEAD of its real permission check and reports it with the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -530,6 +530,106 @@ describe('#8019 — change-email notifies the previous address without gating on
});
});

// ───────────────────────────────────────────────────────────────────────────
/**
* #8019, whatever way the caller is signed in. The notice is owed for every
* accepted request, so it must reach the account's current address on the
* session-cookie lane and on the bearer-token lane alike. Every accepted case
* also asserts the acceptance itself (a 200, and one confirmation to the
* requested address), so no case can pass on a request better-auth refused;
* and every refused case asserts that nothing at all was mailed.
*/
describe('#8019 — a change-email request notifies the current address whatever way the caller is signed in', () => {
const bearerFrom = (response: Response): string => response.headers.get('set-auth-token') ?? '';

const changeEmailAs = (manager: AuthManager, credentials: { cookie?: string; bearer?: string }, newEmail: string) =>
manager.handleRequest(
new Request(`${AUTH}/change-email`, {
method: 'POST',
headers: {
'content-type': 'application/json',
origin: ORIGIN,
...(credentials.cookie ? { cookie: credentials.cookie } : {}),
...(credentials.bearer ? { authorization: `Bearer ${credentials.bearer}` } : {}),
},
body: JSON.stringify({ newEmail }),
}),
);

const confirmationsTo = (sent: SendTemplateInput[]): unknown[] =>
sent.filter((s) => s.template === 'auth.verify_email').map((s) => s.to);

const TRANSPORTS: ReadonlyArray<readonly [string, (signedUp: Response) => { cookie?: string; bearer?: string }]> = [
['the session cookie', (signedUp) => ({ cookie: cookieFrom(signedUp) })],
['a bearer token alone', (signedUp) => ({ bearer: bearerFrom(signedUp) })],
];

for (const [transport, credentialsOf] of TRANSPORTS) {
it(`signed in by ${transport}: an accepted request mails the notice to the current address`, async () => {
const engine = createMemoryEngine();
const email = createRecordingEmailService();
const manager = makeManager(engine, email.service);
const credentials = credentialsOf(await signUp(manager, 'current@example.com'));
expect(Object.values(credentials).join(''), 'sign-up must hand out the credential').not.toBe('');
email.sent.length = 0;

const response = await changeEmailAs(manager, credentials, 'requested@example.com');
expect(response.status, await response.clone().text()).toBe(200);
expect(await response.json()).toEqual({ status: true });
expect(confirmationsTo(email.sent), 'the request was accepted').toEqual([
expect.objectContaining({ address: 'requested@example.com' }),
]);

expect(notices(email.sent), 'exactly one notice, to the address being left').toHaveLength(1);
const [notice] = notices(email.sent);
expect(notice.to).toMatchObject({ address: 'current@example.com' });
expect(notice.data).toMatchObject({
newEmail: 'requested@example.com',
user: { email: 'current@example.com', id: userRows(engine)[0]!.id },
});
// Nothing has moved yet: the notice names the address the account holds now.
expect(userRows(engine).map((r) => r.email)).toEqual(['current@example.com']);
});

it(`signed in by ${transport}: a refused request mails nothing`, async () => {
const engine = createMemoryEngine();
const email = createRecordingEmailService();
const manager = makeManager(engine, email.service);
const credentials = credentialsOf(await signUp(manager, 'current@example.com'));
email.sent.length = 0;

// better-auth refuses the account's own address before it mints anything.
const response = await changeEmailAs(manager, credentials, 'current@example.com');
expect(response.status).toBe(400);
expect(await errorBody(response)).toMatchObject({ code: 'VALIDATION_ERROR' });
expect(email.sent).toEqual([]);
});
}

it('signed in as two accounts at once: the notice goes to the current address of the account the request changes', async () => {
const engine = createMemoryEngine();
const email = createRecordingEmailService();
const manager = makeManager(engine, email.service);
const cookie = cookieFrom(await signUp(manager, 'cookie-holder@example.com'));
const bearer = bearerFrom(await signUp(manager, 'token-holder@example.com'));
const tokenHolderId = userRows(engine).find((r) => r.email === 'token-holder@example.com')!.id;
email.sent.length = 0;

const response = await changeEmailAs(manager, { cookie, bearer }, 'requested@example.com');
expect(response.status, await response.clone().text()).toBe(200);

// The account the request changes is the one its confirmation is for.
const confirmations = email.sent.filter((s) => s.template === 'auth.verify_email');
expect(confirmations).toHaveLength(1);
expect(confirmations[0].to).toMatchObject({ address: 'requested@example.com' });
expect((confirmations[0].data as { user?: { id?: string } }).user?.id).toBe(tokenHolderId);

// …and the notice goes to that account's current address, and to no other.
expect(notices(email.sent).map((s) => s.to)).toEqual([expect.objectContaining({ address: 'token-holder@example.com' })]);
expect((notices(email.sent)[0].data as { user?: { id?: string } }).user?.id).toBe(tokenHolderId);
});
});

// ───────────────────────────────────────────────────────────────────────────
describe('#29163 — the uncoded refusals on /change-email and /update-user carry a ledgered code', () => {
/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ async function driveAuthMail(tenancy: Tenancy) {
await config.hooks.after({
path: '/change-email',
body: { newEmail: 'new@example.com' },
context: { __osChangeEmailFrom: { email: USER.email, name: USER.name, id: USER.id }, returned: { status: true } },
context: { session: { user: { email: USER.email, name: USER.name, id: USER.id } }, returned: { status: true } },
});
await config.plugins.find((p: any) => p.id === 'organization')._opts.sendInvitationEmail({
email: 'invitee@example.com',
Expand Down
Loading