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.
This commit is contained in:
Josh Creek
2026-09-01 14:51:21 +01:00
parent 330f99bb0e
commit 520613aab0
8 changed files with 497 additions and 14 deletions
+38
View File
@@ -3,10 +3,12 @@ package api
import (
"context"
"database/sql"
"fmt"
"time"
"github.com/cosmic-clash/cosmic-clash/server/domain"
"github.com/cosmic-clash/cosmic-clash/server/store"
"github.com/cosmic-clash/cosmic-clash/server/workload"
)
// AssignmentProviderFromStore adapts the durable player-scoped assignment
@@ -71,3 +73,39 @@ func ServerRegistrarFromStore(db *sql.DB) ServerRegistrar {
}
return postgresServerRegistrar{db: db}
}
// WorkloadVerifierFromSignedToken builds WorkloadVerify from a control-plane
// -owned signed token instead of a Kubernetes-projected JWT (see
// workload/signed_token.go for why: it needs no live cluster to verify).
// secret must be kept out of source control (env var in cmd/control-plane);
// an empty secret returns nil so a misconfigured deployment fails the same
// way an unwired verifier already does today (503, not a silent bypass).
func WorkloadVerifierFromSignedToken(secret []byte, db *sql.DB) WorkloadVerifier {
if len(secret) == 0 || db == nil {
return nil
}
return func(token string, now time.Time) (domain.WorkloadBinding, error) {
claims, err := workload.ParseSignedWorkloadToken(secret, token, now)
if err != nil {
return domain.WorkloadBinding{}, err
}
// WorkloadVerifier has no context parameter (see its type in
// service.go) so the durable cross-check below cannot inherit the
// caller's request context; bound it locally instead of running
// unbounded against context.Background().
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
ok, err := store.AllocationBindingStillValid(ctx, db, claims.AllocationID, claims.MatchID, claims.ServerID)
if err != nil {
return domain.WorkloadBinding{}, err
}
if !ok {
return domain.WorkloadBinding{}, fmt.Errorf("signed workload token names an allocation that is no longer valid")
}
return domain.WorkloadBinding{
AllocationID: claims.AllocationID,
MatchID: claims.MatchID,
ServerID: claims.ServerID,
}, nil
}
}
@@ -0,0 +1,168 @@
//go:build integration
package api
import (
"context"
"database/sql"
"os"
"path/filepath"
"testing"
"time"
"github.com/cosmic-clash/cosmic-clash/server/domain"
"github.com/cosmic-clash/cosmic-clash/server/migrations"
"github.com/cosmic-clash/cosmic-clash/server/store"
"github.com/cosmic-clash/cosmic-clash/server/workload"
_ "github.com/jackc/pgx/v5/stdlib"
)
// This binary is deliberately opt-in, matching store's integration suite: it
// requires a disposable PostgreSQL instance supplied by
// scripts/run_postgres_integration.sh.
func openIntegrationPostgres(t *testing.T) *sql.DB {
t.Helper()
dsn := os.Getenv("COSMIC_CLASH_POSTGRES_DSN")
if dsn == "" {
t.Skip("COSMIC_CLASH_POSTGRES_DSN is not set")
}
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatalf("open PostgreSQL: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := db.PingContext(ctx); err != nil {
db.Close()
t.Fatalf("ping PostgreSQL: %v", err)
}
t.Cleanup(func() { db.Close() })
migrationDir := os.Getenv("COSMIC_CLASH_MIGRATIONS_DIR")
if migrationDir == "" {
migrationDir = filepath.Join("..", "migrations")
}
if err := migrations.Apply(ctx, db, migrationDir); err != nil {
t.Fatalf("apply migrations: %v", err)
}
return db
}
// seedRealAllocation claims a real ready server and allocation row, exactly
// the durable state a signed workload token must later be cross-checked
// against (see store.AllocationBindingStillValid).
func seedRealAllocation(t *testing.T, db *sql.DB, allocationID, matchID string, now time.Time) domain.Allocation {
t.Helper()
ctx := context.Background()
serverID := "server-" + allocationID
if err := store.RegisterReadyServer(ctx, db, domain.ReadyServer{ServerID: serverID, Region: "EU", Build: "build-1", Protocol: 1, Transport: "enet", State: domain.ServerReady}, now); err != nil {
t.Fatalf("register ready server: %v", err)
}
allocation, err := store.ClaimAllocation(ctx, db, domain.AllocationRequest{AllocationID: allocationID, MatchID: matchID, Region: "EU", Build: "build-1", Protocol: 1, Transport: "enet"}, now)
if err != nil {
t.Fatalf("claim allocation: %v", err)
}
return allocation
}
// TestWorkloadVerifierFromSignedTokenAcceptsARealAllocation proves the full
// wired path: a token issued by workload.IssueSignedWorkloadToken for a real
// allocation row verifies successfully through
// WorkloadVerifierFromSignedToken and returns a binding matching what
// serverMutation actually checks (ServerID, MatchID). This is the "wired,
// working" counterpart to cmd/control-plane's
// TestServerRoutesRequireWorkloadVerifyToBeWired, which pins the
// unconfigured-503 case.
func TestWorkloadVerifierFromSignedTokenAcceptsARealAllocation(t *testing.T) {
db := openIntegrationPostgres(t)
now := time.Now().UTC()
allocation := seedRealAllocation(t, db, "alloc-verify-1", "match-verify-1", now)
secret := []byte("integration-test-secret")
token, err := workload.IssueSignedWorkloadToken(secret, allocation.AllocationID, allocation.MatchID, allocation.ServerID, now, time.Minute)
if err != nil {
t.Fatalf("issue token: %v", err)
}
verify := WorkloadVerifierFromSignedToken(secret, db)
if verify == nil {
t.Fatal("WorkloadVerifierFromSignedToken returned nil with a real secret and database")
}
binding, err := verify(token, now.Add(30*time.Second))
if err != nil {
t.Fatalf("verify: %v", err)
}
if binding.ServerID != allocation.ServerID || binding.MatchID != allocation.MatchID || binding.AllocationID != allocation.AllocationID {
t.Fatalf("unexpected binding: %+v, want server=%s match=%s allocation=%s", binding, allocation.ServerID, allocation.MatchID, allocation.AllocationID)
}
}
// TestWorkloadVerifierFromSignedTokenRejectsAnUnknownAllocation proves the
// durable cross-check actually runs: a validly-signed, unexpired token whose
// allocation was never recorded (e.g. superseded, or simply fabricated) must
// still be rejected. Signature and expiry checks alone are not enough.
func TestWorkloadVerifierFromSignedTokenRejectsAnUnknownAllocation(t *testing.T) {
db := openIntegrationPostgres(t)
now := time.Now().UTC()
secret := []byte("integration-test-secret")
token, err := workload.IssueSignedWorkloadToken(secret, "alloc-never-recorded", "match-never-recorded", "server-never-recorded", now, time.Minute)
if err != nil {
t.Fatalf("issue token: %v", err)
}
verify := WorkloadVerifierFromSignedToken(secret, db)
if _, err := verify(token, now.Add(time.Second)); err == nil {
t.Fatal("expected rejection for a token naming an allocation that was never recorded")
}
}
// TestWorkloadVerifierFromSignedTokenRejectsAMismatchedTriple proves the
// cross-check binds all three identifiers together, not each independently:
// a real allocation's own allocation_id combined with someone else's
// match/server must still fail.
func TestWorkloadVerifierFromSignedTokenRejectsAMismatchedTriple(t *testing.T) {
db := openIntegrationPostgres(t)
now := time.Now().UTC()
allocation := seedRealAllocation(t, db, "alloc-verify-2", "match-verify-2", now)
secret := []byte("integration-test-secret")
token, err := workload.IssueSignedWorkloadToken(secret, allocation.AllocationID, "a-different-match", allocation.ServerID, now, time.Minute)
if err != nil {
t.Fatalf("issue token: %v", err)
}
verify := WorkloadVerifierFromSignedToken(secret, db)
if _, err := verify(token, now.Add(time.Second)); err == nil {
t.Fatal("expected rejection for a real allocation id paired with the wrong match id")
}
}
// TestWorkloadVerifierFromSignedTokenClosesTheDefaultUnwiredGap proves the
// 503-by-default gap pinned by
// cmd/control-plane.TestServerRoutesRequireWorkloadVerifyToBeWired is
// actually closed once a secret and database are wired: a Service built the
// same way newAPIHandler builds one now accepts a validly-issued token for a
// real allocation, through the exact Service.WorkloadVerify field the HTTP
// handler calls. (serverMutation's deeper match-state transition --
// requiring the match to already be ALLOCATING -- is exercised separately by
// the store package's own allocation/match integration tests; this test's
// job is only the WorkloadVerify boundary itself.)
func TestWorkloadVerifierFromSignedTokenClosesTheDefaultUnwiredGap(t *testing.T) {
db := openIntegrationPostgres(t)
now := time.Now().UTC()
allocation := seedRealAllocation(t, db, "alloc-verify-3", "match-verify-3", now)
secret := []byte("integration-test-secret")
token, err := workload.IssueSignedWorkloadToken(secret, allocation.AllocationID, allocation.MatchID, allocation.ServerID, now, time.Minute)
if err != nil {
t.Fatalf("issue token: %v", err)
}
svc := &Service{
ServerRegistrar: ServerRegistrarFromStore(db),
WorkloadVerify: WorkloadVerifierFromSignedToken(secret, db),
Now: func() time.Time { return now.Add(time.Second) },
}
binding, err := svc.WorkloadVerify(token, now.Add(time.Second))
if err != nil {
t.Fatalf("WorkloadVerify rejected a validly-issued token for a real allocation: %v", err)
}
if binding.ServerID != allocation.ServerID {
t.Fatalf("binding.ServerID = %q, want %q", binding.ServerID, allocation.ServerID)
}
}
+7 -2
View File
@@ -27,6 +27,7 @@ func main() {
redisAddr := flag.String("redis-addr", os.Getenv("COSMIC_CLASH_REDIS_ADDR"), "optional Redis address for the candidate projection")
redisPrefix := flag.String("redis-prefix", envOrDefault("COSMIC_CLASH_REDIS_PREFIX", "cosmic-clash"), "Redis key prefix")
redisTTL := flag.Duration("redis-ttl", 60*time.Second, "TTL for transient candidate projection entries")
workloadSecret := flag.String("workload-secret", os.Getenv("COSMIC_CLASH_WORKLOAD_SECRET"), "HMAC secret for control-plane-issued workload tokens (see workload/signed_token.go); server registration/result submission return 503 until this is set")
flag.Parse()
if *role != "api" {
fatalf("unsupported role %q (only api is implemented)", *role)
@@ -57,7 +58,10 @@ func main() {
defer redisClient.Close()
candidateIndex = store.RedisCandidateIndex{Client: redisClient, Prefix: *redisPrefix, TTL: *redisTTL}
}
server := &http.Server{Addr: *listen, Handler: newAPIHandler(db, candidateIndex), ReadHeaderTimeout: 5 * time.Second}
if *workloadSecret == "" {
fmt.Fprintln(os.Stderr, "control-plane: warning: --workload-secret / COSMIC_CLASH_WORKLOAD_SECRET is unset; server registration and result submission will return 503")
}
server := &http.Server{Addr: *listen, Handler: newAPIHandler(db, *workloadSecret, candidateIndex), ReadHeaderTimeout: 5 * time.Second}
serveErr := make(chan error, 1)
go func() { serveErr <- server.ListenAndServe() }()
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
@@ -76,7 +80,7 @@ func main() {
}
}
func newAPIHandler(db *sql.DB, indexes ...api.CandidateIndex) http.Handler {
func newAPIHandler(db *sql.DB, workloadSecret string, indexes ...api.CandidateIndex) http.Handler {
var candidateIndex api.CandidateIndex
if len(indexes) > 0 {
candidateIndex = indexes[0]
@@ -93,6 +97,7 @@ func newAPIHandler(db *sql.DB, indexes ...api.CandidateIndex) http.Handler {
Assignment: api.AssignmentProviderFromStore(db),
CandidateIndex: candidateIndex,
ProbeRecorder: store.PostgresQueue{DB: db},
WorkloadVerify: api.WorkloadVerifierFromSignedToken([]byte(workloadSecret), db),
Now: func() time.Time { return time.Now().UTC() },
Log: logEvent,
}).Handler()
+13 -12
View File
@@ -9,24 +9,25 @@ import (
func TestAPIHandlerExposesHealthWithoutDatabase(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
rec := httptest.NewRecorder()
newAPIHandler(nil).ServeHTTP(rec, req)
newAPIHandler(nil, "").ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("health status = %d", rec.Code)
}
}
// TestServerRoutesRequireWorkloadVerifyToBeWired pins a real, known gap
// rather than leaving it silent: newAPIHandler wires ServerRegistrar and
// ResultSubmitter, but never a WorkloadVerify -- and Service.serverMutation
// treats a nil WorkloadVerify as fatal for BOTH the register and result
// routes, regardless of whether their own dependency is present. So today,
// in the actual running binary, POST /v1/servers/{id}/register and
// /v1/servers/{id}/result both always 503, independent of a real database or
// real request. This test should start failing (and be updated, not
// deleted) the day a real WorkloadVerify is wired -- that's the intended
// signal, not a bug in the test.
// TestServerRoutesRequireWorkloadVerifyToBeWired pins the deployment
// misconfiguration case: newAPIHandler wires ServerRegistrar and
// ResultSubmitter, but WorkloadVerifierFromSignedToken deliberately returns
// nil whenever the secret or the database is missing (see
// api.WorkloadVerifierFromSignedToken) rather than silently accepting every
// caller. Service.serverMutation treats a nil WorkloadVerify as fatal for
// BOTH the register and result routes. This test should start failing (and
// be updated, not deleted) the day this path stops 503ing with an empty
// secret and a nil database -- that's the intended signal, not a bug in the
// test. See TestWorkloadVerifierFromSignedTokenAcceptsARealAllocation in the
// api package's Postgres integration suite for the wired, working path.
func TestServerRoutesRequireWorkloadVerifyToBeWired(t *testing.T) {
handler := newAPIHandler(nil)
handler := newAPIHandler(nil, "")
for _, path := range []string{"/v1/servers/server-1/register", "/v1/servers/server-1/result"} {
req := httptest.NewRequest(http.MethodPost, path, nil)
req.Header.Set("Idempotency-Key", "regression-pin-key-123456")
+9
View File
@@ -36,6 +36,7 @@ func main() {
listen := flag.String("listen", "127.0.0.1:0", "HTTP listen address; port 0 picks a free port, printed on startup")
dsn := flag.String("dsn", os.Getenv("COSMIC_CLASH_POSTGRES_DSN"), "PostgreSQL connection string")
migrationDir := flag.String("migrations", "migrations", "directory containing numbered SQL migrations")
workloadSecret := flag.String("workload-secret", envOrDefault("COSMIC_CLASH_WORKLOAD_SECRET", "testkit-workload-secret"), "HMAC secret for signed workload tokens; defaults to a fixed test value since this binary is test-only")
flag.Parse()
if *dsn == "" {
fatalf("--dsn or COSMIC_CLASH_POSTGRES_DSN is required")
@@ -65,6 +66,7 @@ func main() {
RankedProfileProvider: store.PostgresRankedProfiles{DB: db},
Assignment: api.AssignmentProviderFromStore(db),
ProbeRecorder: store.PostgresQueue{DB: db},
WorkloadVerify: api.WorkloadVerifierFromSignedToken([]byte(*workloadSecret), db),
Now: func() time.Time { return time.Now().UTC() },
}).Handler()
listener, err := net.Listen("tcp", *listen)
@@ -107,6 +109,13 @@ func (f fakeSteamLogin) Authenticate(ctx context.Context, ticket string, _ time.
return domain.VerifiedIdentity{PlayerID: playerID, SteamID: steamID}, nil
}
func envOrDefault(name, fallback string) string {
if value := os.Getenv(name); value != "" {
return value
}
return fallback
}
func fatalf(format string, args ...any) {
fmt.Fprintf(os.Stderr, "testkit-api: "+format+"\n", args...)
os.Exit(1)
+36
View File
@@ -0,0 +1,36 @@
package store
import (
"context"
"database/sql"
)
// AllocationBindingStillValidSQL cross-checks a signed workload token's
// claims against the durable allocation record before trusting it. A
// validly-signed, unexpired token alone is not proof the allocation it names
// is still the live binding for that match/server pair -- this closes that
// gap defense-in-depth. allocations rows are append-only and never leave
// 'ALLOCATED' (see allocator_sql.go), so this is a simple existence check,
// not a state-machine walk.
const AllocationBindingStillValidSQL = `SELECT 1 FROM allocations
WHERE allocation_id = $1 AND match_id = $2 AND server_id = $3 AND state = 'ALLOCATED'`
// AllocationBindingStillValid reports whether the given (allocationID,
// matchID, serverID) triple names a real, still-allocated row. db, and every
// identifier, must be non-empty -- callers pass this an already-parsed and
// signature-verified token's claims, so empty fields here indicate a caller
// bug rather than a legitimate "not found".
func AllocationBindingStillValid(ctx context.Context, db *sql.DB, allocationID, matchID, serverID string) (bool, error) {
if db == nil || allocationID == "" || matchID == "" || serverID == "" {
return false, sql.ErrNoRows
}
var one int
err := db.QueryRowContext(ctx, AllocationBindingStillValidSQL, allocationID, matchID, serverID).Scan(&one)
if err == sql.ErrNoRows {
return false, nil
}
if err != nil {
return false, err
}
return true, nil
}
+126
View File
@@ -0,0 +1,126 @@
package workload
import (
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"time"
)
// SignedWorkloadToken is a control-plane-issued bearer credential for the
// WorkloadVerify boundary (see multiplayer-next.md 8.10). It exists because
// the obvious approach -- verifying a Kubernetes-projected service-account
// JWT via TokenReview/JWKS (see jwt.go, ParseAndValidate) -- needs a live
// cluster to validate against and so cannot be built or tested here.
//
// This sidesteps that requirement entirely: the control plane signs its own
// short-lived token over (allocation_id, match_id, server_id, expiry) with a
// secret only it holds, exactly the way domain.SessionStore already mints
// player session tokens elsewhere in this codebase. It needs no Kubernetes
// trust boundary to verify -- HMAC signature plus expiry is self-contained.
//
// The delivery channel is what makes this safe despite not proving pod
// identity the way a Kubernetes-issued token would: the token is meant to be
// handed to the allocated GameServer via the same Agones GameServerAllocation
// annotation channel allocation.go already uses for match-id/allocation-id
// (see agones/allocation.go), which only the actually-allocated pod's local
// SDK sidecar can read. A caller who can present this token has already
// proven, via that channel, that it is the pod Agones allocated.
type SignedWorkloadToken struct {
AllocationID string `json:"a"`
MatchID string `json:"m"`
ServerID string `json:"s"`
ExpiresAt time.Time `json:"e"`
}
var (
ErrEmptyWorkloadSecret = errors.New("workload token signing secret is empty")
ErrMalformedToken = errors.New("malformed signed workload token")
ErrTokenSignature = errors.New("signed workload token signature mismatch")
ErrTokenExpired = errors.New("signed workload token expired")
ErrTokenClaims = errors.New("signed workload token missing required claims")
)
// IssueSignedWorkloadToken produces a compact "payload.signature" token
// binding the three identifiers the API layer actually checks (see
// api.Service's WorkloadVerify call site: it only compares ServerID and
// MatchID on the returned domain.WorkloadBinding). now must be non-zero and
// ttl must be positive so a token is never silently issued already-expired.
func IssueSignedWorkloadToken(secret []byte, allocationID, matchID, serverID string, now time.Time, ttl time.Duration) (string, error) {
if len(secret) == 0 {
return "", ErrEmptyWorkloadSecret
}
if allocationID == "" || matchID == "" || serverID == "" {
return "", ErrTokenClaims
}
if now.IsZero() || ttl <= 0 {
return "", fmt.Errorf("issue signed workload token: now and ttl must be valid")
}
claims := SignedWorkloadToken{
AllocationID: allocationID,
MatchID: matchID,
ServerID: serverID,
ExpiresAt: now.Add(ttl).UTC(),
}
payload, err := json.Marshal(claims)
if err != nil {
return "", fmt.Errorf("marshal signed workload token: %w", err)
}
payloadEnc := base64.RawURLEncoding.EncodeToString(payload)
mac := hmac.New(sha256.New, secret)
mac.Write([]byte(payloadEnc))
sigEnc := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
return payloadEnc + "." + sigEnc, nil
}
// ParseSignedWorkloadToken verifies the signature in constant time, checks
// expiry against now, and returns the claims. It never trusts the payload
// before the signature is verified.
func ParseSignedWorkloadToken(secret []byte, token string, now time.Time) (SignedWorkloadToken, error) {
if len(secret) == 0 {
return SignedWorkloadToken{}, ErrEmptyWorkloadSecret
}
dot := -1
for i := 0; i < len(token); i++ {
if token[i] == '.' {
dot = i
break
}
}
if dot <= 0 || dot == len(token)-1 {
return SignedWorkloadToken{}, ErrMalformedToken
}
payloadEnc, sigEnc := token[:dot], token[dot+1:]
mac := hmac.New(sha256.New, secret)
mac.Write([]byte(payloadEnc))
expectedSig := mac.Sum(nil)
gotSig, err := base64.RawURLEncoding.DecodeString(sigEnc)
if err != nil {
return SignedWorkloadToken{}, ErrMalformedToken
}
if subtle.ConstantTimeCompare(expectedSig, gotSig) != 1 {
return SignedWorkloadToken{}, ErrTokenSignature
}
payload, err := base64.RawURLEncoding.DecodeString(payloadEnc)
if err != nil {
return SignedWorkloadToken{}, ErrMalformedToken
}
var claims SignedWorkloadToken
if err := json.Unmarshal(payload, &claims); err != nil {
return SignedWorkloadToken{}, ErrMalformedToken
}
if claims.AllocationID == "" || claims.MatchID == "" || claims.ServerID == "" || claims.ExpiresAt.IsZero() {
return SignedWorkloadToken{}, ErrTokenClaims
}
if now.IsZero() {
return SignedWorkloadToken{}, fmt.Errorf("parse signed workload token: now must be valid")
}
if !now.Before(claims.ExpiresAt) {
return SignedWorkloadToken{}, ErrTokenExpired
}
return claims, nil
}
+100
View File
@@ -0,0 +1,100 @@
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)
}
}
}