diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
index 67c27610..19b6a58c 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`).
+ - **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/build/credscan-exclude.json b/build/credscan-exclude.json
index f045498d..30f2d1f9 100644
--- a/build/credscan-exclude.json
+++ b/build/credscan-exclude.json
@@ -1,5 +1,9 @@
{
"tool": "Credential Scanner",
"suppressions": [
+ {
+ "file": "msal4j-sdk/src/test/resources/mtls_test_cert.p12",
+ "_justification": "Self-signed, test-only certificate (CN=msal4j-mtls-test) used by unit tests for mTLS Proof-of-Possession. Contains no production secret."
+ }
]
-}
\ No newline at end of file
+}
diff --git a/changelog.txt b/changelog.txt
index 78dcfcf3..f7487561 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
- 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 00000000..66ecdb9f
--- /dev/null
+++ b/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/MtlsPopIT.java
@@ -0,0 +1,188 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+package com.microsoft.aad.msal4j;
+
+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).
+ *
+ *
The primary scenario is covered:
+ *
+ * - Direct SNI cert → mTLS PoP (client credentials), global and regional endpoints.
+ *
+ *
+ * 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).
+ *
+ *
App/tenant requirement: ESTS only issues mTLS PoP tokens for the SN/I-allow-listed app in the
+ * MSI team tenant. This mirrors MSAL .NET's {@code ClientCredentialsMtlsPopTests}: any other app (e.g. the
+ * general {@code LabVaultAppID}) is rejected with {@code AADSTS700025: Client is public...}. The lab cert
+ * ({@link KeyVaultSecretsProvider#CERTIFICATE_ALIAS}) is registered for SN/I on this app.
+ */
+@TestInstance(TestInstance.Lifecycle.PER_CLASS)
+class MtlsPopIT {
+
+ // SN/I-allow-listed app and MSI team tenant. mTLS PoP only works on this app/tenant pair; mirrors
+ // MSAL .NET's ClientCredentialsMtlsPopTests. App and tenant IDs are public identifiers, not secrets.
+ private static final String SNI_ALLOWLISTED_APP_ID = "163ffef9-a313-45b4-ab2f-c7e2f5e0e23e";
+ private static final String SNI_ALLOWLISTED_AUTHORITY =
+ "https://login.microsoftonline.com/bea21ebe-8b64-4d06-9f6d-6a889b120a7c";
+ private static final String TEST_SLICE_REGION = "westus3";
+
+ private PrivateKey privateKey;
+ private X509Certificate publicCertificate;
+ private IClientCertificate certificate;
+
+ @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 {
+ ConfidentialClientApplication cca = ConfidentialClientApplication.builder(SNI_ALLOWLISTED_APP_ID, certificate)
+ .authority(SNI_ALLOWLISTED_AUTHORITY) // tenanted authority (required for mTLS PoP)
+ .build();
+
+ IAuthenticationResult result = cca.acquireToken(ClientCredentialParameters
+ .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 {
+ ConfidentialClientApplication cca = ConfidentialClientApplication.builder(SNI_ALLOWLISTED_APP_ID, certificate)
+ .authority(SNI_ALLOWLISTED_AUTHORITY)
+ .azureRegion(TEST_SLICE_REGION)
+ .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 {
+ ConfidentialClientApplication cca = ConfidentialClientApplication.builder(SNI_ALLOWLISTED_APP_ID, certificate)
+ .authority(SNI_ALLOWLISTED_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");
+ }
+
+ 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 d56f8176..f4d12e32 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/AuthenticationErrorCode.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AuthenticationErrorCode.java
index d5fba3df..d21335eb 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 35cded71..2921f262 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 00000000..9947d7c4
--- /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/ClientCredentialParameters.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ClientCredentialParameters.java
index 3146cb26..31adfd4b 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,15 +32,18 @@ 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.
- private SortedMap cacheKeyComponents;
+ private volatile SortedMap cacheKeyComponents;
- // Memoized hash of cacheKeyComponents (computed once since parameters are immutable).
- private String extCacheKeyHashCache;
+ // Lazily memoized hash of cacheKeyComponents. Invalidated (set to null) whenever the
+ // components change (e.g. bindingCertificateKeyId adds the cert KeyId) so it is recomputed.
+ private volatile String extCacheKeyHashCache;
- private ClientCredentialParameters(Set scopes, Boolean skipCache, ClaimsRequest claims, Map extraHttpHeaders, Map extraQueryParameters, String tenant, IClientCredential clientCredential, String fmiPath) {
+ 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 +52,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 +118,41 @@ 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. This params instance may be reused across concurrent acquireToken calls, so the
+ * update is copy-on-write and synchronized, and clears the memoized hash so it is recomputed.
+ */
+ void bindingCertificateKeyId(String keyId) {
+ if (StringHelper.isBlank(keyId)) {
+ return;
+ }
+ synchronized (this) {
+ // Copy-on-write: never mutate a map that another thread may be reading. Swap in a fresh
+ // map and invalidate the memoized hash so it is recomputed with the added component.
+ TreeMap updated = this.cacheKeyComponents == null
+ ? new TreeMap<>()
+ : new TreeMap<>(this.cacheKeyComponents);
+ updated.put("cert_kid", keyId);
+ this.cacheKeyComponents = updated;
+ this.extCacheKeyHashCache = null;
+ }
+ }
+
/**
* Builds the sorted map of cache key components from the parameters that require
* cache isolation. Returns null if no components are present.
@@ -127,6 +166,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;
}
@@ -142,15 +187,18 @@ SortedMap cacheKeyComponents() {
* Computes the extended cache key hash from all cache key components.
* Returns an empty string if no components are present.
*
- * The result is memoized since ClientCredentialParameters is immutable after construction.
+ * The result is lazily memoized and invalidated whenever the cache key components change
+ * (see {@link #bindingCertificateKeyId(String)}).
* Used by both cache writes ({@link TokenCache}) and cache reads (silent lookup).
*/
String computeExtCacheKeyHash() {
- if (extCacheKeyHashCache != null) {
- return extCacheKeyHashCache;
+ String cached = extCacheKeyHashCache;
+ if (cached != null) {
+ return cached;
}
- extCacheKeyHashCache = StringHelper.computeExtCacheKeyHash(cacheKeyComponents);
- return extCacheKeyHashCache;
+ String computed = StringHelper.computeExtCacheKeyHash(cacheKeyComponents);
+ extCacheKeyHashCache = computed;
+ return computed;
}
public static class ClientCredentialParametersBuilder {
@@ -162,6 +210,7 @@ public static class ClientCredentialParametersBuilder {
private String tenant;
private IClientCredential clientCredential;
private String fmiPath;
+ private boolean mtlsProofOfPossession;
ClientCredentialParametersBuilder() {
}
@@ -245,12 +294,33 @@ 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}) 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 8d60f82b..88c2d64f 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/HttpHelper.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/HttpHelper.java
index 2f6da6ae..5ef9e3a4 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 099e40b9..0f25fb3a 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 00000000..f6d2a1b7
--- /dev/null
+++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/MtlsClientCertificateHelper.java
@@ -0,0 +1,241 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+package com.microsoft.aad.msal4j;
+
+import javax.net.ssl.KeyManager;
+import javax.net.ssl.SSLContext;
+import javax.net.ssl.SSLEngine;
+import javax.net.ssl.SSLSocketFactory;
+import javax.net.ssl.X509ExtendedKeyManager;
+import java.io.ByteArrayInputStream;
+import java.net.Socket;
+import java.security.GeneralSecurityException;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.security.Principal;
+import java.security.PrivateKey;
+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).
+ * Only public material is ever surfaced; the private key is used in place (through its own provider)
+ * and is never exported or copied into a key store.
+ */
+final class MtlsClientCertificateHelper {
+
+ 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 when it is a certificate (direct SN/I cert or FIC Leg 1).
+ *
+ * @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;
+ }
+
+ throw new MsalClientException(
+ "mTLS Proof-of-Possession requires a client certificate. Configure the application with a " +
+ "certificate credential.",
+ 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);
+ }
+
+ PrivateKey privateKey = certificate.privateKey();
+ if (privateKey == null) {
+ throw new MsalClientException(
+ "mTLS Proof-of-Possession requires a certificate with an accessible private key.",
+ AuthenticationErrorCode.MTLS_POP_ERROR);
+ }
+
+ // Present the certificate to the TLS layer through a KeyManager that holds the live private
+ // key object directly, instead of importing it into a PKCS12 KeyStore. The key may be a
+ // non-exportable handle (e.g. a Windows-MY / SunMSCAPI or HSM key) whose getEncoded()
+ // returns null and which therefore cannot be added to a KeyStore; the TLS handshake can
+ // still sign through the key's own provider. Ordinary exportable keys use the same path.
+ KeyManager[] keyManagers = {
+ new SingleCertificateKeyManager(KEY_ENTRY_ALIAS, privateKey,
+ chain.toArray(new X509Certificate[0]))
+ };
+
+ SSLContext sslContext = SSLContext.getInstance("TLS");
+ sslContext.init(keyManagers, null, null);
+
+ return sslContext.getSocketFactory();
+ } catch (MsalClientException e) {
+ throw e;
+ } catch (GeneralSecurityException 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;
+ }
+
+ /**
+ * An {@link X509ExtendedKeyManager} that always presents a single, pre-resolved client certificate
+ * and its private key during a TLS handshake. It holds the live {@link PrivateKey} reference rather
+ * than importing the key into a {@link java.security.KeyStore}, so it works with non-exportable keys
+ * (e.g. Windows-MY / SunMSCAPI or HSM-backed keys) whose {@code getEncoded()} returns {@code null}.
+ * Signing is delegated to the key's own provider by the SSL engine.
+ */
+ private static final class SingleCertificateKeyManager extends X509ExtendedKeyManager {
+
+ private final String alias;
+ private final PrivateKey privateKey;
+ private final X509Certificate[] chain;
+
+ SingleCertificateKeyManager(String alias, PrivateKey privateKey, X509Certificate[] chain) {
+ this.alias = alias;
+ this.privateKey = privateKey;
+ this.chain = chain;
+ }
+
+ @Override
+ public String[] getClientAliases(String keyType, Principal[] issuers) {
+ return new String[]{alias};
+ }
+
+ @Override
+ public String chooseClientAlias(String[] keyType, Principal[] issuers, Socket socket) {
+ return alias;
+ }
+
+ @Override
+ public String chooseEngineClientAlias(String[] keyType, Principal[] issuers, SSLEngine engine) {
+ return alias;
+ }
+
+ @Override
+ public String[] getServerAliases(String keyType, Principal[] issuers) {
+ return null;
+ }
+
+ @Override
+ public String chooseServerAlias(String keyType, Principal[] issuers, Socket socket) {
+ return null;
+ }
+
+ @Override
+ public String chooseEngineServerAlias(String keyType, Principal[] issuers, SSLEngine engine) {
+ return null;
+ }
+
+ @Override
+ public X509Certificate[] getCertificateChain(String alias) {
+ return chain.clone();
+ }
+
+ @Override
+ public PrivateKey getPrivateKey(String alias) {
+ return privateKey;
+ }
+ }
+}
diff --git a/msal4j-sdk/src/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 00000000..e3838642
--- /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 49ecc2fc..b0031063 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 3c7e3519..7f9e3b2c 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,6 +159,8 @@ 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());
@@ -161,6 +190,12 @@ private void addCredentialToRequest(Map queryParameters,
addJWTBearerAssertionParams(queryParameters, clientAssertion.assertion());
}
} 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(
@@ -182,6 +217,25 @@ private void addJWTBearerAssertionParams(Map queryParameters, St
queryParameters.put("client_assertion_type", ClientAssertion.ASSERTION_TYPE_JWT_BEARER);
}
+ /**
+ * @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 when it is a certificate (direct SN/I cert or FIC Leg 1).
+ */
+ private IClientCertificate resolveBindingCertificate(ConfidentialClientApplication application) {
+ ClientCredentialParameters parameters = msalRequest instanceof ClientCredentialRequest
+ ? ((ClientCredentialRequest) msalRequest).parameters : null;
+ return MtlsClientCertificateHelper.resolveBindingCertificate(application, parameters);
+ }
+
private AuthenticationResult createAuthenticationResultFromOauthHttpResponse(HttpResponse oauthHttpResponse) {
AuthenticationResult result;
@@ -213,6 +267,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 +288,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 b314bb77..54e29584 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 00000000..4a9c571e
--- /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 00000000..964f91fc
--- /dev/null
+++ b/msal4j-sdk/src/samples/confidential-client/ClientCredentialMtlsProofOfPossession.java
@@ -0,0 +1,97 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+import com.microsoft.aad.msal4j.BindingCertificate;
+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);
+ }
+
+ /**
+ * 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());
+ }
+
+ /**
+ * 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 00000000..0ab3c09e
--- /dev/null
+++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/MtlsClientCertificateHelperTest.java
@@ -0,0 +1,161 @@
+// 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.NoSuchAlgorithmException;
+import java.security.PrivateKey;
+import java.security.cert.CertificateEncodingException;
+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());
+ }
+
+ // Regression for the mTLS PoP integration tests on CI (Windows + JDK 8): the lab certificate is
+ // loaded from the Windows-MY store via SunMSCAPI, so its private key is a non-exportable handle
+ // whose getEncoded()/getFormat() return null. Importing such a key into a PKCS12 KeyStore fails
+ // ("Key protection algorithm not found: java.lang.NullPointerException"). Building the socket
+ // factory must instead hold the live key and delegate signing to its provider, so it succeeds.
+ @Test
+ void createMtlsSocketFactory_nonExportablePrivateKey_buildsFactory() {
+ IClientCertificate nonExportable = withNonExportableKey(certificate);
+ SSLSocketFactory factory = MtlsClientCertificateHelper.createMtlsSocketFactory(nonExportable);
+ assertNotNull(factory);
+ }
+
+ // Wraps a certificate so its private key mimics a non-exportable OS/HSM key handle: the public
+ // chain still resolves, but getEncoded()/getFormat() return null (as SunMSCAPI keys do).
+ private static IClientCertificate withNonExportableKey(IClientCertificate delegate) {
+ PrivateKey nonExportableKey = new PrivateKey() {
+ @Override
+ public String getAlgorithm() {
+ return delegate.privateKey().getAlgorithm();
+ }
+
+ @Override
+ public String getFormat() {
+ return null;
+ }
+
+ @Override
+ public byte[] getEncoded() {
+ return null;
+ }
+ };
+
+ return new IClientCertificate() {
+ @Override
+ public PrivateKey privateKey() {
+ return nonExportableKey;
+ }
+
+ @Override
+ public String publicCertificateHash() throws CertificateEncodingException, NoSuchAlgorithmException {
+ return delegate.publicCertificateHash();
+ }
+
+ @Override
+ public List getEncodedPublicKeyCertificateChain() throws CertificateEncodingException {
+ return delegate.getEncodedPublicKeyCertificateChain();
+ }
+ };
+ }
+
+ @Test
+ void computeThumbprintSha256_matchesBase64UrlSha256OfLeafDer() throws Exception {
+ String encodedLeaf = certificate.getEncodedPublicKeyCertificateChain().get(0);
+ 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_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 00000000..00d2fb33
--- /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 00000000..95c9225a
--- /dev/null
+++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/MtlsProofOfPossessionTest.java
@@ -0,0 +1,213 @@
+// 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 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 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 00000000..1ad9e1a4
Binary files /dev/null and b/msal4j-sdk/src/test/resources/mtls_test_cert.p12 differ