diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 67c276105..19b6a58c3 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 f045498d8..30f2d1f9a 100644 --- a/build/credscan-exclude.json +++ b/build/credscan-exclude.json @@ -1,5 +1,9 @@ { "tool": "Credential Scanner", "suppressions": [ + { + "file": "msal4j-sdk/src/test/resources/mtls_test_cert.p12", + "_justification": "Self-signed, test-only certificate (CN=msal4j-mtls-test) used by unit tests for mTLS Proof-of-Possession. Contains no production secret." + } ] -} \ No newline at end of file +} diff --git a/changelog.txt b/changelog.txt index 78dcfcf3a..c425dc0ba 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. The response token_type is validated, so a request that is not honored as mtls_pop fails closed rather than surfacing an unbound token - Add Federated Managed Identity (FMI) support for client credentials flow (#1025) - Add User Federated Identity Credential (user_fic) grant type support (#1026) - Add MSAL client metadata headers to IMDS managed identity requests (#1024) diff --git a/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/MtlsPopIT.java b/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/MtlsPopIT.java new file mode 100644 index 000000000..31e3a5fa2 --- /dev/null +++ b/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/MtlsPopIT.java @@ -0,0 +1,205 @@ +// 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.Assumptions; +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 java.util.concurrent.ExecutionException; + +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: + *

+ * + *

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 = acquireMtlsPopOrSkipOnDowngrade(cca, ClientCredentialParameters + .builder(Collections.singleton(KEYVAULT_DEFAULT_SCOPE)) + .mtlsProofOfPossession() + .build()); + + 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 = acquireMtlsPopOrSkipOnDowngrade(cca, ClientCredentialParameters + .builder(Collections.singleton(KEYVAULT_DEFAULT_SCOPE)) + .mtlsProofOfPossession() + .build()); + + assertMtlsPopResult(result, expectedLabThumbprint()); + + // The mTLS-PoP token must be cached under {token_type + cert KeyId} and returned on lookup. + IAuthenticationResult cached = acquireMtlsPopOrSkipOnDowngrade(cca, ClientCredentialParameters + .builder(Collections.singleton(KEYVAULT_DEFAULT_SCOPE)) + .mtlsProofOfPossession() + .build()); + + 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 = acquireMtlsPopOrSkipOnDowngrade(cca, ClientCredentialParameters + .builder(Collections.singleton(KEYVAULT_DEFAULT_SCOPE)) + .mtlsProofOfPossession() + .build()); + 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"); + } + + // ESTS's mTLS PoP test slice is a known intermittent token_type downgrader. When it returns a + // non-mtls_pop token the access token is not certificate-bound, and MSAL now fails closed with + // TOKEN_TYPE_MISMATCH. Treat that specific outcome as inconclusive (skip) rather than a hard failure, + // mirroring MSAL .NET's ExecuteOrInconclusiveOnTokenTypeMismatchAsync. + private static IAuthenticationResult acquireMtlsPopOrSkipOnDowngrade( + ConfidentialClientApplication cca, ClientCredentialParameters parameters) throws Exception { + try { + return cca.acquireToken(parameters).get(); + } catch (ExecutionException e) { + if (e.getCause() instanceof MsalClientException + && AuthenticationErrorCode.TOKEN_TYPE_MISMATCH.equals( + ((MsalClientException) e.getCause()).errorCode())) { + Assumptions.abort("ESTS returned a non-mtls_pop token_type (downgrade); treating as " + + "inconclusive: " + e.getCause().getMessage()); + } + throw e; + } + } + + private void assertMtlsPopResult(IAuthenticationResult result, String expectedThumbprint) { + assertNotNull(result, "Auth result should not be null"); + assertNotNull(result.accessToken(), "Access token should not be null"); + 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..d6758c0a9 100644 --- a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AcquireTokenByClientCredentialSupplier.java +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AcquireTokenByClientCredentialSupplier.java @@ -19,6 +19,17 @@ 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. + IClientCertificate resolvedBindingCertificate = null; + if (clientCredentialRequest.parameters.mtlsProofOfPossession()) { + resolvedBindingCertificate = MtlsClientCertificateHelper.resolveBindingCertificate( + (ConfidentialClientApplication) this.clientApplication, clientCredentialRequest.parameters); + clientCredentialRequest.parameters.bindingCertificateKeyId( + MtlsClientCertificateHelper.computeCertificateKeyId(resolvedBindingCertificate)); + } + if (clientCredentialRequest.parameters.skipCache() != null && !clientCredentialRequest.parameters.skipCache()) { LOG.debug("SkipCache set to false. Attempting cache lookup"); @@ -50,7 +61,9 @@ AuthenticationResult execute() throws Exception { this.clientApplication, silentRequest); - return supplier.execute(); + AuthenticationResult cachedResult = supplier.execute(); + restoreMtlsProofOfPossessionMetadata(cachedResult, resolvedBindingCertificate); + return cachedResult; } catch (MsalClientException ex) { LOG.debug("Cache lookup failed: {}", ex.getMessage()); return acquireTokenByClientCredential(); @@ -61,6 +74,29 @@ AuthenticationResult execute() throws Exception { return acquireTokenByClientCredential(); } + /** + * Restores the mTLS Proof-of-Possession metadata (token type and binding certificate) on a result + * served from the cache. The network path stamps these in {@link TokenRequestExecutor}, but the + * silent/cache read path does not, so without this a cached PoP token would report {@code BEARER} + * and a null binding certificate. + * + *

The cache is isolated by {@code token_type} and the certificate KeyId (x5t#S256), so a cache + * hit for an mTLS PoP request is guaranteed to be an {@code mtls_pop} token bound to this exact + * certificate; re-stamping it makes a cached PoP token report the same {@code tokenType()} and + * {@code bindingCertificate()} as a freshly issued one. Mirrors MSAL.NET, which persists/restores + * the token type from the cache item and re-runs the mTLS operation (re-stamping the binding + * certificate) on cache hits. + */ + private void restoreMtlsProofOfPossessionMetadata(AuthenticationResult result, + IClientCertificate bindingCertificate) { + if (result == null || bindingCertificate == null) { + return; + } + result.metadata().tokenType(TokenType.MTLS_POP); + result.metadata().bindingCertificate( + MtlsClientCertificateHelper.buildBindingCertificate(bindingCertificate)); + } + private AuthenticationResult acquireTokenByClientCredential() throws Exception { if (this.clientCredentialRequest.appTokenProvider != null) { diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AuthenticationErrorCode.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AuthenticationErrorCode.java index d5fba3dff..9979c3ef8 100644 --- a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AuthenticationErrorCode.java +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AuthenticationErrorCode.java @@ -161,6 +161,21 @@ 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 an mTLS Proof-of-Possession token was requested, but the identity provider returned + * a token with a different {@code token_type} (for example, a Bearer downgrade). The returned access + * token is not certificate-bound, so MSAL fails closed rather than surfacing an unbound token as mTLS + * PoP. For more details, see https://aka.ms/msal4j-pop + */ + public static final String TOKEN_TYPE_MISMATCH = "token_type_mismatch"; + /** * Indicates that instance discovery failed because the authority is not a valid instance. * This is returned by the instance discovery endpoint when the provided authority host is unknown. diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/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/ClientCredentialParameters.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ClientCredentialParameters.java index 3146cb26c..31adfd4b0 100644 --- a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ClientCredentialParameters.java +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ClientCredentialParameters.java @@ -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 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/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..f5287eea3 --- /dev/null +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/MtlsClientCertificateHelper.java @@ -0,0 +1,247 @@ +// 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; + try { + der = Base64.getDecoder().decode(encoded); + } catch (IllegalArgumentException e) { + throw new CertificateException( + "mTLS binding certificate chain contains an x5c entry that is not valid base64.", e); + } + chain.add((X509Certificate) certificateFactory.generateCertificate(new ByteArrayInputStream(der))); + } + return chain; + } + + /** + * 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 000000000..581d91339 --- /dev/null +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/MtlsEndpointHelper.java @@ -0,0 +1,183 @@ +// 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.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.Locale; +import java.util.Set; + +/** + * Derives the mutual-TLS (mTLS) token endpoint used for mTLS Proof-of-Possession requests by rewriting the + * standard token endpoint host ({@code login.*}) to the mTLS host ({@code mtlsauth.*}). + * + *

+ * + *

Region is OPTIONAL — there is no "region required" error path. The authority must be tenanted + * ({@code /common} and {@code /organizations} are rejected). + * + *

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

+ */ +final class MtlsEndpointHelper { + + static final String GLOBAL_MTLS_HOST = "mtlsauth.microsoft.com"; + + private static final String REGIONAL_LOGIN_SUFFIX = ".login.microsoft.com"; + + private static final String LOGIN_LABEL = "login"; + private static final String MTLS_LABEL = "mtlsauth"; + private static final String LOGIN_LABEL_PREFIX = LOGIN_LABEL + "."; + + // mTLS PoP is unsupported ONLY for these two legacy sovereign hosts (they have no mtlsauth.* endpoint). + // Every other login.* host — including Azure Government and the current national clouds — is allowed and + // rewritten domain-preserving. Mirrors MSAL.NET's s_unsupportedMtlsHosts denylist. Hosts are lowercase; + // callers lowercase the input before lookup. + private static final Set MTLS_UNSUPPORTED_HOSTS = Collections.unmodifiableSet(new HashSet<>( + Arrays.asList("login.usgovcloudapi.net", "login.chinacloudapi.cn"))); + + private MtlsEndpointHelper() { + } + + /** + * 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 for the legacy sovereign host '" + host + + "'. This host has no mtlsauth.* endpoint.", + AuthenticationErrorCode.MTLS_POP_ERROR); + } + + String mtlsHost = deriveMtlsHost(host); + if (mtlsHost == null) { + throw new MsalClientException( + "mTLS Proof-of-Possession requires a Microsoft Entra 'login.*' authority host, but the " + + "token endpoint host '" + host + "' is not recognized. Configure a public-cloud " + + "Microsoft Entra authority.", + AuthenticationErrorCode.MTLS_POP_ERROR); + } + + try { + return new URL(tokenEndpoint.getProtocol(), mtlsHost, tokenEndpoint.getPort(), tokenEndpoint.getFile()); + } 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 both any regional + * prefix and the cloud domain. + * + * + * + * @return the {@code mtlsauth.*} host, or {@code null} if {@code loginHost} is not a recognizable + * {@code login.*} host and therefore cannot be safely rewritten + */ + static String deriveMtlsHost(String loginHost) { + String lower = loginHost.toLowerCase(Locale.ROOT); + + // 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; + } + + // Public global hosts all resolve to the single public mTLS host. + if (isPublicHost(lower)) { + return GLOBAL_MTLS_HOST; + } + + // Any other login.* host: preserve the domain and only swap the leading "login" label for + // "mtlsauth", so the request stays within the same cloud boundary (never rewritten to the public + // endpoint). Mirrors MSAL.NET's domain-preserving rewrite. + if (lower.startsWith(LOGIN_LABEL_PREFIX)) { + return MTLS_LABEL + lower.substring(LOGIN_LABEL.length()); + } + + // Not a recognizable login.* host: cannot safely derive an mTLS host. + return null; + } + + /** + * @return {@code true} if {@code host} is a known public (non-sovereign) Microsoft Entra host that + * should resolve to the global public mTLS host {@code mtlsauth.microsoft.com}. + */ + private static boolean isPublicHost(String host) { + return AadInstanceDiscoveryProvider.TRUSTED_HOSTS_SET.contains(host) + && !AadInstanceDiscoveryProvider.TRUSTED_SOVEREIGN_HOSTS_SET.contains(host); + } + + /** + * Isolated sovereign-cloud guardrail. Returns {@code true} only for the two legacy sovereign hosts that + * have no {@code mtlsauth.*} endpoint ({@code login.usgovcloudapi.net}, {@code login.chinacloudapi.cn}). + * Every other {@code login.*} host — including Azure Government and the current national clouds — is + * supported and rewritten domain-preserving by {@link #deriveMtlsHost(String)}. Mirrors MSAL.NET's + * {@code s_unsupportedMtlsHosts} denylist; keep this as the single point of truth. + */ + static boolean isMtlsPoPUnsupportedCloud(String host) { + return MTLS_UNSUPPORTED_HOSTS.contains(host.toLowerCase(Locale.ROOT)); + } + + 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..c1778aa05 100644 --- a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/TokenRequestExecutor.java +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/TokenRequestExecutor.java @@ -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,25 @@ private AuthenticationResult createAuthenticationResultFromOauthHttpResponse(Htt } long currTimestampSec = new Date().getTime() / 1000; + // The token type is taken from the identity provider's response, never assumed from the request. + TokenType tokenType = TokenType.fromString(response.tokenType()); + BindingCertificate bindingCertificate = null; + if (isMtlsProofOfPossession()) { + // Fail closed: an mTLS Proof-of-Possession request MUST come back as an mtls_pop + // (certificate-bound) token. If ESTS returns a different token_type (for example a Bearer + // downgrade), the access token is not bound to the certificate; surfacing it as MTLS_POP + // would mask the downgrade, so reject it instead. + if (tokenType != TokenType.MTLS_POP) { + throw new MsalClientException( + String.format("An mTLS Proof-of-Possession token was requested, but the identity " + + "provider returned token_type '%s' instead of 'mtls_pop'. The access token is " + + "not certificate-bound.", response.tokenType()), + AuthenticationErrorCode.TOKEN_TYPE_MISMATCH); + } + // Surface the binding certificate only after the token type has been validated. + bindingCertificate = MtlsClientCertificateHelper.buildBindingCertificate(resolvedBindingCertificate); + } + result = AuthenticationResult.builder(). accessToken(response.accessToken()). refreshToken(response.refreshToken()). @@ -227,6 +300,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..9a3ef6d6a --- /dev/null +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/TokenType.java @@ -0,0 +1,70 @@ +// 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"), + + /** + * 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"); + + private final String value; + + TokenType(String value) { + this.value = value; + } + + /** + * @return the wire/string representation of this token type + */ + public String value() { + return value; + } + + /** + * Maps a token_type string (as returned in a token response) to a {@link TokenType}. + * Unknown or null values map to {@link #BEARER}. + * + *

The match is an exact (case-insensitive) comparison against {@code mtls_pop}; the SHR-style + * {@code pop} token type is intentionally NOT treated as {@link #MTLS_POP}, so a non-certificate-bound + * {@code pop} response cannot pass the mTLS Proof-of-Possession fail-closed check. Mirrors MSAL.NET, + * which compares the response {@code token_type} exactly against the requested scheme. + * + * @param tokenType the token_type string from a token response + * @return the corresponding {@link TokenType}, defaulting to {@link #BEARER} + */ + static TokenType fromString(String tokenType) { + if (StringHelper.isBlank(tokenType)) { + return BEARER; + } + + if (MTLS_POP.value.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..964f91fca --- /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 000000000..8be1e5fc8 --- /dev/null +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/MtlsClientCertificateHelperTest.java @@ -0,0 +1,201 @@ +// 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()); + } + + // Regression: a custom IClientCertificate returning a non-base64 x5c entry must surface as a + // MsalClientException(MTLS_POP_ERROR), not a raw IllegalArgumentException from Base64 decoding. + @Test + void buildBindingCertificate_nonBase64Chain_throwsMtlsPopError() { + IClientCertificate badChain = withCertificateChain("this is not base64!!!"); + + MsalClientException ex = assertThrows(MsalClientException.class, () -> + MtlsClientCertificateHelper.buildBindingCertificate(badChain)); + assertEquals(AuthenticationErrorCode.MTLS_POP_ERROR, ex.errorCode()); + } + + @Test + void computeCertificateKeyId_nonBase64Chain_throwsMtlsPopError() { + IClientCertificate badChain = withCertificateChain("also not base64 %%%"); + + MsalClientException ex = assertThrows(MsalClientException.class, () -> + MtlsClientCertificateHelper.computeCertificateKeyId(badChain)); + assertEquals(AuthenticationErrorCode.MTLS_POP_ERROR, ex.errorCode()); + } + + // Stub whose public chain contains the given (possibly invalid) x5c entries; the private key is unused. + private static IClientCertificate withCertificateChain(String... encodedChain) { + return new IClientCertificate() { + @Override + public PrivateKey privateKey() { + return null; + } + + @Override + public String publicCertificateHash() { + return null; + } + + @Override + public List getEncodedPublicKeyCertificateChain() { + return java.util.Arrays.asList(encodedChain); + } + }; + } +} 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..b602b04df --- /dev/null +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/MtlsEndpointHelperTest.java @@ -0,0 +1,197 @@ +// 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.assertNull; +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_azureUsGovCloud_rewritesDomainPreserving() throws Exception { + URL result = MtlsEndpointHelper.deriveMtlsTokenEndpoint( + url("https://login.microsoftonline.us/contoso.onmicrosoft.com/oauth2/v2.0/token")); + + assertEquals("mtlsauth.microsoftonline.us", result.getHost()); + assertEquals("/contoso.onmicrosoft.com/oauth2/v2.0/token", result.getPath()); + } + + @Test + void deriveMtlsTokenEndpoint_currentChinaCloud_rewritesDomainPreserving() throws Exception { + URL result = MtlsEndpointHelper.deriveMtlsTokenEndpoint( + url("https://login.partner.microsoftonline.cn/contoso/oauth2/v2.0/token")); + + assertEquals("mtlsauth.partner.microsoftonline.cn", result.getHost()); + } + + @Test + void deriveMtlsTokenEndpoint_legacyUsGovCloudApiHost_failsFast() { + MsalClientException ex = assertThrows(MsalClientException.class, () -> + MtlsEndpointHelper.deriveMtlsTokenEndpoint( + url("https://login.usgovcloudapi.net/contoso/oauth2/v2.0/token"))); + + assertEquals(AuthenticationErrorCode.MTLS_POP_ERROR, ex.errorCode()); + assertTrue(ex.getMessage().toLowerCase().contains("legacy")); + } + + @Test + void deriveMtlsTokenEndpoint_legacyChinaCloudApiHost_failsFast() { + MsalClientException ex = assertThrows(MsalClientException.class, () -> + MtlsEndpointHelper.deriveMtlsTokenEndpoint( + url("https://login.chinacloudapi.cn/contoso/oauth2/v2.0/token"))); + + assertEquals(AuthenticationErrorCode.MTLS_POP_ERROR, ex.errorCode()); + assertTrue(ex.getMessage().toLowerCase().contains("legacy")); + } + + @Test + void isMtlsPoPUnsupportedCloud_predicate() { + // Supported: public, regional public, Azure Gov, and current national clouds. + assertFalse(MtlsEndpointHelper.isMtlsPoPUnsupportedCloud("login.microsoftonline.com")); + assertFalse(MtlsEndpointHelper.isMtlsPoPUnsupportedCloud("westus.login.microsoft.com")); + assertFalse(MtlsEndpointHelper.isMtlsPoPUnsupportedCloud("login.microsoftonline.us")); + assertFalse(MtlsEndpointHelper.isMtlsPoPUnsupportedCloud("login.partner.microsoftonline.cn")); + assertFalse(MtlsEndpointHelper.isMtlsPoPUnsupportedCloud("login.microsoftonline.de")); + + // Unsupported: only the two legacy sovereign hosts (no mtlsauth.* endpoint). + assertTrue(MtlsEndpointHelper.isMtlsPoPUnsupportedCloud("login.usgovcloudapi.net")); + assertTrue(MtlsEndpointHelper.isMtlsPoPUnsupportedCloud("login.chinacloudapi.cn")); + } + + // Only the two legacy sovereign hosts (which have no mtlsauth.* endpoint) are denied by the predicate; + // every other login.* host — including Azure Gov and the current national clouds — is supported and + // rewritten domain-preserving. Mirrors MSAL.NET's two-host denylist. + @Test + void isMtlsPoPUnsupportedCloud_deniesOnlyLegacyHosts() { + assertTrue(MtlsEndpointHelper.isMtlsPoPUnsupportedCloud("login.usgovcloudapi.net")); + assertTrue(MtlsEndpointHelper.isMtlsPoPUnsupportedCloud("login.chinacloudapi.cn")); + + String[] supportedSovereignHosts = { + "login.partner.microsoftonline.cn", + "login.microsoftonline.de", + "login.microsoftonline.us", + "login.sovcloud-identity.fr", + "login.sovcloud-identity.de", + "login.sovcloud-identity.sg" + }; + for (String host : supportedSovereignHosts) { + assertFalse(MtlsEndpointHelper.isMtlsPoPUnsupportedCloud(host), + host + " must be supported (allowed + domain-preserving rewrite)"); + } + } + + @Test + void deriveMtlsTokenEndpoint_germanCloud_rewritesDomainPreserving() throws Exception { + URL result = MtlsEndpointHelper.deriveMtlsTokenEndpoint( + url("https://login.microsoftonline.de/contoso/oauth2/v2.0/token")); + + assertEquals("mtlsauth.microsoftonline.de", result.getHost()); + } + + // Legacy US host is not a "login." host (it is "login-us."), so it is rejected during host derivation. + @Test + void deriveMtlsTokenEndpoint_legacyUsCloud_failsFast() { + MsalClientException ex = assertThrows(MsalClientException.class, () -> + MtlsEndpointHelper.deriveMtlsTokenEndpoint( + url("https://login-us.microsoftonline.com/contoso/oauth2/v2.0/token"))); + + assertEquals(AuthenticationErrorCode.MTLS_POP_ERROR, ex.errorCode()); + } + + @Test + void deriveMtlsTokenEndpoint_sovereignFranceCloud_rewritesDomainPreserving() throws Exception { + URL result = MtlsEndpointHelper.deriveMtlsTokenEndpoint( + url("https://login.sovcloud-identity.fr/contoso/oauth2/v2.0/token")); + + assertEquals("mtlsauth.sovcloud-identity.fr", result.getHost()); + } + + // A non-"login.*" host must be rejected rather than rewritten to (and its client cert presented at) + // the public mtlsauth endpoint. + @Test + void deriveMtlsTokenEndpoint_nonLoginHost_isRejected() { + MsalClientException ex = assertThrows(MsalClientException.class, () -> + MtlsEndpointHelper.deriveMtlsTokenEndpoint( + url("https://contoso.example.com/contoso/oauth2/v2.0/token"))); + + assertEquals(AuthenticationErrorCode.MTLS_POP_ERROR, ex.errorCode()); + } + + // An unrecognized but "login.*"-shaped host keeps its own domain (login -> mtlsauth) rather than + // collapsing to the public host, so the request stays within its cloud boundary. + @Test + void deriveMtlsHost_unknownLoginHost_preservesDomain() { + assertEquals("mtlsauth.example.com", MtlsEndpointHelper.deriveMtlsHost("login.example.com")); + } + + @Test + void deriveMtlsHost_nonLoginHost_returnsNull() { + assertNull(MtlsEndpointHelper.deriveMtlsHost("contoso.example.com")); + } +} diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/MtlsProofOfPossessionTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/MtlsProofOfPossessionTest.java new file mode 100644 index 000000000..074b37fe3 --- /dev/null +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/MtlsProofOfPossessionTest.java @@ -0,0 +1,298 @@ +// 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.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +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) { + return successResponse(accessToken, "Bearer"); + } + + private static HttpResponse successResponse(String accessToken, String tokenType) { + HashMap values = new HashMap<>(); + values.put("access_token", accessToken); + values.put("token_type", tokenType); + return TestHelper.expectedResponse(HttpStatus.HTTP_OK, TestHelper.getSuccessfulTokenResponse(values)); + } + + @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", "mtls_pop")))) { + + 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 mtlsPop_serverDowngradesToBearer_failsClosed() throws Exception { + ConfidentialClientApplication app = baseCertAppBuilder().build(); + + // ESTS honored the request path but returned a Bearer token_type instead of mtls_pop, so the + // access token is not certificate-bound. MSAL must fail closed rather than mislabel it as MTLS_POP. + try (MockedConstruction mocked = mockConstruction(DefaultHttpClient.class, + (m, ctx) -> when(m.send(any(HttpRequest.class))).thenReturn(successResponse("downgraded-token", "Bearer")))) { + + ExecutionException ex = assertThrows(ExecutionException.class, () -> + app.acquireToken(ClientCredentialParameters.builder(Collections.singleton("https://graph.microsoft.com/.default")) + .mtlsProofOfPossession() + .skipCache(true) + .build()).get()); + + assertInstanceOf(MsalClientException.class, ex.getCause()); + assertEquals(AuthenticationErrorCode.TOKEN_TYPE_MISMATCH, + ((MsalClientException) ex.getCause()).errorCode()); + } + } + + @Test + void bearerCert_backwardCompatible_usesLoginEndpointAndJwtBearerAssertion() throws Exception { + IHttpClient appHttpClient = mock(IHttpClient.class); + 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", "mtls_pop")))) { + + 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"); + } + + @Test + void mtlsPop_serverReturnsShrPopTokenType_failsClosed() throws Exception { + ConfidentialClientApplication app = baseCertAppBuilder().build(); + + // ESTS returned token_type=pop (an SHR/bearer-style PoP), which is NOT a certificate-bound + // mtls_pop token. MSAL must fail closed rather than surface it as MTLS_POP (the "pop" alias must + // not satisfy the mTLS PoP fail-closed check). + try (MockedConstruction mocked = mockConstruction(DefaultHttpClient.class, + (m, ctx) -> when(m.send(any(HttpRequest.class))).thenReturn(successResponse("shr-pop-token", "pop")))) { + + ExecutionException ex = assertThrows(ExecutionException.class, () -> + app.acquireToken(ClientCredentialParameters.builder(Collections.singleton("https://graph.microsoft.com/.default")) + .mtlsProofOfPossession() + .skipCache(true) + .build()).get()); + + assertInstanceOf(MsalClientException.class, ex.getCause()); + assertEquals(AuthenticationErrorCode.TOKEN_TYPE_MISMATCH, + ((MsalClientException) ex.getCause()).errorCode()); + } + } + + @Test + void mtlsPop_cacheHit_restoresTokenTypeAndBindingCertificate() throws Exception { + ConfidentialClientApplication app = baseCertAppBuilder().build(); + + IAuthenticationResult networkResult; + IAuthenticationResult cachedResult; + try (MockedConstruction mocked = mockConstruction(DefaultHttpClient.class, + (m, ctx) -> when(m.send(any(HttpRequest.class))).thenReturn(successResponse("mtls-pop-token", "mtls_pop")))) { + + // First call: the cache is empty, so this goes to the (mocked) network and caches the PoP token. + networkResult = app.acquireToken(mtlsPopCacheableParams()).get(); + // Second call: served from the cache (no additional mTLS network client is constructed). + cachedResult = app.acquireToken(mtlsPopCacheableParams()).get(); + + assertEquals(1, mocked.constructed().size(), + "Second acquireToken must be served from the cache, not the network"); + } + + assertEquals(TokenType.MTLS_POP, networkResult.metadata().tokenType()); + + // The cache hit must report the same PoP metadata rather than defaulting to Bearer / null. + assertEquals(TokenType.MTLS_POP, cachedResult.metadata().tokenType()); + assertNotNull(cachedResult.metadata().bindingCertificate()); + assertEquals(MtlsClientCertificateHelper.computeCertificateKeyId(certificate), + cachedResult.metadata().bindingCertificate().thumbprintSha256()); + } + + private static ClientCredentialParameters mtlsPopCacheableParams() { + return ClientCredentialParameters.builder(Collections.singleton("https://graph.microsoft.com/.default")) + .mtlsProofOfPossession() + .skipCache(false) + .build(); + } +} diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/TestHelper.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/TestHelper.java index 4112dcebd..cadad4ddf 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/TestHelper.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/TestHelper.java @@ -61,7 +61,7 @@ class TestHelper { private static final String successfulResponseFormat = "{\"access_token\":\"%s\",\"id_token\":\"%s\",\"refresh_token\":\"%s\"," + "\"client_id\":\"%s\",\"client_info\":\"%s\"," + "\"refresh_in\": %d,\"expires_on\": %d,\"expires_in\": %d," + - "\"token_type\":\"Bearer\"}"; + "\"token_type\":\"%s\"}"; static final String idTokenFormat = "{\"aud\": \"%s\"," + "\"iss\": \"%s\"," + @@ -167,7 +167,8 @@ static String getSuccessfulTokenResponse(HashMap responseValues) responseValues.getOrDefault("client_info", "eyJ1aWQiOiI1OTdmODZjZC0xM2YzLTQ0YzAtYmVjZS1hMWU3N2JhNDMyMjgiLCJ1dGlkIjoiZjY0NWFkOTItZTM4ZC00ZDFhLWI1MTAtZDFiMDlhNzRhOGNhIn0"), refreshIn, expiresOn, - expiresIn + expiresIn, + responseValues.getOrDefault("token_type", "Bearer") ); } 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