AUTH4D-266 verified email step: the hosted-login “Add your email” page

Hosted login shows this page when POST /oauth/authorize/continue parks a sign-in for the verified email step (auth4d-262.md (repository file docs/api-spec/auth4d-262.md)). The page asks for an address, mails a code and checks it through the step endpoints (auth4d-263.md (repository file docs/api-spec/auth4d-263.md)). This ticket adds the page, two page-text fields for it, and a restart option on abandon.

The page

/login/verify-email?tenant_id=…&client_id=…, one more copy of the hosted-login shell (ROUTES in apps/login/vite.config.ts).

  • Opening. When continue answers {"interaction":{"type":"verify_email","step":"est_…"}}, the sign-in screen opens the step in place and moves the address bar to the page URL with history.replaceState. The step handle is kept in that history entry’s state, so a reload of the same tab continues the step. It is never in the URL, sessionStorage or localStorage. The HttpOnly __Host-auth4_email_step cookie binds the step to the browser. Opened without the handle (another tab, a copied link, or the 302 answer of a non-JSON continue), the page says the step has expired.
  • Address. state decides the first screen: the address form, or the code form when a code was already sent (it then shows only the masked address). The address goes only in the body of send.
  • Code. The code form is the email-code screen of hosted sign-in. Resend calls resend, which mails the address of the live code, so the page never needs the address again. Resend is disabled during the 60-second cooldown, with a visible countdown. A refused resend (slow_down) restarts the countdown with retry_after, and code_expired returns to the address form.
  • Confirm. A redirect_to answer opens the application’s redirect URI. A Flows denial or a terms screen are shown as after continue. Until the next ticket replaces confirm’s bare 204, the page then says the address is verified and asks the player to start again.
  • Use a different sign-in method. Shown on both forms. It calls abandon with restart: true and opens the sign-in page it answers, which lists the methods again for the same authorization request.
  • Errors are hosted login’s own wording for the error codes. Server descriptions are never shown. step_expired (and 403) show the expired screen.

The page meets the hosted-login rules: one h1, focus moves to it on every screen, role="status" and role="alert" regions, a visible focus ring, no horizontal scrolling at 320px, and the strict Content Security Policy (no inline script or style).

Page text

Two more per-application page-text fields (AUTH4D-166) in BrandingSchema and BrandingUpdateSchema, stored in migration 0038 (page_verify_email_title, page_verify_email_intro):

Field Limit Replaces
verify_email_title 60 The heading, “Add your email”
verify_email_intro 280 The lead, “{App} needs a verified email address for …”

They follow the PageTextSchema rules (plain text, no <>{}, no links, no runs of six digits) and are set with the existing PATCH /management/tenants/{tenantId}/clients/{clientId} branding object, where null clears a field. The console shows them in the Page text form, in an “Add your email page” group. GET /t/{tenantId}/login/context serves them in branding when set. The audit event management.client.presentation.updated names them as branding.verify_email_title and branding.verify_email_intro and never holds the text. The code entry, the countdown and every message stay fixed.

abandon with restart

POST /t/{tenantId}/email-step/abandon accepts an optional restart: true (any other value is 400 invalid_request):

{ "client_id": "cli_…", "step": "est_…", "restart": true }

The step is consumed, its code dropped and email_step.abandoned written, exactly as without restart. The step and old flow cookies are cleared. Instead of returning to the application, the parked request (client, redirect URI, state, PKCE challenge, scopes, nonce, prompt=consent) is stored as a new hosted-login flow, bound by a new __Host-auth4_flow_<id> cookie, and the answer is its sign-in page:

{ "redirect_to": "https://auth.auth4.dev/login?tenant_id=ten_…&client_id=cli_…&flow_id=flw_…" }

The player the step was for is not signed in on that flow, so any method starts afresh. Without restart, abandon still sends the application access_denied.

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