diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 67c276105..516e22282 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -25,6 +25,7 @@ - Acquire access tokens for protected APIs (Microsoft Graph, custom APIs) - Token caching and automatic refresh - Support for various authentication flows (interactive, silent, client credentials, on-behalf-of, device code, managed identity) +- mTLS Proof-of-Possession (PoP) tokens for confidential clients using an SN/I certificate (see Client Credentials) - Multi-cloud and B2C support ### Repository Structure @@ -137,6 +138,8 @@ MSAL4J supports multiple authentication flows, each with a public `*Parameters` - **Parameters**: `ClientCredentialParameters` - App-only authentication (daemon apps) - **Internal**: `ClientCredentialRequest` → `AcquireTokenByClientCredentialSupplier` - **Key Classes**: `IClientCredential`, `ClientSecret`, `ClientCertificate`, `ClientAssertion` +- **mTLS Proof-of-Possession (PoP)**: Opt in with `ClientCredentialParameters.builder(...).mtlsProofOfPossession()` to obtain an mTLS-bound PoP token (`token_type=mtls_pop`). The app's SN/I `IClientCertificate` is presented as the client TLS certificate to a rewritten `mtlsauth.*` endpoint (no `client_assertion` on the direct path) instead of signing an x5c assertion (the existing SNI+Bearer path is unchanged). Requires a tenanted authority and an ESTS allow-listed resource; region is optional (global `mtlsauth.microsoft.com` when absent). The result exposes `metadata().tokenType()` and `metadata().bindingCertificate()` (public material only — x5c chain + `x5t#S256`). For 2-leg FIC over mTLS PoP, attach the binding cert to an assertion-authenticated app with `ConfidentialClientApplication.Builder.mtlsBindingCertificate(IClientCertificate)`. + - **Key Classes**: `TokenType`, `BindingCertificate`, `MtlsClientCertificateHelper`, `MtlsEndpointHelper`; internal `AuthScheme`. Not supported: US Gov / China clouds, and user-scoped (`user_fic`) FIC over mTLS. **On-Behalf-Of (OBO)** - **Public API**: `acquireToken(OnBehalfOfParameters)` diff --git a/changelog.txt b/changelog.txt index 78dcfcf3a..451a6506b 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,5 +1,6 @@ Version 1.25.0 ============= +- Add SN/I certificate support over mTLS Proof-of-Possession (PoP) for confidential clients, presenting the certificate as the client TLS cert to obtain an mTLS-bound token; includes the 2-leg Federated Identity Credential (FIC) flow where both legs are mTLS PoP - Add Federated Managed Identity (FMI) support for client credentials flow (#1025) - Add User Federated Identity Credential (user_fic) grant type support (#1026) - Add MSAL client metadata headers to IMDS managed identity requests (#1024) diff --git a/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/MtlsPopIT.java b/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/MtlsPopIT.java new file mode 100644 index 000000000..b2917fc81 --- /dev/null +++ b/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/MtlsPopIT.java @@ -0,0 +1,252 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j; + +import com.microsoft.aad.msal4j.labapi.KeyVaultRegistry; +import com.microsoft.aad.msal4j.labapi.KeyVaultSecretsProvider; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; + +import java.io.IOException; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.NoSuchProviderException; +import java.security.PrivateKey; +import java.security.UnrecoverableKeyException; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.util.Collections; + +import static com.microsoft.aad.msal4j.TestConstants.KEYVAULT_DEFAULT_SCOPE; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +/** + * End-to-end integration tests for SN/I certificate over mTLS Proof-of-Possession (PoP). + * + *

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

Both scenarios from the plan are covered: + *

+ * + *

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

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

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

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

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

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

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

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

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

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

+ * Only the certificate's public material is ever surfaced on the result (see + * {@link AuthenticationResultMetadata#bindingCertificate()}); the private key is never exposed. + * + * @param val the binding certificate + * @return instance of the Builder on which method was called + */ + public ConfidentialClientApplication.Builder mtlsBindingCertificate(IClientCertificate val) { + validateNotNull("mtlsBindingCertificate", val); + this.mtlsBindingCertificate = val; + + return self(); + } + ///

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

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

+ * + *

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

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

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

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

Requirements / notes: + *

+ * + *

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

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

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

Leg 2: the consuming app authenticates with {@code client_assertion = T1} + * ({@code client_assertion_type = ...:jwt-pop}) and presents a binding certificate on the TLS + * handshake via {@code mtlsBindingCertificate(...)}. The final token (T2) is also cert-bound. + */ + private static void twoLegFicMtlsPop(IClientCertificate sniCert) throws Exception { + // Exchange audience is CALLER-SUPPLIED (not SDK-hardcoded): + // generic S2S FIC : "api://AzureADTokenExchange" + // FMI variant : "api://AzureFMITokenExchange" (client id "urn:microsoft:identity:fmi"), + // driven by fmiPath(...) + Set exchangeScope = Collections.singleton("api://AzureADTokenExchange/.default"); + + // LEG 1 — SN/I cert (blueprint/RMA) mints a federated credential over mTLS PoP. + ConfidentialClientApplication rma = + ConfidentialClientApplication + .builder("", sniCert) + .authority(AUTHORITY) // tenanted + // .azureRegion("westus") // OPTIONAL + .build(); + + IAuthenticationResult leg1 = rma.acquireToken( + ClientCredentialParameters + .builder(exchangeScope) + .fmiPath("SomeFmiPath/FmiCredentialPath") // FMI variant only; omit for generic S2S FIC + .mtlsProofOfPossession() // Leg 1 over mTLS PoP -> mints the cnf + .build()) + .join(); + + String t1 = leg1.accessToken(); // cert-bound federated credential + System.out.println("Leg 1 token type: " + leg1.metadata().tokenType()); // MTLS_POP + + // LEG 2 — consuming app authenticates with T1 AND presents the binding cert on TLS. + // The final resource must be an ESTS allow-listed resource (e.g. MS Graph / Key Vault). + ConfidentialClientApplication agent = + ConfidentialClientApplication + .builder("", ClientCredentialFactory.createFromClientAssertion(t1)) + .authority(AUTHORITY) + .mtlsBindingCertificate(sniCert) // binding cert for the mTLS handshake + .build(); + + IAuthenticationResult leg2 = agent.acquireToken( + ClientCredentialParameters + .builder(SCOPE) // allow-listed resource + .mtlsProofOfPossession() // Leg 2 also over mTLS PoP + .build()) + .join(); + + System.out.println("Leg 2 access token: " + leg2.accessToken()); + System.out.println("Leg 2 token type: " + leg2.metadata().tokenType()); // MTLS_POP + System.out.println("Final token bound to cert x5t#S256: " + + leg2.metadata().bindingCertificate().thumbprintSha256()); + } + + /** + * Load the SN/I certificate. In a real app this typically comes from a secure store (e.g. the OS + * certificate store, a PKCS#12 file, or Azure Key Vault). The certificate object holds everything + * MSAL needs to drive the mTLS handshake (private key + chain). + */ + private static IClientCertificate loadSniCertificate() { + PrivateKey privateKey = null; // load from your secure store + X509Certificate publicCertificate = null; // load from your secure store + return ClientCredentialFactory.createFromCertificate(privateKey, publicCertificate); + } +} diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/MtlsClientCertificateHelperTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/MtlsClientCertificateHelperTest.java new file mode 100644 index 000000000..bdce92164 --- /dev/null +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/MtlsClientCertificateHelperTest.java @@ -0,0 +1,121 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; + +import javax.net.ssl.SSLSocketFactory; +import java.io.InputStream; +import java.security.MessageDigest; +import java.security.cert.X509Certificate; +import java.util.Base64; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class MtlsClientCertificateHelperTest { + + private static final String PKCS12_RESOURCE = "/mtls_test_cert.p12"; + private static final String PKCS12_PASSWORD = "password"; + private static final String AUTHORITY = "https://login.microsoftonline.com/contoso.onmicrosoft.com/"; + + private IClientCertificate certificate; + + @BeforeAll + void setUp() throws Exception { + try (InputStream pkcs12 = getClass().getResourceAsStream(PKCS12_RESOURCE)) { + assertNotNull(pkcs12, "Test PKCS12 resource " + PKCS12_RESOURCE + " should be present"); + certificate = ClientCredentialFactory.createFromCertificate(pkcs12, PKCS12_PASSWORD); + } + } + + @Test + void createMtlsSocketFactory_buildsFactoryFromCertificate() { + SSLSocketFactory factory = MtlsClientCertificateHelper.createMtlsSocketFactory(certificate); + assertNotNull(factory); + } + + @Test + void createMtlsSocketFactory_nullCertificate_throwsMtlsPopError() { + MsalClientException ex = assertThrows(MsalClientException.class, () -> + MtlsClientCertificateHelper.createMtlsSocketFactory(null)); + assertEquals(AuthenticationErrorCode.MTLS_POP_ERROR, ex.errorCode()); + } + + @Test + void computeThumbprintSha256_matchesBase64UrlSha256OfLeafDer() throws Exception { + String encodedLeaf = certificate.getEncodedPublicKeyCertificateChain().get(0); + byte[] der = Base64.getDecoder().decode(encodedLeaf); + X509Certificate leaf = (X509Certificate) java.security.cert.CertificateFactory + .getInstance("X.509") + .generateCertificate(new java.io.ByteArrayInputStream(der)); + + String expected = Base64.getUrlEncoder().withoutPadding() + .encodeToString(MessageDigest.getInstance("SHA-256").digest(leaf.getEncoded())); + + assertEquals(expected, MtlsClientCertificateHelper.computeThumbprintSha256(leaf)); + assertEquals(expected, MtlsClientCertificateHelper.computeCertificateKeyId(certificate)); + } + + @Test + void buildBindingCertificate_exposesPublicMaterialOnly() { + BindingCertificate binding = MtlsClientCertificateHelper.buildBindingCertificate(certificate); + + assertNotNull(binding); + List chain = binding.certificateChain(); + assertFalse(chain.isEmpty()); + assertEquals(MtlsClientCertificateHelper.computeCertificateKeyId(certificate), binding.thumbprintSha256()); + + // BindingCertificate must never surface a private key. It only exposes the chain and thumbprint. + for (java.lang.reflect.Method m : BindingCertificate.class.getMethods()) { + assertFalse(m.getName().toLowerCase().contains("private"), + "BindingCertificate must not expose private key material via " + m.getName()); + } + } + + @Test + void resolveBindingCertificate_certCredential_returnsThatCertificate() throws Exception { + ConfidentialClientApplication app = ConfidentialClientApplication.builder("clientId", certificate) + .authority(AUTHORITY) + .instanceDiscovery(false) + .validateAuthority(false) + .build(); + + assertEquals(certificate, MtlsClientCertificateHelper.resolveBindingCertificate(app, null)); + } + + @Test + void resolveBindingCertificate_assertionWithBindingCert_returnsBindingCert() throws Exception { + ConfidentialClientApplication app = ConfidentialClientApplication.builder("clientId", + ClientCredentialFactory.createFromClientAssertion(TestHelper.signedAssertion)) + .authority(AUTHORITY) + .mtlsBindingCertificate(certificate) + .instanceDiscovery(false) + .validateAuthority(false) + .build(); + + assertEquals(certificate, MtlsClientCertificateHelper.resolveBindingCertificate(app, null)); + } + + @Test + void resolveBindingCertificate_assertionWithoutBindingCert_throwsMtlsPopError() throws Exception { + ConfidentialClientApplication app = ConfidentialClientApplication.builder("clientId", + ClientCredentialFactory.createFromClientAssertion(TestHelper.signedAssertion)) + .authority(AUTHORITY) + .instanceDiscovery(false) + .validateAuthority(false) + .build(); + + MsalClientException ex = assertThrows(MsalClientException.class, () -> + MtlsClientCertificateHelper.resolveBindingCertificate(app, null)); + assertEquals(AuthenticationErrorCode.MTLS_POP_ERROR, ex.errorCode()); + } +} diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/MtlsEndpointHelperTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/MtlsEndpointHelperTest.java new file mode 100644 index 000000000..00d2fb336 --- /dev/null +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/MtlsEndpointHelperTest.java @@ -0,0 +1,103 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; + +import java.net.URL; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class MtlsEndpointHelperTest { + + private static URL url(String spec) throws Exception { + return new URL(spec); + } + + @Test + void deriveMtlsTokenEndpoint_noRegion_usesGlobalMtlsHost() throws Exception { + URL result = MtlsEndpointHelper.deriveMtlsTokenEndpoint( + url("https://login.microsoftonline.com/contoso.onmicrosoft.com/oauth2/v2.0/token")); + + assertEquals("mtlsauth.microsoft.com", result.getHost()); + assertEquals("/contoso.onmicrosoft.com/oauth2/v2.0/token", result.getPath()); + assertEquals("https", result.getProtocol()); + } + + @Test + void deriveMtlsTokenEndpoint_regionalHost_preservesRegionPrefix() throws Exception { + URL result = MtlsEndpointHelper.deriveMtlsTokenEndpoint( + url("https://westus.login.microsoft.com/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/oauth2/v2.0/token")); + + assertEquals("westus.mtlsauth.microsoft.com", result.getHost()); + assertEquals("/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/oauth2/v2.0/token", result.getPath()); + } + + @Test + void deriveMtlsHost_variants() { + assertEquals("mtlsauth.microsoft.com", MtlsEndpointHelper.deriveMtlsHost("login.microsoftonline.com")); + assertEquals("mtlsauth.microsoft.com", MtlsEndpointHelper.deriveMtlsHost("login.microsoft.com")); + assertEquals("mtlsauth.microsoft.com", MtlsEndpointHelper.deriveMtlsHost("login.windows.net")); + assertEquals("eastus.mtlsauth.microsoft.com", MtlsEndpointHelper.deriveMtlsHost("eastus.login.microsoft.com")); + } + + @Test + void extractTenant_returnsFirstPathSegment() throws Exception { + assertEquals("mytenant", MtlsEndpointHelper.extractTenant( + url("https://login.microsoftonline.com/mytenant/oauth2/v2.0/token"))); + } + + @Test + void deriveMtlsTokenEndpoint_commonAuthority_isRejected() { + MsalClientException ex = assertThrows(MsalClientException.class, () -> + MtlsEndpointHelper.deriveMtlsTokenEndpoint( + url("https://login.microsoftonline.com/common/oauth2/v2.0/token"))); + + assertEquals(AuthenticationErrorCode.MTLS_POP_ERROR, ex.errorCode()); + assertTrue(ex.getMessage().toLowerCase().contains("tenanted")); + } + + @Test + void deriveMtlsTokenEndpoint_organizationsAuthority_isRejected() { + MsalClientException ex = assertThrows(MsalClientException.class, () -> + MtlsEndpointHelper.deriveMtlsTokenEndpoint( + url("https://login.microsoftonline.com/organizations/oauth2/v2.0/token"))); + + assertEquals(AuthenticationErrorCode.MTLS_POP_ERROR, ex.errorCode()); + } + + @Test + void deriveMtlsTokenEndpoint_usGovCloud_failsFast() { + MsalClientException ex = assertThrows(MsalClientException.class, () -> + MtlsEndpointHelper.deriveMtlsTokenEndpoint( + url("https://login.microsoftonline.us/contoso.onmicrosoft.com/oauth2/v2.0/token"))); + + assertEquals(AuthenticationErrorCode.MTLS_POP_ERROR, ex.errorCode()); + assertTrue(ex.getMessage().toLowerCase().contains("cloud")); + } + + @Test + void deriveMtlsTokenEndpoint_chinaCloud_failsFast() { + MsalClientException ex = assertThrows(MsalClientException.class, () -> + MtlsEndpointHelper.deriveMtlsTokenEndpoint( + url("https://login.partner.microsoftonline.cn/contoso/oauth2/v2.0/token"))); + + assertEquals(AuthenticationErrorCode.MTLS_POP_ERROR, ex.errorCode()); + } + + @Test + void isMtlsPoPUnsupportedCloud_predicate() { + assertFalse(MtlsEndpointHelper.isMtlsPoPUnsupportedCloud("login.microsoftonline.com")); + assertFalse(MtlsEndpointHelper.isMtlsPoPUnsupportedCloud("westus.login.microsoft.com")); + + assertTrue(MtlsEndpointHelper.isMtlsPoPUnsupportedCloud("login.microsoftonline.us")); + assertTrue(MtlsEndpointHelper.isMtlsPoPUnsupportedCloud("login.partner.microsoftonline.cn")); + assertTrue(MtlsEndpointHelper.isMtlsPoPUnsupportedCloud("login.chinacloudapi.cn")); + } +} diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/MtlsProofOfPossessionTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/MtlsProofOfPossessionTest.java new file mode 100644 index 000000000..5f605175e --- /dev/null +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/MtlsProofOfPossessionTest.java @@ -0,0 +1,256 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.microsoft.aad.msal4j; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedConstruction; + +import java.io.InputStream; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.concurrent.AbstractExecutorService; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockConstruction; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class MtlsProofOfPossessionTest { + + private static final String PKCS12_RESOURCE = "/mtls_test_cert.p12"; + private static final String PKCS12_PASSWORD = "password"; + private static final String AUTHORITY = "https://login.microsoftonline.com/contoso.onmicrosoft.com/"; + private static final String JWT_POP_ASSERTION_TYPE = + "urn:ietf:params:oauth:client-assertion-type:jwt-pop"; + + private IClientCertificate certificate; + + // Runs token acquisition on the calling thread so Mockito's thread-local mockConstruction intercepts + // the mTLS DefaultHttpClient (which msal4j otherwise builds on a ForkJoinPool worker thread). + private static final ExecutorService SAME_THREAD_EXECUTOR = new SameThreadExecutorService(); + + private static final class SameThreadExecutorService extends AbstractExecutorService { + @Override + public void execute(Runnable command) { + command.run(); + } + + @Override + public void shutdown() { + } + + @Override + public List shutdownNow() { + return Collections.emptyList(); + } + + @Override + public boolean isShutdown() { + return false; + } + + @Override + public boolean isTerminated() { + return false; + } + + @Override + public boolean awaitTermination(long timeout, TimeUnit unit) { + return true; + } + } + + @BeforeAll + void setUp() throws Exception { + try (InputStream pkcs12 = getClass().getResourceAsStream(PKCS12_RESOURCE)) { + assertNotNull(pkcs12, "Test PKCS12 resource " + PKCS12_RESOURCE + " should be present"); + certificate = ClientCredentialFactory.createFromCertificate(pkcs12, PKCS12_PASSWORD); + } + } + + private ConfidentialClientApplication.Builder baseCertAppBuilder() throws Exception { + return ConfidentialClientApplication.builder("clientId", certificate) + .authority(AUTHORITY) + .instanceDiscovery(false) + .validateAuthority(false) + .executorService(SAME_THREAD_EXECUTOR) + .httpClient(mock(IHttpClient.class)); + } + + private static HttpResponse successResponse(String accessToken) { + HashMap values = new HashMap<>(); + values.put("access_token", accessToken); + return TestHelper.expectedResponse(HttpStatus.HTTP_OK, TestHelper.getSuccessfulTokenResponse(values)); + } + + @Test + void directSniCert_mtlsPop_targetsMtlsEndpoint_omitsClientAssertion_returnsMtlsPopToken() throws Exception { + ConfidentialClientApplication app = baseCertAppBuilder().build(); + + HttpRequest captured; + IAuthenticationResult result; + try (MockedConstruction mocked = mockConstruction(DefaultHttpClient.class, + (m, ctx) -> when(m.send(any(HttpRequest.class))).thenReturn(successResponse("mtls-pop-token")))) { + + result = app.acquireToken(ClientCredentialParameters.builder(Collections.singleton("https://graph.microsoft.com/.default")) + .mtlsProofOfPossession() + .skipCache(true) + .build()).get(); + + assertEquals(1, mocked.constructed().size(), "Exactly one mTLS DefaultHttpClient should be built"); + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(HttpRequest.class); + verify(mocked.constructed().get(0)).send(requestCaptor.capture()); + captured = requestCaptor.getValue(); + } + + // Endpoint: host rewritten to the global mTLS host, tenanted token path preserved. + assertEquals("mtlsauth.microsoft.com", captured.url().getHost()); + assertTrue(captured.url().getPath().contains("/contoso.onmicrosoft.com/oauth2/v2.0/token")); + + // Body: token_type=mtls_pop, and NO client_assertion (ESTS resolves SN/I trust from the TLS cert). + String body = captured.body(); + assertTrue(body.contains("token_type=mtls_pop"), "body should request token_type=mtls_pop"); + assertFalse(body.contains("client_assertion"), "vanilla SN/I mTLS PoP must not send a client_assertion"); + assertFalse(body.contains("req_cnf"), "mTLS PoP must not send req_cnf"); + + // Result: cert-bound PoP token, binding cert surfaced as public material only. + assertEquals(TokenType.MTLS_POP, result.metadata().tokenType()); + assertNotNull(result.metadata().bindingCertificate()); + assertEquals(MtlsClientCertificateHelper.computeCertificateKeyId(certificate), + result.metadata().bindingCertificate().thumbprintSha256()); + } + + @Test + void ficLeg2_assertionWithBindingCert_usesJwtPopAssertionType() throws Exception { + String leg1Token = TestHelper.signedAssertion; + + ConfidentialClientApplication app = ConfidentialClientApplication.builder("agentClientId", + ClientCredentialFactory.createFromClientAssertion(leg1Token)) + .authority(AUTHORITY) + .mtlsBindingCertificate(certificate) + .instanceDiscovery(false) + .validateAuthority(false) + .executorService(SAME_THREAD_EXECUTOR) + .httpClient(mock(IHttpClient.class)) + .build(); + + HttpRequest captured; + IAuthenticationResult result; + try (MockedConstruction mocked = mockConstruction(DefaultHttpClient.class, + (m, ctx) -> when(m.send(any(HttpRequest.class))).thenReturn(successResponse("leg2-token")))) { + + result = app.acquireToken(ClientCredentialParameters.builder(Collections.singleton("https://graph.microsoft.com/.default")) + .mtlsProofOfPossession() + .skipCache(true) + .build()).get(); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(HttpRequest.class); + verify(mocked.constructed().get(0)).send(requestCaptor.capture()); + captured = requestCaptor.getValue(); + } + + assertEquals("mtlsauth.microsoft.com", captured.url().getHost()); + + String body = captured.body(); + assertTrue(body.contains("client_assertion=" + leg1Token), "Leg 2 must authenticate with the Leg-1 token"); + assertTrue(body.contains("jwt-pop"), "Leg 2 must use the jwt-pop client_assertion_type"); + assertTrue(body.contains("token_type=mtls_pop")); + + assertEquals(TokenType.MTLS_POP, result.metadata().tokenType()); + assertEquals(MtlsClientCertificateHelper.computeCertificateKeyId(certificate), + result.metadata().bindingCertificate().thumbprintSha256()); + } + + @Test + void bearerCert_backwardCompatible_usesLoginEndpointAndJwtBearerAssertion() throws Exception { + IHttpClient appHttpClient = mock(IHttpClient.class); + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(HttpRequest.class); + when(appHttpClient.send(any(HttpRequest.class))).thenReturn(successResponse("bearer-token")); + + ConfidentialClientApplication app = ConfidentialClientApplication.builder("clientId", certificate) + .authority(AUTHORITY) + .instanceDiscovery(false) + .validateAuthority(false) + .executorService(SAME_THREAD_EXECUTOR) + .httpClient(appHttpClient) + .build(); + + IAuthenticationResult result = app.acquireToken( + ClientCredentialParameters.builder(Collections.singleton("https://graph.microsoft.com/.default")) + .skipCache(true) + .build()).get(); + + verify(appHttpClient).send(requestCaptor.capture()); + HttpRequest captured = requestCaptor.getValue(); + + // Unchanged Bearer path: standard login endpoint, x5c client assertion (jwt-bearer), no mTLS markers. + assertEquals("login.microsoftonline.com", captured.url().getHost()); + String body = captured.body(); + assertTrue(body.contains("client_assertion"), "Bearer SN/I path still sends a client_assertion"); + assertTrue(body.contains("jwt-bearer"), "Bearer path uses the jwt-bearer client_assertion_type"); + assertFalse(body.contains("token_type=mtls_pop"), "Bearer path must not request mTLS PoP"); + + assertEquals(TokenType.BEARER, result.metadata().tokenType()); + } + + @Test + void cacheKey_isolatesBearerFromMtlsPopAndByCertificate() { + ClientCredentialParameters bearer = ClientCredentialParameters + .builder(Collections.singleton("scope")).build(); + + ClientCredentialParameters mtls = ClientCredentialParameters + .builder(Collections.singleton("scope")).mtlsProofOfPossession().build(); + mtls.bindingCertificateKeyId(MtlsClientCertificateHelper.computeCertificateKeyId(certificate)); + + ClientCredentialParameters mtlsOtherCert = ClientCredentialParameters + .builder(Collections.singleton("scope")).mtlsProofOfPossession().build(); + mtlsOtherCert.bindingCertificateKeyId("a-different-cert-key-id"); + + String bearerHash = bearer.computeExtCacheKeyHash(); + String mtlsHash = mtls.computeExtCacheKeyHash(); + String mtlsOtherHash = mtlsOtherCert.computeExtCacheKeyHash(); + + assertNotEquals(bearerHash, mtlsHash, "Bearer and mTLS PoP tokens must not alias in the cache"); + assertNotEquals(mtlsHash, mtlsOtherHash, "mTLS PoP tokens bound to different certs must not alias"); + } + + @Test + void mtlsPop_composesWithFmiPath_forFicLeg1() throws Exception { + ConfidentialClientApplication app = baseCertAppBuilder().build(); + + HttpRequest captured; + try (MockedConstruction mocked = mockConstruction(DefaultHttpClient.class, + (m, ctx) -> when(m.send(any(HttpRequest.class))).thenReturn(successResponse("fic-leg1-token")))) { + + app.acquireToken(ClientCredentialParameters.builder(Collections.singleton("api://AzureADTokenExchange/.default")) + .fmiPath("SomeFmiPath/CredentialPath") + .mtlsProofOfPossession() + .skipCache(true) + .build()).get(); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(HttpRequest.class); + verify(mocked.constructed().get(0)).send(requestCaptor.capture()); + captured = requestCaptor.getValue(); + } + + assertEquals("mtlsauth.microsoft.com", captured.url().getHost()); + String body = captured.body(); + assertTrue(body.contains("token_type=mtls_pop")); + assertTrue(body.contains("fmi_path"), "FIC Leg 1 should still send fmi_path alongside mTLS PoP"); + assertFalse(body.contains("client_assertion"), "FIC Leg 1 (cert) must not send a client_assertion"); + } +} diff --git a/msal4j-sdk/src/test/resources/mtls_test_cert.p12 b/msal4j-sdk/src/test/resources/mtls_test_cert.p12 new file mode 100644 index 000000000..1ad9e1a47 Binary files /dev/null and b/msal4j-sdk/src/test/resources/mtls_test_cert.p12 differ