From 34d51cf99db03470d5ca133b8945b60051099c1b Mon Sep 17 00:00:00 2001 From: Ryandi Tjia Date: Tue, 22 Sep 2026 14:57:04 +0700 Subject: [PATCH 1/9] feat(cardinal): authenticate game player tokens --- pkg/cardinal/auth_internal_test.go | 120 ++++++++++++++++++++++ pkg/cardinal/service.go | 139 ++++++++++++++------------ pkg/cardinal/service_internal_test.go | 2 +- 3 files changed, 196 insertions(+), 65 deletions(-) create mode 100644 pkg/cardinal/auth_internal_test.go diff --git a/pkg/cardinal/auth_internal_test.go b/pkg/cardinal/auth_internal_test.go new file mode 100644 index 000000000..ddf73d4e4 --- /dev/null +++ b/pkg/cardinal/auth_internal_test.go @@ -0,0 +1,120 @@ +package cardinal + +import ( + "context" + "crypto/ed25519" + "encoding/base64" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/goccy/go-json" + "github.com/golang-jwt/jwt/v5" + "github.com/stretchr/testify/require" +) + +func TestAuthenticatorArgusAcceptsGamePlayerToken(t *testing.T) { + publicKey, privateKey, err := ed25519.GenerateKey(nil) + require.NoError(t, err) + + server := newAuthTestServer(t, publicKey) + authenticator, err := newAuthenticatorArgus(server.URL + "/") + require.NoError(t, err) + + token := signGameToken(t, privateKey, jwt.RegisteredClaims{ + Subject: "player-123", + Issuer: server.URL + "/auth", + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Minute)), + }, "game") + player, err := authenticator.authenticate(context.Background(), requestWithBearer(t, token)) + require.NoError(t, err) + require.Equal(t, &Player{ID: "player-123"}, player) +} + +func TestAuthenticatorArgusRejectsInvalidGameClaims(t *testing.T) { + publicKey, privateKey, err := ed25519.GenerateKey(nil) + require.NoError(t, err) + _, otherPrivateKey, err := ed25519.GenerateKey(nil) + require.NoError(t, err) + + server := newAuthTestServer(t, publicKey) + authenticator, err := newAuthenticatorArgus(server.URL) + require.NoError(t, err) + + validClaims := jwt.RegisteredClaims{ + Subject: "player-123", + Issuer: server.URL + "/auth", + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Minute)), + } + for _, test := range []struct { + name string + key ed25519.PrivateKey + claims jwt.RegisteredClaims + tokenUse string + }{ + {name: "untrusted signature", key: otherPrivateKey, claims: validClaims, tokenUse: "game"}, + {name: "account token", key: privateKey, claims: validClaims}, + {name: "wrong issuer", key: privateKey, claims: jwt.RegisteredClaims{Subject: "player-123", Issuer: "https://other.example/auth", ExpiresAt: validClaims.ExpiresAt}, tokenUse: "game"}, + {name: "missing expiry", key: privateKey, claims: jwt.RegisteredClaims{Subject: "player-123", Issuer: validClaims.Issuer}, tokenUse: "game"}, + {name: "expired", key: privateKey, claims: jwt.RegisteredClaims{Subject: "player-123", Issuer: validClaims.Issuer, ExpiresAt: jwt.NewNumericDate(time.Now().Add(-time.Minute))}, tokenUse: "game"}, + {name: "missing subject", key: privateKey, claims: jwt.RegisteredClaims{Issuer: validClaims.Issuer, ExpiresAt: validClaims.ExpiresAt}, tokenUse: "game"}, + } { + t.Run(test.name, func(t *testing.T) { + token := signGameToken(t, test.key, test.claims, test.tokenUse) + _, authErr := authenticator.authenticate(context.Background(), requestWithBearer(t, token)) + require.Error(t, authErr) + }) + } +} + +func TestAuthenticatorDevUsesPlayerID(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("X-Player-ID", " player-123 ") + + player, err := (authenticatorDev{}).authenticate(context.Background(), req) + require.NoError(t, err) + require.Equal(t, &Player{ID: "player-123"}, player) + + _, err = (authenticatorDev{}).authenticate(context.Background(), httptest.NewRequest(http.MethodGet, "/", nil)) + require.Error(t, err) +} + +func newAuthTestServer(t *testing.T, publicKey ed25519.PublicKey) *httptest.Server { + t.Helper() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + if req.URL.Path != "/auth/jwks" { + http.NotFound(w, req) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{"keys": []map[string]string{{ + "kty": "OKP", + "crv": "Ed25519", + "alg": "EdDSA", + "use": "sig", + "kid": "test-key", + "x": base64.RawURLEncoding.EncodeToString(publicKey), + }}}) + })) + t.Cleanup(server.Close) + return server +} + +func signGameToken(t *testing.T, privateKey ed25519.PrivateKey, claims jwt.RegisteredClaims, tokenUse string) string { + t.Helper() + token := jwt.NewWithClaims(jwt.SigningMethodEdDSA, gameTokenClaims{ + RegisteredClaims: claims, + TokenUse: tokenUse, + }) + token.Header["kid"] = "test-key" + signed, err := token.SignedString(privateKey) + require.NoError(t, err) + return signed +} + +func requestWithBearer(t *testing.T, token string) *http.Request { + t.Helper() + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("Authorization", "Bearer "+token) + return req +} diff --git a/pkg/cardinal/service.go b/pkg/cardinal/service.go index 7a2b3e5aa..78a42648c 100644 --- a/pkg/cardinal/service.go +++ b/pkg/cardinal/service.go @@ -202,8 +202,7 @@ func (s *service) registerCommandHandler(name string) { // Command handlers // ------------------------------------------------------------------------------------------------- -// TODO: eventually, we'll probably have more user fields in the command metadata, possibly a User -// struct field instead of a single persona ID. +// Client command personas carry the authenticated player ID. type streamSubscriber struct { ctx context.Context @@ -229,15 +228,15 @@ func (s *service) SendCommand( default: } - user := UserFromContext(ctx) - assert.That(user != nil, "user should exist in authenticated request context") + player := PlayerFromContext(ctx) + assert.That(player != nil, "player should exist in authenticated request context") cmd := req.Msg.GetCommand() assert.That(cmd != nil, "command should have been validated") assert.That(cmd.GetPersona() != nil, "command persona should have been validated") - cmd.Persona.Id = user.ID - oteltrace.SpanFromContext(ctx).SetAttributes(semconv.EnduserID(user.ID), attrCommandName.String(cmd.GetName())) + cmd.Persona.Id = player.ID + oteltrace.SpanFromContext(ctx).SetAttributes(semconv.EnduserID(player.ID), attrCommandName.String(cmd.GetName())) if micro.String(s.world.address) != micro.String(cmd.GetAddress()) { return nil, connect.NewError(connect.CodeInvalidArgument, eris.New("address doesn't match shard address")) @@ -254,16 +253,16 @@ func (s *service) SendCommandWithReply( ctx context.Context, req *connect.Request[cardinalv1.SendCommandWithReplyRequest], ) (*connect.Response[cardinalv1.SendCommandWithReplyResponse], error) { - user := UserFromContext(ctx) - assert.That(user != nil, "user should exist in authenticated request context") + player := PlayerFromContext(ctx) + assert.That(player != nil, "player should exist in authenticated request context") cmd := req.Msg.GetCommand() assert.That(cmd != nil, "command should have been validated") assert.That(cmd.GetPersona() != nil, "command persona should have been validated") - cmd.Persona.Id = user.ID + cmd.Persona.Id = player.ID span := oteltrace.SpanFromContext(ctx) - span.SetAttributes(semconv.EnduserID(user.ID), attrCommandName.String(cmd.GetName()), + span.SetAttributes(semconv.EnduserID(player.ID), attrCommandName.String(cmd.GetName()), attrEventName.String(req.Msg.GetEventName())) if micro.String(s.world.address) != micro.String(cmd.GetAddress()) { @@ -324,23 +323,23 @@ func (s *service) StartEventStream( req *connect.Request[cardinalv1.StartEventStreamRequest], stream *connect.ServerStream[cardinalv1.StartEventStreamResponse], ) error { - user := UserFromContext(ctx) - assert.That(user != nil, "user should exist in authenticated stream context") - oteltrace.SpanFromContext(ctx).SetAttributes(semconv.EnduserID(user.ID), + player := PlayerFromContext(ctx) + assert.That(player != nil, "player should exist in authenticated stream context") + oteltrace.SpanFromContext(ctx).SetAttributes(semconv.EnduserID(player.ID), attrEventSubscriptions.Int(countSubscriptions(req.Msg.GetSubscriptions()))) - subscriber, err := s.addSubscriber(ctx, user, stream) + subscriber, err := s.addSubscriber(ctx, player, stream) if err != nil { return connect.NewError(connect.CodeFailedPrecondition, err) } - defer s.removeSubscriber(user) + defer s.removeSubscriber(player) for _, subscription := range req.Msg.GetSubscriptions() { if micro.String(s.world.address) != micro.String(subscription.GetAddress()) { return connect.NewError(connect.CodeInvalidArgument, eris.New("address doesn't match shard address")) } } - s.subscribeEvents(user, req.Msg.GetSubscriptions()) + s.subscribeEvents(player, req.Msg.GetSubscriptions()) if err := subscriber.send(&cardinalv1.StartEventStreamResponse{}); err != nil { return connect.NewError(connect.CodeInternal, eris.Wrap(err, "failed to send initial empty event to client")) @@ -370,11 +369,11 @@ func (s *service) SubscribeEvents( ctx context.Context, req *connect.Request[cardinalv1.SubscribeEventsRequest], ) (*connect.Response[cardinalv1.SubscribeEventsResponse], error) { - user, err := s.subscriptionRequest(ctx, req.Msg.GetSubscriptions()) + player, err := s.subscriptionRequest(ctx, req.Msg.GetSubscriptions()) if err != nil { return nil, err } - s.subscribeEvents(user, req.Msg.GetSubscriptions()) + s.subscribeEvents(player, req.Msg.GetSubscriptions()) return connect.NewResponse(&cardinalv1.SubscribeEventsResponse{}), nil } @@ -383,25 +382,25 @@ func (s *service) UnsubscribeEvents( ctx context.Context, req *connect.Request[cardinalv1.UnsubscribeEventsRequest], ) (*connect.Response[cardinalv1.UnsubscribeEventsResponse], error) { - user, err := s.subscriptionRequest(ctx, req.Msg.GetSubscriptions()) + player, err := s.subscriptionRequest(ctx, req.Msg.GetSubscriptions()) if err != nil { return nil, err } - s.unsubscribeEvents(user, req.Msg.GetSubscriptions()) + s.unsubscribeEvents(player, req.Msg.GetSubscriptions()) return connect.NewResponse(&cardinalv1.UnsubscribeEventsResponse{}), nil } -// subscriptionRequest validates a subscribe or unsubscribe request from a user with an open stream. +// subscriptionRequest validates a subscribe or unsubscribe request from a player with an open stream. func (s *service) subscriptionRequest( ctx context.Context, subscriptions []*cardinalv1.EventSubscription, -) (*User, error) { - user := UserFromContext(ctx) - assert.That(user != nil, "user should exist in authenticated request context") - oteltrace.SpanFromContext(ctx).SetAttributes(semconv.EnduserID(user.ID), +) (*Player, error) { + player := PlayerFromContext(ctx) + assert.That(player != nil, "player should exist in authenticated request context") + oteltrace.SpanFromContext(ctx).SetAttributes(semconv.EnduserID(player.ID), attrEventSubscriptions.Int(countSubscriptions(subscriptions))) - if !s.hasSubscriber(user) { + if !s.hasSubscriber(player) { return nil, connect.NewError(connect.CodeFailedPrecondition, eris.New("client has no established stream")) } @@ -410,19 +409,19 @@ func (s *service) subscriptionRequest( return nil, connect.NewError(connect.CodeInvalidArgument, eris.New("address doesn't match shard address")) } } - return user, nil + return player, nil } func (s *service) addSubscriber( ctx context.Context, - user *User, + player *Player, stream *connect.ServerStream[cardinalv1.StartEventStreamResponse], ) (*streamSubscriber, error) { s.mu.Lock() defer s.mu.Unlock() - if _, exists := s.subscribers[user.ID]; exists { - return nil, eris.Errorf("user %s already has an open stream", user.ID) + if _, exists := s.subscribers[player.ID]; exists { + return nil, eris.Errorf("player %s already has an open stream", player.ID) } subscriber := &streamSubscriber{ @@ -430,22 +429,22 @@ func (s *service) addSubscriber( stream: stream, events: make(map[string]struct{}), } - s.subscribers[user.ID] = subscriber + s.subscribers[player.ID] = subscriber return subscriber, nil } -func (s *service) removeSubscriber(user *User) { +func (s *service) removeSubscriber(player *Player) { s.mu.Lock() defer s.mu.Unlock() - delete(s.subscribers, user.ID) + delete(s.subscribers, player.ID) } -func (s *service) subscribeEvents(user *User, subscriptions []*cardinalv1.EventSubscription) { +func (s *service) subscribeEvents(player *Player, subscriptions []*cardinalv1.EventSubscription) { s.mu.Lock() defer s.mu.Unlock() - subscriber := s.subscribers[user.ID] + subscriber := s.subscribers[player.ID] assert.That(subscriber != nil, "subscriber should exist for authenticated stream") for _, subscription := range subscriptions { @@ -455,11 +454,11 @@ func (s *service) subscribeEvents(user *User, subscriptions []*cardinalv1.EventS } } -func (s *service) unsubscribeEvents(user *User, subscriptions []*cardinalv1.EventSubscription) { +func (s *service) unsubscribeEvents(player *Player, subscriptions []*cardinalv1.EventSubscription) { s.mu.Lock() defer s.mu.Unlock() - subscriber := s.subscribers[user.ID] + subscriber := s.subscribers[player.ID] assert.That(subscriber != nil, "subscriber should exist for authenticated stream") for _, subscription := range subscriptions { @@ -469,11 +468,11 @@ func (s *service) unsubscribeEvents(user *User, subscriptions []*cardinalv1.Even } } -func (s *service) hasSubscriber(user *User) bool { +func (s *service) hasSubscriber(player *Player) bool { s.mu.RLock() defer s.mu.RUnlock() - _, ok := s.subscribers[user.ID] + _, ok := s.subscribers[player.ID] return ok } @@ -667,12 +666,9 @@ func (s *service) publishInterShardCommand(ctx context.Context, evt event.Event) // Authentication // ------------------------------------------------------------------------------------------------- -type User struct { - jwt.RegisteredClaims - - ID string `json:"id"` - Name string `json:"name"` - Email string `json:"email"` +// Player is the authenticated gameplay identity supplied to Cardinal handlers. +type Player struct { + ID string } // AuthMode selects the authentication mode for the client-facing ConnectRPC service. @@ -718,16 +714,16 @@ func ParseAuthMode(s string) (AuthMode, error) { } } -func UserFromContext(ctx context.Context) *User { +func PlayerFromContext(ctx context.Context) *Player { info := authn.GetInfo(ctx) if info == nil { return nil } - user, ok := info.(*User) + player, ok := info.(*Player) if !ok { return nil } - return user + return player } // ------------------------------------------------------------------------------------------------- @@ -735,13 +731,15 @@ func UserFromContext(ctx context.Context) *User { // ------------------------------------------------------------------------------------------------- type authenticatorArgus struct { + issuer string keyfunc keyfunc.Keyfunc } func newAuthenticatorArgus(argusAuthURL string) (*authenticatorArgus, error) { assert.That(argusAuthURL != "", "Should've validated the URL") - jwksURL := argusAuthURL + "/auth/jwks" + issuer := strings.TrimRight(argusAuthURL, "/") + "/auth" + jwksURL := issuer + "/jwks" client := &http.Client{ Timeout: 3 * time.Second, } @@ -771,7 +769,12 @@ func newAuthenticatorArgus(argusAuthURL string) (*authenticatorArgus, error) { return nil, eris.Wrap(err, "failed to create keyfunc") } - return &authenticatorArgus{keyfunc: keyfn}, nil + return &authenticatorArgus{issuer: issuer, keyfunc: keyfn}, nil +} + +type gameTokenClaims struct { + jwt.RegisteredClaims + TokenUse string `json:"token_use"` } func (a *authenticatorArgus) authenticate(_ context.Context, req *http.Request) (any, error) { @@ -780,21 +783,29 @@ func (a *authenticatorArgus) authenticate(_ context.Context, req *http.Request) return nil, authn.Errorf("Authorization header must be in format: 'Bearer '") } - user := &User{} - token, err := jwt.ParseWithClaims(jwtString, user, a.keyfunc.Keyfunc) + claims := &gameTokenClaims{} + token, err := jwt.ParseWithClaims( + jwtString, + claims, + a.keyfunc.Keyfunc, + jwt.WithValidMethods([]string{jwt.SigningMethodEdDSA.Alg()}), + jwt.WithIssuer(a.issuer), + jwt.WithExpirationRequired(), + ) if err != nil { - return nil, eris.Wrap(err, "JWT parse error") + return nil, authn.Errorf("invalid JWT: %v", err) } if !token.Valid { - return nil, eris.New("JWT token is invalid") + return nil, authn.Errorf("JWT token is invalid") + } + if claims.TokenUse != "game" { + return nil, authn.Errorf("JWT token_use must be game") + } + if strings.TrimSpace(claims.Subject) == "" { + return nil, authn.Errorf("JWT subject is required") } - // TODO: Remove this comment once persona ID is removed from the JWT. - // if u.PersonaID == "" { - // return nil, authn.Errorf("JWT token is missing persona ID") - // } - - return user, nil + return &Player{ID: claims.Subject}, nil } // ------------------------------------------------------------------------------------------------- @@ -804,10 +815,10 @@ func (a *authenticatorArgus) authenticate(_ context.Context, req *http.Request) type authenticatorDev struct{} func (a authenticatorDev) authenticate(_ context.Context, req *http.Request) (any, error) { - email := strings.TrimSpace(req.Header.Get("X-Email")) - if email == "" { - return nil, authn.Errorf("X-Email header is required") + playerID := strings.TrimSpace(req.Header.Get("X-Player-ID")) + if playerID == "" { + return nil, authn.Errorf("X-Player-ID header is required") } - return &User{ID: email, Email: email}, nil + return &Player{ID: playerID}, nil } diff --git a/pkg/cardinal/service_internal_test.go b/pkg/cardinal/service_internal_test.go index 3d8665711..cd0435665 100644 --- a/pkg/cardinal/service_internal_test.go +++ b/pkg/cardinal/service_internal_test.go @@ -267,5 +267,5 @@ func newServiceFixture(t *testing.T, prng *rand.Rand, registerNATSEndpoints bool } func serviceTestContext(userID string) context.Context { - return authn.SetInfo(context.Background(), &User{ID: userID}) + return authn.SetInfo(context.Background(), &Player{ID: userID}) } From a072f149b41b68ff52aae2614e035e68de8e119e Mon Sep 17 00:00:00 2001 From: Ryandi Tjia Date: Tue, 22 Sep 2026 15:08:03 +0700 Subject: [PATCH 2/9] chore(cardinal): satisfy auth lint checks --- pkg/cardinal/auth_internal_test.go | 42 ++++++++++++++++++++++++++---- pkg/cardinal/service.go | 2 +- 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/pkg/cardinal/auth_internal_test.go b/pkg/cardinal/auth_internal_test.go index ddf73d4e4..c302bd2d6 100644 --- a/pkg/cardinal/auth_internal_test.go +++ b/pkg/cardinal/auth_internal_test.go @@ -55,10 +55,42 @@ func TestAuthenticatorArgusRejectsInvalidGameClaims(t *testing.T) { }{ {name: "untrusted signature", key: otherPrivateKey, claims: validClaims, tokenUse: "game"}, {name: "account token", key: privateKey, claims: validClaims}, - {name: "wrong issuer", key: privateKey, claims: jwt.RegisteredClaims{Subject: "player-123", Issuer: "https://other.example/auth", ExpiresAt: validClaims.ExpiresAt}, tokenUse: "game"}, - {name: "missing expiry", key: privateKey, claims: jwt.RegisteredClaims{Subject: "player-123", Issuer: validClaims.Issuer}, tokenUse: "game"}, - {name: "expired", key: privateKey, claims: jwt.RegisteredClaims{Subject: "player-123", Issuer: validClaims.Issuer, ExpiresAt: jwt.NewNumericDate(time.Now().Add(-time.Minute))}, tokenUse: "game"}, - {name: "missing subject", key: privateKey, claims: jwt.RegisteredClaims{Issuer: validClaims.Issuer, ExpiresAt: validClaims.ExpiresAt}, tokenUse: "game"}, + { + name: "wrong issuer", + key: privateKey, + claims: jwt.RegisteredClaims{ + Subject: "player-123", Issuer: "https://other.example/auth", ExpiresAt: validClaims.ExpiresAt, + }, + tokenUse: "game", + }, + { + name: "missing expiry", + key: privateKey, + claims: jwt.RegisteredClaims{ + Subject: "player-123", Issuer: validClaims.Issuer, + }, + tokenUse: "game", + }, + { + name: "expired", + key: privateKey, + claims: jwt.RegisteredClaims{ + Subject: "player-123", + Issuer: validClaims.Issuer, + ExpiresAt: jwt.NewNumericDate( + time.Now().Add(-time.Minute), + ), + }, + tokenUse: "game", + }, + { + name: "missing subject", + key: privateKey, + claims: jwt.RegisteredClaims{ + Issuer: validClaims.Issuer, ExpiresAt: validClaims.ExpiresAt, + }, + tokenUse: "game", + }, } { t.Run(test.name, func(t *testing.T) { token := signGameToken(t, test.key, test.claims, test.tokenUse) @@ -70,7 +102,7 @@ func TestAuthenticatorArgusRejectsInvalidGameClaims(t *testing.T) { func TestAuthenticatorDevUsesPlayerID(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/", nil) - req.Header.Set("X-Player-ID", " player-123 ") + req.Header.Set("X-Player-Id", " player-123 ") player, err := (authenticatorDev{}).authenticate(context.Background(), req) require.NoError(t, err) diff --git a/pkg/cardinal/service.go b/pkg/cardinal/service.go index 78a42648c..6c6cbb307 100644 --- a/pkg/cardinal/service.go +++ b/pkg/cardinal/service.go @@ -815,7 +815,7 @@ func (a *authenticatorArgus) authenticate(_ context.Context, req *http.Request) type authenticatorDev struct{} func (a authenticatorDev) authenticate(_ context.Context, req *http.Request) (any, error) { - playerID := strings.TrimSpace(req.Header.Get("X-Player-ID")) + playerID := strings.TrimSpace(req.Header.Get("X-Player-Id")) if playerID == "" { return nil, authn.Errorf("X-Player-ID header is required") } From 1cca1154029854f0dccb5926af33324f3df90235 Mon Sep 17 00:00:00 2001 From: Ryandi Tjia Date: Thu, 24 Sep 2026 15:21:57 +0700 Subject: [PATCH 3/9] feat(cardinal): scope player tokens to organization and project --- pkg/cardinal/auth_internal_test.go | 65 +++++++++++++++++++----------- pkg/cardinal/service.go | 28 ++++++------- 2 files changed, 55 insertions(+), 38 deletions(-) diff --git a/pkg/cardinal/auth_internal_test.go b/pkg/cardinal/auth_internal_test.go index c302bd2d6..fb3f9eb3f 100644 --- a/pkg/cardinal/auth_internal_test.go +++ b/pkg/cardinal/auth_internal_test.go @@ -19,14 +19,15 @@ func TestAuthenticatorArgusAcceptsGamePlayerToken(t *testing.T) { require.NoError(t, err) server := newAuthTestServer(t, publicKey) - authenticator, err := newAuthenticatorArgus(server.URL + "/") + authenticator, err := newAuthenticatorArgus(server.URL+"/", "argus", "rampage") require.NoError(t, err) token := signGameToken(t, privateKey, jwt.RegisteredClaims{ Subject: "player-123", Issuer: server.URL + "/auth", + Audience: jwt.ClaimStrings{"argus/rampage"}, ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Minute)), - }, "game") + }) player, err := authenticator.authenticate(context.Background(), requestWithBearer(t, token)) require.NoError(t, err) require.Equal(t, &Player{ID: "player-123"}, player) @@ -39,65 +40,84 @@ func TestAuthenticatorArgusRejectsInvalidGameClaims(t *testing.T) { require.NoError(t, err) server := newAuthTestServer(t, publicKey) - authenticator, err := newAuthenticatorArgus(server.URL) + authenticator, err := newAuthenticatorArgus(server.URL, "argus", "rampage") require.NoError(t, err) validClaims := jwt.RegisteredClaims{ Subject: "player-123", Issuer: server.URL + "/auth", + Audience: jwt.ClaimStrings{"argus/rampage"}, ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Minute)), } for _, test := range []struct { - name string - key ed25519.PrivateKey - claims jwt.RegisteredClaims - tokenUse string + name string + key ed25519.PrivateKey + claims jwt.RegisteredClaims }{ - {name: "untrusted signature", key: otherPrivateKey, claims: validClaims, tokenUse: "game"}, - {name: "account token", key: privateKey, claims: validClaims}, + {name: "untrusted signature", key: otherPrivateKey, claims: validClaims}, + { + name: "wrong audience", key: privateKey, + claims: jwt.RegisteredClaims{ + Subject: validClaims.Subject, Issuer: validClaims.Issuer, + Audience: jwt.ClaimStrings{"argus/other"}, ExpiresAt: validClaims.ExpiresAt, + }, + }, + { + name: "missing audience", key: privateKey, + claims: jwt.RegisteredClaims{ + Subject: validClaims.Subject, Issuer: validClaims.Issuer, ExpiresAt: validClaims.ExpiresAt, + }, + }, { name: "wrong issuer", key: privateKey, claims: jwt.RegisteredClaims{ - Subject: "player-123", Issuer: "https://other.example/auth", ExpiresAt: validClaims.ExpiresAt, + Subject: validClaims.Subject, Issuer: "https://other.example/auth", + Audience: validClaims.Audience, ExpiresAt: validClaims.ExpiresAt, }, - tokenUse: "game", }, { name: "missing expiry", key: privateKey, claims: jwt.RegisteredClaims{ - Subject: "player-123", Issuer: validClaims.Issuer, + Subject: validClaims.Subject, Issuer: validClaims.Issuer, Audience: validClaims.Audience, }, - tokenUse: "game", }, { name: "expired", key: privateKey, claims: jwt.RegisteredClaims{ - Subject: "player-123", - Issuer: validClaims.Issuer, + Subject: validClaims.Subject, + Issuer: validClaims.Issuer, + Audience: validClaims.Audience, ExpiresAt: jwt.NewNumericDate( time.Now().Add(-time.Minute), ), }, - tokenUse: "game", }, { name: "missing subject", key: privateKey, claims: jwt.RegisteredClaims{ - Issuer: validClaims.Issuer, ExpiresAt: validClaims.ExpiresAt, + Issuer: validClaims.Issuer, Audience: validClaims.Audience, ExpiresAt: validClaims.ExpiresAt, }, - tokenUse: "game", }, } { t.Run(test.name, func(t *testing.T) { - token := signGameToken(t, test.key, test.claims, test.tokenUse) + token := signGameToken(t, test.key, test.claims) _, authErr := authenticator.authenticate(context.Background(), requestWithBearer(t, token)) require.Error(t, authErr) }) } + + t.Run("non-EdDSA algorithm", func(t *testing.T) { + token := jwt.NewWithClaims(jwt.SigningMethodHS256, validClaims) + token.Header["kid"] = "test-key" + signed, signErr := token.SignedString([]byte("test-secret")) + require.NoError(t, signErr) + _, authErr := authenticator.authenticate(context.Background(), requestWithBearer(t, signed)) + require.Error(t, authErr) + }) } func TestAuthenticatorDevUsesPlayerID(t *testing.T) { @@ -132,12 +152,9 @@ func newAuthTestServer(t *testing.T, publicKey ed25519.PublicKey) *httptest.Serv return server } -func signGameToken(t *testing.T, privateKey ed25519.PrivateKey, claims jwt.RegisteredClaims, tokenUse string) string { +func signGameToken(t *testing.T, privateKey ed25519.PrivateKey, claims jwt.RegisteredClaims) string { t.Helper() - token := jwt.NewWithClaims(jwt.SigningMethodEdDSA, gameTokenClaims{ - RegisteredClaims: claims, - TokenUse: tokenUse, - }) + token := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims) token.Header["kid"] = "test-key" signed, err := token.SignedString(privateKey) require.NoError(t, err) diff --git a/pkg/cardinal/service.go b/pkg/cardinal/service.go index 6c6cbb307..581ad42b4 100644 --- a/pkg/cardinal/service.go +++ b/pkg/cardinal/service.go @@ -112,7 +112,9 @@ func (s *service) init(address string) error { var authenticate func(context.Context, *http.Request) (any, error) switch s.authMode { case AuthModeArgus: - authenticator, err := newAuthenticatorArgus(s.argusAuthURL) + authenticator, err := newAuthenticatorArgus( + s.argusAuthURL, s.world.options.Organization, s.world.options.Project, + ) if err != nil { return eris.Wrap(err, "failed to create argus authenticator") } @@ -731,11 +733,12 @@ func PlayerFromContext(ctx context.Context) *Player { // ------------------------------------------------------------------------------------------------- type authenticatorArgus struct { - issuer string - keyfunc keyfunc.Keyfunc + issuer string + audience string + keyfunc keyfunc.Keyfunc } -func newAuthenticatorArgus(argusAuthURL string) (*authenticatorArgus, error) { +func newAuthenticatorArgus(argusAuthURL, organization, project string) (*authenticatorArgus, error) { assert.That(argusAuthURL != "", "Should've validated the URL") issuer := strings.TrimRight(argusAuthURL, "/") + "/auth" @@ -769,12 +772,11 @@ func newAuthenticatorArgus(argusAuthURL string) (*authenticatorArgus, error) { return nil, eris.Wrap(err, "failed to create keyfunc") } - return &authenticatorArgus{issuer: issuer, keyfunc: keyfn}, nil -} - -type gameTokenClaims struct { - jwt.RegisteredClaims - TokenUse string `json:"token_use"` + return &authenticatorArgus{ + issuer: issuer, + audience: organization + "/" + project, + keyfunc: keyfn, + }, nil } func (a *authenticatorArgus) authenticate(_ context.Context, req *http.Request) (any, error) { @@ -783,13 +785,14 @@ func (a *authenticatorArgus) authenticate(_ context.Context, req *http.Request) return nil, authn.Errorf("Authorization header must be in format: 'Bearer '") } - claims := &gameTokenClaims{} + claims := &jwt.RegisteredClaims{} token, err := jwt.ParseWithClaims( jwtString, claims, a.keyfunc.Keyfunc, jwt.WithValidMethods([]string{jwt.SigningMethodEdDSA.Alg()}), jwt.WithIssuer(a.issuer), + jwt.WithAudience(a.audience), jwt.WithExpirationRequired(), ) if err != nil { @@ -798,9 +801,6 @@ func (a *authenticatorArgus) authenticate(_ context.Context, req *http.Request) if !token.Valid { return nil, authn.Errorf("JWT token is invalid") } - if claims.TokenUse != "game" { - return nil, authn.Errorf("JWT token_use must be game") - } if strings.TrimSpace(claims.Subject) == "" { return nil, authn.Errorf("JWT subject is required") } From e2cb5e46477ff7e7f6be677def180c69f00140e7 Mon Sep 17 00:00:00 2001 From: Ryandi Tjia Date: Thu, 1 Oct 2026 23:52:52 +0700 Subject: [PATCH 4/9] chore(cardinal): remove stray persona comment Co-Authored-By: Claude Opus 5.5 (1M context) --- pkg/cardinal/service.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/pkg/cardinal/service.go b/pkg/cardinal/service.go index 581ad42b4..c30653979 100644 --- a/pkg/cardinal/service.go +++ b/pkg/cardinal/service.go @@ -204,8 +204,6 @@ func (s *service) registerCommandHandler(name string) { // Command handlers // ------------------------------------------------------------------------------------------------- -// Client command personas carry the authenticated player ID. - type streamSubscriber struct { ctx context.Context stream *connect.ServerStream[cardinalv1.StartEventStreamResponse] From 88211ff67867a111a27a6ee8bc16fdbda4f378e4 Mon Sep 17 00:00:00 2001 From: Ryandi Tjia Date: Fri, 2 Oct 2026 20:52:22 +0700 Subject: [PATCH 5/9] fix(cardinal): stop checking the game token issuer Shards reach Auth through an internal URL that differs from the public issuer. Signature, audience, and expiry still bind the token to Auth and this project. Co-Authored-By: Claude Opus 5.5 (1M context) --- pkg/cardinal/auth_internal_test.go | 8 -------- pkg/cardinal/service.go | 6 +----- 2 files changed, 1 insertion(+), 13 deletions(-) diff --git a/pkg/cardinal/auth_internal_test.go b/pkg/cardinal/auth_internal_test.go index fb3f9eb3f..be568068d 100644 --- a/pkg/cardinal/auth_internal_test.go +++ b/pkg/cardinal/auth_internal_test.go @@ -68,14 +68,6 @@ func TestAuthenticatorArgusRejectsInvalidGameClaims(t *testing.T) { Subject: validClaims.Subject, Issuer: validClaims.Issuer, ExpiresAt: validClaims.ExpiresAt, }, }, - { - name: "wrong issuer", - key: privateKey, - claims: jwt.RegisteredClaims{ - Subject: validClaims.Subject, Issuer: "https://other.example/auth", - Audience: validClaims.Audience, ExpiresAt: validClaims.ExpiresAt, - }, - }, { name: "missing expiry", key: privateKey, diff --git a/pkg/cardinal/service.go b/pkg/cardinal/service.go index c30653979..b493f9dff 100644 --- a/pkg/cardinal/service.go +++ b/pkg/cardinal/service.go @@ -731,7 +731,6 @@ func PlayerFromContext(ctx context.Context) *Player { // ------------------------------------------------------------------------------------------------- type authenticatorArgus struct { - issuer string audience string keyfunc keyfunc.Keyfunc } @@ -739,8 +738,7 @@ type authenticatorArgus struct { func newAuthenticatorArgus(argusAuthURL, organization, project string) (*authenticatorArgus, error) { assert.That(argusAuthURL != "", "Should've validated the URL") - issuer := strings.TrimRight(argusAuthURL, "/") + "/auth" - jwksURL := issuer + "/jwks" + jwksURL := argusAuthURL + "/auth/jwks" client := &http.Client{ Timeout: 3 * time.Second, } @@ -771,7 +769,6 @@ func newAuthenticatorArgus(argusAuthURL, organization, project string) (*authent } return &authenticatorArgus{ - issuer: issuer, audience: organization + "/" + project, keyfunc: keyfn, }, nil @@ -789,7 +786,6 @@ func (a *authenticatorArgus) authenticate(_ context.Context, req *http.Request) claims, a.keyfunc.Keyfunc, jwt.WithValidMethods([]string{jwt.SigningMethodEdDSA.Alg()}), - jwt.WithIssuer(a.issuer), jwt.WithAudience(a.audience), jwt.WithExpirationRequired(), ) From 887ecd21fce8fc8509ceb1f5935f75e2fd27ae75 Mon Sep 17 00:00:00 2001 From: Ryandi Tjia Date: Fri, 2 Oct 2026 20:52:22 +0700 Subject: [PATCH 6/9] fix(cli): send the player ID header for MCP dev auth Co-Authored-By: Claude Opus 5.5 (1M context) --- cli/commands/mcp/mcp_internal_test.go | 6 +++--- cli/commands/mcp/tool_send_command.go | 8 ++++---- cli/commands/mcp/util.go | 20 ++++++++++---------- docs/cardinal/client-integration.mdx | 18 +++++++++--------- 4 files changed, 26 insertions(+), 26 deletions(-) diff --git a/cli/commands/mcp/mcp_internal_test.go b/cli/commands/mcp/mcp_internal_test.go index 17799fc9f..fe1849e58 100644 --- a/cli/commands/mcp/mcp_internal_test.go +++ b/cli/commands/mcp/mcp_internal_test.go @@ -65,7 +65,7 @@ func TestSendCommandInput_Validate_Valid(t *testing.T) { require.NoError(t, err) assert.Empty(t, input.ShardURL) // resolved from the cluster in the handler, not validate() - assert.Equal(t, defaultDevEmail, input.Email) + assert.Equal(t, defaultDevPlayerID, input.PlayerID) assert.Equal(t, defaultRegion, input.Region) assert.NotNil(t, input.Payload) } @@ -107,7 +107,7 @@ func TestSendCommandInput_Validate_CustomDefaults(t *testing.T) { ShardID: "game", CommandName: "create-player", ShardURL: "http://custom:9999", - Email: "custom@example.com", + PlayerID: "custom-player", Region: "ap-southeast-1", } @@ -115,7 +115,7 @@ func TestSendCommandInput_Validate_CustomDefaults(t *testing.T) { require.NoError(t, err) assert.Equal(t, "http://custom:9999", input.ShardURL) - assert.Equal(t, "custom@example.com", input.Email) + assert.Equal(t, "custom-player", input.PlayerID) assert.Equal(t, "ap-southeast-1", input.Region) } diff --git a/cli/commands/mcp/tool_send_command.go b/cli/commands/mcp/tool_send_command.go index 4ab1850e3..63c968307 100644 --- a/cli/commands/mcp/tool_send_command.go +++ b/cli/commands/mcp/tool_send_command.go @@ -25,7 +25,7 @@ type SendCommandInput struct { Payload map[string]any `json:"payload" jsonschema_description:"JSON payload for the command (the command's input data)"` ShardURL string `json:"shard_url,omitempty" jsonschema_description:"Cardinal shard API URL; auto-resolved (per instance) from the cluster when omitted. When set, the operator is not contacted: pair it with instance_name (the exact instance, e.g. 'game-2') when targeting a non-default pod so the request address matches the shard. Also pass organization/project to skip the cluster lookup entirely; otherwise they're still auto-resolved via a cluster call."` OperatorURL string `json:"operator_url,omitempty" jsonschema_description:"cardinal-operator URL used to resolve instance_name (defaults to http://localhost:8090 for local dev)"` - Email string `json:"email,omitempty" jsonschema_description:"Email for dev auth (defaults to mcp@dev.local)"` + PlayerID string `json:"player_id,omitempty" jsonschema_description:"Player ID for dev auth (defaults to mcp-dev-player)"` Region string `json:"region,omitempty" jsonschema_description:"Service address region (defaults to us-west1 for local dev)"` } @@ -99,7 +99,7 @@ func sendCommandHandler( client := cardinalv1connect.NewCardinalServiceClient( &http.Client{Timeout: defaultCommandTimeout}, target.shardURL, - connect.WithInterceptors(&devAuthInterceptor{email: args.Email}), + connect.WithInterceptors(&devAuthInterceptor{playerID: args.PlayerID}), ) req := connect.NewRequest(&cardinalv1.SendCommandRequest{ @@ -143,8 +143,8 @@ func (s *SendCommandInput) validate() error { return eris.New("command_name is required") } // Use defaults for optional fields (ShardURL is resolved in the handler). - if s.Email == "" { - s.Email = defaultDevEmail + if s.PlayerID == "" { + s.PlayerID = defaultDevPlayerID } if s.Region == "" { s.Region = defaultRegion diff --git a/cli/commands/mcp/util.go b/cli/commands/mcp/util.go index 403b05797..3939377f7 100644 --- a/cli/commands/mcp/util.go +++ b/cli/commands/mcp/util.go @@ -28,9 +28,9 @@ import ( // ------------------------------------------------------------------------------------------------- const ( - // defaultDevEmail is the default email used for dev auth. - defaultDevEmail = "mcp@dev.local" - // devPersonaID is a placeholder persona (regex-safe); the shard overwrites Persona.Id from X-Email. + // defaultDevPlayerID is the default player ID used for dev auth. + defaultDevPlayerID = "mcp-dev-player" + // devPersonaID is a placeholder persona (regex-safe); the shard overwrites Persona.Id from X-Player-Id. devPersonaID = "-1" // defaultRegion is the region local shards register with (CARDINAL_REGION); // it must match or a command reaches no responders. @@ -39,29 +39,29 @@ const ( defaultCommandTimeout = 30 * time.Second ) -// devAuthInterceptor implements connect.Interceptor to add the X-Email header to all requests. +// devAuthInterceptor implements connect.Interceptor to add the X-Player-Id header to all requests. // // Cardinal's dev auth middleware (AuthModeDev) requires this header to authenticate requests in -// development mode. The header value is used to look up or create a persona ID for the request. +// development mode. The header value is the player ID for the request. type devAuthInterceptor struct { - email string + playerID string } -// WrapUnary injects the X-Email header on unary RPCs (SendCommand) — the +// WrapUnary injects the X-Player-Id header on unary RPCs (SendCommand) — the // only interceptor path the MCP tools use. func (i *devAuthInterceptor) WrapUnary(next connect.UnaryFunc) connect.UnaryFunc { return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) { - req.Header().Set("X-Email", i.email) + req.Header().Set("X-Player-Id", i.playerID) return next(ctx, req) } } -// WrapStreamingClient injects the X-Email header on streaming clients. Required +// WrapStreamingClient injects the X-Player-Id header on streaming clients. Required // by connect.Interceptor; the MCP tools make no streaming calls. func (i *devAuthInterceptor) WrapStreamingClient(next connect.StreamingClientFunc) connect.StreamingClientFunc { return func(ctx context.Context, spec connect.Spec) connect.StreamingClientConn { conn := next(ctx, spec) - conn.RequestHeader().Set("X-Email", i.email) + conn.RequestHeader().Set("X-Player-Id", i.playerID) return conn } } diff --git a/docs/cardinal/client-integration.mdx b/docs/cardinal/client-integration.mdx index 251a95288..9ba88b99b 100644 --- a/docs/cardinal/client-integration.mdx +++ b/docs/cardinal/client-integration.mdx @@ -45,20 +45,20 @@ Create a client with a configuration object that specifies the auth URL and regi ## Authentication -Sign in to authenticate the user. You can retrieve the current user's information and persona after signing in. +Restore the saved session at startup, or sign in. Cardinal identifies the player by the stable player ID in the game token. ```csharp - var result = await client.SignInAsync(); - if (result.Error == null) - { - var user = result.Data; - Debug.Log($"Signed in as {user.Email} (Persona: {user.PersonaId})"); - } + var result = await client.RestoreSessionAsync(); + if (result.Error is AuthNoSavedSessionException) + result = await client.SignInAsync(); // or SignInAsGuestAsync() + + if (result.Player is PlayerState.Registered player) + Debug.Log($"Signed in as {player.Email} (Player: {player.Id})"); - // Get current user - var currentUser = client.GetUser(); + // Read the current player snapshot + var current = client.Player; ``` From 9af74c78df1545436e5bf7bde6f9448e9f5bfbd9 Mon Sep 17 00:00:00 2001 From: Ryandi Tjia Date: Fri, 2 Oct 2026 20:52:52 +0700 Subject: [PATCH 7/9] fix(cardinal): keep trimming trailing slashes from the auth URL Co-Authored-By: Claude Opus 5.5 (1M context) --- pkg/cardinal/service.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cardinal/service.go b/pkg/cardinal/service.go index b493f9dff..5a40b67af 100644 --- a/pkg/cardinal/service.go +++ b/pkg/cardinal/service.go @@ -738,7 +738,7 @@ type authenticatorArgus struct { func newAuthenticatorArgus(argusAuthURL, organization, project string) (*authenticatorArgus, error) { assert.That(argusAuthURL != "", "Should've validated the URL") - jwksURL := argusAuthURL + "/auth/jwks" + jwksURL := strings.TrimRight(argusAuthURL, "/") + "/auth/jwks" client := &http.Client{ Timeout: 3 * time.Second, } From 73a3a584b860e0568a99955091a3150a3b30101f Mon Sep 17 00:00:00 2001 From: Ryandi Tjia Date: Fri, 2 Oct 2026 21:04:01 +0700 Subject: [PATCH 8/9] refactor(cardinal): match main's JWKS URL and fix the test instead Co-Authored-By: Claude Opus 5.5 (1M context) --- pkg/cardinal/auth_internal_test.go | 2 +- pkg/cardinal/service.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/cardinal/auth_internal_test.go b/pkg/cardinal/auth_internal_test.go index be568068d..bd2f48d09 100644 --- a/pkg/cardinal/auth_internal_test.go +++ b/pkg/cardinal/auth_internal_test.go @@ -19,7 +19,7 @@ func TestAuthenticatorArgusAcceptsGamePlayerToken(t *testing.T) { require.NoError(t, err) server := newAuthTestServer(t, publicKey) - authenticator, err := newAuthenticatorArgus(server.URL+"/", "argus", "rampage") + authenticator, err := newAuthenticatorArgus(server.URL, "argus", "rampage") require.NoError(t, err) token := signGameToken(t, privateKey, jwt.RegisteredClaims{ diff --git a/pkg/cardinal/service.go b/pkg/cardinal/service.go index 5a40b67af..b493f9dff 100644 --- a/pkg/cardinal/service.go +++ b/pkg/cardinal/service.go @@ -738,7 +738,7 @@ type authenticatorArgus struct { func newAuthenticatorArgus(argusAuthURL, organization, project string) (*authenticatorArgus, error) { assert.That(argusAuthURL != "", "Should've validated the URL") - jwksURL := strings.TrimRight(argusAuthURL, "/") + "/auth/jwks" + jwksURL := argusAuthURL + "/auth/jwks" client := &http.Client{ Timeout: 3 * time.Second, } From 3254b70589ea8fd23b4474b08557328827d04223 Mon Sep 17 00:00:00 2001 From: Ryandi Tjia Date: Sat, 3 Oct 2026 11:39:49 +0700 Subject: [PATCH 9/9] docs(cardinal): update Unity auth and shard examples to the current SDK API Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/cardinal/client-integration.mdx | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/docs/cardinal/client-integration.mdx b/docs/cardinal/client-integration.mdx index 9ba88b99b..cc13a9d6e 100644 --- a/docs/cardinal/client-integration.mdx +++ b/docs/cardinal/client-integration.mdx @@ -45,20 +45,26 @@ Create a client with a configuration object that specifies the auth URL and regi ## Authentication -Restore the saved session at startup, or sign in. Cardinal identifies the player by the stable player ID in the game token. +`client.AuthStatus` says what the game can do: `SignedOut`, `NeedsRefresh` (a saved login needs a fresh game token), or `Ready`. `client.Player` says who is playing. `client.AuthChanged` fires on Unity's main thread whenever either changes. Cardinal identifies the player by the stable player ID in the game token. ```csharp - var result = await client.RestoreSessionAsync(); - if (result.Error is AuthNoSavedSessionException) - result = await client.SignInAsync(); // or SignInAsGuestAsync() + client.AuthChanged += Render; + Render(); + if (client.AuthStatus == AuthStatus.NeedsRefresh) + await client.RefreshAuthAsync(); - if (result.Player is PlayerState.Registered player) - Debug.Log($"Signed in as {player.Email} (Player: {player.Id})"); + // On a sign-in button: + var result = await client.SignInAsync(); // or SignInAsGuestAsync() + if (result.Error is AuthAttemptFailedException failed) + Debug.LogWarning($"Sign-in failed: {failed.Code}"); - // Read the current player snapshot - var current = client.Player; + void Render() + { + if (client.Player is PlayerState.Registered player) + Debug.Log($"Signed in as {player.Email} (Player: {player.Id})"); + } ``` @@ -100,7 +106,7 @@ To connect, specify the shard's address (region, organization, project, and shar ShardId = "game-shard" }; - var shard = client.ConnectShard(addr); + var shard = await client.ConnectShardAsync(addr); ```