AUTH4D-302 verified email step on guest upgrades

Guest upgrades (auth4d-15.md (repository file docs/api-spec/auth4d-15.md), native intents in auth4d-67.md (repository file docs/api-spec/auth4d-67.md)) now run the application’s verified email step (auth4d-260.md (repository file docs/api-spec/auth4d-260.md), auth4d-262.md (repository file docs/api-spec/auth4d-262.md)) like every other sign-in path. The step is judged as a sign-in with the linked credential, on the guest’s identities after the link: under the default when_missing a guest who links Steam or Epic Games (no address) is asked for one; off, an upstream-verified address (for example Google) and a guest who already has an Auth4-verified address skip it. Email, password, SMS and passkey links never ask.

When the step applies, the credential is linked to the guest, but no replacement session is created, no token is issued, the guest session keeps working and guest.upgraded is neither audited nor published. The upgrade is parked as a guest_upgrade step in the tenant’s Durable Object for ten minutes, keyed by tenant and the SHA-256 of the step handle. It holds the tenant, application, guest user, the guest session it will replace, the linking flow, the linked identity, the sign-in facts, the reason and the SHA-256 of the __Host-auth4_email_step cookie’s secret. It never holds a token, a code, a poll secret or a native intent handle (only its SHA-256).

POST /t/{tenantId}/guest/upgrade

The request is unchanged. When the step applies, the proof and the linking intent are consumed and the answer is 200 with Cache-Control: no-store and the __Host-auth4_email_step cookie (EmailStepRequiredResponseSchema), always in JSON:

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

Every other answer is as before. The hosted upgrade window opens the step’s “Add your email” screen in place, with the address bar left on /login/upgrade.

POST /t/{tenantId}/guest/upgrade-intent/complete

The request is unchanged. When the step applies, the answer is 200 {"status": "verify_email", "interaction": {…}} (GuestUpgradeIntentEmailStepSchema) with the step cookie, and the intent stays pending: /guest/upgrade-intent/token keeps answering authorization_pending, and /begin, /complete and /deny answer 409 access_denied until the step ends.

Step endpoints

POST /t/{tenantId}/email-step/{state,send,resend,confirm,abandon} work as in auth4d-263.md (repository file docs/api-spec/auth4d-263.md) and take the guest’s client_id (a browser or native application). They need the step cookie of the browser the upgrade was parked in.

  • confirm proves the code and attaches the address to the guest as an email identity (auth4d-264.md (repository file docs/api-spec/auth4d-264.md)). A guest is never merged into another account, so an address another player owns always answers 409 email_unavailable, whatever linkVerifiedEmail says; the step stays open for another address. The gate is checked again, then the upgrade finishes:

    • browser: the replacement session is created, the guest session revoked, guest.upgraded audited and published once (keyed on the replaced session), and the answer is 200 {"status": "upgraded", "upgrade": {…}} (GuestEmailStepUpgradedSchema). upgrade is the /guest/upgrade answer: user_id (the guest’s, unchanged), identity, the token set and session_id. The ID token carries idp (for example steam). Guest sessions are never granted the email scope, so under the AUTH4D-265 (repository file docs/api-spec/auth4d-265.md) rule the proven address is stored but not emitted (auth4d-332.md (repository file docs/api-spec/auth4d-332.md)). The hosted window posts it to its opener only.
    • native: the guest session is revoked, guest.upgraded audited and published once, and the intent becomes completable. The answer is 200 {"status": "approved", "identity": {…}} (GuestEmailStepApprovedSchema); no token. The game’s next poll of /guest/upgrade-intent/token returns the token set with the same claims.

    Both answers clear the step cookie.

    Status Body When
    401 {"error":"invalid_session"} The guest session ended or the guest was disabled meanwhile
    403 {"error":"access_denied"} The step still applies
    400 {"error":"expired_token"} Native: the upgrade intent expired meanwhile
    410 {"error":"step_expired"} Replayed, expired, or another browser’s cookie
  • abandon ends the step without upgrading. The guest session keeps working; the linked credential stays on the guest, so linking it again asks again rather than linking it twice. A native intent becomes pending again. The answer is {"redirect_to": "{AUTH_ORIGIN}/login/upgrade?tenant_id=…&client_id=…"} with the step cookie cleared; restart is accepted and ignored. The hosted page shows its methods again in place.

An expired step leaves the guest exactly as an abandoned one does.

Audit and events

The step writes the email_step.* events with the guest’s client_id and the linked method’s idp, and no code or whole address. guest.upgraded is written and published once, only when the upgrade finishes.

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