@@ -267,7 +267,7 @@ void acquireTokenForAgent_withOid_acquiresUserToken() throws Exception {
267267 // ========================================================================
268268
269269 @ Test
270- void acquireTokenForAgent_samAgentId_reusesCca () throws Exception {
270+ void acquireTokenForAgent_sameAgentId_reusesCca () throws Exception {
271271 // Arrange
272272 DefaultHttpClient httpClientMock = mock (DefaultHttpClient .class );
273273
@@ -827,165 +827,4 @@ void acquireTokenForAgent_leg2CacheIsolation_credentialFmiPathPreventsCollision(
827827 assertEquals (3 , agentCca .tokenCache .accessTokens .size (),
828828 "Agent CCA should have 3 tokens: 1 Leg 2 assertion + 2 user tokens" );
829829 }
830-
831- // ========================================================================
832- // Scope collision test: caller uses api://AzureADTokenExchange/.default
833- // which is the same scope used internally for Leg 2 assertion tokens.
834- // This probes whether the flat cache can distinguish between an app-level
835- // assertion token and a user-level FIC token when both share the same scope.
836- // ========================================================================
837-
838- @ Test
839- void acquireTokenForAgent_callerScopeMatchesInternalAssertionScope_noCollision () throws Exception {
840- // The internal Leg 2 uses api://AzureADTokenExchange/.default for the assertion token.
841- // If an external caller also requests that scope for a user token, the agent CCA's
842- // flat cache will contain both an app token and a user token with the same scope.
843- Set <String > tokenExchangeScope = Collections .singleton ("api://AzureADTokenExchange/.default" );
844-
845- String user3Upn = "charlie@contoso.com" ;
846- String user3Oid = "33333333-3333-3333-3333-333333333333" ;
847-
848- DefaultHttpClient httpClientMock = mock (DefaultHttpClient .class );
849-
850- when (httpClientMock .send (any (HttpRequest .class )))
851- .thenReturn (
852- // --- Alice: normal scopes (Legs 1+2+3 = 3 HTTP calls) ---
853- createAppTokenResponse ("fmi-credential-token" ),
854- createAppTokenResponse ("assertion-token-leg2" ),
855- createUserTokenResponse ("alice-graph-token" , USER1_UPN , USER1_OID ),
856-
857- // --- Bob: api://AzureADTokenExchange scope (Leg 3 only = 1 HTTP call) ---
858- // Legs 1+2 are cached from Alice's flow.
859- // Leg 3 creates a USER token with the same scope as the Leg 2 app token.
860- createUserTokenResponse ("bob-exchange-token" , USER2_UPN , USER2_OID ),
861-
862- // --- Charlie: non-agent client_credentials with same exchange scope ---
863- // This goes through the BLUEPRINT CCA (not the agent CCA).
864- createAppTokenResponse ("charlie-exchange-app-token" ));
865-
866- ConfidentialClientApplication blueprintCca = createBlueprintCca (httpClientMock );
867-
868- AgentIdentity aliceAgent = AgentIdentity .withUsername (AGENT_APP_ID , USER1_UPN );
869- AgentIdentity bobAgent = AgentIdentity .withUsername (AGENT_APP_ID , USER2_UPN );
870-
871- // ---- Step 1: Alice with normal Graph scopes ----
872- IAuthenticationResult aliceResult = blueprintCca .acquireTokenForAgent (
873- AcquireTokenForAgentParameters .builder (CALLER_SCOPES , aliceAgent ).build ()
874- ).get ();
875-
876- assertEquals ("alice-graph-token" , aliceResult .accessToken ());
877- verify (httpClientMock , times (3 )).send (any (HttpRequest .class ));
878-
879- // ---- Step 2: Bob with api://AzureADTokenExchange/.default ----
880- // This is the dangerous scenario: the agent CCA already has an app token
881- // (assertion-token-leg2) for this exact scope from Leg 2. Now we're asking
882- // for a USER token with the same scope. If the cache lookup for Leg 2 on
883- // future calls uses findAny() without filtering by homeAccountId, it could
884- // return Bob's user token instead of the assertion token.
885- IAuthenticationResult bobResult = blueprintCca .acquireTokenForAgent (
886- AcquireTokenForAgentParameters .builder (tokenExchangeScope , bobAgent ).build ()
887- ).get ();
888-
889- assertEquals ("bob-exchange-token" , bobResult .accessToken ());
890- verify (httpClientMock , times (4 )).send (any (HttpRequest .class )); // +1 for Bob's Leg 3
891-
892- // ---- Step 3: Verify agent CCA cache state ----
893- ConfidentialClientApplication agentCca =
894- blueprintCca .agentCcaCache .get ("agent_" + AGENT_APP_ID );
895- assertNotNull (agentCca );
896-
897- // Agent CCA should have:
898- // - 1 Leg 2 assertion token (app token, scope=api://AzureADTokenExchange/.default, homeAccountId="")
899- // - 1 Alice user token (scope=graph.microsoft.com/.default, homeAccountId=alice-oid.tenant)
900- // - 1 Bob user token (scope=api://AzureADTokenExchange/.default, homeAccountId=bob-oid.tenant)
901- assertEquals (3 , agentCca .tokenCache .accessTokens .size (),
902- "Agent CCA cache should have 3 tokens (1 assertion + 2 user)" );
903-
904- // Count how many tokens have api://AzureADTokenExchange scope in the agent CCA.
905- // There should be 2: the Leg 2 assertion token (app) and Bob's user token.
906- long exchangeScopeTokenCount = agentCca .tokenCache .accessTokens .values ().stream ()
907- .filter (at -> at .target () != null &&
908- at .target ().toLowerCase ().contains ("azureadtokenexchange" ))
909- .count ();
910- assertEquals (2 , exchangeScopeTokenCount ,
911- "Agent CCA should have 2 tokens with the exchange scope (1 app + 1 user)" );
912-
913- // ---- Step 4: Alice again — should still return from cache (no collision) ----
914- IAuthenticationResult aliceAgain = blueprintCca .acquireTokenForAgent (
915- AcquireTokenForAgentParameters .builder (CALLER_SCOPES , aliceAgent ).build ()
916- ).get ();
917-
918- assertEquals ("alice-graph-token" , aliceAgain .accessToken ());
919- verify (httpClientMock , times (4 )).send (any (HttpRequest .class )); // still 4
920-
921- // ---- Step 5: Bob again — this is the critical test ----
922- // When we request Bob's token again, the silent lookup should find Bob's
923- // USER token (not the Leg 2 assertion token) even though both share the same scope.
924- IAuthenticationResult bobAgain = blueprintCca .acquireTokenForAgent (
925- AcquireTokenForAgentParameters .builder (tokenExchangeScope , bobAgent ).build ()
926- ).get ();
927-
928- assertEquals ("bob-exchange-token" , bobAgain .accessToken (),
929- "Bob's silent retrieval should return the user token, not the assertion token" );
930- verify (httpClientMock , times (4 )).send (any (HttpRequest .class )); // still 4
931-
932- // ---- Step 6: Now trigger a NEW agent flow for a different user ----
933- // This is where the Leg 2 collision matters most. A new user triggers Leg 2
934- // (acquireToken(ClientCredentialParameters)) which uses getApplicationAccessTokenCacheEntity.
935- // If that lookup returns Bob's USER token instead of the app assertion token,
936- // Leg 3 will use the wrong assertion and fail or return incorrect results.
937- //
938- // We queue a Leg 3 response for a hypothetical third user to test this.
939- // If Leg 2 correctly returns the cached assertion token, only 1 HTTP call (Leg 3) fires.
940- // If Leg 2 gets a collision and returns Bob's user token as the assertion, the behavior
941- // will be unpredictable (wrong assertion value, possibly an error, or 2+ HTTP calls).
942-
943- String user3_upn = "charlie@contoso.com" ;
944- String user3_oid = "33333333-3333-3333-3333-333333333333" ;
945-
946- // Clear invocation history so we can count only Charlie's HTTP calls
947- clearInvocations (httpClientMock );
948-
949- // Reset mock for the next sequence: only Leg 3 should fire (1 call).
950- // If Leg 2 has a cache collision, it may re-fetch (2+ calls), or if
951- // the wrong token is used as the assertion, it may error out.
952- when (httpClientMock .send (any (HttpRequest .class )))
953- .thenReturn (
954- createUserTokenResponse ("charlie-exchange-token" , user3_upn , user3_oid ));
955-
956- AgentIdentity charlieAgent = AgentIdentity .withUsername (AGENT_APP_ID , user3_upn );
957- IAuthenticationResult charlieResult = blueprintCca .acquireTokenForAgent (
958- AcquireTokenForAgentParameters .builder (tokenExchangeScope , charlieAgent ).build ()
959- ).get ();
960-
961- // If this assertion fails, it means Leg 2 returned Bob's user token instead of the
962- // cached assertion token, causing downstream problems.
963- assertEquals ("charlie-exchange-token" , charlieResult .accessToken (),
964- "Charlie should get a fresh Leg 3 token using the cached Leg 2 assertion" );
965-
966- // Verify only 1 new HTTP call was made (Leg 3 for Charlie).
967- // If 2+ new calls were made, Leg 2 had to re-fetch because of a cache collision.
968- verify (httpClientMock , times (1 )).send (any (HttpRequest .class ));
969-
970- // ---- Step 7: Final cache state verification ----
971- assertEquals (4 , agentCca .tokenCache .accessTokens .size (),
972- "Agent CCA should now have 4 tokens: 1 assertion + 3 user tokens" );
973-
974- // ---- Step 8: Non-agent client_credentials with exchange scope on BLUEPRINT ----
975- // This tests that the blueprint's own cache doesn't collide with the FMI token
976- // (which also targets api://AzureADTokenExchange/.default but has an extCacheKeyHash).
977- clearInvocations (httpClientMock );
978- when (httpClientMock .send (any (HttpRequest .class )))
979- .thenReturn (createAppTokenResponse ("blueprint-exchange-app-token" ));
980-
981- IAuthenticationResult blueprintExchangeResult = blueprintCca .acquireToken (
982- ClientCredentialParameters .builder (tokenExchangeScope ).build ()
983- ).get ();
984-
985- // The FMI token has extCacheKeyHash set (from fmi_path), so a plain
986- // client_credentials call without fmi_path should NOT match it.
987- // It should trigger a new HTTP call and store a separate cache entry.
988- assertEquals ("blueprint-exchange-app-token" , blueprintExchangeResult .accessToken (),
989- "Blueprint's non-FMI exchange token should not collide with the FMI token" );
990- }
991830}
0 commit comments