Skip to content

Commit e2a28b8

Browse files
authored
Improve IDW10109 error handling for credential loading failures (#3946)
1 parent d0ffac2 commit e2a28b8

6 files changed

Lines changed: 118 additions & 22 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,9 @@ objd/
368368

369369
key*.xml
370370

371+
# MSBuild temp files (.NET 9+ SDK writes these to a Temp/ folder in the repo root)
372+
Temp/
373+
371374
# bin files
372375

373376
*.bin

src/Microsoft.Identity.Web.Certificate/CertificateErrorMessage.cs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ internal static class CertificateErrorMessage
1515
"For instance, in the appsettings.json file. ";
1616
public const string BothClientSecretAndCertificateProvided =
1717
"IDW10105: Both client secret and client certificate, cannot be included in the configuration of the web app when calling a web API. ";
18-
public const string ClientCertificatesHaveExpiredOrCannotBeLoaded = "IDW10109: All client certificates passed to the configuration have expired or can't be loaded. ";
1918
public const string CustomProviderNameAlreadyExists =
2019
"IDW10111 The custom signed assertion provider '{0}' already exists, only the the first instance of ICustomSignedAssertionProvider with this name will be used.";
2120
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.";

src/Microsoft.Identity.Web.TokenAcquisition/CredentialsProvider.cs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ public CredentialsProvider(
5555
IEnumerable<CredentialDescription> clientCredentials = options.ClientCredentials ?? [];
5656

5757
string errorMessage = "\n";
58+
List<Exception>? exceptions = null;
5859

5960
foreach (CredentialDescription credential in clientCredentials)
6061
{
@@ -72,6 +73,7 @@ public CredentialsProvider(
7273
{
7374
LogMessages.AttemptToLoadCredentialsFailed(_logger, credential, ex);
7475
errorMessage += $"Credential {credential.Id} failed because: {ex} \n";
76+
(exceptions ??= []).Add(ex);
7577
}
7678

7779
if (credential.CredentialType == CredentialType.SignedAssertion)
@@ -139,9 +141,19 @@ public CredentialsProvider(
139141

140142
if (clientCredentials.Any(c => c.CredentialType == CredentialType.Certificate || c.CredentialType == CredentialType.SignedAssertion))
141143
{
144+
// Preserve the original exceptions so callers can inspect service-level
145+
// error details such as AADSTS error codes via InnerException.
146+
Exception? innerException = exceptions switch
147+
{
148+
{ Count: 1 } => exceptions[0],
149+
{ Count: > 1 } => new AggregateException(exceptions),
150+
_ => null,
151+
};
152+
142153
throw new ArgumentException(
143154
IDWebErrorMessage.ClientCertificatesHaveExpiredOrCannotBeLoaded + errorMessage,
144-
nameof(clientCredentials));
155+
nameof(clientCredentials),
156+
innerException);
145157
}
146158

147159
_logger.LogInformation($"No client credential could be used. Secret may have been defined elsewhere. " +

src/Microsoft.Identity.Web.TokenAcquisition/IDWebErrorMessage.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ internal static class IDWebErrorMessage
2424
public const string ConfigurationOptionRequired = "IDW10106: The '{0}' option must be provided. ";
2525
public const string ScopesNotConfiguredInConfigurationOrViaDelegate = "IDW10107: Scopes need to be passed-in either by configuration or by the delegate overriding it. ";
2626
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. ";
27-
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: ";
27+
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: ";
2828
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.";
2929
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.";
3030
public const string TokenBindingRequiresEnabledAppTokenAcquisition = "IDW10116: Token binding requires enabled app token acquisition.";

src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs

Lines changed: 8 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1535,25 +1535,14 @@ private async Task<IConfidentialClientApplication> BuildConfidentialClientApplic
15351535
}
15361536
else
15371537
{
1538-
try
1539-
{
1540-
await builder.WithClientCredentialsAsync(
1541-
mergedOptions,
1542-
_credentialsProvider,
1543-
new CredentialSourceLoaderParameters(mergedOptions.ClientId!, authority)
1544-
{
1545-
Protocol = isTokenBinding ? ProtocolNames.MtlsPop : ProtocolNames.Bearer,
1546-
},
1547-
isTokenBinding);
1548-
}
1549-
catch (ArgumentException ex) when (ex.Message == IDWebErrorMessage.ClientCertificatesHaveExpiredOrCannotBeLoaded)
1550-
{
1551-
Logger.TokenAcquisitionError(
1552-
_logger,
1553-
IDWebErrorMessage.ClientCertificatesHaveExpiredOrCannotBeLoaded,
1554-
ex);
1555-
throw;
1556-
}
1538+
await builder.WithClientCredentialsAsync(
1539+
mergedOptions,
1540+
_credentialsProvider,
1541+
new CredentialSourceLoaderParameters(mergedOptions.ClientId!, authority)
1542+
{
1543+
Protocol = isTokenBinding ? ProtocolNames.MtlsPop : ProtocolNames.Bearer,
1544+
},
1545+
isTokenBinding);
15571546
}
15581547

15591548
IConfidentialClientApplication app = builder.Build();

tests/Microsoft.Identity.Web.Test/Certificates/WithClientCredentialsTests.cs

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -653,5 +653,98 @@ public async Task WithClientCredentialsAsync_CertificateWithUseBoundCredentialFa
653653

654654
#endregion
655655

656+
#region IDW10109 inner exception preservation tests
657+
658+
[Fact]
659+
public async Task AllCredentialsFail_SingleFailure_PreservesInnerExceptionAndErrorText()
660+
{
661+
// Arrange
662+
var logger = Substitute.For<ILogger<CredentialsProvider>>();
663+
ICredentialsLoader credLoader = Substitute.For<ICredentialsLoader>();
664+
665+
var msalException = new MsalServiceException(
666+
"AADSTS700027",
667+
"AADSTS700027: Client assertion contains an invalid signature.");
668+
669+
credLoader.LoadCredentialsIfNeededAsync(Arg.Any<CredentialDescription>(), Arg.Any<CredentialSourceLoaderParameters>())
670+
.Returns(args =>
671+
{
672+
var cd = (args[0] as CredentialDescription)!;
673+
cd.Skip = true;
674+
return Task.FromException(msalException);
675+
});
676+
677+
CredentialsProvider provider = new CredentialsProvider(logger, credLoader, [], null);
678+
679+
var credential = new CredentialDescription
680+
{
681+
SourceType = CredentialSource.SignedAssertionFromManagedIdentity,
682+
ManagedIdentityClientId = "test-client-id"
683+
};
684+
685+
// Act
686+
var ex = await Assert.ThrowsAsync<ArgumentException>(() => provider.GetCredentialAsync(
687+
new MergedOptions { ClientCredentials = new[] { credential } }, null));
688+
689+
// Assert — original exception is preserved as InnerException
690+
Assert.NotNull(ex.InnerException);
691+
var inner = Assert.IsType<MsalServiceException>(ex.InnerException);
692+
Assert.Equal("AADSTS700027", inner.ErrorCode);
693+
694+
// Assert — error message includes IDW10109 code and FIC guidance
695+
Assert.StartsWith("IDW10109:", ex.Message, StringComparison.Ordinal);
696+
Assert.Contains("Federated Identity Credential", ex.Message, StringComparison.Ordinal);
697+
Assert.Contains("inner exception", ex.Message, StringComparison.OrdinalIgnoreCase);
698+
}
699+
700+
[Fact]
701+
public async Task AllCredentialsFail_MultipleFailures_PreservesAllExceptionsViaAggregateException()
702+
{
703+
// Arrange
704+
var logger = Substitute.For<ILogger<CredentialsProvider>>();
705+
ICredentialsLoader credLoader = Substitute.For<ICredentialsLoader>();
706+
707+
var msalException = new MsalServiceException("AADSTS700027", "Invalid signature.");
708+
var certException = new InvalidOperationException("Certificate not found in store.");
709+
710+
int callCount = 0;
711+
credLoader.LoadCredentialsIfNeededAsync(Arg.Any<CredentialDescription>(), Arg.Any<CredentialSourceLoaderParameters>())
712+
.Returns(args =>
713+
{
714+
var cd = (args[0] as CredentialDescription)!;
715+
cd.Skip = true;
716+
return Task.FromException(callCount++ == 0 ? (Exception)msalException : certException);
717+
});
718+
719+
CredentialsProvider provider = new CredentialsProvider(logger, credLoader, [], null);
720+
721+
var credentials = new[]
722+
{
723+
new CredentialDescription
724+
{
725+
SourceType = CredentialSource.SignedAssertionFromManagedIdentity,
726+
ManagedIdentityClientId = "test-client-id"
727+
},
728+
new CredentialDescription
729+
{
730+
SourceType = CredentialSource.StoreWithDistinguishedName,
731+
CertificateStorePath = "LocalMachine/My",
732+
CertificateDistinguishedName = "CN=Test"
733+
}
734+
};
735+
736+
// Act
737+
var ex = await Assert.ThrowsAsync<ArgumentException>(() => provider.GetCredentialAsync(
738+
new MergedOptions { ClientCredentials = credentials }, null));
739+
740+
// Assert — InnerException is AggregateException containing both originals
741+
Assert.NotNull(ex.InnerException);
742+
var aggEx = Assert.IsType<AggregateException>(ex.InnerException);
743+
Assert.Equal(2, aggEx.InnerExceptions.Count);
744+
Assert.IsType<MsalServiceException>(aggEx.InnerExceptions[0]);
745+
Assert.IsType<InvalidOperationException>(aggEx.InnerExceptions[1]);
746+
}
747+
748+
#endregion
656749
}
657750
}

0 commit comments

Comments
 (0)