diff --git a/.changeset/change-email-notice-every-sign-in.md b/.changeset/change-email-notice-every-sign-in.md new file mode 100644 index 00000000000..97a0c1719a7 --- /dev/null +++ b/.changeset/change-email-notice-every-sign-in.md @@ -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. diff --git a/packages/plugins/plugin-auth/src/auth-email-locale.test.ts b/packages/plugins/plugin-auth/src/auth-email-locale.test.ts index 62070feba53..cb8614fd057 100644 --- a/packages/plugins/plugin-auth/src/auth-email-locale.test.ts +++ b/packages/plugins/plugin-auth/src/auth-email-locale.test.ts @@ -168,7 +168,7 @@ async function driveAllFive(locale: string | undefined, extra: Record 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 diff --git a/packages/plugins/plugin-auth/src/change-email-delete-user-wiring.test.ts b/packages/plugins/plugin-auth/src/change-email-delete-user-wiring.test.ts index 9534d051949..110cca8f694 100644 --- a/packages/plugins/plugin-auth/src/change-email-delete-user-wiring.test.ts +++ b/packages/plugins/plugin-auth/src/change-email-delete-user-wiring.test.ts @@ -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 { 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', () => { /** diff --git a/packages/plugins/plugin-auth/src/identity-mail-sibling.test.ts b/packages/plugins/plugin-auth/src/identity-mail-sibling.test.ts index 571fbb28aa4..be267e9b41a 100644 --- a/packages/plugins/plugin-auth/src/identity-mail-sibling.test.ts +++ b/packages/plugins/plugin-auth/src/identity-mail-sibling.test.ts @@ -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',