Skip to content

Commit 347e50f

Browse files
isra-felYeming Liu
andauthored
Refine error message about MFA (#28124)
Co-authored-by: Yeming Liu <[email protected]>
1 parent e4a264b commit 347e50f

File tree

9 files changed

+112
-34
lines changed

9 files changed

+112
-34
lines changed

src/Accounts/Accounts.Test/SilentReAuthByTenantCmdletTest.cs

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
using System.Threading.Tasks;
3737
using Xunit;
3838
using Xunit.Abstractions;
39+
using Microsoft.Azure.Commands.Common.Exceptions;
3940

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

5758
private const string body200 = @"{{""value"":[{{""id"":""/tenants/{0}"",""tenantId"":""{0}"",""countryCode"":""US"",""displayName"":""AzureSDKTeam"",""domains"":[""AzureSDKTeam.onmicrosoft.com"",""azdevextest.com""],""tenantCategory"":""Home""}}]}}";
58-
private const string body401 = @"{""error"":{""code"":""AuthenticationFailed"",""message"":""Authentication failed.""}}";
59-
private const string WwwAuthenticateIP = @"Bearer authorization_uri=""https://login.windows.net/"", error=""invalid_token"", error_description=""Tenant IP Policy validate failed."", claims=""eyJhY2Nlc3NfdG9rZW4iOnsibmJmIjp7ImVzc2VudGlhbCI6dHJ1ZSwidmFsdWUiOiIxNjEzOTgyNjA2In0sInhtc19ycF9pcGFkZHIiOnsidmFsdWUiOiIxNjcuMjIwLjI1NS40MSJ9fX0=""";
60-
59+
private const string bodyErrorMessage401 = "Authentication failed.";
60+
private const string body401 = @"{""error"":{""code"":""AuthenticationFailed"",""message"":"""+bodyErrorMessage401+@"""}}";
61+
private const string claimsChallengeBase64 = "eyJhY2Nlc3NfdG9rZW4iOnsibmJmIjp7ImVzc2VudGlhbCI6dHJ1ZSwidmFsdWUiOiIxNjEzOTgyNjA2In0sInhtc19ycF9pcGFkZHIiOnsidmFsdWUiOiIxNjcuMjIwLjI1NS40MSJ9fX0=";
62+
private const string WwwAuthenticateIP = @"Bearer authorization_uri=""https://login.windows.net/"", error=""invalid_token"", error_description=""Tenant IP Policy validate failed."", claims="""+ claimsChallengeBase64+@"""";
63+
private const string identityExceptionMessage = "Exception from Azure Identity.";
6164
XunitTracingInterceptor xunitLogger;
6265

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

191194
// Act
192195
cmdlet.InvokeBeginProcessing();
193-
AuthenticationFailedException e = Assert.Throws<AuthenticationFailedException>(() => cmdlet.ExecuteCmdlet());
194-
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}";
195-
Assert.Equal(errorMessage, e.Message);
196+
AzPSAuthenticationFailedException e = Assert.Throws<AzPSAuthenticationFailedException>(() => cmdlet.ExecuteCmdlet());
197+
Assert.DoesNotContain(identityExceptionMessage, e.Message); // cause it's misleading
198+
Assert.Contains(bodyErrorMessage401, e.Message);
199+
Assert.Contains("Connect-AzAccount", e.Message);
200+
Assert.Contains(claimsChallengeBase64, e.Message);
196201
cmdlet.InvokeEndProcessing();
197202
}
198203
finally

src/Accounts/Accounts/ChangeLog.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
-->
2020

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

2324
## Version 5.1.1
2425
* Updated the date in the message about multi-factor authentication (MFA). For more details, see https://go.microsoft.com/fwlink/?linkid=2276971

src/Accounts/Accounts/CommonModule/ContextAdapter.cs

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -12,22 +12,23 @@
1212
// limitations under the License.
1313
// ----------------------------------------------------------------------------------
1414

15-
using System;
16-
using System.Threading;
17-
using System.Threading.Tasks;
18-
using System.Net.Http;
19-
using System.Collections.Generic;
15+
using Azure.Identity;
16+
using Microsoft.Azure.Commands.Common.Authentication;
2017
using Microsoft.Azure.Commands.Common.Authentication.Abstractions;
2118
using Microsoft.Azure.Commands.Common.Authentication.Abstractions.Core;
19+
using Microsoft.Azure.Commands.Common.Exceptions;
2220
using Microsoft.Azure.Commands.Common.Utilities;
2321
using Microsoft.Azure.Commands.Profile.Models;
24-
using System.Globalization;
25-
using Microsoft.Azure.Commands.Common.Authentication;
22+
using Microsoft.Azure.Commands.Profile.Properties;
2623
using Microsoft.Azure.Commands.ResourceManager.Common.ArgumentCompleters;
24+
using System;
25+
using System.Collections.Generic;
26+
using System.Globalization;
2727
using System.Linq;
2828
using System.Management.Automation;
29-
using Microsoft.Azure.Commands.Profile.Properties;
30-
using Azure.Identity;
29+
using System.Net.Http;
30+
using System.Threading;
31+
using System.Threading.Tasks;
3132

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

203-
if (response.MatchClaimsChallengePattern())
204+
if (response.MatchClaimsChallengePattern(out var claimsChallenge))
204205
{
205206
//get token again with claims challenge
206207
if (accessToken is IClaimsChallengeProcessor processor)
207208
{
208209
try
209210
{
210-
var claimsChallenge = ClaimsChallengeUtilities.GetClaimsChallenge(response);
211211
if (!string.IsNullOrEmpty(claimsChallenge))
212212
{
213213
await processor.OnClaimsChallenageAsync(newRequest, claimsChallenge, cancelToken).ConfigureAwait(false);
@@ -219,7 +219,7 @@ internal async Task<HttpResponseMessage> AuthenticationHelper(IAzureContext cont
219219
}
220220
catch (AuthenticationFailedException e)
221221
{
222-
throw e.WithAdditionalMessage(response?.GetWwwAuthenticateMessage());
222+
throw new AzPSAuthenticationFailedException(ClaimsChallengeUtilities.FormatClaimsChallengeErrorMessage(claimsChallenge, await response?.Content?.ReadAsStringAsync()), null, e);
223223
}
224224
}
225225
}

src/Accounts/Authentication/Authentication/IClaimsChallengeProcessor.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ public interface IClaimsChallengeProcessor
2929
/// <param name="request">The origin request that responds with a claim challenge</param>
3030
/// <param name="claimsChallenge">Claims challenge string</param>
3131
/// <param name="cancellationToken">Cancellation token</param>
32-
/// <returns>Successful or not</returns>
32+
/// <returns>A boolean indicated whether the request should be retried</returns>
3333
ValueTask<bool> OnClaimsChallenageAsync(HttpRequestMessage request, string claimsChallenge, CancellationToken cancellationToken);
3434
}
3535
}

src/Accounts/Authentication/ClaimsChallengeHandler.cs

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@
1313
// ----------------------------------------------------------------------------------
1414

1515
using Azure.Identity;
16+
using Microsoft.Azure.Commands.Common.Exceptions;
1617
using System;
17-
using System.Net;
1818
using System.Net.Http;
1919
using System.Threading;
2020
using System.Threading.Tasks;
@@ -34,18 +34,19 @@ public ClaimsChallengeHandler(IClaimsChallengeProcessor claimsChallengeProcessor
3434
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
3535
{
3636
var response = await base.SendAsync(request, cancellationToken);
37-
if (response.MatchClaimsChallengePattern())
37+
if (response.MatchClaimsChallengePattern(out var claimsChallenge))
3838
{
3939
try
4040
{
41-
if (await OnChallengeAsync(request, response, cancellationToken))
41+
if (await OnChallengeAsync(claimsChallenge, request, response, cancellationToken))
4242
{
4343
return await base.SendAsync(request, cancellationToken);
4444
}
4545
}
4646
catch (AuthenticationFailedException e)
4747
{
48-
throw e.WithAdditionalMessage(response?.GetWwwAuthenticateMessage());
48+
string additionalErrorMessage = ClaimsChallengeUtilities.FormatClaimsChallengeErrorMessage(claimsChallenge, await response?.Content?.ReadAsStringAsync());
49+
throw new AzPSAuthenticationFailedException(additionalErrorMessage, null, e);
4950
}
5051
}
5152
return response;
@@ -59,14 +60,13 @@ public virtual object Clone()
5960
/// Executed in the event a 401 response with a WWW-Authenticate authentication challenge header is received after the initial request.
6061
/// </summary>
6162
/// <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>
63+
/// <param name="claimsChallenge"></param>
6264
/// <param name="requestMessage">The HttpMessage to be authenticated.</param>
6365
/// <param name="cancellationToken">Cancellation token</param>
6466
/// <param name="responseMessage"></param>
6567
/// <returns>A boolean indicated whether the request should be retried</returns>
66-
protected virtual async Task<bool> OnChallengeAsync(HttpRequestMessage requestMessage, HttpResponseMessage responseMessage, CancellationToken cancellationToken)
68+
protected virtual async Task<bool> OnChallengeAsync(string claimsChallenge, HttpRequestMessage requestMessage, HttpResponseMessage responseMessage, CancellationToken cancellationToken)
6769
{
68-
var claimsChallenge = ClaimsChallengeUtilities.GetClaimsChallenge(responseMessage);
69-
7070
if (!string.IsNullOrEmpty(claimsChallenge))
7171
{
7272
return await ClaimsChallengeProcessor.OnClaimsChallenageAsync(requestMessage, claimsChallenge, cancellationToken).ConfigureAwait(false);

src/Accounts/Authentication/Properties/Resources.Designer.cs

Lines changed: 13 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/Accounts/Authentication/Properties/Resources.resx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -426,4 +426,12 @@
426426
<value>The scope of authenticating for SSH is not set. Please run "Set-AzEnvironment -Name {0} -SshAuthScope ..." to set it first.</value>
427427
<comment>0 = environment name</comment>
428428
</data>
429+
<data name="ErrorMessageOfClaimsChallengeRequired" xml:space="preserve">
430+
<value>{0}
431+
432+
Run the cmdlet below to authenticate interactively; additional parameters may be added as needed.
433+
434+
Connect-AzAccount -Tenant (Get-AzContext).Tenant.Id -ClaimsChallenge "{1}"</value>
435+
<comment>0 = error message about policy violation; 1 = claims challenge in base64</comment>
436+
</data>
429437
</root>

src/Accounts/Authentication/Utilities/ClaimsChallengeUtilities.cs

Lines changed: 46 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,16 @@
1212
// limitations under the License.
1313
// ----------------------------------------------------------------------------------
1414

15+
using Microsoft.Azure.Commands.Common.Authentication.Properties;
16+
using Microsoft.WindowsAzure.Commands.Common;
1517
using Microsoft.WindowsAzure.Commands.Utilities.Common;
1618
using System;
1719
using System.Collections.Generic;
1820
using System.Linq;
19-
using System.Net;
2021
using System.Net.Http;
2122
using System.Text;
2223
using System.Text.RegularExpressions;
2324

24-
using Microsoft.Azure.Commands.Profile.Utilities;
25-
2625
namespace Microsoft.Azure.Commands.Common.Authentication
2726
{
2827
static public class ClaimsChallengeUtilities
@@ -33,7 +32,7 @@ static public class ClaimsChallengeUtilities
3332
private static readonly Regex AuthenticationChallengeRegex = new Regex(AuthenticationChallengePattern);
3433
private static readonly Regex ChallengeParameterRegex = new Regex(ChallengeParameterPattern);
3534

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

49-
public static bool MatchClaimsChallengePattern(this HttpResponseMessage response)
48+
public static bool MatchClaimsChallengePattern(this HttpResponseMessage response, out string claimsChallenge)
5049
{
51-
return response.StatusCode == System.Net.HttpStatusCode.Unauthorized && response.Headers.WwwAuthenticate?.Count > 0;
50+
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized && response.Headers.WwwAuthenticate?.Count > 0)
51+
{
52+
claimsChallenge = GetClaimsChallenge(response);
53+
return true;
54+
}
55+
56+
claimsChallenge = null;
57+
return false;
5258
}
5359

5460
private static IEnumerable<(string, string)> ParseWwwAuthenticate(HttpResponseMessage response)
5561
{
5662
return Enumerable.Repeat(response, 1)
57-
.Where(r => r.MatchClaimsChallengePattern())
5863
.Select(r => r.Headers.WwwAuthenticate.FirstOrDefault().ToString())
5964
.SelectMany(h => ParseChallenges(h))
6065
.Where(c => string.Equals(c.Item1, "Bearer", StringComparison.OrdinalIgnoreCase))
@@ -80,5 +85,39 @@ public static bool MatchClaimsChallengePattern(this HttpResponseMessage response
8085
yield return (paramMatches[i].Groups[1].Value, paramMatches[i].Groups[2].Value);
8186
}
8287
}
88+
89+
/// <summary>
90+
/// Format the error message from the response content of the original failed request.
91+
/// If the error is caused by CAE (continuous Access Evaluation), this will include why the request failed, and which policy was violated.
92+
/// </summary>
93+
/// <param name="claimsChallenge"></param>
94+
/// <param name="responseContent"></param>
95+
/// <returns></returns>
96+
public static string FormatClaimsChallengeErrorMessage(string claimsChallenge, string responseContent)
97+
{
98+
var errorMessage = TryGetErrorMessageFromOriginalResponse(responseContent);
99+
// Convert claimsChallenge to base64
100+
var claimsChallengeBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(claimsChallenge ?? string.Empty));
101+
return string.Format(Resources.ErrorMessageOfClaimsChallengeRequired, errorMessage, claimsChallengeBase64);
102+
}
103+
104+
private static string TryGetErrorMessageFromOriginalResponse(string content)
105+
{
106+
if (string.IsNullOrWhiteSpace(content))
107+
{
108+
return content;
109+
}
110+
111+
try
112+
{
113+
var parsedJson = Newtonsoft.Json.Linq.JToken.Parse(content);
114+
return parsedJson["error"].Value<string>("message");
115+
}
116+
catch
117+
{
118+
// If parsing fails, return the original content
119+
return content;
120+
}
121+
}
83122
}
84123
}

src/Accounts/Authenticators/MsalAccessToken.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@
2828

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

128+
/// <summary>
129+
/// Receives a claims challenge from the server and processes it to obtain a new access token.
130+
/// Then updates the request with the new access token.
131+
/// </summary>
132+
/// <param name="request"></param>
133+
/// <param name="claimsChallenge"></param>
134+
/// <param name="cancellationToken"></param>
135+
/// <returns>A boolean indicated whether the request should be retried. Throws if the reauth fails.</returns>
124136
public async ValueTask<bool> OnClaimsChallenageAsync(HttpRequestMessage request, string claimsChallenge, CancellationToken cancellationToken)
125137
{
126138
TracingAdapter.Information($"{DateTime.Now:T} - [ClaimsChallengeProcessor] Calling {TokenCredential.GetType().Name}.GetTokenAsync- claimsChallenge:'{claimsChallenge}'");

0 commit comments

Comments
 (0)