-
Notifications
You must be signed in to change notification settings - Fork 161
Add SN/I certificate support over mTLS Proof-of-Possession (PoP) #1040
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: dev
Are you sure you want to change the base?
Changes from 2 commits
d8cac48
150a554
68d3715
66d59db
e6dee5f
288df02
6bf6024
f848b54
3ff929c
3ef7681
d576db6
683e54e
3858dc3
d22059a
48fd92d
29fc91b
1805895
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 |
|---|---|---|
| @@ -0,0 +1,186 @@ | ||
| // Copyright (c) Microsoft Corporation. All rights reserved. | ||
| // Licensed under the MIT License. | ||
|
|
||
| package com.microsoft.aad.msal4j; | ||
|
|
||
| import com.microsoft.aad.msal4j.labapi.KeyVaultRegistry; | ||
| import com.microsoft.aad.msal4j.labapi.KeyVaultSecretsProvider; | ||
| import org.junit.jupiter.api.BeforeAll; | ||
| import org.junit.jupiter.api.Test; | ||
| import org.junit.jupiter.api.TestInstance; | ||
|
|
||
| import java.io.IOException; | ||
| import java.security.KeyStore; | ||
| import java.security.KeyStoreException; | ||
| import java.security.NoSuchAlgorithmException; | ||
| import java.security.NoSuchProviderException; | ||
| import java.security.PrivateKey; | ||
| import java.security.UnrecoverableKeyException; | ||
| import java.security.cert.CertificateException; | ||
| import java.security.cert.X509Certificate; | ||
| import java.util.Collections; | ||
|
|
||
| import static com.microsoft.aad.msal4j.TestConstants.KEYVAULT_DEFAULT_SCOPE; | ||
| import static org.junit.jupiter.api.Assertions.assertEquals; | ||
| import static org.junit.jupiter.api.Assertions.assertFalse; | ||
| import static org.junit.jupiter.api.Assertions.assertNotEquals; | ||
| import static org.junit.jupiter.api.Assertions.assertNotNull; | ||
|
|
||
| /** | ||
| * End-to-end integration tests for SN/I certificate over mTLS Proof-of-Possession (PoP). | ||
| * | ||
| * <p>These exercise the primary deliverable of this work: a confidential-client app configured with a | ||
| * Subject-Name/Issuer (SN/I) certificate obtains an <b>mTLS-bound PoP access token</b> from Entra ID | ||
| * (ESTS), where that same SNI cert is presented as the client TLS certificate in the mutual-TLS | ||
| * handshake to the token endpoint (no {@code private_key_jwt} / x5c client assertion on the direct | ||
| * path). | ||
| * | ||
| * <p>The primary scenario is covered: | ||
| * <ul> | ||
| * <li><b>Direct SNI cert → mTLS PoP</b> (client credentials), global and regional endpoints.</li> | ||
| * </ul> | ||
| * | ||
| * <p><b>Testability gate (SME note A):</b> ESTS gates mTLS PoP on the <i>final resource audience</i>, | ||
| * which must be an ESTS allow-listed resource (e.g. Azure Key Vault or MS Graph) — not the client app. | ||
| * Every test below therefore requests a token for an allow-listed resource. | ||
| * | ||
| * <p>The lab SN/I certificate is <b>non-CNG</b>, so these tests are E2E-runnable in CI/CD using the same | ||
| * certificate the pipelines already provision for {@code ClientCredentialsIT} and {@code AgenticIT} (the | ||
| * OS keystore alias {@link KeyVaultSecretsProvider#CERTIFICATE_ALIAS}). They require lab credentials and | ||
| * network access and only pass in CI (like the other {@code *IT} tests, they are not run by the unit-test | ||
| * surefire pass). | ||
| */ | ||
| @TestInstance(TestInstance.Lifecycle.PER_CLASS) | ||
| class MtlsPopIT { | ||
|
|
||
| private PrivateKey privateKey; | ||
| private X509Certificate publicCertificate; | ||
| private IClientCertificate certificate; | ||
|
|
||
| @BeforeAll | ||
| void init() throws KeyStoreException, NoSuchProviderException, IOException, | ||
| NoSuchAlgorithmException, CertificateException, UnrecoverableKeyException { | ||
| KeyStore keystore = CertificateHelper.createKeyStore(); | ||
| keystore.load(null, null); | ||
|
|
||
| privateKey = (PrivateKey) keystore.getKey(KeyVaultSecretsProvider.CERTIFICATE_ALIAS, null); | ||
| publicCertificate = (X509Certificate) keystore.getCertificate(KeyVaultSecretsProvider.CERTIFICATE_ALIAS); | ||
|
|
||
| assertNotNull(privateKey, "Lab private key not found. Ensure the lab cert is installed."); | ||
| assertNotNull(publicCertificate, "Lab certificate not found. Ensure the lab cert is installed."); | ||
|
|
||
| certificate = ClientCredentialFactory.createFromCertificate(privateKey, publicCertificate); | ||
| } | ||
|
|
||
| /** | ||
| * Direct SNI cert → mTLS PoP with <b>no region</b> (exercises the global | ||
| * {@code mtlsauth.microsoft.com} endpoint). The lab cert is presented as the client TLS certificate; | ||
| * the request carries {@code token_type=mtls_pop} and <b>no</b> client assertion. Requests an | ||
| * allow-listed resource (Key Vault) so ESTS issues the bound token. | ||
| */ | ||
| @Test | ||
| void acquireTokenClientCredentials_Certificate_MtlsPop() throws Exception { | ||
| final String clientId = KeyVaultRegistry.getMsidLabProvider() | ||
| .getSecretByName("LabVaultAppID").getValue(); | ||
|
|
||
| ConfidentialClientApplication cca = ConfidentialClientApplication.builder(clientId, certificate) | ||
| .authority(TestConstants.MICROSOFT_AUTHORITY) // tenanted authority (required for mTLS PoP) | ||
| .build(); | ||
|
|
||
| IAuthenticationResult result = cca.acquireToken(ClientCredentialParameters | ||
| .builder(Collections.singleton(KEYVAULT_DEFAULT_SCOPE)) | ||
| .mtlsProofOfPossession() | ||
| .build()) | ||
| .get(); | ||
|
|
||
| assertMtlsPopResult(result, expectedLabThumbprint()); | ||
|
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 resource call uses the original credential rather than the binding returned by MSAL, so it does not satisfy BIND-01/BIND-07. Return |
||
| } | ||
|
|
||
| /** | ||
| * Direct SNI cert → mTLS PoP with a region configured (exercises the regional | ||
| * {@code <region>.mtlsauth.microsoft.com} endpoint), and verifies the bound token is cached and | ||
| * retrieved on a second call. | ||
| */ | ||
| @Test | ||
| void acquireTokenClientCredentials_Certificate_MtlsPop_Regional() throws Exception { | ||
| final String clientId = KeyVaultRegistry.getMsidLabProvider() | ||
| .getSecretByName("LabVaultAppID").getValue(); | ||
|
|
||
| ConfidentialClientApplication cca = ConfidentialClientApplication.builder(clientId, certificate) | ||
| .authority(TestConstants.MICROSOFT_AUTHORITY) | ||
| .azureRegion("westus") | ||
| .build(); | ||
|
|
||
| IAuthenticationResult result = cca.acquireToken(ClientCredentialParameters | ||
| .builder(Collections.singleton(KEYVAULT_DEFAULT_SCOPE)) | ||
| .mtlsProofOfPossession() | ||
| .build()) | ||
| .get(); | ||
|
|
||
| assertMtlsPopResult(result, expectedLabThumbprint()); | ||
|
|
||
| // The mTLS-PoP token must be cached under {token_type + cert KeyId} and returned on lookup. | ||
| IAuthenticationResult cached = cca.acquireToken(ClientCredentialParameters | ||
| .builder(Collections.singleton(KEYVAULT_DEFAULT_SCOPE)) | ||
| .mtlsProofOfPossession() | ||
| .build()) | ||
| .get(); | ||
|
|
||
| assertEquals(result.accessToken(), cached.accessToken(), | ||
| "Second mTLS-PoP request should return the cached bound token"); | ||
| } | ||
|
|
||
| /** | ||
| * Requesting a Bearer token and an mTLS-PoP token for the same scope on the same app must yield two | ||
| * distinct tokens (cache isolation on {token_type + cert KeyId}), confirming the PoP path never | ||
| * aliases the existing SNI+Bearer path. | ||
| */ | ||
| @Test | ||
| void acquireTokenClientCredentials_BearerAndMtlsPop_AreCacheIsolated() throws Exception { | ||
| final String clientId = KeyVaultRegistry.getMsidLabProvider() | ||
| .getSecretByName("LabVaultAppID").getValue(); | ||
|
|
||
| ConfidentialClientApplication cca = ConfidentialClientApplication.builder(clientId, certificate) | ||
| .authority(TestConstants.MICROSOFT_AUTHORITY) | ||
| .build(); | ||
|
|
||
| // Existing SNI + Bearer path (unchanged). | ||
| IAuthenticationResult bearer = cca.acquireToken(ClientCredentialParameters | ||
| .builder(Collections.singleton(KEYVAULT_DEFAULT_SCOPE)) | ||
| .build()) | ||
| .get(); | ||
| assertEquals(TokenType.BEARER, bearer.metadata().tokenType()); | ||
|
|
||
| // New SNI + mTLS PoP path. | ||
| IAuthenticationResult pop = cca.acquireToken(ClientCredentialParameters | ||
| .builder(Collections.singleton(KEYVAULT_DEFAULT_SCOPE)) | ||
| .mtlsProofOfPossession() | ||
| .build()) | ||
| .get(); | ||
| assertEquals(TokenType.MTLS_POP, pop.metadata().tokenType()); | ||
|
|
||
| assertNotEquals(bearer.accessToken(), pop.accessToken(), | ||
| "Bearer and mTLS-PoP tokens for the same scope must be distinct cache entries"); | ||
| assertEquals(2, cca.tokenCache.accessTokens.size(), | ||
| "Bearer and mTLS-PoP tokens must occupy separate cache entries"); | ||
| } | ||
|
|
||
| private void assertMtlsPopResult(IAuthenticationResult result, String expectedThumbprint) { | ||
| assertNotNull(result, "Auth result should not be null"); | ||
| assertNotNull(result.accessToken(), "Access token should not be null"); | ||
| assertFalse(result.accessToken().isEmpty(), "Access token should not be empty"); | ||
| assertEquals(TokenType.MTLS_POP, result.metadata().tokenType(), | ||
| "Result token type should be MTLS_POP"); | ||
|
|
||
| BindingCertificate binding = result.metadata().bindingCertificate(); | ||
| assertNotNull(binding, "mTLS-PoP result must expose a binding certificate"); | ||
| assertNotNull(binding.thumbprintSha256(), "Binding certificate must expose its SHA-256 thumbprint"); | ||
| assertFalse(binding.certificateChain().isEmpty(), "Binding certificate must expose its x5c chain"); | ||
| assertEquals(expectedThumbprint, binding.thumbprintSha256(), | ||
| "Binding certificate thumbprint must match the lab SNI cert (x5t#S256)"); | ||
| } | ||
|
|
||
| private String expectedLabThumbprint() { | ||
| return MtlsClientCertificateHelper.computeThumbprintSha256(publicCertificate); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -19,6 +19,16 @@ class AcquireTokenByClientCredentialSupplier extends AuthenticationResultSupplie | |
|
|
||
| @Override | ||
| AuthenticationResult execute() throws Exception { | ||
| // For mTLS Proof-of-Possession, isolate the access token in the cache by the binding certificate's | ||
|
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] |
||
| // KeyId (x5t#S256) in addition to the token_type dimension, so PoP tokens bound to different | ||
| // certificates never alias. Stamped before the cache lookup so reads and writes hash identically. | ||
| if (clientCredentialRequest.parameters.mtlsProofOfPossession()) { | ||
| IClientCertificate bindingCertificate = MtlsClientCertificateHelper.resolveBindingCertificate( | ||
| (ConfidentialClientApplication) this.clientApplication, clientCredentialRequest.parameters); | ||
| clientCredentialRequest.parameters.bindingCertificateKeyId( | ||
| MtlsClientCertificateHelper.computeCertificateKeyId(bindingCertificate)); | ||
| } | ||
|
|
||
| if (clientCredentialRequest.parameters.skipCache() != null && | ||
| !clientCredentialRequest.parameters.skipCache()) { | ||
| LOG.debug("SkipCache set to false. Attempting cache lookup"); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -25,10 +25,28 @@ public class AuthenticationResultMetadata implements Serializable { | |
| */ | ||
| private CacheRefreshReason cacheRefreshReason = CacheRefreshReason.NOT_APPLICABLE; | ||
|
|
||
| /** | ||
| * The type of the access token in the {@link AuthenticationResult}, see {@link TokenType} for possible | ||
| * values. Defaults to {@link TokenType#BEARER}. | ||
| */ | ||
| private TokenType tokenType = TokenType.BEARER; | ||
|
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/compatibility] |
||
|
|
||
| /** | ||
| * For {@link TokenType#MTLS_POP} results, the certificate the token is bound to (public material only). | ||
| * Null for Bearer results. | ||
| */ | ||
| private BindingCertificate bindingCertificate; | ||
|
|
||
| AuthenticationResultMetadata(TokenSource tokenSource, Long refreshOn, CacheRefreshReason cacheRefreshReason) { | ||
| this(tokenSource, refreshOn, cacheRefreshReason, TokenType.BEARER, null); | ||
| } | ||
|
|
||
| AuthenticationResultMetadata(TokenSource tokenSource, Long refreshOn, CacheRefreshReason cacheRefreshReason, TokenType tokenType, BindingCertificate bindingCertificate) { | ||
| this.tokenSource = tokenSource; | ||
| this.refreshOn = refreshOn; | ||
| this.cacheRefreshReason = cacheRefreshReason == null ? CacheRefreshReason.NOT_APPLICABLE : cacheRefreshReason; | ||
| this.tokenType = tokenType == null ? TokenType.BEARER : tokenType; | ||
| this.bindingCertificate = bindingCertificate; | ||
| } | ||
|
|
||
| public static AuthenticationResultMetadataBuilder builder() { | ||
|
|
@@ -47,6 +65,22 @@ public CacheRefreshReason cacheRefreshReason() { | |
| return this.cacheRefreshReason; | ||
| } | ||
|
|
||
| /** | ||
| * @return the {@link TokenType} of the access token (e.g. {@link TokenType#BEARER} or | ||
| * {@link TokenType#MTLS_POP}). Never null. | ||
| */ | ||
| public TokenType tokenType() { | ||
| return this.tokenType; | ||
| } | ||
|
|
||
| /** | ||
| * @return for {@link TokenType#MTLS_POP} results, the {@link BindingCertificate} (x5c chain + | ||
| * SHA-256 thumbprint, public material only) the token is bound to; null for Bearer results. | ||
| */ | ||
| public BindingCertificate bindingCertificate() { | ||
| return this.bindingCertificate; | ||
| } | ||
|
|
||
| void tokenSource(TokenSource tokenSource) { | ||
| this.tokenSource = tokenSource; | ||
| } | ||
|
|
@@ -59,10 +93,20 @@ void cacheRefreshReason(CacheRefreshReason cacheRefreshReason) { | |
| this.cacheRefreshReason = cacheRefreshReason; | ||
| } | ||
|
|
||
| void tokenType(TokenType tokenType) { | ||
| this.tokenType = tokenType == null ? TokenType.BEARER : tokenType; | ||
| } | ||
|
|
||
| void bindingCertificate(BindingCertificate bindingCertificate) { | ||
| this.bindingCertificate = bindingCertificate; | ||
| } | ||
|
|
||
| public static class AuthenticationResultMetadataBuilder { | ||
| private TokenSource tokenSource; | ||
| private Long refreshOn; | ||
| private CacheRefreshReason cacheRefreshReason; | ||
| private TokenType tokenType = TokenType.BEARER; | ||
| private BindingCertificate bindingCertificate; | ||
|
|
||
| AuthenticationResultMetadataBuilder() { | ||
| } | ||
|
|
@@ -82,12 +126,22 @@ public AuthenticationResultMetadataBuilder cacheRefreshReason(CacheRefreshReason | |
| return this; | ||
| } | ||
|
|
||
| public AuthenticationResultMetadataBuilder tokenType(TokenType tokenType) { | ||
| this.tokenType = tokenType; | ||
| return this; | ||
| } | ||
|
|
||
| public AuthenticationResultMetadataBuilder bindingCertificate(BindingCertificate bindingCertificate) { | ||
| this.bindingCertificate = bindingCertificate; | ||
| return this; | ||
| } | ||
|
|
||
| public AuthenticationResultMetadata build() { | ||
| return new AuthenticationResultMetadata(this.tokenSource, this.refreshOn, cacheRefreshReason); | ||
| return new AuthenticationResultMetadata(this.tokenSource, this.refreshOn, cacheRefreshReason, tokenType, bindingCertificate); | ||
| } | ||
|
|
||
| public String toString() { | ||
| return "AuthenticationResultMetadata.AuthenticationResultMetadataBuilder(tokenSource=" + this.tokenSource + ", refreshOn=" + this.refreshOn + ", cacheRefreshReason$value=" + this.cacheRefreshReason + ")"; | ||
| return "AuthenticationResultMetadata.AuthenticationResultMetadataBuilder(tokenSource=" + this.tokenSource + ", refreshOn=" + this.refreshOn + ", cacheRefreshReason$value=" + this.cacheRefreshReason + ", tokenType=" + this.tokenType + ", bindingCertificate=" + this.bindingCertificate + ")"; | ||
| } | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.