Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)**
Expand Down
1 change: 1 addition & 0 deletions changelog.txt
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,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 &rarr; 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>,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 — <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()} &rarr; 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(...)} &rarr; 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");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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
Expand Up @@ -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<String> assertionProvider;
private final Function<AssertionRequestOptions, String> contextAwareAssertionProvider;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -298,7 +298,9 @@ public ClientCredentialParametersBuilder fmiPath(String fmiPath) {
* Requests a mutual-TLS Proof-of-Possession (mTLS PoP) token instead of a Bearer token.
* <p>
* 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}.
* <p>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
Expand All @@ -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) {
Expand All @@ -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) {
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,10 @@
* mutual-TLS (mTLS) handshake to the token endpoint, and to describe that certificate as a public
* {@link BindingCertificate} on the result.
*
* <p>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.
* <p>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 {

Expand All @@ -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
*/
Expand All @@ -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);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,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;
String assertion;
if (clientAssertion.isContextAware()) {
// Build assertion context with client assertion FMI path if available
String clientAssertionFmiPath = null;
Expand All @@ -183,12 +184,18 @@ private void addCredentialToRequest(Map<String, String> queryParameters,
AssertionRequestOptions options = new AssertionRequestOptions(
application.clientId(),
tokenEndpoint,
clientAssertionFmiPath);
clientAssertionFmiPath,
mtlsPoP);

addJWTBearerAssertionParams(queryParameters, clientAssertion.assertion(options));
assertion = clientAssertion.assertion(options);
} else {
addJWTBearerAssertionParams(queryParameters, clientAssertion.assertion());
assertion = clientAssertion.assertion();
}

// For mTLS PoP (FIC Leg 2), the assertion is authenticated with the jwt-pop assertion type and
// the binding certificate is presented on the TLS handshake.
addJWTAssertionParams(queryParameters, assertion,
mtlsPoP ? ClientAssertion.ASSERTION_TYPE_JWT_POP : ClientAssertion.ASSERTION_TYPE_JWT_BEARER);
} else if (credentialToUse instanceof ClientCertificate) {
if (mtlsPoP) {
// For mTLS PoP (direct SN/I cert / FIC Leg 1), the certificate is presented as the client
Expand All @@ -213,8 +220,19 @@ private void addCredentialToRequest(Map<String, String> queryParameters,
* @param assertion The JWT assertion string
*/
private void addJWTBearerAssertionParams(Map<String, String> queryParameters, String assertion) {
addJWTAssertionParams(queryParameters, assertion, ClientAssertion.ASSERTION_TYPE_JWT_BEARER);
}

/**
* Adds the JWT assertion parameters to the request with the given client_assertion_type.
*
* @param queryParameters The map of query parameters to add to
* @param assertion The JWT assertion string
* @param assertionType The client_assertion_type value (jwt-bearer for Bearer, jwt-pop for mTLS PoP)
*/
private void addJWTAssertionParams(Map<String, String> queryParameters, String assertion, String assertionType) {
queryParameters.put("client_assertion", assertion);
queryParameters.put("client_assertion_type", ClientAssertion.ASSERTION_TYPE_JWT_BEARER);
queryParameters.put("client_assertion_type", assertionType);
}

/**
Expand All @@ -228,7 +246,8 @@ private boolean isMtlsProofOfPossession() {

/**
* 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 configured {@code mtlsBindingCertificate} (FIC Leg 2, assertion-authenticated).
*/
private IClientCertificate resolveBindingCertificate(ConfidentialClientApplication application) {
ClientCredentialParameters parameters = msalRequest instanceof ClientCredentialRequest
Expand Down
Loading