Files
CosmicClash/server/workload/signed_token_test.go
T
Josh Creek 520613aab0 feat(multiplayer): implement WorkloadVerify without a Kubernetes trust boundary
WorkloadVerify (api.Service.WorkloadVerify) was permanently unwired: both
/v1/servers/{id}/register and /v1/servers/{id}/result always 503, because
the only design considered so far was verifying a Kubernetes-projected
service-account JWT (server/workload/jwt.go), which needs a live cluster's
TokenReview/JWKS endpoint to validate against safely -- something this
sandbox cannot do without guessing at a trust boundary.

The API layer doesn't actually require that specific mechanism. serverMutation
only compares WorkloadBinding.ServerID and .MatchID (server/api/service.go);
AdvanceServerRegistration only uses .MatchID/.ServerID/.AllocationID. Nothing
downstream needs Namespace/ServiceAcct/PodUID/GameServerUID populated.

This adds a self-contained alternative: a short-lived, HMAC-signed token the
control plane mints and verifies with a secret only it holds (server/workload/
signed_token.go), the same trust model domain.SessionStore already uses for
player sessions elsewhere in this codebase. It needs no cluster to verify --
signature + expiry is fully self-contained and unit-testable.

The design's soundness rests on the delivery channel, not the crypto: the
token is meant to reach the allocated GameServer via the same Agones
GameServerAllocation annotation channel allocation.go already uses for
match-id/allocation-id, readable only by that pod's own local SDK sidecar. A
caller presenting this token has already proven, via that channel, that it is
the pod Agones allocated. (Wiring the actual annotation delivery -- extending
agones.Client.Allocate and the supervisor's token source -- is a separate,
follow-up change; this commit lands the verification core it depends on.)

store.AllocationBindingStillValid adds defense-in-depth on top of signature
and expiry: it cross-checks the token's claims against the durable
allocations table (append-only, never leaves 'ALLOCATED'), so a validly-signed
token naming an allocation that was never recorded -- or a real allocation id
paired with a mismatched match/server -- is still rejected.

api.WorkloadVerifierFromSignedToken wires the two together and is now plugged
into cmd/control-plane (new --workload-secret / COSMIC_CLASH_WORKLOAD_SECRET
flag; a startup warning is logged if it's left unset, since the route then
stays 503 exactly as before) and cmd/testkit-api (fixed test secret, since
that binary is test-only already).

Verified: new unit tests in server/workload (signature tamper, wrong secret,
expiry boundary, malformed input) and a new Postgres integration suite in
server/api (real allocation row, real signed token, acceptance / unknown-
allocation rejection / mismatched-triple rejection / the previously-503
Service.WorkloadVerify field itself) -- both run clean with -race across
multiple passes against a live postgres:17-alpine container. Full
`go build ./... && go vet ./... && gofmt -l . && go test ./... -race` and
`go test -tags integration ./... -race` both clean.
2026-09-01 14:51:21 +01:00

101 lines
3.6 KiB
Go

package workload
import (
"errors"
"testing"
"time"
)
func TestSignedWorkloadTokenRoundTrips(t *testing.T) {
secret := []byte("test-secret")
now := time.Unix(1_700_000_000, 0).UTC()
token, err := IssueSignedWorkloadToken(secret, "alloc-1", "match-1", "server-1", now, time.Minute)
if err != nil {
t.Fatalf("issue: %v", err)
}
claims, err := ParseSignedWorkloadToken(secret, token, now.Add(30*time.Second))
if err != nil {
t.Fatalf("parse: %v", err)
}
if claims.AllocationID != "alloc-1" || claims.MatchID != "match-1" || claims.ServerID != "server-1" {
t.Fatalf("unexpected claims: %+v", claims)
}
}
func TestSignedWorkloadTokenRejectsExpiry(t *testing.T) {
secret := []byte("test-secret")
now := time.Unix(1_700_000_000, 0).UTC()
token, err := IssueSignedWorkloadToken(secret, "alloc-1", "match-1", "server-1", now, time.Minute)
if err != nil {
t.Fatalf("issue: %v", err)
}
if _, err := ParseSignedWorkloadToken(secret, token, now.Add(61*time.Second)); !errors.Is(err, ErrTokenExpired) {
t.Fatalf("expected ErrTokenExpired, got %v", err)
}
// Boundary: exactly at expiry must also be rejected (Before, not
// Before-or-equal), matching the proposal-expiry read boundary
// convention used elsewhere in this codebase.
if _, err := ParseSignedWorkloadToken(secret, token, now.Add(time.Minute)); !errors.Is(err, ErrTokenExpired) {
t.Fatalf("expected ErrTokenExpired at the boundary, got %v", err)
}
}
func TestSignedWorkloadTokenRejectsTamperedPayload(t *testing.T) {
secret := []byte("test-secret")
now := time.Unix(1_700_000_000, 0).UTC()
token, err := IssueSignedWorkloadToken(secret, "alloc-1", "match-1", "server-1", now, time.Minute)
if err != nil {
t.Fatalf("issue: %v", err)
}
tampered := token[:len(token)-4] + "AAAA"
if _, err := ParseSignedWorkloadToken(secret, tampered, now); !errors.Is(err, ErrTokenSignature) && !errors.Is(err, ErrMalformedToken) {
t.Fatalf("expected signature/malformed rejection, got %v", err)
}
}
func TestSignedWorkloadTokenRejectsWrongSecret(t *testing.T) {
now := time.Unix(1_700_000_000, 0).UTC()
token, err := IssueSignedWorkloadToken([]byte("secret-a"), "alloc-1", "match-1", "server-1", now, time.Minute)
if err != nil {
t.Fatalf("issue: %v", err)
}
if _, err := ParseSignedWorkloadToken([]byte("secret-b"), token, now); !errors.Is(err, ErrTokenSignature) {
t.Fatalf("expected ErrTokenSignature, got %v", err)
}
}
func TestSignedWorkloadTokenRejectsMalformedInput(t *testing.T) {
secret := []byte("test-secret")
now := time.Unix(1_700_000_000, 0).UTC()
for _, token := range []string{"", "no-dot-here", ".missing-payload", "missing-signature.", "!!!.!!!"} {
if _, err := ParseSignedWorkloadToken(secret, token, now); err == nil {
t.Fatalf("token %q: expected an error, got nil", token)
}
}
}
func TestIssueSignedWorkloadTokenRejectsInvalidInput(t *testing.T) {
now := time.Unix(1_700_000_000, 0).UTC()
cases := []struct {
name string
secret []byte
allocationID string
matchID string
serverID string
now time.Time
ttl time.Duration
}{
{"empty secret", nil, "a", "m", "s", now, time.Minute},
{"empty allocation id", []byte("k"), "", "m", "s", now, time.Minute},
{"empty match id", []byte("k"), "a", "", "s", now, time.Minute},
{"empty server id", []byte("k"), "a", "m", "", now, time.Minute},
{"zero now", []byte("k"), "a", "m", "s", time.Time{}, time.Minute},
{"non-positive ttl", []byte("k"), "a", "m", "s", now, 0},
}
for _, c := range cases {
if _, err := IssueSignedWorkloadToken(c.secret, c.allocationID, c.matchID, c.serverID, c.now, c.ttl); err == nil {
t.Fatalf("%s: expected an error, got nil", c.name)
}
}
}