AUTH4D-258 verified email step: contracts

This fragment freezes the wire shapes for the verified email step in hosted login. The design is docs/architecture/verified-email-step.md (repository file docs/architecture/verified-email-step.md). The schemas live in packages/contracts/src/index.ts (settings, endpoints, audit) and packages/contracts/src/sdk.ts (the idp claim). AUTH4D-258 adds contracts only. The endpoints, the gate in /oauth/authorize/continue, migration 0037 and the claims are built by later tickets of the epic, which update this page when they ship.

Settings

Setting Where Values Default
requireVerifiedEmail Application (ClientPolicySchema, create and update) always, when_missing, off when_missing
linkVerifiedEmail Project settings (ProjectEmailLinkingSettingsSchema) never, after_code never

Both fields are optional on the wire; absent means the default (DEFAULT_REQUIRE_VERIFIED_EMAIL, DEFAULT_LINK_VERIFIED_EMAIL).

  • when_missing asks a social sign-in for an email code when the user has no email identity that Auth4 verified and the provider gave no address or an unverified one.
  • always asks whenever the user has no email identity that Auth4 verified, whatever the provider says.
  • off never asks.
  • after_code attaches a social identity created in this same sign-in, before any token was issued, to the existing user who owns the address the step just proved. Existing identities never move, so sub never changes for an existing user. Every other conflict answers email_unavailable.

Starting the step

When the step is required, POST /oauth/authorize/continue (JSON mode) answers 200 with an interaction instead of redirect_to, and sets __Host-auth4_email_step (HttpOnly; Secure; SameSite=Lax; Path=/, 600 seconds). The cookie’s secret binds the step to this browser.

{ "interaction": { "type": "verify_email", "step": "est_…", "expires_in": 600 } }

Hosted login then opens /login/verify-email?tenant_id=…&client_id=…. The step handle (est_ and 43 base64url characters) is not a secret and travels only in request bodies, never in a URL. A step lives for EMAIL_STEP_EXPIRY_SECONDS (600 seconds) and is consumed once.

Endpoints

All are POST /t/{tenantId}/email-step/<action> with a JSON body, the step cookie and an Origin equal to the auth origin. Every response is JSON with Cache-Control: no-store. Each body has client_id and step; extra fields are refused.

Action Extra body fields 200/202 response
state none EmailStepStateResponseSchema
send email 202 { "expires_in": 600, "resend_after": 60 }, always the same
resend none 202 { "expires_in": 600, "resend_after": 60 }, always the same
confirm code (6 digits) { "redirect_to" }, { "redirect_to", "denial" } or { "interaction" }
abandon none { "redirect_to" }: the application receives access_denied

state answers what the page shows when it opens or reloads:

{
  "step": "est_…",
  "client_id": "cli_…",
  "idp": "steam",
  "reason": "no_email",
  "expires_in": 540,
  "masked_address": "p***@e***.com",
  "resend_after": 42
}

reason is no_email, unverified_email or always. masked_address and resend_after appear together, once a code has been sent. A masked address shows one character of the local part and of the domain, then the top-level labels, so it can never hold a whole address or a code.

send and resend reuse the AUTH4D-13 rules: 6-digit codes stored as hashes, a 10-minute expiry, single use, at most 5 wrong attempts, a 60-second resend cooldown, and limits per address and per IP. Like AUTH4D-13 they always answer the same body, even when throttled.

confirm consumes the step on success, adds the address as an email identity of the user (or links under after_code), and continues exactly as /oauth/authorize/continue would.

Errors

4xx answers carry only a code, { "error": "…" }, plus retry_after (seconds) with slow_down. Hosted login shows its own text and never a server description.

error When
invalid_code The code is wrong
code_expired The code is older than 10 minutes or was replaced by a resend
too_many_attempts 5 wrong codes; send a new code
slow_down A resend inside the cooldown, or a rate limit
email_unavailable 409, only after the code is proven; the step stays open for another address
step_expired 410: the step is missing, expired, consumed or bound to another browser

The idp claim

ID tokens and userinfo gain an optional idp string naming the method used for this sign-in. The access-token contract accepts it too, so SDKs keep working if a later release adds it there. Issuers emit only IdpSchema values:

email, password, magic_link, passkey, sms, discord, steam, epic, google, apple, microsoft, oidc, sso, guest, session.

Email codes, magic links and passwords all issue email_verification proofs today, so they map to email (IDP_BY_PROOF_PURPOSE). The step never changes idp: a Steam sign-in that passed it is still steam. Token readers accept any value matching ^[a-z][a-z0-9_]{1,31}$ (IdpClaimSchema), so a method added later does not make older SDKs refuse a token. The C#, Go and Python server SDKs accept idp in access and ID tokens and refuse a malformed one.

Audit events

email_step.code_sent, email_step.verified, email_step.linked, email_step.failed and email_step.abandoned. AuditEventSchema refuses any other email_step.* name, and their metadata may hold only client_id, idp, identity_id, reason and masked_address (EmailStepAuditMetadataSchema). No code, code hash or whole address fits.

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