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:
+ *
+ * - Direct SNI cert → mTLS PoP (client credentials), global and regional endpoints.
+ * - 2-leg FIC over mTLS PoP — both legs are mTLS-PoP requests and the final token is bound to
+ * the Leg-1 certificate thumbprint.
+ *
+ *
+ * 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.*}).
+ *
+ *
+ * - No region configured: {@code https://mtlsauth.microsoft.com//oauth2/v2.0/token} — the
+ * global mTLS host is production-ready (ESTS-R regional failover).
+ * - Region configured: {@code https://.mtlsauth.microsoft.com//oauth2/v2.0/token}.
+ *
+ *
+ * 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:
+ *
+ * - The authority must be tenanted ({@code /common} and {@code /organizations} are rejected
+ * on the mTLS PoP path).
+ * - A region is optional: omit {@code azureRegion(...)} to use the global
+ * {@code mtlsauth.microsoft.com} endpoint (production-ready via ESTS-R regional failover), or set
+ * one to target {@code .mtlsauth.microsoft.com} (recommended).
+ * - ESTS gates mTLS PoP on the final resource audience: it must be an ESTS allow-listed
+ * resource (e.g. Azure Key Vault or MS Graph), not the client app.
+ * - mTLS PoP is a confidential-client feature and is distinct from the broker-based Signed-HTTP-
+ * Request (SHR) PoP used by public clients. US Gov / China clouds are not yet supported.
+ *
+ *
+ * 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 000000000..1ad9e1a47
Binary files /dev/null and b/msal4j-sdk/src/test/resources/mtls_test_cert.p12 differ