Skip to content
Open
6 changes: 3 additions & 3 deletions cli/commands/mcp/mcp_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -107,15 +107,15 @@ 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",
}

err := input.validate()
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)
}

Expand Down
8 changes: 4 additions & 4 deletions cli/commands/mcp/tool_send_command.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)"`
}

Expand Down Expand Up @@ -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{
Expand Down Expand Up @@ -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
Expand Down
20 changes: 10 additions & 10 deletions cli/commands/mcp/util.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
}
}
Expand Down
18 changes: 9 additions & 9 deletions docs/cardinal/client-integration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<Tabs>
<Tab title="Unity C#">
```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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This snippet only handles AuthNoSavedSessionException, so a restore failure for any other reason (expired/revoked session, network error) or a failed SignInAsync leaves result.Player empty and the example proceeds to client.Player with no log and no handling. The previous version guarded on result.Error == null; check result.Error after the restore/sign-in chain and bail out before using client.Player.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At docs/cardinal/client-integration.mdx, line 57:

<comment>This snippet only handles `AuthNoSavedSessionException`, so a restore failure for any other reason (expired/revoked session, network error) or a failed `SignInAsync` leaves `result.Player` empty and the example proceeds to `client.Player` with no log and no handling. The previous version guarded on `result.Error == null`; check `result.Error` after the restore/sign-in chain and bail out before using `client.Player`.</comment>

<file context>
@@ -45,20 +45,20 @@ Create a client with a configuration object that specifies the auth URL and regi
+    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})");
 
</file context>

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;
```
</Tab>
<Tab title="TypeScript">
Expand Down
161 changes: 161 additions & 0 deletions pkg/cardinal/auth_internal_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
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+"/", "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)),
})
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) {
Comment thread
ryanditjia marked this conversation as resolved.
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, "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
}{
{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: "missing expiry",
key: privateKey,
claims: jwt.RegisteredClaims{
Subject: validClaims.Subject, Issuer: validClaims.Issuer, Audience: validClaims.Audience,
},
},
{
name: "expired",
key: privateKey,
claims: jwt.RegisteredClaims{
Subject: validClaims.Subject,
Issuer: validClaims.Issuer,
Audience: validClaims.Audience,
ExpiresAt: jwt.NewNumericDate(
time.Now().Add(-time.Minute),
),
},
},
{
name: "missing subject",
key: privateKey,
claims: jwt.RegisteredClaims{
Issuer: validClaims.Issuer, Audience: validClaims.Audience, ExpiresAt: validClaims.ExpiresAt,
},
},
} {
t.Run(test.name, func(t *testing.T) {
token := signGameToken(t, test.key, test.claims)
_, authErr := authenticator.authenticate(context.Background(), requestWithBearer(t, token))
Comment thread
ryanditjia marked this conversation as resolved.
require.Error(t, authErr)
Comment thread
ryanditjia marked this conversation as resolved.
})
}

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) {
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) string {
t.Helper()
token := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims)
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
}
Loading
Loading