AUTH4D-256 browser SDK changeEmail() and removeEmail()

@auth4/sdk-browser gains two methods on Auth4BrowserClient for the change and remove actions of AUTH4D-212 (repository file docs/api-spec/auth4d-212.md). They share the AUTH4D-149 (repository file docs/api-spec/auth4d-149.md) addEmail() flow and add no endpoints or wire shapes.

Methods

changeEmail(options?: { signal?: AbortSignal }): Promise<ChangeEmailResult>;
removeEmail(options?: { signal?: AbortSignal }): Promise<RemoveEmailResult>;

interface ChangeEmailResult {
  session: BrowserSession; // same user, session.user.email is the new address
  email: string;
  identity: { identityId: string; provider: "email" };
}

interface RemoveEmailResult {
  session: BrowserSession; // same user, no email in session.user
}

Both follow the addEmail() rules:

  • Call them synchronously from a user gesture. The window opens as about:blank before the first await and is pointed at the hosted page once the intent exists.
  • POST /t/{tenantId}/account/email/intent is sent with the in-memory access token and { "client_id": "cli_…", "action": "change" } or "action": "remove". addEmail() now sends "action": "add", which the API treats exactly like no action.
  • add_email_uri must be on the issuer’s origin and have the path for the action: /login/add-email, /login/change-email or /login/remove-email. Any other page, including the page for another action, rejects with the action’s failure code before the window is navigated.
  • The SDK polls /account/email/intent/token with the poll secret it keeps in memory, honouring interval and slow_down. The window never receives a token or the poll secret.
  • Only one email window can be pending. Calling the same method again focuses the window and returns the same promise; calling another of the three rejects with email_window_open.
  • Guests are refused with guest_account, and an aborted signal closes the window and rejects with cancelled.
  • Before the tokens are swapped, the SDK checks that the local session is still the one that created the intent (same generation and subject) and that the token set is for the same user. Otherwise it rejects with invalid_session and keeps the current session. Tokens stay in memory.

On success:

Method Session after Event
changeEmail() { sub, email: <new address>, email_verified: true } email_changed
removeEmail() { sub } (the poll response has no identity or email) email_removed

Errors

Auth4BrowserError.code values:

Code changeEmail() removeEmail() When
invalid_session yes yes No session, the server ended it, or it changed mid-flow
guest_account yes yes The player is a guest
email_not_added yes yes The account has no email address
only_sign_in_method yes The address is the only way to sign in
email_sign_in_disabled yes Email sign-in is off for the project
popup_blocked yes yes The browser refused the window
email_window_open yes yes Another add, change or remove window is pending
cancelled yes yes The player chose Not now, or the signal was aborted
window_closed yes yes The window was closed before the player finished
change_email_failed yes Malformed response, wrong page, expiry or other failures
remove_email_failed yes Malformed response, wrong page, expiry or other failures

addEmail() is unchanged apart from the action field and the new email_window_open refusal.

Tests

  • packages/sdk-browser/src/index.test.ts (changeEmail and removeEmail (AUTH4D-256)): the intent body, page-path and origin checks, the change and remove results and events, the creation refusals, Not now, a closed window, slow_down, guests, a session change between polls and during the final poll, the single pending window and AbortSignal. Run with npx vitest run --config vitest.config.ts in packages/sdk-browser.
  • tests/e2e/auth4d-256.browser-change-email.spec.ts (port 5356): a routed game page runs the SDK against the built /login/change-email and /login/remove-email pages with a mocked API. Run with pnpm exec playwright test -c tests/e2e/auth4d-256.playwright.config.ts.

Source: docs/api-spec/auth4d-256.md