Add FIC Leg 2 over mTLS Proof-of-Possession (stacked on #1040) - #1041
Add FIC Leg 2 over mTLS Proof-of-Possession (stacked on #1040)#1041Robbie-Microsoft wants to merge 16 commits into
Conversation
Adds the 2-leg Federated Identity Credential (FIC) flow over mTLS PoP: an assertion-authenticated confidential client can bind to a certificate via ConfidentialClientApplication.Builder.mtlsBindingCertificate(...), presenting the binding certificate on the TLS handshake and authenticating the federated assertion with client_assertion_type=...:jwt-pop. Both legs are cert-bound and the final token is bound to the Leg-1 certificate thumbprint. Stacked on #1040 (core direct SN/I -> mTLS PoP); retarget base to dev after #1040 merges. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
This PR extends MSAL4J’s confidential-client mTLS Proof-of-Possession (PoP) support to cover 2-leg Federated Identity Credential (FIC) “Leg 2” scenarios, where the app authenticates using a client assertion while still binding the resulting token to a certificate presented on the mTLS handshake.
Changes:
- Add
ConfidentialClientApplication.Builder.mtlsBindingCertificate(IClientCertificate)to supply a binding certificate for assertion-authenticated mTLS PoP requests. - Update token request construction to use
client_assertion_type=...:jwt-popwhenmtlsProofOfPossession()is set with a client assertion credential. - Re-introduce unit/integration tests and expand the sample/docs to demonstrate the 2-leg FIC over mTLS PoP flow.
Reviewed changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ConfidentialClientApplication.java | Adds builder option + getter for mtlsBindingCertificate used to present a cert on the mTLS handshake for assertion-authenticated apps. |
| msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/TokenRequestExecutor.java | Switches client-assertion requests to jwt-pop assertion type under mTLS PoP and threads PoP context to assertion construction. |
| msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/MtlsClientCertificateHelper.java | Extends binding-certificate resolution to fall back to mtlsBindingCertificate for assertion-authenticated scenarios. |
| msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ClientCredentialParameters.java | Updates mTLS PoP Javadoc to mention mtlsBindingCertificate(...) for assertion-authenticated apps. |
| msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ClientAssertion.java | Introduces ASSERTION_TYPE_JWT_POP constant for client_assertion_type. |
| msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AssertionRequestOptions.java | Adds a PoP flag to the context passed to context-aware assertion providers. |
| msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/MtlsProofOfPossessionTest.java | Re-adds unit coverage for “FIC Leg 2” behavior (jwt-pop + binding cert). |
| msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/MtlsClientCertificateHelperTest.java | Adds unit test to ensure binding cert resolves correctly for assertion-authenticated apps. |
| msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/MtlsPopIT.java | Re-adds CI-only E2E test covering the 2-leg FIC mTLS PoP flow. |
| msal4j-sdk/src/samples/confidential-client/ClientCredentialMtlsProofOfPossession.java | Adds an end-to-end sample scenario showing developer-orchestrated 2-leg FIC over mTLS PoP. |
| changelog.txt | Notes the new 2-leg FIC over mTLS PoP capability. |
| .github/copilot-instructions.md | Updates internal repo guidance to mention mtlsBindingCertificate(...) for 2-leg FIC over mTLS PoP. |
The Unit Tests build's Credential Scanner (Guardian) gate flags the checked-in PKCS12 test cert (mtls_test_cert.p12), failing the build. It is a self-signed, test-only certificate (CN=msal4j-mtls-test) with no production secret, so allowlist it in build/credscan-exclude.json. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Pulls in the mTLS PoP socket-factory JDK 8 fix and the ClientCredentialParameters thread-safety/comment fixes from #1040. Resolved copilot-instructions.md to keep the FIC Leg 2 sentence alongside the Key Classes markdown split. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The FIC Leg 2 mTLS PoP test now parses the url-encoded body and asserts exact decoded values for client_assertion, client_assertion_type (via the JWT_POP_ASSERTION_TYPE constant, previously unused), and token_type, instead of brittle substring checks. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…-leg2 # Conflicts: # msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/MtlsClientCertificateHelper.java
…-leg2 # Conflicts: # msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/MtlsPopIT.java
Pulls in the fail-closed mTLS PoP token_type validation. Resolves conflicts keeping both features: the FIC Leg 2 tests and the parent's downgrade handling. Updates the ficLeg2 unit test mock to return token_type=mtls_pop (now that the response type is validated), and routes both FIC e2e legs through acquireMtlsPopOrSkipOnDowngrade so a slice downgrade on either leg is treated as inconclusive rather than a hard failure. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
resolveBindingCertificate only falls back to the app's mtlsBindingCertificate when the authenticating credential is an IClientAssertion (FIC Leg 2). A non-assertion credential (e.g. client secret) with a stray binding cert now fails with MTLS_POP_ERROR instead of silently binding. Also makes the ConfidentialClientApplication.mtlsBindingCertificate field private (only read via its getter). Adds a secret+bindingCert negative test. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…name the X509 matrix Ports the rigor from the merged MSAL.NET reference (PR #6100) to the SNI mTLS PoP integration tests and docs: - Add reusable MtlsResourceCaller.callResourceWithMtlsToken(url, token, cert): presents the binding certificate on the TLS handshake (reusing createMtlsSocketFactory), sends Authorization: mtls_pop <token>, GETs the resource, returns the status; drains and never logs the external body. The headline PoP test now calls mtlstb.graph.microsoft.com and requires HTTP 200, proving the bound token is genuinely usable (not just well-formed). - Fix the @BeforeAll escape hatch: assertNotNull on the lab cert hard-failed the whole class off-CI; use Assumptions.assumeTrue so it skips instead (missing lab cert is an environment condition, not a defect). - Establish the Credential_X509_Output_<Pop|Bearer> naming: rename the PoP tests into the family and add a standalone Credential_X509_Output_Bearer (asserts BEARER + null binding cert) instead of relying only on the cache-isolation side effect. - Remove a redundant assertNotNull(thumbprint) immediately before assertEquals. - Add docs/mtls-pop.md documenting the correct public API (mtlsProofOfPossession(), result.metadata().tokenType()/bindingCertificate(), region optional/global fallback, tenanted authority, Bearer omission is mTLS-specific). Written fresh; not copied from the stale #1021 branch. - Fix the sample's missing ClientCredentialFactory import. The Fic E2E resource call reuses MtlsResourceCaller in the FIC follow-up (#1041). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Bring the parent SNI branch (rginsburg/sni-mtls-pop @ d22059a) into the FIC branch with --no-ff. Resolve the shared MtlsPopIT.java conflict by adopting the SNI hardening for the direct-SNI cells while preserving the FIC two-leg test and its skip-on-downgrade hatch untouched: - Adopt: @BeforeAll Assumptions.assumeTrue env-absence skip; the Credential_X509_Output_{Pop,Bearer,Pop_CacheHit,Pop_And_Bearer_CacheIsolated} matrix; the MtlsResourceCaller resource-call assertEquals(200) on the headline Pop cell; Bearer on the regional endpoint; direct mtls_pop assertions (no skip). - Preserve: acquireTokenFic_TwoLeg_MtlsPop_BothLegsBound and the retained acquireMtlsPopOrSkipOnDowngrade helper it depends on (de-hatching FIC is Task 2). New parent-only files (docs/mtls-pop.md, MtlsResourceCaller.java) and the sample import merged cleanly. Integration-test sources compile. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Bring the base-resynced parent (48fd92d: SN/I mTLS PoP + upstream dev through 56b9c6b) into the FIC Leg 2 branch. ClientCredentialParameters and TokenRequestExecutor auto-merged: dev's ExtendedCacheKey refactor / clientClaims support and this branch's FIC jwt-pop assertion path coexist. Only changelog.txt conflicted; resolved by keeping the 2-leg FIC entry under the unreleased 1.25.1 section alongside the SN/I mTLS PoP and dev entries. MtlsPopIT.java took this branch's version unchanged, so the FIC test (acquireTokenFic_TwoLeg_MtlsPop_ BothLegsBound) and its acquireMtlsPopOrSkipOnDowngrade hatch are preserved intact (de-hatch is Task 2). mvn -pl msal4j-sdk test = 405 green; integrationtest compiles. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…anch Brings in MtlsResourceCaller error-body surfacing + Credential_X509_Output_Pop assertion message. FIC test acquireTokenFic_TwoLeg_MtlsPop_BothLegsBound and its skip gate are untouched. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Pulls in MtlsResourceCaller Accept: application/json fix. FIC test acquireTokenFic_TwoLeg_MtlsPop_BothLegsBound and its acquireMtlsPopOrSkipOnDowngrade hatch untouched. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
| * @param val the binding certificate | ||
| * @return instance of the Builder on which method was called | ||
| */ | ||
| public ConfidentialClientApplication.Builder mtlsBindingCertificate(IClientCertificate val) { |
There was a problem hiding this comment.
[Blocker/API] The assertion and binding certificate are configured through unrelated channels, so they can rotate independently and MSAL cannot prove they represent the same generation. This API also cannot consume #1059’s IMtlsBindingContext because it requires an exposed PrivateKey. Please replace it with an atomic callback result containing the assertion and exact reusable binding context.
| clientAssertionFmiPath); | ||
| clientAssertionFmiPath, | ||
| mtlsPoP); | ||
|
|
There was a problem hiding this comment.
[Blocker] This derives both client_assertion_type=jwt-pop and output token_type=mtls_pop from one flag. These are independent dimensions: a binding-context-backed jwt-pop assertion can authenticate the client while the requested output token remains Bearer. Derive assertion type from the assertion’s binding material and output token type from the requested authentication scheme.
| @@ -183,6 +183,7 @@ private void addCredentialToRequest(Map<String, String> queryParameters, | |||
| } else if (credentialToUse instanceof ClientAssertion) { | |||
| // For client assertion, add client_assertion and client_assertion_type parameters | |||
| ClientAssertion clientAssertion = (ClientAssertion) credentialToUse; | |||
There was a problem hiding this comment.
[Blocker] The request is sent to the resolved global or regional mtlsauth endpoint, but the assertion callback receives the normal login.* token endpoint. A provider using this value as aud will mint an assertion for a different endpoint than the one receiving it. Please pass the exact final endpoint used by OAuthHttpRequest.
| private final String clientId; | ||
| private final String tokenEndpoint; | ||
| private final String clientAssertionFmiPath; | ||
| private final boolean proofOfPossession; |
There was a problem hiding this comment.
[P1/API] A proofOfPossession boolean does not identify the certificate generation MSAL will put on the wire. The provider should atomically return a ClientSignedAssertion containing the assertion and IMtlsBindingContext, rather than returning only a string while relying on separately configured mutable application state.
| @@ -183,6 +183,7 @@ private void addCredentialToRequest(Map<String, String> queryParameters, | |||
| } else if (credentialToUse instanceof ClientAssertion) { | |||
| // For client assertion, add client_assertion and client_assertion_type parameters | |||
| ClientAssertion clientAssertion = (ClientAssertion) credentialToUse; | |||
There was a problem hiding this comment.
[Blocker] Binding resolution accepts any public IClientAssertion, but request serialization handles only the package-private concrete ClientAssertion. A valid custom implementation can therefore resolve a binding and reach the endpoint without any client_assertion parameters. Please process the public interface consistently or introduce a separate public capability interface.
| } | ||
| throw e; | ||
| } | ||
| } |
There was a problem hiding this comment.
[Acceptance blocker] This is the only two-leg FIC E2E, but TOKEN_TYPE_MISMATCH aborts it as inconclusive. CI can therefore pass without proving that the service honored the requested binding. Run this against a deterministic supported cell and fail on every downgrade.
|
|
||
| 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(), |
There was a problem hiding this comment.
[Acceptance blocker] Comparing locally generated metadata to the configured lab certificate does not prove protocol continuity. Decode T1 and T2 and require both cnf.x5t#S256 values to match the exact returned binding context’s key ID, then call the resource with T2 and that context. Add no-cert, wrong-scheme, wrong-cert, A→B→A and persistent-cache cases.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 12 changed files in this pull request and generated 1 comment.
Suppressed comments (4)
msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/ConfidentialClientApplication.java:165
- The API documentation currently implies that this option works with any non-certificate credential, but
resolveBindingCertificatedeliberately accepts it only when the effective credential is anIClientAssertion; a client secret with this option fails withMTLS_POP_ERROR. Document the assertion-only constraint so callers are not led toward a configuration the implementation rejects.
* This is required only when the application authenticates with a credential that is <b>not</b>
* 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
msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/TokenRequestExecutor.java:204
- The new
mtlsPoPvalue passed to context-aware assertion callbacks is not covered: the added FIC test uses a static assertion, while the existing callback tests only exercise bearer requests. Add an mTLS assertion-callback test that verifiesoptions.proofOfPossession()istrueand that the resulting request usesclient_assertion_type=jwt-pop; otherwise this public callback contract could regress unnoticed.
AssertionRequestOptions options = new AssertionRequestOptions(
application.clientId(),
tokenEndpoint,
clientAssertionFmiPath,
mtlsPoP);
msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/MtlsPopIT.java:45
- Adding the FIC scenario makes the later class-level “App/tenant requirement” paragraph inaccurate: it still says only the MSI-team allow-listed app/tenant can obtain mTLS PoP tokens and that any other app is rejected, while this new test uses the agentic blueprint in a different tenant. Qualify that requirement as applying to the direct-SNI cells so the test prerequisites do not contradict the new scenario.
* <li><b>2-leg FIC over mTLS PoP</b> — both legs are mTLS-PoP requests and the final token is
* bound to the Leg-1 certificate thumbprint.</li>
msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/MtlsPopIT.java:289
- These assertions do not verify that T2 is actually bound to this certificate:
bindingCertificate()is populated locally fromresolvedBindingCertificateafter checking only the response'stoken_type, so the thumbprint comparison will pass without inspecting or exercising the token'scnf. The direct-SNI E2E test proves binding by calling the mTLS-enabled resource with the token and certificate; add an equivalent validation for Leg 2 (or otherwise validate T2'scnf) so this test can detect an incorrectly bound server response.
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");
| * 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() { |
Summary
Follow-up to #1040. Adds 2-leg Federated Identity Credential (FIC) over mTLS Proof-of-Possession (PoP): an app that authenticates with a federated client assertion can still bind the resulting token to a certificate. The assertion is sent with
client_assertion_type=...:jwt-popand the binding certificate is presented on the mTLS handshake, so the final token is bound to the Leg-1 certificate thumbprint.Public API
ConfidentialClientApplication.Builder.mtlsBindingCertificate(IClientCertificate)Internal changes
TokenRequestExecutor): under mTLS PoP with an assertion credential, the assertion is sent withclient_assertion_type=...:jwt-popand the binding cert is presented on the mutual-TLS handshake.MtlsClientCertificateHelper.resolveBindingCertificatefalls back to the configuredmtlsBindingCertificate(...)only when the credential is anIClientAssertion(FIC Leg 2); a non-assertion credential (e.g. a client secret) with a stray binding cert fails withMTLS_POP_ERROR.AssertionRequestOptionscarries the PoP flag through to assertion construction; adds theClientAssertion.ASSERTION_TYPE_JWT_POPconstant.Testing
ficLeg2_assertionWithBindingCert_usesJwtPopAssertionType(MtlsProofOfPossessionTest);resolveBindingCertificatepositive (assertion+cert) and negative (secret+cert →MTLS_POP_ERROR) tests (MtlsClientCertificateHelperTest). Suite green (385 tests).acquireTokenFic_TwoLeg_MtlsPop_BothLegsBound(MtlsPopIT, CI-only — needs the lab SN/I cert + an ESTS allow-listed resource).Out of scope
Unchanged from #1040: user-scoped (
user_fic) FIC over mTLS PoP, managed-identity first-leg mTLS, and the two legacy sovereign hosts.