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
* 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
+ * 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();
+ }
+
/// 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 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