From d8cac48f62adbd544665908a0caf1674b5067af1 Mon Sep 17 00:00:00 2001 From: Robbie Ginsburg Date: Wed, 1 Jul 2026 15:40:11 -0400 Subject: [PATCH 01/11] Add SN/I certificate support over mTLS Proof-of-Possession (PoP) Allow a confidential-client app configured with a Subject-Name/Issuer (SN/I) certificate to obtain an mTLS-bound PoP access token from Entra ID by presenting that same certificate as the client TLS certificate in the mutual-TLS handshake to the token endpoint, instead of signing a private_key_jwt (x5c) client assertion. Opt in via ClientCredentialParameters.builder(...).mtlsProofOfPossession(). Adds TokenType, BindingCertificate, MtlsClientCertificateHelper, and MtlsEndpointHelper; threads the mTLS socket factory through the HTTP layer; derives the mtlsauth.* endpoint (region optional); builds the mtls_pop request (direct cert -> no assertion; FIC Leg 2 -> jwt-pop with mtlsBindingCertificate); parses token_type and surfaces the public binding certificate; and isolates the cache on {token_type + cert KeyId}. Also covers the 2-leg FIC over mTLS PoP where both legs are cert-bound. The existing SNI+Bearer (assertion) and broker SHR-PoP flows are unchanged. Includes unit tests, an E2E integration test (MtlsPopIT), a sample, and docs/changelog updates. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/copilot-instructions.md | 3 + changelog.txt | 1 + .../com/microsoft/aad/msal4j/MtlsPopIT.java | 252 +++++++++++++++++ ...cquireTokenByClientCredentialSupplier.java | 10 + .../aad/msal4j/AssertionRequestOptions.java | 17 ++ .../aad/msal4j/AuthenticationErrorCode.java | 7 + .../msal4j/AuthenticationResultMetadata.java | 58 +++- .../aad/msal4j/BindingCertificate.java | 72 +++++ .../microsoft/aad/msal4j/ClientAssertion.java | 1 + .../msal4j/ClientCredentialParameters.java | 68 ++++- .../aad/msal4j/ClientCredentialRequest.java | 4 + .../msal4j/ConfidentialClientApplication.java | 38 +++ .../com/microsoft/aad/msal4j/HttpHelper.java | 2 +- .../com/microsoft/aad/msal4j/IHttpHelper.java | 16 ++ .../msal4j/MtlsClientCertificateHelper.java | 177 ++++++++++++ .../aad/msal4j/MtlsEndpointHelper.java | 120 ++++++++ .../aad/msal4j/OAuthHttpRequest.java | 24 +- .../aad/msal4j/TokenRequestExecutor.java | 92 ++++++- .../microsoft/aad/msal4j/TokenResponse.java | 6 + .../com/microsoft/aad/msal4j/TokenType.java | 76 ++++++ ...ClientCredentialMtlsProofOfPossession.java | 158 +++++++++++ .../MtlsClientCertificateHelperTest.java | 121 +++++++++ .../aad/msal4j/MtlsEndpointHelperTest.java | 103 +++++++ .../aad/msal4j/MtlsProofOfPossessionTest.java | 256 ++++++++++++++++++ .../src/test/resources/mtls_test_cert.p12 | Bin 0 -> 2590 bytes 25 files changed, 1667 insertions(+), 15 deletions(-) create mode 100644 msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/MtlsPopIT.java create mode 100644 msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/BindingCertificate.java create mode 100644 msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/MtlsClientCertificateHelper.java create mode 100644 msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/MtlsEndpointHelper.java create mode 100644 msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/TokenType.java create mode 100644 msal4j-sdk/src/samples/confidential-client/ClientCredentialMtlsProofOfPossession.java create mode 100644 msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/MtlsClientCertificateHelperTest.java create mode 100644 msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/MtlsEndpointHelperTest.java create mode 100644 msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/MtlsProofOfPossessionTest.java create mode 100644 msal4j-sdk/src/test/resources/mtls_test_cert.p12 diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 67c276105..516e22282 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -25,6 +25,7 @@ - Acquire access tokens for protected APIs (Microsoft Graph, custom APIs) - Token caching and automatic refresh - Support for various authentication flows (interactive, silent, client credentials, on-behalf-of, device code, managed identity) +- mTLS Proof-of-Possession (PoP) tokens for confidential clients using an SN/I certificate (see Client Credentials) - Multi-cloud and B2C support ### Repository Structure @@ -137,6 +138,8 @@ MSAL4J supports multiple authentication flows, each with a public `*Parameters` - **Parameters**: `ClientCredentialParameters` - App-only authentication (daemon apps) - **Internal**: `ClientCredentialRequest` → `AcquireTokenByClientCredentialSupplier` - **Key Classes**: `IClientCredential`, `ClientSecret`, `ClientCertificate`, `ClientAssertion` +- **mTLS Proof-of-Possession (PoP)**: Opt in with `ClientCredentialParameters.builder(...).mtlsProofOfPossession()` to obtain an mTLS-bound PoP token (`token_type=mtls_pop`). The app's SN/I `IClientCertificate` is presented as the client TLS certificate to a rewritten `mtlsauth.*` endpoint (no `client_assertion` on the direct path) instead of signing an x5c assertion (the existing SNI+Bearer path is unchanged). Requires a tenanted authority and an ESTS allow-listed resource; region is optional (global `mtlsauth.microsoft.com` when absent). The result exposes `metadata().tokenType()` and `metadata().bindingCertificate()` (public material only — x5c chain + `x5t#S256`). For 2-leg FIC over mTLS PoP, attach the binding cert to an assertion-authenticated app with `ConfidentialClientApplication.Builder.mtlsBindingCertificate(IClientCertificate)`. + - **Key Classes**: `TokenType`, `BindingCertificate`, `MtlsClientCertificateHelper`, `MtlsEndpointHelper`; internal `AuthScheme`. Not supported: US Gov / China clouds, and user-scoped (`user_fic`) FIC over mTLS. **On-Behalf-Of (OBO)** - **Public API**: `acquireToken(OnBehalfOfParameters)` diff --git a/changelog.txt b/changelog.txt index 78dcfcf3a..451a6506b 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,5 +1,6 @@ Version 1.25.0 ============= +- Add SN/I certificate support over mTLS Proof-of-Possession (PoP) for confidential clients, presenting the certificate as the client TLS cert to obtain an mTLS-bound token; includes the 2-leg Federated Identity Credential (FIC) flow where both legs are mTLS PoP - Add Federated Managed Identity (FMI) support for client credentials flow (#1025) - Add User Federated Identity Credential (user_fic) grant type support (#1026) - Add MSAL client metadata headers to IMDS managed identity requests (#1024) 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 new file mode 100644 index 000000000..b2917fc81 --- /dev/null +++ b/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/MtlsPopIT.java @@ -0,0 +1,252 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j; + +import com.microsoft.aad.msal4j.labapi.KeyVaultRegistry; +import com.microsoft.aad.msal4j.labapi.KeyVaultSecretsProvider; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; + +import java.io.IOException; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.NoSuchProviderException; +import java.security.PrivateKey; +import java.security.UnrecoverableKeyException; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.util.Collections; + +import static com.microsoft.aad.msal4j.TestConstants.KEYVAULT_DEFAULT_SCOPE; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +/** + * End-to-end integration tests for SN/I certificate over mTLS Proof-of-Possession (PoP). + * + *

These exercise the primary deliverable of this work: a confidential-client app configured with a + * Subject-Name/Issuer (SN/I) certificate obtains an mTLS-bound PoP access token from Entra ID + * (ESTS), where that same SNI cert is presented as the client TLS certificate in the mutual-TLS + * handshake to the token endpoint (no {@code private_key_jwt} / x5c client assertion on the direct + * path). + * + *

Both scenarios from the plan are covered: + *

+ * + *

Testability gate (SME note A): ESTS gates mTLS PoP on the final resource audience, + * which must be an ESTS allow-listed resource (e.g. Azure Key Vault or MS Graph) — not the client app. + * Every test below therefore requests a token for an allow-listed resource. + * + *

The lab SN/I certificate is non-CNG, so these tests are E2E-runnable in CI/CD using the same + * certificate the pipelines already provision for {@code ClientCredentialsIT} and {@code AgenticIT} (the + * OS keystore alias {@link KeyVaultSecretsProvider#CERTIFICATE_ALIAS}). They require lab credentials and + * network access and only pass in CI (like the other {@code *IT} tests, they are not run by the unit-test + * surefire pass). + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class MtlsPopIT { + + private static final String AGENTIC_AUTHORITY = + "https://login.microsoftonline.com/" + TestConstants.AGENTIC_TENANT_ID + "/"; + + private PrivateKey privateKey; + private X509Certificate publicCertificate; + private IClientCertificate certificate; + + @BeforeAll + void init() throws KeyStoreException, NoSuchProviderException, IOException, + NoSuchAlgorithmException, CertificateException, UnrecoverableKeyException { + KeyStore keystore = CertificateHelper.createKeyStore(); + keystore.load(null, null); + + privateKey = (PrivateKey) keystore.getKey(KeyVaultSecretsProvider.CERTIFICATE_ALIAS, null); + publicCertificate = (X509Certificate) keystore.getCertificate(KeyVaultSecretsProvider.CERTIFICATE_ALIAS); + + assertNotNull(privateKey, "Lab private key not found. Ensure the lab cert is installed."); + assertNotNull(publicCertificate, "Lab certificate not found. Ensure the lab cert is installed."); + + certificate = ClientCredentialFactory.createFromCertificate(privateKey, publicCertificate); + } + + /** + * Direct SNI cert → mTLS PoP with no region (exercises the global + * {@code mtlsauth.microsoft.com} endpoint). The lab cert is presented as the client TLS certificate; + * the request carries {@code token_type=mtls_pop} and no client assertion. Requests an + * allow-listed resource (Key Vault) so ESTS issues the bound token. + */ + @Test + void acquireTokenClientCredentials_Certificate_MtlsPop() throws Exception { + final String clientId = KeyVaultRegistry.getMsidLabProvider() + .getSecretByName("LabVaultAppID").getValue(); + + ConfidentialClientApplication cca = ConfidentialClientApplication.builder(clientId, certificate) + .authority(TestConstants.MICROSOFT_AUTHORITY) // tenanted authority (required for mTLS PoP) + .build(); + + IAuthenticationResult result = cca.acquireToken(ClientCredentialParameters + .builder(Collections.singleton(KEYVAULT_DEFAULT_SCOPE)) + .mtlsProofOfPossession() + .build()) + .get(); + + assertMtlsPopResult(result, expectedLabThumbprint()); + } + + /** + * Direct SNI cert → mTLS PoP with a region configured (exercises the regional + * {@code .mtlsauth.microsoft.com} endpoint), and verifies the bound token is cached and + * retrieved on a second call. + */ + @Test + void acquireTokenClientCredentials_Certificate_MtlsPop_Regional() throws Exception { + final String clientId = KeyVaultRegistry.getMsidLabProvider() + .getSecretByName("LabVaultAppID").getValue(); + + ConfidentialClientApplication cca = ConfidentialClientApplication.builder(clientId, certificate) + .authority(TestConstants.MICROSOFT_AUTHORITY) + .azureRegion("westus") + .build(); + + IAuthenticationResult result = cca.acquireToken(ClientCredentialParameters + .builder(Collections.singleton(KEYVAULT_DEFAULT_SCOPE)) + .mtlsProofOfPossession() + .build()) + .get(); + + assertMtlsPopResult(result, expectedLabThumbprint()); + + // The mTLS-PoP token must be cached under {token_type + cert KeyId} and returned on lookup. + IAuthenticationResult cached = cca.acquireToken(ClientCredentialParameters + .builder(Collections.singleton(KEYVAULT_DEFAULT_SCOPE)) + .mtlsProofOfPossession() + .build()) + .get(); + + assertEquals(result.accessToken(), cached.accessToken(), + "Second mTLS-PoP request should return the cached bound token"); + } + + /** + * Requesting a Bearer token and an mTLS-PoP token for the same scope on the same app must yield two + * distinct tokens (cache isolation on {token_type + cert KeyId}), confirming the PoP path never + * aliases the existing SNI+Bearer path. + */ + @Test + void acquireTokenClientCredentials_BearerAndMtlsPop_AreCacheIsolated() throws Exception { + final String clientId = KeyVaultRegistry.getMsidLabProvider() + .getSecretByName("LabVaultAppID").getValue(); + + ConfidentialClientApplication cca = ConfidentialClientApplication.builder(clientId, certificate) + .authority(TestConstants.MICROSOFT_AUTHORITY) + .build(); + + // Existing SNI + Bearer path (unchanged). + IAuthenticationResult bearer = cca.acquireToken(ClientCredentialParameters + .builder(Collections.singleton(KEYVAULT_DEFAULT_SCOPE)) + .build()) + .get(); + assertEquals(TokenType.BEARER, bearer.metadata().tokenType()); + + // New SNI + mTLS PoP path. + IAuthenticationResult pop = cca.acquireToken(ClientCredentialParameters + .builder(Collections.singleton(KEYVAULT_DEFAULT_SCOPE)) + .mtlsProofOfPossession() + .build()) + .get(); + assertEquals(TokenType.MTLS_POP, pop.metadata().tokenType()); + + assertNotEquals(bearer.accessToken(), pop.accessToken(), + "Bearer and mTLS-PoP tokens for the same scope must be distinct cache entries"); + assertEquals(2, cca.tokenCache.accessTokens.size(), + "Bearer and mTLS-PoP tokens must occupy separate cache entries"); + } + + /** + * 2-leg FIC over mTLS PoP — both legs are mTLS-PoP requests and the final token is bound to + * the Leg-1 certificate thumbprint (locked contract item 12). + * + *

Leg 1: the RMA/blueprint app authenticates with the SNI cert on the TLS handshake and mints a + * federated credential (T1) with {@code fmi_path} + {@code mtlsProofOfPossession()} → T1 is + * itself cert-bound (mints the {@code cnf}). + * + *

Leg 2: the agent app authenticates with {@code client_assertion = T1} + * ({@code client_assertion_type = ...:jwt-pop}) and presents the binding certificate on the + * TLS handshake via {@code mtlsBindingCertificate(...)} → the final token (T2) is also cert-bound. + */ + @Test + void acquireTokenFic_TwoLeg_MtlsPop_BothLegsBound() throws Exception { + // LEG 1 — SNI cert (blueprint) mints a federated credential over mTLS PoP. + ConfidentialClientApplication blueprint = ConfidentialClientApplication.builder( + TestConstants.AGENTIC_BLUEPRINT_CLIENT_ID, certificate) + .authority(AGENTIC_AUTHORITY) + .azureRegion(TestConstants.AGENTIC_AZURE_REGION) + .build(); + + IAuthenticationResult leg1 = blueprint.acquireToken(ClientCredentialParameters + .builder(Collections.singleton(TestConstants.AGENTIC_TOKEN_EXCHANGE_SCOPE)) + .fmiPath(TestConstants.AGENTIC_AGENT_APP_ID) + .mtlsProofOfPossession() + .build()) + .get(); + + assertNotNull(leg1, "Leg 1 result should not be null"); + assertNotNull(leg1.accessToken(), "Leg 1 (T1) access token should not be null"); + assertEquals(TokenType.MTLS_POP, leg1.metadata().tokenType(), + "Leg 1 must itself be an mTLS-PoP (cert-bound) credential"); + assertNotNull(leg1.metadata().bindingCertificate(), "Leg 1 must expose its binding certificate"); + assertEquals(expectedLabThumbprint(), leg1.metadata().bindingCertificate().thumbprintSha256(), + "Leg 1 binding cert must be the lab SNI cert"); + + String t1 = leg1.accessToken(); + + // LEG 2 — agent app consumes T1 as a jwt-pop client_assertion AND presents the binding cert. + ConfidentialClientApplication agent = ConfidentialClientApplication.builder( + TestConstants.AGENTIC_AGENT_APP_ID, ClientCredentialFactory.createFromClientAssertion(t1)) + .authority(AGENTIC_AUTHORITY) + .mtlsBindingCertificate(certificate) + .build(); + + IAuthenticationResult leg2 = agent.acquireToken(ClientCredentialParameters + .builder(Collections.singleton(TestConstants.AGENTIC_GRAPH_SCOPE)) // allow-listed resource + .mtlsProofOfPossession() + .build()) + .get(); + + assertNotNull(leg2, "Leg 2 result should not be null"); + assertNotNull(leg2.accessToken(), "Leg 2 (T2) access token should not be null"); + assertFalse(leg2.accessToken().isEmpty(), "Leg 2 (T2) access token should not be empty"); + assertEquals(TokenType.MTLS_POP, leg2.metadata().tokenType(), + "Leg 2 must also be an mTLS-PoP (cert-bound) token"); + assertNotNull(leg2.metadata().bindingCertificate(), "Leg 2 must expose its binding certificate"); + assertEquals(expectedLabThumbprint(), leg2.metadata().bindingCertificate().thumbprintSha256(), + "Final token (T2) must be bound to the Leg-1 certificate thumbprint"); + } + + private void assertMtlsPopResult(IAuthenticationResult result, String expectedThumbprint) { + assertNotNull(result, "Auth result should not be null"); + assertNotNull(result.accessToken(), "Access token should not be null"); + assertFalse(result.accessToken().isEmpty(), "Access token should not be empty"); + assertEquals(TokenType.MTLS_POP, result.metadata().tokenType(), + "Result token type should be MTLS_POP"); + + BindingCertificate binding = result.metadata().bindingCertificate(); + assertNotNull(binding, "mTLS-PoP result must expose a binding certificate"); + assertNotNull(binding.thumbprintSha256(), "Binding certificate must expose its SHA-256 thumbprint"); + assertFalse(binding.certificateChain().isEmpty(), "Binding certificate must expose its x5c chain"); + assertEquals(expectedThumbprint, binding.thumbprintSha256(), + "Binding certificate thumbprint must match the lab SNI cert (x5t#S256)"); + } + + private String expectedLabThumbprint() { + return MtlsClientCertificateHelper.computeThumbprintSha256(publicCertificate); + } +} diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AcquireTokenByClientCredentialSupplier.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AcquireTokenByClientCredentialSupplier.java index d56f8176f..f4d12e325 100644 --- a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AcquireTokenByClientCredentialSupplier.java +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AcquireTokenByClientCredentialSupplier.java @@ -19,6 +19,16 @@ class AcquireTokenByClientCredentialSupplier extends AuthenticationResultSupplie @Override AuthenticationResult execute() throws Exception { + // For mTLS Proof-of-Possession, isolate the access token in the cache by the binding certificate's + // KeyId (x5t#S256) in addition to the token_type dimension, so PoP tokens bound to different + // certificates never alias. Stamped before the cache lookup so reads and writes hash identically. + if (clientCredentialRequest.parameters.mtlsProofOfPossession()) { + IClientCertificate bindingCertificate = MtlsClientCertificateHelper.resolveBindingCertificate( + (ConfidentialClientApplication) this.clientApplication, clientCredentialRequest.parameters); + clientCredentialRequest.parameters.bindingCertificateKeyId( + MtlsClientCertificateHelper.computeCertificateKeyId(bindingCertificate)); + } + if (clientCredentialRequest.parameters.skipCache() != null && !clientCredentialRequest.parameters.skipCache()) { LOG.debug("SkipCache set to false. Attempting cache lookup"); diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AssertionRequestOptions.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AssertionRequestOptions.java index e84acb364..ec79e981a 100644 --- a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AssertionRequestOptions.java +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AssertionRequestOptions.java @@ -15,11 +15,17 @@ public final class AssertionRequestOptions { private final String clientId; private final String tokenEndpoint; private final String clientAssertionFmiPath; + private final boolean proofOfPossession; AssertionRequestOptions(String clientId, String tokenEndpoint, String clientAssertionFmiPath) { + this(clientId, tokenEndpoint, clientAssertionFmiPath, false); + } + + AssertionRequestOptions(String clientId, String tokenEndpoint, String clientAssertionFmiPath, boolean proofOfPossession) { this.clientId = clientId; this.tokenEndpoint = tokenEndpoint; this.clientAssertionFmiPath = clientAssertionFmiPath; + this.proofOfPossession = proofOfPossession; } /** @@ -50,4 +56,15 @@ public String tokenEndpoint() { public String clientAssertionFmiPath() { return clientAssertionFmiPath; } + + /** + * Indicates whether the in-flight token request is an mTLS Proof-of-Possession (mTLS PoP) request. + * When true, a context-aware assertion provider can mint an appropriately bound assertion for the + * PoP flow (for example, FIC Leg 2). + * + * @return true if the request is an mTLS Proof-of-Possession request, false otherwise + */ + public boolean proofOfPossession() { + return proofOfPossession; + } } 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 d5fba3dff..d21335eb8 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 @@ -161,6 +161,13 @@ public class AuthenticationErrorCode { public static final String INVALID_TIMESTAMP_FORMAT = "invalid_timestamp_format"; + /** + * Indicates an error while configuring or performing an mTLS Proof-of-Possession request, such as a + * missing binding certificate, a non-tenanted authority, or an unsupported cloud. For more details, + * see https://aka.ms/msal4j-pop + */ + public static final String MTLS_POP_ERROR = "mtls_pop_error"; + /** * 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/AuthenticationResultMetadata.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AuthenticationResultMetadata.java index 35cded71d..2921f2623 100644 --- a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AuthenticationResultMetadata.java +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AuthenticationResultMetadata.java @@ -25,10 +25,28 @@ public class AuthenticationResultMetadata implements Serializable { */ private CacheRefreshReason cacheRefreshReason = CacheRefreshReason.NOT_APPLICABLE; + /** + * The type of the access token in the {@link AuthenticationResult}, see {@link TokenType} for possible + * values. Defaults to {@link TokenType#BEARER}. + */ + private TokenType tokenType = TokenType.BEARER; + + /** + * For {@link TokenType#MTLS_POP} results, the certificate the token is bound to (public material only). + * Null for Bearer results. + */ + private BindingCertificate bindingCertificate; + AuthenticationResultMetadata(TokenSource tokenSource, Long refreshOn, CacheRefreshReason cacheRefreshReason) { + this(tokenSource, refreshOn, cacheRefreshReason, TokenType.BEARER, null); + } + + AuthenticationResultMetadata(TokenSource tokenSource, Long refreshOn, CacheRefreshReason cacheRefreshReason, TokenType tokenType, BindingCertificate bindingCertificate) { this.tokenSource = tokenSource; this.refreshOn = refreshOn; this.cacheRefreshReason = cacheRefreshReason == null ? CacheRefreshReason.NOT_APPLICABLE : cacheRefreshReason; + this.tokenType = tokenType == null ? TokenType.BEARER : tokenType; + this.bindingCertificate = bindingCertificate; } public static AuthenticationResultMetadataBuilder builder() { @@ -47,6 +65,22 @@ public CacheRefreshReason cacheRefreshReason() { return this.cacheRefreshReason; } + /** + * @return the {@link TokenType} of the access token (e.g. {@link TokenType#BEARER} or + * {@link TokenType#MTLS_POP}). Never null. + */ + public TokenType tokenType() { + return this.tokenType; + } + + /** + * @return for {@link TokenType#MTLS_POP} results, the {@link BindingCertificate} (x5c chain + + * SHA-256 thumbprint, public material only) the token is bound to; null for Bearer results. + */ + public BindingCertificate bindingCertificate() { + return this.bindingCertificate; + } + void tokenSource(TokenSource tokenSource) { this.tokenSource = tokenSource; } @@ -59,10 +93,20 @@ void cacheRefreshReason(CacheRefreshReason cacheRefreshReason) { this.cacheRefreshReason = cacheRefreshReason; } + void tokenType(TokenType tokenType) { + this.tokenType = tokenType == null ? TokenType.BEARER : tokenType; + } + + void bindingCertificate(BindingCertificate bindingCertificate) { + this.bindingCertificate = bindingCertificate; + } + public static class AuthenticationResultMetadataBuilder { private TokenSource tokenSource; private Long refreshOn; private CacheRefreshReason cacheRefreshReason; + private TokenType tokenType = TokenType.BEARER; + private BindingCertificate bindingCertificate; AuthenticationResultMetadataBuilder() { } @@ -82,12 +126,22 @@ public AuthenticationResultMetadataBuilder cacheRefreshReason(CacheRefreshReason return this; } + public AuthenticationResultMetadataBuilder tokenType(TokenType tokenType) { + this.tokenType = tokenType; + return this; + } + + public AuthenticationResultMetadataBuilder bindingCertificate(BindingCertificate bindingCertificate) { + this.bindingCertificate = bindingCertificate; + return this; + } + public AuthenticationResultMetadata build() { - return new AuthenticationResultMetadata(this.tokenSource, this.refreshOn, cacheRefreshReason); + return new AuthenticationResultMetadata(this.tokenSource, this.refreshOn, cacheRefreshReason, tokenType, bindingCertificate); } public String toString() { - return "AuthenticationResultMetadata.AuthenticationResultMetadataBuilder(tokenSource=" + this.tokenSource + ", refreshOn=" + this.refreshOn + ", cacheRefreshReason$value=" + this.cacheRefreshReason + ")"; + return "AuthenticationResultMetadata.AuthenticationResultMetadataBuilder(tokenSource=" + this.tokenSource + ", refreshOn=" + this.refreshOn + ", cacheRefreshReason$value=" + this.cacheRefreshReason + ", tokenType=" + this.tokenType + ", bindingCertificate=" + this.bindingCertificate + ")"; } } } \ No newline at end of file diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/BindingCertificate.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/BindingCertificate.java new file mode 100644 index 000000000..9947d7c45 --- /dev/null +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/BindingCertificate.java @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j; + +import java.io.Serializable; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +/** + * Represents the certificate that an {@link TokenType#MTLS_POP} access token is bound to. + * + *

This type exposes public certificate material only — the X.509 certificate chain + * ({@code x5c}) and the SHA-256 thumbprint ({@code x5t#S256}) of the leaf certificate. It never + * exposes the private key. + * + *

Instances are available via {@link AuthenticationResultMetadata#bindingCertificate()} for + * mTLS Proof-of-Possession results, and are {@code null} for Bearer results. For more details, see + * https://aka.ms/msal4j-pop + */ +public final class BindingCertificate implements Serializable { + + private static final long serialVersionUID = 1L; + + private final List certificateChain; + private final String thumbprintSha256; + + BindingCertificate(List certificateChain, String thumbprintSha256) { + this.certificateChain = certificateChain == null + ? Collections.emptyList() + : Collections.unmodifiableList(new ArrayList<>(certificateChain)); + this.thumbprintSha256 = thumbprintSha256; + } + + /** + * @return the X.509 certificate chain ({@code x5c}) the token is bound to. The leaf certificate is + * first. Public material only — never contains a private key. + */ + public List certificateChain() { + return certificateChain; + } + + /** + * @return the base64url-encoded SHA-256 thumbprint ({@code x5t#S256}) of the leaf certificate the + * token is bound to. + */ + public String thumbprintSha256() { + return thumbprintSha256; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof BindingCertificate)) return false; + + BindingCertificate other = (BindingCertificate) o; + + if (!Objects.equals(thumbprintSha256, other.thumbprintSha256)) return false; + return Objects.equals(certificateChain, other.certificateChain); + } + + @Override + public int hashCode() { + int result = 1; + result = result * 59 + (this.thumbprintSha256 == null ? 43 : this.thumbprintSha256.hashCode()); + result = result * 59 + (this.certificateChain == null ? 43 : this.certificateChain.hashCode()); + return result; + } +} diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ClientAssertion.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ClientAssertion.java index 8a6ef769c..82c6b821f 100644 --- a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ClientAssertion.java +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ClientAssertion.java @@ -10,6 +10,7 @@ final class ClientAssertion implements IClientAssertion { static final String ASSERTION_TYPE_JWT_BEARER = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"; + static final String ASSERTION_TYPE_JWT_POP = "urn:ietf:params:oauth:client-assertion-type:jwt-pop"; private final String assertion; private final Callable assertionProvider; private final Function contextAwareAssertionProvider; diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ClientCredentialParameters.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ClientCredentialParameters.java index 3146cb26c..7786e6ca0 100644 --- a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ClientCredentialParameters.java +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ClientCredentialParameters.java @@ -32,6 +32,8 @@ public class ClientCredentialParameters implements IAcquireTokenParameters { private String fmiPath; + private boolean mtlsProofOfPossession; + // Generic extended cache key components. Any optional or flow-specific parameters // that should influence token cache isolation adds an entry here. The hash of these // components is used as part of the cache key in relevant scenarios entries. @@ -40,7 +42,7 @@ public class ClientCredentialParameters implements IAcquireTokenParameters { // Memoized hash of cacheKeyComponents (computed once since parameters are immutable). private String extCacheKeyHashCache; - private ClientCredentialParameters(Set scopes, Boolean skipCache, ClaimsRequest claims, Map extraHttpHeaders, Map extraQueryParameters, String tenant, IClientCredential clientCredential, String fmiPath) { + private ClientCredentialParameters(Set scopes, Boolean skipCache, ClaimsRequest claims, Map extraHttpHeaders, Map extraQueryParameters, String tenant, IClientCredential clientCredential, String fmiPath, boolean mtlsProofOfPossession) { this.scopes = scopes; this.skipCache = skipCache; this.claims = claims; @@ -49,6 +51,7 @@ private ClientCredentialParameters(Set scopes, Boolean skipCache, Claims this.tenant = tenant; this.clientCredential = clientCredential; this.fmiPath = fmiPath; + this.mtlsProofOfPossession = mtlsProofOfPossession; // Build cache key components from any parameters that require cache isolation. this.cacheKeyComponents = buildCacheKeyComponents(); @@ -114,6 +117,35 @@ public String fmiPath() { return this.fmiPath; } + /** + * Indicates whether this request should acquire a mutual-TLS Proof-of-Possession (mTLS PoP) token, + * where the client certificate is presented on the TLS handshake to the token endpoint and the + * resulting token is cryptographically bound to that certificate. + * + * @return true if mTLS Proof-of-Possession was requested, false for a standard Bearer token + */ + public boolean mtlsProofOfPossession() { + return this.mtlsProofOfPossession; + } + + /** + * Stamps the resolved binding-certificate KeyId ({@code x5t#S256}) onto this request so the access + * token is cache-isolated by certificate, in addition to the {@code token_type} dimension. + *

+ * Called once (before the silent cache lookup) so both cache reads and writes observe the same + * components. Clears the memoized hash so it is recomputed with the added component. + */ + void bindingCertificateKeyId(String keyId) { + if (StringHelper.isBlank(keyId)) { + return; + } + if (this.cacheKeyComponents == null) { + this.cacheKeyComponents = new TreeMap<>(); + } + this.cacheKeyComponents.put("cert_kid", keyId); + this.extCacheKeyHashCache = null; + } + /** * Builds the sorted map of cache key components from the parameters that require * cache isolation. Returns null if no components are present. @@ -127,6 +159,12 @@ private SortedMap buildCacheKeyComponents() { components = new TreeMap<>(); components.put("fmi_path", fmiPath); } + if (mtlsProofOfPossession) { + if (components == null) { + components = new TreeMap<>(); + } + components.put("token_type", TokenType.MTLS_POP.value()); + } return components; } @@ -162,6 +200,7 @@ public static class ClientCredentialParametersBuilder { private String tenant; private IClientCredential clientCredential; private String fmiPath; + private boolean mtlsProofOfPossession; ClientCredentialParametersBuilder() { } @@ -245,12 +284,35 @@ public ClientCredentialParametersBuilder fmiPath(String fmiPath) { return this; } + /** + * Requests a mutual-TLS Proof-of-Possession (mTLS PoP) token instead of a Bearer token. + *

+ * When set, the app's client certificate (a Subject-Name/Issuer cert configured on the + * {@link ConfidentialClientApplication}, or a certificate configured via + * {@link ConfidentialClientApplication.Builder#mtlsBindingCertificate(IClientCertificate)} for + * assertion-authenticated apps) is presented as the client TLS certificate in the mutual-TLS + * handshake to the token endpoint. Entra ID returns a token that is cryptographically bound to + * that certificate ({@code cnf}/{@code x5t#S256}), and {@code token_type=mtls_pop}. + *

+ * Requirements: the authority must be tenanted (not {@code /common} or {@code /organizations}), + * a binding certificate must be available, and the cloud must support mTLS PoP (public cloud + * today). A region is optional — when omitted, the global {@code mtlsauth.microsoft.com} endpoint + * is used. This mirrors MSAL.NET's {@code WithMtlsProofOfPossession()}. For more details, see + * https://aka.ms/msal4j-pop + * + * @return builder that can be used to construct ClientCredentialParameters + */ + public ClientCredentialParametersBuilder mtlsProofOfPossession() { + this.mtlsProofOfPossession = true; + return this; + } + public ClientCredentialParameters build() { - return new ClientCredentialParameters(this.scopes, this.skipCache, this.claims, this.extraHttpHeaders, this.extraQueryParameters, this.tenant, this.clientCredential, this.fmiPath); + return new ClientCredentialParameters(this.scopes, this.skipCache, this.claims, this.extraHttpHeaders, this.extraQueryParameters, this.tenant, this.clientCredential, this.fmiPath, this.mtlsProofOfPossession); } public String toString() { - return "ClientCredentialParameters.ClientCredentialParametersBuilder(scopes=" + this.scopes + ", skipCache=" + this.skipCache + ", claims=" + this.claims + ", extraHttpHeaders=" + this.extraHttpHeaders + ", extraQueryParameters=" + this.extraQueryParameters + ", tenant=" + this.tenant + ", clientCredential=" + this.clientCredential + ", fmiPath=" + this.fmiPath + ")"; + return "ClientCredentialParameters.ClientCredentialParametersBuilder(scopes=" + this.scopes + ", skipCache=" + this.skipCache + ", claims=" + this.claims + ", extraHttpHeaders=" + this.extraHttpHeaders + ", extraQueryParameters=" + this.extraQueryParameters + ", tenant=" + this.tenant + ", clientCredential=" + this.clientCredential + ", fmiPath=" + this.fmiPath + ", mtlsProofOfPossession=" + this.mtlsProofOfPossession + ")"; } } } diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ClientCredentialRequest.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ClientCredentialRequest.java index 8d60f82ba..88c2d64fd 100644 --- a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ClientCredentialRequest.java +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ClientCredentialRequest.java @@ -34,6 +34,10 @@ private static OAuthAuthorizationGrant createMsalGrant(ClientCredentialParameter params.put("fmi_path", parameters.fmiPath()); } + if (parameters.mtlsProofOfPossession()) { + params.put("token_type", TokenType.MTLS_POP.value()); + } + return new OAuthAuthorizationGrant(params, parameters.scopes(), parameters.claims()); } } 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 50df4466e..23d5e2f1a 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; + IClientCertificate mtlsBindingCertificate; /** 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 @@ -89,6 +90,7 @@ private ConfidentialClientApplication(Builder builder) { log = LoggerFactory.getLogger(ConfidentialClientApplication.class); this.clientCredential = builder.clientCredential; + this.mtlsBindingCertificate = builder.mtlsBindingCertificate; this.tenant = this.authenticationAuthority.tenant; } @@ -110,12 +112,23 @@ public boolean sendX5c() { return this.sendX5c; } + /** + * @return the certificate used as the client TLS certificate for mTLS Proof-of-Possession requests + * when the application's authentication credential is not itself a certificate (e.g. FIC Leg 2, where + * authentication is a federated assertion), or null if not configured. + */ + public IClientCertificate mtlsBindingCertificate() { + return this.mtlsBindingCertificate; + } + public static class Builder extends AbstractClientApplicationBase.Builder { private IClientCredential clientCredential; private boolean sendX5c = true; + private IClientCertificate mtlsBindingCertificate; + private Function> appTokenProvider; private Builder(String clientId, IClientCredential clientCredential) { @@ -139,6 +152,31 @@ public ConfidentialClientApplication.Builder sendX5c(boolean val) { return self(); } + /** + * Configures a certificate to present as the client TLS certificate in the mutual-TLS handshake + * for mTLS Proof-of-Possession requests (see + * {@link ClientCredentialParameters.ClientCredentialParametersBuilder#mtlsProofOfPossession()}). + *

+ * This is required only when the application authenticates with a credential that is not + * itself a certificate — for example, FIC Leg 2, where the application authenticates with a + * federated assertion ({@link ClientCredentialFactory#createFromClientAssertion(String)}) but must + * still bind the resulting token to a certificate. When the application's authentication credential + * is already an {@link IClientCertificate} (direct SN/I cert or FIC Leg 1), that same certificate is + * used as the binding certificate and this option is unnecessary. + *

+ * Only the certificate's public material is ever surfaced on the result (see + * {@link AuthenticationResultMetadata#bindingCertificate()}); the private key is never exposed. + * + * @param val the binding certificate + * @return instance of the Builder on which method was called + */ + public ConfidentialClientApplication.Builder mtlsBindingCertificate(IClientCertificate val) { + validateNotNull("mtlsBindingCertificate", val); + this.mtlsBindingCertificate = 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 diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/HttpHelper.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/HttpHelper.java index 2f6da6ae5..5ef9e3a4c 100644 --- a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/HttpHelper.java +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/HttpHelper.java @@ -71,7 +71,7 @@ public IHttpResponse executeHttpRequest(HttpRequest httpRequest, //Overloaded version of the more commonly used HTTP executor. It does not use ServiceBundle, allowing an HTTP call to be // made only with more bespoke request-level parameters rather than those from the app-level ServiceBundle - IHttpResponse executeHttpRequest(HttpRequest httpRequest, + public IHttpResponse executeHttpRequest(HttpRequest httpRequest, RequestContext requestContext, TelemetryManager telemetryManager, IHttpClient httpClient) { diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/IHttpHelper.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/IHttpHelper.java index 099e40b9c..0f25fb3a0 100644 --- a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/IHttpHelper.java +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/IHttpHelper.java @@ -25,4 +25,20 @@ interface IHttpHelper { IHttpResponse executeHttpRequest(HttpRequest httpRequest, RequestContext requestContext, ServiceBundle serviceBundle); + + /** + * Executes an HTTP request using an explicitly-provided HTTP client and telemetry manager rather than + * the app-level {@link ServiceBundle} client. Used for requests that require a bespoke transport, such + * as mTLS Proof-of-Possession where the client certificate must be presented on the TLS handshake. + * + * @param httpRequest The HTTP request to be executed + * @param requestContext Context information about the current request, including correlation IDs for telemetry + * @param telemetryManager The telemetry manager to use for this request + * @param httpClient The HTTP client to send the request with + * @return An {@link IHttpResponse} object containing the response + */ + IHttpResponse executeHttpRequest(HttpRequest httpRequest, + RequestContext requestContext, + TelemetryManager telemetryManager, + IHttpClient httpClient); } diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/MtlsClientCertificateHelper.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/MtlsClientCertificateHelper.java new file mode 100644 index 000000000..dcb795793 --- /dev/null +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/MtlsClientCertificateHelper.java @@ -0,0 +1,177 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j; + +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLSocketFactory; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.security.GeneralSecurityException; +import java.security.KeyStore; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.cert.Certificate; +import java.security.cert.CertificateEncodingException; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; + +/** + * Builds the TLS material needed to present an {@link IClientCertificate} as the client certificate in a + * mutual-TLS (mTLS) handshake to the token endpoint, and to describe that certificate as a public + * {@link BindingCertificate} on the result. + * + *

The source certificate is resolved from the request/app credential (direct SN/I cert or FIC Leg 1), + * or from a configured {@code mtlsBindingCertificate} (FIC Leg 2 where authentication is a federated + * assertion). Only public material is ever surfaced; the private key stays inside the in-memory key store. + */ +final class MtlsClientCertificateHelper { + + private static final String KEY_ENTRY_ALIAS = "msal-mtls-binding-cert"; + + private MtlsClientCertificateHelper() { + } + + /** + * Resolves the certificate to present as the client TLS certificate for an mTLS PoP request: the + * request/app authentication credential if it is a certificate (direct SN/I cert or FIC Leg 1), + * otherwise the application's configured {@code mtlsBindingCertificate} (FIC Leg 2, assertion-authenticated). + * + * @throws MsalClientException if no certificate can be resolved + */ + static IClientCertificate resolveBindingCertificate(ConfidentialClientApplication application, + ClientCredentialParameters parameters) { + IClientCredential credential = application.clientCredential; + if (parameters != null && parameters.clientCredential() != null) { + credential = parameters.clientCredential(); + } + + if (credential instanceof IClientCertificate) { + return (IClientCertificate) credential; + } + + if (application.mtlsBindingCertificate() != null) { + return application.mtlsBindingCertificate(); + } + + throw new MsalClientException( + "mTLS Proof-of-Possession requires a client certificate. Configure the application with a " + + "certificate credential, or set mtlsBindingCertificate(...) when authenticating with a " + + "client assertion.", + AuthenticationErrorCode.MTLS_POP_ERROR); + } + + /** + * Builds an {@link SSLSocketFactory} that presents the given certificate as the client certificate + * during the TLS handshake. + */ + static SSLSocketFactory createMtlsSocketFactory(IClientCertificate certificate) { + if (certificate == null) { + throw new MsalClientException( + "mTLS Proof-of-Possession requires a client certificate, but none was resolved.", + AuthenticationErrorCode.MTLS_POP_ERROR); + } + + try { + List chain = decodeCertificateChain(certificate); + if (chain.isEmpty()) { + throw new MsalClientException( + "mTLS Proof-of-Possession requires a certificate with a public key chain.", + AuthenticationErrorCode.MTLS_POP_ERROR); + } + + char[] password = new char[0]; + KeyStore keyStore = KeyStore.getInstance("PKCS12"); + keyStore.load(null, null); + keyStore.setKeyEntry(KEY_ENTRY_ALIAS, certificate.privateKey(), password, chain.toArray(new Certificate[0])); + + KeyManagerFactory keyManagerFactory = + KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + keyManagerFactory.init(keyStore, password); + + SSLContext sslContext = SSLContext.getInstance("TLS"); + sslContext.init(keyManagerFactory.getKeyManagers(), null, null); + + return sslContext.getSocketFactory(); + } catch (MsalClientException e) { + throw e; + } catch (GeneralSecurityException | IOException e) { + throw new MsalClientException( + "Failed to build the mTLS client certificate socket factory: " + e.getMessage(), + AuthenticationErrorCode.MTLS_POP_ERROR); + } + } + + /** + * Builds a public {@link BindingCertificate} (x5c chain + SHA-256 thumbprint) for the result metadata. + * Never includes the private key. + */ + static BindingCertificate buildBindingCertificate(IClientCertificate certificate) { + if (certificate == null) { + return null; + } + + try { + List chain = decodeCertificateChain(certificate); + if (chain.isEmpty()) { + return null; + } + String thumbprint = computeThumbprintSha256(chain.get(0)); + return new BindingCertificate(chain, thumbprint); + } catch (CertificateException e) { + throw new MsalClientException( + "Failed to read the mTLS binding certificate: " + e.getMessage(), + AuthenticationErrorCode.MTLS_POP_ERROR); + } + } + + /** + * @return the base64url (no padding) SHA-256 thumbprint (x5t#S256) of the given certificate's KeyId, + * used both as the public thumbprint and as a cache-isolation dimension. + */ + static String computeThumbprintSha256(X509Certificate certificate) { + try { + MessageDigest messageDigest = MessageDigest.getInstance("SHA-256"); + byte[] hash = messageDigest.digest(certificate.getEncoded()); + return Base64.getUrlEncoder().withoutPadding().encodeToString(hash); + } catch (NoSuchAlgorithmException | CertificateEncodingException e) { + throw new MsalClientException( + "Failed to compute the mTLS binding certificate thumbprint: " + e.getMessage(), + AuthenticationErrorCode.MTLS_POP_ERROR); + } + } + + /** + * Computes the KeyId ({@code x5t#S256}) of the leaf certificate of the given credential, used as a + * cache-isolation dimension so tokens bound to different certificates never alias. + */ + static String computeCertificateKeyId(IClientCertificate certificate) { + try { + List chain = decodeCertificateChain(certificate); + if (chain.isEmpty()) { + return null; + } + return computeThumbprintSha256(chain.get(0)); + } catch (CertificateException e) { + throw new MsalClientException( + "Failed to compute the mTLS binding certificate KeyId: " + e.getMessage(), + AuthenticationErrorCode.MTLS_POP_ERROR); + } + } + + private static List decodeCertificateChain(IClientCertificate certificate) + throws CertificateException { + List chain = new ArrayList<>(); + CertificateFactory certificateFactory = CertificateFactory.getInstance("X.509"); + for (String encoded : certificate.getEncodedPublicKeyCertificateChain()) { + byte[] der = Base64.getDecoder().decode(encoded); + chain.add((X509Certificate) certificateFactory.generateCertificate(new ByteArrayInputStream(der))); + } + return chain; + } +} diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/MtlsEndpointHelper.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/MtlsEndpointHelper.java new file mode 100644 index 000000000..e3838642a --- /dev/null +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/MtlsEndpointHelper.java @@ -0,0 +1,120 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j; + +import java.net.MalformedURLException; +import java.net.URL; +import java.util.Locale; + +/** + * Derives the mutual-TLS (mTLS) token endpoint used for mTLS Proof-of-Possession requests by rewriting the + * standard token endpoint host ({@code login.*}) to the mTLS host ({@code mtlsauth.*}). + * + *

+ * + *

Region is OPTIONAL — there is no "region required" error path. The authority must be tenanted + * ({@code /common} and {@code /organizations} are rejected). US Gov / China (and other sovereign clouds) + * are fail-fast for now; the guardrail is isolated in {@link #isMtlsPoPUnsupportedCloud(String)} so it is + * trivial to lift per-cloud once {@code mtlsauth.*} lands there. + */ +final class MtlsEndpointHelper { + + static final String GLOBAL_MTLS_HOST = "mtlsauth.microsoft.com"; + + private static final String REGIONAL_LOGIN_SUFFIX = ".login.microsoft.com"; + + private MtlsEndpointHelper() { + } + + /** + * Derives the mTLS token endpoint URL from a standard (already host-regionalized) token endpoint URL. + * The tenant is taken from the endpoint path and must be a real tenant (not {@code common}/{@code organizations}). + * + * @param tokenEndpoint the standard token endpoint URL (e.g. {@code https://login.microsoftonline.com//oauth2/v2.0/token}) + * @return the mTLS token endpoint URL + */ + static URL deriveMtlsTokenEndpoint(URL tokenEndpoint) { + validateTenanted(extractTenant(tokenEndpoint)); + + String host = tokenEndpoint.getHost(); + if (isMtlsPoPUnsupportedCloud(host)) { + throw new MsalClientException( + "mTLS Proof-of-Possession is not supported in this cloud (host: " + host + "). " + + "It is currently available in the public cloud only.", + AuthenticationErrorCode.MTLS_POP_ERROR); + } + + String mtlsHost = deriveMtlsHost(host); + + try { + return new URL(tokenEndpoint.getProtocol(), mtlsHost, tokenEndpoint.getPort(), tokenEndpoint.getFile()); + } catch (MalformedURLException e) { + throw new MsalClientException( + "Failed to derive the mTLS token endpoint: " + e.getMessage(), + AuthenticationErrorCode.MTLS_POP_ERROR); + } + } + + /** + * Extracts the tenant (first path segment) from a token endpoint URL. + */ + static String extractTenant(URL tokenEndpoint) { + String path = tokenEndpoint.getPath(); + if (StringHelper.isBlank(path)) { + return null; + } + String[] segments = path.split("/"); + for (String segment : segments) { + if (!StringHelper.isBlank(segment)) { + return segment; + } + } + return null; + } + + /** + * Rewrites a {@code login.*} host to its {@code mtlsauth.*} equivalent, preserving any regional prefix. + */ + static String deriveMtlsHost(String loginHost) { + String lower = loginHost.toLowerCase(Locale.ROOT); + + // Regionalized ESTS-R host: .login.microsoft.com -> .mtlsauth.microsoft.com + if (lower.endsWith(REGIONAL_LOGIN_SUFFIX) && lower.length() > REGIONAL_LOGIN_SUFFIX.length()) { + String region = lower.substring(0, lower.length() - REGIONAL_LOGIN_SUFFIX.length()); + return region + "." + GLOBAL_MTLS_HOST; + } + + // Global public hosts (login.microsoftonline.com, login.microsoft.com, login.windows.net, etc.) + return GLOBAL_MTLS_HOST; + } + + /** + * Isolated sovereign-cloud guardrail. Returns {@code true} for clouds where mTLS PoP is not yet + * supported (US Gov, China). Keep this as the single point of truth so it is trivial to lift per-cloud. + */ + static boolean isMtlsPoPUnsupportedCloud(String host) { + String lower = host.toLowerCase(Locale.ROOT); + return lower.endsWith(".us") + || lower.endsWith(".cn") + || lower.contains("usgovcloudapi") + || lower.contains("microsoftonline.us") + || lower.contains("chinacloudapi.cn") + || lower.contains("partner.microsoftonline.cn"); + } + + private static void validateTenanted(String tenant) { + if (StringHelper.isBlank(tenant) + || "common".equalsIgnoreCase(tenant) + || "organizations".equalsIgnoreCase(tenant)) { + throw new MsalClientException( + "mTLS Proof-of-Possession requires a tenanted authority. The '/common' and " + + "'/organizations' authorities are not supported; specify a tenant ID or domain.", + AuthenticationErrorCode.MTLS_POP_ERROR); + } + } +} diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/OAuthHttpRequest.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/OAuthHttpRequest.java index 49ecc2fcf..b00310636 100644 --- a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/OAuthHttpRequest.java +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/OAuthHttpRequest.java @@ -19,6 +19,9 @@ class OAuthHttpRequest { private final Map extraHeaderParams; private final ServiceBundle serviceBundle; private final RequestContext requestContext; + // When set (mTLS Proof-of-Possession), the token request is sent with this client instead of the + // app-level HTTP client so the client certificate is presented on the TLS handshake. + private IHttpClient mtlsHttpClient; OAuthHttpRequest(final HttpMethod method, final URL url, @@ -41,10 +44,19 @@ public HttpResponse send() throws IOException { httpHeaders, this.query); - IHttpResponse httpResponse = serviceBundle.getHttpHelper().executeHttpRequest( - httpRequest, - this.requestContext, - this.serviceBundle); + IHttpResponse httpResponse; + if (mtlsHttpClient != null) { + httpResponse = serviceBundle.getHttpHelper().executeHttpRequest( + httpRequest, + this.requestContext, + serviceBundle.getTelemetryManager(), + mtlsHttpClient); + } else { + httpResponse = serviceBundle.getHttpHelper().executeHttpRequest( + httpRequest, + this.requestContext, + this.serviceBundle); + } return createOauthHttpResponseFromHttpResponse(httpResponse); } @@ -104,6 +116,10 @@ void setQuery(String query) { this.query = query; } + void setMtlsHttpClient(IHttpClient mtlsHttpClient) { + this.mtlsHttpClient = mtlsHttpClient; + } + Map getExtraHeaderParams() { return this.extraHeaderParams; } diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/TokenRequestExecutor.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/TokenRequestExecutor.java index 3c7e35196..aae2f0a4a 100644 --- a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/TokenRequestExecutor.java +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/TokenRequestExecutor.java @@ -6,8 +6,10 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import javax.net.ssl.SSLSocketFactory; import java.io.IOException; import java.net.MalformedURLException; +import java.net.URL; import java.util.*; class TokenRequestExecutor { @@ -18,6 +20,10 @@ class TokenRequestExecutor { private final MsalRequest msalRequest; private final ServiceBundle serviceBundle; + // For mTLS Proof-of-Possession requests, the certificate presented on the TLS handshake. Resolved once + // when building the request and reused to describe the binding certificate on the result. + private IClientCertificate resolvedBindingCertificate; + TokenRequestExecutor(Authority requestAuthority, MsalRequest msalRequest, ServiceBundle serviceBundle) { this.requestAuthority = requestAuthority; this.serviceBundle = serviceBundle; @@ -42,13 +48,34 @@ OAuthHttpRequest createOauthHttpRequest() throws MalformedURLException { AuthenticationErrorCode.INVALID_ENDPOINT_URI); } + URL tokenEndpointUrl = requestAuthority.tokenEndpointUrl(); + IHttpClient mtlsHttpClient = null; + + if (isMtlsProofOfPossession()) { + ConfidentialClientApplication application = (ConfidentialClientApplication) msalRequest.application(); + this.resolvedBindingCertificate = resolveBindingCertificate(application); + tokenEndpointUrl = MtlsEndpointHelper.deriveMtlsTokenEndpoint(tokenEndpointUrl); + SSLSocketFactory mtlsSocketFactory = + MtlsClientCertificateHelper.createMtlsSocketFactory(resolvedBindingCertificate); + mtlsHttpClient = new DefaultHttpClient( + application.proxy(), + mtlsSocketFactory, + application.connectTimeoutForDefaultHttpClient(), + application.readTimeoutForDefaultHttpClient()); + LOG.debug("mTLS Proof-of-Possession requested; using mTLS token endpoint: {}", tokenEndpointUrl); + } + final OAuthHttpRequest oauthHttpRequest = new OAuthHttpRequest( HttpMethod.POST, - requestAuthority.tokenEndpointUrl(), + tokenEndpointUrl, msalRequest.headers().getReadonlyHeaderMap(), msalRequest.requestContext(), this.serviceBundle); + if (mtlsHttpClient != null) { + oauthHttpRequest.setMtlsHttpClient(mtlsHttpClient); + } + final Map params = new HashMap<>(msalRequest.msalAuthorizationGrant().toParameters()); if (msalRequest.application() instanceof AbstractClientApplicationBase && ((AbstractClientApplicationBase) msalRequest.application()).clientCapabilities() != null) { @@ -132,12 +159,15 @@ private void addCredentialToRequest(Map queryParameters, return; } + boolean mtlsPoP = isMtlsProofOfPossession(); + if (credentialToUse instanceof ClientSecret) { // For client secret, add client_secret parameter queryParameters.put("client_secret", ((ClientSecret) credentialToUse).clientSecret()); } else if (credentialToUse instanceof ClientAssertion) { // For client assertion, add client_assertion and client_assertion_type parameters ClientAssertion clientAssertion = (ClientAssertion) credentialToUse; + String assertion; if (clientAssertion.isContextAware()) { // Build assertion context with client assertion FMI path if available String clientAssertionFmiPath = null; @@ -154,13 +184,25 @@ private void addCredentialToRequest(Map queryParameters, AssertionRequestOptions options = new AssertionRequestOptions( application.clientId(), tokenEndpoint, - clientAssertionFmiPath); + clientAssertionFmiPath, + mtlsPoP); - addJWTBearerAssertionParams(queryParameters, clientAssertion.assertion(options)); + assertion = clientAssertion.assertion(options); } else { - addJWTBearerAssertionParams(queryParameters, clientAssertion.assertion()); + assertion = clientAssertion.assertion(); } + + // For mTLS PoP (FIC Leg 2), the assertion is authenticated with the jwt-pop assertion type and + // the binding certificate is presented on the TLS handshake. + addJWTAssertionParams(queryParameters, assertion, + mtlsPoP ? ClientAssertion.ASSERTION_TYPE_JWT_POP : ClientAssertion.ASSERTION_TYPE_JWT_BEARER); } else if (credentialToUse instanceof ClientCertificate) { + if (mtlsPoP) { + // For mTLS PoP (direct SN/I cert / FIC Leg 1), the certificate is presented as the client + // TLS certificate and authenticates the client; no client_assertion is sent (ESTS resolves + // 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 ClientCertificate certificate = (ClientCertificate) credentialToUse; String assertion = certificate.getAssertion( @@ -178,8 +220,39 @@ private void addCredentialToRequest(Map queryParameters, * @param assertion The JWT assertion string */ private void addJWTBearerAssertionParams(Map queryParameters, String assertion) { + addJWTAssertionParams(queryParameters, assertion, ClientAssertion.ASSERTION_TYPE_JWT_BEARER); + } + + /** + * Adds the JWT assertion parameters to the request with the given client_assertion_type. + * + * @param queryParameters The map of query parameters to add to + * @param assertion The JWT assertion string + * @param assertionType The client_assertion_type value (jwt-bearer for Bearer, jwt-pop for mTLS PoP) + */ + private void addJWTAssertionParams(Map queryParameters, String assertion, String assertionType) { queryParameters.put("client_assertion", assertion); - queryParameters.put("client_assertion_type", ClientAssertion.ASSERTION_TYPE_JWT_BEARER); + queryParameters.put("client_assertion_type", assertionType); + } + + /** + * @return true if this request opted into mTLS Proof-of-Possession via + * {@link ClientCredentialParameters.ClientCredentialParametersBuilder#mtlsProofOfPossession()}. + */ + private boolean isMtlsProofOfPossession() { + return msalRequest instanceof ClientCredentialRequest + && ((ClientCredentialRequest) msalRequest).parameters.mtlsProofOfPossession(); + } + + /** + * Resolves the certificate to present as the client TLS certificate for an mTLS PoP request: the + * request/app authentication credential if it is a certificate (direct SN/I cert or FIC Leg 1), + * otherwise the configured {@code mtlsBindingCertificate} (FIC Leg 2, assertion-authenticated). + */ + private IClientCertificate resolveBindingCertificate(ConfidentialClientApplication application) { + ClientCredentialParameters parameters = msalRequest instanceof ClientCredentialRequest + ? ((ClientCredentialRequest) msalRequest).parameters : null; + return MtlsClientCertificateHelper.resolveBindingCertificate(application, parameters); } private AuthenticationResult createAuthenticationResultFromOauthHttpResponse(HttpResponse oauthHttpResponse) { @@ -213,6 +286,13 @@ private AuthenticationResult createAuthenticationResultFromOauthHttpResponse(Htt } long currTimestampSec = new Date().getTime() / 1000; + TokenType tokenType = TokenType.BEARER; + BindingCertificate bindingCertificate = null; + if (isMtlsProofOfPossession()) { + tokenType = TokenType.MTLS_POP; + bindingCertificate = MtlsClientCertificateHelper.buildBindingCertificate(resolvedBindingCertificate); + } + result = AuthenticationResult.builder(). accessToken(response.accessToken()). refreshToken(response.refreshToken()). @@ -227,6 +307,8 @@ private AuthenticationResult createAuthenticationResultFromOauthHttpResponse(Htt metadata(AuthenticationResultMetadata.builder() .tokenSource(TokenSource.IDENTITY_PROVIDER) .refreshOn(response.getRefreshIn() > 0 ? currTimestampSec + response.getRefreshIn() : 0) + .tokenType(tokenType) + .bindingCertificate(bindingCertificate) .build()). build(); diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/TokenResponse.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/TokenResponse.java index b314bb77b..54e29584a 100644 --- a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/TokenResponse.java +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/TokenResponse.java @@ -16,6 +16,7 @@ class TokenResponse { private String accessToken; private String idToken; private String refreshToken; + private String tokenType; TokenResponse(Map jsonMap) { this.accessToken = jsonMap.get("access_token"); @@ -27,6 +28,7 @@ class TokenResponse { this.extExpiresIn = StringHelper.isNullOrBlank(jsonMap.get("ext_expires_in")) ? 0 : Long.parseLong(jsonMap.get("ext_expires_in")); this.refreshIn = StringHelper.isNullOrBlank(jsonMap.get("refresh_in")) ? 0: Long.parseLong(jsonMap.get("refresh_in")); this.foci = jsonMap.get("foci"); + this.tokenType = jsonMap.get("token_type"); } static TokenResponse parseHttpResponse(final HttpResponse httpResponse) { @@ -73,4 +75,8 @@ public String idToken() { public String refreshToken() { return refreshToken; } + + String tokenType() { + return tokenType; + } } diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/TokenType.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/TokenType.java new file mode 100644 index 000000000..4a9c571e0 --- /dev/null +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/TokenType.java @@ -0,0 +1,76 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j; + +/** + * Represents the type of an access token returned by the identity provider. + * + *

A {@link #BEARER} token can be used by any caller that possesses it. An {@link #MTLS_POP} + * (mutual-TLS Proof-of-Possession) token is cryptographically bound to a certificate and can only be + * used by a caller that can prove possession of that certificate on the TLS connection to the resource. + * + *

The token type of a result is available via {@link AuthenticationResultMetadata#tokenType()}. + * For more details on mTLS Proof-of-Possession, see https://aka.ms/msal4j-pop + */ +public enum TokenType { + + /** + * A standard Bearer access token. This is the default token type. + */ + BEARER("Bearer", 2), + + /** + * A mutual-TLS Proof-of-Possession access token, cryptographically bound to the client certificate + * presented on the TLS handshake to the token endpoint (bound via {@code cnf}/{@code x5t#S256}). + */ + MTLS_POP("mtls_pop", 6); + + private final String value; + private final int telemetryValue; + + TokenType(String value, int telemetryValue) { + this.value = value; + this.telemetryValue = telemetryValue; + } + + /** + * @return the wire/string representation of this token type + */ + public String value() { + return value; + } + + /** + * @return the numeric telemetry value for this token type. This value is kept in parity with the + * other MSAL SDKs (for example MSAL.NET's {@code TelemetryTokenTypeConstants}, where mTLS PoP is + * {@code 6}) so that ESTS/telemetry dashboards attribute the request consistently across SDKs. + */ + int telemetryValue() { + return telemetryValue; + } + + /** + * Maps a token_type string (as returned in a token response) to a {@link TokenType}. + * Unknown or null values map to {@link #BEARER}. + * + * @param tokenType the token_type string from a token response + * @return the corresponding {@link TokenType}, defaulting to {@link #BEARER} + */ + static TokenType fromString(String tokenType) { + if (StringHelper.isBlank(tokenType)) { + return BEARER; + } + + if (MTLS_POP.value.equalsIgnoreCase(tokenType) || "pop".equalsIgnoreCase(tokenType)) { + return MTLS_POP; + } + + return BEARER; + } + + @Override + public String toString() { + return value; + } +} diff --git a/msal4j-sdk/src/samples/confidential-client/ClientCredentialMtlsProofOfPossession.java b/msal4j-sdk/src/samples/confidential-client/ClientCredentialMtlsProofOfPossession.java new file mode 100644 index 000000000..0ccbd4253 --- /dev/null +++ b/msal4j-sdk/src/samples/confidential-client/ClientCredentialMtlsProofOfPossession.java @@ -0,0 +1,158 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import com.microsoft.aad.msal4j.BindingCertificate; +import com.microsoft.aad.msal4j.ClientCredentialFactory; +import com.microsoft.aad.msal4j.ClientCredentialParameters; +import com.microsoft.aad.msal4j.ConfidentialClientApplication; +import com.microsoft.aad.msal4j.IAuthenticationResult; +import com.microsoft.aad.msal4j.IClientCertificate; +import com.microsoft.aad.msal4j.TokenType; + +import java.security.PrivateKey; +import java.security.cert.X509Certificate; +import java.util.Collections; +import java.util.Set; + +/** + * Demonstrates using a Subject-Name/Issuer (SN/I) certificate as the first-leg credential over + * mTLS Proof-of-Possession (PoP). + * + *

Instead of using the SN/I certificate to sign a {@code private_key_jwt} (x5c) client assertion + * — which yields a Bearer token — the same certificate is presented as the client TLS + * certificate in the mutual-TLS handshake to the token endpoint, so Entra ID (ESTS) returns an + * mTLS-bound PoP access token ({@code token_type = mtls_pop}, bound via {@code cnf/x5t#S256}). + * The credential is the same certificate; only the mechanism changes (assertion-signer → TLS + * client cert). + * + *

Requirements / notes: + *

+ * + *

See https://aka.ms/msal4j-pop for more details. + */ +class ClientCredentialMtlsProofOfPossession { + + private final static String CLIENT_ID = ""; + // Must be a tenanted authority — not /common or /organizations. + private final static String AUTHORITY = "https://login.microsoftonline.com//"; + // Must be an ESTS allow-listed resource, e.g. Key Vault or MS Graph. + private final static Set SCOPE = Collections.singleton("https://vault.azure.net/.default"); + + public static void main(String args[]) throws Exception { + IClientCertificate sniCert = loadSniCertificate(); + + directSniCertMtlsPop(sniCert); + twoLegFicMtlsPop(sniCert); + } + + /** + * Scenario 1 — Direct SN/I certificate → mTLS-bound PoP token. + * + *

The SN/I certificate is presented as the client TLS certificate; the request carries + * {@code token_type=mtls_pop} and no client assertion (ESTS resolves the SN/I trust from the + * TLS-presented certificate). + */ + private static void directSniCertMtlsPop(IClientCertificate sniCert) throws Exception { + ConfidentialClientApplication cca = + ConfidentialClientApplication + .builder(CLIENT_ID, sniCert) + .authority(AUTHORITY) // tenanted authority required + // .azureRegion("westus") // OPTIONAL — omit to use global mtlsauth.microsoft.com + .build(); + + ClientCredentialParameters parameters = + ClientCredentialParameters + .builder(SCOPE) + .mtlsProofOfPossession() // request an mTLS-bound PoP token + .build(); + + IAuthenticationResult result = cca.acquireToken(parameters).join(); + + System.out.println("Access token: " + result.accessToken()); + System.out.println("Token type: " + result.metadata().tokenType()); // TokenType.MTLS_POP + + // The binding certificate exposes public material only (x5c chain + SHA-256 thumbprint). + BindingCertificate binding = result.metadata().bindingCertificate(); + System.out.println("Bound to cert x5t#S256: " + binding.thumbprintSha256()); + } + + /** + * Scenario 2 — Developer-orchestrated 2-leg Federated Identity Credential (FIC) over mTLS PoP. + * Both legs are mTLS-PoP requests, and the final token is bound to the Leg-1 certificate. + * + *

Leg 1: the SN/I-cert app mints a federated credential (T1) by presenting the cert on the TLS + * handshake ({@code fmiPath(...)} + {@code mtlsProofOfPossession()}). T1 is itself cert-bound. + * + *

Leg 2: the consuming app authenticates with {@code client_assertion = T1} + * ({@code client_assertion_type = ...:jwt-pop}) and presents a binding certificate on the TLS + * handshake via {@code mtlsBindingCertificate(...)}. The final token (T2) is also cert-bound. + */ + private static void twoLegFicMtlsPop(IClientCertificate sniCert) throws Exception { + // Exchange audience is CALLER-SUPPLIED (not SDK-hardcoded): + // generic S2S FIC : "api://AzureADTokenExchange" + // FMI variant : "api://AzureFMITokenExchange" (client id "urn:microsoft:identity:fmi"), + // driven by fmiPath(...) + Set exchangeScope = Collections.singleton("api://AzureADTokenExchange/.default"); + + // LEG 1 — SN/I cert (blueprint/RMA) mints a federated credential over mTLS PoP. + ConfidentialClientApplication rma = + ConfidentialClientApplication + .builder("", sniCert) + .authority(AUTHORITY) // tenanted + // .azureRegion("westus") // OPTIONAL + .build(); + + IAuthenticationResult leg1 = rma.acquireToken( + ClientCredentialParameters + .builder(exchangeScope) + .fmiPath("SomeFmiPath/FmiCredentialPath") // FMI variant only; omit for generic S2S FIC + .mtlsProofOfPossession() // Leg 1 over mTLS PoP -> mints the cnf + .build()) + .join(); + + String t1 = leg1.accessToken(); // cert-bound federated credential + System.out.println("Leg 1 token type: " + leg1.metadata().tokenType()); // MTLS_POP + + // LEG 2 — consuming app authenticates with T1 AND presents the binding cert on TLS. + // The final resource must be an ESTS allow-listed resource (e.g. MS Graph / Key Vault). + ConfidentialClientApplication agent = + ConfidentialClientApplication + .builder("", ClientCredentialFactory.createFromClientAssertion(t1)) + .authority(AUTHORITY) + .mtlsBindingCertificate(sniCert) // binding cert for the mTLS handshake + .build(); + + IAuthenticationResult leg2 = agent.acquireToken( + ClientCredentialParameters + .builder(SCOPE) // allow-listed resource + .mtlsProofOfPossession() // Leg 2 also over mTLS PoP + .build()) + .join(); + + System.out.println("Leg 2 access token: " + leg2.accessToken()); + System.out.println("Leg 2 token type: " + leg2.metadata().tokenType()); // MTLS_POP + System.out.println("Final token bound to cert x5t#S256: " + + leg2.metadata().bindingCertificate().thumbprintSha256()); + } + + /** + * Load the SN/I certificate. In a real app this typically comes from a secure store (e.g. the OS + * certificate store, a PKCS#12 file, or Azure Key Vault). The certificate object holds everything + * MSAL needs to drive the mTLS handshake (private key + chain). + */ + private static IClientCertificate loadSniCertificate() { + PrivateKey privateKey = null; // load from your secure store + X509Certificate publicCertificate = null; // load from your secure store + return ClientCredentialFactory.createFromCertificate(privateKey, publicCertificate); + } +} diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/MtlsClientCertificateHelperTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/MtlsClientCertificateHelperTest.java new file mode 100644 index 000000000..bdce92164 --- /dev/null +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/MtlsClientCertificateHelperTest.java @@ -0,0 +1,121 @@ +// 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 javax.net.ssl.SSLSocketFactory; +import java.io.InputStream; +import java.security.MessageDigest; +import java.security.cert.X509Certificate; +import java.util.Base64; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class MtlsClientCertificateHelperTest { + + 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 IClientCertificate certificate; + + @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); + } + } + + @Test + void createMtlsSocketFactory_buildsFactoryFromCertificate() { + SSLSocketFactory factory = MtlsClientCertificateHelper.createMtlsSocketFactory(certificate); + assertNotNull(factory); + } + + @Test + void createMtlsSocketFactory_nullCertificate_throwsMtlsPopError() { + MsalClientException ex = assertThrows(MsalClientException.class, () -> + MtlsClientCertificateHelper.createMtlsSocketFactory(null)); + assertEquals(AuthenticationErrorCode.MTLS_POP_ERROR, ex.errorCode()); + } + + @Test + void computeThumbprintSha256_matchesBase64UrlSha256OfLeafDer() throws Exception { + String encodedLeaf = certificate.getEncodedPublicKeyCertificateChain().get(0); + byte[] der = Base64.getDecoder().decode(encodedLeaf); + X509Certificate leaf = (X509Certificate) java.security.cert.CertificateFactory + .getInstance("X.509") + .generateCertificate(new java.io.ByteArrayInputStream(der)); + + String expected = Base64.getUrlEncoder().withoutPadding() + .encodeToString(MessageDigest.getInstance("SHA-256").digest(leaf.getEncoded())); + + assertEquals(expected, MtlsClientCertificateHelper.computeThumbprintSha256(leaf)); + assertEquals(expected, MtlsClientCertificateHelper.computeCertificateKeyId(certificate)); + } + + @Test + void buildBindingCertificate_exposesPublicMaterialOnly() { + BindingCertificate binding = MtlsClientCertificateHelper.buildBindingCertificate(certificate); + + assertNotNull(binding); + List chain = binding.certificateChain(); + assertFalse(chain.isEmpty()); + assertEquals(MtlsClientCertificateHelper.computeCertificateKeyId(certificate), binding.thumbprintSha256()); + + // BindingCertificate must never surface a private key. It only exposes the chain and thumbprint. + for (java.lang.reflect.Method m : BindingCertificate.class.getMethods()) { + assertFalse(m.getName().toLowerCase().contains("private"), + "BindingCertificate must not expose private key material via " + m.getName()); + } + } + + @Test + void resolveBindingCertificate_certCredential_returnsThatCertificate() throws Exception { + ConfidentialClientApplication app = ConfidentialClientApplication.builder("clientId", certificate) + .authority(AUTHORITY) + .instanceDiscovery(false) + .validateAuthority(false) + .build(); + + assertEquals(certificate, MtlsClientCertificateHelper.resolveBindingCertificate(app, null)); + } + + @Test + void resolveBindingCertificate_assertionWithBindingCert_returnsBindingCert() throws Exception { + ConfidentialClientApplication app = ConfidentialClientApplication.builder("clientId", + ClientCredentialFactory.createFromClientAssertion(TestHelper.signedAssertion)) + .authority(AUTHORITY) + .mtlsBindingCertificate(certificate) + .instanceDiscovery(false) + .validateAuthority(false) + .build(); + + assertEquals(certificate, MtlsClientCertificateHelper.resolveBindingCertificate(app, null)); + } + + @Test + void resolveBindingCertificate_assertionWithoutBindingCert_throwsMtlsPopError() throws Exception { + ConfidentialClientApplication app = ConfidentialClientApplication.builder("clientId", + ClientCredentialFactory.createFromClientAssertion(TestHelper.signedAssertion)) + .authority(AUTHORITY) + .instanceDiscovery(false) + .validateAuthority(false) + .build(); + + MsalClientException ex = assertThrows(MsalClientException.class, () -> + MtlsClientCertificateHelper.resolveBindingCertificate(app, null)); + assertEquals(AuthenticationErrorCode.MTLS_POP_ERROR, ex.errorCode()); + } +} diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/MtlsEndpointHelperTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/MtlsEndpointHelperTest.java new file mode 100644 index 000000000..00d2fb336 --- /dev/null +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/MtlsEndpointHelperTest.java @@ -0,0 +1,103 @@ +// 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.net.URL; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class MtlsEndpointHelperTest { + + private static URL url(String spec) throws Exception { + return new URL(spec); + } + + @Test + void deriveMtlsTokenEndpoint_noRegion_usesGlobalMtlsHost() throws Exception { + URL result = MtlsEndpointHelper.deriveMtlsTokenEndpoint( + url("https://login.microsoftonline.com/contoso.onmicrosoft.com/oauth2/v2.0/token")); + + assertEquals("mtlsauth.microsoft.com", result.getHost()); + assertEquals("/contoso.onmicrosoft.com/oauth2/v2.0/token", result.getPath()); + assertEquals("https", result.getProtocol()); + } + + @Test + void deriveMtlsTokenEndpoint_regionalHost_preservesRegionPrefix() throws Exception { + URL result = MtlsEndpointHelper.deriveMtlsTokenEndpoint( + url("https://westus.login.microsoft.com/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/oauth2/v2.0/token")); + + assertEquals("westus.mtlsauth.microsoft.com", result.getHost()); + assertEquals("/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/oauth2/v2.0/token", result.getPath()); + } + + @Test + void deriveMtlsHost_variants() { + assertEquals("mtlsauth.microsoft.com", MtlsEndpointHelper.deriveMtlsHost("login.microsoftonline.com")); + assertEquals("mtlsauth.microsoft.com", MtlsEndpointHelper.deriveMtlsHost("login.microsoft.com")); + assertEquals("mtlsauth.microsoft.com", MtlsEndpointHelper.deriveMtlsHost("login.windows.net")); + assertEquals("eastus.mtlsauth.microsoft.com", MtlsEndpointHelper.deriveMtlsHost("eastus.login.microsoft.com")); + } + + @Test + void extractTenant_returnsFirstPathSegment() throws Exception { + assertEquals("mytenant", MtlsEndpointHelper.extractTenant( + url("https://login.microsoftonline.com/mytenant/oauth2/v2.0/token"))); + } + + @Test + void deriveMtlsTokenEndpoint_commonAuthority_isRejected() { + MsalClientException ex = assertThrows(MsalClientException.class, () -> + MtlsEndpointHelper.deriveMtlsTokenEndpoint( + url("https://login.microsoftonline.com/common/oauth2/v2.0/token"))); + + assertEquals(AuthenticationErrorCode.MTLS_POP_ERROR, ex.errorCode()); + assertTrue(ex.getMessage().toLowerCase().contains("tenanted")); + } + + @Test + void deriveMtlsTokenEndpoint_organizationsAuthority_isRejected() { + MsalClientException ex = assertThrows(MsalClientException.class, () -> + MtlsEndpointHelper.deriveMtlsTokenEndpoint( + url("https://login.microsoftonline.com/organizations/oauth2/v2.0/token"))); + + assertEquals(AuthenticationErrorCode.MTLS_POP_ERROR, ex.errorCode()); + } + + @Test + void deriveMtlsTokenEndpoint_usGovCloud_failsFast() { + MsalClientException ex = assertThrows(MsalClientException.class, () -> + MtlsEndpointHelper.deriveMtlsTokenEndpoint( + url("https://login.microsoftonline.us/contoso.onmicrosoft.com/oauth2/v2.0/token"))); + + assertEquals(AuthenticationErrorCode.MTLS_POP_ERROR, ex.errorCode()); + assertTrue(ex.getMessage().toLowerCase().contains("cloud")); + } + + @Test + void deriveMtlsTokenEndpoint_chinaCloud_failsFast() { + MsalClientException ex = assertThrows(MsalClientException.class, () -> + MtlsEndpointHelper.deriveMtlsTokenEndpoint( + url("https://login.partner.microsoftonline.cn/contoso/oauth2/v2.0/token"))); + + assertEquals(AuthenticationErrorCode.MTLS_POP_ERROR, ex.errorCode()); + } + + @Test + void isMtlsPoPUnsupportedCloud_predicate() { + assertFalse(MtlsEndpointHelper.isMtlsPoPUnsupportedCloud("login.microsoftonline.com")); + assertFalse(MtlsEndpointHelper.isMtlsPoPUnsupportedCloud("westus.login.microsoft.com")); + + assertTrue(MtlsEndpointHelper.isMtlsPoPUnsupportedCloud("login.microsoftonline.us")); + assertTrue(MtlsEndpointHelper.isMtlsPoPUnsupportedCloud("login.partner.microsoftonline.cn")); + assertTrue(MtlsEndpointHelper.isMtlsPoPUnsupportedCloud("login.chinacloudapi.cn")); + } +} diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/MtlsProofOfPossessionTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/MtlsProofOfPossessionTest.java new file mode 100644 index 000000000..5f605175e --- /dev/null +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/MtlsProofOfPossessionTest.java @@ -0,0 +1,256 @@ +// 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.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.concurrent.AbstractExecutorService; +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.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +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; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class MtlsProofOfPossessionTest { + + 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 JWT_POP_ASSERTION_TYPE = + "urn:ietf:params:oauth:client-assertion-type:jwt-pop"; + + 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) { + HashMap values = new HashMap<>(); + values.put("access_token", accessToken); + return TestHelper.expectedResponse(HttpStatus.HTTP_OK, TestHelper.getSuccessfulTokenResponse(values)); + } + + @Test + void directSniCert_mtlsPop_targetsMtlsEndpoint_omitsClientAssertion_returnsMtlsPopToken() throws Exception { + ConfidentialClientApplication app = baseCertAppBuilder().build(); + + HttpRequest captured; + IAuthenticationResult result; + try (MockedConstruction mocked = mockConstruction(DefaultHttpClient.class, + (m, ctx) -> when(m.send(any(HttpRequest.class))).thenReturn(successResponse("mtls-pop-token")))) { + + result = app.acquireToken(ClientCredentialParameters.builder(Collections.singleton("https://graph.microsoft.com/.default")) + .mtlsProofOfPossession() + .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: host rewritten to the global mTLS host, tenanted token path preserved. + assertEquals("mtlsauth.microsoft.com", captured.url().getHost()); + assertTrue(captured.url().getPath().contains("/contoso.onmicrosoft.com/oauth2/v2.0/token")); + + // Body: token_type=mtls_pop, and NO client_assertion (ESTS resolves SN/I trust from the TLS cert). + String body = captured.body(); + assertTrue(body.contains("token_type=mtls_pop"), "body should request token_type=mtls_pop"); + assertFalse(body.contains("client_assertion"), "vanilla SN/I mTLS PoP must not send a client_assertion"); + assertFalse(body.contains("req_cnf"), "mTLS PoP must not send req_cnf"); + + // Result: cert-bound PoP token, binding cert surfaced as public material only. + assertEquals(TokenType.MTLS_POP, result.metadata().tokenType()); + assertNotNull(result.metadata().bindingCertificate()); + assertEquals(MtlsClientCertificateHelper.computeCertificateKeyId(certificate), + result.metadata().bindingCertificate().thumbprintSha256()); + } + + @Test + void ficLeg2_assertionWithBindingCert_usesJwtPopAssertionType() throws Exception { + String leg1Token = TestHelper.signedAssertion; + + ConfidentialClientApplication app = ConfidentialClientApplication.builder("agentClientId", + ClientCredentialFactory.createFromClientAssertion(leg1Token)) + .authority(AUTHORITY) + .mtlsBindingCertificate(certificate) + .instanceDiscovery(false) + .validateAuthority(false) + .executorService(SAME_THREAD_EXECUTOR) + .httpClient(mock(IHttpClient.class)) + .build(); + + HttpRequest captured; + IAuthenticationResult result; + try (MockedConstruction mocked = mockConstruction(DefaultHttpClient.class, + (m, ctx) -> when(m.send(any(HttpRequest.class))).thenReturn(successResponse("leg2-token")))) { + + result = app.acquireToken(ClientCredentialParameters.builder(Collections.singleton("https://graph.microsoft.com/.default")) + .mtlsProofOfPossession() + .skipCache(true) + .build()).get(); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(HttpRequest.class); + verify(mocked.constructed().get(0)).send(requestCaptor.capture()); + captured = requestCaptor.getValue(); + } + + assertEquals("mtlsauth.microsoft.com", captured.url().getHost()); + + String body = captured.body(); + assertTrue(body.contains("client_assertion=" + leg1Token), "Leg 2 must authenticate with the Leg-1 token"); + assertTrue(body.contains("jwt-pop"), "Leg 2 must use the jwt-pop client_assertion_type"); + assertTrue(body.contains("token_type=mtls_pop")); + + assertEquals(TokenType.MTLS_POP, result.metadata().tokenType()); + assertEquals(MtlsClientCertificateHelper.computeCertificateKeyId(certificate), + result.metadata().bindingCertificate().thumbprintSha256()); + } + + @Test + void bearerCert_backwardCompatible_usesLoginEndpointAndJwtBearerAssertion() throws Exception { + IHttpClient appHttpClient = mock(IHttpClient.class); + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(HttpRequest.class); + when(appHttpClient.send(any(HttpRequest.class))).thenReturn(successResponse("bearer-token")); + + ConfidentialClientApplication app = ConfidentialClientApplication.builder("clientId", certificate) + .authority(AUTHORITY) + .instanceDiscovery(false) + .validateAuthority(false) + .executorService(SAME_THREAD_EXECUTOR) + .httpClient(appHttpClient) + .build(); + + IAuthenticationResult result = app.acquireToken( + ClientCredentialParameters.builder(Collections.singleton("https://graph.microsoft.com/.default")) + .skipCache(true) + .build()).get(); + + verify(appHttpClient).send(requestCaptor.capture()); + HttpRequest captured = requestCaptor.getValue(); + + // Unchanged Bearer path: standard login endpoint, x5c client assertion (jwt-bearer), no mTLS markers. + assertEquals("login.microsoftonline.com", captured.url().getHost()); + String body = captured.body(); + assertTrue(body.contains("client_assertion"), "Bearer SN/I path still sends a client_assertion"); + assertTrue(body.contains("jwt-bearer"), "Bearer path uses the jwt-bearer client_assertion_type"); + assertFalse(body.contains("token_type=mtls_pop"), "Bearer path must not request mTLS PoP"); + + assertEquals(TokenType.BEARER, result.metadata().tokenType()); + } + + @Test + void cacheKey_isolatesBearerFromMtlsPopAndByCertificate() { + ClientCredentialParameters bearer = ClientCredentialParameters + .builder(Collections.singleton("scope")).build(); + + ClientCredentialParameters mtls = ClientCredentialParameters + .builder(Collections.singleton("scope")).mtlsProofOfPossession().build(); + mtls.bindingCertificateKeyId(MtlsClientCertificateHelper.computeCertificateKeyId(certificate)); + + ClientCredentialParameters mtlsOtherCert = ClientCredentialParameters + .builder(Collections.singleton("scope")).mtlsProofOfPossession().build(); + mtlsOtherCert.bindingCertificateKeyId("a-different-cert-key-id"); + + String bearerHash = bearer.computeExtCacheKeyHash(); + String mtlsHash = mtls.computeExtCacheKeyHash(); + String mtlsOtherHash = mtlsOtherCert.computeExtCacheKeyHash(); + + assertNotEquals(bearerHash, mtlsHash, "Bearer and mTLS PoP tokens must not alias in the cache"); + assertNotEquals(mtlsHash, mtlsOtherHash, "mTLS PoP tokens bound to different certs must not alias"); + } + + @Test + void mtlsPop_composesWithFmiPath_forFicLeg1() throws Exception { + ConfidentialClientApplication app = baseCertAppBuilder().build(); + + HttpRequest captured; + try (MockedConstruction mocked = mockConstruction(DefaultHttpClient.class, + (m, ctx) -> when(m.send(any(HttpRequest.class))).thenReturn(successResponse("fic-leg1-token")))) { + + app.acquireToken(ClientCredentialParameters.builder(Collections.singleton("api://AzureADTokenExchange/.default")) + .fmiPath("SomeFmiPath/CredentialPath") + .mtlsProofOfPossession() + .skipCache(true) + .build()).get(); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(HttpRequest.class); + verify(mocked.constructed().get(0)).send(requestCaptor.capture()); + captured = requestCaptor.getValue(); + } + + assertEquals("mtlsauth.microsoft.com", captured.url().getHost()); + String body = captured.body(); + assertTrue(body.contains("token_type=mtls_pop")); + assertTrue(body.contains("fmi_path"), "FIC Leg 1 should still send fmi_path alongside mTLS PoP"); + assertFalse(body.contains("client_assertion"), "FIC Leg 1 (cert) must not send a client_assertion"); + } +} diff --git a/msal4j-sdk/src/test/resources/mtls_test_cert.p12 b/msal4j-sdk/src/test/resources/mtls_test_cert.p12 new file mode 100644 index 0000000000000000000000000000000000000000..1ad9e1a478fde20fdd24590304fa9e09506bf73d GIT binary patch literal 2590 zcma)8X*d*&7M@jE#xlk(OWDUV#;zC)G9hc2q_K{Kv5b!-WNb;Y?`cNag)r7iX)FmP zWGP9c$u1FD>blRpPv5=2?)`C|_c`x-&in7Y&p}bxgh4`7G@J-r`^bf@l<|!6A7HMf|e?g`w~$?Y~vFWN z&39!i@v5V}wk`OU5f1*v%dc9{klY?{#a{0^IgRBLb zo?@n>IgGGgRI=}emSA1#{8Y}u3MaVlv(Hms8atM1*T7`ux30fsz>g?-HT&TJ;V&G@ zllpK@QcFpoR(dvB7`J~m(=zV+^N$$BEE)Nep zq5>&6JJBP$p|B>x?JZ7J29G;w!Z?G-@+t#>`1Ee_u9vS6K^%E{{TnPnxPt4%>{imv zF?Ib;fY$VG4pQEu&Q-l~%$C~jt3uA4xh1GXM4#Wu#iMlMUc-1(ASIQU0idsCVYjC# z2wWD%6HZ=}VG9ZQHmSpMRu3L$6#-)-$*o*WN;3mOc@TnKZ|+V37Oh$L80^)}28x5V zh-|6wy5iJH#gA$yb-l6`gj?NZgrWv`P$qoqKfZd87gpliqNjFTT&jXN9pe-vBYlxwF^0 zrDJpzjeT8cr?A7x|ANXRF#YODpki9$o=jbH6UXLgm_}zX))RN)p)*XYQRWyV$S*%S zB><#S{-`|WS`m>J(fzXj7ne|OtgStbS?|xDUh0= zTDO|&CayUKfh;=M*8)0?vNY`4k-XJIydhx2`B%gO~+-Afwozg^`M zzi1cm5zX#fmFLZ?PHGNQ9@;BG`%qIzF0oy`(h=a z=HH_tat&|so$OL+PaX!##@FJA6V`fa=NSw=@3qu`@U}evv@%sMTz7_c@p`gee`rI} zaMHndr86(w^Z7tTms?TL&kA0xnZH2 zv}*W|_dP4;qh;o+3}Tyz2`uS&+>ij1T;RxKd6YyDkG2x{t9d|(o@LAy%#ouhYvuPS z(m>S|CHW^VSrMuTF*v{<5CZT6kO4}E=yix8|2e{^ih#iFef+&7RJF9xI%+6YEgiI) zI*J1R_Q&T43ylJvJ*1{VK)@l4|E&Q3S6JWw`SI$vZ6ovAk^|B^2bZF^!OX?~C#=8h z=QK4fhm>YmwNMjb?>}6pfL#Nzrt1a>0@*?eGk!~WjHlhEBLz^+2E3sc~{o*i2iU{Od+xAV7DIb<+-()dmrmpXd(d zT??!K_3?9`#@ptyWf{gSvD>s`&hFVImyJa3Cr%%(bsl{hk$*Wt@?ygE9RBJxN4lDs zS;e^)a~F&|@}|-J-~rdjC#$U;l0(hVNKCa{!IR6EQ&T0h1hHc^>uh|f1J>#o!^kBL z=K+>wYA`pRA6j%BR86hi9jAWRZ|K_V;o7%~jcs^Aj58!ig_Tcy7IUat|s^RCcq_Lt(zMZNwBRWeZ2; zxevE9CO@FlDSWG(nPismX(MLNJ5@>R*WrrDz>%-)M5eT?)0=clx>zAK`DkW6T&%?2 z?ZNG3VDG|EC8x{=HCbzuQ$sY~Uq9G~({W}DZ8hld9X?xJ__BpHMC+32!MFU#0xm5 z4C{hdm={V{a?mTFj4M)DbtSi=iw%Qz_hd@!xGAb>S{{`SPSnb3nZfvme2!)4%Gg-S zOColObS;?CRx2A{Hj%@*m`QoDG3-NjQ;KvSIZNLvH@HKX$=#17sUYeSRvejZPd7^| zDx4>7pSb7AVP*WNYvZCZoVW^Z1uUoTZV?xu^}8Y$yzV1ON^|j9Asy;%_qMiPaYVU)MFDou6x*{ccv3Ah!Q6ruR~gW_$2S}JW5;9? z$$UOuGFz>J&1k4E8(+ Date: Mon, 6 Jul 2026 18:29:58 -0400 Subject: [PATCH 02/11] Extract FIC Leg 2 (mTLS PoP) into a follow-up PR Per review feedback, FIC Leg 2 (binding an assertion-authenticated app to a certificate via mtlsBindingCertificate + client_assertion_type=jwt-pop) is moved to a separate stacked follow-up PR. This PR retains the core direct SN/I -> mTLS PoP path and FIC Leg 1. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/copilot-instructions.md | 3 +- changelog.txt | 2 +- .../com/microsoft/aad/msal4j/MtlsPopIT.java | 68 +------------------ .../aad/msal4j/AssertionRequestOptions.java | 17 ----- .../microsoft/aad/msal4j/ClientAssertion.java | 1 - .../msal4j/ClientCredentialParameters.java | 4 +- .../msal4j/ConfidentialClientApplication.java | 38 ----------- .../msal4j/MtlsClientCertificateHelper.java | 15 ++-- .../aad/msal4j/TokenRequestExecutor.java | 29 ++------ ...ClientCredentialMtlsProofOfPossession.java | 61 ----------------- .../MtlsClientCertificateHelperTest.java | 13 ---- .../aad/msal4j/MtlsProofOfPossessionTest.java | 43 ------------ 12 files changed, 13 insertions(+), 281 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 516e22282..2bb13aaf3 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -138,8 +138,7 @@ MSAL4J supports multiple authentication flows, each with a public `*Parameters` - **Parameters**: `ClientCredentialParameters` - App-only authentication (daemon apps) - **Internal**: `ClientCredentialRequest` → `AcquireTokenByClientCredentialSupplier` - **Key Classes**: `IClientCredential`, `ClientSecret`, `ClientCertificate`, `ClientAssertion` -- **mTLS Proof-of-Possession (PoP)**: Opt in with `ClientCredentialParameters.builder(...).mtlsProofOfPossession()` to obtain an mTLS-bound PoP token (`token_type=mtls_pop`). The app's SN/I `IClientCertificate` is presented as the client TLS certificate to a rewritten `mtlsauth.*` endpoint (no `client_assertion` on the direct path) instead of signing an x5c assertion (the existing SNI+Bearer path is unchanged). Requires a tenanted authority and an ESTS allow-listed resource; region is optional (global `mtlsauth.microsoft.com` when absent). The result exposes `metadata().tokenType()` and `metadata().bindingCertificate()` (public material only — x5c chain + `x5t#S256`). For 2-leg FIC over mTLS PoP, attach the binding cert to an assertion-authenticated app with `ConfidentialClientApplication.Builder.mtlsBindingCertificate(IClientCertificate)`. - - **Key Classes**: `TokenType`, `BindingCertificate`, `MtlsClientCertificateHelper`, `MtlsEndpointHelper`; internal `AuthScheme`. Not supported: US Gov / China clouds, and user-scoped (`user_fic`) FIC over mTLS. +- **mTLS Proof-of-Possession (PoP)**: Opt in with `ClientCredentialParameters.builder(...).mtlsProofOfPossession()` to obtain an mTLS-bound PoP token (`token_type=mtls_pop`). The app's SN/I `IClientCertificate` is presented as the client TLS certificate to a rewritten `mtlsauth.*` endpoint (no `client_assertion` on the direct path) instead of signing an x5c assertion (the existing SNI+Bearer path is unchanged). Requires a tenanted authority and an ESTS allow-listed resource; region is optional (global `mtlsauth.microsoft.com` when absent). The result exposes `metadata().tokenType()` and `metadata().bindingCertificate()` (public material only — x5c chain + `x5t#S256`). - **Key Classes**: `TokenType`, `BindingCertificate`, `MtlsClientCertificateHelper`, `MtlsEndpointHelper`; internal `AuthScheme`. Not supported: US Gov / China clouds, and user-scoped (`user_fic`) FIC over mTLS. **On-Behalf-Of (OBO)** - **Public API**: `acquireToken(OnBehalfOfParameters)` diff --git a/changelog.txt b/changelog.txt index 451a6506b..f74875619 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,6 +1,6 @@ Version 1.25.0 ============= -- Add SN/I certificate support over mTLS Proof-of-Possession (PoP) for confidential clients, presenting the certificate as the client TLS cert to obtain an mTLS-bound token; includes the 2-leg Federated Identity Credential (FIC) flow where both legs are mTLS PoP +- Add SN/I certificate support over mTLS Proof-of-Possession (PoP) for confidential clients, presenting the certificate as the client TLS cert to obtain an mTLS-bound token - Add Federated Managed Identity (FMI) support for client credentials flow (#1025) - Add User Federated Identity Credential (user_fic) grant type support (#1026) - Add MSAL client metadata headers to IMDS managed identity requests (#1024) 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 b2917fc81..80d783346 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 @@ -35,11 +35,9 @@ * handshake to the token endpoint (no {@code private_key_jwt} / x5c client assertion on the direct * path). * - *

Both scenarios from the plan are covered: + *

The primary scenario is covered: *

* *

Testability gate (SME note A): ESTS gates mTLS PoP on the final resource audience, @@ -55,9 +53,6 @@ @TestInstance(TestInstance.Lifecycle.PER_CLASS) class MtlsPopIT { - private static final String AGENTIC_AUTHORITY = - "https://login.microsoftonline.com/" + TestConstants.AGENTIC_TENANT_ID + "/"; - private PrivateKey privateKey; private X509Certificate publicCertificate; private IClientCertificate certificate; @@ -170,67 +165,6 @@ void acquireTokenClientCredentials_BearerAndMtlsPop_AreCacheIsolated() throws Ex "Bearer and mTLS-PoP tokens must occupy separate cache entries"); } - /** - * 2-leg FIC over mTLS PoP — both legs are mTLS-PoP requests and the final token is bound to - * the Leg-1 certificate thumbprint (locked contract item 12). - * - *

Leg 1: the RMA/blueprint app authenticates with the SNI cert on the TLS handshake and mints a - * federated credential (T1) with {@code fmi_path} + {@code mtlsProofOfPossession()} → T1 is - * itself cert-bound (mints the {@code cnf}). - * - *

Leg 2: the agent app authenticates with {@code client_assertion = T1} - * ({@code client_assertion_type = ...:jwt-pop}) and presents the binding certificate on the - * TLS handshake via {@code mtlsBindingCertificate(...)} → the final token (T2) is also cert-bound. - */ - @Test - void acquireTokenFic_TwoLeg_MtlsPop_BothLegsBound() throws Exception { - // LEG 1 — SNI cert (blueprint) mints a federated credential over mTLS PoP. - ConfidentialClientApplication blueprint = ConfidentialClientApplication.builder( - TestConstants.AGENTIC_BLUEPRINT_CLIENT_ID, certificate) - .authority(AGENTIC_AUTHORITY) - .azureRegion(TestConstants.AGENTIC_AZURE_REGION) - .build(); - - IAuthenticationResult leg1 = blueprint.acquireToken(ClientCredentialParameters - .builder(Collections.singleton(TestConstants.AGENTIC_TOKEN_EXCHANGE_SCOPE)) - .fmiPath(TestConstants.AGENTIC_AGENT_APP_ID) - .mtlsProofOfPossession() - .build()) - .get(); - - assertNotNull(leg1, "Leg 1 result should not be null"); - assertNotNull(leg1.accessToken(), "Leg 1 (T1) access token should not be null"); - assertEquals(TokenType.MTLS_POP, leg1.metadata().tokenType(), - "Leg 1 must itself be an mTLS-PoP (cert-bound) credential"); - assertNotNull(leg1.metadata().bindingCertificate(), "Leg 1 must expose its binding certificate"); - assertEquals(expectedLabThumbprint(), leg1.metadata().bindingCertificate().thumbprintSha256(), - "Leg 1 binding cert must be the lab SNI cert"); - - String t1 = leg1.accessToken(); - - // LEG 2 — agent app consumes T1 as a jwt-pop client_assertion AND presents the binding cert. - ConfidentialClientApplication agent = ConfidentialClientApplication.builder( - TestConstants.AGENTIC_AGENT_APP_ID, ClientCredentialFactory.createFromClientAssertion(t1)) - .authority(AGENTIC_AUTHORITY) - .mtlsBindingCertificate(certificate) - .build(); - - IAuthenticationResult leg2 = agent.acquireToken(ClientCredentialParameters - .builder(Collections.singleton(TestConstants.AGENTIC_GRAPH_SCOPE)) // allow-listed resource - .mtlsProofOfPossession() - .build()) - .get(); - - assertNotNull(leg2, "Leg 2 result should not be null"); - assertNotNull(leg2.accessToken(), "Leg 2 (T2) access token should not be null"); - assertFalse(leg2.accessToken().isEmpty(), "Leg 2 (T2) access token should not be empty"); - assertEquals(TokenType.MTLS_POP, leg2.metadata().tokenType(), - "Leg 2 must also be an mTLS-PoP (cert-bound) token"); - assertNotNull(leg2.metadata().bindingCertificate(), "Leg 2 must expose its binding certificate"); - assertEquals(expectedLabThumbprint(), leg2.metadata().bindingCertificate().thumbprintSha256(), - "Final token (T2) must be bound to the Leg-1 certificate thumbprint"); - } - 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/AssertionRequestOptions.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AssertionRequestOptions.java index ec79e981a..e84acb364 100644 --- a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AssertionRequestOptions.java +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AssertionRequestOptions.java @@ -15,17 +15,11 @@ public final class AssertionRequestOptions { private final String clientId; private final String tokenEndpoint; private final String clientAssertionFmiPath; - private final boolean proofOfPossession; AssertionRequestOptions(String clientId, String tokenEndpoint, String clientAssertionFmiPath) { - this(clientId, tokenEndpoint, clientAssertionFmiPath, false); - } - - AssertionRequestOptions(String clientId, String tokenEndpoint, String clientAssertionFmiPath, boolean proofOfPossession) { this.clientId = clientId; this.tokenEndpoint = tokenEndpoint; this.clientAssertionFmiPath = clientAssertionFmiPath; - this.proofOfPossession = proofOfPossession; } /** @@ -56,15 +50,4 @@ public String tokenEndpoint() { public String clientAssertionFmiPath() { return clientAssertionFmiPath; } - - /** - * Indicates whether the in-flight token request is an mTLS Proof-of-Possession (mTLS PoP) request. - * When true, a context-aware assertion provider can mint an appropriately bound assertion for the - * PoP flow (for example, FIC Leg 2). - * - * @return true if the request is an mTLS Proof-of-Possession request, false otherwise - */ - public boolean proofOfPossession() { - return proofOfPossession; - } } diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ClientAssertion.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ClientAssertion.java index 82c6b821f..8a6ef769c 100644 --- a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ClientAssertion.java +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ClientAssertion.java @@ -10,7 +10,6 @@ final class ClientAssertion implements IClientAssertion { static final String ASSERTION_TYPE_JWT_BEARER = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"; - static final String ASSERTION_TYPE_JWT_POP = "urn:ietf:params:oauth:client-assertion-type:jwt-pop"; private final String assertion; private final Callable assertionProvider; private final Function contextAwareAssertionProvider; diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ClientCredentialParameters.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ClientCredentialParameters.java index 7786e6ca0..03eba7917 100644 --- a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ClientCredentialParameters.java +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ClientCredentialParameters.java @@ -288,9 +288,7 @@ public ClientCredentialParametersBuilder fmiPath(String fmiPath) { * Requests a mutual-TLS Proof-of-Possession (mTLS PoP) token instead of a Bearer token. *

* When set, the app's client certificate (a Subject-Name/Issuer cert configured on the - * {@link ConfidentialClientApplication}, or a certificate configured via - * {@link ConfidentialClientApplication.Builder#mtlsBindingCertificate(IClientCertificate)} for - * assertion-authenticated apps) is presented as the client TLS certificate in the mutual-TLS + * {@link ConfidentialClientApplication}) is presented as the client TLS certificate in the mutual-TLS * handshake to the token endpoint. Entra ID returns a token that is cryptographically bound to * that certificate ({@code cnf}/{@code x5t#S256}), and {@code token_type=mtls_pop}. *

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 23d5e2f1a..50df4466e 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,7 +21,6 @@ public class ConfidentialClientApplication extends AbstractClientApplicationBase IClientCredential clientCredential; private boolean sendX5c; - IClientCertificate mtlsBindingCertificate; /** 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 @@ -90,7 +89,6 @@ private ConfidentialClientApplication(Builder builder) { log = LoggerFactory.getLogger(ConfidentialClientApplication.class); this.clientCredential = builder.clientCredential; - this.mtlsBindingCertificate = builder.mtlsBindingCertificate; this.tenant = this.authenticationAuthority.tenant; } @@ -112,23 +110,12 @@ public boolean sendX5c() { return this.sendX5c; } - /** - * @return the certificate used as the client TLS certificate for mTLS Proof-of-Possession requests - * when the application's authentication credential is not itself a certificate (e.g. FIC Leg 2, where - * authentication is a federated assertion), or null if not configured. - */ - public IClientCertificate mtlsBindingCertificate() { - return this.mtlsBindingCertificate; - } - public static class Builder extends AbstractClientApplicationBase.Builder { private IClientCredential clientCredential; private boolean sendX5c = true; - private IClientCertificate mtlsBindingCertificate; - private Function> appTokenProvider; private Builder(String clientId, IClientCredential clientCredential) { @@ -152,31 +139,6 @@ public ConfidentialClientApplication.Builder sendX5c(boolean val) { return self(); } - /** - * Configures a certificate to present as the client TLS certificate in the mutual-TLS handshake - * for mTLS Proof-of-Possession requests (see - * {@link ClientCredentialParameters.ClientCredentialParametersBuilder#mtlsProofOfPossession()}). - *

- * This is required only when the application authenticates with a credential that is not - * itself a certificate — for example, FIC Leg 2, where the application authenticates with a - * federated assertion ({@link ClientCredentialFactory#createFromClientAssertion(String)}) but must - * still bind the resulting token to a certificate. When the application's authentication credential - * is already an {@link IClientCertificate} (direct SN/I cert or FIC Leg 1), that same certificate is - * used as the binding certificate and this option is unnecessary. - *

- * Only the certificate's public material is ever surfaced on the result (see - * {@link AuthenticationResultMetadata#bindingCertificate()}); the private key is never exposed. - * - * @param val the binding certificate - * @return instance of the Builder on which method was called - */ - public ConfidentialClientApplication.Builder mtlsBindingCertificate(IClientCertificate val) { - validateNotNull("mtlsBindingCertificate", val); - this.mtlsBindingCertificate = 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 diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/MtlsClientCertificateHelper.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/MtlsClientCertificateHelper.java index dcb795793..cb6357c6d 100644 --- a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/MtlsClientCertificateHelper.java +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/MtlsClientCertificateHelper.java @@ -26,9 +26,8 @@ * mutual-TLS (mTLS) handshake to the token endpoint, and to describe that certificate as a public * {@link BindingCertificate} on the result. * - *

The source certificate is resolved from the request/app credential (direct SN/I cert or FIC Leg 1), - * or from a configured {@code mtlsBindingCertificate} (FIC Leg 2 where authentication is a federated - * assertion). Only public material is ever surfaced; the private key stays inside the in-memory key store. + *

The source certificate is resolved from the request/app credential (direct SN/I cert or FIC Leg 1). + * Only public material is ever surfaced; the private key stays inside the in-memory key store. */ final class MtlsClientCertificateHelper { @@ -39,8 +38,7 @@ private MtlsClientCertificateHelper() { /** * Resolves the certificate to present as the client TLS certificate for an mTLS PoP request: the - * request/app authentication credential if it is a certificate (direct SN/I cert or FIC Leg 1), - * otherwise the application's configured {@code mtlsBindingCertificate} (FIC Leg 2, assertion-authenticated). + * request/app authentication credential when it is a certificate (direct SN/I cert or FIC Leg 1). * * @throws MsalClientException if no certificate can be resolved */ @@ -55,14 +53,9 @@ static IClientCertificate resolveBindingCertificate(ConfidentialClientApplicatio return (IClientCertificate) credential; } - if (application.mtlsBindingCertificate() != null) { - return application.mtlsBindingCertificate(); - } - throw new MsalClientException( "mTLS Proof-of-Possession requires a client certificate. Configure the application with a " + - "certificate credential, or set mtlsBindingCertificate(...) when authenticating with a " + - "client assertion.", + "certificate credential.", AuthenticationErrorCode.MTLS_POP_ERROR); } diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/TokenRequestExecutor.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/TokenRequestExecutor.java index aae2f0a4a..7f9e3b2c2 100644 --- a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/TokenRequestExecutor.java +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/TokenRequestExecutor.java @@ -167,7 +167,6 @@ private void addCredentialToRequest(Map queryParameters, } else if (credentialToUse instanceof ClientAssertion) { // For client assertion, add client_assertion and client_assertion_type parameters ClientAssertion clientAssertion = (ClientAssertion) credentialToUse; - String assertion; if (clientAssertion.isContextAware()) { // Build assertion context with client assertion FMI path if available String clientAssertionFmiPath = null; @@ -184,18 +183,12 @@ private void addCredentialToRequest(Map queryParameters, AssertionRequestOptions options = new AssertionRequestOptions( application.clientId(), tokenEndpoint, - clientAssertionFmiPath, - mtlsPoP); + clientAssertionFmiPath); - assertion = clientAssertion.assertion(options); + addJWTBearerAssertionParams(queryParameters, clientAssertion.assertion(options)); } else { - assertion = clientAssertion.assertion(); + addJWTBearerAssertionParams(queryParameters, clientAssertion.assertion()); } - - // For mTLS PoP (FIC Leg 2), the assertion is authenticated with the jwt-pop assertion type and - // the binding certificate is presented on the TLS handshake. - addJWTAssertionParams(queryParameters, assertion, - mtlsPoP ? ClientAssertion.ASSERTION_TYPE_JWT_POP : ClientAssertion.ASSERTION_TYPE_JWT_BEARER); } else if (credentialToUse instanceof ClientCertificate) { if (mtlsPoP) { // For mTLS PoP (direct SN/I cert / FIC Leg 1), the certificate is presented as the client @@ -220,19 +213,8 @@ private void addCredentialToRequest(Map queryParameters, * @param assertion The JWT assertion string */ private void addJWTBearerAssertionParams(Map queryParameters, String assertion) { - addJWTAssertionParams(queryParameters, assertion, ClientAssertion.ASSERTION_TYPE_JWT_BEARER); - } - - /** - * Adds the JWT assertion parameters to the request with the given client_assertion_type. - * - * @param queryParameters The map of query parameters to add to - * @param assertion The JWT assertion string - * @param assertionType The client_assertion_type value (jwt-bearer for Bearer, jwt-pop for mTLS PoP) - */ - private void addJWTAssertionParams(Map queryParameters, String assertion, String assertionType) { queryParameters.put("client_assertion", assertion); - queryParameters.put("client_assertion_type", assertionType); + queryParameters.put("client_assertion_type", ClientAssertion.ASSERTION_TYPE_JWT_BEARER); } /** @@ -246,8 +228,7 @@ private boolean isMtlsProofOfPossession() { /** * Resolves the certificate to present as the client TLS certificate for an mTLS PoP request: the - * request/app authentication credential if it is a certificate (direct SN/I cert or FIC Leg 1), - * otherwise the configured {@code mtlsBindingCertificate} (FIC Leg 2, assertion-authenticated). + * request/app authentication credential when it is a certificate (direct SN/I cert or FIC Leg 1). */ private IClientCertificate resolveBindingCertificate(ConfidentialClientApplication application) { ClientCredentialParameters parameters = msalRequest instanceof ClientCredentialRequest diff --git a/msal4j-sdk/src/samples/confidential-client/ClientCredentialMtlsProofOfPossession.java b/msal4j-sdk/src/samples/confidential-client/ClientCredentialMtlsProofOfPossession.java index 0ccbd4253..964f91fca 100644 --- a/msal4j-sdk/src/samples/confidential-client/ClientCredentialMtlsProofOfPossession.java +++ b/msal4j-sdk/src/samples/confidential-client/ClientCredentialMtlsProofOfPossession.java @@ -2,7 +2,6 @@ // Licensed under the MIT License. import com.microsoft.aad.msal4j.BindingCertificate; -import com.microsoft.aad.msal4j.ClientCredentialFactory; import com.microsoft.aad.msal4j.ClientCredentialParameters; import com.microsoft.aad.msal4j.ConfidentialClientApplication; import com.microsoft.aad.msal4j.IAuthenticationResult; @@ -52,7 +51,6 @@ public static void main(String args[]) throws Exception { IClientCertificate sniCert = loadSniCertificate(); directSniCertMtlsPop(sniCert); - twoLegFicMtlsPop(sniCert); } /** @@ -86,65 +84,6 @@ private static void directSniCertMtlsPop(IClientCertificate sniCert) throws Exce System.out.println("Bound to cert x5t#S256: " + binding.thumbprintSha256()); } - /** - * Scenario 2 — Developer-orchestrated 2-leg Federated Identity Credential (FIC) over mTLS PoP. - * Both legs are mTLS-PoP requests, and the final token is bound to the Leg-1 certificate. - * - *

Leg 1: the SN/I-cert app mints a federated credential (T1) by presenting the cert on the TLS - * handshake ({@code fmiPath(...)} + {@code mtlsProofOfPossession()}). T1 is itself cert-bound. - * - *

Leg 2: the consuming app authenticates with {@code client_assertion = T1} - * ({@code client_assertion_type = ...:jwt-pop}) and presents a binding certificate on the TLS - * handshake via {@code mtlsBindingCertificate(...)}. The final token (T2) is also cert-bound. - */ - private static void twoLegFicMtlsPop(IClientCertificate sniCert) throws Exception { - // Exchange audience is CALLER-SUPPLIED (not SDK-hardcoded): - // generic S2S FIC : "api://AzureADTokenExchange" - // FMI variant : "api://AzureFMITokenExchange" (client id "urn:microsoft:identity:fmi"), - // driven by fmiPath(...) - Set exchangeScope = Collections.singleton("api://AzureADTokenExchange/.default"); - - // LEG 1 — SN/I cert (blueprint/RMA) mints a federated credential over mTLS PoP. - ConfidentialClientApplication rma = - ConfidentialClientApplication - .builder("", sniCert) - .authority(AUTHORITY) // tenanted - // .azureRegion("westus") // OPTIONAL - .build(); - - IAuthenticationResult leg1 = rma.acquireToken( - ClientCredentialParameters - .builder(exchangeScope) - .fmiPath("SomeFmiPath/FmiCredentialPath") // FMI variant only; omit for generic S2S FIC - .mtlsProofOfPossession() // Leg 1 over mTLS PoP -> mints the cnf - .build()) - .join(); - - String t1 = leg1.accessToken(); // cert-bound federated credential - System.out.println("Leg 1 token type: " + leg1.metadata().tokenType()); // MTLS_POP - - // LEG 2 — consuming app authenticates with T1 AND presents the binding cert on TLS. - // The final resource must be an ESTS allow-listed resource (e.g. MS Graph / Key Vault). - ConfidentialClientApplication agent = - ConfidentialClientApplication - .builder("", ClientCredentialFactory.createFromClientAssertion(t1)) - .authority(AUTHORITY) - .mtlsBindingCertificate(sniCert) // binding cert for the mTLS handshake - .build(); - - IAuthenticationResult leg2 = agent.acquireToken( - ClientCredentialParameters - .builder(SCOPE) // allow-listed resource - .mtlsProofOfPossession() // Leg 2 also over mTLS PoP - .build()) - .join(); - - System.out.println("Leg 2 access token: " + leg2.accessToken()); - System.out.println("Leg 2 token type: " + leg2.metadata().tokenType()); // MTLS_POP - System.out.println("Final token bound to cert x5t#S256: " - + leg2.metadata().bindingCertificate().thumbprintSha256()); - } - /** * Load the SN/I certificate. In a real app this typically comes from a secure store (e.g. the OS * certificate store, a PKCS#12 file, or Azure Key Vault). The certificate object holds everything diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/MtlsClientCertificateHelperTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/MtlsClientCertificateHelperTest.java index bdce92164..a96e65e88 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/MtlsClientCertificateHelperTest.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/MtlsClientCertificateHelperTest.java @@ -92,19 +92,6 @@ void resolveBindingCertificate_certCredential_returnsThatCertificate() throws Ex assertEquals(certificate, MtlsClientCertificateHelper.resolveBindingCertificate(app, null)); } - @Test - void resolveBindingCertificate_assertionWithBindingCert_returnsBindingCert() throws Exception { - ConfidentialClientApplication app = ConfidentialClientApplication.builder("clientId", - ClientCredentialFactory.createFromClientAssertion(TestHelper.signedAssertion)) - .authority(AUTHORITY) - .mtlsBindingCertificate(certificate) - .instanceDiscovery(false) - .validateAuthority(false) - .build(); - - assertEquals(certificate, MtlsClientCertificateHelper.resolveBindingCertificate(app, null)); - } - @Test void resolveBindingCertificate_assertionWithoutBindingCert_throwsMtlsPopError() throws Exception { ConfidentialClientApplication app = ConfidentialClientApplication.builder("clientId", diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/MtlsProofOfPossessionTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/MtlsProofOfPossessionTest.java index 5f605175e..95c9225a8 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/MtlsProofOfPossessionTest.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/MtlsProofOfPossessionTest.java @@ -34,8 +34,6 @@ class MtlsProofOfPossessionTest { 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 JWT_POP_ASSERTION_TYPE = - "urn:ietf:params:oauth:client-assertion-type:jwt-pop"; private IClientCertificate certificate; @@ -134,47 +132,6 @@ void directSniCert_mtlsPop_targetsMtlsEndpoint_omitsClientAssertion_returnsMtlsP result.metadata().bindingCertificate().thumbprintSha256()); } - @Test - void ficLeg2_assertionWithBindingCert_usesJwtPopAssertionType() throws Exception { - String leg1Token = TestHelper.signedAssertion; - - ConfidentialClientApplication app = ConfidentialClientApplication.builder("agentClientId", - ClientCredentialFactory.createFromClientAssertion(leg1Token)) - .authority(AUTHORITY) - .mtlsBindingCertificate(certificate) - .instanceDiscovery(false) - .validateAuthority(false) - .executorService(SAME_THREAD_EXECUTOR) - .httpClient(mock(IHttpClient.class)) - .build(); - - HttpRequest captured; - IAuthenticationResult result; - try (MockedConstruction mocked = mockConstruction(DefaultHttpClient.class, - (m, ctx) -> when(m.send(any(HttpRequest.class))).thenReturn(successResponse("leg2-token")))) { - - result = app.acquireToken(ClientCredentialParameters.builder(Collections.singleton("https://graph.microsoft.com/.default")) - .mtlsProofOfPossession() - .skipCache(true) - .build()).get(); - - ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(HttpRequest.class); - verify(mocked.constructed().get(0)).send(requestCaptor.capture()); - captured = requestCaptor.getValue(); - } - - assertEquals("mtlsauth.microsoft.com", captured.url().getHost()); - - String body = captured.body(); - assertTrue(body.contains("client_assertion=" + leg1Token), "Leg 2 must authenticate with the Leg-1 token"); - assertTrue(body.contains("jwt-pop"), "Leg 2 must use the jwt-pop client_assertion_type"); - assertTrue(body.contains("token_type=mtls_pop")); - - assertEquals(TokenType.MTLS_POP, result.metadata().tokenType()); - assertEquals(MtlsClientCertificateHelper.computeCertificateKeyId(certificate), - result.metadata().bindingCertificate().thumbprintSha256()); - } - @Test void bearerCert_backwardCompatible_usesLoginEndpointAndJwtBearerAssertion() throws Exception { IHttpClient appHttpClient = mock(IHttpClient.class); From 68d3715a1436669a09efc4d39c0a904b728a6e59 Mon Sep 17 00:00:00 2001 From: Robbie Ginsburg Date: Mon, 6 Jul 2026 18:51:47 -0400 Subject: [PATCH 03/11] Suppress CredScan finding for self-signed mTLS test certificate The Unit Tests build's Credential Scanner (Guardian) gate flags the checked-in PKCS12 test cert (mtls_test_cert.p12), failing the build. It is a self-signed, test-only certificate (CN=msal4j-mtls-test) with no production secret, so allowlist it in build/credscan-exclude.json. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- build/credscan-exclude.json | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/build/credscan-exclude.json b/build/credscan-exclude.json index f045498d8..30f2d1f9a 100644 --- a/build/credscan-exclude.json +++ b/build/credscan-exclude.json @@ -1,5 +1,9 @@ { "tool": "Credential Scanner", "suppressions": [ + { + "file": "msal4j-sdk/src/test/resources/mtls_test_cert.p12", + "_justification": "Self-signed, test-only certificate (CN=msal4j-mtls-test) used by unit tests for mTLS Proof-of-Possession. Contains no production secret." + } ] -} \ No newline at end of file +} From 66d59db906d15b8324eb8d3a7b46556c34c739a3 Mon Sep 17 00:00:00 2001 From: Robbie Ginsburg Date: Tue, 7 Jul 2026 18:21:41 -0400 Subject: [PATCH 04/11] Fix mTLS PoP socket factory on JDK 8 and address review feedback - MtlsClientCertificateHelper: use a non-empty transient in-memory keystore password (JDK 8 PKCS12 setKeyEntry throws 'Key protection algorithm not found' NPE with an empty password) and guard against a null private key. Fixes the MtlsPopIT failures seen only in CI (JDK 8). - ClientCredentialParameters: make the cache-key fields volatile and rewrite bindingCertificateKeyId as synchronized copy-on-write so a reused params instance can't observe a partially-updated map/hash; correct the stale 'computed once / immutable' comments. - copilot-instructions.md: split the inline '- Key Classes' segment into its own nested bullet so it renders as a list item. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/copilot-instructions.md | 3 +- .../msal4j/ClientCredentialParameters.java | 36 ++++++++++++------- .../msal4j/MtlsClientCertificateHelper.java | 17 +++++++-- 3 files changed, 40 insertions(+), 16 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 2bb13aaf3..19b6a58c3 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -138,7 +138,8 @@ MSAL4J supports multiple authentication flows, each with a public `*Parameters` - **Parameters**: `ClientCredentialParameters` - App-only authentication (daemon apps) - **Internal**: `ClientCredentialRequest` → `AcquireTokenByClientCredentialSupplier` - **Key Classes**: `IClientCredential`, `ClientSecret`, `ClientCertificate`, `ClientAssertion` -- **mTLS Proof-of-Possession (PoP)**: Opt in with `ClientCredentialParameters.builder(...).mtlsProofOfPossession()` to obtain an mTLS-bound PoP token (`token_type=mtls_pop`). The app's SN/I `IClientCertificate` is presented as the client TLS certificate to a rewritten `mtlsauth.*` endpoint (no `client_assertion` on the direct path) instead of signing an x5c assertion (the existing SNI+Bearer path is unchanged). Requires a tenanted authority and an ESTS allow-listed resource; region is optional (global `mtlsauth.microsoft.com` when absent). The result exposes `metadata().tokenType()` and `metadata().bindingCertificate()` (public material only — x5c chain + `x5t#S256`). - **Key Classes**: `TokenType`, `BindingCertificate`, `MtlsClientCertificateHelper`, `MtlsEndpointHelper`; internal `AuthScheme`. Not supported: US Gov / China clouds, and user-scoped (`user_fic`) FIC over mTLS. +- **mTLS Proof-of-Possession (PoP)**: Opt in with `ClientCredentialParameters.builder(...).mtlsProofOfPossession()` to obtain an mTLS-bound PoP token (`token_type=mtls_pop`). The app's SN/I `IClientCertificate` is presented as the client TLS certificate to a rewritten `mtlsauth.*` endpoint (no `client_assertion` on the direct path) instead of signing an x5c assertion (the existing SNI+Bearer path is unchanged). Requires a tenanted authority and an ESTS allow-listed resource; region is optional (global `mtlsauth.microsoft.com` when absent). The result exposes `metadata().tokenType()` and `metadata().bindingCertificate()` (public material only — x5c chain + `x5t#S256`). + - **Key Classes**: `TokenType`, `BindingCertificate`, `MtlsClientCertificateHelper`, `MtlsEndpointHelper`; internal `AuthScheme`. Not supported: US Gov / China clouds, and user-scoped (`user_fic`) FIC over mTLS. **On-Behalf-Of (OBO)** - **Public API**: `acquireToken(OnBehalfOfParameters)` diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ClientCredentialParameters.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ClientCredentialParameters.java index 03eba7917..31adfd4b0 100644 --- a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ClientCredentialParameters.java +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ClientCredentialParameters.java @@ -37,10 +37,11 @@ public class ClientCredentialParameters implements IAcquireTokenParameters { // Generic extended cache key components. Any optional or flow-specific parameters // that should influence token cache isolation adds an entry here. The hash of these // components is used as part of the cache key in relevant scenarios entries. - private SortedMap cacheKeyComponents; + private volatile SortedMap cacheKeyComponents; - // Memoized hash of cacheKeyComponents (computed once since parameters are immutable). - private String extCacheKeyHashCache; + // Lazily memoized hash of cacheKeyComponents. Invalidated (set to null) whenever the + // components change (e.g. bindingCertificateKeyId adds the cert KeyId) so it is recomputed. + private volatile String extCacheKeyHashCache; private ClientCredentialParameters(Set scopes, Boolean skipCache, ClaimsRequest claims, Map extraHttpHeaders, Map extraQueryParameters, String tenant, IClientCredential clientCredential, String fmiPath, boolean mtlsProofOfPossession) { this.scopes = scopes; @@ -133,17 +134,23 @@ public boolean mtlsProofOfPossession() { * token is cache-isolated by certificate, in addition to the {@code token_type} dimension. *

* Called once (before the silent cache lookup) so both cache reads and writes observe the same - * components. Clears the memoized hash so it is recomputed with the added component. + * components. This params instance may be reused across concurrent acquireToken calls, so the + * update is copy-on-write and synchronized, and clears the memoized hash so it is recomputed. */ void bindingCertificateKeyId(String keyId) { if (StringHelper.isBlank(keyId)) { return; } - if (this.cacheKeyComponents == null) { - this.cacheKeyComponents = new TreeMap<>(); + synchronized (this) { + // Copy-on-write: never mutate a map that another thread may be reading. Swap in a fresh + // map and invalidate the memoized hash so it is recomputed with the added component. + TreeMap updated = this.cacheKeyComponents == null + ? new TreeMap<>() + : new TreeMap<>(this.cacheKeyComponents); + updated.put("cert_kid", keyId); + this.cacheKeyComponents = updated; + this.extCacheKeyHashCache = null; } - this.cacheKeyComponents.put("cert_kid", keyId); - this.extCacheKeyHashCache = null; } /** @@ -180,15 +187,18 @@ SortedMap cacheKeyComponents() { * Computes the extended cache key hash from all cache key components. * Returns an empty string if no components are present. *

- * The result is memoized since ClientCredentialParameters is immutable after construction. + * The result is lazily memoized and invalidated whenever the cache key components change + * (see {@link #bindingCertificateKeyId(String)}). * Used by both cache writes ({@link TokenCache}) and cache reads (silent lookup). */ String computeExtCacheKeyHash() { - if (extCacheKeyHashCache != null) { - return extCacheKeyHashCache; + String cached = extCacheKeyHashCache; + if (cached != null) { + return cached; } - extCacheKeyHashCache = StringHelper.computeExtCacheKeyHash(cacheKeyComponents); - return extCacheKeyHashCache; + String computed = StringHelper.computeExtCacheKeyHash(cacheKeyComponents); + extCacheKeyHashCache = computed; + return computed; } public static class ClientCredentialParametersBuilder { diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/MtlsClientCertificateHelper.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/MtlsClientCertificateHelper.java index cb6357c6d..fc57ce67d 100644 --- a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/MtlsClientCertificateHelper.java +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/MtlsClientCertificateHelper.java @@ -12,6 +12,7 @@ import java.security.KeyStore; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; import java.security.cert.Certificate; import java.security.cert.CertificateEncodingException; import java.security.cert.CertificateException; @@ -20,6 +21,7 @@ import java.util.ArrayList; import java.util.Base64; import java.util.List; +import java.util.UUID; /** * Builds the TLS material needed to present an {@link IClientCertificate} as the client certificate in a @@ -78,10 +80,21 @@ static SSLSocketFactory createMtlsSocketFactory(IClientCertificate certificate) AuthenticationErrorCode.MTLS_POP_ERROR); } - char[] password = new char[0]; + PrivateKey privateKey = certificate.privateKey(); + if (privateKey == null) { + throw new MsalClientException( + "mTLS Proof-of-Possession requires a certificate with an accessible private key.", + AuthenticationErrorCode.MTLS_POP_ERROR); + } + + // The key store is in-memory and transient (never persisted), so the password value is + // irrelevant to security. It must, however, be non-empty: on some JDKs (notably 8) a + // zero-length password makes PKCS12 setKeyEntry fail with + // "Key protection algorithm not found: java.lang.NullPointerException". + char[] password = UUID.randomUUID().toString().toCharArray(); KeyStore keyStore = KeyStore.getInstance("PKCS12"); keyStore.load(null, null); - keyStore.setKeyEntry(KEY_ENTRY_ALIAS, certificate.privateKey(), password, chain.toArray(new Certificate[0])); + keyStore.setKeyEntry(KEY_ENTRY_ALIAS, privateKey, password, chain.toArray(new Certificate[0])); KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); From e6dee5f310f9d665d0c7d9981f4585dbec9f87ef Mon Sep 17 00:00:00 2001 From: Robbie Ginsburg Date: Tue, 7 Jul 2026 18:39:12 -0400 Subject: [PATCH 05/11] Build mTLS PoP socket factory from a live key manager (support non-exportable keys) createMtlsSocketFactory imported the certificate's private key into an in-memory PKCS12 KeyStore via setKeyEntry. That serializes the key (PrivateKey.getEncoded()), which returns null for non-exportable OS/HSM key handles. On the Windows CI agent the lab cert is loaded from Windows-MY via SunMSCAPI, so all three MtlsPopIT tests failed with 'Key protection algorithm not found: java.lang.NullPointerException'. The earlier password change was a misdiagnosis; the password is irrelevant to this failure. Present the certificate through a custom X509ExtendedKeyManager that holds the live PrivateKey and chain, so the TLS handshake signs through the key's own provider (as JWT client-assertion signing already does). Works for both non-exportable and ordinary exportable keys. Add a regression unit test using a private key whose getEncoded()/getFormat() return null. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../msal4j/MtlsClientCertificateHelper.java | 98 +++++++++++++++---- .../MtlsClientCertificateHelperTest.java | 53 ++++++++++ 2 files changed, 131 insertions(+), 20 deletions(-) diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/MtlsClientCertificateHelper.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/MtlsClientCertificateHelper.java index fc57ce67d..f6d2a1b73 100644 --- a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/MtlsClientCertificateHelper.java +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/MtlsClientCertificateHelper.java @@ -3,17 +3,18 @@ package com.microsoft.aad.msal4j; -import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.KeyManager; import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLEngine; import javax.net.ssl.SSLSocketFactory; +import javax.net.ssl.X509ExtendedKeyManager; import java.io.ByteArrayInputStream; -import java.io.IOException; +import java.net.Socket; import java.security.GeneralSecurityException; -import java.security.KeyStore; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; +import java.security.Principal; import java.security.PrivateKey; -import java.security.cert.Certificate; import java.security.cert.CertificateEncodingException; import java.security.cert.CertificateException; import java.security.cert.CertificateFactory; @@ -21,7 +22,6 @@ import java.util.ArrayList; import java.util.Base64; import java.util.List; -import java.util.UUID; /** * Builds the TLS material needed to present an {@link IClientCertificate} as the client certificate in a @@ -29,7 +29,8 @@ * {@link BindingCertificate} on the result. * *

The source certificate is resolved from the request/app credential (direct SN/I cert or FIC Leg 1). - * Only public material is ever surfaced; the private key stays inside the in-memory key store. + * Only public material is ever surfaced; the private key is used in place (through its own provider) + * and is never exported or copied into a key store. */ final class MtlsClientCertificateHelper { @@ -87,26 +88,23 @@ static SSLSocketFactory createMtlsSocketFactory(IClientCertificate certificate) AuthenticationErrorCode.MTLS_POP_ERROR); } - // The key store is in-memory and transient (never persisted), so the password value is - // irrelevant to security. It must, however, be non-empty: on some JDKs (notably 8) a - // zero-length password makes PKCS12 setKeyEntry fail with - // "Key protection algorithm not found: java.lang.NullPointerException". - char[] password = UUID.randomUUID().toString().toCharArray(); - KeyStore keyStore = KeyStore.getInstance("PKCS12"); - keyStore.load(null, null); - keyStore.setKeyEntry(KEY_ENTRY_ALIAS, privateKey, password, chain.toArray(new Certificate[0])); - - KeyManagerFactory keyManagerFactory = - KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); - keyManagerFactory.init(keyStore, password); + // Present the certificate to the TLS layer through a KeyManager that holds the live private + // key object directly, instead of importing it into a PKCS12 KeyStore. The key may be a + // non-exportable handle (e.g. a Windows-MY / SunMSCAPI or HSM key) whose getEncoded() + // returns null and which therefore cannot be added to a KeyStore; the TLS handshake can + // still sign through the key's own provider. Ordinary exportable keys use the same path. + KeyManager[] keyManagers = { + new SingleCertificateKeyManager(KEY_ENTRY_ALIAS, privateKey, + chain.toArray(new X509Certificate[0])) + }; SSLContext sslContext = SSLContext.getInstance("TLS"); - sslContext.init(keyManagerFactory.getKeyManagers(), null, null); + sslContext.init(keyManagers, null, null); return sslContext.getSocketFactory(); } catch (MsalClientException e) { throw e; - } catch (GeneralSecurityException | IOException e) { + } catch (GeneralSecurityException e) { throw new MsalClientException( "Failed to build the mTLS client certificate socket factory: " + e.getMessage(), AuthenticationErrorCode.MTLS_POP_ERROR); @@ -180,4 +178,64 @@ private static List decodeCertificateChain(IClientCertificate c } return chain; } + + /** + * An {@link X509ExtendedKeyManager} that always presents a single, pre-resolved client certificate + * and its private key during a TLS handshake. It holds the live {@link PrivateKey} reference rather + * than importing the key into a {@link java.security.KeyStore}, so it works with non-exportable keys + * (e.g. Windows-MY / SunMSCAPI or HSM-backed keys) whose {@code getEncoded()} returns {@code null}. + * Signing is delegated to the key's own provider by the SSL engine. + */ + private static final class SingleCertificateKeyManager extends X509ExtendedKeyManager { + + private final String alias; + private final PrivateKey privateKey; + private final X509Certificate[] chain; + + SingleCertificateKeyManager(String alias, PrivateKey privateKey, X509Certificate[] chain) { + this.alias = alias; + this.privateKey = privateKey; + this.chain = chain; + } + + @Override + public String[] getClientAliases(String keyType, Principal[] issuers) { + return new String[]{alias}; + } + + @Override + public String chooseClientAlias(String[] keyType, Principal[] issuers, Socket socket) { + return alias; + } + + @Override + public String chooseEngineClientAlias(String[] keyType, Principal[] issuers, SSLEngine engine) { + return alias; + } + + @Override + public String[] getServerAliases(String keyType, Principal[] issuers) { + return null; + } + + @Override + public String chooseServerAlias(String keyType, Principal[] issuers, Socket socket) { + return null; + } + + @Override + public String chooseEngineServerAlias(String keyType, Principal[] issuers, SSLEngine engine) { + return null; + } + + @Override + public X509Certificate[] getCertificateChain(String alias) { + return chain.clone(); + } + + @Override + public PrivateKey getPrivateKey(String alias) { + return privateKey; + } + } } diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/MtlsClientCertificateHelperTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/MtlsClientCertificateHelperTest.java index a96e65e88..0ab3c09e4 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/MtlsClientCertificateHelperTest.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/MtlsClientCertificateHelperTest.java @@ -10,6 +10,9 @@ import javax.net.ssl.SSLSocketFactory; import java.io.InputStream; import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.cert.CertificateEncodingException; import java.security.cert.X509Certificate; import java.util.Base64; import java.util.List; @@ -50,6 +53,56 @@ void createMtlsSocketFactory_nullCertificate_throwsMtlsPopError() { assertEquals(AuthenticationErrorCode.MTLS_POP_ERROR, ex.errorCode()); } + // Regression for the mTLS PoP integration tests on CI (Windows + JDK 8): the lab certificate is + // loaded from the Windows-MY store via SunMSCAPI, so its private key is a non-exportable handle + // whose getEncoded()/getFormat() return null. Importing such a key into a PKCS12 KeyStore fails + // ("Key protection algorithm not found: java.lang.NullPointerException"). Building the socket + // factory must instead hold the live key and delegate signing to its provider, so it succeeds. + @Test + void createMtlsSocketFactory_nonExportablePrivateKey_buildsFactory() { + IClientCertificate nonExportable = withNonExportableKey(certificate); + SSLSocketFactory factory = MtlsClientCertificateHelper.createMtlsSocketFactory(nonExportable); + assertNotNull(factory); + } + + // Wraps a certificate so its private key mimics a non-exportable OS/HSM key handle: the public + // chain still resolves, but getEncoded()/getFormat() return null (as SunMSCAPI keys do). + private static IClientCertificate withNonExportableKey(IClientCertificate delegate) { + PrivateKey nonExportableKey = new PrivateKey() { + @Override + public String getAlgorithm() { + return delegate.privateKey().getAlgorithm(); + } + + @Override + public String getFormat() { + return null; + } + + @Override + public byte[] getEncoded() { + return null; + } + }; + + return new IClientCertificate() { + @Override + public PrivateKey privateKey() { + return nonExportableKey; + } + + @Override + public String publicCertificateHash() throws CertificateEncodingException, NoSuchAlgorithmException { + return delegate.publicCertificateHash(); + } + + @Override + public List getEncodedPublicKeyCertificateChain() throws CertificateEncodingException { + return delegate.getEncodedPublicKeyCertificateChain(); + } + }; + } + @Test void computeThumbprintSha256_matchesBase64UrlSha256OfLeafDer() throws Exception { String encodedLeaf = certificate.getEncodedPublicKeyCertificateChain().get(0); From 288df022e38f8f2f97a274fb92df2981c8068667 Mon Sep 17 00:00:00 2001 From: Robbie Ginsburg Date: Tue, 7 Jul 2026 19:05:21 -0400 Subject: [PATCH 06/11] Point MtlsPopIT at the SN/I-allow-listed app for mTLS PoP ESTS only issues mTLS PoP tokens for the SN/I-allow-listed app in the MSI team tenant. MtlsPopIT authenticated as LabVaultAppID against microsoft.onmicrosoft.com, which is valid for Bearer client-credentials but not allow-listed for mTLS PoP, so ESTS rejected the PoP requests with AADSTS700025 (Client is public). Mirror MSAL .NET's ClientCredentialsMtlsPopTests: use the SN/I-allow-listed app (163ffef9-...), the MSI team tenant authority (bea21ebe-...), and region westus3. App/tenant IDs are public identifiers. The lab cert and Key Vault scope are unchanged. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../com/microsoft/aad/msal4j/MtlsPopIT.java | 36 ++++++++++--------- 1 file changed, 19 insertions(+), 17 deletions(-) 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 80d783346..66ecdb9fe 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 @@ -3,7 +3,6 @@ package com.microsoft.aad.msal4j; -import com.microsoft.aad.msal4j.labapi.KeyVaultRegistry; import com.microsoft.aad.msal4j.labapi.KeyVaultSecretsProvider; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; @@ -49,10 +48,22 @@ * OS keystore alias {@link KeyVaultSecretsProvider#CERTIFICATE_ALIAS}). They require lab credentials and * network access and only pass in CI (like the other {@code *IT} tests, they are not run by the unit-test * surefire pass). + * + *

App/tenant requirement: ESTS only issues mTLS PoP tokens for the SN/I-allow-listed app in the + * MSI team tenant. This mirrors MSAL .NET's {@code ClientCredentialsMtlsPopTests}: any other app (e.g. the + * general {@code LabVaultAppID}) is rejected with {@code AADSTS700025: Client is public...}. The lab cert + * ({@link KeyVaultSecretsProvider#CERTIFICATE_ALIAS}) is registered for SN/I on this app. */ @TestInstance(TestInstance.Lifecycle.PER_CLASS) class MtlsPopIT { + // SN/I-allow-listed app and MSI team tenant. mTLS PoP only works on this app/tenant pair; mirrors + // MSAL .NET's ClientCredentialsMtlsPopTests. App and tenant IDs are public identifiers, not secrets. + private static final String SNI_ALLOWLISTED_APP_ID = "163ffef9-a313-45b4-ab2f-c7e2f5e0e23e"; + private static final String SNI_ALLOWLISTED_AUTHORITY = + "https://login.microsoftonline.com/bea21ebe-8b64-4d06-9f6d-6a889b120a7c"; + private static final String TEST_SLICE_REGION = "westus3"; + private PrivateKey privateKey; private X509Certificate publicCertificate; private IClientCertificate certificate; @@ -80,11 +91,8 @@ void init() throws KeyStoreException, NoSuchProviderException, IOException, */ @Test void acquireTokenClientCredentials_Certificate_MtlsPop() throws Exception { - final String clientId = KeyVaultRegistry.getMsidLabProvider() - .getSecretByName("LabVaultAppID").getValue(); - - ConfidentialClientApplication cca = ConfidentialClientApplication.builder(clientId, certificate) - .authority(TestConstants.MICROSOFT_AUTHORITY) // tenanted authority (required for mTLS PoP) + ConfidentialClientApplication cca = ConfidentialClientApplication.builder(SNI_ALLOWLISTED_APP_ID, certificate) + .authority(SNI_ALLOWLISTED_AUTHORITY) // tenanted authority (required for mTLS PoP) .build(); IAuthenticationResult result = cca.acquireToken(ClientCredentialParameters @@ -103,12 +111,9 @@ void acquireTokenClientCredentials_Certificate_MtlsPop() throws Exception { */ @Test void acquireTokenClientCredentials_Certificate_MtlsPop_Regional() throws Exception { - final String clientId = KeyVaultRegistry.getMsidLabProvider() - .getSecretByName("LabVaultAppID").getValue(); - - ConfidentialClientApplication cca = ConfidentialClientApplication.builder(clientId, certificate) - .authority(TestConstants.MICROSOFT_AUTHORITY) - .azureRegion("westus") + ConfidentialClientApplication cca = ConfidentialClientApplication.builder(SNI_ALLOWLISTED_APP_ID, certificate) + .authority(SNI_ALLOWLISTED_AUTHORITY) + .azureRegion(TEST_SLICE_REGION) .build(); IAuthenticationResult result = cca.acquireToken(ClientCredentialParameters @@ -137,11 +142,8 @@ void acquireTokenClientCredentials_Certificate_MtlsPop_Regional() throws Excepti */ @Test void acquireTokenClientCredentials_BearerAndMtlsPop_AreCacheIsolated() throws Exception { - final String clientId = KeyVaultRegistry.getMsidLabProvider() - .getSecretByName("LabVaultAppID").getValue(); - - ConfidentialClientApplication cca = ConfidentialClientApplication.builder(clientId, certificate) - .authority(TestConstants.MICROSOFT_AUTHORITY) + ConfidentialClientApplication cca = ConfidentialClientApplication.builder(SNI_ALLOWLISTED_APP_ID, certificate) + .authority(SNI_ALLOWLISTED_AUTHORITY) .build(); // Existing SNI + Bearer path (unchanged). From 6bf6024fc72c20164a9fd7431c68ca1d4b272857 Mon Sep 17 00:00:00 2001 From: Robbie Ginsburg Date: Wed, 8 Jul 2026 14:21:02 -0400 Subject: [PATCH 07/11] Fail closed on mTLS PoP token_type downgrade Validate the identity provider's response token_type instead of assuming MTLS_POP from the request. When an mTLS Proof-of-Possession token is requested but ESTS returns a different token_type (e.g. a Bearer downgrade), the access token is not certificate-bound; MSAL now throws MsalClientException(token_type_mismatch) rather than mislabeling an unbound token as MTLS_POP. The binding certificate is only surfaced after the token type is validated, and the result token type is derived from the validated response so Bearer flows keep reporting Bearer. Adds AuthenticationErrorCode.TOKEN_TYPE_MISMATCH, a fail-closed unit test, a token_type-overridable test mock, and a skip-on-downgrade accommodation in MtlsPopIT (mirrors MSAL .NET's ExecuteOrInconclusiveOnTokenTypeMismatchAsync since the AAD test slice is a known intermittent downgrader). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- changelog.txt | 2 +- .../com/microsoft/aad/msal4j/MtlsPopIT.java | 41 +++++++++++++------ .../aad/msal4j/AuthenticationErrorCode.java | 8 ++++ .../aad/msal4j/TokenRequestExecutor.java | 16 +++++++- .../aad/msal4j/MtlsProofOfPossessionTest.java | 33 ++++++++++++++- .../com/microsoft/aad/msal4j/TestHelper.java | 5 ++- 6 files changed, 86 insertions(+), 19 deletions(-) diff --git a/changelog.txt b/changelog.txt index f74875619..c425dc0ba 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,6 +1,6 @@ Version 1.25.0 ============= -- Add SN/I certificate support over mTLS Proof-of-Possession (PoP) for confidential clients, presenting the certificate as the client TLS cert to obtain an mTLS-bound token +- Add SN/I certificate support over mTLS Proof-of-Possession (PoP) for confidential clients, presenting the certificate as the client TLS cert to obtain an mTLS-bound token. The response token_type is validated, so a request that is not honored as mtls_pop fails closed rather than surfacing an unbound token - Add Federated Managed Identity (FMI) support for client credentials flow (#1025) - Add User Federated Identity Credential (user_fic) grant type support (#1026) - Add MSAL client metadata headers to IMDS managed identity requests (#1024) 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 66ecdb9fe..31e3a5fa2 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 @@ -4,6 +4,7 @@ package com.microsoft.aad.msal4j; import com.microsoft.aad.msal4j.labapi.KeyVaultSecretsProvider; +import org.junit.jupiter.api.Assumptions; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.TestInstance; @@ -18,6 +19,7 @@ import java.security.cert.CertificateException; import java.security.cert.X509Certificate; import java.util.Collections; +import java.util.concurrent.ExecutionException; import static com.microsoft.aad.msal4j.TestConstants.KEYVAULT_DEFAULT_SCOPE; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -95,11 +97,10 @@ void acquireTokenClientCredentials_Certificate_MtlsPop() throws Exception { .authority(SNI_ALLOWLISTED_AUTHORITY) // tenanted authority (required for mTLS PoP) .build(); - IAuthenticationResult result = cca.acquireToken(ClientCredentialParameters + IAuthenticationResult result = acquireMtlsPopOrSkipOnDowngrade(cca, ClientCredentialParameters .builder(Collections.singleton(KEYVAULT_DEFAULT_SCOPE)) .mtlsProofOfPossession() - .build()) - .get(); + .build()); assertMtlsPopResult(result, expectedLabThumbprint()); } @@ -116,20 +117,18 @@ void acquireTokenClientCredentials_Certificate_MtlsPop_Regional() throws Excepti .azureRegion(TEST_SLICE_REGION) .build(); - IAuthenticationResult result = cca.acquireToken(ClientCredentialParameters + IAuthenticationResult result = acquireMtlsPopOrSkipOnDowngrade(cca, ClientCredentialParameters .builder(Collections.singleton(KEYVAULT_DEFAULT_SCOPE)) .mtlsProofOfPossession() - .build()) - .get(); + .build()); assertMtlsPopResult(result, expectedLabThumbprint()); // The mTLS-PoP token must be cached under {token_type + cert KeyId} and returned on lookup. - IAuthenticationResult cached = cca.acquireToken(ClientCredentialParameters + IAuthenticationResult cached = acquireMtlsPopOrSkipOnDowngrade(cca, ClientCredentialParameters .builder(Collections.singleton(KEYVAULT_DEFAULT_SCOPE)) .mtlsProofOfPossession() - .build()) - .get(); + .build()); assertEquals(result.accessToken(), cached.accessToken(), "Second mTLS-PoP request should return the cached bound token"); @@ -154,11 +153,10 @@ void acquireTokenClientCredentials_BearerAndMtlsPop_AreCacheIsolated() throws Ex assertEquals(TokenType.BEARER, bearer.metadata().tokenType()); // New SNI + mTLS PoP path. - IAuthenticationResult pop = cca.acquireToken(ClientCredentialParameters + IAuthenticationResult pop = acquireMtlsPopOrSkipOnDowngrade(cca, ClientCredentialParameters .builder(Collections.singleton(KEYVAULT_DEFAULT_SCOPE)) .mtlsProofOfPossession() - .build()) - .get(); + .build()); assertEquals(TokenType.MTLS_POP, pop.metadata().tokenType()); assertNotEquals(bearer.accessToken(), pop.accessToken(), @@ -167,6 +165,25 @@ void acquireTokenClientCredentials_BearerAndMtlsPop_AreCacheIsolated() throws Ex "Bearer and mTLS-PoP tokens must occupy separate cache entries"); } + // ESTS's mTLS PoP test slice is a known intermittent token_type downgrader. When it returns a + // non-mtls_pop token the access token is not certificate-bound, and MSAL now fails closed with + // TOKEN_TYPE_MISMATCH. Treat that specific outcome as inconclusive (skip) rather than a hard failure, + // mirroring MSAL .NET's ExecuteOrInconclusiveOnTokenTypeMismatchAsync. + private static IAuthenticationResult acquireMtlsPopOrSkipOnDowngrade( + ConfidentialClientApplication cca, ClientCredentialParameters parameters) throws Exception { + try { + return cca.acquireToken(parameters).get(); + } catch (ExecutionException e) { + if (e.getCause() instanceof MsalClientException + && AuthenticationErrorCode.TOKEN_TYPE_MISMATCH.equals( + ((MsalClientException) e.getCause()).errorCode())) { + Assumptions.abort("ESTS returned a non-mtls_pop token_type (downgrade); treating as " + + "inconclusive: " + e.getCause().getMessage()); + } + throw e; + } + } + 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 d21335eb8..9979c3ef8 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 @@ -168,6 +168,14 @@ public class AuthenticationErrorCode { */ public static final String MTLS_POP_ERROR = "mtls_pop_error"; + /** + * Indicates that an mTLS Proof-of-Possession token was requested, but the identity provider returned + * a token with a different {@code token_type} (for example, a Bearer downgrade). The returned access + * token is not certificate-bound, so MSAL fails closed rather than surfacing an unbound token as mTLS + * PoP. For more details, see https://aka.ms/msal4j-pop + */ + public static final String TOKEN_TYPE_MISMATCH = "token_type_mismatch"; + /** * 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/TokenRequestExecutor.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/TokenRequestExecutor.java index 7f9e3b2c2..c1778aa05 100644 --- a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/TokenRequestExecutor.java +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/TokenRequestExecutor.java @@ -267,10 +267,22 @@ private AuthenticationResult createAuthenticationResultFromOauthHttpResponse(Htt } long currTimestampSec = new Date().getTime() / 1000; - TokenType tokenType = TokenType.BEARER; + // The token type is taken from the identity provider's response, never assumed from the request. + TokenType tokenType = TokenType.fromString(response.tokenType()); BindingCertificate bindingCertificate = null; if (isMtlsProofOfPossession()) { - tokenType = TokenType.MTLS_POP; + // Fail closed: an mTLS Proof-of-Possession request MUST come back as an mtls_pop + // (certificate-bound) token. If ESTS returns a different token_type (for example a Bearer + // downgrade), the access token is not bound to the certificate; surfacing it as MTLS_POP + // would mask the downgrade, so reject it instead. + if (tokenType != TokenType.MTLS_POP) { + throw new MsalClientException( + String.format("An mTLS Proof-of-Possession token was requested, but the identity " + + "provider returned token_type '%s' instead of 'mtls_pop'. The access token is " + + "not certificate-bound.", response.tokenType()), + AuthenticationErrorCode.TOKEN_TYPE_MISMATCH); + } + // Surface the binding certificate only after the token type has been validated. bindingCertificate = MtlsClientCertificateHelper.buildBindingCertificate(resolvedBindingCertificate); } diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/MtlsProofOfPossessionTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/MtlsProofOfPossessionTest.java index 95c9225a8..e8e641d83 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/MtlsProofOfPossessionTest.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/MtlsProofOfPossessionTest.java @@ -14,13 +14,16 @@ import java.util.HashMap; import java.util.List; 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.assertNotEquals; 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; @@ -90,8 +93,13 @@ private ConfidentialClientApplication.Builder baseCertAppBuilder() throws Except } private static HttpResponse successResponse(String accessToken) { + return successResponse(accessToken, "Bearer"); + } + + 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)); } @@ -102,7 +110,7 @@ void directSniCert_mtlsPop_targetsMtlsEndpoint_omitsClientAssertion_returnsMtlsP HttpRequest captured; IAuthenticationResult result; try (MockedConstruction mocked = mockConstruction(DefaultHttpClient.class, - (m, ctx) -> when(m.send(any(HttpRequest.class))).thenReturn(successResponse("mtls-pop-token")))) { + (m, ctx) -> when(m.send(any(HttpRequest.class))).thenReturn(successResponse("mtls-pop-token", "mtls_pop")))) { result = app.acquireToken(ClientCredentialParameters.builder(Collections.singleton("https://graph.microsoft.com/.default")) .mtlsProofOfPossession() @@ -132,6 +140,27 @@ void directSniCert_mtlsPop_targetsMtlsEndpoint_omitsClientAssertion_returnsMtlsP result.metadata().bindingCertificate().thumbprintSha256()); } + @Test + void mtlsPop_serverDowngradesToBearer_failsClosed() throws Exception { + ConfidentialClientApplication app = baseCertAppBuilder().build(); + + // ESTS honored the request path but returned a Bearer token_type instead of mtls_pop, so the + // access token is not certificate-bound. MSAL must fail closed rather than mislabel it as MTLS_POP. + try (MockedConstruction mocked = mockConstruction(DefaultHttpClient.class, + (m, ctx) -> when(m.send(any(HttpRequest.class))).thenReturn(successResponse("downgraded-token", "Bearer")))) { + + ExecutionException ex = assertThrows(ExecutionException.class, () -> + app.acquireToken(ClientCredentialParameters.builder(Collections.singleton("https://graph.microsoft.com/.default")) + .mtlsProofOfPossession() + .skipCache(true) + .build()).get()); + + assertInstanceOf(MsalClientException.class, ex.getCause()); + assertEquals(AuthenticationErrorCode.TOKEN_TYPE_MISMATCH, + ((MsalClientException) ex.getCause()).errorCode()); + } + } + @Test void bearerCert_backwardCompatible_usesLoginEndpointAndJwtBearerAssertion() throws Exception { IHttpClient appHttpClient = mock(IHttpClient.class); @@ -191,7 +220,7 @@ void mtlsPop_composesWithFmiPath_forFicLeg1() throws Exception { HttpRequest captured; try (MockedConstruction mocked = mockConstruction(DefaultHttpClient.class, - (m, ctx) -> when(m.send(any(HttpRequest.class))).thenReturn(successResponse("fic-leg1-token")))) { + (m, ctx) -> when(m.send(any(HttpRequest.class))).thenReturn(successResponse("fic-leg1-token", "mtls_pop")))) { app.acquireToken(ClientCredentialParameters.builder(Collections.singleton("api://AzureADTokenExchange/.default")) .fmiPath("SomeFmiPath/CredentialPath") diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/TestHelper.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/TestHelper.java index 4112dcebd..cadad4ddf 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/TestHelper.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/TestHelper.java @@ -61,7 +61,7 @@ class TestHelper { private static final String successfulResponseFormat = "{\"access_token\":\"%s\",\"id_token\":\"%s\",\"refresh_token\":\"%s\"," + "\"client_id\":\"%s\",\"client_info\":\"%s\"," + "\"refresh_in\": %d,\"expires_on\": %d,\"expires_in\": %d," + - "\"token_type\":\"Bearer\"}"; + "\"token_type\":\"%s\"}"; static final String idTokenFormat = "{\"aud\": \"%s\"," + "\"iss\": \"%s\"," + @@ -167,7 +167,8 @@ static String getSuccessfulTokenResponse(HashMap responseValues) responseValues.getOrDefault("client_info", "eyJ1aWQiOiI1OTdmODZjZC0xM2YzLTQ0YzAtYmVjZS1hMWU3N2JhNDMyMjgiLCJ1dGlkIjoiZjY0NWFkOTItZTM4ZC00ZDFhLWI1MTAtZDFiMDlhNzRhOGNhIn0"), refreshIn, expiresOn, - expiresIn + expiresIn, + responseValues.getOrDefault("token_type", "Bearer") ); } From f848b5486904cc410a2a65ae551493e8a734664c Mon Sep 17 00:00:00 2001 From: Robbie Ginsburg Date: Wed, 8 Jul 2026 14:34:38 -0400 Subject: [PATCH 08/11] Remove unused telemetryValue from TokenType The per-constant telemetry value was never wired into telemetry emission, so drop the field, constructor parameter, and accessor. It can be reintroduced if/when token_type telemetry is actually plumbed through. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../com/microsoft/aad/msal4j/TokenType.java | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/TokenType.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/TokenType.java index 4a9c571e0..b2aead1d6 100644 --- a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/TokenType.java +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/TokenType.java @@ -18,20 +18,18 @@ public enum TokenType { /** * A standard Bearer access token. This is the default token type. */ - BEARER("Bearer", 2), + BEARER("Bearer"), /** * A mutual-TLS Proof-of-Possession access token, cryptographically bound to the client certificate * presented on the TLS handshake to the token endpoint (bound via {@code cnf}/{@code x5t#S256}). */ - MTLS_POP("mtls_pop", 6); + MTLS_POP("mtls_pop"); private final String value; - private final int telemetryValue; - TokenType(String value, int telemetryValue) { + TokenType(String value) { this.value = value; - this.telemetryValue = telemetryValue; } /** @@ -41,15 +39,6 @@ public String value() { return value; } - /** - * @return the numeric telemetry value for this token type. This value is kept in parity with the - * other MSAL SDKs (for example MSAL.NET's {@code TelemetryTokenTypeConstants}, where mTLS PoP is - * {@code 6}) so that ESTS/telemetry dashboards attribute the request consistently across SDKs. - */ - int telemetryValue() { - return telemetryValue; - } - /** * Maps a token_type string (as returned in a token response) to a {@link TokenType}. * Unknown or null values map to {@link #BEARER}. From 3ff929c3ed474393c6ee8466a12c55981240cf0e Mon Sep 17 00:00:00 2001 From: Robbie Ginsburg Date: Wed, 8 Jul 2026 17:50:29 -0400 Subject: [PATCH 09/11] Align mTLS PoP with MSAL.NET: sovereign-cloud guard, cache metadata, exact token_type Fix three divergences from the MSAL.NET reference in the direct SN/I -> mTLS Proof-of-Possession path: - MtlsEndpointHelper: enforce cloud boundaries so sovereign login.* hosts fail fast (via the authoritative TRUSTED_SOVEREIGN_HOSTS_SET, including regional forms) instead of being rewritten to the public mtlsauth.microsoft.com and having the client certificate presented cross-cloud. Other login.* hosts are rewritten domain-preserving (login -> mtlsauth) and non-login hosts are rejected. - AcquireTokenByClientCredentialSupplier: restore tokenType(MTLS_POP) and the binding certificate on cache hits, which previously defaulted to BEARER/null because only the network path stamped them. - TokenType.fromString: map only the exact (case-insensitive) mtls_pop token_type to MTLS_POP, so a non-certificate-bound "pop" response is correctly rejected by the mTLS PoP fail-closed check instead of masquerading as MTLS_POP. Adds regression tests for all three (378 msal4j-sdk unit tests green). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- ...cquireTokenByClientCredentialSupplier.java | 32 ++++++- .../aad/msal4j/MtlsEndpointHelper.java | 88 ++++++++++++++++--- .../com/microsoft/aad/msal4j/TokenType.java | 7 +- .../aad/msal4j/MtlsEndpointHelperTest.java | 75 ++++++++++++++++ .../aad/msal4j/MtlsProofOfPossessionTest.java | 56 ++++++++++++ 5 files changed, 241 insertions(+), 17 deletions(-) diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AcquireTokenByClientCredentialSupplier.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AcquireTokenByClientCredentialSupplier.java index f4d12e325..d6758c0a9 100644 --- a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AcquireTokenByClientCredentialSupplier.java +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AcquireTokenByClientCredentialSupplier.java @@ -22,11 +22,12 @@ AuthenticationResult execute() throws Exception { // For mTLS Proof-of-Possession, isolate the access token in the cache by the binding certificate's // KeyId (x5t#S256) in addition to the token_type dimension, so PoP tokens bound to different // certificates never alias. Stamped before the cache lookup so reads and writes hash identically. + IClientCertificate resolvedBindingCertificate = null; if (clientCredentialRequest.parameters.mtlsProofOfPossession()) { - IClientCertificate bindingCertificate = MtlsClientCertificateHelper.resolveBindingCertificate( + resolvedBindingCertificate = MtlsClientCertificateHelper.resolveBindingCertificate( (ConfidentialClientApplication) this.clientApplication, clientCredentialRequest.parameters); clientCredentialRequest.parameters.bindingCertificateKeyId( - MtlsClientCertificateHelper.computeCertificateKeyId(bindingCertificate)); + MtlsClientCertificateHelper.computeCertificateKeyId(resolvedBindingCertificate)); } if (clientCredentialRequest.parameters.skipCache() != null && @@ -60,7 +61,9 @@ AuthenticationResult execute() throws Exception { this.clientApplication, silentRequest); - return supplier.execute(); + AuthenticationResult cachedResult = supplier.execute(); + restoreMtlsProofOfPossessionMetadata(cachedResult, resolvedBindingCertificate); + return cachedResult; } catch (MsalClientException ex) { LOG.debug("Cache lookup failed: {}", ex.getMessage()); return acquireTokenByClientCredential(); @@ -71,6 +74,29 @@ AuthenticationResult execute() throws Exception { return acquireTokenByClientCredential(); } + /** + * Restores the mTLS Proof-of-Possession metadata (token type and binding certificate) on a result + * served from the cache. The network path stamps these in {@link TokenRequestExecutor}, but the + * silent/cache read path does not, so without this a cached PoP token would report {@code BEARER} + * and a null binding certificate. + * + *

The cache is isolated by {@code token_type} and the certificate KeyId (x5t#S256), so a cache + * hit for an mTLS PoP request is guaranteed to be an {@code mtls_pop} token bound to this exact + * certificate; re-stamping it makes a cached PoP token report the same {@code tokenType()} and + * {@code bindingCertificate()} as a freshly issued one. Mirrors MSAL.NET, which persists/restores + * the token type from the cache item and re-runs the mTLS operation (re-stamping the binding + * certificate) on cache hits. + */ + private void restoreMtlsProofOfPossessionMetadata(AuthenticationResult result, + IClientCertificate bindingCertificate) { + if (result == null || bindingCertificate == null) { + return; + } + result.metadata().tokenType(TokenType.MTLS_POP); + result.metadata().bindingCertificate( + MtlsClientCertificateHelper.buildBindingCertificate(bindingCertificate)); + } + private AuthenticationResult acquireTokenByClientCredential() throws Exception { if (this.clientCredentialRequest.appTokenProvider != null) { diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/MtlsEndpointHelper.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/MtlsEndpointHelper.java index e3838642a..ff0fadc1b 100644 --- a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/MtlsEndpointHelper.java +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/MtlsEndpointHelper.java @@ -18,9 +18,20 @@ * * *

Region is OPTIONAL — there is no "region required" error path. The authority must be tenanted - * ({@code /common} and {@code /organizations} are rejected). US Gov / China (and other sovereign clouds) - * are fail-fast for now; the guardrail is isolated in {@link #isMtlsPoPUnsupportedCloud(String)} so it is - * trivial to lift per-cloud once {@code mtlsauth.*} lands there. + * ({@code /common} and {@code /organizations} are rejected). + * + *

Cloud boundaries are enforced so a request is never rewritten to (and its client certificate never + * presented at) a host in a different cloud: + *

    + *
  • Sovereign clouds (US Gov, China, and other national clouds) are fail-fast for now — the guardrail + * is isolated in {@link #isMtlsPoPUnsupportedCloud(String)}, backed by the authoritative sovereign-host + * set so no sovereign host can fall through, and is trivial to lift per-cloud once {@code mtlsauth.*} + * lands there.
  • + *
  • Public global hosts collapse to {@code mtlsauth.microsoft.com}; any other {@code login.*} host is + * rewritten domain-preserving ({@code login} → {@code mtlsauth}) so it stays within its own cloud + * boundary rather than being sent to the public endpoint (mirrors MSAL.NET).
  • + *
  • A host that is not a recognizable {@code login.*} host is rejected.
  • + *
*/ final class MtlsEndpointHelper { @@ -28,6 +39,10 @@ final class MtlsEndpointHelper { private static final String REGIONAL_LOGIN_SUFFIX = ".login.microsoft.com"; + private static final String LOGIN_LABEL = "login"; + private static final String MTLS_LABEL = "mtlsauth"; + private static final String LOGIN_LABEL_PREFIX = LOGIN_LABEL + "."; + private MtlsEndpointHelper() { } @@ -50,6 +65,13 @@ static URL deriveMtlsTokenEndpoint(URL tokenEndpoint) { } String mtlsHost = deriveMtlsHost(host); + if (mtlsHost == null) { + throw new MsalClientException( + "mTLS Proof-of-Possession requires a Microsoft Entra 'login.*' authority host, but the " + + "token endpoint host '" + host + "' is not recognized. Configure a public-cloud " + + "Microsoft Entra authority.", + AuthenticationErrorCode.MTLS_POP_ERROR); + } try { return new URL(tokenEndpoint.getProtocol(), mtlsHost, tokenEndpoint.getPort(), tokenEndpoint.getFile()); @@ -78,7 +100,19 @@ static String extractTenant(URL tokenEndpoint) { } /** - * Rewrites a {@code login.*} host to its {@code mtlsauth.*} equivalent, preserving any regional prefix. + * Rewrites a {@code login.*} host to its {@code mtlsauth.*} equivalent, preserving both any regional + * prefix and the cloud domain. + * + *
    + *
  • Regionalized public host {@code .login.microsoft.com} → {@code .mtlsauth.microsoft.com}.
  • + *
  • Public global hosts (e.g. {@code login.microsoftonline.com}, {@code login.windows.net}, + * {@code login.microsoft.com}, {@code sts.windows.net}) → {@code mtlsauth.microsoft.com}.
  • + *
  • Any other {@code login.*} host → domain-preserving swap ({@code login} → {@code mtlsauth}), + * keeping the request within the same cloud boundary (mirrors MSAL.NET).
  • + *
+ * + * @return the {@code mtlsauth.*} host, or {@code null} if {@code loginHost} is not a recognizable + * {@code login.*} host and therefore cannot be safely rewritten */ static String deriveMtlsHost(String loginHost) { String lower = loginHost.toLowerCase(Locale.ROOT); @@ -89,22 +123,50 @@ static String deriveMtlsHost(String loginHost) { return region + "." + GLOBAL_MTLS_HOST; } - // Global public hosts (login.microsoftonline.com, login.microsoft.com, login.windows.net, etc.) - return GLOBAL_MTLS_HOST; + // Public global hosts all resolve to the single public mTLS host. + if (isPublicHost(lower)) { + return GLOBAL_MTLS_HOST; + } + + // Any other login.* host: preserve the domain and only swap the leading "login" label for + // "mtlsauth", so the request stays within the same cloud boundary (never rewritten to the public + // endpoint). Mirrors MSAL.NET's domain-preserving rewrite. + if (lower.startsWith(LOGIN_LABEL_PREFIX)) { + return MTLS_LABEL + lower.substring(LOGIN_LABEL.length()); + } + + // Not a recognizable login.* host: cannot safely derive an mTLS host. + return null; + } + + /** + * @return {@code true} if {@code host} is a known public (non-sovereign) Microsoft Entra host that + * should resolve to the global public mTLS host {@code mtlsauth.microsoft.com}. + */ + private static boolean isPublicHost(String host) { + return AadInstanceDiscoveryProvider.TRUSTED_HOSTS_SET.contains(host) + && !AadInstanceDiscoveryProvider.TRUSTED_SOVEREIGN_HOSTS_SET.contains(host); } /** * Isolated sovereign-cloud guardrail. Returns {@code true} for clouds where mTLS PoP is not yet - * supported (US Gov, China). Keep this as the single point of truth so it is trivial to lift per-cloud. + * supported (US Gov, China, and other national clouds). Backed by the authoritative + * {@link AadInstanceDiscoveryProvider#TRUSTED_SOVEREIGN_HOSTS_SET} — including regionalized forms — + * so no sovereign host can fall through to the public mTLS endpoint. Keep this as the single point of + * truth so it is trivial to lift per-cloud once {@code mtlsauth.*} lands there. */ static boolean isMtlsPoPUnsupportedCloud(String host) { String lower = host.toLowerCase(Locale.ROOT); - return lower.endsWith(".us") - || lower.endsWith(".cn") - || lower.contains("usgovcloudapi") - || lower.contains("microsoftonline.us") - || lower.contains("chinacloudapi.cn") - || lower.contains("partner.microsoftonline.cn"); + if (AadInstanceDiscoveryProvider.TRUSTED_SOVEREIGN_HOSTS_SET.contains(lower)) { + return true; + } + // Also reject regionalized sovereign hosts, e.g. .login.microsoftonline.us. + for (String sovereignHost : AadInstanceDiscoveryProvider.TRUSTED_SOVEREIGN_HOSTS_SET) { + if (lower.endsWith("." + sovereignHost.toLowerCase(Locale.ROOT))) { + return true; + } + } + return false; } private static void validateTenanted(String tenant) { diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/TokenType.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/TokenType.java index b2aead1d6..9a3ef6d6a 100644 --- a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/TokenType.java +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/TokenType.java @@ -43,6 +43,11 @@ public String value() { * Maps a token_type string (as returned in a token response) to a {@link TokenType}. * Unknown or null values map to {@link #BEARER}. * + *

The match is an exact (case-insensitive) comparison against {@code mtls_pop}; the SHR-style + * {@code pop} token type is intentionally NOT treated as {@link #MTLS_POP}, so a non-certificate-bound + * {@code pop} response cannot pass the mTLS Proof-of-Possession fail-closed check. Mirrors MSAL.NET, + * which compares the response {@code token_type} exactly against the requested scheme. + * * @param tokenType the token_type string from a token response * @return the corresponding {@link TokenType}, defaulting to {@link #BEARER} */ @@ -51,7 +56,7 @@ static TokenType fromString(String tokenType) { return BEARER; } - if (MTLS_POP.value.equalsIgnoreCase(tokenType) || "pop".equalsIgnoreCase(tokenType)) { + if (MTLS_POP.value.equalsIgnoreCase(tokenType)) { return MTLS_POP; } diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/MtlsEndpointHelperTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/MtlsEndpointHelperTest.java index 00d2fb336..d780bb356 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/MtlsEndpointHelperTest.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/MtlsEndpointHelperTest.java @@ -10,6 +10,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -100,4 +101,78 @@ void isMtlsPoPUnsupportedCloud_predicate() { assertTrue(MtlsEndpointHelper.isMtlsPoPUnsupportedCloud("login.partner.microsoftonline.cn")); assertTrue(MtlsEndpointHelper.isMtlsPoPUnsupportedCloud("login.chinacloudapi.cn")); } + + // Regression: every sovereign host in the trusted set must fail fast (none may fall through to the + // public mtlsauth endpoint). The last five previously slipped past the substring denylist. + @Test + void isMtlsPoPUnsupportedCloud_coversAllSovereignHosts() { + String[] sovereignHosts = { + "login.chinacloudapi.cn", + "login.partner.microsoftonline.cn", + "login-us.microsoftonline.com", + "login.microsoftonline.de", + "login.microsoftonline.us", + "login.usgovcloudapi.net", + "login.sovcloud-identity.fr", + "login.sovcloud-identity.de", + "login.sovcloud-identity.sg" + }; + for (String host : sovereignHosts) { + assertTrue(MtlsEndpointHelper.isMtlsPoPUnsupportedCloud(host), + host + " must be treated as an unsupported (sovereign) cloud"); + } + // Regionalized sovereign hosts must also fail fast. + assertTrue(MtlsEndpointHelper.isMtlsPoPUnsupportedCloud("westus.login.microsoftonline.us")); + } + + @Test + void deriveMtlsTokenEndpoint_germanCloud_failsFast() { + MsalClientException ex = assertThrows(MsalClientException.class, () -> + MtlsEndpointHelper.deriveMtlsTokenEndpoint( + url("https://login.microsoftonline.de/contoso/oauth2/v2.0/token"))); + + assertEquals(AuthenticationErrorCode.MTLS_POP_ERROR, ex.errorCode()); + assertTrue(ex.getMessage().toLowerCase().contains("cloud")); + } + + @Test + void deriveMtlsTokenEndpoint_legacyUsCloud_failsFast() { + MsalClientException ex = assertThrows(MsalClientException.class, () -> + MtlsEndpointHelper.deriveMtlsTokenEndpoint( + url("https://login-us.microsoftonline.com/contoso/oauth2/v2.0/token"))); + + assertEquals(AuthenticationErrorCode.MTLS_POP_ERROR, ex.errorCode()); + } + + @Test + void deriveMtlsTokenEndpoint_sovereignFranceCloud_failsFast() { + MsalClientException ex = assertThrows(MsalClientException.class, () -> + MtlsEndpointHelper.deriveMtlsTokenEndpoint( + url("https://login.sovcloud-identity.fr/contoso/oauth2/v2.0/token"))); + + assertEquals(AuthenticationErrorCode.MTLS_POP_ERROR, ex.errorCode()); + } + + // A non-"login.*" host must be rejected rather than rewritten to (and its client cert presented at) + // the public mtlsauth endpoint. + @Test + void deriveMtlsTokenEndpoint_nonLoginHost_isRejected() { + MsalClientException ex = assertThrows(MsalClientException.class, () -> + MtlsEndpointHelper.deriveMtlsTokenEndpoint( + url("https://contoso.example.com/contoso/oauth2/v2.0/token"))); + + assertEquals(AuthenticationErrorCode.MTLS_POP_ERROR, ex.errorCode()); + } + + // An unrecognized but "login.*"-shaped host keeps its own domain (login -> mtlsauth) rather than + // collapsing to the public host, so the request stays within its cloud boundary. + @Test + void deriveMtlsHost_unknownLoginHost_preservesDomain() { + assertEquals("mtlsauth.example.com", MtlsEndpointHelper.deriveMtlsHost("login.example.com")); + } + + @Test + void deriveMtlsHost_nonLoginHost_returnsNull() { + assertNull(MtlsEndpointHelper.deriveMtlsHost("contoso.example.com")); + } } diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/MtlsProofOfPossessionTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/MtlsProofOfPossessionTest.java index e8e641d83..074b37fe3 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/MtlsProofOfPossessionTest.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/MtlsProofOfPossessionTest.java @@ -239,4 +239,60 @@ void mtlsPop_composesWithFmiPath_forFicLeg1() throws Exception { assertTrue(body.contains("fmi_path"), "FIC Leg 1 should still send fmi_path alongside mTLS PoP"); assertFalse(body.contains("client_assertion"), "FIC Leg 1 (cert) must not send a client_assertion"); } + + @Test + void mtlsPop_serverReturnsShrPopTokenType_failsClosed() throws Exception { + ConfidentialClientApplication app = baseCertAppBuilder().build(); + + // ESTS returned token_type=pop (an SHR/bearer-style PoP), which is NOT a certificate-bound + // mtls_pop token. MSAL must fail closed rather than surface it as MTLS_POP (the "pop" alias must + // not satisfy the mTLS PoP fail-closed check). + try (MockedConstruction mocked = mockConstruction(DefaultHttpClient.class, + (m, ctx) -> when(m.send(any(HttpRequest.class))).thenReturn(successResponse("shr-pop-token", "pop")))) { + + ExecutionException ex = assertThrows(ExecutionException.class, () -> + app.acquireToken(ClientCredentialParameters.builder(Collections.singleton("https://graph.microsoft.com/.default")) + .mtlsProofOfPossession() + .skipCache(true) + .build()).get()); + + assertInstanceOf(MsalClientException.class, ex.getCause()); + assertEquals(AuthenticationErrorCode.TOKEN_TYPE_MISMATCH, + ((MsalClientException) ex.getCause()).errorCode()); + } + } + + @Test + void mtlsPop_cacheHit_restoresTokenTypeAndBindingCertificate() throws Exception { + ConfidentialClientApplication app = baseCertAppBuilder().build(); + + IAuthenticationResult networkResult; + IAuthenticationResult cachedResult; + try (MockedConstruction mocked = mockConstruction(DefaultHttpClient.class, + (m, ctx) -> when(m.send(any(HttpRequest.class))).thenReturn(successResponse("mtls-pop-token", "mtls_pop")))) { + + // First call: the cache is empty, so this goes to the (mocked) network and caches the PoP token. + networkResult = app.acquireToken(mtlsPopCacheableParams()).get(); + // Second call: served from the cache (no additional mTLS network client is constructed). + cachedResult = app.acquireToken(mtlsPopCacheableParams()).get(); + + assertEquals(1, mocked.constructed().size(), + "Second acquireToken must be served from the cache, not the network"); + } + + assertEquals(TokenType.MTLS_POP, networkResult.metadata().tokenType()); + + // The cache hit must report the same PoP metadata rather than defaulting to Bearer / null. + assertEquals(TokenType.MTLS_POP, cachedResult.metadata().tokenType()); + assertNotNull(cachedResult.metadata().bindingCertificate()); + assertEquals(MtlsClientCertificateHelper.computeCertificateKeyId(certificate), + cachedResult.metadata().bindingCertificate().thumbprintSha256()); + } + + private static ClientCredentialParameters mtlsPopCacheableParams() { + return ClientCredentialParameters.builder(Collections.singleton("https://graph.microsoft.com/.default")) + .mtlsProofOfPossession() + .skipCache(false) + .build(); + } } From 3ef7681e1ce5b92c80409e553497478a9a22b308 Mon Sep 17 00:00:00 2001 From: Robbie Ginsburg Date: Wed, 8 Jul 2026 18:22:52 -0400 Subject: [PATCH 10/11] Align mTLS PoP cloud eligibility with MSAL.NET: allow Azure Gov + current national clouds Relax the mTLS PoP sovereign-cloud guardrail to match MSAL.NET's RegionAndMtlsDiscoveryProvider. isMtlsPoPUnsupportedCloud now denies only the two legacy sovereign hosts that have no mtlsauth.* endpoint (login.usgovcloudapi.net, login.chinacloudapi.cn). Every other login.* host -- including Azure Government and the current national clouds -- is allowed and rewritten domain-preserving (login -> mtlsauth), keeping each request within its own cloud boundary. This keeps the earlier domain-preserving rewrite (the actual cross-cloud leak fix) while removing the over-broad blanket sovereign rejection. Non-login. hosts remain rejected by deriveMtlsHost. MtlsEndpointHelperTest updated: the Azure Gov / current China / German / France cases now assert domain-preserving rewrite, and two legacy hosts assert fail-fast. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../aad/msal4j/MtlsEndpointHelper.java | 49 ++++++------ .../aad/msal4j/MtlsEndpointHelperTest.java | 79 ++++++++++++------- 2 files changed, 74 insertions(+), 54 deletions(-) diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/MtlsEndpointHelper.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/MtlsEndpointHelper.java index ff0fadc1b..581d91339 100644 --- a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/MtlsEndpointHelper.java +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/MtlsEndpointHelper.java @@ -5,7 +5,11 @@ import java.net.MalformedURLException; import java.net.URL; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; import java.util.Locale; +import java.util.Set; /** * Derives the mutual-TLS (mTLS) token endpoint used for mTLS Proof-of-Possession requests by rewriting the @@ -23,12 +27,12 @@ *

Cloud boundaries are enforced so a request is never rewritten to (and its client certificate never * presented at) a host in a different cloud: *

    - *
  • Sovereign clouds (US Gov, China, and other national clouds) are fail-fast for now — the guardrail - * is isolated in {@link #isMtlsPoPUnsupportedCloud(String)}, backed by the authoritative sovereign-host - * set so no sovereign host can fall through, and is trivial to lift per-cloud once {@code mtlsauth.*} - * lands there.
  • - *
  • Public global hosts collapse to {@code mtlsauth.microsoft.com}; any other {@code login.*} host is - * rewritten domain-preserving ({@code login} → {@code mtlsauth}) so it stays within its own cloud + *
  • Two legacy sovereign hosts that have no {@code mtlsauth.*} endpoint — {@code login.usgovcloudapi.net} + * and {@code login.chinacloudapi.cn} — are rejected fast by {@link #isMtlsPoPUnsupportedCloud(String)} + * (mirrors MSAL.NET's {@code s_unsupportedMtlsHosts} denylist).
  • + *
  • Every other {@code login.*} host — including Azure Government and the current national clouds — is + * allowed: public global hosts collapse to {@code mtlsauth.microsoft.com}; any other {@code login.*} host + * is rewritten domain-preserving ({@code login} → {@code mtlsauth}) so it stays within its own cloud * boundary rather than being sent to the public endpoint (mirrors MSAL.NET).
  • *
  • A host that is not a recognizable {@code login.*} host is rejected.
  • *
@@ -43,6 +47,13 @@ final class MtlsEndpointHelper { private static final String MTLS_LABEL = "mtlsauth"; private static final String LOGIN_LABEL_PREFIX = LOGIN_LABEL + "."; + // mTLS PoP is unsupported ONLY for these two legacy sovereign hosts (they have no mtlsauth.* endpoint). + // Every other login.* host — including Azure Government and the current national clouds — is allowed and + // rewritten domain-preserving. Mirrors MSAL.NET's s_unsupportedMtlsHosts denylist. Hosts are lowercase; + // callers lowercase the input before lookup. + private static final Set MTLS_UNSUPPORTED_HOSTS = Collections.unmodifiableSet(new HashSet<>( + Arrays.asList("login.usgovcloudapi.net", "login.chinacloudapi.cn"))); + private MtlsEndpointHelper() { } @@ -59,8 +70,8 @@ static URL deriveMtlsTokenEndpoint(URL tokenEndpoint) { String host = tokenEndpoint.getHost(); if (isMtlsPoPUnsupportedCloud(host)) { throw new MsalClientException( - "mTLS Proof-of-Possession is not supported in this cloud (host: " + host + "). " + - "It is currently available in the public cloud only.", + "mTLS Proof-of-Possession is not supported for the legacy sovereign host '" + host + + "'. This host has no mtlsauth.* endpoint.", AuthenticationErrorCode.MTLS_POP_ERROR); } @@ -149,24 +160,14 @@ private static boolean isPublicHost(String host) { } /** - * Isolated sovereign-cloud guardrail. Returns {@code true} for clouds where mTLS PoP is not yet - * supported (US Gov, China, and other national clouds). Backed by the authoritative - * {@link AadInstanceDiscoveryProvider#TRUSTED_SOVEREIGN_HOSTS_SET} — including regionalized forms — - * so no sovereign host can fall through to the public mTLS endpoint. Keep this as the single point of - * truth so it is trivial to lift per-cloud once {@code mtlsauth.*} lands there. + * Isolated sovereign-cloud guardrail. Returns {@code true} only for the two legacy sovereign hosts that + * have no {@code mtlsauth.*} endpoint ({@code login.usgovcloudapi.net}, {@code login.chinacloudapi.cn}). + * Every other {@code login.*} host — including Azure Government and the current national clouds — is + * supported and rewritten domain-preserving by {@link #deriveMtlsHost(String)}. Mirrors MSAL.NET's + * {@code s_unsupportedMtlsHosts} denylist; keep this as the single point of truth. */ static boolean isMtlsPoPUnsupportedCloud(String host) { - String lower = host.toLowerCase(Locale.ROOT); - if (AadInstanceDiscoveryProvider.TRUSTED_SOVEREIGN_HOSTS_SET.contains(lower)) { - return true; - } - // Also reject regionalized sovereign hosts, e.g. .login.microsoftonline.us. - for (String sovereignHost : AadInstanceDiscoveryProvider.TRUSTED_SOVEREIGN_HOSTS_SET) { - if (lower.endsWith("." + sovereignHost.toLowerCase(Locale.ROOT))) { - return true; - } - } - return false; + return MTLS_UNSUPPORTED_HOSTS.contains(host.toLowerCase(Locale.ROOT)); } private static void validateTenanted(String tenant) { diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/MtlsEndpointHelperTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/MtlsEndpointHelperTest.java index d780bb356..b602b04df 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/MtlsEndpointHelperTest.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/MtlsEndpointHelperTest.java @@ -74,67 +74,87 @@ void deriveMtlsTokenEndpoint_organizationsAuthority_isRejected() { } @Test - void deriveMtlsTokenEndpoint_usGovCloud_failsFast() { + void deriveMtlsTokenEndpoint_azureUsGovCloud_rewritesDomainPreserving() throws Exception { + URL result = MtlsEndpointHelper.deriveMtlsTokenEndpoint( + url("https://login.microsoftonline.us/contoso.onmicrosoft.com/oauth2/v2.0/token")); + + assertEquals("mtlsauth.microsoftonline.us", result.getHost()); + assertEquals("/contoso.onmicrosoft.com/oauth2/v2.0/token", result.getPath()); + } + + @Test + void deriveMtlsTokenEndpoint_currentChinaCloud_rewritesDomainPreserving() throws Exception { + URL result = MtlsEndpointHelper.deriveMtlsTokenEndpoint( + url("https://login.partner.microsoftonline.cn/contoso/oauth2/v2.0/token")); + + assertEquals("mtlsauth.partner.microsoftonline.cn", result.getHost()); + } + + @Test + void deriveMtlsTokenEndpoint_legacyUsGovCloudApiHost_failsFast() { MsalClientException ex = assertThrows(MsalClientException.class, () -> MtlsEndpointHelper.deriveMtlsTokenEndpoint( - url("https://login.microsoftonline.us/contoso.onmicrosoft.com/oauth2/v2.0/token"))); + url("https://login.usgovcloudapi.net/contoso/oauth2/v2.0/token"))); assertEquals(AuthenticationErrorCode.MTLS_POP_ERROR, ex.errorCode()); - assertTrue(ex.getMessage().toLowerCase().contains("cloud")); + assertTrue(ex.getMessage().toLowerCase().contains("legacy")); } @Test - void deriveMtlsTokenEndpoint_chinaCloud_failsFast() { + void deriveMtlsTokenEndpoint_legacyChinaCloudApiHost_failsFast() { MsalClientException ex = assertThrows(MsalClientException.class, () -> MtlsEndpointHelper.deriveMtlsTokenEndpoint( - url("https://login.partner.microsoftonline.cn/contoso/oauth2/v2.0/token"))); + url("https://login.chinacloudapi.cn/contoso/oauth2/v2.0/token"))); assertEquals(AuthenticationErrorCode.MTLS_POP_ERROR, ex.errorCode()); + assertTrue(ex.getMessage().toLowerCase().contains("legacy")); } @Test void isMtlsPoPUnsupportedCloud_predicate() { + // Supported: public, regional public, Azure Gov, and current national clouds. assertFalse(MtlsEndpointHelper.isMtlsPoPUnsupportedCloud("login.microsoftonline.com")); assertFalse(MtlsEndpointHelper.isMtlsPoPUnsupportedCloud("westus.login.microsoft.com")); + assertFalse(MtlsEndpointHelper.isMtlsPoPUnsupportedCloud("login.microsoftonline.us")); + assertFalse(MtlsEndpointHelper.isMtlsPoPUnsupportedCloud("login.partner.microsoftonline.cn")); + assertFalse(MtlsEndpointHelper.isMtlsPoPUnsupportedCloud("login.microsoftonline.de")); - assertTrue(MtlsEndpointHelper.isMtlsPoPUnsupportedCloud("login.microsoftonline.us")); - assertTrue(MtlsEndpointHelper.isMtlsPoPUnsupportedCloud("login.partner.microsoftonline.cn")); + // Unsupported: only the two legacy sovereign hosts (no mtlsauth.* endpoint). + assertTrue(MtlsEndpointHelper.isMtlsPoPUnsupportedCloud("login.usgovcloudapi.net")); assertTrue(MtlsEndpointHelper.isMtlsPoPUnsupportedCloud("login.chinacloudapi.cn")); } - // Regression: every sovereign host in the trusted set must fail fast (none may fall through to the - // public mtlsauth endpoint). The last five previously slipped past the substring denylist. + // Only the two legacy sovereign hosts (which have no mtlsauth.* endpoint) are denied by the predicate; + // every other login.* host — including Azure Gov and the current national clouds — is supported and + // rewritten domain-preserving. Mirrors MSAL.NET's two-host denylist. @Test - void isMtlsPoPUnsupportedCloud_coversAllSovereignHosts() { - String[] sovereignHosts = { - "login.chinacloudapi.cn", + void isMtlsPoPUnsupportedCloud_deniesOnlyLegacyHosts() { + assertTrue(MtlsEndpointHelper.isMtlsPoPUnsupportedCloud("login.usgovcloudapi.net")); + assertTrue(MtlsEndpointHelper.isMtlsPoPUnsupportedCloud("login.chinacloudapi.cn")); + + String[] supportedSovereignHosts = { "login.partner.microsoftonline.cn", - "login-us.microsoftonline.com", "login.microsoftonline.de", "login.microsoftonline.us", - "login.usgovcloudapi.net", "login.sovcloud-identity.fr", "login.sovcloud-identity.de", "login.sovcloud-identity.sg" }; - for (String host : sovereignHosts) { - assertTrue(MtlsEndpointHelper.isMtlsPoPUnsupportedCloud(host), - host + " must be treated as an unsupported (sovereign) cloud"); + for (String host : supportedSovereignHosts) { + assertFalse(MtlsEndpointHelper.isMtlsPoPUnsupportedCloud(host), + host + " must be supported (allowed + domain-preserving rewrite)"); } - // Regionalized sovereign hosts must also fail fast. - assertTrue(MtlsEndpointHelper.isMtlsPoPUnsupportedCloud("westus.login.microsoftonline.us")); } @Test - void deriveMtlsTokenEndpoint_germanCloud_failsFast() { - MsalClientException ex = assertThrows(MsalClientException.class, () -> - MtlsEndpointHelper.deriveMtlsTokenEndpoint( - url("https://login.microsoftonline.de/contoso/oauth2/v2.0/token"))); + void deriveMtlsTokenEndpoint_germanCloud_rewritesDomainPreserving() throws Exception { + URL result = MtlsEndpointHelper.deriveMtlsTokenEndpoint( + url("https://login.microsoftonline.de/contoso/oauth2/v2.0/token")); - assertEquals(AuthenticationErrorCode.MTLS_POP_ERROR, ex.errorCode()); - assertTrue(ex.getMessage().toLowerCase().contains("cloud")); + assertEquals("mtlsauth.microsoftonline.de", result.getHost()); } + // Legacy US host is not a "login." host (it is "login-us."), so it is rejected during host derivation. @Test void deriveMtlsTokenEndpoint_legacyUsCloud_failsFast() { MsalClientException ex = assertThrows(MsalClientException.class, () -> @@ -145,12 +165,11 @@ void deriveMtlsTokenEndpoint_legacyUsCloud_failsFast() { } @Test - void deriveMtlsTokenEndpoint_sovereignFranceCloud_failsFast() { - MsalClientException ex = assertThrows(MsalClientException.class, () -> - MtlsEndpointHelper.deriveMtlsTokenEndpoint( - url("https://login.sovcloud-identity.fr/contoso/oauth2/v2.0/token"))); + void deriveMtlsTokenEndpoint_sovereignFranceCloud_rewritesDomainPreserving() throws Exception { + URL result = MtlsEndpointHelper.deriveMtlsTokenEndpoint( + url("https://login.sovcloud-identity.fr/contoso/oauth2/v2.0/token")); - assertEquals(AuthenticationErrorCode.MTLS_POP_ERROR, ex.errorCode()); + assertEquals("mtlsauth.sovcloud-identity.fr", result.getHost()); } // A non-"login.*" host must be rejected rather than rewritten to (and its client cert presented at) From d576db6b680f12b0aafc46f144db9501798ade82 Mon Sep 17 00:00:00 2001 From: Robbie Ginsburg Date: Wed, 8 Jul 2026 18:47:03 -0400 Subject: [PATCH 11/11] Wrap non-base64 x5c decode as CertificateException in mTLS binding cert decodeCertificateChain could throw a raw IllegalArgumentException when an x5c entry was not valid base64, bypassing the MTLS_POP_ERROR surface that callers expect. Catch it and rethrow as CertificateException so buildBindingCertificate/computeCertificateKeyId/createMtlsSocketFactory all report MsalClientException(MTLS_POP_ERROR). Adds regression tests. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../msal4j/MtlsClientCertificateHelper.java | 8 +++- .../MtlsClientCertificateHelperTest.java | 40 +++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/MtlsClientCertificateHelper.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/MtlsClientCertificateHelper.java index f6d2a1b73..f5287eea3 100644 --- a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/MtlsClientCertificateHelper.java +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/MtlsClientCertificateHelper.java @@ -173,7 +173,13 @@ private static List decodeCertificateChain(IClientCertificate c List chain = new ArrayList<>(); CertificateFactory certificateFactory = CertificateFactory.getInstance("X.509"); for (String encoded : certificate.getEncodedPublicKeyCertificateChain()) { - byte[] der = Base64.getDecoder().decode(encoded); + byte[] der; + try { + der = Base64.getDecoder().decode(encoded); + } catch (IllegalArgumentException e) { + throw new CertificateException( + "mTLS binding certificate chain contains an x5c entry that is not valid base64.", e); + } chain.add((X509Certificate) certificateFactory.generateCertificate(new ByteArrayInputStream(der))); } return chain; diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/MtlsClientCertificateHelperTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/MtlsClientCertificateHelperTest.java index 0ab3c09e4..8be1e5fc8 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/MtlsClientCertificateHelperTest.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/MtlsClientCertificateHelperTest.java @@ -158,4 +158,44 @@ void resolveBindingCertificate_assertionWithoutBindingCert_throwsMtlsPopError() MtlsClientCertificateHelper.resolveBindingCertificate(app, null)); assertEquals(AuthenticationErrorCode.MTLS_POP_ERROR, ex.errorCode()); } + + // Regression: a custom IClientCertificate returning a non-base64 x5c entry must surface as a + // MsalClientException(MTLS_POP_ERROR), not a raw IllegalArgumentException from Base64 decoding. + @Test + void buildBindingCertificate_nonBase64Chain_throwsMtlsPopError() { + IClientCertificate badChain = withCertificateChain("this is not base64!!!"); + + MsalClientException ex = assertThrows(MsalClientException.class, () -> + MtlsClientCertificateHelper.buildBindingCertificate(badChain)); + assertEquals(AuthenticationErrorCode.MTLS_POP_ERROR, ex.errorCode()); + } + + @Test + void computeCertificateKeyId_nonBase64Chain_throwsMtlsPopError() { + IClientCertificate badChain = withCertificateChain("also not base64 %%%"); + + MsalClientException ex = assertThrows(MsalClientException.class, () -> + MtlsClientCertificateHelper.computeCertificateKeyId(badChain)); + assertEquals(AuthenticationErrorCode.MTLS_POP_ERROR, ex.errorCode()); + } + + // Stub whose public chain contains the given (possibly invalid) x5c entries; the private key is unused. + private static IClientCertificate withCertificateChain(String... encodedChain) { + return new IClientCertificate() { + @Override + public PrivateKey privateKey() { + return null; + } + + @Override + public String publicCertificateHash() { + return null; + } + + @Override + public List getEncodedPublicKeyCertificateChain() { + return java.util.Arrays.asList(encodedChain); + } + }; + } }