Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@
* <p>The primary scenario is covered:
* <ul>
* <li><b>Direct SNI cert &rarr; mTLS PoP</b> (client credentials), global and regional endpoints.</li>
* <li><b>Bearer-over-mTLS</b> (Task 3): the same SNI cert is presented on the TLS handshake to the mTLS
* token endpoint via {@code sendCertificateOverMtls(true)}, but a plain (unbound) {@code Bearer}
* token is returned. Live client-credentials cell + a skip-gated OBO cell documenting the
* user-flow allow-listing gap.</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 @@ -223,6 +227,90 @@ void Credential_X509_Output_Pop_And_Bearer_CacheIsolated() throws Exception {
"Bearer and mTLS-PoP tokens must occupy separate cache entries");
}

/**
* <b>Bearer-over-mTLS (Task 3), proven end to end.</b> With {@code sendCertificateOverMtls(true)}
* (and <b>no</b> per-request {@code mtlsProofOfPossession()}), the lab SN/I cert is presented as the
* client TLS certificate on the handshake to the <b>mTLS</b> token endpoint, but the issued token is a
* plain <b>{@code Bearer}</b> access token that is <b>not</b> bound to the certificate (unlike mTLS
* PoP). This mirrors MSAL .NET's {@code Sni_Over_Mtls_Gets_Bearer_Token_Successfully} and uses the same
* live config: the SN/I-allow-listed app, {@code westus3} region, Key Vault scope.
*
* <p>The token-endpoint <i>wire</i> contract (routing to {@code mtlsauth.*}, {@code client_assertion}
* with the x5c chain forced on, no {@code token_type=mtls_pop} / {@code req_cnf}) is asserted at the
* unit level in {@code BearerOverMtlsTest} via {@code mockConstruction(DefaultHttpClient)} — the mTLS
* HTTP client is constructed internally by {@code TokenRequestExecutor} with the client-cert socket
* factory, so there is no factory injection point to record the live request here. This live cell
* therefore proves the complementary half: ESTS accepts the cert-over-mTLS handshake for this app and
* issues a usable, unbound {@code Bearer} token.
*/
@Test
void Credential_X509_Output_BearerOverMtls() throws Exception {
ConfidentialClientApplication cca = ConfidentialClientApplication.builder(SNI_ALLOWLISTED_APP_ID, certificate)
.authority(SNI_ALLOWLISTED_AUTHORITY)
.azureRegion(TEST_SLICE_REGION) // regional endpoint is safe (Bearer token type is deterministic)
.sendCertificateOverMtls(true) // route over mTLS, but keep a plain Bearer token
.build();

IAuthenticationResult result = cca.acquireToken(ClientCredentialParameters
.builder(Collections.singleton(KEYVAULT_DEFAULT_SCOPE))
.build()) // no mtlsProofOfPossession() -> Bearer, not mtls_pop
.get();

assertNotNull(result.accessToken(), "Access token should not be null");
assertFalse(result.accessToken().isEmpty(), "Access token should not be empty");
assertEquals(TokenType.BEARER, result.metadata().tokenType(),
"Bearer-over-mTLS must yield a plain Bearer token, not mtls_pop");
assertNull(result.metadata().bindingCertificate(),
"Bearer-over-mTLS token must not be bound to a certificate (no binding cert exposed)");
}

/**
* Bearer-over-mTLS cache behavior: the plain Bearer token is cached under the <b>standard</b> key (it
* is <b>not</b> thumbprint-fenced like mTLS PoP), so a second acquisition for the same scope is served
* from the cache and returns the same access token. This also guards the 2nd-call regression: after the
* first call the cached entry's environment is the mTLS host, and a second lookup must serve from cache
* without crashing on region / instance-metadata resolution.
*/
@Test
void Credential_X509_Output_BearerOverMtls_CacheHit() throws Exception {
ConfidentialClientApplication cca = ConfidentialClientApplication.builder(SNI_ALLOWLISTED_APP_ID, certificate)
.authority(SNI_ALLOWLISTED_AUTHORITY)
.azureRegion(TEST_SLICE_REGION)
.sendCertificateOverMtls(true)
.build();

IAuthenticationResult result = cca.acquireToken(ClientCredentialParameters
.builder(Collections.singleton(KEYVAULT_DEFAULT_SCOPE))
.build())
.get();
assertEquals(TokenType.BEARER, result.metadata().tokenType());

IAuthenticationResult cached = cca.acquireToken(ClientCredentialParameters
.builder(Collections.singleton(KEYVAULT_DEFAULT_SCOPE))
.build())
.get();

assertEquals(result.accessToken(), cached.accessToken(),
"Second Bearer-over-mTLS request for the same scope should be served from the cache");
}

/**
* <b>OBO Bearer-over-mTLS live acquisition — skip-gated (pending app mTLS-enablement).</b> Mirrors MSAL
* .NET's {@code [Ignore]}d OBO/refresh/auth-code Bearer-over-mTLS live tests: the on-behalf-of (and
* refresh-token / auth-code) apps are <b>not</b> mTLS-enabled, so a live acquisition is rejected with
* {@code AADSTS700027 / AADSTS392189} — the same class of allow-listing block as the FIC
* {@code AADSTS51000}. The MSAL request shape for these flows (mTLS endpoint, {@code client_assertion}
* with forced x5c, correct grant) is fully asserted in {@code BearerOverMtlsTest} unit cells; this cell
* documents that the live user-flow path cannot be exercised until the apps are enabled.
*/
@Test
void Credential_Obo_Output_BearerOverMtls_LiveAcquire_SkipGated() {
Assumptions.assumeTrue(false,
"OBO/refresh/auth-code Bearer-over-mTLS live acquisition is pending app mTLS-enablement "
+ "(AADSTS700027 / AADSTS392189); the request shape is covered by BearerOverMtlsTest "
+ "unit cells via mockConstruction(DefaultHttpClient).");
}

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 @@ -176,6 +176,14 @@ public class AuthenticationErrorCode {
*/
public static final String TOKEN_TYPE_MISMATCH = "token_type_mismatch";

/**
* Indicates that {@code sendCertificateOverMtls(true)} was configured on a confidential client that is
* not authenticated with a certificate credential. Presenting a client certificate on the mTLS handshake
* requires an {@link IClientCertificate}; MSAL fails fast at build time rather than silently ignoring the
* option. For more details, see https://aka.ms/msal4j-pop
*/
public static final String CERTIFICATE_REQUIRED_FOR_MTLS = "certificate_required_for_mtls";

/**
* Indicates that instance discovery failed because the authority is not a valid instance.
* This is returned by the instance discovery endpoint when the provided authority host is unknown.
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;
private boolean sendCertificateOverMtls;

/** 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 @@ -84,6 +85,7 @@ public CompletableFuture<IAuthenticationResult> acquireToken(UserFederatedIdenti
private ConfidentialClientApplication(Builder builder) {
super(builder);
sendX5c = builder.sendX5c;
sendCertificateOverMtls = builder.sendCertificateOverMtls;
appTokenProvider = builder.appTokenProvider;

log = LoggerFactory.getLogger(ConfidentialClientApplication.class);
Expand All @@ -110,12 +112,19 @@ public boolean sendX5c() {
return this.sendX5c;
}

@Override
public boolean sendCertificateOverMtls() {
return this.sendCertificateOverMtls;
}

public static class Builder extends AbstractClientApplicationBase.Builder<Builder> {

private IClientCredential clientCredential;

private boolean sendX5c = true;

private boolean sendCertificateOverMtls = false;

private Function<AppTokenProviderParameters, CompletableFuture<TokenProviderResult>> appTokenProvider;

private Builder(String clientId, IClientCredential clientCredential) {
Expand All @@ -139,6 +148,32 @@ public ConfidentialClientApplication.Builder sendX5c(boolean val) {
return self();
}

/**
* Specifies whether the application's certificate credential is presented as the client certificate
* on the mutual-TLS (mTLS) handshake to the token endpoint. When enabled, requests are routed to the
* mTLS token endpoint ({@code mtlsauth.*}) and the identity provider returns a plain Bearer access
* token (the token is NOT bound to the certificate).
* <p>
* This is distinct from per-request mTLS Proof-of-Possession
* ({@link ClientCredentialParameters.ClientCredentialParametersBuilder#mtlsProofOfPossession()}),
* which binds the token to the certificate ({@code token_type=mtls_pop}); a per-request mtls_pop
* opt-in always takes precedence over this app-level flag. The flag is honored by every confidential
* flow (client credentials, on-behalf-of, refresh token, authorization code).
* <p>
* Default value is {@code false}. When enabled, the application MUST be configured with a certificate
* credential ({@link IClientCertificate}); otherwise {@link #build()} throws a
* {@link MsalClientException} with error code
* {@link AuthenticationErrorCode#CERTIFICATE_REQUIRED_FOR_MTLS}.
*
* @param val {@code true} to present the certificate over mTLS and receive a Bearer token
* @return instance of the Builder on which method was called
*/
public ConfidentialClientApplication.Builder sendCertificateOverMtls(boolean val) {
this.sendCertificateOverMtls = 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 All @@ -159,6 +194,13 @@ public ConfidentialClientApplication.Builder appTokenProvider(Function<AppTokenP

@Override
public ConfidentialClientApplication build() {
if (sendCertificateOverMtls && !(clientCredential instanceof IClientCertificate)) {
throw new MsalClientException(
"sendCertificateOverMtls(true) requires a certificate credential (IClientCertificate) " +
"so it can be presented as the client certificate on the mTLS handshake. Configure " +
"the application with a certificate credential or disable sendCertificateOverMtls.",
AuthenticationErrorCode.CERTIFICATE_REQUIRED_FOR_MTLS);
}

return new ConfidentialClientApplication(this);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,14 @@ public interface IConfidentialClientApplication extends IClientApplicationBase {
*/
boolean sendX5c();

/**
* @return {@code true} if the application should present its certificate credential as the client
* certificate on the mTLS handshake to the token endpoint (routing the request to the mTLS endpoint)
* and receive a plain Bearer access token. See
* {@link ConfidentialClientApplication.Builder#sendCertificateOverMtls(boolean)}.
*/
boolean sendCertificateOverMtls();

/**
* Acquires tokens from the authority configured in the application, for the confidential client
* itself. It will by default attempt to get tokens from the token cache. If no tokens are found,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ OAuthHttpRequest createOauthHttpRequest() throws MalformedURLException {
URL tokenEndpointUrl = requestAuthority.tokenEndpointUrl();
IHttpClient mtlsHttpClient = null;

if (isMtlsProofOfPossession()) {
if (usesMtlsTransport()) {
ConfidentialClientApplication application = (ConfidentialClientApplication) msalRequest.application();
this.resolvedBindingCertificate = resolveBindingCertificate(application);
tokenEndpointUrl = MtlsEndpointHelper.deriveMtlsTokenEndpoint(tokenEndpointUrl);
Expand All @@ -62,7 +62,7 @@ OAuthHttpRequest createOauthHttpRequest() throws MalformedURLException {
mtlsSocketFactory,
application.connectTimeoutForDefaultHttpClient(),
application.readTimeoutForDefaultHttpClient());
LOG.debug("mTLS Proof-of-Possession requested; using mTLS token endpoint: {}", tokenEndpointUrl);
LOG.debug("mTLS transport requested; using mTLS token endpoint: {}", tokenEndpointUrl);
}

final OAuthHttpRequest oauthHttpRequest = new OAuthHttpRequest(
Expand Down Expand Up @@ -212,12 +212,15 @@ private void addCredentialToRequest(Map<String, String> queryParameters,
// SN/I trust from the TLS-presented certificate and binds the token via x5t#S256/cnf).
return;
}
// For client certificate, generate a new assertion and add it to the request
// For client certificate, generate a new assertion and add it to the request. For
// Bearer-over-mTLS the certificate is ALSO presented on the TLS handshake, so the x5c issuer
// chain is forced on the assertion (regardless of the app's sendX5c setting) so ESTS can do
// SN/I subject+issuer matching over the mTLS channel.
ClientCertificate certificate = (ClientCertificate) credentialToUse;
String assertion = certificate.getAssertion(
authorityToUse,
application.clientId(),
application.sendX5c());
application.sendX5c() || isBearerOverMtls());
addJWTBearerAssertionParams(queryParameters, assertion);
}
}
Expand All @@ -242,6 +245,34 @@ private boolean isMtlsProofOfPossession() {
&& ((ClientCredentialRequest) msalRequest).parameters.mtlsProofOfPossession();
}

/**
* @return true if this request must present the client certificate on the TLS handshake and route to
* the mTLS token endpoint — either because of per-request mTLS Proof-of-Possession or the app-level
* {@link ConfidentialClientApplication.Builder#sendCertificateOverMtls(boolean)} (Bearer-over-mTLS) flag.
*/
private boolean usesMtlsTransport() {
return isMtlsProofOfPossession() || isBearerOverMtls();
}

/**
* @return true if the app-level {@code sendCertificateOverMtls} flag is set and this is a
* certificate-authenticated confidential client, and the request did NOT opt into per-request
* mTLS Proof-of-Possession (which always wins). Read from the application (not a request cast) so it is
* honored by every confidential flow: client credentials, on-behalf-of, refresh token, authorization code.
*/
private boolean isBearerOverMtls() {
if (isMtlsProofOfPossession()) {
// A per-request mtls_pop opt-in always takes precedence over the app-level flag.
return false;
}
if (!(msalRequest.application() instanceof ConfidentialClientApplication)) {
return false;
}
ConfidentialClientApplication application = (ConfidentialClientApplication) msalRequest.application();
return application.sendCertificateOverMtls()
&& application.clientCredential instanceof IClientCertificate;
}

/**
* 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).
Expand Down
Loading