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.
- Nobody in the project owns it. It becomes a provider
emailidentity of the signing-in user (provider_subjectandemailthe address,email_verified1), 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 samesub. - Another user owns it, and linking applies. The project’s
linkVerifiedEmailisafter_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 (identityCreatedon 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 (subis the owner’s,is_first_sign_infalse for the post_login flow). - 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’ssubnever 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.createdevent andpre_registrationhook already ran for the user the Steam callback created; deleting that user underafter_codepublishes no event of its own. - Hosted device approval and guest upgrade through the step are later tickets.
Source: docs/api-spec/auth4d-264.md