Skip to content

Refine error message about MFA #28124

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Jul 15, 2025
Merged
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
19 changes: 12 additions & 7 deletions src/Accounts/Accounts.Test/SilentReAuthByTenantCmdletTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
using System.Threading.Tasks;
using Xunit;
using Xunit.Abstractions;
using Microsoft.Azure.Commands.Common.Exceptions;

namespace Microsoft.Azure.Commands.ResourceManager.Common.Test
{
Expand All @@ -55,9 +56,11 @@ public class SilentReAuthByTenantCmdletTest
private const string fakeToken = "fakertoken";

private const string body200 = @"{{""value"":[{{""id"":""/tenants/{0}"",""tenantId"":""{0}"",""countryCode"":""US"",""displayName"":""AzureSDKTeam"",""domains"":[""AzureSDKTeam.onmicrosoft.com"",""azdevextest.com""],""tenantCategory"":""Home""}}]}}";
private const string body401 = @"{""error"":{""code"":""AuthenticationFailed"",""message"":""Authentication failed.""}}";
private const string WwwAuthenticateIP = @"Bearer authorization_uri=""https://login.windows.net/"", error=""invalid_token"", error_description=""Tenant IP Policy validate failed."", claims=""eyJhY2Nlc3NfdG9rZW4iOnsibmJmIjp7ImVzc2VudGlhbCI6dHJ1ZSwidmFsdWUiOiIxNjEzOTgyNjA2In0sInhtc19ycF9pcGFkZHIiOnsidmFsdWUiOiIxNjcuMjIwLjI1NS40MSJ9fX0=""";

private const string bodyErrorMessage401 = "Authentication failed.";
private const string body401 = @"{""error"":{""code"":""AuthenticationFailed"",""message"":"""+bodyErrorMessage401+@"""}}";
private const string claimsChallengeBase64 = "eyJhY2Nlc3NfdG9rZW4iOnsibmJmIjp7ImVzc2VudGlhbCI6dHJ1ZSwidmFsdWUiOiIxNjEzOTgyNjA2In0sInhtc19ycF9pcGFkZHIiOnsidmFsdWUiOiIxNjcuMjIwLjI1NS40MSJ9fX0=";
private const string WwwAuthenticateIP = @"Bearer authorization_uri=""https://login.windows.net/"", error=""invalid_token"", error_description=""Tenant IP Policy validate failed."", claims="""+ claimsChallengeBase64+@"""";
private const string identityExceptionMessage = "Exception from Azure Identity.";
XunitTracingInterceptor xunitLogger;

public class GetAzureRMTenantCommandMock : GetAzureRMTenantCommand
Expand Down Expand Up @@ -171,7 +174,7 @@ public void SilentReauthenticateFailure()
{
return new ValueTask<AccessToken>(new AccessToken(fakeToken, DateTimeOffset.Now.AddHours(1)));
}
throw new CredentialUnavailableException("Exception from Azure Identity.");
throw new CredentialUnavailableException(identityExceptionMessage);
}
));
AzureSession.Instance.RegisterComponent(nameof(AzureCredentialFactory), () => mockAzureCredentialFactory.Object, true);
Expand All @@ -190,9 +193,11 @@ public void SilentReauthenticateFailure()

// Act
cmdlet.InvokeBeginProcessing();
AuthenticationFailedException e = Assert.Throws<AuthenticationFailedException>(() => cmdlet.ExecuteCmdlet());
string errorMessage = $"Exception from Azure Identity.{Environment.NewLine}authorization_uri: https://login.windows.net/{Environment.NewLine}error: invalid_token{Environment.NewLine}error_description: Tenant IP Policy validate failed.{Environment.NewLine}claims: eyJhY2Nlc3NfdG9rZW4iOnsibmJmIjp7ImVzc2VudGlhbCI6dHJ1ZSwidmFsdWUiOiIxNjEzOTgyNjA2In0sInhtc19ycF9pcGFkZHIiOnsidmFsdWUiOiIxNjcuMjIwLjI1NS40MSJ9fX0={Environment.NewLine}";
Assert.Equal(errorMessage, e.Message);
AzPSAuthenticationFailedException e = Assert.Throws<AzPSAuthenticationFailedException>(() => cmdlet.ExecuteCmdlet());
Assert.DoesNotContain(identityExceptionMessage, e.Message); // cause it's misleading
Assert.Contains(bodyErrorMessage401, e.Message);
Assert.Contains("Connect-AzAccount", e.Message);
Assert.Contains(claimsChallengeBase64, e.Message);
cmdlet.InvokeEndProcessing();
}
finally
Expand Down
1 change: 1 addition & 0 deletions src/Accounts/Accounts/ChangeLog.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
-->

## Upcoming Release
* Refined the error message when a cmdlet fails because of policy violations about Multi-Factor Authentication (MFA) to provide more actionable guidance.

## Version 5.1.1
* Updated the date in the message about multi-factor authentication (MFA). For more details, see https://go.microsoft.com/fwlink/?linkid=2276971
Expand Down
24 changes: 12 additions & 12 deletions src/Accounts/Accounts/CommonModule/ContextAdapter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,22 +12,23 @@
// limitations under the License.
// ----------------------------------------------------------------------------------

using System;
using System.Threading;
using System.Threading.Tasks;
using System.Net.Http;
using System.Collections.Generic;
using Azure.Identity;
using Microsoft.Azure.Commands.Common.Authentication;
using Microsoft.Azure.Commands.Common.Authentication.Abstractions;
using Microsoft.Azure.Commands.Common.Authentication.Abstractions.Core;
using Microsoft.Azure.Commands.Common.Exceptions;
using Microsoft.Azure.Commands.Common.Utilities;
using Microsoft.Azure.Commands.Profile.Models;
using System.Globalization;
using Microsoft.Azure.Commands.Common.Authentication;
using Microsoft.Azure.Commands.Profile.Properties;
using Microsoft.Azure.Commands.ResourceManager.Common.ArgumentCompleters;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Management.Automation;
using Microsoft.Azure.Commands.Profile.Properties;
using Azure.Identity;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;

namespace Microsoft.Azure.Commands.Common
{
Expand Down Expand Up @@ -200,14 +201,13 @@ internal async Task<HttpResponseMessage> AuthenticationHelper(IAzureContext cont
{
var response = await next(request, cancelToken, cancelAction, signal);

if (response.MatchClaimsChallengePattern())
if (response.MatchClaimsChallengePattern(out var claimsChallenge))
{
//get token again with claims challenge
if (accessToken is IClaimsChallengeProcessor processor)
{
try
{
var claimsChallenge = ClaimsChallengeUtilities.GetClaimsChallenge(response);
if (!string.IsNullOrEmpty(claimsChallenge))
{
await processor.OnClaimsChallenageAsync(newRequest, claimsChallenge, cancelToken).ConfigureAwait(false);
Expand All @@ -219,7 +219,7 @@ internal async Task<HttpResponseMessage> AuthenticationHelper(IAzureContext cont
}
catch (AuthenticationFailedException e)
{
throw e.WithAdditionalMessage(response?.GetWwwAuthenticateMessage());
throw new AzPSAuthenticationFailedException(ClaimsChallengeUtilities.FormatClaimsChallengeErrorMessage(claimsChallenge, await response?.Content?.ReadAsStringAsync()), null, e);
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ public interface IClaimsChallengeProcessor
/// <param name="request">The origin request that responds with a claim challenge</param>
/// <param name="claimsChallenge">Claims challenge string</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Successful or not</returns>
/// <returns>A boolean indicated whether the request should be retried</returns>
ValueTask<bool> OnClaimsChallenageAsync(HttpRequestMessage request, string claimsChallenge, CancellationToken cancellationToken);
}
}
14 changes: 7 additions & 7 deletions src/Accounts/Authentication/ClaimsChallengeHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@
// ----------------------------------------------------------------------------------

using Azure.Identity;
using Microsoft.Azure.Commands.Common.Exceptions;
using System;
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
Expand All @@ -34,18 +34,19 @@ public ClaimsChallengeHandler(IClaimsChallengeProcessor claimsChallengeProcessor
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var response = await base.SendAsync(request, cancellationToken);
if (response.MatchClaimsChallengePattern())
if (response.MatchClaimsChallengePattern(out var claimsChallenge))
{
try
{
if (await OnChallengeAsync(request, response, cancellationToken))
if (await OnChallengeAsync(claimsChallenge, request, response, cancellationToken))
{
return await base.SendAsync(request, cancellationToken);
}
}
catch (AuthenticationFailedException e)
{
throw e.WithAdditionalMessage(response?.GetWwwAuthenticateMessage());
string additionalErrorMessage = ClaimsChallengeUtilities.FormatClaimsChallengeErrorMessage(claimsChallenge, await response?.Content?.ReadAsStringAsync());
throw new AzPSAuthenticationFailedException(additionalErrorMessage, null, e);
}
}
return response;
Expand All @@ -59,14 +60,13 @@ public virtual object Clone()
/// Executed in the event a 401 response with a WWW-Authenticate authentication challenge header is received after the initial request.
/// </summary>
/// <remarks>This implementation handles common authentication challenges such as claims challenges. Service client libraries may derive from this and extend to handle service specific authentication challenges.</remarks>
/// <param name="claimsChallenge"></param>
/// <param name="requestMessage">The HttpMessage to be authenticated.</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <param name="responseMessage"></param>
/// <returns>A boolean indicated whether the request should be retried</returns>
protected virtual async Task<bool> OnChallengeAsync(HttpRequestMessage requestMessage, HttpResponseMessage responseMessage, CancellationToken cancellationToken)
protected virtual async Task<bool> OnChallengeAsync(string claimsChallenge, HttpRequestMessage requestMessage, HttpResponseMessage responseMessage, CancellationToken cancellationToken)
{
var claimsChallenge = ClaimsChallengeUtilities.GetClaimsChallenge(responseMessage);

if (!string.IsNullOrEmpty(claimsChallenge))
{
return await ClaimsChallengeProcessor.OnClaimsChallenageAsync(requestMessage, claimsChallenge, cancellationToken).ConfigureAwait(false);
Expand Down
13 changes: 13 additions & 0 deletions src/Accounts/Authentication/Properties/Resources.Designer.cs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions src/Accounts/Authentication/Properties/Resources.resx
Original file line number Diff line number Diff line change
Expand Up @@ -426,4 +426,12 @@
<value>The scope of authenticating for SSH is not set. Please run "Set-AzEnvironment -Name {0} -SshAuthScope ..." to set it first.</value>
<comment>0 = environment name</comment>
</data>
<data name="ErrorMessageOfClaimsChallengeRequired" xml:space="preserve">
<value>{0}

Run the cmdlet below to authenticate interactively; additional parameters may be added as needed.

Connect-AzAccount -Tenant (Get-AzContext).Tenant.Id -ClaimsChallenge "{1}"</value>
<comment>0 = error message about policy violation; 1 = claims challenge in base64</comment>
</data>
</root>
53 changes: 46 additions & 7 deletions src/Accounts/Authentication/Utilities/ClaimsChallengeUtilities.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,16 @@
// limitations under the License.
// ----------------------------------------------------------------------------------

using Microsoft.Azure.Commands.Common.Authentication.Properties;
using Microsoft.WindowsAzure.Commands.Common;
using Microsoft.WindowsAzure.Commands.Utilities.Common;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Text.RegularExpressions;

using Microsoft.Azure.Commands.Profile.Utilities;

namespace Microsoft.Azure.Commands.Common.Authentication
{
static public class ClaimsChallengeUtilities
Expand All @@ -33,7 +32,7 @@ static public class ClaimsChallengeUtilities
private static readonly Regex AuthenticationChallengeRegex = new Regex(AuthenticationChallengePattern);
private static readonly Regex ChallengeParameterRegex = new Regex(ChallengeParameterPattern);

public static string GetClaimsChallenge(HttpResponseMessage response)
private static string GetClaimsChallenge(HttpResponseMessage response)
{
return ParseWwwAuthenticate(response)?
.Where((p) => string.Equals(p.Item1, "claims", StringComparison.OrdinalIgnoreCase))
Expand All @@ -46,15 +45,21 @@ public static string GetWwwAuthenticateMessage(this HttpResponseMessage response
return string.Join(string.Empty, ParseWwwAuthenticate(response)?.Select(p => $"{p.Item1}: {p.Item2}{Environment.NewLine}"));
}

public static bool MatchClaimsChallengePattern(this HttpResponseMessage response)
public static bool MatchClaimsChallengePattern(this HttpResponseMessage response, out string claimsChallenge)
{
return response.StatusCode == System.Net.HttpStatusCode.Unauthorized && response.Headers.WwwAuthenticate?.Count > 0;
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized && response.Headers.WwwAuthenticate?.Count > 0)
{
claimsChallenge = GetClaimsChallenge(response);
return true;
}

claimsChallenge = null;
return false;
}

private static IEnumerable<(string, string)> ParseWwwAuthenticate(HttpResponseMessage response)
{
return Enumerable.Repeat(response, 1)
.Where(r => r.MatchClaimsChallengePattern())
.Select(r => r.Headers.WwwAuthenticate.FirstOrDefault().ToString())
.SelectMany(h => ParseChallenges(h))
.Where(c => string.Equals(c.Item1, "Bearer", StringComparison.OrdinalIgnoreCase))
Expand All @@ -80,5 +85,39 @@ public static bool MatchClaimsChallengePattern(this HttpResponseMessage response
yield return (paramMatches[i].Groups[1].Value, paramMatches[i].Groups[2].Value);
}
}

/// <summary>
/// Format the error message from the response content of the original failed request.
/// If the error is caused by CAE (continuous Access Evaluation), this will include why the request failed, and which policy was violated.
/// </summary>
/// <param name="claimsChallenge"></param>
/// <param name="responseContent"></param>
/// <returns></returns>
public static string FormatClaimsChallengeErrorMessage(string claimsChallenge, string responseContent)
{
var errorMessage = TryGetErrorMessageFromOriginalResponse(responseContent);
// Convert claimsChallenge to base64
var claimsChallengeBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(claimsChallenge ?? string.Empty));
return string.Format(Resources.ErrorMessageOfClaimsChallengeRequired, errorMessage, claimsChallengeBase64);
}

private static string TryGetErrorMessageFromOriginalResponse(string content)
{
if (string.IsNullOrWhiteSpace(content))
{
return content;
}

try
{
var parsedJson = Newtonsoft.Json.Linq.JToken.Parse(content);
return parsedJson["error"].Value<string>("message");
}
catch
{
// If parsing fails, return the original content
return content;
}
}
}
}
12 changes: 12 additions & 0 deletions src/Accounts/Authenticators/MsalAccessToken.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@

namespace Microsoft.Azure.PowerShell.Authenticators
{
/// <summary>
/// Represents an access token obtained from Entra ID using MSAL (Microsoft Authentication Library).
/// Holds the access token, metadata about the user and tenant, and the context needed to renew the token.
/// </summary>
public class MsalAccessToken : IAccessToken, IClaimsChallengeProcessor
{
public string AccessToken { get; private set; }
Expand Down Expand Up @@ -121,6 +125,14 @@ private bool IsNearExpiration()
return timeUntilExpiration < ExpirationThreshold;
}

/// <summary>
/// Receives a claims challenge from the server and processes it to obtain a new access token.
/// Then updates the request with the new access token.
/// </summary>
/// <param name="request"></param>
/// <param name="claimsChallenge"></param>
/// <param name="cancellationToken"></param>
/// <returns>A boolean indicated whether the request should be retried. Throws if the reauth fails.</returns>
Copy link
Preview

Copilot AI Jul 3, 2025

Choose a reason for hiding this comment

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

Correct the grammar in the <returns> tag: change 'indicated' to 'indicating'.

Suggested change
/// <returns>A boolean indicated whether the request should be retried. Throws if the reauth fails.</returns>
/// <returns>A boolean indicating whether the request should be retried. Throws if the reauth fails.</returns>

Copilot uses AI. Check for mistakes.

public async ValueTask<bool> OnClaimsChallenageAsync(HttpRequestMessage request, string claimsChallenge, CancellationToken cancellationToken)
{
TracingAdapter.Information($"{DateTime.Now:T} - [ClaimsChallengeProcessor] Calling {TokenCredential.GetType().Name}.GetTokenAsync- claimsChallenge:'{claimsChallenge}'");
Expand Down