diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AbstractApplicationBase.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AbstractApplicationBase.java index 5cc20f1a..19064264 100644 --- a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AbstractApplicationBase.java +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AbstractApplicationBase.java @@ -115,6 +115,10 @@ private AuthenticationResultSupplier getAuthenticationResultSupplier(MsalRequest supplier = new AcquireTokenByUserFederatedIdentityCredentialSupplier( (ConfidentialClientApplication) this, (UserFederatedIdentityCredentialRequest) msalRequest); + } else if (msalRequest instanceof AcquireTokenForAgentRequest) { + supplier = new AcquireTokenForAgentSupplier( + (ConfidentialClientApplication) this, + (AcquireTokenForAgentRequest) msalRequest); } else if (msalRequest instanceof ManagedIdentityRequest) { supplier = new AcquireTokenByManagedIdentitySupplier( (ManagedIdentityApplication) this, diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AcquireTokenForAgentParameters.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AcquireTokenForAgentParameters.java new file mode 100644 index 00000000..4dad1a69 --- /dev/null +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AcquireTokenForAgentParameters.java @@ -0,0 +1,176 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j; + +import java.util.Map; +import java.util.Set; + +import static com.microsoft.aad.msal4j.ParameterValidationUtils.validateNotNull; + +/** + * Object containing parameters for the composite agent token acquisition flow. + * This orchestrates the full three-leg FMI/FIC token exchange: the developer passes + * scopes and an {@link AgentIdentity}, and MSAL handles Legs 1-3 internally. + *

+ * Can be used as parameter to + * {@link ConfidentialClientApplication#acquireTokenForAgent(AcquireTokenForAgentParameters)} + */ +public class AcquireTokenForAgentParameters implements IAcquireTokenParameters { + + private Set scopes; + private AgentIdentity agentIdentity; + private boolean forceRefresh; + private ClaimsRequest claims; + private Map extraHttpHeaders; + private Map extraQueryParameters; + private String tenant; + + private AcquireTokenForAgentParameters( + Set scopes, + AgentIdentity agentIdentity, + boolean forceRefresh, + ClaimsRequest claims, + Map extraHttpHeaders, + Map extraQueryParameters, + String tenant) { + this.scopes = scopes; + this.agentIdentity = agentIdentity; + this.forceRefresh = forceRefresh; + this.claims = claims; + this.extraHttpHeaders = extraHttpHeaders; + this.extraQueryParameters = extraQueryParameters; + this.tenant = tenant; + } + + /** + * Builder for {@link AcquireTokenForAgentParameters}. + * + * @param scopes scopes application is requesting access to + * @param agentIdentity the identity of the agent and (optionally) the target user + * @return builder that can be used to construct AcquireTokenForAgentParameters + */ + public static AcquireTokenForAgentParametersBuilder builder( + Set scopes, AgentIdentity agentIdentity) { + validateNotNull("scopes", scopes); + validateNotNull("agentIdentity", agentIdentity); + + return new AcquireTokenForAgentParametersBuilder() + .scopes(scopes) + .agentIdentity(agentIdentity); + } + + public Set scopes() { + return this.scopes; + } + + public AgentIdentity agentIdentity() { + return this.agentIdentity; + } + + public boolean forceRefresh() { + return this.forceRefresh; + } + + public ClaimsRequest claims() { + return this.claims; + } + + public Map extraHttpHeaders() { + return this.extraHttpHeaders; + } + + public Map extraQueryParameters() { + return this.extraQueryParameters; + } + + public String tenant() { + return this.tenant; + } + + public static class AcquireTokenForAgentParametersBuilder { + private Set scopes; + private AgentIdentity agentIdentity; + private boolean forceRefresh; + private ClaimsRequest claims; + private Map extraHttpHeaders; + private Map extraQueryParameters; + private String tenant; + + AcquireTokenForAgentParametersBuilder() { + } + + AcquireTokenForAgentParametersBuilder scopes(Set scopes) { + this.scopes = scopes; + return this; + } + + AcquireTokenForAgentParametersBuilder agentIdentity(AgentIdentity agentIdentity) { + this.agentIdentity = agentIdentity; + return this; + } + + /** + * If true, the request will ignore cached access tokens on read, but will still write + * them to the cache once obtained from the identity provider. The default is false. + * + * @param forceRefresh whether to bypass the user token cache + * @return this builder + */ + public AcquireTokenForAgentParametersBuilder forceRefresh(boolean forceRefresh) { + this.forceRefresh = forceRefresh; + return this; + } + + /** + * Claims to be requested through the OIDC claims request parameter, allowing requests + * for standard and custom claims. + * + * @param claims {@link ClaimsRequest} + * @return this builder + */ + public AcquireTokenForAgentParametersBuilder claims(ClaimsRequest claims) { + this.claims = claims; + return this; + } + + /** + * Adds additional headers to the token request. + * + * @param extraHttpHeaders headers to include + * @return this builder + */ + public AcquireTokenForAgentParametersBuilder extraHttpHeaders(Map extraHttpHeaders) { + this.extraHttpHeaders = extraHttpHeaders; + return this; + } + + /** + * Adds additional query parameters to the token request. + * + * @param extraQueryParameters query parameters to include + * @return this builder + */ + public AcquireTokenForAgentParametersBuilder extraQueryParameters(Map extraQueryParameters) { + this.extraQueryParameters = extraQueryParameters; + return this; + } + + /** + * Sets the tenant for the request, overriding the application's configured authority. + * + * @param tenant tenant ID or domain + * @return this builder + */ + public AcquireTokenForAgentParametersBuilder tenant(String tenant) { + this.tenant = tenant; + return this; + } + + public AcquireTokenForAgentParameters build() { + return new AcquireTokenForAgentParameters( + scopes, agentIdentity, forceRefresh, claims, + extraHttpHeaders, extraQueryParameters, tenant); + } + } +} diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AcquireTokenForAgentRequest.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AcquireTokenForAgentRequest.java new file mode 100644 index 00000000..e73480af --- /dev/null +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AcquireTokenForAgentRequest.java @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j; + +/** + * Internal request class for the composite agent token acquisition flow. + * This request does not create its own grant; actual grants are produced + * by the inner CCA calls orchestrated by {@link AcquireTokenForAgentSupplier}. + */ +class AcquireTokenForAgentRequest extends MsalRequest { + + AcquireTokenForAgentParameters parameters; + + AcquireTokenForAgentRequest(AcquireTokenForAgentParameters parameters, + ConfidentialClientApplication application, + RequestContext requestContext) { + super(application, null, requestContext); + this.parameters = parameters; + } +} diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AcquireTokenForAgentSupplier.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AcquireTokenForAgentSupplier.java new file mode 100644 index 00000000..47b67edf --- /dev/null +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AcquireTokenForAgentSupplier.java @@ -0,0 +1,282 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.net.MalformedURLException; +import java.util.Collections; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; + +/** + * Orchestrates a multi-leg token acquisition for agent scenarios. + *

+ * Two CCA instances are involved: + *

    + *
  1. Blueprint CCA — the developer-created CCA that holds the real credential + * (certificate, secret, etc.). It only participates in Leg 1: acquiring an FMI + * credential via AcquireTokenForClient + WithFmiPath. Its app token cache stores + * the FMI credential.
  2. + *
  3. Agent CCA — an internal CCA keyed by the agent's app ID, created and cached + * by this class. Its client assertion callback delegates to the Blueprint for FMI + * credentials (Leg 1). It handles both Leg 2 (AcquireTokenForClient for the assertion + * token) and Leg 3 (AcquireTokenByUserFederatedIdentityCredential for the user token).
  4. + *
+ *

+ * Caching behavior: + *

+ */ +class AcquireTokenForAgentSupplier extends AuthenticationResultSupplier { + + private static final Logger LOG = LoggerFactory.getLogger(AcquireTokenForAgentSupplier.class); + private static final Set TOKEN_EXCHANGE_SCOPE = + Collections.singleton("api://AzureADTokenExchange/.default"); + private static final String AGENT_CCA_KEY_PREFIX = "agent_"; + + private final AcquireTokenForAgentRequest agentRequest; + private final ConfidentialClientApplication blueprintApplication; + + AcquireTokenForAgentSupplier(ConfidentialClientApplication clientApplication, + AcquireTokenForAgentRequest agentRequest) { + super(clientApplication, agentRequest); + this.agentRequest = agentRequest; + this.blueprintApplication = clientApplication; + } + + @Override + AuthenticationResult execute() throws Exception { + AgentIdentity agentIdentity = agentRequest.parameters.agentIdentity(); + String agentAppId = agentIdentity.agentApplicationId(); + Set callerScopes = agentRequest.parameters.scopes(); + + // Retrieve (or create) the internal Agent CCA for this agent app ID. + ConfidentialClientApplication agentCca = getOrCreateAgentCca(agentAppId); + + if (!agentIdentity.hasUserIdentifier()) { + // App-only flow: AcquireTokenForClient has built-in cache-first logic, + // so no explicit silent pre-check is needed. + LOG.debug("App-only agent flow for agent app ID: {}", agentAppId); + return (AuthenticationResult) joinAndUnwrap( + agentCca.acquireToken( + ClientCredentialParameters.builder(callerScopes).build())); + } + + // --- User identity flow --- + + // Check the Agent CCA's user token cache for a previously-acquired token for this user. + // ForceRefresh skips this check so a fresh user token is always obtained from the network. + if (!agentRequest.parameters.forceRefresh()) { + AuthenticationResult cachedResult = tryAcquireTokenSilent(agentCca, agentIdentity, callerScopes); + if (cachedResult != null) { + LOG.debug("Returning cached user token for agent app ID: {}", agentAppId); + return cachedResult; + } + } + + // Cache miss (or ForceRefresh) — execute Leg 2 + Leg 3. + + // Leg 2: Acquire an assertion token from the Agent CCA's app token cache (or network). + // The Agent CCA's assertion callback will invoke Leg 1 (FMI credential from Blueprint), + // but AcquireTokenForClient's built-in cache handles repeat calls. + LOG.debug("Executing Leg 2 (assertion token) for agent app ID: {}", agentAppId); + IAuthenticationResult assertionResult = joinAndUnwrap( + agentCca.acquireToken( + ClientCredentialParameters.builder(TOKEN_EXCHANGE_SCOPE).build())); + + String assertion = assertionResult.accessToken(); + + // Leg 3: Exchange the assertion for a user-scoped token via UserFIC. + // The result is written to the Agent CCA's user token cache for future silent retrieval. + LOG.debug("Executing Leg 3 (user FIC token) for agent app ID: {}", agentAppId); + UserFederatedIdentityCredentialParameters ficParams; + if (agentIdentity.userObjectId() != null) { + ficParams = UserFederatedIdentityCredentialParameters + .builder(callerScopes, agentIdentity.userObjectId(), assertion) + .forceRefresh(true) // always fetch from network (we already checked the cache above) + .build(); + } else { + ficParams = UserFederatedIdentityCredentialParameters + .builder(callerScopes, agentIdentity.username(), assertion) + .forceRefresh(true) + .build(); + } + + return (AuthenticationResult) joinAndUnwrap(agentCca.acquireToken(ficParams)); + } + + /** + * Searches the Agent CCA's user token cache for a previously-acquired token + * matching the specified user identity (by OID or UPN). + * Returns null if no matching account exists or the cached token is expired. + */ + private AuthenticationResult tryAcquireTokenSilent( + ConfidentialClientApplication agentCca, + AgentIdentity agentIdentity, + Set scopes) { + try { + Set accounts = joinAndUnwrap(agentCca.getAccounts()); + IAccount matchedAccount = findMatchingAccount(accounts, agentIdentity); + if (matchedAccount == null) { + return null; + } + + SilentParameters silentParams = SilentParameters + .builder(scopes, matchedAccount) + .build(); + + return (AuthenticationResult) joinAndUnwrap( + agentCca.acquireTokenSilently(silentParams)); + } catch (Exception ex) { + // Token expired or requires interaction — fall through to full Leg 2 + Leg 3 flow + LOG.debug("Silent token acquisition failed for agent: {}", ex.getMessage()); + return null; + } + } + + /** + * Finds an account in the Agent CCA's cache that matches the user identity. + * Matches by OID (HomeAccountId objectId) if the caller specified a UUID, + * otherwise by UPN (Account.username). Both comparisons are case-insensitive. + */ + private static IAccount findMatchingAccount(Set accounts, AgentIdentity agentIdentity) { + if (agentIdentity.userObjectId() != null) { + String targetOid = agentIdentity.userObjectId().toString(); + return accounts.stream() + .filter(a -> a.homeAccountId() != null && + targetOid.equalsIgnoreCase(extractOid(a.homeAccountId()))) + .findFirst() + .orElse(null); + } + + return accounts.stream() + .filter(a -> agentIdentity.username().equalsIgnoreCase(a.username())) + .findFirst() + .orElse(null); + } + + /** + * Extracts the OID portion from a homeAccountId (format: "oid.tid"). + */ + private static String extractOid(String homeAccountId) { + int dotIndex = homeAccountId.indexOf('.'); + return dotIndex >= 0 ? homeAccountId.substring(0, dotIndex) : homeAccountId; + } + + // ======================================================================== + // Agent CCA Construction and Configuration + // ======================================================================== + + /** + * Retrieves the cached internal Agent CCA for the given agent app ID, or creates one + * if this is the first call. The Agent CCA is stored in the Blueprint's agentCcaCache + * so its app and user token caches persist across calls. + */ + private ConfidentialClientApplication getOrCreateAgentCca(String agentAppId) { + String key = AGENT_CCA_KEY_PREFIX + agentAppId; + return blueprintApplication.agentCcaCache.computeIfAbsent(key, k -> { + try { + return buildAgentCca(agentAppId); + } catch (MalformedURLException e) { + throw new MsalClientException(e); + } + }); + } + + /** + * Builds a new internal Agent CCA configured with: + *
    + *
  • Client ID = the agent's app ID
  • + *
  • Authority = the Blueprint's resolved authority
  • + *
  • Client assertion callback = Leg 1 (FMI credential from Blueprint)
  • + *
  • App-level config = propagated from the Blueprint
  • + *
+ */ + private ConfidentialClientApplication buildAgentCca(String agentAppId) + throws MalformedURLException { + // Capture only the blueprint reference (long-lived) in the assertion callback. + // Do NOT capture 'this' (per-request state) to avoid pinning stale request data. + final ConfidentialClientApplication blueprint = blueprintApplication; + + IClientCredential assertionCredential = ClientCredentialFactory.createFromCallback( + (AssertionRequestOptions opts) -> { + try { + // Leg 1: Acquire an FMI credential from the Blueprint CCA. + // AcquireTokenForClient has built-in cache-first logic — only the + // first call hits the network; subsequent calls return cached credential. + IAuthenticationResult result = joinAndUnwrap( + blueprint.acquireToken( + ClientCredentialParameters.builder(TOKEN_EXCHANGE_SCOPE) + .fmiPath(agentAppId) + .build())); + return result.accessToken(); + } catch (RuntimeException e) { + throw e; + } catch (Exception e) { + throw new MsalClientException(e); + } + }); + + ConfidentialClientApplication.Builder builder = + ConfidentialClientApplication.builder(agentAppId, assertionCredential) + .authority(blueprint.authority()); + + propagateBlueprintConfig(builder, blueprint); + return builder.build(); + } + + /** + * Propagates app-level configuration from the Blueprint CCA to the Agent CCA builder. + * This ensures the Agent CCA shares the Blueprint's HTTP behavior, logging, instance + * discovery settings, and telemetry identity. + */ + private static void propagateBlueprintConfig( + ConfidentialClientApplication.Builder builder, + ConfidentialClientApplication blueprint) { + // HTTP: share the same HTTP client + if (blueprint.httpClient() != null) { + builder.httpClient(blueprint.httpClient()); + } + + // Logging + builder.logPii(blueprint.logPii()); + + // Instance discovery: honor the Blueprint's settings + builder.instanceDiscovery(blueprint.instanceDiscovery()); + builder.validateAuthority(blueprint.validateAuthority()); + + // Telemetry: attribute network calls to the same caller + if (blueprint.applicationName() != null) { + builder.applicationName(blueprint.applicationName()); + } + if (blueprint.applicationVersion() != null) { + builder.applicationVersion(blueprint.applicationVersion()); + } + } + + /** + * Calls {@link CompletableFuture#join()} and unwraps any {@link CompletionException} + * so the original exception propagates with its correct type. + */ + private static T joinAndUnwrap(CompletableFuture future) throws Exception { + try { + return future.join(); + } catch (CompletionException e) { + Throwable cause = e.getCause(); + if (cause instanceof Exception) { + throw (Exception) cause; + } + throw e; + } + } +} diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AgentIdentity.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AgentIdentity.java new file mode 100644 index 00000000..81f71dfe --- /dev/null +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AgentIdentity.java @@ -0,0 +1,102 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j; + +import java.util.UUID; + +import static com.microsoft.aad.msal4j.ParameterValidationUtils.validateNotBlank; +import static com.microsoft.aad.msal4j.ParameterValidationUtils.validateNotNull; + +/** + * Represents the identity of an agent application and the user it acts on behalf of. + * Used with {@link IConfidentialClientApplication#acquireTokenForAgent(AcquireTokenForAgentParameters)} + * to acquire tokens for agent scenarios using Federated Managed Identity (FMI) and + * User Federated Identity Credentials (UserFIC). + */ +public final class AgentIdentity { + + private final String agentApplicationId; + private UUID userObjectId; + private String username; + + private AgentIdentity(String agentApplicationId) { + validateNotBlank("agentApplicationId", agentApplicationId); + this.agentApplicationId = agentApplicationId; + } + + /** + * Creates an {@link AgentIdentity} that identifies the user by their object ID (OID). + * This is the recommended approach for identifying users in agent scenarios. + * + * @param agentApplicationId the client ID of the agent application + * @param userObjectId the object ID (OID) of the user the agent acts on behalf of + */ + public AgentIdentity(String agentApplicationId, UUID userObjectId) { + this(agentApplicationId); + validateNotNull("userObjectId", userObjectId); + this.userObjectId = userObjectId; + } + + /** + * Creates an {@link AgentIdentity} that identifies the user by their UPN (User Principal Name). + * + * @param agentApplicationId the client ID of the agent application + * @param username the UPN of the user the agent acts on behalf of + * @return an {@link AgentIdentity} configured with the user's UPN + */ + public static AgentIdentity withUsername(String agentApplicationId, String username) { + validateNotBlank("username", username); + AgentIdentity identity = new AgentIdentity(agentApplicationId); + identity.username = username; + return identity; + } + + /** + * Creates an {@link AgentIdentity} for app-only (no user) scenarios, where only Legs 1-2 + * of the agent token acquisition are performed. + * + * @param agentApplicationId the client ID of the agent application + * @return an {@link AgentIdentity} configured for app-only access + */ + public static AgentIdentity appOnly(String agentApplicationId) { + return new AgentIdentity(agentApplicationId); + } + + /** + * Gets the client ID of the agent application. + * + * @return the agent application's client ID + */ + public String agentApplicationId() { + return agentApplicationId; + } + + /** + * Gets the object ID (OID) of the user, if specified. + * + * @return the user's OID, or null if not specified + */ + public UUID userObjectId() { + return userObjectId; + } + + /** + * Gets the UPN of the user, if specified. + * + * @return the user's UPN, or null if not specified + */ + public String username() { + return username; + } + + /** + * Returns whether this identity includes a user identifier (OID or UPN). + * When false, the agent flow is app-only (Legs 1-2 only). + * + * @return true if a user OID or UPN is present + */ + public boolean hasUserIdentifier() { + return userObjectId != null || !StringHelper.isBlank(username); + } +} diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ConfidentialClientApplication.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ConfidentialClientApplication.java index 50df4466..d4824db3 100644 --- a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ConfidentialClientApplication.java +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ConfidentialClientApplication.java @@ -6,6 +6,7 @@ import org.slf4j.LoggerFactory; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; import java.util.function.Function; import static com.microsoft.aad.msal4j.ParameterValidationUtils.validateNotNull; @@ -22,6 +23,12 @@ public class ConfidentialClientApplication extends AbstractClientApplicationBase IClientCredential clientCredential; private boolean sendX5c; + /** + * Cache of internal Agent CCA instances keyed by "agent_" + agentAppId. + * Each agent CCA has its own app and user token caches that persist across calls. + */ + final ConcurrentHashMap agentCcaCache = new ConcurrentHashMap<>(); + /** AppTokenProvider creates a Credential from a function that provides access tokens. The function must be concurrency safe. This is intended only to allow the Azure SDK to cache MSI tokens. It isn't useful to applications in general because the token provider must implement all authentication logic. */ @@ -81,6 +88,24 @@ public CompletableFuture acquireToken(UserFederatedIdenti return this.executeRequest(userFicRequest); } + @Override + public CompletableFuture acquireTokenForAgent(AcquireTokenForAgentParameters parameters) { + validateNotNull("parameters", parameters); + + RequestContext context = new RequestContext( + this, + PublicApi.ACQUIRE_TOKEN_FOR_AGENT, + parameters); + + AcquireTokenForAgentRequest agentRequest = + new AcquireTokenForAgentRequest( + parameters, + this, + context); + + return this.executeRequest(agentRequest); + } + private ConfidentialClientApplication(Builder builder) { super(builder); sendX5c = builder.sendX5c; diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/IConfidentialClientApplication.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/IConfidentialClientApplication.java index eaa7cd2c..bfb89443 100644 --- a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/IConfidentialClientApplication.java +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/IConfidentialClientApplication.java @@ -62,4 +62,18 @@ public interface IConfidentialClientApplication extends IClientApplicationBase { * @return {@link CompletableFuture} containing an {@link IAuthenticationResult} */ CompletableFuture acquireToken(UserFederatedIdentityCredentialParameters parameters); + + /** + * Acquires a token for agent scenarios by orchestrating the full three-leg + * FMI/FIC token exchange. The developer passes scopes and an {@link AgentIdentity}; + * MSAL handles Legs 1-3 internally, including caching intermediate tokens. + *

+ * For user-scoped tokens, the agent identity must include either a UPN + * ({@link AgentIdentity#withUsername}) or an Object ID ({@link AgentIdentity#AgentIdentity(String, java.util.UUID)}). + * For app-only tokens, use {@link AgentIdentity#appOnly}. + * + * @param parameters instance of {@link AcquireTokenForAgentParameters} + * @return {@link CompletableFuture} containing an {@link IAuthenticationResult} + */ + CompletableFuture acquireTokenForAgent(AcquireTokenForAgentParameters parameters); } diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/PublicApi.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/PublicApi.java index faf19722..c30c5c98 100644 --- a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/PublicApi.java +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/PublicApi.java @@ -14,6 +14,7 @@ enum PublicApi { ACQUIRE_TOKEN_FOR_CLIENT(729), ACQUIRE_TOKEN_BY_AUTHORIZATION_CODE(831), ACQUIRE_TOKEN_BY_USER_FEDERATED_IDENTITY_CREDENTIAL(900), + ACQUIRE_TOKEN_FOR_AGENT(1020), ACQUIRE_TOKEN_SILENTLY(800), GET_ACCOUNTS(801), REMOVE_ACCOUNTS(802), diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/AcquireTokenForAgentTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/AcquireTokenForAgentTest.java new file mode 100644 index 00000000..c7ad5b78 --- /dev/null +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/AcquireTokenForAgentTest.java @@ -0,0 +1,374 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Collections; +import java.util.HashMap; +import java.util.Set; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * Tests for the composite AcquireTokenForAgent flow (§10 from AgentIDs_ComponentsReference). + * Validates the three-leg orchestration, internal CCA caching, per-user token isolation, + * and silent retrieval. + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class AcquireTokenForAgentTest { + + private static final String BLUEPRINT_CLIENT_ID = "blueprint-client-id"; + private static final String AUTHORITY = "https://login.microsoftonline.com/tenant/"; + private static final Set CALLER_SCOPES = Collections.singleton("https://graph.microsoft.com/.default"); + + private static final String AGENT_APP_ID = "agent-app-id-abc"; + private static final String TENANT_ID = "f645ad92-e38d-4d1a-b510-d1b09a74a8ca"; + + private static final String USER1_UPN = "alice@contoso.com"; + private static final String USER1_OID = "11111111-1111-1111-1111-111111111111"; + private static final String USER2_UPN = "bob@contoso.com"; + private static final String USER2_OID = "22222222-2222-2222-2222-222222222222"; + + private ConfidentialClientApplication createBlueprintCca(DefaultHttpClient httpClientMock) throws Exception { + return ConfidentialClientApplication.builder( + BLUEPRINT_CLIENT_ID, + ClientCredentialFactory.createFromSecret("secret")) + .authority(AUTHORITY) + .instanceDiscovery(false) + .validateAuthority(false) + .httpClient(httpClientMock) + .build(); + } + + /** + * Creates a simple app-token response (for Leg 1 FMI credential or Leg 2 assertion token). + */ + private HttpResponse createAppTokenResponse(String accessToken) { + HashMap responseValues = new HashMap<>(); + responseValues.put("access_token", accessToken); + return TestHelper.expectedResponse(HttpStatus.HTTP_OK, + TestHelper.getSuccessfulTokenResponse(responseValues)); + } + + /** + * Creates a user-token response (for Leg 3) with id_token and client_info so that + * an account is properly stored in the cache. + */ + private HttpResponse createUserTokenResponse(String accessToken, String upn, String oid) { + HashMap idTokenValues = new HashMap<>(); + idTokenValues.put("oid", oid); + idTokenValues.put("preferred_username", upn); + String idToken = TestHelper.createIdToken(idTokenValues); + + String clientInfo = createClientInfo(oid); + + HashMap responseValues = new HashMap<>(); + responseValues.put("access_token", accessToken); + responseValues.put("id_token", idToken); + responseValues.put("client_info", clientInfo); + return TestHelper.expectedResponse(HttpStatus.HTTP_OK, + TestHelper.getSuccessfulTokenResponse(responseValues)); + } + + private String createClientInfo(String uid) { + String json = String.format("{\"uid\":\"%s\",\"utid\":\"%s\"}", uid, TENANT_ID); + return Base64.getEncoder().encodeToString(json.getBytes(StandardCharsets.UTF_8)); + } + + // ======================================================================== + // Core composite test: two users + caching (matches .NET TwoUpns test) + // ======================================================================== + + @Test + void acquireTokenForAgent_twoUpns_cacheReturnsCorrectUserToken() throws Exception { + // Arrange + DefaultHttpClient httpClientMock = mock(DefaultHttpClient.class); + + // Queue 4 HTTP responses in order: + // 1. Leg 1 (FMI credential for blueprint) + // 2. Leg 2 (assertion token for agent CCA) + // 3. Leg 3 (user token for alice) + // 4. Leg 3 (user token for bob — Legs 1+2 are cached) + when(httpClientMock.send(any(HttpRequest.class))) + .thenReturn( + createAppTokenResponse("fmi-credential-token"), + createAppTokenResponse("assertion-token"), + createUserTokenResponse("alice-access-token", USER1_UPN, USER1_OID), + createUserTokenResponse("bob-access-token", USER2_UPN, USER2_OID)); + + ConfidentialClientApplication blueprintCca = createBlueprintCca(httpClientMock); + + AgentIdentity aliceAgent = AgentIdentity.withUsername(AGENT_APP_ID, USER1_UPN); + AgentIdentity bobAgent = AgentIdentity.withUsername(AGENT_APP_ID, USER2_UPN); + + // Act 1: Alice — should trigger 3 HTTP calls (Leg 1 + Leg 2 + Leg 3) + IAuthenticationResult result1 = blueprintCca.acquireTokenForAgent( + AcquireTokenForAgentParameters.builder(CALLER_SCOPES, aliceAgent).build() + ).get(); + + assertEquals("alice-access-token", result1.accessToken()); + verify(httpClientMock, times(3)).send(any(HttpRequest.class)); + + // Act 2: Bob — should trigger only 1 HTTP call (Leg 3; Legs 1+2 cached) + IAuthenticationResult result2 = blueprintCca.acquireTokenForAgent( + AcquireTokenForAgentParameters.builder(CALLER_SCOPES, bobAgent).build() + ).get(); + + assertEquals("bob-access-token", result2.accessToken()); + verify(httpClientMock, times(4)).send(any(HttpRequest.class)); + + // Act 3: Alice again — should return from cache (0 HTTP calls) + IAuthenticationResult result3 = blueprintCca.acquireTokenForAgent( + AcquireTokenForAgentParameters.builder(CALLER_SCOPES, aliceAgent).build() + ).get(); + + assertEquals("alice-access-token", result3.accessToken()); + verify(httpClientMock, times(4)).send(any(HttpRequest.class)); // still 4 total + } + + // ======================================================================== + // App-only flow + // ======================================================================== + + @Test + void acquireTokenForAgent_appOnly_acquiresTokenWithoutUserLeg() throws Exception { + // Arrange + DefaultHttpClient httpClientMock = mock(DefaultHttpClient.class); + + // App-only: Leg 1 (FMI credential) + Leg 2 (app token for caller scopes) + when(httpClientMock.send(any(HttpRequest.class))) + .thenReturn( + createAppTokenResponse("fmi-credential-token"), + createAppTokenResponse("agent-app-token")); + + ConfidentialClientApplication blueprintCca = createBlueprintCca(httpClientMock); + + AgentIdentity appOnlyAgent = AgentIdentity.appOnly(AGENT_APP_ID); + + // Act + IAuthenticationResult result = blueprintCca.acquireTokenForAgent( + AcquireTokenForAgentParameters.builder(CALLER_SCOPES, appOnlyAgent).build() + ).get(); + + // Assert + assertEquals("agent-app-token", result.accessToken()); + verify(httpClientMock, times(2)).send(any(HttpRequest.class)); + } + + @Test + void acquireTokenForAgent_appOnly_secondCallReturnsCached() throws Exception { + // Arrange + DefaultHttpClient httpClientMock = mock(DefaultHttpClient.class); + + when(httpClientMock.send(any(HttpRequest.class))) + .thenReturn( + createAppTokenResponse("fmi-credential-token"), + createAppTokenResponse("agent-app-token")); + + ConfidentialClientApplication blueprintCca = createBlueprintCca(httpClientMock); + AgentIdentity appOnlyAgent = AgentIdentity.appOnly(AGENT_APP_ID); + + // Act 1: first call triggers HTTP + blueprintCca.acquireTokenForAgent( + AcquireTokenForAgentParameters.builder(CALLER_SCOPES, appOnlyAgent).build() + ).get(); + verify(httpClientMock, times(2)).send(any(HttpRequest.class)); + + // Act 2: second call should return from app cache (0 new HTTP calls) + IAuthenticationResult result = blueprintCca.acquireTokenForAgent( + AcquireTokenForAgentParameters.builder(CALLER_SCOPES, appOnlyAgent).build() + ).get(); + + assertEquals("agent-app-token", result.accessToken()); + verify(httpClientMock, times(2)).send(any(HttpRequest.class)); // still 2 total + } + + // ======================================================================== + // ForceRefresh bypasses user cache + // ======================================================================== + + @Test + void acquireTokenForAgent_forceRefresh_bypassesUserCache() throws Exception { + // Arrange + DefaultHttpClient httpClientMock = mock(DefaultHttpClient.class); + + // First call: 3 HTTP calls (Legs 1+2+3) + // Second call with forceRefresh: Leg 3 again (Legs 1+2 still cached) + when(httpClientMock.send(any(HttpRequest.class))) + .thenReturn( + createAppTokenResponse("fmi-credential-token"), + createAppTokenResponse("assertion-token"), + createUserTokenResponse("alice-token-1", USER1_UPN, USER1_OID), + createUserTokenResponse("alice-token-2", USER1_UPN, USER1_OID)); + + ConfidentialClientApplication blueprintCca = createBlueprintCca(httpClientMock); + AgentIdentity aliceAgent = AgentIdentity.withUsername(AGENT_APP_ID, USER1_UPN); + + // Act 1: normal call + IAuthenticationResult result1 = blueprintCca.acquireTokenForAgent( + AcquireTokenForAgentParameters.builder(CALLER_SCOPES, aliceAgent).build() + ).get(); + assertEquals("alice-token-1", result1.accessToken()); + verify(httpClientMock, times(3)).send(any(HttpRequest.class)); + + // Act 2: forceRefresh — should bypass user cache and execute Leg 3 again + IAuthenticationResult result2 = blueprintCca.acquireTokenForAgent( + AcquireTokenForAgentParameters.builder(CALLER_SCOPES, aliceAgent) + .forceRefresh(true) + .build() + ).get(); + assertEquals("alice-token-2", result2.accessToken()); + // Leg 3 fires again (1 more HTTP call), Legs 1+2 still from cache + verify(httpClientMock, times(4)).send(any(HttpRequest.class)); + } + + // ======================================================================== + // User identity by OID + // ======================================================================== + + @Test + void acquireTokenForAgent_withOid_acquiresUserToken() throws Exception { + // Arrange + DefaultHttpClient httpClientMock = mock(DefaultHttpClient.class); + + when(httpClientMock.send(any(HttpRequest.class))) + .thenReturn( + createAppTokenResponse("fmi-credential-token"), + createAppTokenResponse("assertion-token"), + createUserTokenResponse("alice-token", USER1_UPN, USER1_OID)); + + ConfidentialClientApplication blueprintCca = createBlueprintCca(httpClientMock); + + UUID aliceOid = UUID.fromString(USER1_OID); + AgentIdentity aliceByOid = new AgentIdentity(AGENT_APP_ID, aliceOid); + + // Act + IAuthenticationResult result = blueprintCca.acquireTokenForAgent( + AcquireTokenForAgentParameters.builder(CALLER_SCOPES, aliceByOid).build() + ).get(); + + // Assert + assertEquals("alice-token", result.accessToken()); + verify(httpClientMock, times(3)).send(any(HttpRequest.class)); + } + + // ======================================================================== + // Agent CCA caching: same agent ID reuses CCA + // ======================================================================== + + @Test + void acquireTokenForAgent_samAgentId_reusesCca() throws Exception { + // Arrange + DefaultHttpClient httpClientMock = mock(DefaultHttpClient.class); + + when(httpClientMock.send(any(HttpRequest.class))) + .thenReturn( + createAppTokenResponse("fmi-token"), + createAppTokenResponse("assertion-token"), + createUserTokenResponse("user-token", USER1_UPN, USER1_OID)); + + ConfidentialClientApplication blueprintCca = createBlueprintCca(httpClientMock); + AgentIdentity agent = AgentIdentity.withUsername(AGENT_APP_ID, USER1_UPN); + + // Act + blueprintCca.acquireTokenForAgent( + AcquireTokenForAgentParameters.builder(CALLER_SCOPES, agent).build() + ).get(); + + // Assert — agent CCA cache should have exactly one entry + assertEquals(1, blueprintCca.agentCcaCache.size()); + assertTrue(blueprintCca.agentCcaCache.containsKey("agent_" + AGENT_APP_ID)); + } + + // ======================================================================== + // Leg 1 sends fmi_path in body + // ======================================================================== + + @Test + void acquireTokenForAgent_leg1_sendsFmiPathInBody() throws Exception { + // Arrange + DefaultHttpClient httpClientMock = mock(DefaultHttpClient.class); + + when(httpClientMock.send(any(HttpRequest.class))) + .thenReturn( + createAppTokenResponse("fmi-token"), + createAppTokenResponse("assertion-token"), + createUserTokenResponse("user-token", USER1_UPN, USER1_OID)); + + ConfidentialClientApplication blueprintCca = createBlueprintCca(httpClientMock); + AgentIdentity agent = AgentIdentity.withUsername(AGENT_APP_ID, USER1_UPN); + + // Act + blueprintCca.acquireTokenForAgent( + AcquireTokenForAgentParameters.builder(CALLER_SCOPES, agent).build() + ).get(); + + // Assert — first HTTP call (Leg 1) should include fmi_path=agentAppId + verify(httpClientMock, atLeastOnce()).send(argThat(request -> { + String body = request.body(); + return body != null && body.contains("fmi_path=" + AGENT_APP_ID); + })); + } + + // ======================================================================== + // Leg 3 sends user_fic grant type + // ======================================================================== + + @Test + void acquireTokenForAgent_leg3_sendsUserFicGrantType() throws Exception { + // Arrange + DefaultHttpClient httpClientMock = mock(DefaultHttpClient.class); + + when(httpClientMock.send(any(HttpRequest.class))) + .thenReturn( + createAppTokenResponse("fmi-token"), + createAppTokenResponse("assertion-token"), + createUserTokenResponse("user-token", USER1_UPN, USER1_OID)); + + ConfidentialClientApplication blueprintCca = createBlueprintCca(httpClientMock); + AgentIdentity agent = AgentIdentity.withUsername(AGENT_APP_ID, USER1_UPN); + + // Act + blueprintCca.acquireTokenForAgent( + AcquireTokenForAgentParameters.builder(CALLER_SCOPES, agent).build() + ).get(); + + // Assert — one of the HTTP calls should contain grant_type=user_fic + verify(httpClientMock, atLeastOnce()).send(argThat(request -> { + String body = request.body(); + return body != null && body.contains("grant_type=user_fic"); + })); + } + + // ======================================================================== + // Input validation + // ======================================================================== + + @Test + void acquireTokenForAgent_nullParameters_throwsException() throws Exception { + ConfidentialClientApplication cca = createBlueprintCca(mock(DefaultHttpClient.class)); + assertThrows(IllegalArgumentException.class, () -> + cca.acquireTokenForAgent(null)); + } + + @Test + void parameterBuilder_nullScopes_throwsException() { + AgentIdentity agent = AgentIdentity.withUsername(AGENT_APP_ID, USER1_UPN); + assertThrows(IllegalArgumentException.class, () -> + AcquireTokenForAgentParameters.builder(null, agent)); + } + + @Test + void parameterBuilder_nullAgentIdentity_throwsException() { + assertThrows(IllegalArgumentException.class, () -> + AcquireTokenForAgentParameters.builder(CALLER_SCOPES, null)); + } +} diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/AgentIdentityTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/AgentIdentityTest.java new file mode 100644 index 00000000..d4bf59c0 --- /dev/null +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/AgentIdentityTest.java @@ -0,0 +1,140 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; + +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests for the {@link AgentIdentity} model class (§9 from AgentIDs_ComponentsReference). + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class AgentIdentityTest { + + private static final String AGENT_APP_ID = "agent-app-id-123"; + private static final String USER_UPN = "alice@contoso.com"; + private static final UUID USER_OID = UUID.fromString("11111111-1111-1111-1111-111111111111"); + + // ======================================================================== + // Constructor: by Object ID (recommended) + // ======================================================================== + + @Test + void constructor_withOid_setsPropertiesCorrectly() { + AgentIdentity identity = new AgentIdentity(AGENT_APP_ID, USER_OID); + + assertEquals(AGENT_APP_ID, identity.agentApplicationId()); + assertEquals(USER_OID, identity.userObjectId()); + assertNull(identity.username()); + assertTrue(identity.hasUserIdentifier()); + } + + @Test + void constructor_withOid_nullAgentAppId_throwsException() { + assertThrows(IllegalArgumentException.class, () -> + new AgentIdentity(null, USER_OID)); + } + + @Test + void constructor_withOid_emptyAgentAppId_throwsException() { + assertThrows(IllegalArgumentException.class, () -> + new AgentIdentity("", USER_OID)); + } + + @Test + void constructor_withOid_nullOid_throwsException() { + assertThrows(IllegalArgumentException.class, () -> + new AgentIdentity(AGENT_APP_ID, null)); + } + + // ======================================================================== + // Factory: withUsername (by UPN) + // ======================================================================== + + @Test + void withUsername_setsPropertiesCorrectly() { + AgentIdentity identity = AgentIdentity.withUsername(AGENT_APP_ID, USER_UPN); + + assertEquals(AGENT_APP_ID, identity.agentApplicationId()); + assertNull(identity.userObjectId()); + assertEquals(USER_UPN, identity.username()); + assertTrue(identity.hasUserIdentifier()); + } + + @Test + void withUsername_nullAgentAppId_throwsException() { + assertThrows(IllegalArgumentException.class, () -> + AgentIdentity.withUsername(null, USER_UPN)); + } + + @Test + void withUsername_emptyAgentAppId_throwsException() { + assertThrows(IllegalArgumentException.class, () -> + AgentIdentity.withUsername("", USER_UPN)); + } + + @Test + void withUsername_nullUsername_throwsException() { + assertThrows(IllegalArgumentException.class, () -> + AgentIdentity.withUsername(AGENT_APP_ID, null)); + } + + @Test + void withUsername_emptyUsername_throwsException() { + assertThrows(IllegalArgumentException.class, () -> + AgentIdentity.withUsername(AGENT_APP_ID, "")); + } + + // ======================================================================== + // Factory: appOnly (no user) + // ======================================================================== + + @Test + void appOnly_setsPropertiesCorrectly() { + AgentIdentity identity = AgentIdentity.appOnly(AGENT_APP_ID); + + assertEquals(AGENT_APP_ID, identity.agentApplicationId()); + assertNull(identity.userObjectId()); + assertNull(identity.username()); + assertFalse(identity.hasUserIdentifier()); + } + + @Test + void appOnly_nullAgentAppId_throwsException() { + assertThrows(IllegalArgumentException.class, () -> + AgentIdentity.appOnly(null)); + } + + @Test + void appOnly_emptyAgentAppId_throwsException() { + assertThrows(IllegalArgumentException.class, () -> + AgentIdentity.appOnly("")); + } + + // ======================================================================== + // hasUserIdentifier behavior + // ======================================================================== + + @Test + void hasUserIdentifier_withOid_returnsTrue() { + AgentIdentity identity = new AgentIdentity(AGENT_APP_ID, USER_OID); + assertTrue(identity.hasUserIdentifier()); + } + + @Test + void hasUserIdentifier_withUsername_returnsTrue() { + AgentIdentity identity = AgentIdentity.withUsername(AGENT_APP_ID, USER_UPN); + assertTrue(identity.hasUserIdentifier()); + } + + @Test + void hasUserIdentifier_appOnly_returnsFalse() { + AgentIdentity identity = AgentIdentity.appOnly(AGENT_APP_ID); + assertFalse(identity.hasUserIdentifier()); + } +}