mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-11 17:23:44 +00:00
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.
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
package steam
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const validTicket = "140000008bc0a1f45fd4b4b7e0af2c4a01001001"
|
||||
|
||||
func stubValve(t *testing.T, status int, body string, inspect func(*http.Request)) WebAPIVerifier {
|
||||
t.Helper()
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if inspect != nil {
|
||||
inspect(r)
|
||||
}
|
||||
w.WriteHeader(status)
|
||||
_, _ = w.Write([]byte(body))
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
return WebAPIVerifier{PublisherKey: "publisher-key", AppID: 480, Endpoint: server.URL, HTTP: server.Client()}
|
||||
}
|
||||
|
||||
func TestVerifyReturnsIdentityForAnAcceptedTicket(t *testing.T) {
|
||||
var seen *http.Request
|
||||
verifier := stubValve(t, http.StatusOK,
|
||||
`{"response":{"params":{"result":"OK","steamid":"76561198000000001","ownersteamid":"76561198000000001","vacbanned":false,"publisherbanned":false}}}`,
|
||||
func(r *http.Request) { seen = r })
|
||||
identity, err := verifier.Verify(context.Background(), validTicket)
|
||||
if err != nil {
|
||||
t.Fatalf("verify: %v", err)
|
||||
}
|
||||
if identity.SteamID != "76561198000000001" {
|
||||
t.Fatalf("identity = %+v", identity)
|
||||
}
|
||||
// The publisher key must be sent to Valve and nowhere else; assert it is
|
||||
// carried in the request rather than, say, logged or returned.
|
||||
if seen.URL.Query().Get("key") != "publisher-key" || seen.URL.Query().Get("appid") != "480" {
|
||||
t.Fatalf("request query = %s", seen.URL.RawQuery)
|
||||
}
|
||||
if seen.URL.Query().Get("ticket") != validTicket {
|
||||
t.Fatalf("ticket was not forwarded verbatim: %s", seen.URL.Query().Get("ticket"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyRejectsTicketsValveDoesNotAccept(t *testing.T) {
|
||||
for name, body := range map[string]string{
|
||||
"explicit failure": `{"response":{"params":{"result":"Failure","steamid":"76561198000000001"}}}`,
|
||||
"error object": `{"response":{"error":{"errorcode":101,"errordesc":"Invalid ticket"}}}`,
|
||||
"empty response": `{"response":{}}`,
|
||||
"no steam id": `{"response":{"params":{"result":"OK"}}}`,
|
||||
"bogus steam id": `{"response":{"params":{"result":"OK","steamid":"not-a-steam-id"}}}`,
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
verifier := stubValve(t, http.StatusOK, body, nil)
|
||||
if _, err := verifier.Verify(context.Background(), validTicket); !errors.Is(err, ErrTicketRejected) {
|
||||
t.Fatalf("err = %v, want ErrTicketRejected", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyRejectsFamilySharedAndBannedAccounts(t *testing.T) {
|
||||
shared := stubValve(t, http.StatusOK,
|
||||
`{"response":{"params":{"result":"OK","steamid":"76561198000000002","ownersteamid":"76561198000000001"}}}`, nil)
|
||||
if _, err := shared.Verify(context.Background(), validTicket); !errors.Is(err, ErrTicketRejected) {
|
||||
t.Fatalf("family-shared copy accepted: %v", err)
|
||||
}
|
||||
|
||||
banned := stubValve(t, http.StatusOK,
|
||||
`{"response":{"params":{"result":"OK","steamid":"76561198000000001","ownersteamid":"76561198000000001","vacbanned":true}}}`, nil)
|
||||
banned.RejectBanned = true
|
||||
if _, err := banned.Verify(context.Background(), validTicket); !errors.Is(err, ErrTicketRejected) {
|
||||
t.Fatalf("VAC-banned account accepted: %v", err)
|
||||
}
|
||||
banned.RejectBanned = false
|
||||
if _, err := banned.Verify(context.Background(), validTicket); err != nil {
|
||||
t.Fatalf("ban enforcement should be configurable: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A Valve outage or a revoked publisher key must not read as "this player's
|
||||
// ticket is bad", or a legitimate player is told to fix an account that is
|
||||
// fine while the real fault goes unnoticed.
|
||||
func TestVerifyDistinguishesOurFaultsFromBadTickets(t *testing.T) {
|
||||
for name, status := range map[string]int{
|
||||
"revoked publisher key": http.StatusForbidden,
|
||||
"unauthorized": http.StatusUnauthorized,
|
||||
"valve error": http.StatusInternalServerError,
|
||||
"valve gateway": http.StatusBadGateway,
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
verifier := stubValve(t, status, `{}`, nil)
|
||||
if _, err := verifier.Verify(context.Background(), validTicket); !errors.Is(err, ErrUnavailable) {
|
||||
t.Fatalf("err = %v, want ErrUnavailable", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("malformed response", func(t *testing.T) {
|
||||
verifier := stubValve(t, http.StatusOK, `not json`, nil)
|
||||
if _, err := verifier.Verify(context.Background(), validTicket); !errors.Is(err, ErrUnavailable) {
|
||||
t.Fatalf("err = %v, want ErrUnavailable", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestVerifyRefusesMalformedTicketsWithoutCallingValve(t *testing.T) {
|
||||
called := false
|
||||
verifier := stubValve(t, http.StatusOK, `{}`, func(*http.Request) { called = true })
|
||||
for name, ticket := range map[string]string{
|
||||
"empty": "",
|
||||
"whitespace": " ",
|
||||
"not hex": "zzzz-not-a-ticket",
|
||||
"oversized": strings.Repeat("a", MaxTicketBytes+1),
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, err := verifier.Verify(context.Background(), ticket); !errors.Is(err, ErrTicketRejected) {
|
||||
t.Fatalf("err = %v, want ErrTicketRejected", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
if called {
|
||||
t.Fatal("a malformed ticket was forwarded to Valve")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyIsUnavailableWithoutCredentials(t *testing.T) {
|
||||
for name, verifier := range map[string]WebAPIVerifier{
|
||||
"no key": {AppID: 480},
|
||||
"no app id": {PublisherKey: "publisher-key"},
|
||||
"neither": {},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, err := verifier.Verify(context.Background(), validTicket); !errors.Is(err, ErrUnavailable) {
|
||||
t.Fatalf("err = %v, want ErrUnavailable", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user