diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 19b6a58c..516e2228 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -138,7 +138,7 @@ MSAL4J supports multiple authentication flows, each with a public `*Parameters` - **Parameters**: `ClientCredentialParameters` - App-only authentication (daemon apps) - **Internal**: `ClientCredentialRequest` → `AcquireTokenByClientCredentialSupplier` - **Key Classes**: `IClientCredential`, `ClientSecret`, `ClientCertificate`, `ClientAssertion` -- **mTLS Proof-of-Possession (PoP)**: Opt in with `ClientCredentialParameters.builder(...).mtlsProofOfPossession()` to obtain an mTLS-bound PoP token (`token_type=mtls_pop`). The app's SN/I `IClientCertificate` is presented as the client TLS certificate to a rewritten `mtlsauth.*` endpoint (no `client_assertion` on the direct path) instead of signing an x5c assertion (the existing SNI+Bearer path is unchanged). Requires a tenanted authority and an ESTS allow-listed resource; region is optional (global `mtlsauth.microsoft.com` when absent). The result exposes `metadata().tokenType()` and `metadata().bindingCertificate()` (public material only — x5c chain + `x5t#S256`). +- **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)** diff --git a/changelog.txt b/changelog.txt index f7487561..963e0a4a 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,6 +1,7 @@ Version 1.25.0 ============= - Add SN/I certificate support over mTLS Proof-of-Possession (PoP) for confidential clients, presenting the certificate as the client TLS cert to obtain an mTLS-bound token +- Add 2-leg Federated Identity Credential (FIC) over mTLS Proof-of-Possession: bind an assertion-authenticated app to a certificate via mtlsBindingCertificate(...) (client_assertion_type=jwt-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 index 66ecdb9f..8a536ce8 100644 --- a/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/MtlsPopIT.java +++ b/msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/MtlsPopIT.java @@ -34,9 +34,11 @@ * handshake to the token endpoint (no {@code private_key_jwt} / x5c client assertion on the direct * path). * - *

The primary scenario is covered: + *

Both scenarios from the plan are covered: *

* *

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

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

Leg 2: the agent app authenticates with {@code client_assertion = T1} + * ({@code client_assertion_type = ...:jwt-pop}) and presents the binding certificate on the + * TLS handshake via {@code mtlsBindingCertificate(...)} → the final token (T2) is also cert-bound. + */ + @Test + void acquireTokenFic_TwoLeg_MtlsPop_BothLegsBound() throws Exception { + // LEG 1 — SNI cert (blueprint) mints a federated credential over mTLS PoP. + ConfidentialClientApplication blueprint = ConfidentialClientApplication.builder( + TestConstants.AGENTIC_BLUEPRINT_CLIENT_ID, certificate) + .authority(AGENTIC_AUTHORITY) + .azureRegion(TestConstants.AGENTIC_AZURE_REGION) + .build(); + + IAuthenticationResult leg1 = blueprint.acquireToken(ClientCredentialParameters + .builder(Collections.singleton(TestConstants.AGENTIC_TOKEN_EXCHANGE_SCOPE)) + .fmiPath(TestConstants.AGENTIC_AGENT_APP_ID) + .mtlsProofOfPossession() + .build()) + .get(); + + assertNotNull(leg1, "Leg 1 result should not be null"); + assertNotNull(leg1.accessToken(), "Leg 1 (T1) access token should not be null"); + assertEquals(TokenType.MTLS_POP, leg1.metadata().tokenType(), + "Leg 1 must itself be an mTLS-PoP (cert-bound) credential"); + assertNotNull(leg1.metadata().bindingCertificate(), "Leg 1 must expose its binding certificate"); + assertEquals(expectedLabThumbprint(), leg1.metadata().bindingCertificate().thumbprintSha256(), + "Leg 1 binding cert must be the lab SNI cert"); + + String t1 = leg1.accessToken(); + + // LEG 2 — agent app consumes T1 as a jwt-pop client_assertion AND presents the binding cert. + ConfidentialClientApplication agent = ConfidentialClientApplication.builder( + TestConstants.AGENTIC_AGENT_APP_ID, ClientCredentialFactory.createFromClientAssertion(t1)) + .authority(AGENTIC_AUTHORITY) + .mtlsBindingCertificate(certificate) + .build(); + + IAuthenticationResult leg2 = agent.acquireToken(ClientCredentialParameters + .builder(Collections.singleton(TestConstants.AGENTIC_GRAPH_SCOPE)) // allow-listed resource + .mtlsProofOfPossession() + .build()) + .get(); + + assertNotNull(leg2, "Leg 2 result should not be null"); + assertNotNull(leg2.accessToken(), "Leg 2 (T2) access token should not be null"); + assertFalse(leg2.accessToken().isEmpty(), "Leg 2 (T2) access token should not be empty"); + assertEquals(TokenType.MTLS_POP, leg2.metadata().tokenType(), + "Leg 2 must also be an mTLS-PoP (cert-bound) token"); + assertNotNull(leg2.metadata().bindingCertificate(), "Leg 2 must expose its binding certificate"); + assertEquals(expectedLabThumbprint(), leg2.metadata().bindingCertificate().thumbprintSha256(), + "Final token (T2) must be bound to the Leg-1 certificate thumbprint"); + } + private void assertMtlsPopResult(IAuthenticationResult result, String expectedThumbprint) { assertNotNull(result, "Auth result should not be null"); assertNotNull(result.accessToken(), "Access token should not be null"); diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AssertionRequestOptions.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AssertionRequestOptions.java index e84acb36..ec79e981 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/ClientAssertion.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ClientAssertion.java index 8a6ef769..82c6b821 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 31adfd4b..b84b7029 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 @@ -298,7 +298,9 @@ public ClientCredentialParametersBuilder fmiPath(String fmiPath) { * Requests a mutual-TLS Proof-of-Possession (mTLS PoP) token instead of a Bearer token. *

* When set, the app's client certificate (a Subject-Name/Issuer cert configured on the - * {@link ConfidentialClientApplication}) is presented as the client TLS certificate in the mutual-TLS + * {@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}. *

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 50df4466..23d5e2f1 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/MtlsClientCertificateHelper.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/MtlsClientCertificateHelper.java index f6d2a1b7..71aa320a 100644 --- a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/MtlsClientCertificateHelper.java +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/MtlsClientCertificateHelper.java @@ -28,9 +28,10 @@ * mutual-TLS (mTLS) handshake to the token endpoint, and to describe that certificate as a public * {@link BindingCertificate} on the result. * - *

The source certificate is resolved from the request/app credential (direct SN/I cert or FIC Leg 1). - * Only public material is ever surfaced; the private key is used in place (through its own provider) - * and is never exported or copied into a key store. + *

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 is used in place (through its own + * provider) and is never exported or copied into a key store. */ final class MtlsClientCertificateHelper { @@ -41,7 +42,8 @@ private MtlsClientCertificateHelper() { /** * Resolves the certificate to present as the client TLS certificate for an mTLS PoP request: the - * request/app authentication credential when it is a certificate (direct SN/I cert or FIC Leg 1). + * 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 */ @@ -56,9 +58,14 @@ static IClientCertificate resolveBindingCertificate(ConfidentialClientApplicatio return (IClientCertificate) credential; } + if (application.mtlsBindingCertificate() != null) { + return application.mtlsBindingCertificate(); + } + throw new MsalClientException( "mTLS Proof-of-Possession requires a client certificate. Configure the application with a " + - "certificate credential.", + "certificate credential, or set mtlsBindingCertificate(...) when authenticating with a " + + "client assertion.", AuthenticationErrorCode.MTLS_POP_ERROR); } diff --git a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/TokenRequestExecutor.java b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/TokenRequestExecutor.java index 7f9e3b2c..aae2f0a4 100644 --- a/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/TokenRequestExecutor.java +++ b/msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/TokenRequestExecutor.java @@ -167,6 +167,7 @@ private void addCredentialToRequest(Map queryParameters, } else if (credentialToUse instanceof ClientAssertion) { // For client assertion, add client_assertion and client_assertion_type parameters ClientAssertion clientAssertion = (ClientAssertion) credentialToUse; + String assertion; if (clientAssertion.isContextAware()) { // Build assertion context with client assertion FMI path if available String clientAssertionFmiPath = null; @@ -183,12 +184,18 @@ 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 @@ -213,8 +220,19 @@ 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); } /** @@ -228,7 +246,8 @@ private boolean isMtlsProofOfPossession() { /** * Resolves the certificate to present as the client TLS certificate for an mTLS PoP request: the - * request/app authentication credential when it is a certificate (direct SN/I cert or FIC Leg 1). + * 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 diff --git a/msal4j-sdk/src/samples/confidential-client/ClientCredentialMtlsProofOfPossession.java b/msal4j-sdk/src/samples/confidential-client/ClientCredentialMtlsProofOfPossession.java index 964f91fc..0ccbd425 100644 --- a/msal4j-sdk/src/samples/confidential-client/ClientCredentialMtlsProofOfPossession.java +++ b/msal4j-sdk/src/samples/confidential-client/ClientCredentialMtlsProofOfPossession.java @@ -2,6 +2,7 @@ // 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; @@ -51,6 +52,7 @@ public static void main(String args[]) throws Exception { IClientCertificate sniCert = loadSniCertificate(); directSniCertMtlsPop(sniCert); + twoLegFicMtlsPop(sniCert); } /** @@ -84,6 +86,65 @@ private static void directSniCertMtlsPop(IClientCertificate sniCert) throws Exce System.out.println("Bound to cert x5t#S256: " + binding.thumbprintSha256()); } + /** + * Scenario 2 — Developer-orchestrated 2-leg Federated Identity Credential (FIC) over mTLS PoP. + * Both legs are mTLS-PoP requests, and the final token is bound to the Leg-1 certificate. + * + *

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

Leg 2: the consuming app authenticates with {@code client_assertion = T1} + * ({@code client_assertion_type = ...:jwt-pop}) and presents a binding certificate on the TLS + * handshake via {@code mtlsBindingCertificate(...)}. The final token (T2) is also cert-bound. + */ + private static void twoLegFicMtlsPop(IClientCertificate sniCert) throws Exception { + // Exchange audience is CALLER-SUPPLIED (not SDK-hardcoded): + // generic S2S FIC : "api://AzureADTokenExchange" + // FMI variant : "api://AzureFMITokenExchange" (client id "urn:microsoft:identity:fmi"), + // driven by fmiPath(...) + Set exchangeScope = Collections.singleton("api://AzureADTokenExchange/.default"); + + // LEG 1 — SN/I cert (blueprint/RMA) mints a federated credential over mTLS PoP. + ConfidentialClientApplication rma = + ConfidentialClientApplication + .builder("", sniCert) + .authority(AUTHORITY) // tenanted + // .azureRegion("westus") // OPTIONAL + .build(); + + IAuthenticationResult leg1 = rma.acquireToken( + ClientCredentialParameters + .builder(exchangeScope) + .fmiPath("SomeFmiPath/FmiCredentialPath") // FMI variant only; omit for generic S2S FIC + .mtlsProofOfPossession() // Leg 1 over mTLS PoP -> mints the cnf + .build()) + .join(); + + String t1 = leg1.accessToken(); // cert-bound federated credential + System.out.println("Leg 1 token type: " + leg1.metadata().tokenType()); // MTLS_POP + + // LEG 2 — consuming app authenticates with T1 AND presents the binding cert on TLS. + // The final resource must be an ESTS allow-listed resource (e.g. MS Graph / Key Vault). + ConfidentialClientApplication agent = + ConfidentialClientApplication + .builder("", ClientCredentialFactory.createFromClientAssertion(t1)) + .authority(AUTHORITY) + .mtlsBindingCertificate(sniCert) // binding cert for the mTLS handshake + .build(); + + IAuthenticationResult leg2 = agent.acquireToken( + ClientCredentialParameters + .builder(SCOPE) // allow-listed resource + .mtlsProofOfPossession() // Leg 2 also over mTLS PoP + .build()) + .join(); + + System.out.println("Leg 2 access token: " + leg2.accessToken()); + System.out.println("Leg 2 token type: " + leg2.metadata().tokenType()); // MTLS_POP + System.out.println("Final token bound to cert x5t#S256: " + + leg2.metadata().bindingCertificate().thumbprintSha256()); + } + /** * Load the SN/I certificate. In a real app this typically comes from a secure store (e.g. the OS * certificate store, a PKCS#12 file, or Azure Key Vault). The certificate object holds everything diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/MtlsClientCertificateHelperTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/MtlsClientCertificateHelperTest.java index 0ab3c09e..810ab4e0 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/MtlsClientCertificateHelperTest.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/MtlsClientCertificateHelperTest.java @@ -145,6 +145,19 @@ void resolveBindingCertificate_certCredential_returnsThatCertificate() throws Ex assertEquals(certificate, MtlsClientCertificateHelper.resolveBindingCertificate(app, null)); } + @Test + void resolveBindingCertificate_assertionWithBindingCert_returnsBindingCert() throws Exception { + ConfidentialClientApplication app = ConfidentialClientApplication.builder("clientId", + ClientCredentialFactory.createFromClientAssertion(TestHelper.signedAssertion)) + .authority(AUTHORITY) + .mtlsBindingCertificate(certificate) + .instanceDiscovery(false) + .validateAuthority(false) + .build(); + + assertEquals(certificate, MtlsClientCertificateHelper.resolveBindingCertificate(app, null)); + } + @Test void resolveBindingCertificate_assertionWithoutBindingCert_throwsMtlsPopError() throws Exception { ConfidentialClientApplication app = ConfidentialClientApplication.builder("clientId", diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/MtlsProofOfPossessionTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/MtlsProofOfPossessionTest.java index 95c9225a..22052fc2 100644 --- a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/MtlsProofOfPossessionTest.java +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/MtlsProofOfPossessionTest.java @@ -10,9 +10,12 @@ import org.mockito.MockedConstruction; import java.io.InputStream; +import java.io.UnsupportedEncodingException; +import java.net.URLDecoder; import java.util.Collections; import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.concurrent.AbstractExecutorService; import java.util.concurrent.ExecutorService; import java.util.concurrent.TimeUnit; @@ -34,6 +37,8 @@ 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; @@ -95,6 +100,28 @@ private static HttpResponse successResponse(String accessToken) { return TestHelper.expectedResponse(HttpStatus.HTTP_OK, TestHelper.getSuccessfulTokenResponse(values)); } + /** + * Parses an {@code application/x-www-form-urlencoded} request body into decoded key/value pairs so + * assertions can compare exact values instead of brittle substring matches against the encoded body. + */ + private static Map parseFormBody(String body) { + Map params = new HashMap<>(); + if (body == null || body.isEmpty()) { + return params; + } + for (String pair : body.split("&")) { + int eq = pair.indexOf('='); + String key = eq >= 0 ? pair.substring(0, eq) : pair; + String value = eq >= 0 ? pair.substring(eq + 1) : ""; + try { + params.put(URLDecoder.decode(key, "UTF-8"), URLDecoder.decode(value, "UTF-8")); + } catch (UnsupportedEncodingException e) { + throw new IllegalStateException(e); + } + } + return params; + } + @Test void directSniCert_mtlsPop_targetsMtlsEndpoint_omitsClientAssertion_returnsMtlsPopToken() throws Exception { ConfidentialClientApplication app = baseCertAppBuilder().build(); @@ -132,6 +159,49 @@ void directSniCert_mtlsPop_targetsMtlsEndpoint_omitsClientAssertion_returnsMtlsP result.metadata().bindingCertificate().thumbprintSha256()); } + @Test + void ficLeg2_assertionWithBindingCert_usesJwtPopAssertionType() throws Exception { + String leg1Token = TestHelper.signedAssertion; + + ConfidentialClientApplication app = ConfidentialClientApplication.builder("agentClientId", + ClientCredentialFactory.createFromClientAssertion(leg1Token)) + .authority(AUTHORITY) + .mtlsBindingCertificate(certificate) + .instanceDiscovery(false) + .validateAuthority(false) + .executorService(SAME_THREAD_EXECUTOR) + .httpClient(mock(IHttpClient.class)) + .build(); + + HttpRequest captured; + IAuthenticationResult result; + try (MockedConstruction mocked = mockConstruction(DefaultHttpClient.class, + (m, ctx) -> when(m.send(any(HttpRequest.class))).thenReturn(successResponse("leg2-token")))) { + + result = app.acquireToken(ClientCredentialParameters.builder(Collections.singleton("https://graph.microsoft.com/.default")) + .mtlsProofOfPossession() + .skipCache(true) + .build()).get(); + + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(HttpRequest.class); + verify(mocked.constructed().get(0)).send(requestCaptor.capture()); + captured = requestCaptor.getValue(); + } + + assertEquals("mtlsauth.microsoft.com", captured.url().getHost()); + + Map params = parseFormBody(captured.body()); + assertEquals(leg1Token, params.get("client_assertion"), + "Leg 2 must authenticate with the Leg-1 token"); + assertEquals(JWT_POP_ASSERTION_TYPE, params.get("client_assertion_type"), + "Leg 2 must use the jwt-pop client_assertion_type"); + assertEquals("mtls_pop", params.get("token_type"), "Leg 2 must request 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);