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
24 changes: 15 additions & 9 deletions docs/cardinal/client-integration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -45,20 +45,26 @@ 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.
`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.

<Tabs>
<Tab title="Unity C#">
```csharp
var result = await client.SignInAsync();
if (result.Error == null)
client.AuthChanged += Render;
Render();
if (client.AuthStatus == AuthStatus.NeedsRefresh)
await client.RefreshAuthAsync();
Comment on lines +53 to +56

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: The startup sample never handles the SignedOut case, even though the paragraph above says AuthStatus tells the game what it can do. A fresh install (no saved login) follows this snippet and gets no render and no direction to sign in; a failed RefreshAuthAsync is likewise silently swallowed. Render the SignedOut state and make refresh failure visible so readers following the sample end up authenticated or with a clear next step.

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 53:

<comment>The startup sample never handles the `SignedOut` case, even though the paragraph above says `AuthStatus` tells the game what it can do. A fresh install (no saved login) follows this snippet and gets no render and no direction to sign in; a failed `RefreshAuthAsync` is likewise silently swallowed. Render the `SignedOut` state and make refresh failure visible so readers following the sample end up authenticated or with a clear next step.</comment>

<file context>
@@ -45,20 +45,26 @@ Create a client with a configuration object that specifies the auth URL and regi
-    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)
</file context>
Suggested change
client.AuthChanged += Render;
Render();
if (client.AuthStatus == AuthStatus.NeedsRefresh)
await client.RefreshAuthAsync();
client.AuthChanged += Render;
Render();
if (client.AuthStatus == AuthStatus.SignedOut)
Debug.Log("No saved login — sign in to continue.");
else if (client.AuthStatus == AuthStatus.NeedsRefresh)
await client.RefreshAuthAsync();


// 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}");

void Render()
{
var user = result.Data;
Debug.Log($"Signed in as {user.Email} (Persona: {user.PersonaId})");
if (client.Player is PlayerState.Registered player)
Debug.Log($"Signed in as {player.Email} (Player: {player.Id})");
}

// Get current user
var currentUser = client.GetUser();
```
</Tab>
<Tab title="TypeScript">
Expand Down Expand Up @@ -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);
```
</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