@@ -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