Skip to content

Commit 0816c1d

Browse files
authored
test(broker): add direct cross-user isolation test for CredentialResolver (#690)
Backlog follow-up to MCP-1039 (#688), which verified user isolation only structurally (every lookup is store.Get(userID, serverKey)-keyed with no shared fallback). This adds an end-to-end behavioural guard: seed user B's credential and assert Resolve as user A for the same serverKey falls closed (token-exchange error / NotConnectedError) and never returns user B's token. The seeded user B credential is valid and not near expiry, so a regression dropping the per-user keying would make acquire() return it directly from cache — the test fails in exactly that case (verified by simulating a shared fallback). Covers both token_exchange and oauth_connect modes. Related #688
1 parent c7a78b0 commit 0816c1d

1 file changed

Lines changed: 94 additions & 0 deletions

File tree

internal/serveredition/broker/credential_resolver_test.go

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -483,3 +483,97 @@ func TestResolve_ConnectFlow_RefreshFails_ReturnsReconnectError(t *testing.T) {
483483
t.Fatalf("expected the connect URL to be built once, got %d", c)
484484
}
485485
}
486+
487+
// TestResolve_CrossUserIsolation_NeverReturnsAnotherUsersCredential is the
488+
// direct cross-user isolation guard (MCP-2578, backlog follow-up to MCP-1039 /
489+
// #688). #688 verified isolation only structurally (every lookup is
490+
// store.Get(userID, serverKey)-keyed with no shared fallback); this asserts the
491+
// behaviour end-to-end: seed user B's credential, Resolve as user A for the
492+
// SAME serverKey, and prove user A gets the fail-closed path and NEVER user B's
493+
// token.
494+
//
495+
// The seeded user B credential is deliberately VALID and not near expiry, so a
496+
// regression that dropped the per-user keying (a shared/static fallback) would
497+
// make acquire() return it directly from cache (FR-014). With correct keying,
498+
// store.Get(userA, key) misses and user A falls through to its own — absent —
499+
// acquisition path, which fails closed.
500+
func TestResolve_CrossUserIsolation_NeverReturnsAnotherUsersCredential(t *testing.T) {
501+
const userBToken = "userB-secret-token-MUST-NOT-LEAK"
502+
503+
t.Run("token_exchange mode falls closed, not to user B's cache", func(t *testing.T) {
504+
store := newFakeStore()
505+
server := httpServer("grafana", tokenExchangeBroker())
506+
key := oauth.GenerateServerKey(server.Name, server.URL)
507+
508+
// Seed user B with a valid, long-lived credential for the same serverKey.
509+
store.seed("userB", key, &UpstreamCredential{
510+
Type: "oauth2",
511+
AccessToken: userBToken,
512+
ExpiresAt: time.Now().Add(time.Hour),
513+
ObtainedVia: "token_exchange",
514+
})
515+
516+
// User A has no credential and its own acquisition path fails (fail closed).
517+
ex := &fakeExchanger{err: errors.New("token exchange failed: status 401, error \"invalid_grant\"")}
518+
r := NewCredentialResolver(ResolverDeps{Store: store, Exchanger: ex})
519+
520+
got, err := r.Resolve(context.Background(), "userA", server)
521+
if err == nil {
522+
t.Fatal("expected user A to fail closed (no per-user credential), got nil error")
523+
}
524+
if got != nil {
525+
t.Fatalf("user A must receive no credential, got %+v", got)
526+
}
527+
// The acquisition path must have been exercised — proving user A did NOT
528+
// short-circuit to user B's cached credential (which a shared fallback
529+
// would do, skipping Exchange entirely).
530+
if c := atomic.LoadInt32(&ex.calls); c != 1 {
531+
t.Fatalf("expected user A to go through its own acquisition (1 Exchange call), got %d — a shared cache fallback would skip it", c)
532+
}
533+
534+
// User B's credential is still intact and retrievable as user B, proving
535+
// the seed was real and the miss for user A is isolation, not absence.
536+
bCred, bErr := store.Get("userB", key)
537+
if bErr != nil || bCred == nil || bCred.AccessToken != userBToken {
538+
t.Fatalf("user B's credential should be intact; got %+v err=%v", bCred, bErr)
539+
}
540+
})
541+
542+
t.Run("oauth_connect mode falls closed to NotConnectedError, not user B's cache", func(t *testing.T) {
543+
store := newFakeStore()
544+
server := httpServer("github", connectBroker())
545+
key := oauth.GenerateServerKey(server.Name, server.URL)
546+
547+
// Seed user B with a valid connect-flow credential for the same serverKey.
548+
store.seed("userB", key, &UpstreamCredential{
549+
Type: "oauth2",
550+
AccessToken: userBToken,
551+
RefreshToken: "userB-refresh",
552+
ExpiresAt: time.Now().Add(time.Hour),
553+
ObtainedVia: "oauth_connect",
554+
})
555+
556+
conn := &fakeConnector{serverKey: key, authURL: "https://idp/authorize?client_id=client&state=state-xyz"}
557+
r := NewCredentialResolver(ResolverDeps{Store: store, Connectors: &fakeConnectorProvider{conn: conn}})
558+
559+
got, err := r.Resolve(context.Background(), "userA", server)
560+
if got != nil {
561+
t.Fatalf("user A must receive no credential, got %+v", got)
562+
}
563+
var nce *NotConnectedError
564+
if !errors.As(err, &nce) {
565+
t.Fatalf("expected user A to fail closed with *NotConnectedError, got %T: %v", err, err)
566+
}
567+
if nce.ConnectURL != conn.authURL {
568+
t.Fatalf("expected the actionable connect URL %q, got %q", conn.authURL, nce.ConnectURL)
569+
}
570+
// User A must be steered into its own connect flow, never handed user B's
571+
// existing connection (no refresh against user B's cached credential).
572+
if c := atomic.LoadInt32(&conn.refreshCalls); c != 0 {
573+
t.Fatalf("user A must not refresh against user B's cached credential, got %d refresh calls", c)
574+
}
575+
if !strings.Contains(err.Error(), conn.authURL) || strings.Contains(err.Error(), userBToken) {
576+
t.Fatalf("error must surface user A's connect URL and never user B's token, got %q", err.Error())
577+
}
578+
})
579+
}

0 commit comments

Comments
 (0)