Skip to content

Commit 683e54e

Browse files
Harden SNI mTLS PoP E2E: prove real resource usability, skip off-CI, 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>
1 parent d576db6 commit 683e54e

4 files changed

Lines changed: 210 additions & 14 deletions

File tree

msal4j-sdk/docs/mtls-pop.md

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
# SN/I certificate over mTLS Proof-of-Possession (PoP)
2+
3+
MSAL4J lets a confidential-client application present its **Subject-Name/Issuer (SN/I) certificate as
4+
the client TLS certificate** during the mutual-TLS handshake to the token endpoint. Entra ID (ESTS)
5+
then returns an access token that is **cryptographically bound to that certificate**
6+
(`token_type=mtls_pop`, bound via `cnf` / `x5t#S256`), instead of a plain Bearer token.
7+
8+
The credential is exactly the same certificate you already use for SN/I authentication — only the
9+
mechanism changes: instead of signing a `private_key_jwt` (x5c) client assertion, the certificate is
10+
presented on the TLS handshake.
11+
12+
> mTLS PoP is a **confidential-client** feature. It is distinct from the broker-based Signed-HTTP-Request
13+
> (SHR) PoP used by public clients (`token_type=pop`), which is unaffected.
14+
15+
## Enabling it
16+
17+
Opt in per request with `ClientCredentialParameters.Builder.mtlsProofOfPossession()`:
18+
19+
```java
20+
ConfidentialClientApplication cca =
21+
ConfidentialClientApplication.builder(CLIENT_ID, sniCertificate)
22+
.authority("https://login.microsoftonline.com/<tenant>/") // tenanted authority required
23+
// .azureRegion("westus") // OPTIONAL — omit for the global mtlsauth.microsoft.com endpoint
24+
.build();
25+
26+
ClientCredentialParameters parameters =
27+
ClientCredentialParameters
28+
.builder(Collections.singleton("https://graph.microsoft.com/.default"))
29+
.mtlsProofOfPossession() // request an mTLS-bound PoP token
30+
.build();
31+
32+
IAuthenticationResult result = cca.acquireToken(parameters).join();
33+
34+
result.metadata().tokenType(); // TokenType.MTLS_POP
35+
result.metadata().bindingCertificate(); // public material only: x5c chain + x5t#S256 thumbprint
36+
```
37+
38+
The resulting token is bound to the certificate. To call a protected resource, present the **same
39+
certificate** on the TLS handshake and send the token with the `mtls_pop` authorization scheme:
40+
41+
```
42+
Authorization: mtls_pop <access_token>
43+
```
44+
45+
`BindingCertificate` exposes public material only (the `x5c` chain and the SHA-256 thumbprint); the
46+
private key is never exported — it is used in place through its own provider.
47+
48+
## Bearer vs. mTLS PoP
49+
50+
Without `mtlsProofOfPossession()`, the SN/I certificate behaves exactly as before: it **signs and sends
51+
a `private_key_jwt` client assertion**, and the result is a `TokenType.BEARER` token with no binding
52+
certificate. Omitting the client assertion (and presenting the certificate on the TLS handshake
53+
instead) is **specific to the mTLS PoP path**; the legacy SNI + Bearer flow is unchanged.
54+
55+
Bearer and mTLS-PoP tokens for the same scope are cached separately (keyed on
56+
`{token_type + certificate key id}`), so enabling PoP never aliases an existing Bearer entry.
57+
58+
## Requirements and notes
59+
60+
- **Tenanted authority is required.** `/common` and `/organizations` are rejected on the mTLS PoP path.
61+
- **Region is optional.** Omit `azureRegion(...)` to use the global `mtlsauth.microsoft.com` endpoint
62+
(production-ready via ESTS-R regional failover), or set a region to target
63+
`<region>.mtlsauth.microsoft.com` (recommended when you know your region).
64+
- **Resource audience must be allow-listed.** ESTS gates mTLS PoP on the **final resource audience**,
65+
which must be an ESTS mTLS-PoP allow-listed resource (for example Azure Key Vault or Microsoft Graph),
66+
not the client app itself.
67+
- **Cloud support.** Azure Public, Azure Government, and the current national clouds are supported (the
68+
`login.*` authority host is rewritten to the matching `mtlsauth.*` host). Two legacy sovereign hosts
69+
that have no `mtlsauth.*` endpoint — `login.usgovcloudapi.net` and `login.chinacloudapi.cn` — are not
70+
supported and fail fast.
71+
72+
See also the runnable sample:
73+
[`src/samples/confidential-client/ClientCredentialMtlsProofOfPossession.java`](../src/samples/confidential-client/ClientCredentialMtlsProofOfPossession.java),
74+
and https://aka.ms/msal4j-pop.

msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/MtlsPopIT.java

Lines changed: 60 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,13 @@
2121
import java.util.Collections;
2222
import java.util.concurrent.ExecutionException;
2323

24+
import static com.microsoft.aad.msal4j.TestConstants.AGENTIC_GRAPH_SCOPE;
2425
import static com.microsoft.aad.msal4j.TestConstants.KEYVAULT_DEFAULT_SCOPE;
2526
import static org.junit.jupiter.api.Assertions.assertEquals;
2627
import static org.junit.jupiter.api.Assertions.assertFalse;
2728
import static org.junit.jupiter.api.Assertions.assertNotEquals;
2829
import static org.junit.jupiter.api.Assertions.assertNotNull;
30+
import static org.junit.jupiter.api.Assertions.assertNull;
2931

3032
/**
3133
* End-to-end integration tests for SN/I certificate over mTLS Proof-of-Possession (PoP).
@@ -66,6 +68,11 @@ class MtlsPopIT {
6668
"https://login.microsoftonline.com/bea21ebe-8b64-4d06-9f6d-6a889b120a7c";
6769
private static final String TEST_SLICE_REGION = "westus3";
6870

71+
// mTLS-enabled MS Graph host (NOT plain graph.microsoft.com, which does not perform the client-cert
72+
// handshake). A token bound to the presented certificate is accepted here with HTTP 200.
73+
private static final String MTLS_GRAPH_RESOURCE =
74+
"https://mtlstb.graph.microsoft.com/v1.0/applications?$top=1";
75+
6976
private PrivateKey privateKey;
7077
private X509Certificate publicCertificate;
7178
private IClientCertificate certificate;
@@ -79,39 +86,79 @@ void init() throws KeyStoreException, NoSuchProviderException, IOException,
7986
privateKey = (PrivateKey) keystore.getKey(KeyVaultSecretsProvider.CERTIFICATE_ALIAS, null);
8087
publicCertificate = (X509Certificate) keystore.getCertificate(KeyVaultSecretsProvider.CERTIFICATE_ALIAS);
8188

82-
assertNotNull(privateKey, "Lab private key not found. Ensure the lab cert is installed.");
83-
assertNotNull(publicCertificate, "Lab certificate not found. Ensure the lab cert is installed.");
89+
// These are live-lab E2E tests (like the other *IT classes). Off-CI the lab SN/I cert is absent
90+
// from the OS keystore, so SKIP the whole class rather than hard-failing it: a missing lab cert
91+
// is an environment condition, not a product defect.
92+
Assumptions.assumeTrue(privateKey != null && publicCertificate != null,
93+
"Lab SN/I certificate not available (alias '" + KeyVaultSecretsProvider.CERTIFICATE_ALIAS
94+
+ "'); skipping mTLS PoP E2E. Expected off-CI.");
8495

8596
certificate = ClientCredentialFactory.createFromCertificate(privateKey, publicCertificate);
8697
}
8798

8899
/**
89-
* Direct SNI cert &rarr; mTLS PoP with <b>no region</b> (exercises the global
90-
* {@code mtlsauth.microsoft.com} endpoint). The lab cert is presented as the client TLS certificate;
91-
* the request carries {@code token_type=mtls_pop} and <b>no</b> client assertion. Requests an
92-
* allow-listed resource (Key Vault) so ESTS issues the bound token.
100+
* <b>X509 SNI cert &rarr; mTLS PoP, proven end to end.</b> Canonical matrix cell
101+
* {@code Credential_X509_Output_Pop}: with <b>no region</b> configured (global
102+
* {@code mtlsauth.microsoft.com} endpoint), the lab cert is presented as the client TLS certificate
103+
* and the request carries {@code token_type=mtls_pop} with <b>no</b> client assertion.
104+
*
105+
* <p>Beyond asserting the token is issued and bound to the cert, this proves the bound token is
106+
* <b>actually usable</b>: it is presented (with the binding cert on the TLS handshake) to an
107+
* mTLS-enabled resource, which must return HTTP 200. A 401/403 would mean the certificate was not
108+
* presented or the {@code mtls_pop} scheme was wrong. The MS Graph scope is used because Graph is an
109+
* ESTS mTLS-PoP allow-listed resource whose {@code mtlstb.graph.microsoft.com} host performs the
110+
* client-cert handshake (the app must be granted Graph {@code Application.Read.All}).
93111
*/
94112
@Test
95-
void acquireTokenClientCredentials_Certificate_MtlsPop() throws Exception {
113+
void Credential_X509_Output_Pop() throws Exception {
96114
ConfidentialClientApplication cca = ConfidentialClientApplication.builder(SNI_ALLOWLISTED_APP_ID, certificate)
97115
.authority(SNI_ALLOWLISTED_AUTHORITY) // tenanted authority (required for mTLS PoP)
98116
.build();
99117

100118
IAuthenticationResult result = acquireMtlsPopOrSkipOnDowngrade(cca, ClientCredentialParameters
101-
.builder(Collections.singleton(KEYVAULT_DEFAULT_SCOPE))
119+
.builder(Collections.singleton(AGENTIC_GRAPH_SCOPE))
102120
.mtlsProofOfPossession()
103121
.build());
104122

105123
assertMtlsPopResult(result, expectedLabThumbprint());
124+
125+
int status = MtlsResourceCaller.callResourceWithMtlsToken(
126+
MTLS_GRAPH_RESOURCE, result.accessToken(), certificate);
127+
assertEquals(200, status,
128+
"mTLS-enabled resource must accept the bound PoP token (HTTP 200); 401/403 means the "
129+
+ "binding certificate was not presented on the handshake or the mtls_pop scheme was wrong");
130+
}
131+
132+
/**
133+
* Canonical matrix cell {@code Credential_X509_Output_Bearer}: the same SN/I cert <b>without</b>
134+
* {@code mtlsProofOfPossession()} yields the existing {@code Bearer} token (the cert signs a
135+
* {@code private_key_jwt} client assertion) and exposes <b>no</b> binding certificate. This anchors
136+
* that opting out of mTLS PoP leaves the legacy SNI+Bearer behaviour intact.
137+
*/
138+
@Test
139+
void Credential_X509_Output_Bearer() throws Exception {
140+
ConfidentialClientApplication cca = ConfidentialClientApplication.builder(SNI_ALLOWLISTED_APP_ID, certificate)
141+
.authority(SNI_ALLOWLISTED_AUTHORITY)
142+
.build();
143+
144+
IAuthenticationResult result = cca.acquireToken(ClientCredentialParameters
145+
.builder(Collections.singleton(KEYVAULT_DEFAULT_SCOPE))
146+
.build()) // no mtlsProofOfPossession() -> Bearer
147+
.get();
148+
149+
assertNotNull(result.accessToken(), "Access token should not be null");
150+
assertEquals(TokenType.BEARER, result.metadata().tokenType(), "Result token type should be BEARER");
151+
assertNull(result.metadata().bindingCertificate(),
152+
"Bearer result must not expose a binding certificate");
106153
}
107154

108155
/**
109-
* Direct SNI cert &rarr; mTLS PoP with a region configured (exercises the regional
110-
* {@code <region>.mtlsauth.microsoft.com} endpoint), and verifies the bound token is cached and
111-
* retrieved on a second call.
156+
* {@code Credential_X509_Output_Pop} over the regional endpoint: with a region configured the
157+
* request targets {@code <region>.mtlsauth.microsoft.com}, and the bound token is cached under
158+
* {@code {token_type + cert KeyId}} and returned on a second call.
112159
*/
113160
@Test
114-
void acquireTokenClientCredentials_Certificate_MtlsPop_Regional() throws Exception {
161+
void Credential_X509_Output_Pop_Regional() throws Exception {
115162
ConfidentialClientApplication cca = ConfidentialClientApplication.builder(SNI_ALLOWLISTED_APP_ID, certificate)
116163
.authority(SNI_ALLOWLISTED_AUTHORITY)
117164
.azureRegion(TEST_SLICE_REGION)
@@ -140,7 +187,7 @@ void acquireTokenClientCredentials_Certificate_MtlsPop_Regional() throws Excepti
140187
* aliases the existing SNI+Bearer path.
141188
*/
142189
@Test
143-
void acquireTokenClientCredentials_BearerAndMtlsPop_AreCacheIsolated() throws Exception {
190+
void Credential_X509_Output_Pop_And_Bearer_CacheIsolated() throws Exception {
144191
ConfidentialClientApplication cca = ConfidentialClientApplication.builder(SNI_ALLOWLISTED_APP_ID, certificate)
145192
.authority(SNI_ALLOWLISTED_AUTHORITY)
146193
.build();
@@ -193,7 +240,6 @@ private void assertMtlsPopResult(IAuthenticationResult result, String expectedTh
193240

194241
BindingCertificate binding = result.metadata().bindingCertificate();
195242
assertNotNull(binding, "mTLS-PoP result must expose a binding certificate");
196-
assertNotNull(binding.thumbprintSha256(), "Binding certificate must expose its SHA-256 thumbprint");
197243
assertFalse(binding.certificateChain().isEmpty(), "Binding certificate must expose its x5c chain");
198244
assertEquals(expectedThumbprint, binding.thumbprintSha256(),
199245
"Binding certificate thumbprint must match the lab SNI cert (x5t#S256)");
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
package com.microsoft.aad.msal4j;
5+
6+
import javax.net.ssl.HttpsURLConnection;
7+
import javax.net.ssl.SSLSocketFactory;
8+
import java.io.IOException;
9+
import java.io.InputStream;
10+
import java.net.HttpURLConnection;
11+
import java.net.URL;
12+
13+
/**
14+
* Test-only helper that calls a protected resource with an mTLS Proof-of-Possession (PoP) token,
15+
* presenting the token's binding certificate as the client certificate on the TLS handshake.
16+
*
17+
* <p>Mirrors the resource-call contract validated by MSAL .NET (its {@code ResourceCaller}): the
18+
* binding certificate is the client TLS certificate, the token is sent as
19+
* {@code Authorization: mtls_pop <token>}, and a correctly bound token is accepted with HTTP 200. A
20+
* 401/403 means the certificate was not presented on the handshake or the {@code mtls_pop} scheme was
21+
* wrong. Reused by both the SNI and (later) FIC mTLS-PoP end-to-end tests, so acquiring a token is
22+
* proven to be genuinely usable rather than only well-formed.
23+
*/
24+
final class MtlsResourceCaller {
25+
26+
private MtlsResourceCaller() {
27+
}
28+
29+
/**
30+
* GETs {@code resourceUrl}, presenting {@code bindingCert} as the client TLS certificate and the
31+
* {@code mtls_pop} access token, and returns the HTTP status code.
32+
*
33+
* <p>The response body is drained and discarded — it must never be logged in (public) CI. Only the
34+
* status code is needed for the caller's assertion.
35+
*
36+
* @param resourceUrl the mTLS-enabled resource endpoint (e.g. {@code https://mtlstb.graph.microsoft.com/...})
37+
* @param accessToken the mTLS-bound PoP access token
38+
* @param bindingCert the certificate the token is bound to; must expose its private key for the handshake
39+
* @return the HTTP status code returned by the resource
40+
*/
41+
static int callResourceWithMtlsToken(String resourceUrl, String accessToken, IClientCertificate bindingCert)
42+
throws IOException {
43+
SSLSocketFactory socketFactory = MtlsClientCertificateHelper.createMtlsSocketFactory(bindingCert);
44+
45+
HttpsURLConnection connection = (HttpsURLConnection) new URL(resourceUrl).openConnection();
46+
try {
47+
connection.setSSLSocketFactory(socketFactory);
48+
connection.setRequestMethod("GET");
49+
connection.setRequestProperty("Authorization", "mtls_pop " + accessToken);
50+
connection.setConnectTimeout(30_000);
51+
connection.setReadTimeout(30_000);
52+
53+
int status = connection.getResponseCode();
54+
drainAndClose(status < HttpURLConnection.HTTP_BAD_REQUEST
55+
? connection.getInputStream() : connection.getErrorStream());
56+
return status;
57+
} finally {
58+
connection.disconnect();
59+
}
60+
}
61+
62+
// Reads and discards the response body so the connection can be released cleanly. The external body
63+
// is intentionally never logged (it can be large and is not needed for the status-code assertion).
64+
private static void drainAndClose(InputStream stream) throws IOException {
65+
if (stream == null) {
66+
return;
67+
}
68+
try (InputStream in = stream) {
69+
byte[] buffer = new byte[4096];
70+
while (in.read(buffer) != -1) {
71+
// discard
72+
}
73+
}
74+
}
75+
}

msal4j-sdk/src/samples/confidential-client/ClientCredentialMtlsProofOfPossession.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// Licensed under the MIT License.
33

44
import com.microsoft.aad.msal4j.BindingCertificate;
5+
import com.microsoft.aad.msal4j.ClientCredentialFactory;
56
import com.microsoft.aad.msal4j.ClientCredentialParameters;
67
import com.microsoft.aad.msal4j.ConfidentialClientApplication;
78
import com.microsoft.aad.msal4j.IAuthenticationResult;

0 commit comments

Comments
 (0)