diff --git a/src/client/Microsoft.Identity.Client/ApiConfig/AbstractConfidentialClientAcquireTokenParameterBuilder.cs b/src/client/Microsoft.Identity.Client/ApiConfig/AbstractConfidentialClientAcquireTokenParameterBuilder.cs index 25d64952be..fcb67f34c6 100644 --- a/src/client/Microsoft.Identity.Client/ApiConfig/AbstractConfidentialClientAcquireTokenParameterBuilder.cs +++ b/src/client/Microsoft.Identity.Client/ApiConfig/AbstractConfidentialClientAcquireTokenParameterBuilder.cs @@ -48,7 +48,8 @@ protected override void Validate() // Confidential client must have a credential if (ServiceBundle?.Config.ClientCredential == null && CommonParameters.OnBeforeTokenRequestHandler == null && - ServiceBundle?.Config.AppTokenProvider == null + ServiceBundle?.Config.AppTokenProvider == null && + ServiceBundle?.Config.ClientCredentialCertificateProvider == null ) { throw new MsalClientException( diff --git a/src/client/Microsoft.Identity.Client/AppConfig/ApplicationConfiguration.cs b/src/client/Microsoft.Identity.Client/AppConfig/ApplicationConfiguration.cs index 4e22a2855c..7371f02a7d 100644 --- a/src/client/Microsoft.Identity.Client/AppConfig/ApplicationConfiguration.cs +++ b/src/client/Microsoft.Identity.Client/AppConfig/ApplicationConfiguration.cs @@ -131,6 +131,26 @@ public string ClientVersion internal IRetryPolicyFactory RetryPolicyFactory { get; set; } internal ICsrFactory CsrFactory { get; set; } + #region Extensibility Callbacks + + /// + /// Dynamic certificate provider callback for client credential flows. + /// + public Func> ClientCredentialCertificateProvider { get; set; } + + /// + /// MSAL service failure callback that determines whether to retry after a token acquisition failure from the identity provider. + /// Only invoked for MsalServiceException (errors from the Security Token Service). + /// + public Func> OnMsalServiceFailureCallback { get; set; } + + /// + /// Success callback that receives the result of token acquisition attempts (typically successful, but can include failures after retries are exhausted). + /// + public Func OnSuccessCallback { get; set; } + + #endregion + #region ClientCredentials // Indicates if claims or assertions are used within the configuration diff --git a/src/client/Microsoft.Identity.Client/AppConfig/AssertionRequestOptions.cs b/src/client/Microsoft.Identity.Client/AppConfig/AssertionRequestOptions.cs index 245db3fada..41ee6bcf63 100644 --- a/src/client/Microsoft.Identity.Client/AppConfig/AssertionRequestOptions.cs +++ b/src/client/Microsoft.Identity.Client/AppConfig/AssertionRequestOptions.cs @@ -6,15 +6,34 @@ namespace Microsoft.Identity.Client { - /// - /// Information about the client assertion that need to be generated See https://aka.ms/msal-net-client-assertion - /// - /// Use the provided information to generate the client assertion payload + /// + /// Information about the client assertion that need to be generated See https://aka.ms/msal-net-client-assertion + /// + /// Use the provided information to generate the client assertion payload #if !SUPPORTS_CONFIDENTIAL_CLIENT [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] // hide confidential client on mobile #endif public class AssertionRequestOptions { /// + /// Default constructor for AssertionRequestOptions + /// + public AssertionRequestOptions() + { + } + + /// + /// Internal constructor that creates AssertionRequestOptions from ApplicationConfiguration + /// + /// The application configuration + internal AssertionRequestOptions(ApplicationConfiguration appConfig) + { + ClientID = appConfig.ClientId; + TenantId = appConfig.Authority?.TenantId; + Authority = appConfig.Authority?.AuthorityInfo?.CanonicalAuthority?.ToString(); + } + + /// + /// Cancellation token to cancel the operation /// public CancellationToken CancellationToken { get; set; } @@ -23,6 +42,16 @@ public class AssertionRequestOptions { /// public string ClientID { get; set; } + /// + /// Tenant ID for the authentication request + /// + public string TenantId { get; set; } + + /// + /// The authority URL (e.g., https://login.microsoftonline.com/{tenantId}) + /// + public string Authority { get; set; } + /// /// The intended token endpoint /// diff --git a/src/client/Microsoft.Identity.Client/Extensibility/ConfidentialClientApplicationBuilderExtensions.cs b/src/client/Microsoft.Identity.Client/Extensibility/ConfidentialClientApplicationBuilderExtensions.cs index 7328b69ad3..8b7e1f90ec 100644 --- a/src/client/Microsoft.Identity.Client/Extensibility/ConfidentialClientApplicationBuilderExtensions.cs +++ b/src/client/Microsoft.Identity.Client/Extensibility/ConfidentialClientApplicationBuilderExtensions.cs @@ -2,7 +2,9 @@ // Licensed under the MIT License. using System; +using System.Security.Cryptography.X509Certificates; using System.Threading.Tasks; +using Microsoft.Identity.Client.Internal.ClientCredential; namespace Microsoft.Identity.Client.Extensibility { @@ -29,5 +31,158 @@ public static ConfidentialClientApplicationBuilder WithAppTokenProvider( builder.Config.AppTokenProvider = appTokenProvider ?? throw new ArgumentNullException(nameof(appTokenProvider)); return builder; } + + /// + /// Configures an async callback to provide the client credential certificate dynamically. + /// The callback is invoked before each token acquisition request to the identity provider (including retries). + /// This enables scenarios such as certificate rotation and dynamic certificate selection based on application context. + /// + /// The confidential client application builder. + /// + /// An async callback that provides the certificate based on the application configuration. + /// Called before each network request to acquire a token. + /// Must return a valid with a private key. + /// + /// The builder to chain additional configuration calls. + /// Thrown when is null. + /// + /// Thrown at build time if both + /// and this method are configured. + /// + /// + /// This method cannot be used together with . + /// The callback is not invoked when tokens are retrieved from cache, only for network calls. + /// The certificate returned by the callback will be used to sign the client assertion (JWT) for that token request. + /// The callback can perform async operations such as fetching certificates from Azure Key Vault or other secret management systems. + /// See https://aka.ms/msal-net-client-credentials for more details on client credentials. + /// + public static ConfidentialClientApplicationBuilder WithCertificate( + this ConfidentialClientApplicationBuilder builder, + Func> certificateProvider) + { + if (certificateProvider == null) + { + throw new ArgumentNullException(nameof(certificateProvider)); + } + + builder.Config.ClientCredentialCertificateProvider = certificateProvider; + + // Create a CertificateAndClaimsClientCredential with null certificate + // The certificate will be resolved dynamically via the provider in ResolveCertificateAsync + builder.Config.ClientCredential = new Microsoft.Identity.Client.Internal.ClientCredential.CertificateAndClaimsClientCredential( + certificate: null, + claimsToSign: null, + appendDefaultClaims: true); + + return builder; + } + + /// + /// Configures an async callback that is invoked when MSAL receives an error response from the identity provider (Security Token Service). + /// The callback determines whether MSAL should retry the token request or propagate the exception. + /// This callback is invoked after each service failure and can be called multiple times until it returns false or the request succeeds. + /// + /// The confidential client application builder. + /// + /// An async callback that determines whether to retry after a service failure. + /// Receives the assertion request options and the that occurred. + /// Returns true to retry the request, or false to stop retrying and propagate the exception. + /// The callback will be invoked repeatedly after each service failure until it returns false or the request succeeds. + /// + /// The builder to chain additional configuration calls. + /// Thrown when is null. + /// + /// This callback is ONLY triggered for - errors returned by the identity provider (e.g., HTTP 500, 503, throttling). + /// This callback is NOT triggered for client-side errors () or network failures handled internally by MSAL. + /// This callback is only invoked for network token acquisition attempts, not when tokens are retrieved from cache. + /// When the callback returns true, MSAL will invoke the certificate provider (if configured via ) + /// before making another token request, enabling certificate rotation scenarios. + /// MSAL's internal throttling and retry mechanisms will still apply, including respecting Retry-After headers from the identity provider. + /// To prevent infinite loops, ensure your callback has appropriate termination conditions (e.g., max retry count, timeout). + /// The callback can perform async operations such as logging to remote services, checking external health endpoints, or querying configuration stores. + /// + /// + /// + /// int retryCount = 0; + /// var app = ConfidentialClientApplicationBuilder + /// .Create(clientId) + /// .WithCertificate(async options => await GetCertificateFromKeyVaultAsync(options.TokenEndpoint)) + /// .OnMsalServiceFailure(async (options, serviceException) => + /// { + /// retryCount++; + /// await LogExceptionAsync(serviceException); + /// + /// // Retry up to 3 times for transient service errors (5xx) + /// return serviceException.StatusCode >= 500 && retryCount < 3; + /// }) + /// .Build(); + /// + /// + public static ConfidentialClientApplicationBuilder OnMsalServiceFailure( + this ConfidentialClientApplicationBuilder builder, + Func> onMsalServiceFailureCallback) + { + if (onMsalServiceFailureCallback == null) + throw new ArgumentNullException(nameof(onMsalServiceFailureCallback)); + + builder.Config.OnMsalServiceFailureCallback = onMsalServiceFailureCallback; + return builder; + } + + /// + /// Configures an async callback that is invoked when a token acquisition request completes. + /// This callback is invoked once per AcquireTokenForClient call, after all retry attempts have been exhausted. + /// While named OnSuccess for the common case, this callback fires for both successful and failed acquisitions. + /// This enables scenarios such as telemetry, logging, and custom result handling. + /// + /// The confidential client application builder. + /// + /// An async callback that receives the assertion request options and the execution result. + /// The result contains either the successful or the that occurred. + /// This callback is invoked after all retries have been exhausted (if an handler is configured). + /// + /// The builder to chain additional configuration calls. + /// Thrown when is null. + /// + /// This callback is invoked for both successful and failed token acquisitions. Check to determine the outcome. + /// This callback is only invoked for network token acquisition attempts, not when tokens are retrieved from cache. + /// If multiple calls to OnSuccess are made, only the last configured callback will be used. + /// Exceptions thrown by this callback will be caught and logged internally to prevent disruption of the authentication flow. + /// The callback is invoked on the same thread/context as the token acquisition request. + /// The callback can perform async operations such as sending telemetry to Application Insights, persisting logs to databases, or triggering webhooks. + /// + /// + /// + /// var app = ConfidentialClientApplicationBuilder + /// .Create(clientId) + /// .WithCertificate(certificate) + /// .OnSuccess(async (options, result) => + /// { + /// if (result.Successful) + /// { + /// await telemetry.TrackEventAsync("TokenAcquired", new { ClientId = options.ClientID }); + /// } + /// else + /// { + /// await telemetry.TrackExceptionAsync(result.Exception); + /// } + /// }) + /// .Build(); + /// + /// + public static ConfidentialClientApplicationBuilder OnSuccess( + this ConfidentialClientApplicationBuilder builder, + Func onSuccessCallback) + { + builder.ValidateUseOfExperimentalFeature(); + + if (onSuccessCallback == null) + { + throw new ArgumentNullException(nameof(onSuccessCallback)); + } + + builder.Config.OnSuccessCallback = onSuccessCallback; + return builder; + } } } diff --git a/src/client/Microsoft.Identity.Client/Extensibility/ExecutionResult.cs b/src/client/Microsoft.Identity.Client/Extensibility/ExecutionResult.cs new file mode 100644 index 0000000000..f6660206b2 --- /dev/null +++ b/src/client/Microsoft.Identity.Client/Extensibility/ExecutionResult.cs @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +namespace Microsoft.Identity.Client.Extensibility +{ + /// + /// Represents the result of a token acquisition attempt. + /// Used by the execution observer configured via . + /// + public class ExecutionResult + { + /// + /// Internal constructor for ExecutionResult. + /// + internal ExecutionResult() { } + + /// + /// Indicates whether the token acquisition was successful. + /// + /// + /// true if the token was successfully acquired; otherwise, false. + /// + public bool Successful { get; internal set; } + + /// + /// The authentication result if the token acquisition was successful. + /// + /// + /// An containing the access token and related metadata if is true; + /// otherwise, null. + /// + public AuthenticationResult Result { get; internal set; } + + /// + /// The exception that occurred if the token acquisition failed. + /// + /// + /// An describing the failure if is false; + /// otherwise, null. + /// + public MsalException Exception { get; internal set; } + } +} diff --git a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CertificateAndClaimsClientCredential.cs b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CertificateAndClaimsClientCredential.cs index 035571f7f2..c2446b2a33 100644 --- a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CertificateAndClaimsClientCredential.cs +++ b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CertificateAndClaimsClientCredential.cs @@ -1,10 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -using System; using System.Collections.Generic; -using System.Runtime.ConstrainedExecution; -using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using System.Threading; using System.Threading.Tasks; @@ -35,10 +32,15 @@ public CertificateAndClaimsClientCredential( Certificate = certificate; _claimsToSign = claimsToSign; _appendDefaultClaims = appendDefaultClaims; - _base64EncodedThumbprint = Base64UrlHelpers.Encode(certificate.GetCertHash()); + + // Certificate can be null when using dynamic certificate provider + if (certificate != null) + { + _base64EncodedThumbprint = Base64UrlHelpers.Encode(certificate.GetCertHash()); + } } - public Task AddConfidentialClientParametersAsync( + public async Task AddConfidentialClientParametersAsync( OAuth2Client oAuth2Client, AuthenticationRequestParameters requestParameters, ICryptographyManager cryptographyManager, @@ -54,6 +56,9 @@ public Task AddConfidentialClientParametersAsync( { requestParameters.RequestContext.Logger.Verbose(() => "Proceeding with JWT token creation and adding client assertion."); + // Resolve the certificate - either from static config or dynamic provider + X509Certificate2 effectiveCertificate = await ResolveCertificateAsync(requestParameters, cancellationToken).ConfigureAwait(false); + bool useSha2 = requestParameters.AuthorityManager.Authority.AuthorityInfo.IsSha2CredentialSupported; var jwtToken = new JsonWebToken( @@ -63,7 +68,7 @@ public Task AddConfidentialClientParametersAsync( _claimsToSign, _appendDefaultClaims); - string assertion = jwtToken.Sign(Certificate, requestParameters.SendX5C, useSha2); + string assertion = jwtToken.Sign(effectiveCertificate, requestParameters.SendX5C, useSha2); oAuth2Client.AddBodyParameter(OAuth2Parameter.ClientAssertionType, OAuth2AssertionType.JwtBearer); oAuth2Client.AddBodyParameter(OAuth2Parameter.ClientAssertion, assertion); @@ -73,8 +78,82 @@ public Task AddConfidentialClientParametersAsync( // Log that MTLS PoP is required and JWT token creation is skipped requestParameters.RequestContext.Logger.Verbose(() => "MTLS PoP Client credential request. Skipping client assertion."); } + } + + /// + /// Resolves the certificate to use for signing the client assertion. + /// If a dynamic certificate provider is configured, it will be invoked to get the certificate. + /// Otherwise, the static certificate configured at build time is used. + /// + /// The authentication request parameters containing app config + /// Cancellation token for the async operation + /// The X509Certificate2 to use for signing + /// Thrown if the certificate provider returns null or an invalid certificate + private async Task ResolveCertificateAsync( + AuthenticationRequestParameters requestParameters, + CancellationToken cancellationToken) + { + // Check if dynamic certificate provider is configured + if (requestParameters.AppConfig.ClientCredentialCertificateProvider != null) + { + requestParameters.RequestContext.Logger.Verbose( + () => "[CertificateAndClaimsClientCredential] Resolving certificate from dynamic provider."); + + // Create AssertionRequestOptions for the callback + var options = new AssertionRequestOptions((ApplicationConfiguration)requestParameters.AppConfig) + { + CancellationToken = cancellationToken + }; + + // Invoke the provider to get the certificate + X509Certificate2 providedCertificate = await requestParameters.AppConfig + .ClientCredentialCertificateProvider(options) + .ConfigureAwait(false); + + // Validate the certificate returned by the provider + if (providedCertificate == null) + { + requestParameters.RequestContext.Logger.Error( + "[CertificateAndClaimsClientCredential] Certificate provider returned null."); + + throw new MsalClientException( + MsalError.InvalidClientAssertion, + "The certificate provider callback returned null. Ensure the callback returns a valid X509Certificate2 instance."); + } + + if (!providedCertificate.HasPrivateKey) + { + requestParameters.RequestContext.Logger.Error( + "[CertificateAndClaimsClientCredential] Certificate from provider does not have a private key."); + + throw new MsalClientException( + MsalError.CertWithoutPrivateKey, + "The certificate returned by the provider does not have a private key. " + + "Ensure the certificate has a private key for signing operations."); + } + + requestParameters.RequestContext.Logger.Info( + () => $"[CertificateAndClaimsClientCredential] Successfully resolved certificate from provider. " + + $"Thumbprint: {providedCertificate.Thumbprint}"); + + return providedCertificate; + } + + // Use the static certificate configured at build time + if (Certificate == null) + { + requestParameters.RequestContext.Logger.Error( + "[CertificateAndClaimsClientCredential] No certificate configured (static or dynamic)."); + + throw new MsalClientException( + MsalError.InvalidClientAssertion, + "No certificate is configured. Use WithCertificate() to provide a certificate."); + } + + requestParameters.RequestContext.Logger.Verbose( + () => $"[CertificateAndClaimsClientCredential] Using static certificate. Thumbprint: {Certificate.Thumbprint}"); - return Task.CompletedTask; + return Certificate; } } } diff --git a/src/client/Microsoft.Identity.Client/Internal/Requests/ClientCredentialRequest.cs b/src/client/Microsoft.Identity.Client/Internal/Requests/ClientCredentialRequest.cs index e7c08f0fc8..c583743f3a 100644 --- a/src/client/Microsoft.Identity.Client/Internal/Requests/ClientCredentialRequest.cs +++ b/src/client/Microsoft.Identity.Client/Internal/Requests/ClientCredentialRequest.cs @@ -3,10 +3,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; using System.Threading.Tasks; using Microsoft.Identity.Client.ApiConfig.Parameters; @@ -14,8 +11,6 @@ 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; @@ -127,14 +122,148 @@ private async Task GetAccessTokenAsync( { await ResolveAuthorityAsync().ConfigureAwait(false); - // Get a token from AAD - if (ServiceBundle.Config.AppTokenProvider == null) + AuthenticationResult authResult = null; + + // Retry loop using the retry callback if configured + while (true) { - MsalTokenResponse msalTokenResponse = await SendTokenRequestAsync(GetBodyParameters(), cancellationToken).ConfigureAwait(false); - return await CacheTokenResponseAndCreateAuthenticationResultAsync(msalTokenResponse).ConfigureAwait(false); + try + { + // Get a token from AAD + if (ServiceBundle.Config.AppTokenProvider == null) + { + logger.Verbose(() => "[ClientCredentialRequest] Sending token request to AAD."); + MsalTokenResponse msalTokenResponse = await SendTokenRequestAsync( + GetBodyParameters(), + cancellationToken).ConfigureAwait(false); + + authResult = await CacheTokenResponseAndCreateAuthenticationResultAsync(msalTokenResponse) + .ConfigureAwait(false); + } + else + { + // Get a token from the app provider delegate + authResult = await GetAccessTokenFromAppProviderAsync(cancellationToken, logger) + .ConfigureAwait(false); + } + + // Success - invoke OnSuccess callback if configured + await InvokeOnSuccessCallbackAsync(authResult, exception: null, logger).ConfigureAwait(false); + + return authResult; + } + catch (MsalServiceException serviceEx) + { + // Check if OnMsalServiceFailureCallback is configured + if (AuthenticationRequestParameters.AppConfig.OnMsalServiceFailureCallback != null) + { + logger.Info("[ClientCredentialRequest] MsalServiceException caught. Invoking OnMsalServiceFailureCallback."); + + bool shouldRetry = await InvokeOnMsalServiceFailureCallbackAsync(serviceEx, logger) + .ConfigureAwait(false); + + if (shouldRetry) + { + logger.Info("[ClientCredentialRequest] OnMsalServiceFailureCallback returned true. Retrying token request."); + continue; // Retry the loop + } + + logger.Info("[ClientCredentialRequest] OnMsalServiceFailureCallback returned false. Propagating exception."); + } + + // Invoke OnSuccess callback with failure result + await InvokeOnSuccessCallbackAsync(authResult: null, exception: serviceEx, logger).ConfigureAwait(false); + + // Re-throw if no callback or callback returned false + throw; + } + catch (MsalException ex) + { + // For non-service exceptions (MsalClientException, etc.), invoke OnSuccess and re-throw + await InvokeOnSuccessCallbackAsync(authResult: null, exception: ex, logger).ConfigureAwait(false); + throw; + } } + } - // Get a token from the app provider delegate + /// + /// Invokes the OnMsalServiceFailureCallback if configured. + /// Returns true if the request should be retried, false otherwise. + /// + private async Task InvokeOnMsalServiceFailureCallbackAsync( + MsalServiceException serviceException, + ILoggerAdapter logger) + { + try + { + var options = new AssertionRequestOptions(AuthenticationRequestParameters.AppConfig); + + bool shouldRetry = await AuthenticationRequestParameters.AppConfig + .OnMsalServiceFailureCallback(options, serviceException) + .ConfigureAwait(false); + + logger.Verbose(() => $"[ClientCredentialRequest] OnMsalServiceFailureCallback returned: {shouldRetry}"); + return shouldRetry; + } + catch (Exception ex) + { + // If the callback throws, log and don't retry + logger.Error($"[ClientCredentialRequest] OnMsalServiceFailureCallback threw an exception: {ex.Message}"); + logger.ErrorPii(ex); + return false; + } + } + + /// + /// Invokes the OnSuccessCallback if configured. + /// Exceptions from the callback are caught and logged to prevent disrupting the authentication flow. + /// + private async Task InvokeOnSuccessCallbackAsync( + AuthenticationResult authResult, + MsalException exception, + ILoggerAdapter logger) + { + if (AuthenticationRequestParameters.AppConfig.OnSuccessCallback == null) + { + return; + } + + try + { + logger.Verbose(() => "[ClientCredentialRequest] Invoking OnSuccess callback."); + + var options = new AssertionRequestOptions(AuthenticationRequestParameters.AppConfig); + + var executionResult = new ExecutionResult + { + Successful = authResult != null, + Result = authResult, + Exception = exception + }; + + await AuthenticationRequestParameters.AppConfig + .OnSuccessCallback(options, executionResult) + .ConfigureAwait(false); + + logger.Verbose(() => "[ClientCredentialRequest] OnSuccess callback completed successfully."); + } + catch (Exception ex) + { + // Catch and log any exceptions from the observer callback + // Do not propagate - observer should not disrupt authentication flow + logger.Error($"[ClientCredentialRequest] OnSuccess callback threw an exception: {ex.Message}"); + logger.ErrorPii(ex); + } + } + + /// + /// Gets an access token from the app token provider. + /// Uses semaphore to prevent concurrent calls to the external provider. + /// + private async Task GetAccessTokenFromAppProviderAsync( + CancellationToken cancellationToken, + ILoggerAdapter logger) + { AuthenticationResult authResult; MsalAccessTokenCacheItem cachedAccessTokenItem; @@ -301,15 +430,15 @@ private void MarkAccessTokenAsCacheHit() private AuthenticationResult CreateAuthenticationResultFromCache(MsalAccessTokenCacheItem cachedAccessTokenItem) { AuthenticationResult authResult = new AuthenticationResult( - cachedAccessTokenItem, - null, - AuthenticationRequestParameters.AuthenticationScheme, - AuthenticationRequestParameters.RequestContext.CorrelationId, - TokenSource.Cache, - AuthenticationRequestParameters.RequestContext.ApiEvent, - account: null, - spaAuthCode: null, - additionalResponseParameters: null); + cachedAccessTokenItem, + null, + AuthenticationRequestParameters.AuthenticationScheme, + AuthenticationRequestParameters.RequestContext.CorrelationId, + TokenSource.Cache, + AuthenticationRequestParameters.RequestContext.ApiEvent, + account: null, + spaAuthCode: null, + additionalResponseParameters: null); return authResult; } diff --git a/src/client/Microsoft.Identity.Client/ManagedIdentity/AbstractManagedIdentity.cs b/src/client/Microsoft.Identity.Client/ManagedIdentity/AbstractManagedIdentity.cs index 52ef40dbad..f3935b5152 100644 --- a/src/client/Microsoft.Identity.Client/ManagedIdentity/AbstractManagedIdentity.cs +++ b/src/client/Microsoft.Identity.Client/ManagedIdentity/AbstractManagedIdentity.cs @@ -312,7 +312,7 @@ private void HandleException(Exception ex, _requestContext.Logger.Error($"[Managed Identity] Format Exception: {errorMessage}"); CreateAndThrowException(MsalError.InvalidManagedIdentityEndpoint, errorMessage, formatException, source); } - else if (ex is not MsalServiceException or TaskCanceledException) + else if (ex is not MsalServiceException) { _requestContext.Logger.Error($"[Managed Identity] Exception: {ex.Message}"); CreateAndThrowException(MsalError.ManagedIdentityRequestFailed, ex.Message, ex, source); diff --git a/src/client/Microsoft.Identity.Client/MsalError.cs b/src/client/Microsoft.Identity.Client/MsalError.cs index 526718e7df..90d12e9116 100644 --- a/src/client/Microsoft.Identity.Client/MsalError.cs +++ b/src/client/Microsoft.Identity.Client/MsalError.cs @@ -702,6 +702,12 @@ public static class MsalError /// public const string ClientCredentialAuthenticationTypeMustBeDefined = "Client_Credentials_Required_In_Confidential_Client_Application"; + /// + /// What happens?You configured both a static certificate (WithCertificate(X509Certificate2)) and a dynamic certificate provider (WithCertificate(Func)). + /// MitigationChoose one approach for providing the client certificate. + /// + public const string InvalidClientCredentialConfiguration = "invalid_client_credential_configuration"; + #region InvalidGrant suberrors /// /// Issue can be resolved by user interaction during the interactive authentication flow. 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 5f282702bb..0028dea8d0 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,12 @@ - \ No newline at end of file +Microsoft.Identity.Client.AssertionRequestOptions.Authority.get -> string +Microsoft.Identity.Client.AssertionRequestOptions.Authority.set -> void +Microsoft.Identity.Client.AssertionRequestOptions.TenantId.get -> string +Microsoft.Identity.Client.AssertionRequestOptions.TenantId.set -> void +const Microsoft.Identity.Client.MsalError.InvalidClientCredentialConfiguration = "invalid_client_credential_configuration" -> string +Microsoft.Identity.Client.Extensibility.ExecutionResult +Microsoft.Identity.Client.Extensibility.ExecutionResult.Exception.get -> Microsoft.Identity.Client.MsalException +Microsoft.Identity.Client.Extensibility.ExecutionResult.Result.get -> Microsoft.Identity.Client.AuthenticationResult +Microsoft.Identity.Client.Extensibility.ExecutionResult.Successful.get -> bool +static Microsoft.Identity.Client.Extensibility.ConfidentialClientApplicationBuilderExtensions.OnMsalServiceFailure(this Microsoft.Identity.Client.ConfidentialClientApplicationBuilder builder, System.Func> onMsalServiceFailureCallback) -> Microsoft.Identity.Client.ConfidentialClientApplicationBuilder +static Microsoft.Identity.Client.Extensibility.ConfidentialClientApplicationBuilderExtensions.OnSuccess(this Microsoft.Identity.Client.ConfidentialClientApplicationBuilder builder, System.Func onSuccessCallback) -> Microsoft.Identity.Client.ConfidentialClientApplicationBuilder +static Microsoft.Identity.Client.Extensibility.ConfidentialClientApplicationBuilderExtensions.WithCertificate(this Microsoft.Identity.Client.ConfidentialClientApplicationBuilder builder, System.Func> certificateProvider) -> Microsoft.Identity.Client.ConfidentialClientApplicationBuilder 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 5f282702bb..0028dea8d0 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,12 @@ - \ No newline at end of file +Microsoft.Identity.Client.AssertionRequestOptions.Authority.get -> string +Microsoft.Identity.Client.AssertionRequestOptions.Authority.set -> void +Microsoft.Identity.Client.AssertionRequestOptions.TenantId.get -> string +Microsoft.Identity.Client.AssertionRequestOptions.TenantId.set -> void +const Microsoft.Identity.Client.MsalError.InvalidClientCredentialConfiguration = "invalid_client_credential_configuration" -> string +Microsoft.Identity.Client.Extensibility.ExecutionResult +Microsoft.Identity.Client.Extensibility.ExecutionResult.Exception.get -> Microsoft.Identity.Client.MsalException +Microsoft.Identity.Client.Extensibility.ExecutionResult.Result.get -> Microsoft.Identity.Client.AuthenticationResult +Microsoft.Identity.Client.Extensibility.ExecutionResult.Successful.get -> bool +static Microsoft.Identity.Client.Extensibility.ConfidentialClientApplicationBuilderExtensions.OnMsalServiceFailure(this Microsoft.Identity.Client.ConfidentialClientApplicationBuilder builder, System.Func> onMsalServiceFailureCallback) -> Microsoft.Identity.Client.ConfidentialClientApplicationBuilder +static Microsoft.Identity.Client.Extensibility.ConfidentialClientApplicationBuilderExtensions.OnSuccess(this Microsoft.Identity.Client.ConfidentialClientApplicationBuilder builder, System.Func onSuccessCallback) -> Microsoft.Identity.Client.ConfidentialClientApplicationBuilder +static Microsoft.Identity.Client.Extensibility.ConfidentialClientApplicationBuilderExtensions.WithCertificate(this Microsoft.Identity.Client.ConfidentialClientApplicationBuilder builder, System.Func> certificateProvider) -> Microsoft.Identity.Client.ConfidentialClientApplicationBuilder 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 5f282702bb..487820e412 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,12 @@ - \ No newline at end of file +Microsoft.Identity.Client.AssertionRequestOptions.Authority.get -> string +Microsoft.Identity.Client.AssertionRequestOptions.Authority.set -> void +Microsoft.Identity.Client.AssertionRequestOptions.TenantId.get -> string +Microsoft.Identity.Client.AssertionRequestOptions.TenantId.set -> void +const Microsoft.Identity.Client.MsalError.InvalidClientCredentialConfiguration = "invalid_client_credential_configuration" -> string +Microsoft.Identity.Client.Extensibility.ExecutionResult +Microsoft.Identity.Client.Extensibility.ExecutionResult.Exception.get -> Microsoft.Identity.Client.MsalException +Microsoft.Identity.Client.Extensibility.ExecutionResult.Result.get -> Microsoft.Identity.Client.AuthenticationResult +Microsoft.Identity.Client.Extensibility.ExecutionResult.Successful.get -> bool +static Microsoft.Identity.Client.Extensibility.ConfidentialClientApplicationBuilderExtensions.OnMsalServiceFailure(this Microsoft.Identity.Client.ConfidentialClientApplicationBuilder builder, System.Func> onMsalServiceFailureCallback) -> Microsoft.Identity.Client.ConfidentialClientApplicationBuilder +static Microsoft.Identity.Client.Extensibility.ConfidentialClientApplicationBuilderExtensions.OnSuccess(this Microsoft.Identity.Client.ConfidentialClientApplicationBuilder builder, System.Func onSuccessCallback) -> Microsoft.Identity.Client.ConfidentialClientApplicationBuilder +static Microsoft.Identity.Client.Extensibility.ConfidentialClientApplicationBuilderExtensions.WithCertificate(this Microsoft.Identity.Client.ConfidentialClientApplicationBuilder builder, System.Func> certificateProvider) -> Microsoft.Identity.Client.ConfidentialClientApplicationBuilder 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 5f282702bb..487820e412 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,12 @@ - \ No newline at end of file +Microsoft.Identity.Client.AssertionRequestOptions.Authority.get -> string +Microsoft.Identity.Client.AssertionRequestOptions.Authority.set -> void +Microsoft.Identity.Client.AssertionRequestOptions.TenantId.get -> string +Microsoft.Identity.Client.AssertionRequestOptions.TenantId.set -> void +const Microsoft.Identity.Client.MsalError.InvalidClientCredentialConfiguration = "invalid_client_credential_configuration" -> string +Microsoft.Identity.Client.Extensibility.ExecutionResult +Microsoft.Identity.Client.Extensibility.ExecutionResult.Exception.get -> Microsoft.Identity.Client.MsalException +Microsoft.Identity.Client.Extensibility.ExecutionResult.Result.get -> Microsoft.Identity.Client.AuthenticationResult +Microsoft.Identity.Client.Extensibility.ExecutionResult.Successful.get -> bool +static Microsoft.Identity.Client.Extensibility.ConfidentialClientApplicationBuilderExtensions.OnMsalServiceFailure(this Microsoft.Identity.Client.ConfidentialClientApplicationBuilder builder, System.Func> onMsalServiceFailureCallback) -> Microsoft.Identity.Client.ConfidentialClientApplicationBuilder +static Microsoft.Identity.Client.Extensibility.ConfidentialClientApplicationBuilderExtensions.OnSuccess(this Microsoft.Identity.Client.ConfidentialClientApplicationBuilder builder, System.Func onSuccessCallback) -> Microsoft.Identity.Client.ConfidentialClientApplicationBuilder +static Microsoft.Identity.Client.Extensibility.ConfidentialClientApplicationBuilderExtensions.WithCertificate(this Microsoft.Identity.Client.ConfidentialClientApplicationBuilder builder, System.Func> certificateProvider) -> Microsoft.Identity.Client.ConfidentialClientApplicationBuilder 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 5f282702bb..0028dea8d0 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,12 @@ - \ No newline at end of file +Microsoft.Identity.Client.AssertionRequestOptions.Authority.get -> string +Microsoft.Identity.Client.AssertionRequestOptions.Authority.set -> void +Microsoft.Identity.Client.AssertionRequestOptions.TenantId.get -> string +Microsoft.Identity.Client.AssertionRequestOptions.TenantId.set -> void +const Microsoft.Identity.Client.MsalError.InvalidClientCredentialConfiguration = "invalid_client_credential_configuration" -> string +Microsoft.Identity.Client.Extensibility.ExecutionResult +Microsoft.Identity.Client.Extensibility.ExecutionResult.Exception.get -> Microsoft.Identity.Client.MsalException +Microsoft.Identity.Client.Extensibility.ExecutionResult.Result.get -> Microsoft.Identity.Client.AuthenticationResult +Microsoft.Identity.Client.Extensibility.ExecutionResult.Successful.get -> bool +static Microsoft.Identity.Client.Extensibility.ConfidentialClientApplicationBuilderExtensions.OnMsalServiceFailure(this Microsoft.Identity.Client.ConfidentialClientApplicationBuilder builder, System.Func> onMsalServiceFailureCallback) -> Microsoft.Identity.Client.ConfidentialClientApplicationBuilder +static Microsoft.Identity.Client.Extensibility.ConfidentialClientApplicationBuilderExtensions.OnSuccess(this Microsoft.Identity.Client.ConfidentialClientApplicationBuilder builder, System.Func onSuccessCallback) -> Microsoft.Identity.Client.ConfidentialClientApplicationBuilder +static Microsoft.Identity.Client.Extensibility.ConfidentialClientApplicationBuilderExtensions.WithCertificate(this Microsoft.Identity.Client.ConfidentialClientApplicationBuilder builder, System.Func> certificateProvider) -> Microsoft.Identity.Client.ConfidentialClientApplicationBuilder 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 5f282702bb..0028dea8d0 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,12 @@ - \ No newline at end of file +Microsoft.Identity.Client.AssertionRequestOptions.Authority.get -> string +Microsoft.Identity.Client.AssertionRequestOptions.Authority.set -> void +Microsoft.Identity.Client.AssertionRequestOptions.TenantId.get -> string +Microsoft.Identity.Client.AssertionRequestOptions.TenantId.set -> void +const Microsoft.Identity.Client.MsalError.InvalidClientCredentialConfiguration = "invalid_client_credential_configuration" -> string +Microsoft.Identity.Client.Extensibility.ExecutionResult +Microsoft.Identity.Client.Extensibility.ExecutionResult.Exception.get -> Microsoft.Identity.Client.MsalException +Microsoft.Identity.Client.Extensibility.ExecutionResult.Result.get -> Microsoft.Identity.Client.AuthenticationResult +Microsoft.Identity.Client.Extensibility.ExecutionResult.Successful.get -> bool +static Microsoft.Identity.Client.Extensibility.ConfidentialClientApplicationBuilderExtensions.OnMsalServiceFailure(this Microsoft.Identity.Client.ConfidentialClientApplicationBuilder builder, System.Func> onMsalServiceFailureCallback) -> Microsoft.Identity.Client.ConfidentialClientApplicationBuilder +static Microsoft.Identity.Client.Extensibility.ConfidentialClientApplicationBuilderExtensions.OnSuccess(this Microsoft.Identity.Client.ConfidentialClientApplicationBuilder builder, System.Func onSuccessCallback) -> Microsoft.Identity.Client.ConfidentialClientApplicationBuilder +static Microsoft.Identity.Client.Extensibility.ConfidentialClientApplicationBuilderExtensions.WithCertificate(this Microsoft.Identity.Client.ConfidentialClientApplicationBuilder builder, System.Func> certificateProvider) -> Microsoft.Identity.Client.ConfidentialClientApplicationBuilder diff --git a/tests/Microsoft.Identity.Test.Unit/AppConfigTests/ConfidentialClientApplicationExtensibilityApiTests.cs b/tests/Microsoft.Identity.Test.Unit/AppConfigTests/ConfidentialClientApplicationExtensibilityApiTests.cs new file mode 100644 index 0000000000..a8ff9440d9 --- /dev/null +++ b/tests/Microsoft.Identity.Test.Unit/AppConfigTests/ConfidentialClientApplicationExtensibilityApiTests.cs @@ -0,0 +1,353 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Security.Cryptography.X509Certificates; +using System.Threading.Tasks; +using Microsoft.Identity.Client; +using Microsoft.Identity.Client.Extensibility; +using Microsoft.Identity.Test.Common; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.Identity.Test.Unit.AppConfigTests +{ + [TestClass] + [TestCategory(TestCategories.BuilderTests)] + public class ConfidentialClientApplicationExtensibilityApiTests + { + private X509Certificate2 _certificate; + + [TestInitialize] + public void TestInitialize() + { + ApplicationBase.ResetStateForTest(); + } + + [TestCleanup] + public void TestCleanup() + { + _certificate?.Dispose(); + } + + #region WithCertificate Tests + + [TestMethod] + public void WithCertificate_CallbackIsStored() + { + // Arrange + bool callbackInvoked = false; + Task certificateProvider(AssertionRequestOptions options) + { + callbackInvoked = true; + return Task.FromResult(GetTestCertificate()); + } + + // Act + var app = ConfidentialClientApplicationBuilder + .Create(TestConstants.ClientId) + .WithExperimentalFeatures() + .WithCertificate(certificateProvider) + .BuildConcrete(); + + // Assert + Assert.IsNotNull((app.AppConfig as ApplicationConfiguration)?.ClientCredentialCertificateProvider); + Assert.IsFalse(callbackInvoked, "Certificate provider callback is not yet invoked."); + } + + [TestMethod] + public void WithCertificate_ThrowsOnNullCallback() + { + // Act & Assert + var ex = Assert.ThrowsException(() => + ConfidentialClientApplicationBuilder + .Create(TestConstants.ClientId) + .WithExperimentalFeatures() + .WithCertificate((Func>)null) + .Build()); + + Assert.AreEqual("certificateProvider", ex.ParamName); + } + + [TestMethod] + public void WithCertificate_AllowsMultipleCallbackRegistrations_LastOneWins() + { + // Arrange + int firstCallbackInvoked = 0; + int secondCallbackInvoked = 0; + + Task firstProvider(AssertionRequestOptions options) + { + firstCallbackInvoked++; + return Task.FromResult(GetTestCertificate()); + } + + Task secondProvider(AssertionRequestOptions options) + { + secondCallbackInvoked++; + return Task.FromResult(GetTestCertificate()); + } + + // Act + var app = ConfidentialClientApplicationBuilder + .Create(TestConstants.ClientId) + .WithExperimentalFeatures() + .WithCertificate(firstProvider) + .WithCertificate(secondProvider) + .BuildConcrete(); + + // Assert - last one should be stored + var config = app.AppConfig as ApplicationConfiguration; + Assert.IsNotNull(config); + Assert.IsNotNull(config.ClientCredentialCertificateProvider); + Assert.AreNotSame(firstProvider, config.ClientCredentialCertificateProvider); + } + + #endregion + + #region OnMsalServiceFailure Tests + + [TestMethod] + public void OnMsalServiceFailure_CallbackIsStored() + { + // Arrange + Task onMsalServiceFailureCallback(AssertionRequestOptions options, MsalException ex) => Task.FromResult(false); + + // Act + var app = ConfidentialClientApplicationBuilder + .Create(TestConstants.ClientId) + .WithExperimentalFeatures() + .WithClientSecret(TestConstants.ClientSecret) + .OnMsalServiceFailure(onMsalServiceFailureCallback) + .BuildConcrete(); + + // Assert + Assert.IsNotNull((app.AppConfig as ApplicationConfiguration)?.OnMsalServiceFailureCallback); + } + + [TestMethod] + public void OnMsalServiceFailure_ThrowsOnNullCallback() + { + // Act & Assert + var ex = Assert.ThrowsException(() => + ConfidentialClientApplicationBuilder + .Create(TestConstants.ClientId) + .WithExperimentalFeatures() + .WithClientSecret(TestConstants.ClientSecret) + .OnMsalServiceFailure(null) + .Build()); + + Assert.AreEqual("onMsalServiceFailureCallback", ex.ParamName); + } + + #endregion + + #region OnSuccess Tests + + [TestMethod] + public void OnSuccess_CallbackIsStored() + { + // Arrange + Task onSuccessCallback(AssertionRequestOptions options, ExecutionResult result) => Task.CompletedTask; + + // Act + var app = ConfidentialClientApplicationBuilder + .Create(TestConstants.ClientId) + .WithExperimentalFeatures() + .WithClientSecret(TestConstants.ClientSecret) + .OnSuccess(onSuccessCallback) + .BuildConcrete(); + + // Assert + Assert.IsNotNull((app.AppConfig as ApplicationConfiguration)?.OnSuccessCallback); + } + + [TestMethod] + public void OnSuccess_ThrowsOnNullCallback() + { + // Act & Assert + var ex = Assert.ThrowsException(() => + ConfidentialClientApplicationBuilder + .Create(TestConstants.ClientId) + .WithExperimentalFeatures() + .WithClientSecret(TestConstants.ClientSecret) + .OnSuccess(null) + .Build()); + + Assert.AreEqual("onSuccessCallback", ex.ParamName); + } + + #endregion + + #region ExecutionResult Tests + + [TestMethod] + public void ExecutionResult_CanBeCreated() + { + // Act + var result = new ExecutionResult(); + + // Assert + Assert.IsNotNull(result); + Assert.IsFalse(result.Successful); + Assert.IsNull(result.Result); + Assert.IsNull(result.Exception); + } + + [TestMethod] + public void ExecutionResult_PropertiesCanBeSet() + { + // Arrange + var authResult = new AuthenticationResult( + accessToken: "token", + isExtendedLifeTimeToken: false, + uniqueId: "unique_id", + expiresOn: DateTimeOffset.UtcNow.AddHours(1), + extendedExpiresOn: DateTimeOffset.UtcNow.AddHours(2), + tenantId: TestConstants.TenantId, + account: null, + idToken: "id_token", + scopes: new[] { "scope1" }, + correlationId: Guid.NewGuid(), + tokenType: "Bearer", + authenticationResultMetadata: null); + + var msalException = new MsalServiceException("error_code", "error_message"); + + // Act - Success case + var successResult = new ExecutionResult + { + Successful = true, + Result = authResult, + Exception = null + }; + + // Assert + Assert.IsTrue(successResult.Successful); + Assert.AreSame(authResult, successResult.Result); + Assert.IsNull(successResult.Exception); + + // Act - Failure case + var failureResult = new ExecutionResult + { + Successful = false, + Result = null, + Exception = msalException + }; + + // Assert + Assert.IsFalse(failureResult.Successful); + Assert.IsNull(failureResult.Result); + Assert.AreSame(msalException, failureResult.Exception); + } + + #endregion + + #region Integration Tests + + [TestMethod] + public void AllThreeExtensibilityPoints_CanBeConfiguredTogether() + { + // Arrange + Task certificateProvider(AssertionRequestOptions options) => Task.FromResult(GetTestCertificate()); + Task onMsalServiceFailure(AssertionRequestOptions options, MsalException ex) => Task.FromResult(false); + Task onSuccess(AssertionRequestOptions options, ExecutionResult result) => Task.CompletedTask; + + // Act + var app = ConfidentialClientApplicationBuilder + .Create(TestConstants.ClientId) + .WithExperimentalFeatures() + .WithCertificate(certificateProvider) + .OnMsalServiceFailure(onMsalServiceFailure) + .OnSuccess(onSuccess) + .BuildConcrete(); + + // Assert + var config = app.AppConfig as ApplicationConfiguration; + Assert.IsNotNull(config.ClientCredentialCertificateProvider); + Assert.IsNotNull(config.OnMsalServiceFailureCallback); + Assert.IsNotNull(config.OnSuccessCallback); + } + + [TestMethod] + public void ExtensibilityPoints_CanBeConfiguredInAnyOrder() + { + // Arrange + Task certificateProvider(AssertionRequestOptions options) => Task.FromResult(GetTestCertificate()); + Task onMsalServiceFailure(AssertionRequestOptions options, MsalException ex) => Task.FromResult(false); + Task onSuccess(AssertionRequestOptions options, ExecutionResult result) => Task.CompletedTask; + + // Act - Order: OnSuccess, OnMsalServiceFailure, Certificate + var app1 = ConfidentialClientApplicationBuilder + .Create(TestConstants.ClientId) + .WithExperimentalFeatures() + .OnSuccess(onSuccess) + .OnMsalServiceFailure(onMsalServiceFailure) + .WithCertificate(certificateProvider) + .BuildConcrete(); + + // Act - Order: OnMsalServiceFailure, Certificate, OnSuccess + var app2 = ConfidentialClientApplicationBuilder + .Create(TestConstants.ClientId) + .WithExperimentalFeatures() + .OnMsalServiceFailure(onMsalServiceFailure) + .WithCertificate(certificateProvider) + .OnSuccess(onSuccess) + .BuildConcrete(); + + // Assert + var config1 = app1.AppConfig as ApplicationConfiguration; + Assert.IsNotNull(config1); + Assert.IsNotNull(config1.ClientCredentialCertificateProvider); + Assert.IsNotNull(config1.OnMsalServiceFailureCallback); + Assert.IsNotNull(config1.OnSuccessCallback); + + var config2 = app2.AppConfig as ApplicationConfiguration; + Assert.IsNotNull(config2, "app2.AppConfig should be of type ApplicationConfiguration"); + Assert.IsNotNull(config2.ClientCredentialCertificateProvider); + Assert.IsNotNull(config2.OnMsalServiceFailureCallback); + Assert.IsNotNull(config2.OnSuccessCallback); + } + + [TestMethod] + public void WithCertificate_WorksWithOtherConfidentialClientOptions() + { + // Arrange + Task certificateProvider(AssertionRequestOptions options) + { + Assert.AreEqual(TestConstants.ClientId, options.ClientID); + return Task.FromResult(GetTestCertificate()); + } + + // Act + var app = ConfidentialClientApplicationBuilder + .Create(TestConstants.ClientId) + .WithExperimentalFeatures() + .WithAuthority(TestConstants.AadAuthorityWithTestTenantId) + .WithCertificate(certificateProvider) + .BuildConcrete(); + + // Assert + Assert.IsNotNull(app); + Assert.IsNotNull((app.AppConfig as ApplicationConfiguration)?.ClientCredentialCertificateProvider); + } + + #endregion + + #region Helper Methods + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Internal.Analyzers", "IA5352:DoNotMisuseCryptographicApi", + Justification = "Test code only")] + private X509Certificate2 GetTestCertificate() + { + if (_certificate == null) + { + _certificate = new X509Certificate2( + ResourceHelper.GetTestResourceRelativePath("testCert.crtfile"), + TestConstants.TestCertPassword); + } + return _certificate; + } + + #endregion + } +} diff --git a/tests/Microsoft.Identity.Test.Unit/PublicApiTests/ConfidentialClientApplicationExtensibilityTests.cs b/tests/Microsoft.Identity.Test.Unit/PublicApiTests/ConfidentialClientApplicationExtensibilityTests.cs new file mode 100644 index 0000000000..d9727fd777 --- /dev/null +++ b/tests/Microsoft.Identity.Test.Unit/PublicApiTests/ConfidentialClientApplicationExtensibilityTests.cs @@ -0,0 +1,491 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +#if !ANDROID && !iOS +using System; +using System.Threading.Tasks; +using Microsoft.Identity.Client; +using Microsoft.Identity.Client.Extensibility; +using Microsoft.Identity.Test.Common.Core.Helpers; +using Microsoft.Identity.Test.Common.Core.Mocks; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.Identity.Test.Unit.PublicApiTests +{ + [TestClass] + [DeploymentItem(@"Resources\testCert.crtfile")] + public class ConfidentialClientApplicationExtensibilityTests : TestBase + { + [TestInitialize] + public override void TestInitialize() + { + base.TestInitialize(); + } + + #region WithCertificate (Dynamic Provider) Integration Tests + + [TestMethod] + [Description("Dynamic certificate provider is invoked and cert is used for client assertion")] + public async Task DynamicCertificateProvider_IsInvoked_AndUsedForAssertionAsync() + { + // Arrange + using (var harness = CreateTestHarness()) + { + harness.HttpManager.AddInstanceDiscoveryMockHandler(); + + bool providerInvoked = false; + AssertionRequestOptions capturedOptions = null; + + var certificate = CertHelper.GetOrCreateTestCert(); + + var app = ConfidentialClientApplicationBuilder + .Create(TestConstants.ClientId) + .WithExperimentalFeatures() + .WithAuthority(TestConstants.AuthorityCommonTenant) + .WithHttpManager(harness.HttpManager) + .WithCertificate((AssertionRequestOptions options) => + { + providerInvoked = true; + capturedOptions = options; + + // Validate options + Assert.AreEqual(TestConstants.ClientId, options.ClientID); + Assert.IsNotNull(options.TokenEndpoint); + + return Task.FromResult(certificate); + }) + .Build(); + + harness.HttpManager.AddMockHandlerSuccessfulClientCredentialTokenResponseMessage(); + + // Act + var result = await app.AcquireTokenForClient(TestConstants.s_scope) + .ExecuteAsync() + .ConfigureAwait(false); + + // Assert + Assert.IsTrue(providerInvoked, "Certificate provider should have been invoked"); + Assert.IsNotNull(capturedOptions); + Assert.IsNotNull(result.AccessToken); + Assert.AreEqual(TokenSource.IdentityProvider, result.AuthenticationResultMetadata.TokenSource); + } + } + + [TestMethod] + [Description("Dynamic certificate provider returning null throws appropriate exception")] + public async Task DynamicCertificateProvider_ReturnsNull_ThrowsExceptionAsync() + { + // Arrange + using (var harness = CreateTestHarness()) + { + harness.HttpManager.AddInstanceDiscoveryMockHandler(); + + var app = ConfidentialClientApplicationBuilder + .Create(TestConstants.ClientId) + .WithExperimentalFeatures() + .WithAuthority(TestConstants.AuthorityCommonTenant) + .WithHttpManager(harness.HttpManager) + .WithCertificate((AssertionRequestOptions options) => + { + return Task.FromResult(null); // Provider returns null + }) + .Build(); + + // Act & Assert + var exception = await Assert.ThrowsExceptionAsync(async () => + { + await app.AcquireTokenForClient(TestConstants.s_scope) + .ExecuteAsync() + .ConfigureAwait(false); + }).ConfigureAwait(false); + + Assert.AreEqual(MsalError.InvalidClientAssertion, exception.ErrorCode); + Assert.IsTrue(exception.Message.Contains("returned null")); + } + } + + #endregion + + #region OnMsalServiceFailure Integration Tests + + [TestMethod] + [Description("OnMsalServiceFailure is invoked on service exception and retries successfully")] + public async Task OnMsalServiceFailure_RetriesOnServiceError_SucceedsAsync() + { + // Arrange + using (var harness = CreateTestHarness()) + { + harness.HttpManager.AddInstanceDiscoveryMockHandler(); + + int failureCallbackCount = 0; + MsalServiceException capturedException = null; + + var app = ConfidentialClientApplicationBuilder + .Create(TestConstants.ClientId) + .WithExperimentalFeatures() + .WithAuthority(TestConstants.AuthorityCommonTenant) + .WithClientSecret(TestConstants.ClientSecret) + .WithHttpManager(harness.HttpManager) + .OnMsalServiceFailure((AssertionRequestOptions options, MsalException ex) => + { + failureCallbackCount++; + capturedException = ex as MsalServiceException; + + Assert.IsNotNull(capturedException, "Exception should be MsalServiceException"); + Assert.AreEqual(TestConstants.ClientId, options.ClientID); + + // Retry on 503 + return Task.FromResult(capturedException.StatusCode == 400 && failureCallbackCount < 3); + }) + .Build(); + + // Mock 2 failures, then success + harness.HttpManager.AddFailureTokenEndpointResponse("request_failed"); + harness.HttpManager.AddFailureTokenEndpointResponse("request_failed"); + harness.HttpManager.AddMockHandlerSuccessfulClientCredentialTokenResponseMessage(); + + // Act + var result = await app.AcquireTokenForClient(TestConstants.s_scope) + .ExecuteAsync() + .ConfigureAwait(false); + + // Assert + Assert.AreEqual(2, failureCallbackCount, "Callback should be invoked twice"); + Assert.IsNotNull(result.AccessToken); + Assert.AreEqual(400, capturedException.StatusCode); + } + } + + [TestMethod] + [Description("OnMsalServiceFailure returns false and exception is propagated")] + public async Task OnMsalServiceFailure_ReturnsFalse_PropagatesExceptionAsync() + { + // Arrange + using (var harness = CreateTestHarness()) + { + harness.HttpManager.AddInstanceDiscoveryMockHandler(); + + bool callbackInvoked = false; + + var app = ConfidentialClientApplicationBuilder + .Create(TestConstants.ClientId) + .WithExperimentalFeatures() + .WithAuthority(TestConstants.AuthorityCommonTenant) + .WithClientSecret(TestConstants.ClientSecret) + .WithHttpManager(harness.HttpManager) + .OnMsalServiceFailure((AssertionRequestOptions options, MsalException ex) => + { + callbackInvoked = true; + return Task.FromResult(false); // Don't retry + }) + .Build(); + + harness.HttpManager.AddFailureTokenEndpointResponse("request_failed"); + + // Act & Assert + await Assert.ThrowsExceptionAsync(async () => + { + await app.AcquireTokenForClient(TestConstants.s_scope) + .ExecuteAsync() + .ConfigureAwait(false); + }).ConfigureAwait(false); + + Assert.IsTrue(callbackInvoked); + } + } + + [TestMethod] + [Description("OnMsalServiceFailure is NOT invoked for client exceptions")] + public async Task OnMsalServiceFailure_NotInvokedForClientExceptionsAsync() + { + // Arrange + using (var harness = CreateTestHarness()) + { + harness.HttpManager.AddInstanceDiscoveryMockHandler(); + + bool callbackInvoked = false; + + var app = ConfidentialClientApplicationBuilder + .Create(TestConstants.ClientId) + .WithExperimentalFeatures() + .WithAuthority(TestConstants.AuthorityCommonTenant) + .WithCertificate((AssertionRequestOptions options) => + { + return Task.FromResult(null); // Will cause MsalClientException + }) + .OnMsalServiceFailure((AssertionRequestOptions options, MsalException ex) => + { + callbackInvoked = true; + return Task.FromResult(false); + }) + .Build(); + + // Act & Assert + var exception = await Assert.ThrowsExceptionAsync(async () => + { + await app.AcquireTokenForClient(TestConstants.s_scope) + .ExecuteAsync() + .ConfigureAwait(false); + }).ConfigureAwait(false); + + Assert.IsFalse(callbackInvoked, "Callback should NOT be invoked for client exceptions"); + Assert.AreEqual(MsalError.InvalidClientAssertion, exception.ErrorCode); + } + } + + #endregion + + #region OnSuccess Integration Tests + + [TestMethod] + [Description("OnSuccess is invoked with successful result")] + public async Task OnSuccess_InvokedWithSuccessfulResultAsync() + { + // Arrange + using (var harness = CreateTestHarness()) + { + harness.HttpManager.AddInstanceDiscoveryMockHandler(); + + bool observerInvoked = false; + ExecutionResult capturedResult = null; + AssertionRequestOptions capturedOptions = null; + + var app = ConfidentialClientApplicationBuilder + .Create(TestConstants.ClientId) + .WithExperimentalFeatures() + .WithAuthority(TestConstants.AuthorityCommonTenant) + .WithClientSecret(TestConstants.ClientSecret) + .WithHttpManager(harness.HttpManager) + .OnSuccess((AssertionRequestOptions options, ExecutionResult result) => + { + observerInvoked = true; + capturedResult = result; + capturedOptions = options; + + Assert.IsTrue(result.Successful); + Assert.IsNotNull(result.Result); + Assert.IsNull(result.Exception); + Assert.AreEqual(TestConstants.ClientId, options.ClientID); + + return Task.CompletedTask; + }) + .Build(); + + harness.HttpManager.AddMockHandlerSuccessfulClientCredentialTokenResponseMessage(); + + // Act + var result = await app.AcquireTokenForClient(TestConstants.s_scope) + .ExecuteAsync() + .ConfigureAwait(false); + + // Assert + Assert.IsTrue(observerInvoked, "Observer should be invoked"); + Assert.IsNotNull(capturedResult); + Assert.IsTrue(capturedResult.Successful); + Assert.IsNotNull(capturedResult.Result); + Assert.AreEqual(result.AccessToken, capturedResult.Result.AccessToken); + } + } + + [TestMethod] + [Description("OnSuccess is invoked with failure result after retries exhausted")] + public async Task OnSuccess_InvokedWithFailureResult_AfterRetriesExhaustedAsync() + { + // Arrange + using (var harness = CreateTestHarness()) + { + harness.HttpManager.AddInstanceDiscoveryMockHandler(); + + int retryCount = 0; + bool observerInvoked = false; + ExecutionResult capturedResult = null; + + var app = ConfidentialClientApplicationBuilder + .Create(TestConstants.ClientId) + .WithExperimentalFeatures() + .WithAuthority(TestConstants.AuthorityCommonTenant) + .WithClientSecret(TestConstants.ClientSecret) + .WithHttpManager(harness.HttpManager) + .OnMsalServiceFailure((AssertionRequestOptions options, MsalException ex) => + { + retryCount++; + return Task.FromResult(retryCount < 2); // Retry once, then give up + }) + .OnSuccess((AssertionRequestOptions options, ExecutionResult result) => + { + observerInvoked = true; + capturedResult = result; + + Assert.IsFalse(result.Successful); + Assert.IsNull(result.Result); + Assert.IsNotNull(result.Exception); + Assert.IsInstanceOfType(result.Exception, typeof(MsalServiceException)); + + return Task.CompletedTask; + }) + .Build(); + + // Mock 2 failures + harness.HttpManager.AddFailureTokenEndpointResponse("request_failed"); + harness.HttpManager.AddFailureTokenEndpointResponse("request_failed"); + + // Act & Assert + var exception = await Assert.ThrowsExceptionAsync(async () => + { + await app.AcquireTokenForClient(TestConstants.s_scope) + .ExecuteAsync() + .ConfigureAwait(false); + }).ConfigureAwait(false); + + Assert.IsTrue(observerInvoked, "Observer should be invoked even on failure"); + Assert.IsNotNull(capturedResult); + Assert.IsFalse(capturedResult.Successful); + Assert.AreEqual(exception, capturedResult.Exception); + } + } + + [TestMethod] + [Description("OnSuccess exception is caught and logged, doesn't disrupt flow")] + public async Task OnSuccess_ExceptionIsCaught_DoesNotDisruptFlowAsync() + { + // Arrange + using (var harness = CreateTestHarness()) + { + harness.HttpManager.AddInstanceDiscoveryMockHandler(); + + var app = ConfidentialClientApplicationBuilder + .Create(TestConstants.ClientId) + .WithExperimentalFeatures() + .WithAuthority(TestConstants.AuthorityCommonTenant) + .WithClientSecret(TestConstants.ClientSecret) + .WithHttpManager(harness.HttpManager) + .OnSuccess((AssertionRequestOptions options, ExecutionResult result) => + { + throw new InvalidOperationException("Observer threw exception"); + }) + .Build(); + + harness.HttpManager.AddMockHandlerSuccessfulClientCredentialTokenResponseMessage(); + + // Act - should NOT throw, observer exception should be caught + var result = await app.AcquireTokenForClient(TestConstants.s_scope) + .ExecuteAsync() + .ConfigureAwait(false); + + // Assert + Assert.IsNotNull(result); + Assert.IsNotNull(result.AccessToken); + } + } + + #endregion + + #region Combined Scenarios + + [TestMethod] + [Description("All three extensibility points work together: cert provider, retry, observer")] + public async Task AllThreeExtensibilityPoints_WorkTogetherAsync() + { + // Arrange + using (var harness = CreateTestHarness()) + { + harness.HttpManager.AddInstanceDiscoveryMockHandler(); + + int certProviderCount = 0; + int retryCallbackCount = 0; + bool observerInvoked = false; + + var certificate = CertHelper.GetOrCreateTestCert(); + + var app = ConfidentialClientApplicationBuilder + .Create(TestConstants.ClientId) + .WithExperimentalFeatures() + .WithAuthority(TestConstants.AuthorityCommonTenant) + .WithHttpManager(harness.HttpManager) + .WithCertificate((AssertionRequestOptions options) => + { + certProviderCount++; + Assert.AreEqual(TestConstants.ClientId, options.ClientID); + return Task.FromResult(certificate); + }) + .OnMsalServiceFailure((AssertionRequestOptions options, MsalException ex) => + { + retryCallbackCount++; + Assert.IsInstanceOfType(ex, typeof(MsalServiceException)); + return Task.FromResult(retryCallbackCount < 2); // Retry once + }) + .OnSuccess((AssertionRequestOptions options, ExecutionResult result) => + { + observerInvoked = true; + Assert.IsTrue(result.Successful); + Assert.IsNotNull(result.Result); + return Task.CompletedTask; + }) + .Build(); + + // Mock: fail once, then succeed + harness.HttpManager.AddFailureTokenEndpointResponse("request_failed"); + harness.HttpManager.AddMockHandlerSuccessfulClientCredentialTokenResponseMessage(); + + // Act + var result = await app.AcquireTokenForClient(TestConstants.s_scope) + .ExecuteAsync() + .ConfigureAwait(false); + + // Assert + Assert.AreEqual(2, certProviderCount, "Cert provider invoked for initial + retry"); + Assert.AreEqual(1, retryCallbackCount, "Retry callback invoked once"); + Assert.IsTrue(observerInvoked, "Observer invoked once at completion"); + Assert.IsNotNull(result.AccessToken); + } + } + + [TestMethod] + [Description("Certificate rotation scenario: different cert returned on retry")] + public async Task CertificateRotation_DifferentCertOnRetryAsync() + { + // Arrange + using (var harness = CreateTestHarness()) + { + harness.HttpManager.AddInstanceDiscoveryMockHandler(); + + int certProviderCount = 0; + var cert1 = CertHelper.GetOrCreateTestCert(); + var cert2 = CertHelper.GetOrCreateTestCert(regenerateCert: true); + + var app = ConfidentialClientApplicationBuilder + .Create(TestConstants.ClientId) + .WithExperimentalFeatures() + .WithAuthority(TestConstants.AuthorityCommonTenant) + .WithHttpManager(harness.HttpManager) + .WithCertificate((AssertionRequestOptions options) => + { + certProviderCount++; + // Return different cert on retry + return Task.FromResult(certProviderCount == 1 ? cert1 : cert2); + }) + .OnMsalServiceFailure((AssertionRequestOptions options, MsalException ex) => + { + return Task.FromResult(true); // Always retry once + }) + .Build(); + + // First call fails (cert1), second succeeds (cert2) + harness.HttpManager.AddFailureTokenEndpointResponse("request_failed"); + harness.HttpManager.AddMockHandlerSuccessfulClientCredentialTokenResponseMessage(); + + // Act + var result = await app.AcquireTokenForClient(TestConstants.s_scope) + .ExecuteAsync() + .ConfigureAwait(false); + + // Assert + Assert.AreEqual(2, certProviderCount, "Provider should be called twice"); + Assert.IsNotNull(result.AccessToken); + } + } + + #endregion + } +} +#endif