Files
Josh Creek f628ccfd35 feat(auth): wire production Steam sign-in and the client login flow
newAPIService never supplied SteamLogin, so POST /v1/session/steam
always returned 503 auth_unavailable in production. The only
implementation was cmd/testkit-api's fake, which derives an identity
from the ticket string itself and accepts anything -- so the passing
integration path was neither deployable nor secure. On the client side
the game started with an empty token and a loopback base URL, and no
production code called configure() or login_steam(); the menu entered
matchmaking directly, so every request failed ERR_UNAUTHORIZED before
reaching the network.

Add a real ISteamUserAuth/AuthenticateUserTicket adapter behind an
interface, so the production login path is testable with only the Valve
call stubbed. It rejects family-shared copies (the account playing does
not own the app) and, by default, VAC- or publisher-banned accounts, and
refuses malformed tickets locally rather than forwarding them.

Crucially it separates our faults from the player's: a Valve outage or a
revoked publisher key returns 503, not 401. Answering 401 would tell a
legitimate player their login failed and send them to fix an account
that is fine while the real fault went unnoticed. A banned identity now
returns 403 rather than a misleading 503.

Sign-in is configuration-gated on the publisher key and App ID: without
them the endpoint keeps returning 503, since silently accepting an
unverified ticket would be worse than refusing to authenticate. A
returning player keeps the player ID they already had, so ratings,
penalties and bans follow the account rather than the session.

Client side: acquire a web-API ticket through GodotSteam's async
signal -- requesting one returns a handle, not a ticket -- using the
existing dynamic-call pattern so stock Godot still parses the project.
The endpoint is configurable for release builds, and matchmaking
completes sign-in before it will queue.

Verified against real PostgreSQL; 232 Godot tests pass.
2026-09-05 10:57:50 +01:00

186 lines
6.0 KiB
Go

// 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
}