diff --git a/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/MtlsPopIT.java b/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/MtlsPopIT.java index bdc3b29e..5b508b3b 100644 --- a/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/MtlsPopIT.java +++ b/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/MtlsPopIT.java @@ -40,6 +40,10 @@ *

The primary scenario is covered: *

* *

Testability gate (SME note A): ESTS gates mTLS PoP on the final resource audience, @@ -223,6 +227,90 @@ void Credential_X509_Output_Pop_And_Bearer_CacheIsolated() throws Exception { "Bearer and mTLS-PoP tokens must occupy separate cache entries"); } + /** + * Bearer-over-mTLS (Task 3), proven end to end. With {@code sendCertificateOverMtls(true)} + * (and no per-request {@code mtlsProofOfPossession()}), the lab SN/I cert is presented as the + * client TLS certificate on the handshake to the mTLS token endpoint, but the issued token is a + * plain {@code Bearer} access token that is not bound to the certificate (unlike mTLS + * PoP). This mirrors MSAL .NET's {@code Sni_Over_Mtls_Gets_Bearer_Token_Successfully} and uses the same + * live config: the SN/I-allow-listed app, {@code westus3} region, Key Vault scope. + * + *

The token-endpoint wire contract (routing to {@code mtlsauth.*}, {@code client_assertion} + * with the x5c chain forced on, no {@code token_type=mtls_pop} / {@code req_cnf}) is asserted at the + * unit level in {@code BearerOverMtlsTest} via {@code mockConstruction(DefaultHttpClient)} — the mTLS + * HTTP client is constructed internally by {@code TokenRequestExecutor} with the client-cert socket + * factory, so there is no factory injection point to record the live request here. This live cell + * therefore proves the complementary half: ESTS accepts the cert-over-mTLS handshake for this app and + * issues a usable, unbound {@code Bearer} token. + */ + @Test + void Credential_X509_Output_BearerOverMtls() throws Exception { + ConfidentialClientApplication cca = ConfidentialClientApplication.builder(SNI_ALLOWLISTED_APP_ID, certificate) + .authority(SNI_ALLOWLISTED_AUTHORITY) + .azureRegion(TEST_SLICE_REGION) // regional endpoint is safe (Bearer token type is deterministic) + .sendCertificateOverMtls(true) // route over mTLS, but keep a plain Bearer token + .build(); + + IAuthenticationResult result = cca.acquireToken(ClientCredentialParameters + .builder(Collections.singleton(KEYVAULT_DEFAULT_SCOPE)) + .build()) // no mtlsProofOfPossession() -> Bearer, not mtls_pop + .get(); + + assertNotNull(result.accessToken(), "Access token should not be null"); + assertFalse(result.accessToken().isEmpty(), "Access token should not be empty"); + assertEquals(TokenType.BEARER, result.metadata().tokenType(), + "Bearer-over-mTLS must yield a plain Bearer token, not mtls_pop"); + assertNull(result.metadata().bindingCertificate(), + "Bearer-over-mTLS token must not be bound to a certificate (no binding cert exposed)"); + } + + /** + * Bearer-over-mTLS cache behavior: the plain Bearer token is cached under the standard key (it + * is not thumbprint-fenced like mTLS PoP), so a second acquisition for the same scope is served + * from the cache and returns the same access token. This also guards the 2nd-call regression: after the + * first call the cached entry's environment is the mTLS host, and a second lookup must serve from cache + * without crashing on region / instance-metadata resolution. + */ + @Test + void Credential_X509_Output_BearerOverMtls_CacheHit() throws Exception { + ConfidentialClientApplication cca = ConfidentialClientApplication.builder(SNI_ALLOWLISTED_APP_ID, certificate) + .authority(SNI_ALLOWLISTED_AUTHORITY) + .azureRegion(TEST_SLICE_REGION) + .sendCertificateOverMtls(true) + .build(); + + IAuthenticationResult result = cca.acquireToken(ClientCredentialParameters + .builder(Collections.singleton(KEYVAULT_DEFAULT_SCOPE)) + .build()) + .get(); + assertEquals(TokenType.BEARER, result.metadata().tokenType()); + + IAuthenticationResult cached = cca.acquireToken(ClientCredentialParameters + .builder(Collections.singleton(KEYVAULT_DEFAULT_SCOPE)) + .build()) + .get(); + + assertEquals(result.accessToken(), cached.accessToken(), + "Second Bearer-over-mTLS request for the same scope should be served from the cache"); + } + + /** + * OBO Bearer-over-mTLS live acquisition — skip-gated (pending app mTLS-enablement). Mirrors MSAL + * .NET's {@code [Ignore]}d OBO/refresh/auth-code Bearer-over-mTLS live tests: the on-behalf-of (and + * refresh-token / auth-code) apps are not mTLS-enabled, so a live acquisition is rejected with + * {@code AADSTS700027 / AADSTS392189} — the same class of allow-listing block as the FIC + * {@code AADSTS51000}. The MSAL request shape for these flows (mTLS endpoint, {@code client_assertion} + * with forced x5c, correct grant) is fully asserted in {@code BearerOverMtlsTest} unit cells; this cell + * documents that the live user-flow path cannot be exercised until the apps are enabled. + */ + @Test + void Credential_Obo_Output_BearerOverMtls_LiveAcquire_SkipGated() { + Assumptions.assumeTrue(false, + "OBO/refresh/auth-code Bearer-over-mTLS live acquisition is pending app mTLS-enablement " + + "(AADSTS700027 / AADSTS392189); the request shape is covered by BearerOverMtlsTest " + + "unit cells via mockConstruction(DefaultHttpClient)."); + } + private void assertMtlsPopResult(IAuthenticationResult result, String expectedThumbprint) { assertNotNull(result, "Auth result should not be null"); assertNotNull(result.accessToken(), "Access token should not be null"); diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AuthenticationErrorCode.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AuthenticationErrorCode.java index 9979c3ef..909c80b1 100644 --- a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AuthenticationErrorCode.java +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AuthenticationErrorCode.java @@ -176,6 +176,14 @@ public class AuthenticationErrorCode { */ public static final String TOKEN_TYPE_MISMATCH = "token_type_mismatch"; + /** + * Indicates that {@code sendCertificateOverMtls(true)} was configured on a confidential client that is + * not authenticated with a certificate credential. Presenting a client certificate on the mTLS handshake + * requires an {@link IClientCertificate}; MSAL fails fast at build time rather than silently ignoring the + * option. For more details, see https://aka.ms/msal4j-pop + */ + public static final String CERTIFICATE_REQUIRED_FOR_MTLS = "certificate_required_for_mtls"; + /** * Indicates that instance discovery failed because the authority is not a valid instance. * This is returned by the instance discovery endpoint when the provided authority host is unknown. 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..75678e4c 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 @@ -21,6 +21,7 @@ public class ConfidentialClientApplication extends AbstractClientApplicationBase IClientCredential clientCredential; private boolean sendX5c; + private boolean sendCertificateOverMtls; /** 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 @@ -84,6 +85,7 @@ public CompletableFuture acquireToken(UserFederatedIdenti private ConfidentialClientApplication(Builder builder) { super(builder); sendX5c = builder.sendX5c; + sendCertificateOverMtls = builder.sendCertificateOverMtls; appTokenProvider = builder.appTokenProvider; log = LoggerFactory.getLogger(ConfidentialClientApplication.class); @@ -110,12 +112,19 @@ public boolean sendX5c() { return this.sendX5c; } + @Override + public boolean sendCertificateOverMtls() { + return this.sendCertificateOverMtls; + } + public static class Builder extends AbstractClientApplicationBase.Builder { private IClientCredential clientCredential; private boolean sendX5c = true; + private boolean sendCertificateOverMtls = false; + private Function> appTokenProvider; private Builder(String clientId, IClientCredential clientCredential) { @@ -139,6 +148,32 @@ public ConfidentialClientApplication.Builder sendX5c(boolean val) { return self(); } + /** + * Specifies whether the application's certificate credential is presented as the client certificate + * on the mutual-TLS (mTLS) handshake to the token endpoint. When enabled, requests are routed to the + * mTLS token endpoint ({@code mtlsauth.*}) and the identity provider returns a plain Bearer access + * token (the token is NOT bound to the certificate). + *

+ * This is distinct from per-request mTLS Proof-of-Possession + * ({@link ClientCredentialParameters.ClientCredentialParametersBuilder#mtlsProofOfPossession()}), + * which binds the token to the certificate ({@code token_type=mtls_pop}); a per-request mtls_pop + * opt-in always takes precedence over this app-level flag. The flag is honored by every confidential + * flow (client credentials, on-behalf-of, refresh token, authorization code). + *

+ * Default value is {@code false}. When enabled, the application MUST be configured with a certificate + * credential ({@link IClientCertificate}); otherwise {@link #build()} throws a + * {@link MsalClientException} with error code + * {@link AuthenticationErrorCode#CERTIFICATE_REQUIRED_FOR_MTLS}. + * + * @param val {@code true} to present the certificate over mTLS and receive a Bearer token + * @return instance of the Builder on which method was called + */ + public ConfidentialClientApplication.Builder sendCertificateOverMtls(boolean val) { + this.sendCertificateOverMtls = val; + + return self(); + } + ///

/// Allows setting a callback which returns an access token, based on the passed-in parameters. /// MSAL will pass in its authentication parameters to the callback and it is expected that the callback @@ -159,6 +194,13 @@ public ConfidentialClientApplication.Builder appTokenProvider(Function queryParameters, // SN/I trust from the TLS-presented certificate and binds the token via x5t#S256/cnf). return; } - // For client certificate, generate a new assertion and add it to the request + // For client certificate, generate a new assertion and add it to the request. For + // Bearer-over-mTLS the certificate is ALSO presented on the TLS handshake, so the x5c issuer + // chain is forced on the assertion (regardless of the app's sendX5c setting) so ESTS can do + // SN/I subject+issuer matching over the mTLS channel. ClientCertificate certificate = (ClientCertificate) credentialToUse; String assertion = certificate.getAssertion( authorityToUse, application.clientId(), - application.sendX5c()); + application.sendX5c() || isBearerOverMtls()); addJWTBearerAssertionParams(queryParameters, assertion); } } @@ -242,6 +245,34 @@ private boolean isMtlsProofOfPossession() { && ((ClientCredentialRequest) msalRequest).parameters.mtlsProofOfPossession(); } + /** + * @return true if this request must present the client certificate on the TLS handshake and route to + * the mTLS token endpoint — either because of per-request mTLS Proof-of-Possession or the app-level + * {@link ConfidentialClientApplication.Builder#sendCertificateOverMtls(boolean)} (Bearer-over-mTLS) flag. + */ + private boolean usesMtlsTransport() { + return isMtlsProofOfPossession() || isBearerOverMtls(); + } + + /** + * @return true if the app-level {@code sendCertificateOverMtls} flag is set and this is a + * certificate-authenticated confidential client, and the request did NOT opt into per-request + * mTLS Proof-of-Possession (which always wins). Read from the application (not a request cast) so it is + * honored by every confidential flow: client credentials, on-behalf-of, refresh token, authorization code. + */ + private boolean isBearerOverMtls() { + if (isMtlsProofOfPossession()) { + // A per-request mtls_pop opt-in always takes precedence over the app-level flag. + return false; + } + if (!(msalRequest.application() instanceof ConfidentialClientApplication)) { + return false; + } + ConfidentialClientApplication application = (ConfidentialClientApplication) msalRequest.application(); + return application.sendCertificateOverMtls() + && application.clientCredential instanceof IClientCertificate; + } + /** * Resolves the certificate to present as the client TLS certificate for an mTLS PoP request: the * request/app authentication credential when it is a certificate (direct SN/I cert or FIC Leg 1). diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/BearerOverMtlsTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/BearerOverMtlsTest.java new file mode 100644 index 00000000..9d5311e3 --- /dev/null +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/BearerOverMtlsTest.java @@ -0,0 +1,581 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedConstruction; + +import java.io.InputStream; +import java.net.URI; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Base64; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.concurrent.AbstractExecutorService; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockConstruction; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Unit tests for the app-level {@code sendCertificateOverMtls} flag (Bearer-over-mTLS). + * + *

Bearer-over-mTLS presents the SN/I certificate as the client TLS certificate on the handshake to + * the token endpoint and routes to the mTLS endpoint, but the identity provider returns a PLAIN + * {@code Bearer} access token that is NOT bound to the certificate. This is distinct from mTLS + * Proof-of-Possession (which binds the token, {@code token_type=mtls_pop}, and fences the cache by + * certificate thumbprint) and from the ordinary certificate flow (which signs a {@code private_key_jwt} + * to the regular {@code login.*} endpoint and never presents the certificate on the TLS handshake). + * + *

Mirrors MSAL.NET's {@code CertificateOptions.SendCertificateOverMtls} coverage, adapted to msal4j + * idiom and the {@link MtlsProofOfPossessionTest} test style. + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class BearerOverMtlsTest { + + private static final String PKCS12_RESOURCE = "/mtls_test_cert.p12"; + private static final String PKCS12_PASSWORD = "password"; + private static final String AUTHORITY = "https://login.microsoftonline.com/contoso.onmicrosoft.com/"; + private static final String SCOPE = "https://graph.microsoft.com/.default"; + private static final String GLOBAL_MTLS_HOST = "mtlsauth.microsoft.com"; + + private IClientCertificate certificate; + + // Runs token acquisition on the calling thread so Mockito's thread-local mockConstruction intercepts + // the mTLS DefaultHttpClient (which msal4j otherwise builds on a ForkJoinPool worker thread). + private static final ExecutorService SAME_THREAD_EXECUTOR = new SameThreadExecutorService(); + + private static final class SameThreadExecutorService extends AbstractExecutorService { + @Override + public void execute(Runnable command) { + command.run(); + } + + @Override + public void shutdown() { + } + + @Override + public List shutdownNow() { + return Collections.emptyList(); + } + + @Override + public boolean isShutdown() { + return false; + } + + @Override + public boolean isTerminated() { + return false; + } + + @Override + public boolean awaitTermination(long timeout, TimeUnit unit) { + return true; + } + } + + @BeforeAll + void setUp() throws Exception { + try (InputStream pkcs12 = getClass().getResourceAsStream(PKCS12_RESOURCE)) { + assertNotNull(pkcs12, "Test PKCS12 resource " + PKCS12_RESOURCE + " should be present"); + certificate = ClientCredentialFactory.createFromCertificate(pkcs12, PKCS12_PASSWORD); + } + } + + private ConfidentialClientApplication.Builder baseCertAppBuilder() throws Exception { + return ConfidentialClientApplication.builder("clientId", certificate) + .authority(AUTHORITY) + .instanceDiscovery(false) + .validateAuthority(false) + .executorService(SAME_THREAD_EXECUTOR) + .httpClient(mock(IHttpClient.class)); + } + + private static HttpResponse successResponse(String accessToken, String tokenType) { + HashMap values = new HashMap<>(); + values.put("access_token", accessToken); + values.put("token_type", tokenType); + return TestHelper.expectedResponse(HttpStatus.HTTP_OK, TestHelper.getSuccessfulTokenResponse(values)); + } + + private static Map parseFormBody(String body) { + Map params = new HashMap<>(); + if (body == null || body.isEmpty()) { + return params; + } + for (String pair : body.split("&")) { + int idx = pair.indexOf('='); + if (idx < 0) { + params.put(urlDecode(pair), ""); + } else { + params.put(urlDecode(pair.substring(0, idx)), urlDecode(pair.substring(idx + 1))); + } + } + return params; + } + + private static String urlDecode(String value) { + try { + return URLDecoder.decode(value, StandardCharsets.UTF_8.name()); + } catch (Exception e) { + return value; + } + } + + // Decodes the JWT header of a client_assertion and asserts the x5c (certificate chain) claim is present. + private static boolean assertionHeaderHasX5c(String clientAssertion) { + assertNotNull(clientAssertion, "client_assertion must be present"); + String[] segments = clientAssertion.split("\\."); + assertTrue(segments.length >= 2, "client_assertion should be a JWT"); + String headerJson = new String(Base64.getUrlDecoder().decode(segments[0]), StandardCharsets.UTF_8); + return headerJson.contains("\"x5c\""); + } + + // --------------------------------------------------------------------------------------------- + // A. Config / builder + // --------------------------------------------------------------------------------------------- + + @Test + void sendCertificateOverMtls_defaultsFalse() throws Exception { + ConfidentialClientApplication app = baseCertAppBuilder().build(); + + assertFalse(app.sendCertificateOverMtls(), + "sendCertificateOverMtls must default to false (zero behavior change when unset)"); + } + + @Test + void sendCertificateOverMtls_storedAndReturnedByGetter() throws Exception { + ConfidentialClientApplication enabled = baseCertAppBuilder().sendCertificateOverMtls(true).build(); + assertTrue(enabled.sendCertificateOverMtls(), "flag set to true should be reported by the getter"); + + ConfidentialClientApplication disabled = baseCertAppBuilder().sendCertificateOverMtls(false).build(); + assertFalse(disabled.sendCertificateOverMtls(), "flag set to false should be reported by the getter"); + } + + @Test + void sendCertificateOverMtls_nonCertificateCredential_failsFastAtBuild() { + MsalClientException ex = assertThrows(MsalClientException.class, () -> + ConfidentialClientApplication.builder("clientId", new ClientSecret("secret")) + .authority(AUTHORITY) + .instanceDiscovery(false) + .validateAuthority(false) + .sendCertificateOverMtls(true) + .build()); + + assertEquals(AuthenticationErrorCode.CERTIFICATE_REQUIRED_FOR_MTLS, ex.errorCode()); + assertTrue(ex.getMessage().toLowerCase().contains("sendcertificateovermtls"), + "error message should name the sendCertificateOverMtls flag"); + } + + // --------------------------------------------------------------------------------------------- + // B. Client-credentials behavior + // --------------------------------------------------------------------------------------------- + + @Test + void clientCredentials_bearerOverMtls_targetsMtlsEndpoint_forcesX5cAssertion_returnsBearer() throws Exception { + // sendX5c(false) proves the x5c is FORCED on for Bearer-over-mTLS regardless of the app setting. + ConfidentialClientApplication app = baseCertAppBuilder() + .sendCertificateOverMtls(true) + .sendX5c(false) + .build(); + + HttpRequest captured; + IAuthenticationResult result; + try (MockedConstruction mocked = mockConstruction(DefaultHttpClient.class, + (m, ctx) -> when(m.send(any(HttpRequest.class))).thenReturn(successResponse("bearer-token", "Bearer")))) { + + result = app.acquireToken(ClientCredentialParameters.builder(Collections.singleton(SCOPE)) + .skipCache(true) + .build()).get(); + + assertEquals(1, mocked.constructed().size(), "Exactly one mTLS DefaultHttpClient should be built"); + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(HttpRequest.class); + verify(mocked.constructed().get(0)).send(requestCaptor.capture()); + captured = requestCaptor.getValue(); + } + + // Endpoint: cert presented on the TLS handshake, host rewritten to the global mTLS host. + assertEquals(GLOBAL_MTLS_HOST, captured.url().getHost()); + assertTrue(captured.url().getPath().contains("/contoso.onmicrosoft.com/oauth2/v2.0/token")); + + Map body = parseFormBody(captured.body()); + // A plain-Bearer request over mTLS: client_assertion (jwt-bearer) with x5c FORCED on, but NO PoP markers. + assertEquals("client_credentials", body.get("grant_type")); + assertNotNull(body.get("client_assertion"), "Bearer-over-mTLS still sends a client_assertion"); + assertEquals(ClientAssertion.ASSERTION_TYPE_JWT_BEARER, body.get("client_assertion_type")); + assertTrue(assertionHeaderHasX5c(body.get("client_assertion")), + "Bearer-over-mTLS must FORCE the x5c chain on the assertion even when sendX5c(false)"); + assertFalse(body.containsKey("token_type"), "Bearer-over-mTLS must not request token_type=mtls_pop"); + assertFalse(body.containsKey("req_cnf"), "Bearer-over-mTLS must not send req_cnf"); + + // Result: a plain Bearer token, not certificate-bound. + assertEquals(TokenType.BEARER, result.metadata().tokenType()); + } + + @Test + void clientCredentials_bearerOverMtls_regionConfigured_targetsRegionalMtlsEndpoint() throws Exception { + // Pre-seed the regional instance metadata so no live IMDS/instance-discovery call is needed; region + // rewriting only runs with instanceDiscovery(true), and is client-credentials only. + String regionalLoginHost = "westus3.login.microsoft.com"; + AadInstanceDiscoveryProvider.cache.put(regionalLoginHost, + new InstanceDiscoveryMetadataEntry(regionalLoginHost, "login.microsoftonline.com", + new HashSet<>(Arrays.asList(regionalLoginHost, "login.microsoftonline.com")))); + try { + ConfidentialClientApplication app = ConfidentialClientApplication.builder("clientId", certificate) + .authority(AUTHORITY) + .instanceDiscovery(true) + .validateAuthority(false) + .azureRegion("westus3") + .executorService(SAME_THREAD_EXECUTOR) + .httpClient(mock(IHttpClient.class)) + .sendCertificateOverMtls(true) + .build(); + + HttpRequest captured; + try (MockedConstruction mocked = mockConstruction(DefaultHttpClient.class, + (m, ctx) -> when(m.send(any(HttpRequest.class))).thenReturn(successResponse("bearer-token", "Bearer")))) { + + app.acquireToken(ClientCredentialParameters.builder(Collections.singleton(SCOPE)) + .skipCache(true) + .build()).get(); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(HttpRequest.class); + verify(mocked.constructed().get(0)).send(requestCaptor.capture()); + captured = requestCaptor.getValue(); + } + + assertEquals("westus3.mtlsauth.microsoft.com", captured.url().getHost(), + "region-configured Bearer-over-mTLS must target the regional mTLS host"); + } finally { + AadInstanceDiscoveryProvider.cache.remove(regionalLoginHost); + } + } + + @Test + void perRequestMtlsPop_overridesBearerOverMtlsFlag() throws Exception { + // Per-request mtls_pop opt-in ALWAYS wins over the app-level Bearer-over-mTLS flag. + ConfidentialClientApplication app = baseCertAppBuilder() + .sendCertificateOverMtls(true) + .build(); + + HttpRequest captured; + IAuthenticationResult result; + try (MockedConstruction mocked = mockConstruction(DefaultHttpClient.class, + (m, ctx) -> when(m.send(any(HttpRequest.class))).thenReturn(successResponse("mtls-pop-token", "mtls_pop")))) { + + result = app.acquireToken(ClientCredentialParameters.builder(Collections.singleton(SCOPE)) + .mtlsProofOfPossession() + .skipCache(true) + .build()).get(); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(HttpRequest.class); + verify(mocked.constructed().get(0)).send(requestCaptor.capture()); + captured = requestCaptor.getValue(); + } + + assertEquals(GLOBAL_MTLS_HOST, captured.url().getHost()); + Map body = parseFormBody(captured.body()); + // mtls_pop wins: PoP markers present, and NO client_assertion (cert resolved from the TLS handshake). + assertEquals("mtls_pop", body.get("token_type")); + assertFalse(body.containsKey("client_assertion"), + "per-request mtls_pop must win: no client_assertion is sent"); + assertEquals(TokenType.MTLS_POP, result.metadata().tokenType()); + assertNotNull(result.metadata().bindingCertificate()); + } + + @Test + void clientCredentials_bearerOverMtls_secondCall_servedFromCacheAsPlainBearer() throws Exception { + ConfidentialClientApplication app = baseCertAppBuilder() + .sendCertificateOverMtls(true) + .build(); + + IAuthenticationResult networkResult; + IAuthenticationResult cachedResult; + try (MockedConstruction mocked = mockConstruction(DefaultHttpClient.class, + (m, ctx) -> when(m.send(any(HttpRequest.class))).thenReturn(successResponse("bearer-token", "Bearer")))) { + + // First call: cache empty, goes to the (mocked) mTLS network and caches a PLAIN Bearer token. + networkResult = app.acquireToken(ClientCredentialParameters.builder(Collections.singleton(SCOPE)) + .build()).get(); + // Second call: a normal Bearer cache lookup returns it (the entry is not thumbprint-fenced). + cachedResult = app.acquireToken(ClientCredentialParameters.builder(Collections.singleton(SCOPE)) + .build()).get(); + + assertEquals(1, mocked.constructed().size(), + "Second acquireToken must be served from the plain Bearer cache, not the network"); + } + + assertEquals(TokenType.BEARER, networkResult.metadata().tokenType()); + assertEquals(TokenType.BEARER, cachedResult.metadata().tokenType()); + } + + // --------------------------------------------------------------------------------------------- + // C. User flows (OBO / refresh-token / authorization-code) + // --------------------------------------------------------------------------------------------- + + @Test + void onBehalfOf_bearerOverMtls_targetsMtlsEndpoint_forcesX5cAssertion_onBehalfOfGrant() throws Exception { + ConfidentialClientApplication app = baseCertAppBuilder() + .sendCertificateOverMtls(true) + .sendX5c(false) + .build(); + + HttpRequest captured; + IAuthenticationResult result; + try (MockedConstruction mocked = mockConstruction(DefaultHttpClient.class, + (m, ctx) -> when(m.send(any(HttpRequest.class))).thenReturn(successResponse("bearer-token", "Bearer")))) { + + result = app.acquireToken(OnBehalfOfParameters + .builder(Collections.singleton(SCOPE), new UserAssertion(TestHelper.signedAssertion)) + .build()).get(); + + assertEquals(1, mocked.constructed().size(), "Exactly one mTLS DefaultHttpClient should be built"); + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(HttpRequest.class); + verify(mocked.constructed().get(0)).send(requestCaptor.capture()); + captured = requestCaptor.getValue(); + } + + assertEquals(GLOBAL_MTLS_HOST, captured.url().getHost()); + Map body = parseFormBody(captured.body()); + assertEquals("on_behalf_of", body.get("requested_token_use"), "OBO grant should be preserved"); + assertNotNull(body.get("client_assertion"), "Bearer-over-mTLS still sends a client_assertion for OBO"); + assertEquals(ClientAssertion.ASSERTION_TYPE_JWT_BEARER, body.get("client_assertion_type")); + assertTrue(assertionHeaderHasX5c(body.get("client_assertion")), + "Bearer-over-mTLS must FORCE the x5c chain on the OBO client_assertion"); + assertFalse(body.containsKey("token_type"), "OBO Bearer-over-mTLS must not request token_type=mtls_pop"); + assertEquals(TokenType.BEARER, result.metadata().tokenType()); + } + + @Test + void onBehalfOf_bearerOverMtls_regionConfigured_stillTargetsGlobalMtlsEndpoint() throws Exception { + // Region is client-credentials-only in msal4j: AadInstanceDiscoveryProvider.shouldUseRegionalEndpoint + // gates regional routing on ClientCredentialRequest, so user flows (OBO/refresh/auth-code) fall back + // to the global endpoint. Bearer-over-mTLS must therefore route OBO to the GLOBAL mTLS host even when + // a region is configured — it must NOT accidentally introduce regional routing on a flow the SDK does + // not support there. (Behavioral parity with .NET's intent, adapted to msal4j's region architecture.) + ConfidentialClientApplication app = ConfidentialClientApplication.builder("clientId", certificate) + .authority(AUTHORITY) + .instanceDiscovery(false) + .validateAuthority(false) + .azureRegion("westus3") + .executorService(SAME_THREAD_EXECUTOR) + .httpClient(mock(IHttpClient.class)) + .sendCertificateOverMtls(true) + .build(); + + HttpRequest captured; + try (MockedConstruction mocked = mockConstruction(DefaultHttpClient.class, + (m, ctx) -> when(m.send(any(HttpRequest.class))).thenReturn(successResponse("bearer-token", "Bearer")))) { + + app.acquireToken(OnBehalfOfParameters + .builder(Collections.singleton(SCOPE), new UserAssertion(TestHelper.signedAssertion)) + .build()).get(); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(HttpRequest.class); + verify(mocked.constructed().get(0)).send(requestCaptor.capture()); + captured = requestCaptor.getValue(); + } + + assertEquals(GLOBAL_MTLS_HOST, captured.url().getHost(), + "OBO ignores azureRegion (client-credentials only) and uses the global mTLS host"); + } + + @Test + void refreshToken_bearerOverMtls_targetsMtlsEndpoint_forcesX5cAssertion_refreshTokenGrant() throws Exception { + ConfidentialClientApplication app = baseCertAppBuilder() + .sendCertificateOverMtls(true) + .sendX5c(false) + .build(); + + HttpRequest captured; + IAuthenticationResult result; + try (MockedConstruction mocked = mockConstruction(DefaultHttpClient.class, + (m, ctx) -> when(m.send(any(HttpRequest.class))).thenReturn(successResponse("bearer-token", "Bearer")))) { + + result = app.acquireToken(RefreshTokenParameters + .builder(Collections.singleton(SCOPE), "a-refresh-token") + .build()).get(); + + assertEquals(1, mocked.constructed().size(), "Exactly one mTLS DefaultHttpClient should be built"); + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(HttpRequest.class); + verify(mocked.constructed().get(0)).send(requestCaptor.capture()); + captured = requestCaptor.getValue(); + } + + assertEquals(GLOBAL_MTLS_HOST, captured.url().getHost()); + Map body = parseFormBody(captured.body()); + assertEquals("refresh_token", body.get("grant_type")); + assertNotNull(body.get("client_assertion"), "Bearer-over-mTLS still sends a client_assertion for refresh"); + assertTrue(assertionHeaderHasX5c(body.get("client_assertion")), + "Bearer-over-mTLS must FORCE the x5c chain on the refresh client_assertion"); + assertFalse(body.containsKey("token_type"), "refresh Bearer-over-mTLS must not request token_type=mtls_pop"); + assertEquals(TokenType.BEARER, result.metadata().tokenType()); + } + + @Test + void authorizationCode_bearerOverMtls_targetsMtlsEndpoint_forcesX5cAssertion_authorizationCodeGrant() throws Exception { + ConfidentialClientApplication app = baseCertAppBuilder() + .sendCertificateOverMtls(true) + .sendX5c(false) + .build(); + + HttpRequest captured; + IAuthenticationResult result; + try (MockedConstruction mocked = mockConstruction(DefaultHttpClient.class, + (m, ctx) -> when(m.send(any(HttpRequest.class))).thenReturn(successResponse("bearer-token", "Bearer")))) { + + result = app.acquireToken(AuthorizationCodeParameters + .builder("an-auth-code", new URI("http://localhost:8080")) + .scopes(Collections.singleton(SCOPE)) + .build()).get(); + + assertEquals(1, mocked.constructed().size(), "Exactly one mTLS DefaultHttpClient should be built"); + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(HttpRequest.class); + verify(mocked.constructed().get(0)).send(requestCaptor.capture()); + captured = requestCaptor.getValue(); + } + + assertEquals(GLOBAL_MTLS_HOST, captured.url().getHost()); + Map body = parseFormBody(captured.body()); + assertEquals("authorization_code", body.get("grant_type")); + assertNotNull(body.get("client_assertion"), "Bearer-over-mTLS still sends a client_assertion for auth-code"); + assertTrue(assertionHeaderHasX5c(body.get("client_assertion")), + "Bearer-over-mTLS must FORCE the x5c chain on the auth-code client_assertion"); + assertFalse(body.containsKey("token_type"), "auth-code Bearer-over-mTLS must not request token_type=mtls_pop"); + assertEquals(TokenType.BEARER, result.metadata().tokenType()); + } + + @Test + void onBehalfOf_bearerOverMtls_secondCall_servedFromCache_noCrash() throws Exception { + ConfidentialClientApplication app = baseCertAppBuilder() + .sendCertificateOverMtls(true) + .build(); + + OnBehalfOfParameters params = OnBehalfOfParameters + .builder(Collections.singleton(SCOPE), new UserAssertion(TestHelper.signedAssertion)) + .build(); + + IAuthenticationResult networkResult; + IAuthenticationResult cachedResult; + try (MockedConstruction mocked = mockConstruction(DefaultHttpClient.class, + (m, ctx) -> when(m.send(any(HttpRequest.class))).thenReturn(successResponse("bearer-token", "Bearer")))) { + + // First call goes to the (mocked) mTLS network; second must be served from the cache without + // crashing on region/instance-metadata resolution for the mTLS-derived environment. + networkResult = app.acquireToken(params).get(); + cachedResult = app.acquireToken(params).get(); + + assertEquals(1, mocked.constructed().size(), + "Second OBO acquireToken must be served from the cache, not the network"); + } + + assertEquals(TokenType.BEARER, networkResult.metadata().tokenType()); + assertEquals(TokenType.BEARER, cachedResult.metadata().tokenType()); + } + + @Test + void onBehalfOf_bearerOverMtls_secondCall_withInstanceDiscovery_cachedUnderLoginHost_noCrash() throws Exception { + // Regression guard for the Bearer-over-mTLS 2nd-call cache path with instance discovery ENABLED + // (mirrors .NET OboFlow_WithSendCertificateOverMtls_SecondCallDoesNotCrashAsync). In .NET the AT was + // cached under Environment = the rewritten mtlsauth host, so the 2nd call fed mtlsauth.* into + // instance/region metadata discovery — which only accepts login.* hosts — and threw. In msal4j this + // holds by construction: only the local token-endpoint URL is rewritten to mtlsauth, while the + // request authority (and therefore the cached AT's environment) stays the login host. The + // instanceDiscovery(false) sibling test skips discovery entirely, so this one enables it (seeding the + // login-host metadata to avoid a live IMDS call) to actually exercise the resolution path. + String loginHost = "login.microsoftonline.com"; + AadInstanceDiscoveryProvider.cache.put(loginHost, + new InstanceDiscoveryMetadataEntry(loginHost, loginHost, + new HashSet<>(Arrays.asList(loginHost)))); + try { + ConfidentialClientApplication app = ConfidentialClientApplication.builder("clientId", certificate) + .authority(AUTHORITY) + .instanceDiscovery(true) + .validateAuthority(false) + .executorService(SAME_THREAD_EXECUTOR) + .httpClient(mock(IHttpClient.class)) + .sendCertificateOverMtls(true) + .build(); + + OnBehalfOfParameters params = OnBehalfOfParameters + .builder(Collections.singleton(SCOPE), new UserAssertion(TestHelper.signedAssertion)) + .build(); + + IAuthenticationResult cachedResult; + try (MockedConstruction mocked = mockConstruction(DefaultHttpClient.class, + (m, ctx) -> when(m.send(any(HttpRequest.class))).thenReturn(successResponse("bearer-token", "Bearer")))) { + + app.acquireToken(params).get(); // 1st: mocked mTLS network, caches plain Bearer + cachedResult = app.acquireToken(params).get(); // 2nd: must serve from cache, no metadata crash + + assertEquals(1, mocked.constructed().size(), + "Second OBO call must be served from the cache even with instance discovery enabled"); + } + + assertEquals(TokenType.BEARER, cachedResult.metadata().tokenType()); + + // The AT is cached under the LOGIN host, never the rewritten mtlsauth host — the property that + // keeps the 2nd-call metadata resolution valid (the cache key embeds the environment). + assertEquals(1, app.tokenCache.accessTokens.size()); + String cacheKey = app.tokenCache.accessTokens.keySet().iterator().next(); + assertTrue(cacheKey.contains(loginHost), + "Bearer-over-mTLS AT must be cached under the login host, got key: " + cacheKey); + assertFalse(cacheKey.contains("mtlsauth"), + "Bearer-over-mTLS AT must NOT be cached under the rewritten mtlsauth host, got key: " + cacheKey); + } finally { + AadInstanceDiscoveryProvider.cache.remove(loginHost); + } + } + + @Test + void onBehalfOf_withoutFlag_usesLoginEndpoint() throws Exception { + // Negative control: with the flag OFF, OBO uses the standard login endpoint and never presents the + // certificate on an mTLS handshake (no mTLS DefaultHttpClient is constructed). + IHttpClient appHttpClient = mock(IHttpClient.class); + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(HttpRequest.class); + when(appHttpClient.send(any(HttpRequest.class))).thenReturn(successResponse("bearer-token", "Bearer")); + + ConfidentialClientApplication app = ConfidentialClientApplication.builder("clientId", certificate) + .authority(AUTHORITY) + .instanceDiscovery(false) + .validateAuthority(false) + .executorService(SAME_THREAD_EXECUTOR) + .httpClient(appHttpClient) + .build(); + + app.acquireToken(OnBehalfOfParameters + .builder(Collections.singleton(SCOPE), new UserAssertion(TestHelper.signedAssertion)) + .build()).get(); + + verify(appHttpClient).send(requestCaptor.capture()); + HttpRequest captured = requestCaptor.getValue(); + + assertEquals("login.microsoftonline.com", captured.url().getHost(), + "without the flag, OBO must use the standard login endpoint"); + Map body = parseFormBody(captured.body()); + assertEquals("on_behalf_of", body.get("requested_token_use")); + assertNotNull(body.get("client_assertion"), "the standard OBO path still sends a client_assertion"); + } +}