From e8763c4771222ac3c06ea9d53a1af0a2b3cb99a8 Mon Sep 17 00:00:00 2001 From: Yeming Liu Date: Thu, 3 Jul 2025 18:02:20 +1000 Subject: [PATCH 1/2] Refine error message about MFA --- src/Accounts/Accounts/ChangeLog.md | 1 + .../Accounts/CommonModule/ContextAdapter.cs | 5 +- .../IClaimsChallengeProcessor.cs | 2 +- .../Authentication/ClaimsChallengeHandler.cs | 13 +- src/Accounts/Authentication/MFA.dgml | 435 ++++++++++++++++++ .../Utilities/ClaimsChallengeUtilities.cs | 53 ++- .../Authenticators/MsalAccessToken.cs | 12 + 7 files changed, 506 insertions(+), 15 deletions(-) create mode 100644 src/Accounts/Authentication/MFA.dgml diff --git a/src/Accounts/Accounts/ChangeLog.md b/src/Accounts/Accounts/ChangeLog.md index 0a826675688b..24432ad4eaf2 100644 --- a/src/Accounts/Accounts/ChangeLog.md +++ b/src/Accounts/Accounts/ChangeLog.md @@ -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 diff --git a/src/Accounts/Accounts/CommonModule/ContextAdapter.cs b/src/Accounts/Accounts/CommonModule/ContextAdapter.cs index 281fc2dea145..fcd088d91932 100644 --- a/src/Accounts/Accounts/CommonModule/ContextAdapter.cs +++ b/src/Accounts/Accounts/CommonModule/ContextAdapter.cs @@ -200,14 +200,13 @@ internal async Task 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); @@ -219,7 +218,7 @@ internal async Task AuthenticationHelper(IAzureContext cont } catch (AuthenticationFailedException e) { - throw e.WithAdditionalMessage(response?.GetWwwAuthenticateMessage()); + throw e.WithAdditionalMessage(ClaimsChallengeUtilities.FormatClaimsChallengeErrorMessage(claimsChallenge, await response?.Content?.ReadAsStringAsync())); } } } diff --git a/src/Accounts/Authentication/Authentication/IClaimsChallengeProcessor.cs b/src/Accounts/Authentication/Authentication/IClaimsChallengeProcessor.cs index 635adffb5a47..b7eb0ff83abb 100644 --- a/src/Accounts/Authentication/Authentication/IClaimsChallengeProcessor.cs +++ b/src/Accounts/Authentication/Authentication/IClaimsChallengeProcessor.cs @@ -29,7 +29,7 @@ public interface IClaimsChallengeProcessor /// The origin request that responds with a claim challenge /// Claims challenge string /// Cancellation token - /// Successful or not + /// A boolean indicated whether the request should be retried ValueTask OnClaimsChallenageAsync(HttpRequestMessage request, string claimsChallenge, CancellationToken cancellationToken); } } diff --git a/src/Accounts/Authentication/ClaimsChallengeHandler.cs b/src/Accounts/Authentication/ClaimsChallengeHandler.cs index 5f2c93d3150d..0ed037a93275 100644 --- a/src/Accounts/Authentication/ClaimsChallengeHandler.cs +++ b/src/Accounts/Authentication/ClaimsChallengeHandler.cs @@ -14,7 +14,6 @@ using Azure.Identity; using System; -using System.Net; using System.Net.Http; using System.Threading; using System.Threading.Tasks; @@ -34,18 +33,19 @@ public ClaimsChallengeHandler(IClaimsChallengeProcessor claimsChallengeProcessor protected override async Task 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 e.WithAdditionalMessage(additionalErrorMessage); } } return response; @@ -59,14 +59,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. /// /// 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. + /// /// The HttpMessage to be authenticated. /// Cancellation token /// /// A boolean indicated whether the request should be retried - protected virtual async Task OnChallengeAsync(HttpRequestMessage requestMessage, HttpResponseMessage responseMessage, CancellationToken cancellationToken) + protected virtual async Task 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); diff --git a/src/Accounts/Authentication/MFA.dgml b/src/Accounts/Authentication/MFA.dgml new file mode 100644 index 000000000000..f8d88c6ebdbf --- /dev/null +++ b/src/Accounts/Authentication/MFA.dgml @@ -0,0 +1,435 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Accounts/Authentication/Utilities/ClaimsChallengeUtilities.cs b/src/Accounts/Authentication/Utilities/ClaimsChallengeUtilities.cs index 0a387a0f97a7..53422380f1eb 100644 --- a/src/Accounts/Authentication/Utilities/ClaimsChallengeUtilities.cs +++ b/src/Accounts/Authentication/Utilities/ClaimsChallengeUtilities.cs @@ -33,7 +33,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)) @@ -46,15 +46,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)) @@ -80,5 +86,44 @@ public static bool MatchClaimsChallengePattern(this HttpResponseMessage response yield return (paramMatches[i].Groups[1].Value, paramMatches[i].Groups[2].Value); } } + + /// + /// 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. + /// + /// + /// + /// + 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)); + // todo: use resource string + return $@"[This message needs review] Interactive authentication is required. Please run the following cmdlet and add additional parameters as needed: +Connect-AzAccount -ClaimsChallenge ""{claimsChallengeBase64}"" + +Error details: +{errorMessage}"; + } + + 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("message"); + } + catch + { + // If parsing fails, return the original content + return content; + } + } } } diff --git a/src/Accounts/Authenticators/MsalAccessToken.cs b/src/Accounts/Authenticators/MsalAccessToken.cs index 8c5676feeafc..f4591741b33d 100644 --- a/src/Accounts/Authenticators/MsalAccessToken.cs +++ b/src/Accounts/Authenticators/MsalAccessToken.cs @@ -28,6 +28,10 @@ namespace Microsoft.Azure.PowerShell.Authenticators { + /// + /// 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. + /// public class MsalAccessToken : IAccessToken, IClaimsChallengeProcessor { public string AccessToken { get; private set; } @@ -121,6 +125,14 @@ private bool IsNearExpiration() return timeUntilExpiration < ExpirationThreshold; } + /// + /// 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. + /// + /// + /// + /// + /// A boolean indicated whether the request should be retried. Throws if the reauth fails. public async ValueTask OnClaimsChallenageAsync(HttpRequestMessage request, string claimsChallenge, CancellationToken cancellationToken) { TracingAdapter.Information($"{DateTime.Now:T} - [ClaimsChallengeProcessor] Calling {TokenCredential.GetType().Name}.GetTokenAsync- claimsChallenge:'{claimsChallenge}'"); From ba41be0749a09d9110e6da7a61059584c030f8d8 Mon Sep 17 00:00:00 2001 From: Yeming Liu Date: Fri, 4 Jul 2025 13:50:20 +1000 Subject: [PATCH 2/2] Fix tests; add Tenant to parameters --- .../SilentReAuthByTenantCmdletTest.cs | 16 ++++++++++------ .../Utilities/ClaimsChallengeUtilities.cs | 5 +---- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/src/Accounts/Accounts.Test/SilentReAuthByTenantCmdletTest.cs b/src/Accounts/Accounts.Test/SilentReAuthByTenantCmdletTest.cs index 0d16a82ab2f3..2854ef09f061 100644 --- a/src/Accounts/Accounts.Test/SilentReAuthByTenantCmdletTest.cs +++ b/src/Accounts/Accounts.Test/SilentReAuthByTenantCmdletTest.cs @@ -55,9 +55,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 @@ -171,7 +173,7 @@ public void SilentReauthenticateFailure() { return new ValueTask(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); @@ -191,8 +193,10 @@ public void SilentReauthenticateFailure() // Act cmdlet.InvokeBeginProcessing(); AuthenticationFailedException e = Assert.Throws(() => 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); + Assert.Contains(identityExceptionMessage, e.Message); + Assert.Contains(bodyErrorMessage401, e.Message); + Assert.Contains("Connect-AzAccount", e.Message); + Assert.Contains(claimsChallengeBase64, e.Message); cmdlet.InvokeEndProcessing(); } finally diff --git a/src/Accounts/Authentication/Utilities/ClaimsChallengeUtilities.cs b/src/Accounts/Authentication/Utilities/ClaimsChallengeUtilities.cs index 53422380f1eb..a6d73e43e6c6 100644 --- a/src/Accounts/Authentication/Utilities/ClaimsChallengeUtilities.cs +++ b/src/Accounts/Authentication/Utilities/ClaimsChallengeUtilities.cs @@ -16,13 +16,10 @@ 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 @@ -101,7 +98,7 @@ public static string FormatClaimsChallengeErrorMessage(string claimsChallenge, s var claimsChallengeBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(claimsChallenge ?? string.Empty)); // todo: use resource string return $@"[This message needs review] Interactive authentication is required. Please run the following cmdlet and add additional parameters as needed: -Connect-AzAccount -ClaimsChallenge ""{claimsChallengeBase64}"" +Connect-AzAccount -Tenant (Get-AzContext).Tenant.Id -ClaimsChallenge ""{claimsChallengeBase64}"" Error details: {errorMessage}";