AUTH4D-149 add an email address to a signed-in account

A player who signed up with Steam (or Discord) has no email identity. A signed-in player can add one, verified with an email code, and it becomes an email identity of the same user. The flow is an add-email intent, built like the native guest upgrade intent (AUTH4D-67 (repository file docs/api-spec/auth4d-67.md)) and used by browser and native clients alike:

  1. The application creates an intent with the player’s access token. It receives a non-secret intent handle, a poll secret and the hosted page URL.
  2. The application opens add_email_uri (/login/add-email?…&intent={handle}): a window in the browser SDK, the system browser in Unity.
  3. The hosted page binds the intent to that browser, runs the existing email-code step (/t/{tenantId}/email/verification/start and /confirm) and, after the player confirms, adds the proven address.
  4. The application polls with the poll secret. It gets authorization_pending until the page finishes, then a refreshed token set for the same user, exactly once.

The handle appears in a URL and is not a secret. On its own it can only bind the intent to the first browser that opens it. Only the poll secret, which never leaves the application, collects tokens. The API stores SHA-256 hashes of both. Intents live for ADD_EMAIL_INTENT_EXPIRY_SECONDS (600 seconds), are keyed by tenant, and every state change runs under a per-intent lock in the tenant’s Durable Object. All responses are JSON with Cache-Control: no-store.

Since AUTH4D-343 (repository file docs/api-spec/auth4d-343.md), binding also needs the one-time browser verifier from the page URI’s fragment, so the handle alone binds nothing; begin also labels the account, and creating a credential-changing intent needs a recent sign-in.

Conflicts are refused without revealing them. Sending and confirming the code behave the same for a new address and for one that already belongs to another user of the tenant (the enumeration-resistant AUTH4D-13 step). Only after the code has proved the player controls the mailbox does complete return 409 email_unavailable, which does not say why. Accounts are never merged. The intent stays pending, so the player can try a different address.

Create an intent (application)

POST /t/{tenantId}/account/email/intent
Authorization: Bearer {access token}
Content-Type: application/json

{ "client_id": "cli_…" }

The bearer must be a live access token for this tenant and client (audience client_id). The user must not be disabled, must not be a guest (a guest identity and nothing else; guests use the guest upgrade), and must not already have an email identity. Email sign-in must be enabled for the tenant. Intents are budgeted at 10 per user per hour.

{
  "intent": "aei_…",
  "poll_secret": "aep_…",
  "add_email_uri": "https://auth.auth4.dev/login/add-email?tenant_id=ten_…&client_id=cli_…&intent=aei_…",
  "expires_in": 600,
  "interval": 5
}
Status error When
400 invalid_request Malformed body
401 unauthorized_client Unknown, disabled or server client, or inactive tenant
401 invalid_session Missing, expired or revoked token, or a disabled user
403 access_denied Browser Origin not registered on this client
409 guest_account The user is still a guest; use the guest upgrade
409 email_already_added The user already has an email identity
409 email_sign_in_disabled Email sign-in is turned off for the tenant
429 slow_down Intent budget exhausted
503 temporarily_unavailable Session or atomic state unavailable

Wire shapes: AddEmailIntentCreateRequestSchema and AddEmailIntentCreateResponseSchema in packages/contracts. The handle matches ^aei_[A-Za-z0-9_-]{32,64}$ and the poll secret matches ^aep_[A-Za-z0-9_-]{43,86}$.

Poll for the result (application)

POST /t/{tenantId}/account/email/intent/token
Content-Type: application/json

{ "client_id": "cli_…", "intent": "aei_…", "poll_secret": "aep_…" }

Polling semantics match AUTH4D-67 (repository file docs/api-spec/auth4d-67.md): the first poll is due interval seconds after creation, and polling early returns slow_down and adds 5 seconds (up to 60).

Status Body Meaning
400 { "error": "authorization_pending", "interval" } The player has not finished yet
400 { "error": "slow_down", "interval" } Polled faster than the interval, or another poll is in progress
400 { "error": "access_denied" } The player chose Not now; reported once
400 { "error": "expired_token" } The intent expired, or there is no such intent in this tenant
400 { "error": "invalid_grant" } Wrong poll secret, or the result was already collected (replay)
401 { "error": "invalid_client" } Unknown, disabled or server client, or inactive tenant
403 { "error": "access_denied" } Browser Origin not registered on this client
429 { "error": "slow_down" } Poll budget exhausted (12 per intent and 60 per IP each minute)
200 Refreshed token set Returned exactly once
{
  "user_id": "usr_…",
  "identity": { "identity_id": "idn_…", "provider": "email" },
  "email": "player@example.com",
  "access_token": "…",
  "token_type": "Bearer",
  "expires_in": 300,
  "refresh_token": "rfr_…",
  "id_token": "…",
  "scope": "openid offline_access",
  "session_id": "ses_…"
}

The refreshed session has the same user, client and scopes as the session that created the intent, and carries the verified address (email, email_verified: true). refresh_token is present only when the scopes include offline_access. The session that created the intent was revoked when the address was added.

CORS for browser applications

The browser SDK calls create and poll from the application’s page. As for guest sign-in (AUTH4D-122 (repository file docs/api-spec/auth4d-122.md)), deployment-wide origins keep the boundary policy, and any other Origin must be registered on the named browser client. Preflights are answered for an origin registered on any enabled browser client of the tenant in the path: POST with authorization and content-type for create, and content-type only for poll. Responses never allow credentials.

Hosted page steps (browser)

Called by /login/add-email only. They require Origin: {AUTH_ORIGIN} (otherwise 403 access_denied) and never accept or return tokens.

POST /t/{tenantId}/account/email/intent/begin
POST /t/{tenantId}/account/email/intent/complete
POST /t/{tenantId}/account/email/intent/deny
Content-Type: application/json

{ "client_id": "cli_…", "intent": "aei_…", "browser_flow_id": "flw_…" }

complete also takes "proof_id": "prf_…" (AddEmailIntentCompleteRequestSchema); the other two use AddEmailIntentBrowserRequestSchema.

  • begin binds a pending intent to this browser. It creates a linking flow that points at the hosted login (so native clients can run the email step on it too), sets __Host-auth4_flow_{browser_flow_id} (HttpOnly; Secure; SameSite=Lax) and returns { "status": "pending", "expires_in" }. Repeating it from the same browser returns the same result; any other browser gets 403 access_denied.
  • complete requires the bound flow cookie and an email proof issued after binding for the same flow. If the address is new to the tenant, the provisional user created by the code step is emptied and disabled and its email identity moves to the player, in one D1 transaction that also checks the player still has no email identity. It then revokes the session that created the intent, writes a user.email_added audit event (actor_id and target_id are the user; the metadata holds client_id, provider and identity_id, never the address) and returns { "status": "approved", "identity": { "identity_id", "provider": "email" } }. Errors: 409 email_unavailable (the address belongs to another user; the intent stays pending), 409 email_already_added, 400 access_denied (missing, expired or foreign proof; the intent stays pending), 401 invalid_session (the user was disabled).
  • deny requires the bound flow cookie, ends the intent and returns { "status": "denied" }.

Errors common to the three steps: 400 expired_token, 403 access_denied (wrong origin, or another browser), 409 access_denied (already completed, denied or collected), 401 unauthorized_client and 429 slow_down (step in progress, or 10 completion attempts per user per hour exhausted).

Hosted page

/login/add-email?tenant_id={tenantId}&client_id={clientId}&intent={handle}. A malformed link shows the invalid-link page. The page binds the intent and shows Add an email address with Not now, runs the email-code step, asks Add {email}? before calling complete, and shows the added, can’t-be-added, cancelled, expired, already-finished and opened-in-another-browser outcomes. It keeps only the non-secret flow ID (keyed by handle) in sessionStorage.

SDKs

  • Browser: Auth4BrowserClient.addEmail() opens the window inside the click, creates the intent, polls, replaces the in-memory token set and emits an email_added session event. It rejects with guest_account, email_already_added, email_sign_in_disabled, cancelled, window_closed, popup_blocked, invalid_session or add_email_failed.
  • Unity: Auth4Client.AddEmailAsync() opens the page with the BrowserLauncher, polls, and commits the refreshed session. Failures throw Auth4AddEmailException with an AddEmailFailure reason.

Tests

  • tests/integration/auth4d-149.add-email.test.ts: a Steam-only player adds an address (identity on the same user, provisional user disabled, refreshed tokens once, audit event without the address), a taken address refused with email_unavailable and a retry, creation refusals, first- browser binding, poll secret and Not now, expiry and tenant isolation, and browser CORS. Run with pnpm --filter @auth4/integration exec vitest run --config vitest.config.ts auth4d-149.
  • packages/sdk-browser/src/add-email.test.ts and packages/sdk-unity/Tests~/Auth4.Protocol.Tests/AddEmailTests.cs: the SDK sides.
  • tests/e2e/auth4d-149.add-email.spec.ts (port 5249): the hosted page.

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