// Package steam adapts Valve's publisher Web API to the control plane's // SteamLoginProvider. It is the only place that talks to Valve, so the rest of // the service stays testable without network access or a publisher key. package steam import ( "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strconv" "strings" "time" ) // AuthenticateUserTicketURL is the publisher endpoint. Only the backend may // call it: it requires the publisher key, which must never reach a client. const AuthenticateUserTicketURL = "https://partner.steam-api.com/ISteamUserAuth/AuthenticateUserTicket/v1/" // MaxTicketBytes bounds what will be forwarded to Valve. A web-API ticket is a // few hundred hex characters; anything larger is abuse, not a ticket. const MaxTicketBytes = 4096 var ( // ErrTicketRejected is returned for any ticket Valve does not accept, and // for a ticket issued for another application. It deliberately does not // distinguish those cases to the caller. ErrTicketRejected = fmt.Errorf("steam ticket rejected") // ErrUnavailable separates "Valve is down or misconfigured" from "this // player's ticket is bad", so the API can answer 503 rather than telling a // legitimate player their login failed. ErrUnavailable = fmt.Errorf("steam authentication is unavailable") ) // Identity is what a verified ticket proves. It is deliberately not // domain.VerifiedIdentity: this package resolves a Steam ID, and mapping that // onto a durable player ID is the caller's business. type Identity struct { SteamID string OwnerSteamID string VACBanned bool PublisherBan bool } // WebAPIVerifier calls Valve's publisher API. Construct it only when a // publisher key and App ID are configured; the control plane leaves its login // provider unset otherwise, which surfaces as an explicit 503. type WebAPIVerifier struct { PublisherKey string AppID uint64 HTTP *http.Client // Endpoint overrides the Valve URL in tests. Production leaves it empty. Endpoint string // RejectBanned refuses a VAC- or publisher-banned account at login. RejectBanned bool } func (v WebAPIVerifier) validate() error { if v.PublisherKey == "" || v.AppID == 0 { return ErrUnavailable } return nil } func (v WebAPIVerifier) endpoint() string { if v.Endpoint != "" { return v.Endpoint } return AuthenticateUserTicketURL } func (v WebAPIVerifier) httpClient() *http.Client { if v.HTTP != nil { return v.HTTP } return &http.Client{Timeout: 10 * time.Second} } // authenticateResponse is Valve's shape. Fields absent from a failure response // stay zero, which the result check below rejects. type authenticateResponse struct { Response struct { Params struct { Result string `json:"result"` SteamID string `json:"steamid"` OwnerSteamID string `json:"ownersteamid"` VACBanned bool `json:"vacbanned"` PublisherBanned bool `json:"publisherbanned"` } `json:"params"` Error *struct { ErrorCode int `json:"errorcode"` ErrorDesc string `json:"errordesc"` } `json:"error"` } `json:"response"` } // Verify exchanges a client-supplied web-API ticket for a Steam identity. // // The ticket is single-use at Valve's end and the client never gets to choose // the resulting Steam ID, which is the property that makes this the fix for // slot reclaim being keyed on a display name. func (v WebAPIVerifier) Verify(ctx context.Context, ticket string) (Identity, error) { if err := v.validate(); err != nil { return Identity{}, err } ticket = strings.TrimSpace(ticket) if ticket == "" || len(ticket) > MaxTicketBytes || !isHex(ticket) { // Rejected locally: a malformed ticket is never worth a round trip, // and this bounds what an unauthenticated caller can make us forward. return Identity{}, ErrTicketRejected } query := url.Values{} query.Set("key", v.PublisherKey) query.Set("appid", strconv.FormatUint(v.AppID, 10)) query.Set("ticket", ticket) request, err := http.NewRequestWithContext(ctx, http.MethodGet, v.endpoint()+"?"+query.Encode(), nil) if err != nil { return Identity{}, ErrUnavailable } response, err := v.httpClient().Do(request) if err != nil { return Identity{}, ErrUnavailable } defer response.Body.Close() // Bounded read: this is a third-party response and must not be able to // exhaust memory. body, err := io.ReadAll(io.LimitReader(response.Body, 1<<20)) if err != nil { return Identity{}, ErrUnavailable } if response.StatusCode == http.StatusForbidden || response.StatusCode == http.StatusUnauthorized { // Our publisher key is wrong or revoked. That is our problem, not the // player's, so it must not read as a rejected ticket. return Identity{}, ErrUnavailable } if response.StatusCode < 200 || response.StatusCode >= 300 { return Identity{}, ErrUnavailable } var decoded authenticateResponse if err := json.Unmarshal(body, &decoded); err != nil { return Identity{}, ErrUnavailable } if decoded.Response.Error != nil || !strings.EqualFold(decoded.Response.Params.Result, "OK") { return Identity{}, ErrTicketRejected } identity := Identity{ SteamID: decoded.Response.Params.SteamID, OwnerSteamID: decoded.Response.Params.OwnerSteamID, VACBanned: decoded.Response.Params.VACBanned, PublisherBan: decoded.Response.Params.PublisherBanned, } if !isSteamID(identity.SteamID) { return Identity{}, ErrTicketRejected } if identity.OwnerSteamID != "" && identity.OwnerSteamID != identity.SteamID { // Family sharing: the account playing does not own the app. Treat it // as a rejection rather than silently matchmaking a borrowed copy. return Identity{}, ErrTicketRejected } if v.RejectBanned && (identity.VACBanned || identity.PublisherBan) { return Identity{}, ErrTicketRejected } return identity, nil } func isHex(value string) bool { for _, r := range value { switch { case r >= '0' && r <= '9', r >= 'a' && r <= 'f', r >= 'A' && r <= 'F': default: return false } } return true } func isSteamID(value string) bool { if len(value) < 17 || len(value) > 20 { return false } parsed, err := strconv.ParseUint(value, 10, 64) return err == nil && parsed > 0 }