From 773e76352a2b370dec90075991f1e54374ba4ace Mon Sep 17 00:00:00 2001 From: Robbie Ginsburg Date: Fri, 31 Jul 2026 16:12:09 -0400 Subject: [PATCH 1/3] Add Bearer-over-mTLS (sendCertificateOverMtls) for confidential clients Present the app's SN/I certificate as the client certificate on the mTLS handshake to the token endpoint and receive a plain (unbound) Bearer token, ported from MSAL.NET's CertificateOptions.SendCertificateOverMtls (.NET PRs #5849 client-credentials, #6009 OBO/refresh/auth-code). This is distinct from Task-1 mTLS Proof-of-Possession: mtls_pop binds the token to the certificate (token_type=mtls_pop, thumbprint-fenced cache); Bearer-over-mTLS uses the same mTLS transport but returns an ordinary Bearer token that is NOT bound to the certificate. - New app-level ConfidentialClientApplication.Builder.sendCertificateOverMtls(boolean) (default false); getter on ConfidentialClientApplication + IConfidentialClientApplication (mirrors the sendX5c pattern). - build() fails fast with MsalClientException (CERTIFICATE_REQUIRED_FOR_MTLS) when the flag is set without a certificate credential (mirrors .NET InvalidCredentialMaterial). - Generalize TokenRequestExecutor routing from isMtlsProofOfPossession() to usesMtlsTransport() = mtls_pop OR Bearer-over-mTLS. The Bearer branch routes to the mTLS endpoint and forces the x5c chain on the client_assertion (regardless of app.sendX5c()), but keeps token_type Bearer (no token_type=mtls_pop / req_cnf) and produces a plain Bearer cache entry (not thumbprint-fenced). A per-request mtlsProofOfPossession() always takes precedence over the app-level flag. - Honored by every confidential flow (client credentials, on-behalf-of, refresh token, authorization code) by reading the flag off the application rather than a request cast. Tests: - BearerOverMtlsTest (13 unit cells via mockConstruction(DefaultHttpClient)): config (default/stored/non-cert fail-fast); client credentials (global mtlsauth + Bearer + client_assertion with forced x5c and no mtls_pop/req_cnf; regional; per-request mtls_pop wins; plain-Bearer cache); user flows (OBO/refresh/auth-code route to mtlsauth with the correct grant + forced-x5c assertion; OBO 2nd call served from cache; OBO without the flag uses the regular login endpoint). - MtlsPopIT: live Bearer-over-mTLS client-credentials cell + cache-hit (SN/I-allow-listed app), and a skip-gated OBO cell documenting the user-flow allow-listing gap (AADSTS700027 / AADSTS392189). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../com/microsoft/aad/msal4j/MtlsPopIT.java | 88 +++ .../aad/msal4j/AuthenticationErrorCode.java | 8 + .../msal4j/ConfidentialClientApplication.java | 42 ++ .../IConfidentialClientApplication.java | 8 + .../aad/msal4j/TokenRequestExecutor.java | 39 +- .../aad/msal4j/BearerOverMtlsTest.java | 524 ++++++++++++++++++ 6 files changed, 705 insertions(+), 4 deletions(-) create mode 100644 msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/BearerOverMtlsTest.java 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..def63da3 --- /dev/null +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/BearerOverMtlsTest.java @@ -0,0 +1,524 @@ +// 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; user flows silently fall back to the global endpoint. + // Bearer-over-mTLS therefore routes OBO to the GLOBAL mTLS host even when a region is configured. + 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_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"); + } +} From 893a6960de9f07193c9d8c4e91968171104f436d Mon Sep 17 00:00:00 2001 From: Robbie Ginsburg Date: Fri, 31 Jul 2026 16:20:26 -0400 Subject: [PATCH 2/3] Add instance-discovery-on regression guard for Bearer-over-mTLS 2nd-call cache Lock the Bearer-over-mTLS 2nd-call cache-serve seam deterministically in CI. The existing OBO 2nd-call test uses instanceDiscovery(false), which skips the metadata resolution path where .NET's SecondCallDoesNotCrash regression lived. This adds a variant with instanceDiscovery(true) (seeding the login-host metadata to avoid a live IMDS call) that asserts the 2nd call is served from cache without crashing and that the Bearer AT is cached under the login host, never the rewritten mtlsauth host. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../aad/msal4j/BearerOverMtlsTest.java | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) 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 index def63da3..3609695a 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/BearerOverMtlsTest.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/BearerOverMtlsTest.java @@ -492,6 +492,60 @@ void onBehalfOf_bearerOverMtls_secondCall_servedFromCache_noCrash() throws Excep 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 From 793df76ff4f3bc69f6f576f38d41d777f82c9b3f Mon Sep 17 00:00:00 2001 From: Robbie Ginsburg Date: Fri, 31 Jul 2026 16:23:08 -0400 Subject: [PATCH 3/3] Document region-CC-only mechanism in OBO Bearer-over-mTLS region test Per cross-SDK parity review: name the gate (AadInstanceDiscoveryProvider .shouldUseRegionalEndpoint routes regionally only for ClientCredentialRequest, so user flows fall back to the global endpoint). The OBO+region cell locks that Bearer-over-mTLS does not accidentally introduce regional routing on a user flow msal4j does not support there. Comment-only; behavior unchanged (14/14 green). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../java/com/microsoft/aad/msal4j/BearerOverMtlsTest.java | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) 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 index 3609695a..9d5311e3 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/BearerOverMtlsTest.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/BearerOverMtlsTest.java @@ -370,8 +370,11 @@ void onBehalfOf_bearerOverMtls_targetsMtlsEndpoint_forcesX5cAssertion_onBehalfOf @Test void onBehalfOf_bearerOverMtls_regionConfigured_stillTargetsGlobalMtlsEndpoint() throws Exception { - // Region is client-credentials-only in msal4j; user flows silently fall back to the global endpoint. - // Bearer-over-mTLS therefore routes OBO to the GLOBAL mTLS host even when a region is configured. + // 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)