Steam sign-in: would a first-party provider be considered? (OpenID 2.0 + Steamworks session tickets) · Issue #2849 · supabase/auth · GitHub
Skip to content

Steam sign-in: would a first-party provider be considered? (OpenID 2.0 + Steamworks session tickets) #2849

Open

Description

@SasoriOlkof

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_setup redirect → 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 GetAuthTicketForWebApi and the server validates with ISteamUserAuth/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_setup and identifier_select, carrying the state in openid.return_to.
  • On callback it checks openid.mode, pins claimed_id to the Steam host by regexp, requires identity == claimed_id, requires return_to to match our own callback and state, requires the security-relevant fields to appear in openid.signed, and only then calls check_authentication against the configured endpoint, never the op_endpoint echoed 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, DetermineAccountLinking always 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, and check_authentication sent 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, http and https claimed ids, forged claimed_id and foreign return_to rejected 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 lint clean (vet, staticcheck, gosec, govulncheck).
  • End-to-end against real Steam on a self-hosted deployment, which is the check CONTRIBUTING.md asks for: /authorize?provider=steam → Steam sign-in → callback → redirect to the allowed URL with access_token and refresh_token; the JWT carries provider: steam, provider_id and sub equal to the SteamID64, and amr: 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_ID and REQUIRED_APP_ID are single values.
  • The native grant and the ownership gate need a publisher key. The browser sign-in needs no key at all.
  • No provider_token to pass through: Steam issues nothing of the sort.

Questions

  1. Does Steam qualify as the "does not follow the spec" exception, or is the no-new-providers policy absolute for now?
  2. 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?
  3. Should the Steamworks-specific parts (session-ticket grant, ownership gate) stay out of a first PR, leaving only browser sign-in?
  4. 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions