-
Notifications
You must be signed in to change notification settings - Fork 160
Add FIC Leg 2 over mTLS Proof-of-Possession (stacked on #1040) #1041
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: rginsburg/sni-mtls-pop
Are you sure you want to change the base?
Changes from all commits
664593a
7a707cb
38e77e9
26b0e1a
f89163c
7bf1e56
cb3f2d7
387d71d
c76a124
73d50b6
e83266e
227a04c
9718ed2
89ad3b2
15c0705
7e93cbf
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -19,6 +19,7 @@ | |
| import java.security.cert.CertificateException; | ||
| import java.security.cert.X509Certificate; | ||
| import java.util.Collections; | ||
| import java.util.concurrent.ExecutionException; | ||
|
|
||
| import static com.microsoft.aad.msal4j.TestConstants.AGENTIC_GRAPH_SCOPE; | ||
| import static com.microsoft.aad.msal4j.TestConstants.KEYVAULT_DEFAULT_SCOPE; | ||
|
|
@@ -37,9 +38,11 @@ | |
| * handshake to the token endpoint (no {@code private_key_jwt} / x5c client assertion on the direct | ||
| * path). | ||
| * | ||
| * <p>The primary scenario is covered: | ||
| * <p>Both scenarios from the plan are covered: | ||
| * <ul> | ||
| * <li><b>Direct SNI cert → mTLS PoP</b> (client credentials), global and regional endpoints.</li> | ||
| * <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> | ||
| * </ul> | ||
| * | ||
| * <p><b>Testability gate (SME note A):</b> ESTS gates mTLS PoP on the <i>final resource audience</i>, | ||
|
|
@@ -75,6 +78,10 @@ class MtlsPopIT { | |
| private static final String MTLS_GRAPH_RESOURCE = | ||
| "https://mtlstb.graph.microsoft.com/v1.0/applications?$top=1"; | ||
|
|
||
| // FIC (two-leg S2S) authority: the agentic blueprint/agent apps live in the AGENTIC tenant. | ||
| private static final String AGENTIC_AUTHORITY = | ||
| "https://login.microsoftonline.com/" + TestConstants.AGENTIC_TENANT_ID + "/"; | ||
|
|
||
| private PrivateKey privateKey; | ||
| private X509Certificate publicCertificate; | ||
| private IClientCertificate certificate; | ||
|
|
@@ -223,6 +230,85 @@ void Credential_X509_Output_Pop_And_Bearer_CacheIsolated() throws Exception { | |
| "Bearer and mTLS-PoP tokens must occupy separate cache entries"); | ||
| } | ||
|
|
||
| /** | ||
| * 2-leg FIC over mTLS PoP — <b>both legs</b> are mTLS-PoP requests and the final token is bound to | ||
| * the Leg-1 certificate thumbprint (locked contract item 12). | ||
| * | ||
| * <p>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}). | ||
| * | ||
| * <p>Leg 2: the agent app authenticates with {@code client_assertion = T1} | ||
| * ({@code client_assertion_type = ...:jwt-pop}) <i>and</i> 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 = acquireMtlsPopOrSkipOnDowngrade(blueprint, ClientCredentialParameters | ||
| .builder(Collections.singleton(TestConstants.AGENTIC_TOKEN_EXCHANGE_SCOPE)) | ||
| .fmiPath(TestConstants.AGENTIC_AGENT_APP_ID) | ||
| .mtlsProofOfPossession() | ||
| .build()); | ||
|
|
||
| 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 = acquireMtlsPopOrSkipOnDowngrade(agent, ClientCredentialParameters | ||
| .builder(Collections.singleton(TestConstants.AGENTIC_GRAPH_SCOPE)) // allow-listed resource | ||
| .mtlsProofOfPossession() | ||
| .build()); | ||
|
|
||
| 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"); | ||
| } | ||
|
|
||
| // ESTS's mTLS PoP test slice is a known intermittent token_type downgrader. When it returns a | ||
| // non-mtls_pop token the access token is not certificate-bound, and MSAL now fails closed with | ||
| // TOKEN_TYPE_MISMATCH. Treat that specific outcome as inconclusive (skip) rather than a hard failure, | ||
| // mirroring MSAL .NET's ExecuteOrInconclusiveOnTokenTypeMismatchAsync. This hatch is retained ONLY for | ||
| // the FIC two-leg test above; the direct-SNI cells assert mtls_pop directly (de-hatching FIC is Task 2). | ||
| private static IAuthenticationResult acquireMtlsPopOrSkipOnDowngrade( | ||
| ConfidentialClientApplication cca, ClientCredentialParameters parameters) throws Exception { | ||
| try { | ||
| return cca.acquireToken(parameters).get(); | ||
| } catch (ExecutionException e) { | ||
| if (e.getCause() instanceof MsalClientException | ||
| && AuthenticationErrorCode.TOKEN_TYPE_MISMATCH.equals( | ||
| ((MsalClientException) e.getCause()).errorCode())) { | ||
| Assumptions.abort("ESTS returned a non-mtls_pop token_type (downgrade); treating as " | ||
| + "inconclusive: " + e.getCause().getMessage()); | ||
| } | ||
| throw e; | ||
| } | ||
| } | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Acceptance blocker] This is the only two-leg FIC E2E, but |
||
|
|
||
| private void assertMtlsPopResult(IAuthenticationResult result, String expectedThumbprint) { | ||
| assertNotNull(result, "Auth result should not be null"); | ||
| assertNotNull(result.accessToken(), "Access token should not be null"); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -15,11 +15,17 @@ public final class AssertionRequestOptions { | |
| private final String clientId; | ||
| private final String tokenEndpoint; | ||
| private final String clientAssertionFmiPath; | ||
| private final boolean proofOfPossession; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [P1/API] A |
||
|
|
||
| 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; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -21,6 +21,7 @@ public class ConfidentialClientApplication extends AbstractClientApplicationBase | |
|
|
||
| IClientCredential clientCredential; | ||
| private boolean sendX5c; | ||
| private 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<Builder> { | ||
|
|
||
| private IClientCredential clientCredential; | ||
|
|
||
| private boolean sendX5c = true; | ||
|
|
||
| private IClientCertificate mtlsBindingCertificate; | ||
|
|
||
| private Function<AppTokenProviderParameters, CompletableFuture<TokenProviderResult>> 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()}). | ||
| * <p> | ||
| * 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 | ||
| * 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. | ||
| * <p> | ||
| * 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) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [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 |
||
| validateNotNull("mtlsBindingCertificate", val); | ||
| this.mtlsBindingCertificate = val; | ||
|
|
||
| return self(); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// 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 | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[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#S256values 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.