Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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";
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 @@ -899,13 +899,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) ||
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