Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/Microsoft.Identity.Web.TokenAcquisition/Constants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,8 @@ public static class Constants
internal const string SignedAssertionInvalidTimeRange = "AADSTS700024";
internal const string CertificateHasBeenRevoked = "AADSTS7000214";
internal const string CertificateIsOutsideValidityWindow = "AADSTS1000502";
internal const string CertificateNotWithinValidityPeriod = "AADSTS7000274";
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe that this is "Client assertion contains an invalid signature", not CertificateNotWithinValidityPeriod. This makes sense as right now this is the same as CertificateIsOutsideValidityWindow which is right above it. https://stackoverflow.com/questions/62716173/jwt-token-error-aadsts700027-client-assertion-contains-an-invalid-signature

internal const string CertificateWasRevoked = "AADSTS7000277";
internal const string CiamAuthoritySuffix = ".ciamlogin.com";
internal const string TestSlice = "dc";
internal const string ExtensionOptionsServiceProviderKey = "ID_WEB_INTERNAL_SERVICE_PROVIDER";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ const Microsoft.Identity.Web.Constants.AzureADIssuerMetadataUrl = "https://login
const Microsoft.Identity.Web.Constants.BlazorChallengeUri = "MicrosoftIdentity/Account/Challenge?redirectUri=" -> string!
const Microsoft.Identity.Web.Constants.CertificateHasBeenRevoked = "AADSTS7000214" -> string!
const Microsoft.Identity.Web.Constants.CertificateIsOutsideValidityWindow = "AADSTS1000502" -> string!
const Microsoft.Identity.Web.Constants.CertificateNotWithinValidityPeriod = "AADSTS7000274" -> string!
const Microsoft.Identity.Web.Constants.CertificateWasRevoked = "AADSTS7000277" -> string!
const Microsoft.Identity.Web.Constants.CiamAuthoritySuffix = ".ciamlogin.com" -> string!
const Microsoft.Identity.Web.Constants.ClientAssertion = "IDWEB_CLIENT_ASSERTION" -> string!
const Microsoft.Identity.Web.Constants.ClientInfo = "client_info" -> string!
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ const Microsoft.Identity.Web.Constants.AzureADIssuerMetadataUrl = "https://login
const Microsoft.Identity.Web.Constants.BlazorChallengeUri = "MicrosoftIdentity/Account/Challenge?redirectUri=" -> string!
const Microsoft.Identity.Web.Constants.CertificateHasBeenRevoked = "AADSTS7000214" -> string!
const Microsoft.Identity.Web.Constants.CertificateIsOutsideValidityWindow = "AADSTS1000502" -> string!
const Microsoft.Identity.Web.Constants.CertificateNotWithinValidityPeriod = "AADSTS7000274" -> string!
const Microsoft.Identity.Web.Constants.CertificateWasRevoked = "AADSTS7000277" -> string!
const Microsoft.Identity.Web.Constants.CiamAuthoritySuffix = ".ciamlogin.com" -> string!
const Microsoft.Identity.Web.Constants.ClientAssertion = "IDWEB_CLIENT_ASSERTION" -> string!
const Microsoft.Identity.Web.Constants.ClientInfo = "client_info" -> string!
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ const Microsoft.Identity.Web.Constants.AzureADIssuerMetadataUrl = "https://login
const Microsoft.Identity.Web.Constants.BlazorChallengeUri = "MicrosoftIdentity/Account/Challenge?redirectUri=" -> string!
const Microsoft.Identity.Web.Constants.CertificateHasBeenRevoked = "AADSTS7000214" -> string!
const Microsoft.Identity.Web.Constants.CertificateIsOutsideValidityWindow = "AADSTS1000502" -> string!
const Microsoft.Identity.Web.Constants.CertificateNotWithinValidityPeriod = "AADSTS7000274" -> string!
const Microsoft.Identity.Web.Constants.CertificateWasRevoked = "AADSTS7000277" -> string!
const Microsoft.Identity.Web.Constants.CiamAuthoritySuffix = ".ciamlogin.com" -> string!
const Microsoft.Identity.Web.Constants.ClientAssertion = "IDWEB_CLIENT_ASSERTION" -> string!
const Microsoft.Identity.Web.Constants.ClientInfo = "client_info" -> string!
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ const Microsoft.Identity.Web.Constants.AzureADIssuerMetadataUrl = "https://login
const Microsoft.Identity.Web.Constants.BlazorChallengeUri = "MicrosoftIdentity/Account/Challenge?redirectUri=" -> string!
const Microsoft.Identity.Web.Constants.CertificateHasBeenRevoked = "AADSTS7000214" -> string!
const Microsoft.Identity.Web.Constants.CertificateIsOutsideValidityWindow = "AADSTS1000502" -> string!
const Microsoft.Identity.Web.Constants.CertificateNotWithinValidityPeriod = "AADSTS7000274" -> string!
const Microsoft.Identity.Web.Constants.CertificateWasRevoked = "AADSTS7000277" -> string!
const Microsoft.Identity.Web.Constants.CiamAuthoritySuffix = ".ciamlogin.com" -> string!
const Microsoft.Identity.Web.Constants.ClientAssertion = "IDWEB_CLIENT_ASSERTION" -> string!
const Microsoft.Identity.Web.Constants.ClientInfo = "client_info" -> string!
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ const Microsoft.Identity.Web.Constants.AzureADIssuerMetadataUrl = "https://login
const Microsoft.Identity.Web.Constants.BlazorChallengeUri = "MicrosoftIdentity/Account/Challenge?redirectUri=" -> string!
const Microsoft.Identity.Web.Constants.CertificateHasBeenRevoked = "AADSTS7000214" -> string!
const Microsoft.Identity.Web.Constants.CertificateIsOutsideValidityWindow = "AADSTS1000502" -> string!
const Microsoft.Identity.Web.Constants.CertificateNotWithinValidityPeriod = "AADSTS7000274" -> string!
const Microsoft.Identity.Web.Constants.CertificateWasRevoked = "AADSTS7000277" -> string!
const Microsoft.Identity.Web.Constants.CiamAuthoritySuffix = ".ciamlogin.com" -> string!
const Microsoft.Identity.Web.Constants.ClientAssertion = "IDWEB_CLIENT_ASSERTION" -> string!
const Microsoft.Identity.Web.Constants.ClientInfo = "client_info" -> string!
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ const Microsoft.Identity.Web.Constants.AzureADIssuerMetadataUrl = "https://login
const Microsoft.Identity.Web.Constants.BlazorChallengeUri = "MicrosoftIdentity/Account/Challenge?redirectUri=" -> string!
const Microsoft.Identity.Web.Constants.CertificateHasBeenRevoked = "AADSTS7000214" -> string!
const Microsoft.Identity.Web.Constants.CertificateIsOutsideValidityWindow = "AADSTS1000502" -> string!
const Microsoft.Identity.Web.Constants.CertificateNotWithinValidityPeriod = "AADSTS7000274" -> string!
const Microsoft.Identity.Web.Constants.CertificateWasRevoked = "AADSTS7000277" -> string!
const Microsoft.Identity.Web.Constants.CiamAuthoritySuffix = ".ciamlogin.com" -> string!
const Microsoft.Identity.Web.Constants.ClientAssertion = "IDWEB_CLIENT_ASSERTION" -> string!
const Microsoft.Identity.Web.Constants.ClientInfo = "client_info" -> string!
Expand Down
28 changes: 23 additions & 5 deletions src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs
Original file line number Diff line number Diff line change
Expand Up @@ -888,13 +888,31 @@ public async Task RemoveAccountAsync(

private bool IsInvalidClientCertificateOrSignedAssertionError(MsalServiceException exMsal)
{
return !_retryClientCertificate &&
string.Equals(exMsal.ErrorCode, Constants.InvalidClient, StringComparison.OrdinalIgnoreCase) &&
!exMsal.ResponseBody.Contains("AADSTS7000215" // No retry when wrong client secret.
if (_retryClientCertificate ||
!string.Equals(exMsal.ErrorCode, Constants.InvalidClient, StringComparison.OrdinalIgnoreCase))
{
return false;
}

// Only retry for certificate-related or signed assertion-related errors.
// Check for specific error codes that indicate certificate/assertion issues.
string responseBody = exMsal.ResponseBody;

#if NET6_0_OR_GREATER
, StringComparison.OrdinalIgnoreCase
return responseBody.Contains(Constants.InvalidKeyError, StringComparison.OrdinalIgnoreCase) ||
responseBody.Contains(Constants.SignedAssertionInvalidTimeRange, StringComparison.OrdinalIgnoreCase) ||
responseBody.Contains(Constants.CertificateHasBeenRevoked, StringComparison.OrdinalIgnoreCase) ||
responseBody.Contains(Constants.CertificateIsOutsideValidityWindow, StringComparison.OrdinalIgnoreCase) ||
responseBody.Contains(Constants.CertificateNotWithinValidityPeriod, StringComparison.OrdinalIgnoreCase) ||
responseBody.Contains(Constants.CertificateWasRevoked, StringComparison.OrdinalIgnoreCase);
#else
return responseBody.Contains(Constants.InvalidKeyError) ||
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems error prone, if we add a new error code we need to remember to put it in multiple places. Perhaps a dictionary should be created above and referred to?

responseBody.Contains(Constants.SignedAssertionInvalidTimeRange) ||
responseBody.Contains(Constants.CertificateHasBeenRevoked) ||
responseBody.Contains(Constants.CertificateIsOutsideValidityWindow) ||
responseBody.Contains(Constants.CertificateNotWithinValidityPeriod) ||
responseBody.Contains(Constants.CertificateWasRevoked);
#endif
);
}


Expand Down
218 changes: 218 additions & 0 deletions tests/Microsoft.Identity.Web.Test/CertificateReloadLogicTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

using System;
using System.Collections.Generic;
using System.Reflection;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Identity.Abstractions;
using Microsoft.Identity.Client;
using Microsoft.Identity.Web.Test.Common;
using Microsoft.Identity.Web.TestOnly;
using Xunit;

namespace Microsoft.Identity.Web.Test
{
/// <summary>
/// Tests for the certificate reload logic to ensure it only triggers on certificate-related errors.
/// This addresses the regression from PR #3430 where reload was triggered on all invalid_client errors.
/// </summary>
[Collection(nameof(TokenAcquirerFactorySingletonProtection))]
public class CertificateReloadLogicTests
{
private const string InvalidClientErrorCode = "invalid_client";

[Theory]
[InlineData("AADSTS700027", true)] // InvalidKeyError
[InlineData("AADSTS700024", true)] // SignedAssertionInvalidTimeRange
[InlineData("AADSTS7000214", true)] // CertificateHasBeenRevoked
[InlineData("AADSTS1000502", true)] // CertificateIsOutsideValidityWindow
[InlineData("AADSTS7000274", true)] // CertificateNotWithinValidityPeriod
[InlineData("AADSTS7000277", true)] // CertificateWasRevoked
[InlineData("AADSTS7000215", false)] // Invalid client secret - should NOT trigger reload
[InlineData("AADSTS700016", false)] // Application not found - should NOT trigger reload
[InlineData("AADSTS7000222", false)] // Invalid client secret (expired) - should NOT trigger reload
[InlineData("AADSTS50011", false)] // Invalid reply address - should NOT trigger reload
[InlineData("AADSTS50012", false)] // Invalid client credentials - should NOT trigger reload
public void IsInvalidClientCertificateOrSignedAssertionError_ReturnsTrueOnlyForCertificateErrors(
string errorCode,
bool shouldTriggerReload)
{
// Arrange
var tokenAcquisition = CreateTokenAcquisition();
var responseBody = $"{{\"error\":\"invalid_client\",\"error_description\":\"Error {errorCode}: Test error\"}}";
var exception = CreateMsalServiceException(InvalidClientErrorCode, responseBody);

// Act
bool result = InvokeIsInvalidClientCertificateOrSignedAssertionError(tokenAcquisition, exception);

// Assert
Assert.Equal(shouldTriggerReload, result);
}

[Fact]
public void IsInvalidClientCertificateOrSignedAssertionError_ReturnsFalseWhenErrorCodeIsNotInvalidClient()
{
// Arrange
var tokenAcquisition = CreateTokenAcquisition();
var responseBody = "{\"error\":\"unauthorized_client\",\"error_description\":\"Test error\"}";
var exception = CreateMsalServiceException("unauthorized_client", responseBody);

// Act
bool result = InvokeIsInvalidClientCertificateOrSignedAssertionError(tokenAcquisition, exception);

// Assert
Assert.False(result, "Should not trigger reload for non-invalid_client errors");
}

[Fact]
public void IsInvalidClientCertificateOrSignedAssertionError_ReturnsFalseWhenRetryAlreadyInProgress()
{
// Arrange
var tokenAcquisition = CreateTokenAcquisition();

// Set _retryClientCertificate to true to simulate retry in progress
var retryField = typeof(TokenAcquisition).GetField("_retryClientCertificate",
BindingFlags.NonPublic | BindingFlags.Instance);
retryField!.SetValue(tokenAcquisition, true);

var responseBody = $"{{\"error\":\"invalid_client\",\"error_description\":\"Error {Constants.InvalidKeyError}: Test error\"}}";
var exception = CreateMsalServiceException(InvalidClientErrorCode, responseBody);

// Act
bool result = InvokeIsInvalidClientCertificateOrSignedAssertionError(tokenAcquisition, exception);

// Assert
Assert.False(result, "Should not trigger reload when retry is already in progress");
}

[Fact]
public void IsInvalidClientCertificateOrSignedAssertionError_ReturnsFalseForEmptyResponseBody()
{
// Arrange
var tokenAcquisition = CreateTokenAcquisition();
var exception = CreateMsalServiceException(InvalidClientErrorCode, string.Empty);

// Act
bool result = InvokeIsInvalidClientCertificateOrSignedAssertionError(tokenAcquisition, exception);

// Assert
Assert.False(result, "Should not trigger reload when response body is empty");
}

[Theory]
[InlineData("AADSTS700027")] // Case sensitive check - should still work
[InlineData("aadsts700027")] // Lowercase
[InlineData("AaDsTs700027")] // Mixed case
public void IsInvalidClientCertificateOrSignedAssertionError_IsCaseInsensitive(string errorCodeCase)
{
// Arrange
var tokenAcquisition = CreateTokenAcquisition();
var responseBody = $"{{\"error\":\"invalid_client\",\"error_description\":\"Error {errorCodeCase}: Test error\"}}";
var exception = CreateMsalServiceException(InvalidClientErrorCode, responseBody);

// Act
bool result = InvokeIsInvalidClientCertificateOrSignedAssertionError(tokenAcquisition, exception);

// Assert
Assert.True(result, $"Should trigger reload regardless of case: {errorCodeCase}");
}

[Fact]
public void IsInvalidClientCertificateOrSignedAssertionError_WorksWithMultipleErrorCodesInResponse()
{
// Arrange
var tokenAcquisition = CreateTokenAcquisition();
// Response might contain multiple error codes or descriptions
var responseBody = $"{{\"error\":\"invalid_client\",\"error_description\":\"Error {Constants.CertificateHasBeenRevoked}: Certificate has been revoked. Also note AADSTS7000215 in logs.\"}}";
var exception = CreateMsalServiceException(InvalidClientErrorCode, responseBody);

// Act
bool result = InvokeIsInvalidClientCertificateOrSignedAssertionError(tokenAcquisition, exception);

// Assert
Assert.True(result, "Should trigger reload when certificate error code is present, even if other codes are also mentioned");
}

/// <summary>
/// Creates a TokenAcquisition instance for testing.
/// </summary>
private TokenAcquisition CreateTokenAcquisition()
{
TokenAcquirerFactoryTesting.ResetTokenAcquirerFactoryInTest();
var tokenAcquirerFactory = TokenAcquirerFactory.GetDefaultInstance();
tokenAcquirerFactory.Services.Configure<MicrosoftIdentityApplicationOptions>(options =>
{
options.Instance = "https://login.microsoftonline.com/";
options.TenantId = "f645ad92-e38d-4d1a-b510-d1b09a74a8ca";
options.ClientId = "idu773ld-e38d-jud3-45lk-d1b09a74a8ca";
options.ClientCredentials = [new CredentialDescription()
{
SourceType = CredentialSource.ClientSecret,
ClientSecret = "someSecret"
}];
});

var serviceProvider = tokenAcquirerFactory.Build();

// Get the TokenAcquisition instance from the service provider
var tokenAcquisition = serviceProvider.GetService(typeof(ITokenAcquisitionInternal)) as TokenAcquisition;

if (tokenAcquisition == null)
{
throw new InvalidOperationException("Failed to create TokenAcquisition instance for testing");
}

return tokenAcquisition;
}

/// <summary>
/// Creates a MsalServiceException for testing.
/// </summary>
private MsalServiceException CreateMsalServiceException(string errorCode, string responseBody)
{
// Use the MsalServiceException constructor with errorCode and errorMessage
// The ResponseBody property is internal but can be accessed via reflection
var exception = new MsalServiceException(errorCode, $"Test exception: {errorCode}");

// Set the ResponseBody property using reflection
var responseBodyField = typeof(MsalServiceException).GetProperty("ResponseBody");
if (responseBodyField != null && responseBodyField.CanWrite)
{
responseBodyField.SetValue(exception, responseBody);
}
else
{
// Try the backing field instead
var backingField = typeof(MsalServiceException).GetField("<ResponseBody>k__BackingField",
BindingFlags.NonPublic | BindingFlags.Instance);
if (backingField != null)
{
backingField.SetValue(exception, responseBody);
}
}

return exception;
}

/// <summary>
/// Invokes the private IsInvalidClientCertificateOrSignedAssertionError method using reflection.
/// </summary>
private bool InvokeIsInvalidClientCertificateOrSignedAssertionError(
TokenAcquisition tokenAcquisition,
MsalServiceException exception)
{
var method = typeof(TokenAcquisition).GetMethod(
"IsInvalidClientCertificateOrSignedAssertionError",
BindingFlags.NonPublic | BindingFlags.Instance);

if (method == null)
{
throw new InvalidOperationException("Could not find IsInvalidClientCertificateOrSignedAssertionError method");
}

var result = method.Invoke(tokenAcquisition, new object[] { exception });
return (bool)result!;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -332,8 +332,8 @@ protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage reques
var errorResponse = new
{
error = "invalid_client",
error_description = $"Invalid certificate: {this.description.CachedValue}",
error_codes = new[] { 50000 },
error_description = $"AADSTS700027: Invalid certificate: {this.description.CachedValue}",
error_codes = new[] { 700027 },
timestamp = DateTime.UtcNow,
};

Expand Down
Loading