Steam sign-in: would a first-party provider be considered? (OpenID 2.0 + Steamworks session tickets) #2849
Description
What I'm asking
CONTRIBUTING.md currently declines new OAuth provider contributions because they are intended to be covered by webhooks or a generic provider, and a feat PR needs an RFC or a detailed design first. supabase/rfcs is archived, and #1919 was closed asking for an issue before a PR — so this is that issue.
The implementation is finished, tested and running on a self-hosted deployment. I would rather have a maintainer decision before opening a PR than drop ~1.7k lines on you unannounced. If the answer is "not now", I'll keep it in my fork and that is a fine outcome.
Why the generic custom provider cannot cover Steam
CustomOAuthProvider and CustomOIDCProvider are built on golang.org/x/oauth2 and go-oidc: they need an authorization endpoint plus a token endpoint, and for OIDC a discovery document and userinfo.
Steam has none of that:
- It implements OpenID 2.0 only:
checkid_setupredirect → signed assertion →check_authentication. No token endpoint, no discovery, no userinfo, no JWTs. - The assertion carries exactly one fact: the SteamID64 in
claimed_id. There is no email, ever, and no way to request one. - Native game clients never open a browser. They call Steamworks
GetAuthTicketForWebApiand the server validates withISteamUserAuth/AuthenticateUserTicket.
So this is not a provider that generic OAuth2/OIDC configuration can eventually absorb — it is the case #1919 described as "sites which do not follow the spec". Steam is also unlikely to move: it has stayed on OpenID 2.0 for well over a decade while the rest of the ecosystem left it behind.
What the implementation does
Diff against current master: master...SasoriOlkof:auth:feat/steam-auth — 4 commits, 20 files, rebased on master and green. Branch: https://github.com/SasoriOlkof/auth/tree/feat/steam-auth
Browser flow, GET /authorize?provider=steam:
- Redirects with
checkid_setupandidentifier_select, carrying the state inopenid.return_to. - On callback it checks
openid.mode, pinsclaimed_idto the Steam host by regexp, requiresidentity == claimed_id, requiresreturn_toto match our own callback and state, requires the security-relevant fields to appear inopenid.signed, and only then callscheck_authenticationagainst the configured endpoint, never theop_endpointechoed back in the response. - The identity is keyed on SteamID64 with no email (
EmailOptional).
Native flow, POST /token?grant_type=steam_ticket (modelled on web3.go): the ticket is validated as bounded hexadecimal before any network call, then verified with AuthenticateUserTicket using the server's own app id and identity string.
Identity linking, POST /user/identities/steam-ticket: attaches a Steam identity to the authenticated user while keeping the user id, so an existing anonymous/device account keeps its data. Idempotent for the same user, 409 steam_identity_already_linked when that SteamID already belongs to someone else, never auto-merges. Requires manual linking to be enabled.
Optional ownership gate: ISteamUser/CheckAppOwnership with a publisher key, fail-closed, with REQUIRE_PERMANENT to reject Family Sharing, free weekends and PC cafés.
Configuration:
GOTRUE_EXTERNAL_STEAM_ENABLED
GOTRUE_EXTERNAL_STEAM_REDIRECT_URI
GOTRUE_EXTERNAL_STEAM_PUBLISHER_KEY # optional; only for ownership, profile and native tickets
GOTRUE_EXTERNAL_STEAM_APP_ID # with TICKET_IDENTITY, enables the native grant
GOTRUE_EXTERNAL_STEAM_TICKET_IDENTITY
GOTRUE_EXTERNAL_STEAM_REQUIRED_APP_ID # optional ownership gate
GOTRUE_EXTERNAL_STEAM_REQUIRE_PERMANENT # default false
Security properties
- Because Steam never provides an email,
DetermineAccountLinkingalways creates a new account: a Steam identity cannot take over an existing user by email. This is the main reason the provider adds no new account-takeover surface. - The verification anchor never comes from attacker-influenced data: host-pinned
claimed_id, required signed fields, andcheck_authenticationsent to the configured endpoint. Replay is covered by the existing single-use flow state. - The publisher key lives only on the server. The app id, identity string and SteamID sent by a client are never trusted; the ticket is always validated against the server's configured app.
- Authentication and authorization fail closed. Profile enrichment (persona name, avatar) is best effort and falls back to a minimal profile, so a Steam Web API outage cannot block sign-in.
Testing
- 39 tests against a fake Steam server built to the documented request and response shapes, covering: sign-up, existing user with
DisableSignup,httpandhttpsclaimed ids, forgedclaimed_idand foreignreturn_torejected before any network call, unsigned fields,is_valid:false, user cancellation, callback replay, PKCE, profile fallbacks, the ownership gate (object and array response shapes, permanent vs temporary licences, not owned, fail-closed), the ticket grant and identity linking. make lintclean (vet, staticcheck, gosec, govulncheck).- End-to-end against real Steam on a self-hosted deployment, which is the check
CONTRIBUTING.mdasks for:/authorize?provider=steam→ Steam sign-in → callback → redirect to the allowed URL withaccess_tokenandrefresh_token; the JWT carriesprovider: steam,provider_idandsubequal to the SteamID64, andamr: oauth. The browser login reused the same user that a native ticket login had created months earlier, so both flows converge on a single identity.
Known scope limits
- One Steam app per project:
APP_IDandREQUIRED_APP_IDare single values. - The native grant and the ownership gate need a publisher key. The browser sign-in needs no key at all.
- No
provider_tokento pass through: Steam issues nothing of the sort.
Questions
- Does Steam qualify as the "does not follow the spec" exception, or is the no-new-providers policy absolute for now?
- If it qualifies, do you prefer a built-in provider, or an extension point — for example OpenID 2.0 as a protocol option within the custom provider configuration?
- Should the Steamworks-specific parts (session-ticket grant, ownership gate) stay out of a first PR, leaving only browser sign-in?
- Any preference on configuration naming before I open the PR?
Prior art: #1919 (closed draft) and the long-standing request in https://github.com/orgs/supabase/discussions/4500
Activity