feat: add projected workload JWT adapter

This commit is contained in:
Josh Creek
2026-09-01 09:57:33 +01:00
parent febc69bdef
commit a70a0ebc74
4 changed files with 204 additions and 2 deletions
+134
View File
@@ -0,0 +1,134 @@
// Package workload adapts projected JWT workload credentials to the strict
// domain policy. JWT signature/key trust stays injected at this boundary.
package workload
import (
"encoding/base64"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/cosmic-clash/cosmic-clash/server/domain"
)
type SignatureVerifier func(signingInput, signature []byte) bool
// ParseAndValidate parses a compact JWT, verifies its signature before domain
// validation, and returns only the exact one-allocation binding accepted by
// the policy. It intentionally does not fetch keys or trust an alg claim.
func ParseAndValidate(token string, expected domain.WorkloadBinding, verify SignatureVerifier, now time.Time) (domain.WorkloadBinding, error) {
header, claims, signingInput, signature, err := parse(token)
if err != nil || header.Alg == "" || strings.EqualFold(header.Alg, "none") || verify == nil || !verify(signingInput, signature) {
return domain.WorkloadBinding{}, domain.ErrWorkloadCredential
}
credential, err := claims.credential(signature)
if err != nil {
return domain.WorkloadBinding{}, domain.ErrWorkloadCredential
}
policy, err := domain.NewWorkloadCredentialPolicy(expected, func(candidate domain.WorkloadCredential) bool {
return verify(signingInput, candidate.Signature)
})
if err != nil {
return domain.WorkloadBinding{}, domain.ErrWorkloadCredential
}
return policy.Validate(credential, now)
}
type tokenHeader struct {
Alg string `json:"alg"`
}
type tokenClaims map[string]json.RawMessage
func parse(token string) (tokenHeader, tokenClaims, []byte, []byte, error) {
parts := strings.Split(token, ".")
if len(parts) != 3 || parts[0] == "" || parts[1] == "" || parts[2] == "" {
return tokenHeader{}, nil, nil, nil, fmt.Errorf("invalid compact token")
}
headerBytes, err := decode(parts[0])
if err != nil {
return tokenHeader{}, nil, nil, nil, err
}
claimsBytes, err := decode(parts[1])
if err != nil {
return tokenHeader{}, nil, nil, nil, err
}
signature, err := decode(parts[2])
if err != nil || len(signature) == 0 {
return tokenHeader{}, nil, nil, nil, fmt.Errorf("invalid token signature")
}
var header tokenHeader
if err := json.Unmarshal(headerBytes, &header); err != nil {
return tokenHeader{}, nil, nil, nil, err
}
var claims tokenClaims
if err := json.Unmarshal(claimsBytes, &claims); err != nil {
return tokenHeader{}, nil, nil, nil, err
}
return header, claims, []byte(parts[0] + "." + parts[1]), signature, nil
}
func (c tokenClaims) credential(signature []byte) (domain.WorkloadCredential, error) {
issuer, err := c.string("iss")
if err != nil {
return domain.WorkloadCredential{}, err
}
audience, err := c.audience()
if err != nil {
return domain.WorkloadCredential{}, err
}
issuedAt, err := c.time("iat")
if err != nil {
return domain.WorkloadCredential{}, err
}
expiresAt, err := c.time("exp")
if err != nil {
return domain.WorkloadCredential{}, err
}
values := make([]string, 7)
for i, name := range []string{"namespace", "service_account", "pod_uid", "gameserver_uid", "allocation_id", "match_id", "server_id"} {
values[i], err = c.string(name)
if err != nil {
return domain.WorkloadCredential{}, err
}
}
return domain.WorkloadCredential{Issuer: issuer, Audience: audience, IssuedAt: issuedAt, ExpiresAt: expiresAt, Namespace: values[0], ServiceAcct: values[1], PodUID: values[2], GameServerUID: values[3], AllocationID: values[4], MatchID: values[5], ServerID: values[6], Signature: signature}, nil
}
func (c tokenClaims) string(name string) (string, error) {
var value string
raw, ok := c[name]
if !ok || json.Unmarshal(raw, &value) != nil || value == "" {
return "", fmt.Errorf("missing %s", name)
}
return value, nil
}
func (c tokenClaims) time(name string) (time.Time, error) {
var seconds float64
raw, ok := c[name]
if !ok || json.Unmarshal(raw, &seconds) != nil || seconds <= 0 || seconds != float64(int64(seconds)) {
return time.Time{}, fmt.Errorf("invalid %s", name)
}
return time.Unix(int64(seconds), 0).UTC(), nil
}
func (c tokenClaims) audience() (string, error) {
if raw, ok := c["aud"]; ok {
var single string
if json.Unmarshal(raw, &single) == nil && single != "" {
return single, nil
}
var many []string
if json.Unmarshal(raw, &many) == nil && len(many) == 1 && many[0] != "" {
return many[0], nil
}
}
return "", fmt.Errorf("missing aud")
}
func decode(value string) ([]byte, error) {
decoded, err := base64.RawURLEncoding.DecodeString(value)
if err == nil {
return decoded, nil
}
return base64.URLEncoding.DecodeString(value)
}
+66
View File
@@ -0,0 +1,66 @@
package workload
import (
"encoding/base64"
"encoding/json"
"strings"
"testing"
"time"
"github.com/cosmic-clash/cosmic-clash/server/domain"
)
func binding() domain.WorkloadBinding {
return domain.WorkloadBinding{Issuer: "https://issuer", Audience: "cosmic-result", Namespace: "games", ServiceAcct: "match-server", PodUID: "pod-1", GameServerUID: "gs-1", AllocationID: "allocation-1", MatchID: "match-1", ServerID: "server-1"}
}
func tokenFor(t *testing.T, alg string, claims map[string]any) string {
t.Helper()
header, _ := json.Marshal(map[string]string{"alg": alg, "typ": "JWT"})
payload, _ := json.Marshal(claims)
encode := func(value []byte) string { return base64.RawURLEncoding.EncodeToString(value) }
return encode(header) + "." + encode(payload) + "." + encode([]byte("signature"))
}
func validClaims() map[string]any {
return map[string]any{"iss": "https://issuer", "aud": "cosmic-result", "iat": float64(999), "exp": float64(1001), "namespace": "games", "service_account": "match-server", "pod_uid": "pod-1", "gameserver_uid": "gs-1", "allocation_id": "allocation-1", "match_id": "match-1", "server_id": "server-1"}
}
func TestParseAndValidateVerifiesJWTBeforeReturningBinding(t *testing.T) {
token := tokenFor(t, "RS256", validClaims())
wantSigning := strings.Join(strings.Split(token, ".")[:2], ".")
got, err := ParseAndValidate(token, binding(), func(signingInput, signature []byte) bool {
return string(signingInput) == wantSigning && string(signature) == "signature"
}, time.Unix(1000, 0))
if err != nil || got != binding() {
t.Fatalf("binding=%+v err=%v", got, err)
}
}
func TestParseAndValidateRejectsUnsignedMalformedAndMutatedTokens(t *testing.T) {
cases := []string{tokenFor(t, "none", validClaims()), tokenFor(t, "RS256", validClaims())[:10], tokenFor(t, "RS256", validClaims())}
for i, token := range cases {
_, err := ParseAndValidate(token, binding(), func([]byte, []byte) bool { return i != 2 }, time.Unix(1000, 0))
if err == nil {
t.Fatalf("case %d accepted", i)
}
}
claims := validClaims()
claims["server_id"] = "other"
if _, err := ParseAndValidate(tokenFor(t, "RS256", claims), binding(), func([]byte, []byte) bool { return true }, time.Unix(1000, 0)); err == nil {
t.Fatal("mutated binding accepted")
}
}
func TestParseAndValidateRejectsBoundaryExpiryAndMultiAudience(t *testing.T) {
claims := validClaims()
claims["exp"] = float64(1000)
if _, err := ParseAndValidate(tokenFor(t, "RS256", claims), binding(), func([]byte, []byte) bool { return true }, time.Unix(1000, 0)); err == nil {
t.Fatal("expiry boundary accepted")
}
claims = validClaims()
claims["aud"] = []string{"other", "cosmic-result"}
if _, err := ParseAndValidate(tokenFor(t, "RS256", claims), binding(), func([]byte, []byte) bool { return true }, time.Unix(1000, 0)); err == nil {
t.Fatal("ambiguous audience accepted")
}
}