AUTH4D-263 verified email step: send, resend, confirm, state and abandon

When /oauth/authorize/continue parks an authorization for the verified email step (auth4d-262.md (repository file docs/api-spec/auth4d-262.md)), hosted login uses these endpoints to mail a code to the address the player types and to check it. Wire shapes are the AUTH4D-258 contracts (auth4d-258.md (repository file docs/api-spec/auth4d-258.md)); the design is docs/architecture/verified-email-step.md (repository file docs/architecture/verified-email-step.md).

All five are POST /t/{tenantId}/email-step/<action> with a JSON body that names the application and the step handle from the verify_email interaction:

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

Rules for every call

  • Hosted login only. The Origin header must be the auth origin (https://auth.auth4.dev), as for the email-code and add-email steps. Any other origin answers 403 {"error":"access_denied"}.
  • Step cookie. The request must carry __Host-auth4_email_step, set by continue. The step must belong to this browser, tenant and application, and must not be expired or consumed. Otherwise the answer is 410 {"error":"step_expired"}. A step for another browser is left untouched for its owner.
  • One call at a time. Calls on one step are serialised by a lock in the tenant’s Durable Object; a call that arrives while another is running answers 429 slow_down with retry_after: 1.
  • No-store. Every answer has Cache-Control: no-store, max-age=0 and Pragma: no-cache.
  • Codes only. Error bodies carry a code from EmailStepErrorCodeSchema and nothing else (retry_after only with slow_down). Hosted login shows its own text.
  • Same answers for every address. send and confirm never read who owns the address, so their answers and timing are the same for a new address and one that belongs to another account. The ownership check (409 email_unavailable) happens only after a code is proven, when the address is attached (a later ticket).

Transport errors outside the step codes:

Status Body When
400 {"error":"invalid_request"} Malformed JSON, unknown fields, a bad handle, email or code
401 {"error":"unauthorized_client"} The application or project is disabled, or not a browser app
403 {"error":"access_denied"} Origin is not the auth origin
503 {"error":"temporarily_unavailable"} Storage, the privacy key or email delivery is unavailable

The code

The step reuses the AUTH4D-13 email-code rules, bound to the step handle instead of an OAuth flow:

  • 6 digits, drawn by rejection sampling.
  • Stored only as an HMAC (the deployment privacy key, separated by tenant, application, step and challenge id) in the tenant’s Durable Object, under email-step-code:<SHA-256(handle)>.
  • Valid for 10 minutes and usable once. A new send or resend replaces the previous code, so only the last code mailed works.
  • At most 5 wrong codes. The 6th attempt answers too_many_attempts and drops the code, even if it is right; the page must send again.
  • Compared with the constant-time helper (constantTimeEqual).
  • Addresses are trimmed, NFC-normalised and lower-cased exactly as email identities are (normalizeEmail), so the later duplicate check sees the same address.

The code is mailed through the application’s verification email: its wording and sender name (AUTH4D-150) and the project’s verified sending domain (AUTH4D-168). Codes, code hashes and whole addresses are never logged or written to audit metadata.

Limits

All limits are coordinated in the tenant’s Durable Object. Keys are HMACs of the address, IP or user, never the raw value.

Limit Budget Shared with email sign-in
Cooldown per step and address 1 send per 60 s no
Sends per signing-in user 8 per hour no
Sends per address 5 per hour yes
Sends per IP 10 per hour yes
Sends per project / across the deployment 500 / 20,000 per h yes
Code attempts per signing-in user 10 per hour no
Code attempts per IP 20 per hour yes
Wrong codes per code 5 (same rule)

A refused limit answers 429 with Retry-After and

{ "error": "slow_down", "retry_after": 42 }

The cooldown is per address, so send with a different address is not held back by it (the user, IP and address limits still apply).

POST /t/{tenantId}/email-step/state

Body: { client_id, step }. Answers 200 with 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": 20
}

expires_in is the step’s remaining lifetime. masked_address and resend_after appear together once a live code has been sent; resend_after is the time left of the 60-second cooldown.

POST /t/{tenantId}/email-step/send

Body: { client_id, step, email }. Mails a new code to email and answers 202:

{ "expires_in": 600, "resend_after": 60 }

A different address replaces the earlier code. Errors: 429 slow_down, 410 step_expired.

POST /t/{tenantId}/email-step/resend

Body: { client_id, step }. Mails a new code to the address of the live code, replacing it, and answers 202 like send. Errors: 400 code_expired when there is no live code (none was sent, it expired, it was used, or it was dropped after too many attempts; the page goes back to the address form), 429 slow_down within 60 seconds of the last send to that address, 410 step_expired.

POST /t/{tenantId}/email-step/confirm

Body: { client_id, step, code }. Checks the code.

Status Body When
204 none The code matched; the address is recorded on the step
400 {"error":"invalid_code"} Wrong code, or no live code (already used or never sent)
400 {"error":"code_expired"} The code is older than 10 minutes
400 {"error":"too_many_attempts"} Five wrong codes were tried; send a new code
429 {"error":"slow_down", …} Attempt limit per user or IP
410 {"error":"step_expired"} The step is missing, expired, consumed or not this browser’s

On success the code is spent and the step keeps the proven address (proven: {email, emailHash, verifiedAt}) until it expires. In this release confirm stops there with 204: attaching the address as an email identity, the 409 email_unavailable answer and resuming the authorization (EmailStepConfirmResponseSchema) are added by the next ticket of the epic, which replaces the 204. AUTH4D-264 did so: see auth4d-264.md (repository file docs/api-spec/auth4d-264.md).

POST /t/{tenantId}/email-step/abandon

Body: { client_id, step }. Ends the step: the step and its code are deleted, the step and flow cookies are cleared, and the answer sends the player back to the application with access_denied:

{ "redirect_to": "https://game.example/callback?error=access_denied&state=…" }

Errors: 410 step_expired. AUTH4D-266 adds restart: true, which reopens the same authorization request on the hosted sign-in page instead (auth4d-266.md (repository file docs/api-spec/auth4d-266.md)).

Audit events

Written with auditEventStatement against AuditEventSchema. Actor and target are the signing-in user. Metadata is limited to client_id, idp, identity_id, reason and masked_address.

Event Outcome When
email_step.code_sent success send or resend mailed a code (not when a limit refused it)
email_step.failed denied confirm refused a code; reason is invalid_code, code_expired or too_many_attempts
email_step.abandoned failure abandon ended the step; reason is abandoned

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