-
Notifications
You must be signed in to change notification settings - Fork 159
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
Open
Robbie-Microsoft
wants to merge
11
commits into
dev
Choose a base branch
from
rginsburg/sni-mtls-pop
base: dev
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
d8cac48
Add SN/I certificate support over mTLS Proof-of-Possession (PoP)
Robbie-Microsoft 150a554
Extract FIC Leg 2 (mTLS PoP) into a follow-up PR
Robbie-Microsoft 68d3715
Suppress CredScan finding for self-signed mTLS test certificate
Robbie-Microsoft 66d59db
Fix mTLS PoP socket factory on JDK 8 and address review feedback
Robbie-Microsoft e6dee5f
Build mTLS PoP socket factory from a live key manager (support non-ex…
Robbie-Microsoft 288df02
Point MtlsPopIT at the SN/I-allow-listed app for mTLS PoP
Robbie-Microsoft 6bf6024
Fail closed on mTLS PoP token_type downgrade
Robbie-Microsoft f848b54
Remove unused telemetryValue from TokenType
Robbie-Microsoft 3ff929c
Align mTLS PoP with MSAL.NET: sovereign-cloud guard, cache metadata, …
Robbie-Microsoft 3ef7681
Align mTLS PoP cloud eligibility with MSAL.NET: allow Azure Gov + cur…
Robbie-Microsoft d576db6
Wrap non-base64 x5c decode as CertificateException in mTLS binding cert
Robbie-Microsoft File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,9 @@ | ||
| { | ||
| "tool": "Credential Scanner", | ||
| "suppressions": [ | ||
| { | ||
| "file": "msal4j-sdk/src/test/resources/mtls_test_cert.p12", | ||
| "_justification": "Self-signed, test-only certificate (CN=msal4j-mtls-test) used by unit tests for mTLS Proof-of-Possession. Contains no production secret." | ||
| } | ||
| ] | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
205 changes: 205 additions & 0 deletions
205
msal4j-sdk/src/integrationtest/java/com/microsoft/aad/msal4j/MtlsPopIT.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,205 @@ | ||
| // Copyright (c) Microsoft Corporation. All rights reserved. | ||
| // Licensed under the MIT License. | ||
|
|
||
| package com.microsoft.aad.msal4j; | ||
|
|
||
| import com.microsoft.aad.msal4j.labapi.KeyVaultSecretsProvider; | ||
| import org.junit.jupiter.api.Assumptions; | ||
| 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 java.util.concurrent.ExecutionException; | ||
|
|
||
| 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). | ||
| * | ||
| * <p><b>App/tenant requirement:</b> ESTS only issues mTLS PoP tokens for the SN/I-allow-listed app in the | ||
| * MSI team tenant. This mirrors MSAL .NET's {@code ClientCredentialsMtlsPopTests}: any other app (e.g. the | ||
| * general {@code LabVaultAppID}) is rejected with {@code AADSTS700025: Client is public...}. The lab cert | ||
| * ({@link KeyVaultSecretsProvider#CERTIFICATE_ALIAS}) is registered for SN/I on this app. | ||
| */ | ||
| @TestInstance(TestInstance.Lifecycle.PER_CLASS) | ||
| class MtlsPopIT { | ||
|
|
||
| // SN/I-allow-listed app and MSI team tenant. mTLS PoP only works on this app/tenant pair; mirrors | ||
| // MSAL .NET's ClientCredentialsMtlsPopTests. App and tenant IDs are public identifiers, not secrets. | ||
| private static final String SNI_ALLOWLISTED_APP_ID = "163ffef9-a313-45b4-ab2f-c7e2f5e0e23e"; | ||
| private static final String SNI_ALLOWLISTED_AUTHORITY = | ||
| "https://login.microsoftonline.com/bea21ebe-8b64-4d06-9f6d-6a889b120a7c"; | ||
| private static final String TEST_SLICE_REGION = "westus3"; | ||
|
|
||
| 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 { | ||
| ConfidentialClientApplication cca = ConfidentialClientApplication.builder(SNI_ALLOWLISTED_APP_ID, certificate) | ||
| .authority(SNI_ALLOWLISTED_AUTHORITY) // tenanted authority (required for mTLS PoP) | ||
| .build(); | ||
|
|
||
| IAuthenticationResult result = acquireMtlsPopOrSkipOnDowngrade(cca, ClientCredentialParameters | ||
| .builder(Collections.singleton(KEYVAULT_DEFAULT_SCOPE)) | ||
| .mtlsProofOfPossession() | ||
| .build()); | ||
|
|
||
| assertMtlsPopResult(result, expectedLabThumbprint()); | ||
| } | ||
|
|
||
| /** | ||
| * 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 { | ||
| ConfidentialClientApplication cca = ConfidentialClientApplication.builder(SNI_ALLOWLISTED_APP_ID, certificate) | ||
| .authority(SNI_ALLOWLISTED_AUTHORITY) | ||
| .azureRegion(TEST_SLICE_REGION) | ||
| .build(); | ||
|
|
||
| IAuthenticationResult result = acquireMtlsPopOrSkipOnDowngrade(cca, ClientCredentialParameters | ||
| .builder(Collections.singleton(KEYVAULT_DEFAULT_SCOPE)) | ||
| .mtlsProofOfPossession() | ||
| .build()); | ||
|
|
||
| assertMtlsPopResult(result, expectedLabThumbprint()); | ||
|
|
||
| // The mTLS-PoP token must be cached under {token_type + cert KeyId} and returned on lookup. | ||
| IAuthenticationResult cached = acquireMtlsPopOrSkipOnDowngrade(cca, ClientCredentialParameters | ||
| .builder(Collections.singleton(KEYVAULT_DEFAULT_SCOPE)) | ||
| .mtlsProofOfPossession() | ||
| .build()); | ||
|
|
||
| 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 { | ||
| ConfidentialClientApplication cca = ConfidentialClientApplication.builder(SNI_ALLOWLISTED_APP_ID, certificate) | ||
| .authority(SNI_ALLOWLISTED_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 = acquireMtlsPopOrSkipOnDowngrade(cca, ClientCredentialParameters | ||
| .builder(Collections.singleton(KEYVAULT_DEFAULT_SCOPE)) | ||
| .mtlsProofOfPossession() | ||
| .build()); | ||
| 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"); | ||
| } | ||
|
|
||
| // ESTS's mTLS PoP test slice is a known intermittent token_type downgrader. When it returns a | ||
| // non-mtls_pop token the access token is not certificate-bound, and MSAL now fails closed with | ||
| // TOKEN_TYPE_MISMATCH. Treat that specific outcome as inconclusive (skip) rather than a hard failure, | ||
| // mirroring MSAL .NET's ExecuteOrInconclusiveOnTokenTypeMismatchAsync. | ||
| private static IAuthenticationResult acquireMtlsPopOrSkipOnDowngrade( | ||
| ConfidentialClientApplication cca, ClientCredentialParameters parameters) throws Exception { | ||
| try { | ||
| return cca.acquireToken(parameters).get(); | ||
| } catch (ExecutionException e) { | ||
| if (e.getCause() instanceof MsalClientException | ||
| && AuthenticationErrorCode.TOKEN_TYPE_MISMATCH.equals( | ||
| ((MsalClientException) e.getCause()).errorCode())) { | ||
| Assumptions.abort("ESTS returned a non-mtls_pop token_type (downgrade); treating as " | ||
| + "inconclusive: " + e.getCause().getMessage()); | ||
| } | ||
| throw e; | ||
| } | ||
| } | ||
|
|
||
| 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); | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
we have so much reliance on this MSI team's test tenant. Can we have our own SNI test setup in IDLABS1?