AUTH4D-123 Steam sign-in

Steam sign-in is a hosted browser flow in which Steam acts as an OpenID 2.0 provider and auth4.dev is the relying party. Steam needs no client ID or secret and returns no email address, so a user created through Steam has no email until they link one. The identity key is the player’s SteamID64.

Start and callback

GET /t/{tenantId}/identity/steam/start?client_id={clientId}&browser_flow_id={flowId} requires the __Host-auth4_flow_{flowId} HttpOnly browser cookie issued by /oauth/authorize (or by a guest linking flow). As with Discord, the API consumes and restores the tenant-scoped authorization-flow record to check its tenant, client and cookie binding, and ignores caller-supplied redirect URIs, scopes and state. The tenant must be active, the client enabled for authorization code (native clients only inside a guest-linking flow), and Steam enabled for the project. If Steam is not enabled the start route answers 403 with access_denied.

The API redirects to https://steamcommunity.com/openid/login with openid.mode=checkid_setup, identifier_select for both openid.identity and openid.claimed_id, openid.realm set to the auth origin (for example https://auth.auth4.dev/), and openid.return_to set to GET /t/{tenantId}/identity/steam/callback?state={state}. The state is a 256-bit random value stored only as a hash in the tenant’s challenge Durable Object, bound to the authorization flow, client, exact return_to and the hash of the browser cookie. It expires no later than the flow.

GET /t/{tenantId}/identity/steam/callback accepts state plus the openid.* parameters Steam sends (ns, mode, op_endpoint, claimed_id, identity, return_to, response_nonce, assoc_handle, signed, sig, invalidate_handle, error), each at most once; anything else is 400 invalid_request. The state record is consumed first, so every callback URL is single-use. Missing, expired, tenant-mismatched and browser-mismatched state fails closed (400 or 403).

A positive assertion is accepted only when all of these hold:

  • openid.ns is the OpenID 2.0 namespace, openid.mode is id_res and openid.op_endpoint is https://steamcommunity.com/openid/login;
  • openid.return_to equals the stored return_to exactly, so it is on the auth origin and carries this flow’s state;
  • openid.claimed_id matches ^https://steamcommunity\.com/openid/id/(7656119\d{10})$ and openid.identity equals it;
  • openid.signed covers op_endpoint, claimed_id, identity, return_to, response_nonce and assoc_handle, and the response nonce is no older than the browser-flow lifetime;
  • Steam answers is_valid:true when the Worker posts the assertion back to https://steamcommunity.com/openid/login with openid.mode=check_authentication (5-second timeout, redirects refused);
  • the SteamID64 and response nonce have not been accepted before. The nonce is recorded with an atomic put-if-absent in the tenant’s challenge Durable Object.

A tampered, unconfirmed or replayed assertion returns to /login with error=access_denied. A cancelled sign-in (openid.mode=cancel) is also access_denied. A Steam timeout, network failure, non-200 answer or unexpected body, or Steam being switched off while the player was away, returns error=temporarily_unavailable. Error responses never include Steam’s response or the assertion.

On success the SteamID64 resolves to the tenant’s existing steam identity or creates a new user with one, and the callback returns to /login with a one-time proof_id and proof_purpose=steam_login. The hosted login passes it to /oauth/authorize/continue, which now accepts proof_purpose steam_login alongside email_verification and discord_login.

Guest upgrade

Inside a guest-linking flow, a steam_login proof can be passed to the guest upgrade endpoints (/t/{tenantId}/guest/upgrade and /t/{tenantId}/guest/upgrade-intent/complete) like an email or Discord proof. The same rule applies: only a Steam account created inside this linking flow moves to the guest, keeping the guest’s user ID. A SteamID64 that already belongs to another user gives 409 identity_in_use, and accounts are never merged.

Project setting and login context

Project owners enable Steam with PATCH /management/tenants/{tenantId} {"providers": {"steam": {"enabled": true}}}; the project view reports settings.providers.steam.enabled, and each change writes a management.provider.updated audit event with provider: "steam". The setting is stored in project_settings.steam_enabled (migration 0011), not in provider_configurations, because Steam has no credentials. GET /t/{tenantId}/login/context adds "steam": true to login_methods only when Steam is offered; otherwise the key is absent and the response is unchanged.

Migration 0011 rebuilds identities to allow provider = 'steam' (columns, keys and indexes are unchanged; a Steam subject must be a 17-digit SteamID64 with no email) and adds project_settings.steam_enabled, which defaults to 0.

Local development can point Steam at a loopback stand-in with STEAM_LOCAL_MOCK_ORIGIN, which the Worker honours only when RUNTIME_ENV is local. Tests stub check_authentication and never contact Steam.

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