diff --git a/src/client/Microsoft.Identity.Client/ApiConfig/AcquireTokenForClientParameterBuilder.cs b/src/client/Microsoft.Identity.Client/ApiConfig/AcquireTokenForClientParameterBuilder.cs index 195b02a17a..d23792ff9f 100644 --- a/src/client/Microsoft.Identity.Client/ApiConfig/AcquireTokenForClientParameterBuilder.cs +++ b/src/client/Microsoft.Identity.Client/ApiConfig/AcquireTokenForClientParameterBuilder.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. using System; @@ -15,6 +15,7 @@ using Microsoft.Identity.Client.Utils; using Microsoft.Identity.Client.Extensibility; using Microsoft.Identity.Client.OAuth2; +using System.Security.Cryptography.X509Certificates; using System.Security.Cryptography; using System.Text; @@ -98,18 +99,20 @@ public AcquireTokenForClientParameterBuilder WithSendX5C(bool withSendX5C) /// The current instance of to enable method chaining. public AcquireTokenForClientParameterBuilder WithMtlsProofOfPossession() { - if (ServiceBundle.Config.ClientCredential is not CertificateClientCredential certificateCredential) + if (ServiceBundle.Config.ClientCredential is CertificateClientCredential certificateCredential) { - throw new MsalClientException( + if (certificateCredential.Certificate == null) + { + throw new MsalClientException( MsalError.MtlsCertificateNotProvided, MsalErrorMessage.MtlsCertificateNotProvidedMessage); - } - else - { + } + CommonParameters.AuthenticationOperation = new MtlsPopAuthenticationOperation(certificateCredential.Certificate); - CommonParameters.MtlsCertificate = certificateCredential.Certificate; + CommonParameters.MtlsCertificate = certificateCredential.Certificate; } + CommonParameters.IsMtlsPopRequested = true; return this; } diff --git a/src/client/Microsoft.Identity.Client/ApiConfig/Executors/ConfidentialClientExecutor.cs b/src/client/Microsoft.Identity.Client/ApiConfig/Executors/ConfidentialClientExecutor.cs index 93e8eaf785..82b6a0be09 100644 --- a/src/client/Microsoft.Identity.Client/ApiConfig/Executors/ConfidentialClientExecutor.cs +++ b/src/client/Microsoft.Identity.Client/ApiConfig/Executors/ConfidentialClientExecutor.cs @@ -2,12 +2,15 @@ // Licensed under the MIT License. using System; +using System.Security.Cryptography.X509Certificates; using System.Threading; using System.Threading.Tasks; using Microsoft.Identity.Client.ApiConfig.Parameters; using Microsoft.Identity.Client.AuthScheme.PoP; using Microsoft.Identity.Client.Instance.Discovery; +using Microsoft.Identity.Client.Instance.Validation; using Microsoft.Identity.Client.Internal; +using Microsoft.Identity.Client.Internal.ClientCredential; using Microsoft.Identity.Client.Internal.Requests; using Microsoft.Identity.Client.ManagedIdentity; using Microsoft.Identity.Client.Utils; @@ -56,6 +59,9 @@ public async Task ExecuteAsync( AcquireTokenForClientParameters clientParameters, CancellationToken cancellationToken) { + await commonParameters.InitMtlsPopParametersAsync(ServiceBundle, cancellationToken) + .ConfigureAwait(false); + RequestContext requestContext = CreateRequestContextAndLogVersionInfo(commonParameters.CorrelationId, commonParameters.MtlsCertificate, cancellationToken); AuthenticationRequestParameters requestParams = await _confidentialClientApplication.CreateRequestParametersAsync( diff --git a/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenCommonParameters.cs b/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenCommonParameters.cs index d9820cef8e..c9ba8a3037 100644 --- a/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenCommonParameters.cs +++ b/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenCommonParameters.cs @@ -9,8 +9,12 @@ using Microsoft.Identity.Client.AppConfig; using Microsoft.Identity.Client.AuthScheme; using Microsoft.Identity.Client.AuthScheme.Bearer; +using Microsoft.Identity.Client.AuthScheme.PoP; using Microsoft.Identity.Client.Extensibility; +using Microsoft.Identity.Client.Internal; +using Microsoft.Identity.Client.Internal.ClientCredential; using Microsoft.Identity.Client.TelemetryCore.Internal.Events; +using Microsoft.Identity.Client.Utils; using static Microsoft.Identity.Client.Extensibility.AbstractConfidentialClientAcquireTokenParameterBuilderExtension; namespace Microsoft.Identity.Client.ApiConfig.Parameters @@ -34,5 +38,75 @@ internal class AcquireTokenCommonParameters public SortedList>> CacheKeyComponents { get; internal set; } public string FmiPathSuffix { get; internal set; } public string ClientAssertionFmiPath { get; internal set; } + public bool IsMtlsPopRequested { get; set; } + + internal async Task InitMtlsPopParametersAsync(IServiceBundle serviceBundle, CancellationToken ct) + { + if (!IsMtlsPopRequested) + { + return; // PoP not requested + } + + // ──────────────────────────────────── + // Case 1 – Certificate credential + // ──────────────────────────────────── + if (serviceBundle.Config.ClientCredential is CertificateClientCredential certCred) + { + if (certCred.Certificate == null) + { + throw new MsalClientException( + MsalError.MtlsCertificateNotProvided, + MsalErrorMessage.MtlsCertificateNotProvidedMessage); + } + + return; + } + + // ──────────────────────────────────── + // Case 2 – Client‑assertion delegate + // ──────────────────────────────────── + if (serviceBundle.Config.ClientCredential is ClientAssertionDelegateCredential cadc) + { + var opts = new AssertionRequestOptions + { + ClientID = serviceBundle.Config.ClientId, + ClientCapabilities = serviceBundle.Config.ClientCapabilities, + Claims = Claims, + CancellationToken = ct + }; + + ClientAssertion ar = await cadc.GetAssertionAsync(opts, ct).ConfigureAwait(false); + + if (ar.TokenBindingCertificate == null) + { + throw new MsalClientException( + MsalError.MtlsCertificateNotProvided, + MsalErrorMessage.MtlsCertificateNotProvidedMessage); + } + + InitMtlsPopParameters(ar.TokenBindingCertificate, serviceBundle); + return; + } + + // ──────────────────────────────────── + // Case 3 – Any other credential (client‑secret etc.) + // ──────────────────────────────────── + throw new MsalClientException( + MsalError.MtlsCertificateNotProvided, + MsalErrorMessage.MtlsCertificateNotProvidedMessage); + } + + private void InitMtlsPopParameters(X509Certificate2 cert, IServiceBundle serviceBundle) + { + // region check (AAD only) + if (serviceBundle.Config.Authority.AuthorityInfo.AuthorityType == AuthorityType.Aad && + serviceBundle.Config.AzureRegion == null) + { + throw new MsalClientException(MsalError.MtlsPopWithoutRegion, MsalErrorMessage.MtlsPopWithoutRegion); + } + + AuthenticationOperation = new MtlsPopAuthenticationOperation(cert); + MtlsCertificate = cert; + } } } diff --git a/src/client/Microsoft.Identity.Client/AppConfig/ClientAssertion.cs b/src/client/Microsoft.Identity.Client/AppConfig/ClientAssertion.cs new file mode 100644 index 0000000000..74b386be1f --- /dev/null +++ b/src/client/Microsoft.Identity.Client/AppConfig/ClientAssertion.cs @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Security.Cryptography.X509Certificates; + +namespace Microsoft.Identity.Client +{ + /// + /// Container returned from WithClientAssertion. + /// + public class ClientAssertion + { + /// + /// Represents the client assertion (JWT) and optional mutual‑TLS binding certificate returned + /// by the clientAssertionProvider callback supplied to + /// . + /// + /// + /// MSAL forwards to the token endpoint as the client_assertion parameter. + /// When mutual‑TLS Proof‑of‑Possession (PoP) is enabled on the application and a + /// is provided, MSAL sets client_assertion_type to + /// urn:ietf:params:oauth:client-assertion-type:jwt-pop; otherwise it uses jwt-bearer. + ///

+ /// Guidance on constructing the client assertion (required claims, audience, and lifetime) is available at + /// aka.ms/msal-net-client-assertion. + /// The assertion is created by your callback; MSAL does not modify or re‑sign it. + /// **Note:** It is up to the caller to cache the assertion and certificate if reuse is desired. + ///
+ public string Assertion { get; set; } + + /// + /// Optional. Certificate used to bind the client assertion for mutual‑TLS Proof‑of‑Possession (PoP). + /// + /// + /// Provide a value only when PoP is enabled on the application. The certificate should include an + /// accessible private key. If null, MSAL treats the assertion as a bearer assertion and uses + /// client_assertion_type=jwt-bearer. + /// + public X509Certificate2 TokenBindingCertificate { get; set; } + } +} diff --git a/src/client/Microsoft.Identity.Client/AppConfig/ConfidentialClientApplicationBuilder.cs b/src/client/Microsoft.Identity.Client/AppConfig/ConfidentialClientApplicationBuilder.cs index c5ceb0168e..2825efd708 100644 --- a/src/client/Microsoft.Identity.Client/AppConfig/ConfidentialClientApplicationBuilder.cs +++ b/src/client/Microsoft.Identity.Client/AppConfig/ConfidentialClientApplicationBuilder.cs @@ -228,13 +228,12 @@ public ConfidentialClientApplicationBuilder WithClientAssertion(Func cli throw new ArgumentNullException(nameof(clientAssertionDelegate)); } - Func> clientAssertionAsyncDelegate = (_) => - { - return Task.FromResult(clientAssertionDelegate()); - }; - - Config.ClientCredential = new SignedAssertionDelegateClientCredential(clientAssertionAsyncDelegate); - return this; + return WithClientAssertion( + (opts, ct) => + Task.FromResult(new ClientAssertion + { + Assertion = clientAssertionDelegate() // bearer + })); } /// @@ -252,8 +251,12 @@ public ConfidentialClientApplicationBuilder WithClientAssertion(Func + { + string jwt = await clientAssertionAsyncDelegate(ct).ConfigureAwait(false); + return new ClientAssertion { Assertion = jwt }; // bearer + }); } /// @@ -270,7 +273,30 @@ public ConfidentialClientApplicationBuilder WithClientAssertion(Func + { + string jwt = await clientAssertionAsyncDelegate(opts).ConfigureAwait(false); + return new ClientAssertion { Assertion = jwt }; // bearer + }); + } + + /// + /// Configures the client application to use a client assertion for authentication. + /// + /// This method allows the client application to authenticate using a custom client + /// assertion, which can be useful in scenarios where the assertion needs to be dynamically generated or + /// retrieved. + /// A delegate that asynchronously provides an based on the given and . This delegate must not be . + /// The instance configured with the specified client + /// assertion. + /// Thrown if is . + public ConfidentialClientApplicationBuilder WithClientAssertion(Func> clientAssertionProvider) + { + Config.ClientCredential = new ClientAssertionDelegateCredential(clientAssertionProvider); return this; } diff --git a/src/client/Microsoft.Identity.Client/AuthScheme/PoP/MtlsPopAuthenticationOperation.cs b/src/client/Microsoft.Identity.Client/AuthScheme/PoP/MtlsPopAuthenticationOperation.cs index 7ce35c6d25..980ac388f7 100644 --- a/src/client/Microsoft.Identity.Client/AuthScheme/PoP/MtlsPopAuthenticationOperation.cs +++ b/src/client/Microsoft.Identity.Client/AuthScheme/PoP/MtlsPopAuthenticationOperation.cs @@ -17,7 +17,7 @@ internal class MtlsPopAuthenticationOperation : IAuthenticationOperation public MtlsPopAuthenticationOperation(X509Certificate2 mtlsCert) { _mtlsCert = mtlsCert; - KeyId = ComputeX5tS256KeyId(_mtlsCert); + KeyId = CoreHelpers.ComputeX5tS256KeyId(_mtlsCert); } public int TelemetryTokenType => TelemetryTokenTypeConstants.MtlsPop; @@ -40,20 +40,5 @@ public void FormatResult(AuthenticationResult authenticationResult) { authenticationResult.BindingCertificate = _mtlsCert; } - - private static string ComputeX5tS256KeyId(X509Certificate2 certificate) - { - // Extract the raw bytes of the certificate’s public key. - var publicKey = certificate.GetPublicKey(); - - // Compute the SHA-256 hash of the public key. - using (var sha256 = SHA256.Create()) - { - byte[] hash = sha256.ComputeHash(publicKey); - - // Return the hash encoded in Base64 URL format. - return Base64UrlHelpers.Encode(hash); - } - } } } diff --git a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientAssertionDelegateCredential.cs b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientAssertionDelegateCredential.cs new file mode 100644 index 0000000000..e6c3308f17 --- /dev/null +++ b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientAssertionDelegateCredential.cs @@ -0,0 +1,86 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Security.Cryptography.X509Certificates; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Identity.Client; +using Microsoft.Identity.Client.AuthScheme.PoP; +using Microsoft.Identity.Client.Core; +using Microsoft.Identity.Client.Internal.Requests; +using Microsoft.Identity.Client.OAuth2; +using Microsoft.Identity.Client.PlatformsCommon.Interfaces; +using Microsoft.Identity.Client.TelemetryCore; + +namespace Microsoft.Identity.Client.Internal.ClientCredential +{ + /// + /// Handles client assertions supplied via a delegate that returns an + /// (JWT + optional certificate bound for mTLS‑PoP). + /// + internal sealed class ClientAssertionDelegateCredential : IClientCredential + { + private readonly Func> _provider; + + internal Task GetAssertionAsync( + AssertionRequestOptions options, + CancellationToken cancellationToken) => + _provider(options, cancellationToken); + + public ClientAssertionDelegateCredential( + Func> provider) + { + _provider = provider ?? throw new ArgumentNullException(nameof(provider)); + } + + public AssertionType AssertionType => AssertionType.ClientAssertion; + + // ────────────────────────────────── + // Main hook for token requests + // ────────────────────────────────── + public async Task AddConfidentialClientParametersAsync( + OAuth2Client oAuth2Client, + AuthenticationRequestParameters p, + ICryptographyManager _, + string tokenEndpoint, + CancellationToken ct) + { + var opts = new AssertionRequestOptions + { + CancellationToken = ct, + ClientID = p.AppConfig.ClientId, + TokenEndpoint = tokenEndpoint, + ClientCapabilities = p.RequestContext.ServiceBundle.Config.ClientCapabilities, + Claims = p.Claims, + ClientAssertionFmiPath = p.ClientAssertionFmiPath + }; + + ClientAssertion resp = await _provider(opts, ct).ConfigureAwait(false); + + if (string.IsNullOrWhiteSpace(resp?.Assertion)) + { + throw new MsalClientException(MsalError.InvalidClientAssertion, + MsalErrorMessage.InvalidClientAssertionEmpty); + } + + // Decide bearer vs mTLS PoP + bool IsMtlsPopRequested = p.IsMtlsPopRequested; + + if (IsMtlsPopRequested && resp.TokenBindingCertificate != null) + { + oAuth2Client.AddBodyParameter( + OAuth2Parameter.ClientAssertionType, + OAuth2AssertionType.JwtPop /* constant added in OAuth2AssertionType */); + } + else + { + oAuth2Client.AddBodyParameter( + OAuth2Parameter.ClientAssertionType, + OAuth2AssertionType.JwtBearer); + } + + oAuth2Client.AddBodyParameter(OAuth2Parameter.ClientAssertion, resp.Assertion); + } + } +} diff --git a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/SignedAssertionDelegateClientCredential.cs b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/SignedAssertionDelegateClientCredential.cs deleted file mode 100644 index d1dd0e3d9b..0000000000 --- a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/SignedAssertionDelegateClientCredential.cs +++ /dev/null @@ -1,81 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -using System; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Identity.Client.Core; -using Microsoft.Identity.Client.Internal.Requests; -using Microsoft.Identity.Client.OAuth2; -using Microsoft.Identity.Client.PlatformsCommon.Interfaces; -using Microsoft.Identity.Client.TelemetryCore; - -namespace Microsoft.Identity.Client.Internal.ClientCredential -{ - internal class SignedAssertionDelegateClientCredential : IClientCredential - { - internal Func> _signedAssertionDelegate { get; } - internal Func> _signedAssertionWithInfoDelegate { get; } - public AssertionType AssertionType => AssertionType.ClientAssertion; - - [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] - public SignedAssertionDelegateClientCredential(Func> signedAssertionDelegate) - { - _signedAssertionDelegate = signedAssertionDelegate; - } - - public SignedAssertionDelegateClientCredential(Func> signedAssertionDelegate) - { - _signedAssertionWithInfoDelegate = signedAssertionDelegate ?? throw new ArgumentNullException(nameof(signedAssertionDelegate), - "Signed assertion delegate cannot be null."); - } - - public async Task AddConfidentialClientParametersAsync( - OAuth2Client oAuth2Client, - AuthenticationRequestParameters requestParameters, - ICryptographyManager cryptographyManager, - string tokenEndpoint, - CancellationToken cancellationToken) - { - if (_signedAssertionDelegate != null) - { - // If no "AssertionRequestOptions" delegate is supplied - string signedAssertion = await _signedAssertionDelegate(cancellationToken).ConfigureAwait(false); - oAuth2Client.AddBodyParameter(OAuth2Parameter.ClientAssertionType, OAuth2AssertionType.JwtBearer); - oAuth2Client.AddBodyParameter(OAuth2Parameter.ClientAssertion, signedAssertion); - } - else - { - // Build the AssertionRequestOptions and conditionally set ClientCapabilities - var assertionOptions = new AssertionRequestOptions - { - CancellationToken = cancellationToken, - ClientID = requestParameters.AppConfig.ClientId, - TokenEndpoint = tokenEndpoint - }; - - // Set client capabilities - var configuredCapabilities = requestParameters - .RequestContext - .ServiceBundle - .Config - .ClientCapabilities; - - assertionOptions.ClientCapabilities = configuredCapabilities; - - //Set claims - assertionOptions.Claims = requestParameters.Claims; - - //Set client assertion FMI path - assertionOptions.ClientAssertionFmiPath = requestParameters.ClientAssertionFmiPath; - - // Delegate that uses AssertionRequestOptions - string signedAssertion = await _signedAssertionWithInfoDelegate(assertionOptions).ConfigureAwait(false); - - oAuth2Client.AddBodyParameter(OAuth2Parameter.ClientAssertionType, OAuth2AssertionType.JwtBearer); - oAuth2Client.AddBodyParameter(OAuth2Parameter.ClientAssertion, signedAssertion); - } - } - } -} diff --git a/src/client/Microsoft.Identity.Client/Internal/Requests/AuthenticationRequestParameters.cs b/src/client/Microsoft.Identity.Client/Internal/Requests/AuthenticationRequestParameters.cs index a497158134..3619c73864 100644 --- a/src/client/Microsoft.Identity.Client/Internal/Requests/AuthenticationRequestParameters.cs +++ b/src/client/Microsoft.Identity.Client/Internal/Requests/AuthenticationRequestParameters.cs @@ -113,6 +113,8 @@ public AuthenticationRequestParameters( public X509Certificate2 MtlsCertificate => _commonParameters.MtlsCertificate; + public bool IsMtlsPopRequested => _commonParameters.IsMtlsPopRequested; + /// /// Indicates if the user configured claims via .WithClaims. Not affected by Client Capabilities /// diff --git a/src/client/Microsoft.Identity.Client/Internal/Requests/ClientCredentialRequest.cs b/src/client/Microsoft.Identity.Client/Internal/Requests/ClientCredentialRequest.cs index 34948dcb71..48662d8f42 100644 --- a/src/client/Microsoft.Identity.Client/Internal/Requests/ClientCredentialRequest.cs +++ b/src/client/Microsoft.Identity.Client/Internal/Requests/ClientCredentialRequest.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; using System.Text; using System.Text.RegularExpressions; using System.Threading; @@ -13,6 +14,8 @@ using Microsoft.Identity.Client.Core; using Microsoft.Identity.Client.Extensibility; using Microsoft.Identity.Client.Instance; +using Microsoft.Identity.Client.Internal.ClientCredential; +using Microsoft.Identity.Client.Internal.Requests; using Microsoft.Identity.Client.OAuth2; using Microsoft.Identity.Client.PlatformsCommon.Interfaces; using Microsoft.Identity.Client.Utils; @@ -24,7 +27,7 @@ internal class ClientCredentialRequest : RequestBase private readonly AcquireTokenForClientParameters _clientParameters; private static readonly SemaphoreSlim s_semaphoreSlim = new SemaphoreSlim(1, 1); private readonly ICryptographyManager _cryptoManager; - + public ClientCredentialRequest( IServiceBundle serviceBundle, AuthenticationRequestParameters authenticationRequestParameters, @@ -236,7 +239,28 @@ private bool ShouldUseCachedToken(MsalAccessTokenCacheItem cacheItem) return false; } - // 2) If the token’s hash matches AccessTokenHashToRefresh, ignore it + // 2) If an mTLS cert is supplied for THIS request, reuse cache only if + // the cached token's KeyId matches the one provided in the request. + X509Certificate2 requestCert = AuthenticationRequestParameters.MtlsCertificate; + + if (requestCert != null) + { + string expectedKid = CoreHelpers.ComputeX5tS256KeyId(requestCert); + + // If the certificate cannot produce a valid KeyId (SPKI-SHA256), expectedKid will be null or empty. + // In this case, the cache will be bypassed, as we cannot safely match the cached token to the certificate. + if (!string.Equals(cacheItem.KeyId, expectedKid, StringComparison.Ordinal)) + { + AuthenticationRequestParameters.RequestContext.Logger.Verbose(() => + "[ClientCredentialRequest] Cached token KeyId does not match request certificate (SPKI-SHA256 mismatch). Bypassing cache."); + return false; + } + + AuthenticationRequestParameters.RequestContext.Logger.Verbose(() => + "[ClientCredentialRequest] Cached token KeyId matches request certificate (SPKI-SHA256). Using cached token."); + } + + // 3) If the token’s hash matches AccessTokenHashToRefresh, ignore it if (!string.IsNullOrEmpty(_clientParameters.AccessTokenHashToRefresh) && IsMatchingTokenHash(cacheItem.Secret, _clientParameters.AccessTokenHashToRefresh)) { diff --git a/src/client/Microsoft.Identity.Client/MsalError.cs b/src/client/Microsoft.Identity.Client/MsalError.cs index 33cc9dc035..4bcc576a42 100644 --- a/src/client/Microsoft.Identity.Client/MsalError.cs +++ b/src/client/Microsoft.Identity.Client/MsalError.cs @@ -61,6 +61,14 @@ public static class MsalError /// public const string UserAssertionNullError = "user_assertion_null"; + /// + /// Returned when a WithClientAssertion delegate provides + /// a or empty JWT string. + /// Mitigation + /// Ensure the delegate returns a non‑empty, base‑64‑encoded JWT. + /// + public const string InvalidClientAssertion = "invalid_client_assertion"; + /// /// This error code comes back from calls when the /// user is passed as the account parameter. Only some brokers (WAM) can login the current user. diff --git a/src/client/Microsoft.Identity.Client/MsalErrorMessage.cs b/src/client/Microsoft.Identity.Client/MsalErrorMessage.cs index d5b61c67e8..883b9ab30f 100644 --- a/src/client/Microsoft.Identity.Client/MsalErrorMessage.cs +++ b/src/client/Microsoft.Identity.Client/MsalErrorMessage.cs @@ -89,6 +89,8 @@ public static string iOSBrokerKeySaveFailed(string keyChainResult) public const string UserMismatch = "User '{0}' returned by service does not match user '{1}' in the request. "; public const string UserCredentialAssertionTypeEmpty = "credential.AssertionType cannot be empty. "; + public const string InvalidClientAssertionEmpty = "Client‑assertion JWT cannot be null or empty. Verify the delegate supplied to WithClientAssertion returns a valid signed JWT."; + public const string NoPromptFailedErrorMessage = "One of two conditions was encountered: " + @@ -436,7 +438,7 @@ public static string InvalidTokenProviderResponseValue(string invalidValueName) public const string ClaimsChallenge = "The returned error contains a claims challenge. For additional info on how to handle claims related to multifactor authentication, Conditional Access, and incremental consent, see https://aka.ms/msal-conditional-access-claims. If you are using the On-Behalf-Of flow, see https://aka.ms/msal-conditional-access-claims-obo for details."; public const string CryptographicError = "A cryptographic exception occurred. Possible cause: the certificate has been disposed. See inner exception for full details."; public const string MtlsPopWithoutRegion = "mTLS Proof of Possession requires a region to be specified. Please set AzureRegion in the configuration at the application level."; - public const string MtlsCertificateNotProvidedMessage = "mTLS Proof of Possession requires a certificate to be configured. Please provide a certificate at the application level using the .WithCertificate() instead of passing an assertion. See https://aka.ms/msal-net-pop for details."; + public const string MtlsCertificateNotProvidedMessage = "mTLS Proof‑of‑Possession requires a certificate for this request. Either configure the application with .WithCertificate(...) or pass a certificate‑bound client‑assertion and chain .WithMtlsProofOfPossession() on the request builder. See https://aka.ms/msal-net-pop for details."; public const string MtlsInvalidAuthorityTypeMessage = "mTLS PoP is only supported for AAD authority type. See https://aka.ms/msal-net-pop for details."; public const string MtlsNonTenantedAuthorityNotAllowedMessage = "mTLS authentication requires a tenanted authority. Using 'common', 'organizations', or similar non-tenanted authorities is not allowed. Please provide an authority with a specific tenant ID (e.g., 'https://login.microsoftonline.com/{tenantId}'). See https://aka.ms/msal-net-pop for details."; public const string RegionRequiredForMtlsPopMessage = "Regional auto-detect failed. mTLS Proof-of-Possession requires a region to be specified, as there is no global endpoint for mTLS. See https://aka.ms/msal-net-pop for details."; diff --git a/src/client/Microsoft.Identity.Client/OAuth2/OAuthConstants.cs b/src/client/Microsoft.Identity.Client/OAuth2/OAuthConstants.cs index 90d20f4a15..7aa76e3cd1 100644 --- a/src/client/Microsoft.Identity.Client/OAuth2/OAuthConstants.cs +++ b/src/client/Microsoft.Identity.Client/OAuth2/OAuthConstants.cs @@ -66,6 +66,7 @@ internal static class OAuth2ResponseType internal static class OAuth2AssertionType { public const string JwtBearer = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"; + public const string JwtPop = "urn:ietf:params:oauth:client-assertion-type:jwt-pop"; } internal static class OAuth2RequestedTokenUse diff --git a/src/client/Microsoft.Identity.Client/PublicApi/net462/PublicAPI.Unshipped.txt b/src/client/Microsoft.Identity.Client/PublicApi/net462/PublicAPI.Unshipped.txt index d02c6b8081..9a7ea38ff9 100644 --- a/src/client/Microsoft.Identity.Client/PublicApi/net462/PublicAPI.Unshipped.txt +++ b/src/client/Microsoft.Identity.Client/PublicApi/net462/PublicAPI.Unshipped.txt @@ -1 +1,9 @@ +const Microsoft.Identity.Client.MsalError.InvalidClientAssertion = "invalid_client_assertion" -> string +Microsoft.Identity.Client.ClientAssertion +Microsoft.Identity.Client.ClientAssertion.Assertion.get -> string +Microsoft.Identity.Client.ClientAssertion.Assertion.set -> void +Microsoft.Identity.Client.ClientAssertion.ClientAssertion() -> void +Microsoft.Identity.Client.ClientAssertion.TokenBindingCertificate.get -> System.Security.Cryptography.X509Certificates.X509Certificate2 +Microsoft.Identity.Client.ClientAssertion.TokenBindingCertificate.set -> void +Microsoft.Identity.Client.ConfidentialClientApplicationBuilder.WithClientAssertion(System.Func> clientAssertionProvider) -> Microsoft.Identity.Client.ConfidentialClientApplicationBuilder static Microsoft.Identity.Client.Extensibility.AcquireTokenForClientBuilderExtensions.WithExtraBodyParameters(this Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder builder, System.Collections.Generic.Dictionary>> extrabodyparams) -> Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder diff --git a/src/client/Microsoft.Identity.Client/PublicApi/net472/PublicAPI.Unshipped.txt b/src/client/Microsoft.Identity.Client/PublicApi/net472/PublicAPI.Unshipped.txt index d02c6b8081..9a7ea38ff9 100644 --- a/src/client/Microsoft.Identity.Client/PublicApi/net472/PublicAPI.Unshipped.txt +++ b/src/client/Microsoft.Identity.Client/PublicApi/net472/PublicAPI.Unshipped.txt @@ -1 +1,9 @@ +const Microsoft.Identity.Client.MsalError.InvalidClientAssertion = "invalid_client_assertion" -> string +Microsoft.Identity.Client.ClientAssertion +Microsoft.Identity.Client.ClientAssertion.Assertion.get -> string +Microsoft.Identity.Client.ClientAssertion.Assertion.set -> void +Microsoft.Identity.Client.ClientAssertion.ClientAssertion() -> void +Microsoft.Identity.Client.ClientAssertion.TokenBindingCertificate.get -> System.Security.Cryptography.X509Certificates.X509Certificate2 +Microsoft.Identity.Client.ClientAssertion.TokenBindingCertificate.set -> void +Microsoft.Identity.Client.ConfidentialClientApplicationBuilder.WithClientAssertion(System.Func> clientAssertionProvider) -> Microsoft.Identity.Client.ConfidentialClientApplicationBuilder static Microsoft.Identity.Client.Extensibility.AcquireTokenForClientBuilderExtensions.WithExtraBodyParameters(this Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder builder, System.Collections.Generic.Dictionary>> extrabodyparams) -> Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder diff --git a/src/client/Microsoft.Identity.Client/PublicApi/net8.0-android/PublicAPI.Unshipped.txt b/src/client/Microsoft.Identity.Client/PublicApi/net8.0-android/PublicAPI.Unshipped.txt index d02c6b8081..9a7ea38ff9 100644 --- a/src/client/Microsoft.Identity.Client/PublicApi/net8.0-android/PublicAPI.Unshipped.txt +++ b/src/client/Microsoft.Identity.Client/PublicApi/net8.0-android/PublicAPI.Unshipped.txt @@ -1 +1,9 @@ +const Microsoft.Identity.Client.MsalError.InvalidClientAssertion = "invalid_client_assertion" -> string +Microsoft.Identity.Client.ClientAssertion +Microsoft.Identity.Client.ClientAssertion.Assertion.get -> string +Microsoft.Identity.Client.ClientAssertion.Assertion.set -> void +Microsoft.Identity.Client.ClientAssertion.ClientAssertion() -> void +Microsoft.Identity.Client.ClientAssertion.TokenBindingCertificate.get -> System.Security.Cryptography.X509Certificates.X509Certificate2 +Microsoft.Identity.Client.ClientAssertion.TokenBindingCertificate.set -> void +Microsoft.Identity.Client.ConfidentialClientApplicationBuilder.WithClientAssertion(System.Func> clientAssertionProvider) -> Microsoft.Identity.Client.ConfidentialClientApplicationBuilder static Microsoft.Identity.Client.Extensibility.AcquireTokenForClientBuilderExtensions.WithExtraBodyParameters(this Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder builder, System.Collections.Generic.Dictionary>> extrabodyparams) -> Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder diff --git a/src/client/Microsoft.Identity.Client/PublicApi/net8.0-ios/PublicAPI.Unshipped.txt b/src/client/Microsoft.Identity.Client/PublicApi/net8.0-ios/PublicAPI.Unshipped.txt index d02c6b8081..9a7ea38ff9 100644 --- a/src/client/Microsoft.Identity.Client/PublicApi/net8.0-ios/PublicAPI.Unshipped.txt +++ b/src/client/Microsoft.Identity.Client/PublicApi/net8.0-ios/PublicAPI.Unshipped.txt @@ -1 +1,9 @@ +const Microsoft.Identity.Client.MsalError.InvalidClientAssertion = "invalid_client_assertion" -> string +Microsoft.Identity.Client.ClientAssertion +Microsoft.Identity.Client.ClientAssertion.Assertion.get -> string +Microsoft.Identity.Client.ClientAssertion.Assertion.set -> void +Microsoft.Identity.Client.ClientAssertion.ClientAssertion() -> void +Microsoft.Identity.Client.ClientAssertion.TokenBindingCertificate.get -> System.Security.Cryptography.X509Certificates.X509Certificate2 +Microsoft.Identity.Client.ClientAssertion.TokenBindingCertificate.set -> void +Microsoft.Identity.Client.ConfidentialClientApplicationBuilder.WithClientAssertion(System.Func> clientAssertionProvider) -> Microsoft.Identity.Client.ConfidentialClientApplicationBuilder static Microsoft.Identity.Client.Extensibility.AcquireTokenForClientBuilderExtensions.WithExtraBodyParameters(this Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder builder, System.Collections.Generic.Dictionary>> extrabodyparams) -> Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder diff --git a/src/client/Microsoft.Identity.Client/PublicApi/net8.0/PublicAPI.Unshipped.txt b/src/client/Microsoft.Identity.Client/PublicApi/net8.0/PublicAPI.Unshipped.txt index d02c6b8081..9a7ea38ff9 100644 --- a/src/client/Microsoft.Identity.Client/PublicApi/net8.0/PublicAPI.Unshipped.txt +++ b/src/client/Microsoft.Identity.Client/PublicApi/net8.0/PublicAPI.Unshipped.txt @@ -1 +1,9 @@ +const Microsoft.Identity.Client.MsalError.InvalidClientAssertion = "invalid_client_assertion" -> string +Microsoft.Identity.Client.ClientAssertion +Microsoft.Identity.Client.ClientAssertion.Assertion.get -> string +Microsoft.Identity.Client.ClientAssertion.Assertion.set -> void +Microsoft.Identity.Client.ClientAssertion.ClientAssertion() -> void +Microsoft.Identity.Client.ClientAssertion.TokenBindingCertificate.get -> System.Security.Cryptography.X509Certificates.X509Certificate2 +Microsoft.Identity.Client.ClientAssertion.TokenBindingCertificate.set -> void +Microsoft.Identity.Client.ConfidentialClientApplicationBuilder.WithClientAssertion(System.Func> clientAssertionProvider) -> Microsoft.Identity.Client.ConfidentialClientApplicationBuilder static Microsoft.Identity.Client.Extensibility.AcquireTokenForClientBuilderExtensions.WithExtraBodyParameters(this Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder builder, System.Collections.Generic.Dictionary>> extrabodyparams) -> Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder diff --git a/src/client/Microsoft.Identity.Client/PublicApi/netstandard2.0/PublicAPI.Unshipped.txt b/src/client/Microsoft.Identity.Client/PublicApi/netstandard2.0/PublicAPI.Unshipped.txt index d02c6b8081..9a7ea38ff9 100644 --- a/src/client/Microsoft.Identity.Client/PublicApi/netstandard2.0/PublicAPI.Unshipped.txt +++ b/src/client/Microsoft.Identity.Client/PublicApi/netstandard2.0/PublicAPI.Unshipped.txt @@ -1 +1,9 @@ +const Microsoft.Identity.Client.MsalError.InvalidClientAssertion = "invalid_client_assertion" -> string +Microsoft.Identity.Client.ClientAssertion +Microsoft.Identity.Client.ClientAssertion.Assertion.get -> string +Microsoft.Identity.Client.ClientAssertion.Assertion.set -> void +Microsoft.Identity.Client.ClientAssertion.ClientAssertion() -> void +Microsoft.Identity.Client.ClientAssertion.TokenBindingCertificate.get -> System.Security.Cryptography.X509Certificates.X509Certificate2 +Microsoft.Identity.Client.ClientAssertion.TokenBindingCertificate.set -> void +Microsoft.Identity.Client.ConfidentialClientApplicationBuilder.WithClientAssertion(System.Func> clientAssertionProvider) -> Microsoft.Identity.Client.ConfidentialClientApplicationBuilder static Microsoft.Identity.Client.Extensibility.AcquireTokenForClientBuilderExtensions.WithExtraBodyParameters(this Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder builder, System.Collections.Generic.Dictionary>> extrabodyparams) -> Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder diff --git a/src/client/Microsoft.Identity.Client/Utils/CoreHelpers.cs b/src/client/Microsoft.Identity.Client/Utils/CoreHelpers.cs index deadcb07d1..fd53a2ee0c 100644 --- a/src/client/Microsoft.Identity.Client/Utils/CoreHelpers.cs +++ b/src/client/Microsoft.Identity.Client/Utils/CoreHelpers.cs @@ -6,6 +6,7 @@ using System.Globalization; using System.Linq; using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; using System.Text; using Microsoft.Identity.Client.Core; using Microsoft.Identity.Client.Internal; @@ -216,5 +217,20 @@ internal static string ComputeAccessTokenExtCacheKey(SortedList return Base64UrlHelpers.Encode(hashBytes); } } + + internal static string ComputeX5tS256KeyId(X509Certificate2 certificate) + { + // Extract the raw bytes of the certificate’s public key. + var publicKey = certificate.GetPublicKey(); + + // Compute the SHA-256 hash of the public key. + using (var sha256 = SHA256.Create()) + { + byte[] hash = sha256.ComputeHash(publicKey); + + // Return the hash encoded in Base64 URL format. + return Base64UrlHelpers.Encode(hash); + } + } } } diff --git a/tests/Microsoft.Identity.Test.Unit/PublicApiTests/ClientAssertionTests.cs b/tests/Microsoft.Identity.Test.Unit/PublicApiTests/ClientAssertionTests.cs index d4abb9fd06..226f0b70e9 100644 --- a/tests/Microsoft.Identity.Test.Unit/PublicApiTests/ClientAssertionTests.cs +++ b/tests/Microsoft.Identity.Test.Unit/PublicApiTests/ClientAssertionTests.cs @@ -3,13 +3,19 @@ using System; using System.Collections.Generic; +using System.Drawing; using System.Linq; using System.Net.Http; +using System.Runtime.InteropServices; +using System.Security.Cryptography.X509Certificates; using System.Text; +using System.Threading; using System.Threading.Tasks; using Microsoft.Identity.Client; using Microsoft.Identity.Client.Extensibility; +using Microsoft.Identity.Client.Internal; using Microsoft.Identity.Client.OAuth2; +using Microsoft.Identity.Test.Common.Core.Helpers; using Microsoft.Identity.Test.Common.Core.Mocks; using Microsoft.VisualStudio.TestTools.UnitTesting; @@ -330,5 +336,352 @@ public async Task LongRunningObo_RunsSuccessfully_TestAsync() Assert.IsTrue(verified, "The client assertion delegate should have been called with the correct FMI path."); } } + + [TestMethod] + public async Task ClientAssertion_BearerAsync() + { + using var http = new MockHttpManager(); + http.AddInstanceDiscoveryMockHandler(); + + var handler = http.AddMockHandlerSuccessfulClientCredentialTokenResponseMessage(); + var cca = ConfidentialClientApplicationBuilder.Create(TestConstants.ClientId) + .WithClientSecret(TestConstants.ClientSecret) + .WithHttpManager(http) + .WithClientAssertion(BearerDelegate()) + .BuildConcrete(); + + var result = await cca.AcquireTokenForClient(TestConstants.s_scope) + .ExecuteAsync().ConfigureAwait(false); + + Assert.AreEqual(TokenSource.IdentityProvider, result.AuthenticationResultMetadata.TokenSource); + + Assert.AreEqual( + "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", + handler.ActualRequestPostData["client_assertion_type"]); + + result = await cca.AcquireTokenForClient(TestConstants.s_scope) + .ExecuteAsync().ConfigureAwait(false); + + Assert.AreEqual(TokenSource.Cache, result.AuthenticationResultMetadata.TokenSource); + } + + [TestMethod] + public async Task ClientAssertion_WithPoPDelegate_No_Mtls_Api_SendsBearer_Async() + { + using var http = new MockHttpManager(); + { + http.AddInstanceDiscoveryMockHandler(); + var handler = http.AddMockHandlerSuccessfulClientCredentialTokenResponseMessage(); + var cca = ConfidentialClientApplicationBuilder.Create(TestConstants.ClientId) + .WithClientSecret(TestConstants.ClientSecret) + .WithHttpManager(http) + .WithClientAssertion(PopDelegate()) + .BuildConcrete(); + + var result = await cca.AcquireTokenForClient(TestConstants.s_scope) + .ExecuteAsync().ConfigureAwait(false); + + Assert.AreEqual(TokenSource.IdentityProvider, result.AuthenticationResultMetadata.TokenSource); + + Assert.AreEqual( + "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", + handler.ActualRequestPostData["client_assertion_type"]); + } + } + + [TestMethod] + public async Task ClientAssertion_ReceivesClientCapabilitiesAsync() + { + using var http = new MockHttpManager(); + { + http.AddInstanceDiscoveryMockHandler(); + http.AddMockHandlerSuccessfulClientCredentialTokenResponseMessage(); + + bool checkedCaps = false; + var cca = ConfidentialClientApplicationBuilder.Create(TestConstants.ClientId) + .WithClientSecret(TestConstants.ClientSecret) + .WithClientCapabilities(TestConstants.ClientCapabilities) + .WithHttpManager(http) + .WithClientAssertion((opts, ct) => + { + checkedCaps = true; + CollectionAssert.AreEqual( + TestConstants.ClientCapabilities, + opts.ClientCapabilities.ToList()); + return Task.FromResult(new ClientAssertion + { + Assertion = "jwt" + }); + }) + .BuildConcrete(); + + _ = await cca.AcquireTokenForClient(TestConstants.s_scope) + .ExecuteAsync() + .ConfigureAwait(false); + + Assert.IsTrue(checkedCaps); + } + } + + [TestMethod] + public async Task ClientAssertion_EmptyJwt_ThrowsAsync() + { + var cca = ConfidentialClientApplicationBuilder.Create(TestConstants.ClientId) + .WithClientSecret(TestConstants.ClientSecret) + .WithClientAssertion((o, c) => + Task.FromResult(new ClientAssertion { Assertion = string.Empty })) + .BuildConcrete(); + + await AssertException.TaskThrowsAsync(() => + cca.AcquireTokenForClient(TestConstants.s_scope).ExecuteAsync()) + .ConfigureAwait(false); + } + + [TestMethod] + public async Task ClientAssertion_CancellationTokenPropagatesAsync() + { + using var cts = new CancellationTokenSource(); + + var cca = ConfidentialClientApplicationBuilder.Create(TestConstants.ClientId) + .WithClientSecret(TestConstants.ClientSecret) + .WithClientAssertion((o, ct) => + { + Assert.AreEqual(cts.Token, ct); + cts.Cancel(); + ct.ThrowIfCancellationRequested(); + return Task.FromResult(new ClientAssertion { Assertion = "jwt" }); + }) + .BuildConcrete(); + + await AssertException.TaskThrowsAsync(() => + cca.AcquireTokenForClient(TestConstants.s_scope) + .ExecuteAsync(cts.Token)) + .ConfigureAwait(false); + } + + [TestMethod] + public async Task WithMtlsPop_AfterPoPDelegate_Works() + { + const string region = "eastus"; + + using (var envContext = new EnvVariableContext()) + { + Environment.SetEnvironmentVariable("REGION_NAME", region); + + // Set the expected mTLS endpoint for public cloud + string globalEndpoint = "mtlsauth.microsoft.com"; + string expectedTokenEndpoint = $"https://{region}.{globalEndpoint}/123456-1234-2345-1234561234/oauth2/v2.0/token"; + + using (var httpManager = new MockHttpManager()) + { + // Set up mock handler with expected token endpoint URL + httpManager.AddMockHandlerSuccessfulClientCredentialTokenResponseMessage( + tokenType: "mtls_pop"); + + var cert = CertHelper.GetOrCreateTestCert(); + + var app = ConfidentialClientApplicationBuilder.Create(TestConstants.ClientId) + .WithClientAssertion(PopDelegate()) + .WithAuthority($"https://login.microsoftonline.com/123456-1234-2345-1234561234") + .WithAzureRegion(ConfidentialClientApplication.AttemptRegionDiscovery) + .WithHttpManager(httpManager) + .BuildConcrete(); + + // First token acquisition - should hit the identity provider + AuthenticationResult result = await app.AcquireTokenForClient(TestConstants.s_scope) + .WithMtlsProofOfPossession() + .ExecuteAsync() + .ConfigureAwait(false); + + Assert.AreEqual("header.payload.signature", result.AccessToken); + Assert.AreEqual(Constants.MtlsPoPAuthHeaderPrefix, result.TokenType); + Assert.AreEqual(region, result.AuthenticationResultMetadata.RegionDetails.RegionUsed); + Assert.AreEqual(expectedTokenEndpoint, result.AuthenticationResultMetadata.TokenEndpoint); + + Assert.IsNotNull(result.BindingCertificate, "BindingCertificate should be present."); + Assert.AreEqual(cert.Thumbprint, result.BindingCertificate.Thumbprint, + "BindingCertificate must match the cert passed to WithCertificate()."); + + // Second token acquisition - should retrieve from cache + AuthenticationResult secondResult = await app.AcquireTokenForClient(TestConstants.s_scope) + .WithMtlsProofOfPossession() + .ExecuteAsync() + .ConfigureAwait(false); + + Assert.AreEqual("header.payload.signature", secondResult.AccessToken); + Assert.AreEqual(Constants.MtlsPoPAuthHeaderPrefix, secondResult.TokenType); + Assert.AreEqual(TokenSource.Cache, secondResult.AuthenticationResultMetadata.TokenSource); + Assert.AreEqual(expectedTokenEndpoint, result.AuthenticationResultMetadata.TokenEndpoint); + // Cached result must still carry the cert + Assert.IsNotNull(secondResult.BindingCertificate); + Assert.AreEqual(result.BindingCertificate.Thumbprint, + secondResult.BindingCertificate.Thumbprint); + } + } + } + + [TestMethod] + public async Task PoP_CachedTokenWithDifferentCertificate_IsBypassedAsync() + { + const string region = "eastus"; + + // ─────────── Set up HTTP mocks ─────────── + using var httpManager = new MockHttpManager(); + using (var envContext = new EnvVariableContext()) + { + Environment.SetEnvironmentVariable("REGION_NAME", region); + + // 1st network call returns token‑A + httpManager.AddMockHandlerSuccessfulClientCredentialTokenResponseMessage( + tokenType: "mtls_pop"); + + // 2nd network call returns token‑B + httpManager.AddMockHandlerSuccessfulClientCredentialTokenResponseMessage( + tokenType: "mtls_pop"); + + // ─────────── Two distinct certificates ─────────── + var certA = CertHelper.GetOrCreateTestCert(); + var certB = CertHelper.GetOrCreateTestCert(regenerateCert: true); + + // Delegate returns certA on first call, certB on second call + int callCount = 0; + Func> popDelegate = + (opts, ct) => + { + callCount++; + var cert = (callCount == 1) ? certA : certB; + return Task.FromResult(new ClientAssertion + { + Assertion = $"jwt_{callCount}", // payload not important for this test + TokenBindingCertificate = cert + }); + }; + + // ─────────── Build the app ─────────── + var cca = ConfidentialClientApplicationBuilder.Create(TestConstants.ClientId) + .WithClientSecret(TestConstants.ClientSecret) + .WithClientAssertion(popDelegate) + .WithAuthority($"https://login.microsoftonline.com/123456-1234-2345-1234561234") + .WithAzureRegion(ConfidentialClientApplication.AttemptRegionDiscovery) + .WithHttpManager(httpManager) + .BuildConcrete(); + + // ─────────── First acquire – network call, caches token‑A bound to certA ─────────── + AuthenticationResult first = await cca.AcquireTokenForClient(TestConstants.s_scope) + .WithMtlsProofOfPossession() + .ExecuteAsync() + .ConfigureAwait(false); + + Assert.AreEqual(TokenSource.IdentityProvider, first.AuthenticationResultMetadata.TokenSource); + Assert.AreEqual(certA.Thumbprint, first.BindingCertificate.Thumbprint); + + // ─────────── Second acquire – delegate now returns certB ─────────── + AuthenticationResult second = await cca.AcquireTokenForClient(TestConstants.s_scope) + .WithMtlsProofOfPossession() + .ExecuteAsync() + .ConfigureAwait(false); + + // The serial number mismatch should have forced a network call, not a cache hit + Assert.AreEqual(TokenSource.IdentityProvider, second.AuthenticationResultMetadata.TokenSource); + Assert.AreEqual(certB.Thumbprint, second.BindingCertificate.Thumbprint); + } + } + + [TestMethod] + public async Task WithMtlsPop_AfterBearerDelegate_Throws() + { + var cca = ConfidentialClientApplicationBuilder.Create(TestConstants.ClientId) + .WithClientSecret(TestConstants.ClientSecret) + .WithClientAssertion(BearerDelegate()) + .BuildConcrete(); + + var ex = await AssertException.TaskThrowsAsync(() => + cca.AcquireTokenForClient(TestConstants.s_scope) + .WithMtlsProofOfPossession() + .ExecuteAsync()) + .ConfigureAwait(false); + + Assert.AreEqual(MsalError.MtlsCertificateNotProvided, ex.ErrorCode); + } + + [TestMethod] + public async Task ClientAssertion_NotCalledWhenTokenFromCacheAsync() + { + using var http = new MockHttpManager(); + http.AddInstanceDiscoveryMockHandler(); + + int callCount = 0; + http.AddMockHandlerSuccessfulClientCredentialTokenResponseMessage(); // first call => network + + var cca = ConfidentialClientApplicationBuilder.Create(TestConstants.ClientId) + .WithClientSecret(TestConstants.ClientSecret) + .WithHttpManager(http) + .WithClientAssertion((o, c) => + { + callCount++; + return Task.FromResult(new ClientAssertion { Assertion = "jwt" }); + }) + .BuildConcrete(); + + _ = await cca.AcquireTokenForClient(TestConstants.s_scope) + .ExecuteAsync() + .ConfigureAwait(false); + + Assert.AreEqual(1, callCount); + + _ = await cca.AcquireTokenForClient(TestConstants.s_scope) + .ExecuteAsync() + .ConfigureAwait(false); + + Assert.AreEqual(1, callCount); + } + + [TestMethod] + public async Task WithMtlsPop_AfterPoPDelegate_NoRegion_ThrowsAsync() + { + using var http = new MockHttpManager(); + { + // Arrange – CCA with PoP delegate (returns JWT + cert) but **no AzureRegion configured** + var cert = CertHelper.GetOrCreateTestCert(); + var cca = ConfidentialClientApplicationBuilder.Create(TestConstants.ClientId) + .WithClientAssertion(PopDelegate()) + .WithHttpManager(http) + .BuildConcrete(); + + // Act & Assert – should fail because region is missing + var ex = await AssertException.TaskThrowsAsync(async () => + await cca.AcquireTokenForClient(TestConstants.s_scope) + .WithMtlsProofOfPossession() + .ExecuteAsync() + .ConfigureAwait(false)) + .ConfigureAwait(false); + + Assert.AreEqual(MsalError.MtlsPopWithoutRegion, ex.ErrorCode); + } + } + + #region Helper --------------------------------------------------------------- + private static Func> + BearerDelegate(string jwt = "fake_jwt") => + (opts, ct) => Task.FromResult(new ClientAssertion + { + Assertion = jwt, + TokenBindingCertificate = null + }); + + private static Func> + PopDelegate(string jwt = "fake_jwt") => + (opts, ct) => + { + // Obtain (or generate) the test certificate once per call + X509Certificate2 cert = CertHelper.GetOrCreateTestCert(); + + return Task.FromResult(new ClientAssertion + { + Assertion = jwt, + TokenBindingCertificate = cert + }); + }; +#endregion } } diff --git a/tests/Microsoft.Identity.Test.Unit/PublicApiTests/ConfidentialClientApplicationTests.cs b/tests/Microsoft.Identity.Test.Unit/PublicApiTests/ConfidentialClientApplicationTests.cs index 2666f67bd2..a2dfc5586d 100644 --- a/tests/Microsoft.Identity.Test.Unit/PublicApiTests/ConfidentialClientApplicationTests.cs +++ b/tests/Microsoft.Identity.Test.Unit/PublicApiTests/ConfidentialClientApplicationTests.cs @@ -832,32 +832,26 @@ public async Task SignedAssertionWithClientCapabilitiesTestAsync() [TestMethod] public async Task ConfidentialClientUsingSignedClientAssertion_AsyncDelegate_CancellationTestAsync() { - using (var httpManager = new MockHttpManager()) - { - httpManager.AddInstanceDiscoveryMockHandler(); - - CancellationTokenSource cancellationTokenSource = new CancellationTokenSource(); + CancellationTokenSource cancellationTokenSource = new CancellationTokenSource(); - var builder = ConfidentialClientApplicationBuilder.Create(TestConstants.ClientId) - .WithHttpManager(httpManager) - .WithClientAssertion( - async ct => - { - // make sure that the cancellation token given to AcquireToken method - // is propagated to here - cancellationTokenSource.Cancel(); - ct.ThrowIfCancellationRequested(); - return await Task.FromResult(TestConstants.DefaultClientAssertion) - .ConfigureAwait(false); - }); + var builder = ConfidentialClientApplicationBuilder.Create(TestConstants.ClientId) + .WithClientAssertion( + async ct => + { + // make sure that the cancellation token given to AcquireToken method + // is propagated to here + cancellationTokenSource.Cancel(); + ct.ThrowIfCancellationRequested(); + return await Task.FromResult(TestConstants.DefaultClientAssertion) + .ConfigureAwait(false); + }); - var app = builder.BuildConcrete(); - Assert.IsNull(app.Certificate); + var app = builder.BuildConcrete(); + Assert.IsNull(app.Certificate); - await AssertException.TaskThrowsAsync( - () => app.AcquireTokenForClient(TestConstants.s_scope.ToArray()) - .ExecuteAsync(cancellationTokenSource.Token)).ConfigureAwait(false); - } + await AssertException.TaskThrowsAsync( + () => app.AcquireTokenForClient(TestConstants.s_scope.ToArray()) + .ExecuteAsync(cancellationTokenSource.Token)).ConfigureAwait(false); } [TestMethod] @@ -901,27 +895,58 @@ public async Task SignedAssertionWithSingleClientCapabilityTestAsync() [TestMethod] public void Constructor_NullDelegate_ThrowsArgumentNullException() { - // Arrange - Func> nullDelegate = null; + // Arrange + Func> nullDelegate = null; - // Act & Assert + // Act &  Assert Assert.ThrowsException(() => - new SignedAssertionDelegateClientCredential(nullDelegate)); + new ClientAssertionDelegateCredential(nullDelegate)); } - [TestMethod] - public void Constructor_ValidDelegate_DoesNotThrow() + [DataTestMethod] + [DataRow(false)] // bearer (no cert) + [DataRow(true)] // PoP (with cert) + public void Constructor_ValidDelegate_DoesNotThrow(bool withCert) { // Arrange - Func> validDelegate = - (options) => Task.FromResult("fake_assertion"); + X509Certificate2 cert = withCert ? CertHelper.GetOrCreateTestCert() : null; - // Act & Assert - // Should not throw - var credential = new SignedAssertionDelegateClientCredential(validDelegate); + Func> validDelegate = + (options, ct) => Task.FromResult(new ClientAssertion + { + Assertion = "fake_assertion", + TokenBindingCertificate = cert + }); + + // Act + var credential = new ClientAssertionDelegateCredential(validDelegate); + + // Assert Assert.IsNotNull(credential); } + [TestMethod] + public async Task AcquireTokenForClient_EmptyAssertion_ThrowsArgumentExceptionAsync() + { + // Build a CCA whose assertion‑delegate returns NO JWT (error case) + var cca = ConfidentialClientApplicationBuilder.Create(TestConstants.ClientId) + .WithClientSecret(TestConstants.ClientSecret) + .WithClientAssertion( + (opts, ct) => Task.FromResult(new ClientAssertion + { + Assertion = string.Empty, // <-- invalid: must be non‑empty + TokenBindingCertificate = null // no cert => jwt-bearer + })) + .BuildConcrete(); + + // Act & Assert – the first token request will execute the delegate + // and should surface ArgumentException from the credential layer. + await AssertException.TaskThrowsAsync(() => + cca.AcquireTokenForClient(TestConstants.s_scope) + .ExecuteAsync()) + .ConfigureAwait(false); + } + [TestMethod] public async Task GetAuthorizationRequestUrlNoRedirectUriTestAsync() { diff --git a/tests/Microsoft.Identity.Test.Unit/PublicApiTests/MtlsPopTests.cs b/tests/Microsoft.Identity.Test.Unit/PublicApiTests/MtlsPopTests.cs index 50543fef1d..23e9375cf2 100644 --- a/tests/Microsoft.Identity.Test.Unit/PublicApiTests/MtlsPopTests.cs +++ b/tests/Microsoft.Identity.Test.Unit/PublicApiTests/MtlsPopTests.cs @@ -63,8 +63,8 @@ public async Task MtlsPop_AadAuthorityWithoutCertificateAsync() .ExecuteAsync()) .ConfigureAwait(false); - Assert.AreEqual(MsalError.MtlsCertificateNotProvided, ex.ErrorCode); - Assert.AreEqual(MsalErrorMessage.MtlsCertificateNotProvidedMessage, ex.Message); + Assert.AreEqual(MsalError.ClientCredentialAuthenticationTypeMustBeDefined, ex.ErrorCode); + Assert.AreEqual(MsalErrorMessage.ClientCredentialAuthenticationTypeMustBeDefined, ex.Message); } [TestMethod] @@ -328,6 +328,74 @@ public async Task AcquireMtlsPopTokenForClientWithTenantId_SuccessAsync() } } + [TestMethod] + public async Task AcquireMtlsPopTokenForClientWithTenantIdCertChecks_Async() + { + const string region = "eastus"; + + // ─────────── Two distinct certificates ─────────── + var certA = CertHelper.GetOrCreateTestCert(); + var certB = CertHelper.GetOrCreateTestCert(regenerateCert: true); + + using (var envContext = new EnvVariableContext()) + { + Environment.SetEnvironmentVariable("REGION_NAME", region); + + // Set the expected mTLS endpoint for public cloud + string globalEndpoint = "mtlsauth.microsoft.com"; + string expectedTokenEndpoint = $"https://{region}.{globalEndpoint}/123456-1234-2345-1234561234/oauth2/v2.0/token"; + + using (var httpManager = new MockHttpManager()) + { + // Set up mock handler with expected token endpoint URL + httpManager.AddMockHandlerSuccessfulClientCredentialTokenResponseMessage( + tokenType: "mtls_pop"); + + var app = ConfidentialClientApplicationBuilder.Create(TestConstants.ClientId) + .WithCertificate(certA) + .WithTenantId("123456-1234-2345-1234561234") + .WithAzureRegion(ConfidentialClientApplication.AttemptRegionDiscovery) + .WithHttpManager(httpManager) + .BuildConcrete(); + + // First token acquisition - should hit the identity provider + AuthenticationResult result = await app.AcquireTokenForClient(TestConstants.s_scope) + .WithMtlsProofOfPossession() + .ExecuteAsync() + .ConfigureAwait(false); + + Assert.AreEqual("header.payload.signature", result.AccessToken); + Assert.AreEqual(Constants.MtlsPoPAuthHeaderPrefix, result.TokenType); + Assert.AreEqual(region, result.AuthenticationResultMetadata.RegionDetails.RegionUsed); + Assert.AreEqual(expectedTokenEndpoint, result.AuthenticationResultMetadata.TokenEndpoint); + Assert.AreEqual(certA.Thumbprint, result.BindingCertificate.Thumbprint); + + app = ConfidentialClientApplicationBuilder.Create(TestConstants.ClientId) + .WithCertificate(certB) + .WithTenantId("123456-1234-2345-1234561234") + .WithAzureRegion(ConfidentialClientApplication.AttemptRegionDiscovery) + .WithHttpManager(httpManager) + .BuildConcrete(); + + // Set up mock handler with expected token endpoint URL + httpManager.AddMockHandlerSuccessfulClientCredentialTokenResponseMessage( + tokenType: "mtls_pop"); + + // Second token acquisition - should also be from IDP because we have a new cert + AuthenticationResult secondResult = await app.AcquireTokenForClient(TestConstants.s_scope) + .WithMtlsProofOfPossession() + .ExecuteAsync() + .ConfigureAwait(false); + + Assert.AreEqual("header.payload.signature", secondResult.AccessToken); + Assert.AreEqual(Constants.MtlsPoPAuthHeaderPrefix, secondResult.TokenType); + Assert.AreEqual(TokenSource.IdentityProvider, secondResult.AuthenticationResultMetadata.TokenSource); + Assert.AreEqual(expectedTokenEndpoint, result.AuthenticationResultMetadata.TokenEndpoint); + Assert.AreEqual(certB.Thumbprint, secondResult.BindingCertificate.Thumbprint); + } + } + } + [TestMethod] public async Task MtlsPop_KnownRegionAsync() {