Skip to content

Commit d1651ff

Browse files
Add certificate-bound FIC leg 2
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 5949442 commit d1651ff

11 files changed

Lines changed: 614 additions & 87 deletions

‎.github/copilot-instructions.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,9 +136,10 @@ MSAL4J supports multiple authentication flows, each with a public `*Parameters`
136136
- **Public API**: `acquireToken(ClientCredentialParameters)`
137137
- **Parameters**: `ClientCredentialParameters` - App-only authentication (daemon apps)
138138
- **Internal**: `ClientCredentialRequest` → `AcquireTokenByClientCredentialSupplier`
139-
- **Key Classes**: `IClientCredential`, `ClientSecret`, `ClientCertificate`, `ClientAssertion`
139+
- **Key Classes**: `IClientCredential`, `ClientSecret`, `ClientCertificate`, `ClientAssertion`, `ClientSignedAssertion`, `IClientSignedAssertionProvider`
140140
- **Optional mTLS PoP**: `mtlsProofOfPossession()` resolves one acquisition-local `IMtlsBindingContext`, requests `token_type=mtls_pop` over request-specific mTLS, isolates the cache by certificate DER hash, and exposes the live context on `IAuthenticationResult`
141141
- **Optional Bearer-over-mTLS**: `ConfidentialClientApplication.Builder.sendCertificateOverMtls(true)` presents the certificate on the global or configured regional `mtlsauth` token endpoint while sending the normal RS256 certificate assertion (with `x5c`) and retaining ordinary Bearer/cache semantics. Regional routing applies to client credentials, OBO, authorization-code redemption, and refresh-token redemption; per-request mTLS PoP takes precedence.
142+
- **Certificate-bound FIC leg 2**: `ClientCredentialFactory.createFromSignedAssertionCallback(IClientSignedAssertionProvider)` accepts a pre-signed assertion plus an optional `IClientCertificate`. A certificate selects `jwt-pop` and the `mtlsauth` endpoint without rewriting the assertion; `mtlsProofOfPossession()` additionally requests `mtls_pop`, isolates the cache by certificate DER, and restores the binding context on cache hits. The callback runs once before cache lookup and again for network credential resolution.
142143

143144
**On-Behalf-Of (OBO)**
144145
- **Public API**: `acquireToken(OnBehalfOfParameters)`

‎msal4j-sdk/docs/certificate-based-mtls.md‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,30 @@ Custom HTTP clients must implement `IMtlsCapableHttpClient` and honor request-sp
2222
and redirect settings. A configured application `SSLSocketFactory` cannot be combined
2323
safely with this option; unsupported clients, authorities, clouds, endpoints, redirects,
2424
token types, and protocol parameter overrides fail closed.
25+
26+
## Certificate-bound federated client assertions
27+
28+
`ClientCredentialFactory.createFromSignedAssertionCallback(IClientSignedAssertionProvider)`
29+
supports the second leg of a certificate-bound federated identity credential flow. The
30+
callback returns a `ClientSignedAssertion` containing the already-signed assertion and,
31+
optionally, the `IClientCertificate` that binds it to the token endpoint TLS connection. MSAL forwards the
32+
assertion unchanged and does not perform leg-1 acquisition or re-signing.
33+
34+
| Binding certificate | `mtlsProofOfPossession()` | Assertion type | Access token |
35+
|---|---|---|---|
36+
| absent | no | `jwt-bearer` | `Bearer` over the regular endpoint |
37+
| absent | yes | local `mtls_certificate_not_provided` failure | none |
38+
| present | no | `jwt-pop` | `Bearer` over mTLS, with no result binding context |
39+
| present | yes | `jwt-pop` | `mtls_pop` over mTLS, cache-partitioned by certificate DER |
40+
41+
As in MSAL.NET, the callback is invoked during preflight before cache lookup so MSAL can
42+
discover the optional certificate. A cache hit invokes it once. A network request invokes
43+
it again to obtain the assertion sent on the wire. Providers should therefore return
44+
pre-generated or cached material.
45+
46+
Java uses `IClientCertificate` instead of .NET's `X509Certificate2` so provider-backed and
47+
non-exportable private keys remain usable through the existing Java certificate abstraction.
48+
The Java callback is synchronous, matching the existing MSAL Java assertion-provider model.
49+
The factory method has a distinct name because Java cannot safely overload the existing
50+
`createFromCallback(Function<AssertionRequestOptions, String>)` with another single-argument
51+
functional-interface callback without making existing lambdas ambiguous.

‎msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AcquireTokenByClientCredentialSupplier.java‎

Lines changed: 111 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ class AcquireTokenByClientCredentialSupplier extends AuthenticationResultSupplie
2727

2828
@Override
2929
AuthenticationResult execute() throws Exception {
30-
prepareMtlsProofOfPossession();
30+
prepareMtls();
3131

3232
if (clientCredentialRequest.parameters.skipCache() != null &&
3333
!clientCredentialRequest.parameters.skipCache()) {
@@ -75,80 +75,124 @@ AuthenticationResult execute() throws Exception {
7575
return acquireTokenByClientCredential();
7676
}
7777

78-
private void prepareMtlsProofOfPossession() throws MalformedURLException {
79-
ClientCredentialParameters parameters = clientCredentialRequest.parameters;
80-
if (!parameters.mtlsProofOfPossession()) {
81-
return;
82-
}
83-
if (clientCredentialRequest.appTokenProvider != null) {
84-
throw new MsalClientException(
85-
"mTLS Proof-of-Possession cannot be used with appTokenProvider.",
86-
AuthenticationErrorCode.MTLS_POP_ERROR);
87-
}
78+
private void prepareMtls() throws MalformedURLException {
79+
ClientCredentialParameters parameters = clientCredentialRequest.parameters;
80+
if (clientCredentialRequest.appTokenProvider != null) {
81+
if (!parameters.mtlsProofOfPossession()) {
82+
return;
83+
}
84+
throw new MsalClientException(
85+
"mTLS Proof-of-Possession cannot be used with appTokenProvider.",
86+
AuthenticationErrorCode.MTLS_POP_ERROR);
87+
}
88+
89+
ConfidentialClientApplication application = (ConfidentialClientApplication) clientApplication;
90+
IClientCredential credential = parameters.clientCredential() != null
91+
? parameters.clientCredential() : application.clientCredential;
92+
IClientCertificate bindingCertificate = null;
93+
if (credential instanceof ClientAssertion
94+
&& ((ClientAssertion) credential).isSignedAssertionProvider()) {
95+
ClientSignedAssertion preflight = ((ClientAssertion) credential).signedAssertion(
96+
assertionRequestOptions(application));
97+
bindingCertificate = preflight.getTokenBindingCertificate();
98+
}
99+
100+
if (!parameters.mtlsProofOfPossession() && bindingCertificate == null) {
101+
return;
102+
}
88103

89-
Set<String> protocolOwned = new HashSet<>(Arrays.asList(
90-
"token_type", "client_assertion", "client_assertion_type", "req_cnf"));
91-
if (parameters.extraQueryParameters() != null) {
92-
for (String name : parameters.extraQueryParameters().keySet()) {
93-
if (protocolOwned.contains(name.toLowerCase(Locale.ROOT))) {
94-
throw new MsalClientException(
95-
"extraQueryParameters cannot override protocol-owned parameter '" + name + "'.",
96-
AuthenticationErrorCode.MTLS_POP_ERROR);
97-
}
104+
if (parameters.mtlsProofOfPossession()) {
105+
Set<String> protocolOwned = new HashSet<>(Arrays.asList(
106+
"token_type", "client_assertion", "client_assertion_type", "req_cnf"));
107+
if (parameters.extraQueryParameters() != null) {
108+
for (String name : parameters.extraQueryParameters().keySet()) {
109+
if (protocolOwned.contains(name.toLowerCase(Locale.ROOT))) {
110+
throw new MsalClientException(
111+
"extraQueryParameters cannot override protocol-owned parameter '" + name + "'.",
112+
AuthenticationErrorCode.MTLS_POP_ERROR);
98113
}
99114
}
115+
}
116+
}
100117

101-
ConfidentialClientApplication application = (ConfidentialClientApplication) clientApplication;
102-
Authority authority = application.authenticationAuthority;
103-
if (parameters.tenant() != null) {
104-
authority = Authority.replaceTenant(authority, parameters.tenant());
105-
}
106-
if (authority.authorityType() != AuthorityType.AAD) {
107-
throw new MsalClientException(
108-
"mTLS Proof-of-Possession requires a Microsoft Entra AAD authority.",
109-
AuthenticationErrorCode.MTLS_POP_ERROR);
110-
}
111-
MtlsEndpointHelper.deriveMtlsTokenEndpoint(authority.tokenEndpointUrl());
112-
113-
IClientCredential credential = parameters.clientCredential() != null
114-
? parameters.clientCredential() : application.clientCredential;
115-
if (!(credential instanceof IClientCertificate)) {
116-
throw new MsalClientException(
117-
"mTLS Proof-of-Possession requires a certificate credential.",
118-
AuthenticationErrorCode.MTLS_POP_ERROR);
119-
}
120-
if (!(application.httpClient() instanceof IMtlsCapableHttpClient)) {
121-
throw new MsalClientException(
122-
"The configured custom HTTP client does not support request-specific mTLS. "
123-
+ "Implement IMtlsCapableHttpClient and honor the request TLS and redirect settings.",
124-
AuthenticationErrorCode.MTLS_POP_ERROR);
125-
}
126-
if (application.httpClient() instanceof DefaultHttpClient
127-
&& application.sslSocketFactory() != null) {
128-
throw new MsalClientException(
129-
"mTLS Proof-of-Possession cannot safely compose the configured application "
130-
+ "SSLSocketFactory with the binding certificate. Use an "
131-
+ "IMtlsCapableHttpClient that combines HttpRequest.sslContext() with "
132-
+ "the application's trust policy.",
133-
AuthenticationErrorCode.MTLS_POP_ERROR);
134-
}
118+
Authority authority = application.authenticationAuthority;
119+
if (parameters.tenant() != null) {
120+
authority = Authority.replaceTenant(authority, parameters.tenant());
121+
}
122+
if (authority.authorityType() != AuthorityType.AAD) {
123+
throw new MsalClientException(
124+
"mTLS authentication requires a Microsoft Entra AAD authority.",
125+
AuthenticationErrorCode.MTLS_POP_ERROR);
126+
}
127+
MtlsEndpointHelper.deriveMtlsTokenEndpoint(authority.tokenEndpointUrl());
135128

136-
MtlsBindingContext bindingContext = MtlsBindingContext.create((IClientCertificate) credential);
137-
clientCredentialRequest.mtlsBindingContext(bindingContext);
138-
SortedMap<String, String> components = new TreeMap<>();
139-
components.put("token_type", TokenType.MTLS_POP.value());
140-
components.put("cert_der_hash", bindingContext.keyId());
141-
String baseHash = parameters.computeExtCacheKeyHash();
142-
if (!StringHelper.isBlank(baseHash)) {
143-
components.put("parameters", baseHash);
144-
}
145-
clientCredentialRequest.acquisitionCacheKeyHash(
146-
StringHelper.computeExtCacheKeyHash(components));
129+
if (bindingCertificate == null && credential instanceof IClientCertificate) {
130+
bindingCertificate = (IClientCertificate) credential;
131+
}
132+
if (bindingCertificate == null) {
133+
if (credential instanceof ClientAssertion
134+
&& ((ClientAssertion) credential).isSignedAssertionProvider()) {
135+
throw new MsalClientException(
136+
"mTLS Proof-of-Possession requires a certificate for this request. "
137+
+ "Either configure the application with a certificate or return a "
138+
+ "certificate-bound client assertion and request mTLS Proof-of-Possession. "
139+
+ "See https://aka.ms/msal-net-pop for details.",
140+
AuthenticationErrorCode.MTLS_CERTIFICATE_NOT_PROVIDED);
147141
}
142+
throw new MsalClientException(
143+
"mTLS Proof-of-Possession requires a certificate credential.",
144+
AuthenticationErrorCode.MTLS_POP_ERROR);
145+
}
146+
if (!(application.httpClient() instanceof IMtlsCapableHttpClient)) {
147+
throw new MsalClientException(
148+
"The configured custom HTTP client does not support request-specific mTLS. "
149+
+ "Implement IMtlsCapableHttpClient and honor the request TLS and redirect settings.",
150+
AuthenticationErrorCode.MTLS_POP_ERROR);
151+
}
152+
if (application.httpClient() instanceof DefaultHttpClient
153+
&& application.sslSocketFactory() != null) {
154+
throw new MsalClientException(
155+
"mTLS authentication cannot safely compose the configured application "
156+
+ "SSLSocketFactory with the binding certificate. Use an "
157+
+ "IMtlsCapableHttpClient that combines HttpRequest.sslContext() with "
158+
+ "the application's trust policy.",
159+
AuthenticationErrorCode.MTLS_POP_ERROR);
160+
}
161+
162+
MtlsBindingContext bindingContext = MtlsBindingContext.create(bindingCertificate);
163+
if (parameters.mtlsProofOfPossession()) {
164+
clientCredentialRequest.mtlsBindingContext(bindingContext);
165+
} else {
166+
clientCredentialRequest.clientAssertionMtlsBindingContext(bindingContext);
167+
return;
168+
}
169+
SortedMap<String, String> components = new TreeMap<>();
170+
components.put("token_type", TokenType.MTLS_POP.value());
171+
components.put("cert_der_hash", bindingContext.keyId());
172+
String baseHash = parameters.computeExtCacheKeyHash();
173+
if (!StringHelper.isBlank(baseHash)) {
174+
components.put("parameters", baseHash);
175+
}
176+
clientCredentialRequest.acquisitionCacheKeyHash(
177+
StringHelper.computeExtCacheKeyHash(components));
178+
}
179+
180+
private AssertionRequestOptions assertionRequestOptions(
181+
ConfidentialClientApplication application) throws MalformedURLException {
182+
Authority authority = application.authenticationAuthority;
183+
if (clientCredentialRequest.parameters.tenant() != null) {
184+
authority = Authority.replaceTenant(
185+
authority, clientCredentialRequest.parameters.tenant());
186+
}
187+
return new AssertionRequestOptions(
188+
application.clientId(),
189+
authority.tokenEndpointUrl().toString(),
190+
clientCredentialRequest.parameters.fmiPath());
191+
}
148192

149193
private AuthenticationResult reattachMtlsBinding(AuthenticationResult result) {
150-
MtlsBindingContext context = clientCredentialRequest.mtlsBindingContext();
151-
return context == null ? result : result.withMtlsBindingContext(context);
194+
MtlsBindingContext context = clientCredentialRequest.mtlsBindingContext();
195+
return context == null ? result : result.withMtlsBindingContext(context);
152196
}
153197

154198
private AuthenticationResult acquireTokenByClientCredential() throws Exception {

‎msal4j-sdk/src/main/java/com/microsoft/aad/msal4j/AuthenticationErrorCode.java‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@
88
*/
99
public class AuthenticationErrorCode {
1010
public static final String MTLS_POP_ERROR = "mtls_pop_error";
11+
public static final String MTLS_CERTIFICATE_NOT_PROVIDED = "mtls_certificate_not_provided";
1112
public static final String TOKEN_TYPE_MISMATCH = "token_type_mismatch";
13+
public static final String INVALID_CLIENT_ASSERTION = "invalid_client_assertion";
1214

1315
/**
1416
* In the context of device code user has not yet authenticated via browser. For more details,

0 commit comments

Comments
 (0)