AUTH4D-264 verified email step: completing the step

POST /t/{tenantId}/email-step/confirm (auth4d-263.md (repository file docs/api-spec/auth4d-263.md)) no longer stops at 204 when the code matches. It attaches the proven address, applies the project’s linkVerifiedEmail (auth4d-260.md (repository file docs/api-spec/auth4d-260.md)) and resumes the parked authorization (auth4d-262.md (repository file docs/api-spec/auth4d-262.md)), all under the step’s Durable Object lock. The code is in apps/api/src/modules/email-step/complete.ts; the design is docs/architecture/verified-email-step.md (repository file docs/architecture/verified-email-step.md).

The request body, the code rules, the limits and the other error answers are unchanged.

Answers

Status Body When
200 {"redirect_to": "<redirect_uri>?code=…&state=…"} The address was attached or linked and the code was issued
200 {"redirect_to": "…?error=access_denied&state=…", "denial": {…}} A post_login flow denied the sign-in
200 {"interaction": {"type": "terms", …}} A post_login flow asks for a terms screen; finish it with continue
403 {"error":"access_denied"} The application, project or user is no longer active
409 {"error":"email_unavailable"} Another account keeps the address; the step stays open (see below)
503 {"error":"temporarily_unavailable"} D1 failed; nothing was written and the step stays open

The 200 bodies are EmailStepConfirmResponseSchema, the same answers /oauth/authorize/continue gives in its JSON mode, with the step and flow cookies cleared (a terms screen keeps the flow cookie). The answer is always JSON, whatever the request’s Accept header.

What happens to the address

The address is the normalised, lower-cased one the code was mailed to. Who owns it is read only after the code is proven, so send and a wrong confirm answer the same for every address.

  1. Nobody in the project owns it. It becomes a provider email identity of the signing-in user (provider_subject and email the address, email_verified 1), in one guarded insert keyed on the (tenant_id, provider, provider_subject) unique key that AUTH4D-149 relies on. An account keeps one email identity. The authorization resumes with the same sub.
  2. Another user owns it, and linking applies. The project’s linkVerifiedEmail is after_code, the sign-in was a social one (steam, discord, epic, google, apple, microsoft, oidc, sso), and its identity was created by this same login (identityCreated on the proof). One D1 batch moves that identity to the owner and deletes the empty user the callback created. The move re-checks, in SQL, that the identity and its user were created after the authorization started, that the user owns only this identity, has no session, membership, consent or passkey, and that the owner is active, still owns the address and has no identity of the same provider. The authorization resumes as the owner (sub is the owner’s, is_first_sign_in false for the post_login flow).
  3. Anything else. 409 email_unavailable, with no reason given. This covers a returning social user (an identity that existed before this login is never moved, so an existing user’s sub never changes), linkVerifiedEmail: never, a disabled owner, and a fresh user that already had a session. Nothing is written; the step is put back without the address, so the page can ask for another one until the step expires.

A failed batch is rolled back as a whole, so an identity is never half moved. Any failure answers 503 and puts the step back.

Once only

confirm consumes the step before it attaches anything and holds the step lock while it runs. Only 409 and 503 put the step back. A second confirm on a completed step answers 410 step_expired, and the post_login flow, consent and code run once, for the final user.

The next sign-in of the same Steam (or other social) account finds the email identity on its user and skips the step. Removing the address (AUTH4D-212) or tightening requireVerifiedEmail brings the step back on the next sign-in.

Audit events

Written with auditEventStatement; metadata as in AUTH4D-263 (client_id, idp, identity_id, reason, masked_address), never the whole address or the code.

Event Outcome Actor Target identity_id
email_step.verified success the final user user, the final user the new email identity, or the moved one
email_step.linked success the owner identity, the moved one the moved identity
email_step.failed denied the signing-in user user the sign-in’s identity; reason: email_unavailable

Not covered

  • The user.created event and pre_registration hook already ran for the user the Steam callback created; deleting that user under after_code publishes no event of its own.
  • Hosted device approval and guest upgrade through the step are later tickets.

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