From 81bc1c08c3b5fdabac1056119239ab4907f10737 Mon Sep 17 00:00:00 2001 From: avdunn Date: Tue, 14 Jul 2026 11:15:13 -0700 Subject: [PATCH] Improve IDW10109 error handling for credential loading failures --- .gitignore | 3 + .../CertificateErrorMessage.cs | 1 - .../CredentialsProvider.cs | 14 ++- .../IDWebErrorMessage.cs | 2 +- .../TokenAcquisition.cs | 27 ++---- .../WithClientCredentialsTests.cs | 93 +++++++++++++++++++ 6 files changed, 118 insertions(+), 22 deletions(-) diff --git a/.gitignore b/.gitignore index 5e52dce77..f0dee1e9a 100644 --- a/.gitignore +++ b/.gitignore @@ -368,6 +368,9 @@ objd/ key*.xml +# MSBuild temp files (.NET 9+ SDK writes these to a Temp/ folder in the repo root) +Temp/ + # bin files *.bin diff --git a/src/Microsoft.Identity.Web.Certificate/CertificateErrorMessage.cs b/src/Microsoft.Identity.Web.Certificate/CertificateErrorMessage.cs index d896f3f9e..13c80a59a 100644 --- a/src/Microsoft.Identity.Web.Certificate/CertificateErrorMessage.cs +++ b/src/Microsoft.Identity.Web.Certificate/CertificateErrorMessage.cs @@ -15,7 +15,6 @@ internal static class CertificateErrorMessage "For instance, in the appsettings.json file. "; public const string BothClientSecretAndCertificateProvided = "IDW10105: Both client secret and client certificate, cannot be included in the configuration of the web app when calling a web API. "; - public const string ClientCertificatesHaveExpiredOrCannotBeLoaded = "IDW10109: All client certificates passed to the configuration have expired or can't be loaded. "; public const string CustomProviderNameAlreadyExists = "IDW10111 The custom signed assertion provider '{0}' already exists, only the the first instance of ICustomSignedAssertionProvider with this name will be used."; public const string CustomProviderNameNullOrEmpty = "IDW10112: You configured a custom signed assertion but did not specify a provider name in the CustomSignedAssertionProviderName property of the CredentialDescription. Please specify the name of the custom assertion provider."; diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/CredentialsProvider.cs b/src/Microsoft.Identity.Web.TokenAcquisition/CredentialsProvider.cs index e9f1f9d0a..d5a1771d2 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/CredentialsProvider.cs +++ b/src/Microsoft.Identity.Web.TokenAcquisition/CredentialsProvider.cs @@ -55,6 +55,7 @@ public CredentialsProvider( IEnumerable clientCredentials = options.ClientCredentials ?? []; string errorMessage = "\n"; + List? exceptions = null; foreach (CredentialDescription credential in clientCredentials) { @@ -72,6 +73,7 @@ public CredentialsProvider( { LogMessages.AttemptToLoadCredentialsFailed(_logger, credential, ex); errorMessage += $"Credential {credential.Id} failed because: {ex} \n"; + (exceptions ??= []).Add(ex); } if (credential.CredentialType == CredentialType.SignedAssertion) @@ -139,9 +141,19 @@ public CredentialsProvider( if (clientCredentials.Any(c => c.CredentialType == CredentialType.Certificate || c.CredentialType == CredentialType.SignedAssertion)) { + // Preserve the original exceptions so callers can inspect service-level + // error details such as AADSTS error codes via InnerException. + Exception? innerException = exceptions switch + { + { Count: 1 } => exceptions[0], + { Count: > 1 } => new AggregateException(exceptions), + _ => null, + }; + throw new ArgumentException( IDWebErrorMessage.ClientCertificatesHaveExpiredOrCannotBeLoaded + errorMessage, - nameof(clientCredentials)); + nameof(clientCredentials), + innerException); } _logger.LogInformation($"No client credential could be used. Secret may have been defined elsewhere. " + diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/IDWebErrorMessage.cs b/src/Microsoft.Identity.Web.TokenAcquisition/IDWebErrorMessage.cs index e6accc8ed..bbd380966 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/IDWebErrorMessage.cs +++ b/src/Microsoft.Identity.Web.TokenAcquisition/IDWebErrorMessage.cs @@ -24,7 +24,7 @@ internal static class IDWebErrorMessage public const string ConfigurationOptionRequired = "IDW10106: The '{0}' option must be provided. "; public const string ScopesNotConfiguredInConfigurationOrViaDelegate = "IDW10107: Scopes need to be passed-in either by configuration or by the delegate overriding it. "; public const string MissingRequiredScopesForAuthorizationFilter = "IDW10108: RequiredScope Attribute does not contain a value. The scopes need to be set on the controller, the page or action. See https://aka.ms/ms-id-web/required-scope-attribute. "; - public const string ClientCertificatesHaveExpiredOrCannotBeLoaded = "IDW10109: No credential could be loaded. This can happen when certificates passed to the configuration have expired or can't be loaded and the code isn't running on Azure to be able to use Managed Identity, Pod Identity etc. Details: "; + public const string ClientCertificatesHaveExpiredOrCannotBeLoaded = "IDW10109: No credential could be loaded. This can happen when certificates have expired or can't be loaded, when a signed assertion (e.g., Federated Identity Credential) fails to acquire an exchange token, or when the code isn't running on Azure to use Managed Identity. Check the inner exception for service-level error details (e.g., AADSTS error codes). Details: "; public const string ClientSecretAndCredentialsCannotBeCombined = "IDW10110: ClientSecret top level configuration cannot be combined with ClientCredentials. Instead, add a new entry in the ClientCredentials array describing the secret."; public const string MissingTokenBindingCertificate = "IDW10115: Token binding requires either a signing certificate or a binding-aware signed assertion (e.g., from a managed identity supporting mTLS PoP). The loaded credential provides neither."; public const string TokenBindingRequiresEnabledAppTokenAcquisition = "IDW10116: Token binding requires enabled app token acquisition."; diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs b/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs index 3be96c6ec..6aed2207d 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs +++ b/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs @@ -1535,25 +1535,14 @@ private async Task BuildConfidentialClientApplic } else { - try - { - await builder.WithClientCredentialsAsync( - mergedOptions, - _credentialsProvider, - new CredentialSourceLoaderParameters(mergedOptions.ClientId!, authority) - { - Protocol = isTokenBinding ? ProtocolNames.MtlsPop : ProtocolNames.Bearer, - }, - isTokenBinding); - } - catch (ArgumentException ex) when (ex.Message == IDWebErrorMessage.ClientCertificatesHaveExpiredOrCannotBeLoaded) - { - Logger.TokenAcquisitionError( - _logger, - IDWebErrorMessage.ClientCertificatesHaveExpiredOrCannotBeLoaded, - ex); - throw; - } + await builder.WithClientCredentialsAsync( + mergedOptions, + _credentialsProvider, + new CredentialSourceLoaderParameters(mergedOptions.ClientId!, authority) + { + Protocol = isTokenBinding ? ProtocolNames.MtlsPop : ProtocolNames.Bearer, + }, + isTokenBinding); } IConfidentialClientApplication app = builder.Build(); diff --git a/tests/Microsoft.Identity.Web.Test/Certificates/WithClientCredentialsTests.cs b/tests/Microsoft.Identity.Web.Test/Certificates/WithClientCredentialsTests.cs index 908cfe83e..657214786 100644 --- a/tests/Microsoft.Identity.Web.Test/Certificates/WithClientCredentialsTests.cs +++ b/tests/Microsoft.Identity.Web.Test/Certificates/WithClientCredentialsTests.cs @@ -653,5 +653,98 @@ public async Task WithClientCredentialsAsync_CertificateWithUseBoundCredentialFa #endregion + #region IDW10109 inner exception preservation tests + + [Fact] + public async Task AllCredentialsFail_SingleFailure_PreservesInnerExceptionAndErrorText() + { + // Arrange + var logger = Substitute.For>(); + ICredentialsLoader credLoader = Substitute.For(); + + var msalException = new MsalServiceException( + "AADSTS700027", + "AADSTS700027: Client assertion contains an invalid signature."); + + credLoader.LoadCredentialsIfNeededAsync(Arg.Any(), Arg.Any()) + .Returns(args => + { + var cd = (args[0] as CredentialDescription)!; + cd.Skip = true; + return Task.FromException(msalException); + }); + + CredentialsProvider provider = new CredentialsProvider(logger, credLoader, [], null); + + var credential = new CredentialDescription + { + SourceType = CredentialSource.SignedAssertionFromManagedIdentity, + ManagedIdentityClientId = "test-client-id" + }; + + // Act + var ex = await Assert.ThrowsAsync(() => provider.GetCredentialAsync( + new MergedOptions { ClientCredentials = new[] { credential } }, null)); + + // Assert — original exception is preserved as InnerException + Assert.NotNull(ex.InnerException); + var inner = Assert.IsType(ex.InnerException); + Assert.Equal("AADSTS700027", inner.ErrorCode); + + // Assert — error message includes IDW10109 code and FIC guidance + Assert.StartsWith("IDW10109:", ex.Message, StringComparison.Ordinal); + Assert.Contains("Federated Identity Credential", ex.Message, StringComparison.Ordinal); + Assert.Contains("inner exception", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task AllCredentialsFail_MultipleFailures_PreservesAllExceptionsViaAggregateException() + { + // Arrange + var logger = Substitute.For>(); + ICredentialsLoader credLoader = Substitute.For(); + + var msalException = new MsalServiceException("AADSTS700027", "Invalid signature."); + var certException = new InvalidOperationException("Certificate not found in store."); + + int callCount = 0; + credLoader.LoadCredentialsIfNeededAsync(Arg.Any(), Arg.Any()) + .Returns(args => + { + var cd = (args[0] as CredentialDescription)!; + cd.Skip = true; + return Task.FromException(callCount++ == 0 ? (Exception)msalException : certException); + }); + + CredentialsProvider provider = new CredentialsProvider(logger, credLoader, [], null); + + var credentials = new[] + { + new CredentialDescription + { + SourceType = CredentialSource.SignedAssertionFromManagedIdentity, + ManagedIdentityClientId = "test-client-id" + }, + new CredentialDescription + { + SourceType = CredentialSource.StoreWithDistinguishedName, + CertificateStorePath = "LocalMachine/My", + CertificateDistinguishedName = "CN=Test" + } + }; + + // Act + var ex = await Assert.ThrowsAsync(() => provider.GetCredentialAsync( + new MergedOptions { ClientCredentials = credentials }, null)); + + // Assert — InnerException is AggregateException containing both originals + Assert.NotNull(ex.InnerException); + var aggEx = Assert.IsType(ex.InnerException); + Assert.Equal(2, aggEx.InnerExceptions.Count); + Assert.IsType(aggEx.InnerExceptions[0]); + Assert.IsType(aggEx.InnerExceptions[1]); + } + + #endregion } }