diff --git a/src/Accounts/Accounts.Test/SilentReAuthByTenantCmdletTest.cs b/src/Accounts/Accounts.Test/SilentReAuthByTenantCmdletTest.cs index 0d16a82ab2f3..1b3c0eeca518 100644 --- a/src/Accounts/Accounts.Test/SilentReAuthByTenantCmdletTest.cs +++ b/src/Accounts/Accounts.Test/SilentReAuthByTenantCmdletTest.cs @@ -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 { @@ -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 @@ -171,7 +174,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); @@ -190,9 +193,11 @@ 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); + AzPSAuthenticationFailedException e = Assert.Throws(() => 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 diff --git a/src/Accounts/Accounts/Account/ConnectAzureRmAccount.cs b/src/Accounts/Accounts/Account/ConnectAzureRmAccount.cs index ec6817cef2a1..6a1b67085fbf 100644 --- a/src/Accounts/Accounts/Account/ConnectAzureRmAccount.cs +++ b/src/Accounts/Accounts/Account/ConnectAzureRmAccount.cs @@ -13,11 +13,9 @@ // ---------------------------------------------------------------------------------- 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.Authentication.Abstractions.Interfaces; using Microsoft.Azure.Commands.Common.Authentication.Abstractions.Models; using Microsoft.Azure.Commands.Common.Authentication.Config.Models; using Microsoft.Azure.Commands.Common.Authentication.Factories; @@ -41,7 +39,6 @@ using Microsoft.WindowsAzure.Commands.Common.Sanitizer; using Microsoft.WindowsAzure.Commands.Common.Utilities; using Microsoft.WindowsAzure.Commands.Utilities.Common; - using System; using System.Collections.Concurrent; using System.Linq; @@ -237,6 +234,10 @@ public class ConnectAzureRmAccountCommand : AzureContextModificationCmdlet, IMod [ValidateNotNullOrEmpty] public string FederatedToken { get; set; } + [Parameter(ParameterSetName = UserParameterSet, Mandatory = false, HelpMessage = "Specifies the claims challenge with base64 encoding.")] + [ValidateNotNullOrEmpty] + public string ClaimsChallenge { get; set; } + protected override IAzureContext DefaultContext { get @@ -353,7 +354,6 @@ public override void ExecuteCmdlet() { subscriptionName = Subscription; } - } else if (AzureSession.Instance.TryGetComponent(nameof(IConfigManager), out var configManager)) { @@ -373,6 +373,15 @@ public override void ExecuteCmdlet() } } + string claimsChallenge = null; + if (this.IsParameterBound(c => c.ClaimsChallenge)) + { + if (!ClaimsChallengeUtilities.TryParseClaimsChallenge(ClaimsChallenge, out claimsChallenge)) + { + throw new PSArgumentException(Resources.InvalidClaimsChallenge, nameof(ClaimsChallenge)); + } + } + var azureAccount = new AzureAccount(); switch (ParameterSetName) @@ -548,6 +557,7 @@ public override void ExecuteCmdlet() SkipValidation, new OpenIDConfiguration(Tenant, baseUri: _environment.ActiveDirectoryAuthority, httpClientFactory: httpClientFactory), WriteWarningEvent, //Could not use WriteWarning directly because it may be in worker thread + claimsChallenge, name, shouldPopulateContextList, MaxContextPopulation, diff --git a/src/Accounts/Accounts/ChangeLog.md b/src/Accounts/Accounts/ChangeLog.md index 0a826675688b..27c98a9aab37 100644 --- a/src/Accounts/Accounts/ChangeLog.md +++ b/src/Accounts/Accounts/ChangeLog.md @@ -19,6 +19,8 @@ --> ## Upcoming Release +* Added new parameter `-ClaimsChallenge` to `Connect-AzAccount` to support claims challenge authentication for MFA. +* 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..e0490507c7b8 100644 --- a/src/Accounts/Accounts/CommonModule/ContextAdapter.cs +++ b/src/Accounts/Accounts/CommonModule/ContextAdapter.cs @@ -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 { @@ -200,14 +201,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 +219,7 @@ internal async Task AuthenticationHelper(IAzureContext cont } catch (AuthenticationFailedException e) { - throw e.WithAdditionalMessage(response?.GetWwwAuthenticateMessage()); + throw new AzPSAuthenticationFailedException(ClaimsChallengeUtilities.FormatClaimsChallengeErrorMessage(claimsChallenge, await response?.Content?.ReadAsStringAsync()), null, e); } } } diff --git a/src/Accounts/Accounts/Models/RMProfileClient.cs b/src/Accounts/Accounts/Models/RMProfileClient.cs index 46f2f0f836c9..a238636a90b8 100644 --- a/src/Accounts/Accounts/Models/RMProfileClient.cs +++ b/src/Accounts/Accounts/Models/RMProfileClient.cs @@ -11,6 +11,7 @@ // See the License for the specific language governing permissions and // limitations under the License. // ---------------------------------------------------------------------------------- + using Microsoft.Azure.Commands.Common.Authentication; using Microsoft.Azure.Commands.Common.Authentication.Abstractions; using Microsoft.Azure.Commands.Common.Authentication.Abstractions.Interfaces; @@ -22,13 +23,11 @@ using Microsoft.Azure.Commands.Profile.Utilities; using Microsoft.Rest.Azure; using Microsoft.WindowsAzure.Commands.Common; - using System; using System.Collections.Generic; using System.Linq; using System.Management.Automation; using System.Security; - using AuthenticationMessages = Microsoft.Azure.Commands.Common.Authentication.Properties.Resources; using ProfileMessages = Microsoft.Azure.Commands.Profile.Properties.Resources; using ResourceMessages = Microsoft.Azure.Commands.ResourceManager.Common.Properties.Resources; @@ -52,7 +51,7 @@ private IAzureContext DefaultContext { get { - if(_profile == null || _profile.DefaultContext == null || _profile.DefaultContext.Account == null) + if (_profile == null || _profile.DefaultContext == null || _profile.DefaultContext.Account == null) { throw new PSInvalidOperationException(ResourceMessages.RunConnectAccount); } @@ -130,13 +129,14 @@ public AzureRmProfile Login( bool skipValidation, IOpenIDConfiguration openIDConfigDoc, Action promptAction, + string claimsChallenge = null, string name = null, bool shouldPopulateContextList = true, int maxContextPopulation = Profile.ConnectAzureRmAccountCommand.DefaultMaxContextPopulation, string authScope = null, bool IsInteractiveContextSelectionEnabled = true) { - + WriteInteractiveInformationMessage($"{PSStyle.ForegroundColor.BrightYellow}{Resources.PleaseSelectAccount}{PSStyle.Reset}{System.Environment.NewLine}"); IAzureSubscription defaultSubscription = null; @@ -144,7 +144,7 @@ public AzureRmProfile Login( List subscriptions = new List(); List tempSubscriptions = null; string tenantName = null; - + bool selectSubscriptionFromList = AzureAccount.AccountType.User.Equals(account.Type) && IsInteractiveContextSelectionEnabled && string.IsNullOrEmpty(subscriptionId) && @@ -161,9 +161,9 @@ public AzureRmProfile Login( SubscritpionClientCandidates.Reset(); bool needDataPlanAuthFirst = !string.IsNullOrEmpty(authScope); - if(needDataPlanAuthFirst) + if (needDataPlanAuthFirst) { - var token = AcquireAccessToken(account, environment, tenantIdOrName, password, promptBehavior, promptAction, authScope); + var token = AcquireAccessToken(account, environment, tenantIdOrName, password, promptBehavior, promptAction, claimsChallenge, authScope); promptBehavior = ShowDialog.Never; } @@ -202,7 +202,8 @@ public AzureRmProfile Login( tenantIdOrName, password, promptBehavior, - promptAction); + promptAction, + claimsChallenge); if (!Guid.TryParse(tenantIdOrName, out Guid _)) { @@ -229,7 +230,7 @@ public AzureRmProfile Login( } } } - catch(Exception e) + catch (Exception e) { string baseMessage = string.Format(ProfileMessages.TenantDomainNotFound, tenantIdOrName); var typeMessageMap = new Dictionary @@ -293,7 +294,7 @@ public AzureRmProfile Login( try { - token = AcquireAccessToken(account, environment, tenant.Id, password, ShowDialog.Auto, null); + token = AcquireAccessToken(account, environment, tenant.Id, password, ShowDialog.Auto, null, claimsChallenge); if (accountId == null) { accountId = account.Id; @@ -314,7 +315,7 @@ public AzureRmProfile Login( token = null; } } - catch(Exception e) + catch (Exception e) { WriteWarningMessage(string.Format(ProfileMessages.UnableToAqcuireToken, tenant.Id, e.Message)); WriteDebugMessage(string.Format(ProfileMessages.UnableToAqcuireToken, tenant.Id, e.ToString())); @@ -334,7 +335,7 @@ public AzureRmProfile Login( defaultTenant = tempTenant; } } - if(tempSubscription != null) + if (tempSubscription != null) { subscriptions.AddRange(tempSubscriptions); } @@ -397,7 +398,7 @@ public AzureRmProfile Login( { var defaultContext = _profile.DefaultContext; var populatedSubscriptions = (maxContextPopulation < 0 || selectSubscriptionFromList) ? ListSubscriptions(tenantIdOrName) : ListSubscriptions(tenantIdOrName).Take(maxContextPopulation); - + foreach (var subscription in populatedSubscriptions) { IAzureTenant tempTenant = InteractiveSubscriptionSelectionHelper.GetDetailedTenantFromQueryHistory(_queriedTenants, subscription.GetProperty(AzureSubscription.Property.Tenants)) ?? new AzureTenant() @@ -449,7 +450,7 @@ public IAzureContext SetCurrentContext(string subscriptionNameOrId, string tenan } var tenantFromSubscription = subscription.GetTenant(); - tenant = string.IsNullOrWhiteSpace(tenantId) ? (string.IsNullOrEmpty(tenantFromSubscription) ? context.Tenant : CreateTenant(tenantFromSubscription)): CreateTenant(tenantId); + tenant = string.IsNullOrWhiteSpace(tenantId) ? (string.IsNullOrEmpty(tenantFromSubscription) ? context.Tenant : CreateTenant(tenantFromSubscription)) : CreateTenant(tenantId); } else if (!string.IsNullOrWhiteSpace(tenantId)) { @@ -536,14 +537,14 @@ public bool TryGetSubscriptionListByName(string tenantId, string subscriptionNam HashSet existedSubscriptionIds = new HashSet(); // Consider subscription in Home tenant first, exclude duplicate subscriptions by id. - foreach(IAzureSubscription subscription in subscriptions) + foreach (IAzureSubscription subscription in subscriptions) { - if (subscription is PSAzureSubscription && subscription.GetTenant() != null + if (subscription is PSAzureSubscription && subscription.GetTenant() != null && subscription.GetHomeTenant().Equals(subscription.GetTenant()) && existedSubscriptionIds.Add(subscription.GetId())) { subscriptionList.Add(subscription); } - + } // Consider other subscriptions. foreach (IAzureSubscription subscription in subscriptions) @@ -679,6 +680,7 @@ private IAccessToken AcquireAccessToken( SecureString password, string promptBehavior, Action promptAction, + string claimsChallenge = null, string resourceId = AzureEnvironment.Endpoint.ActiveDirectoryServiceEndpointResourceId) { if (account.Type == AzureAccount.AccountType.AccessToken) @@ -689,11 +691,13 @@ private IAccessToken AcquireAccessToken( var optionalParameters = new Dictionary() { - {AuthenticationFactory.TokenCacheParameterName, _cache}, - {AuthenticationFactory.ResourceIdParameterName, resourceId }, - {AuthenticationFactory.CmdletContextParameterName, CmdletContext } + { AuthenticationFactory.ResourceIdParameterName, resourceId }, + { AuthenticationFactory.ClaimsChallengeParameterName, claimsChallenge }, + { AuthenticationFactory.TokenCacheParameterName, _cache }, + { AuthenticationFactory.CmdletContextParameterName, CmdletContext } }; + return AzureSession.Instance.AuthenticationFactory.Authenticate( account, environment, @@ -814,7 +818,7 @@ private List ListAccountTenants( result = SubscriptionAndTenantClient?.ListAccountTenants(commonTenantToken, environment); } - catch(Exception e) + catch (Exception e) { WriteWarningMessage(string.Format(ProfileMessages.UnableToAqcuireToken, commonTenant, e.Message)); WriteDebugMessage(string.Format(ProfileMessages.UnableToAqcuireToken, commonTenant, e.ToString())); @@ -861,7 +865,7 @@ private IEnumerable ListAllSubscriptionsForTenant( { accessToken = AcquireAccessToken(account, environment, tenantId, password, promptBehavior, null); } - catch(Exception e) + catch (Exception e) { WriteWarningMessage(string.Format(ProfileMessages.UnableToAqcuireToken, tenantId, e.Message)); WriteDebugMessage(string.Format(ProfileMessages.UnableToAqcuireToken, tenantId, e.ToString())); @@ -881,7 +885,7 @@ private void WriteWarningMessage(string message) private void WriteDebugMessage(string message) { - if(DebugLog != null) + if (DebugLog != null) { DebugLog(message); } diff --git a/src/Accounts/Accounts/Properties/Resources.Designer.cs b/src/Accounts/Accounts/Properties/Resources.Designer.cs index ff5e428fe1c4..b7fc32718646 100644 --- a/src/Accounts/Accounts/Properties/Resources.Designer.cs +++ b/src/Accounts/Accounts/Properties/Resources.Designer.cs @@ -690,6 +690,15 @@ internal static string InvalidAzureContext { } } + /// + /// Looks up a localized string similar to Invalid claims challenge format. It should be a valid base64 encoded string.. + /// + internal static string InvalidClaimsChallenge { + get { + return ResourceManager.GetString("InvalidClaimsChallenge", resourceCulture); + } + } + /// /// Looks up a localized string similar to Endpoint provided is invalid. Please check the value and retry again with the correct value.. /// diff --git a/src/Accounts/Accounts/Properties/Resources.resx b/src/Accounts/Accounts/Properties/Resources.resx index 249efaac7a4f..e352ab479bc6 100644 --- a/src/Accounts/Accounts/Properties/Resources.resx +++ b/src/Accounts/Accounts/Properties/Resources.resx @@ -637,4 +637,7 @@ Using authentication with username and password in the command line is strongly discouraged. Consider using one of the recommended authentication methods. For more details, see https://go.microsoft.com/fwlink/?linkid=2276971 + + Invalid claims challenge format. It should be a valid base64 encoded string. + \ No newline at end of file diff --git a/src/Accounts/Accounts/help/Connect-AzAccount.md b/src/Accounts/Accounts/help/Connect-AzAccount.md index 80cecef738cb..56602332ee83 100644 --- a/src/Accounts/Accounts/help/Connect-AzAccount.md +++ b/src/Accounts/Accounts/help/Connect-AzAccount.md @@ -16,8 +16,9 @@ Connect to Azure with an authenticated account for use with cmdlets from the Az ``` Connect-AzAccount [-Environment ] [-Tenant ] [-AccountId ] [-Subscription ] [-AuthScope ] [-ContextName ] [-SkipContextPopulation] [-MaxContextPopulation ] - [-UseDeviceAuthentication] [-Force] [-Scope ] - [-DefaultProfile ] [-WhatIf] [-Confirm] [] + [-UseDeviceAuthentication] [-Force] [-ClaimsChallenge ] [-Scope ] + [-DefaultProfile ] [-WhatIf] [-Confirm] + [] ``` ### ServicePrincipalWithSubscriptionId @@ -25,7 +26,8 @@ Connect-AzAccount [-Environment ] [-Tenant ] [-AccountId ] -Credential [-ServicePrincipal] -Tenant [-Subscription ] [-AuthScope ] [-ContextName ] [-SkipContextPopulation] [-MaxContextPopulation ] [-Force] [-Scope ] - [-DefaultProfile ] [-WhatIf] [-Confirm] [] + [-DefaultProfile ] [-WhatIf] [-Confirm] + [] ``` ### UserWithCredential @@ -33,7 +35,8 @@ Connect-AzAccount [-Environment ] -Credential [-ServicePr Connect-AzAccount [-Environment ] -Credential [-Tenant ] [-Subscription ] [-AuthScope ] [-ContextName ] [-SkipContextPopulation] [-MaxContextPopulation ] [-Force] [-Scope ] - [-DefaultProfile ] [-WhatIf] [-Confirm] [] + [-DefaultProfile ] [-WhatIf] [-Confirm] + [] ``` ### ServicePrincipalCertificateWithSubscriptionId @@ -41,8 +44,8 @@ Connect-AzAccount [-Environment ] -Credential [-Tenant ] -CertificateThumbprint -ApplicationId [-ServicePrincipal] -Tenant [-Subscription ] [-AuthScope ] [-ContextName ] [-SkipContextPopulation] [-MaxContextPopulation ] [-Force] [-SendCertificateChain] - [-Scope ] [-DefaultProfile ] [-WhatIf] [-Confirm] - [] + [-Scope ] [-DefaultProfile ] + [-WhatIf] [-Confirm] [] ``` ### ClientAssertionParameterSet @@ -50,7 +53,8 @@ Connect-AzAccount [-Environment ] -CertificateThumbprint -Appli Connect-AzAccount [-Environment ] -ApplicationId [-ServicePrincipal] -Tenant [-Subscription ] [-ContextName ] [-SkipContextPopulation] [-MaxContextPopulation ] [-Force] -FederatedToken [-Scope ] - [-DefaultProfile ] [-WhatIf] [-Confirm] [] + [-DefaultProfile ] [-WhatIf] [-Confirm] + [] ``` ### ServicePrincipalCertificateFileWithSubscriptionId @@ -58,8 +62,8 @@ Connect-AzAccount [-Environment ] -ApplicationId [-ServicePrinc Connect-AzAccount [-Environment ] -ApplicationId [-ServicePrincipal] -Tenant [-Subscription ] [-ContextName ] [-SkipContextPopulation] [-MaxContextPopulation ] [-Force] [-SendCertificateChain] -CertificatePath [-CertificatePassword ] - [-Scope ] [-DefaultProfile ] [-WhatIf] [-Confirm] - [] + [-Scope ] [-DefaultProfile ] + [-WhatIf] [-Confirm] [] ``` ### AccessTokenWithSubscriptionId @@ -68,7 +72,8 @@ Connect-AzAccount [-Environment ] [-Tenant ] -AccessToken ] [-KeyVaultAccessToken ] -AccountId [-Subscription ] [-ContextName ] [-SkipValidation] [-SkipContextPopulation] [-MaxContextPopulation ] [-Force] [-Scope ] - [-DefaultProfile ] [-WhatIf] [-Confirm] [] + [-DefaultProfile ] [-WhatIf] [-Confirm] + [] ``` ### ManagedServiceLogin @@ -76,7 +81,8 @@ Connect-AzAccount [-Environment ] [-Tenant ] -AccessToken ] [-Tenant ] [-AccountId ] [-Identity] [-Subscription ] [-AuthScope ] [-ContextName ] [-SkipContextPopulation] [-MaxContextPopulation ] [-Force] [-Scope ] - [-DefaultProfile ] [-WhatIf] [-Confirm] [] + [-DefaultProfile ] [-WhatIf] [-Confirm] + [] ``` ## DESCRIPTION @@ -292,6 +298,21 @@ Account SubscriptionName TenantId Env xxxxxxxx-xxxx-xxxx-xxxxxxxx Subscription1 yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyy AzureCloud ``` +### Example 11: Connect with claims challenge + +This example demonstrates how to connect using a claims challenge token. +This is useful when you receive a claims challenge during authentication, typically when additional authentication factors are required due to conditional access policies. + +```powershell +Connect-AzAccount -Tenant yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyy -Subscription zzzzzzzz-zzzz-zzzz-zzzz-zzzzzzzz -ClaimsChallenge eyJhY2Nlc3NfdG9rZW4iOnsiYWNycyI6eyJlc3NlbnRpYWwiOnRydWUsInZhbHVlcyI6WyJwMSJdfX19 +``` + +```Output +Account SubscriptionName TenantId Environment +------- ---------------- -------- ----------- +xxxxxxxx-xxxx-xxxx-xxxxxxxx Subscription1 yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyy AzureCloud +``` + ## PARAMETERS ### -AccessToken @@ -421,6 +442,21 @@ Accept pipeline input: False Accept wildcard characters: False ``` +### -ClaimsChallenge +Specifies the claims challenge with base64 encoding. + +```yaml +Type: System.String +Parameter Sets: UserWithSubscriptionId +Aliases: + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + ### -ContextName Name of the default Azure context for this login. For more information about Azure contexts, see 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/Authentication/Parameters/InteractiveParameters.cs b/src/Accounts/Authentication/Authentication/Parameters/InteractiveParameters.cs index 71091a0a00ad..00d8fbdd9e6c 100644 --- a/src/Accounts/Authentication/Authentication/Parameters/InteractiveParameters.cs +++ b/src/Accounts/Authentication/Authentication/Parameters/InteractiveParameters.cs @@ -21,6 +21,8 @@ public class InteractiveParameters : DeviceCodeParameters { public Action PromptAction { get; set; } + public string ClaimsChallenge { get; set; } + public InteractiveParameters( PowerShellTokenCacheProvider tokenCacheProvider, IAzureEnvironment environment, @@ -29,9 +31,11 @@ public InteractiveParameters( string resourceId, string userId, string homeAccountId, - Action promptAction) : base(tokenCacheProvider, environment, tokenCache, tenantId, resourceId, userId, homeAccountId) + Action promptAction, + string claimsChallenge) : base(tokenCacheProvider, environment, tokenCache, tenantId, resourceId, userId, homeAccountId) { PromptAction = promptAction; + ClaimsChallenge = claimsChallenge; } } } diff --git a/src/Accounts/Authentication/Authentication/Parameters/InteractiveWamParameters.cs b/src/Accounts/Authentication/Authentication/Parameters/InteractiveWamParameters.cs index d7f99b85d69a..b9a428d24f2d 100644 --- a/src/Accounts/Authentication/Authentication/Parameters/InteractiveWamParameters.cs +++ b/src/Accounts/Authentication/Authentication/Parameters/InteractiveWamParameters.cs @@ -21,6 +21,8 @@ public class InteractiveWamParameters : DeviceCodeParameters { public Action PromptAction { get; set; } + public string ClaimsChallenge { get; set; } + public InteractiveWamParameters( PowerShellTokenCacheProvider tokenCacheProvider, IAzureEnvironment environment, @@ -29,9 +31,11 @@ public InteractiveWamParameters( string resourceId, string userId, string homeAccountId, - Action promptAction) : base(tokenCacheProvider, environment, tokenCache, tenantId, resourceId, userId, homeAccountId) + Action promptAction, + string claimsChallenge) : base(tokenCacheProvider, environment, tokenCache, tenantId, resourceId, userId, homeAccountId) { PromptAction = promptAction; + ClaimsChallenge = claimsChallenge; } } } diff --git a/src/Accounts/Authentication/ClaimsChallengeHandler.cs b/src/Accounts/Authentication/ClaimsChallengeHandler.cs index 5f2c93d3150d..6f92bbff9fd2 100644 --- a/src/Accounts/Authentication/ClaimsChallengeHandler.cs +++ b/src/Accounts/Authentication/ClaimsChallengeHandler.cs @@ -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; @@ -34,18 +34,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 new AzPSAuthenticationFailedException(additionalErrorMessage, null, e); } } return response; @@ -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. /// /// 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/Factories/AuthenticationFactory.cs b/src/Accounts/Authentication/Factories/AuthenticationFactory.cs index 6dd2996294f4..c6fdb5f90992 100644 --- a/src/Accounts/Authentication/Factories/AuthenticationFactory.cs +++ b/src/Accounts/Authentication/Factories/AuthenticationFactory.cs @@ -13,7 +13,6 @@ // ---------------------------------------------------------------------------------- using Hyak.Common; - using Microsoft.Azure.Commands.Common.Authentication.Abstractions; using Microsoft.Azure.Commands.Common.Authentication.Abstractions.Interfaces; using Microsoft.Azure.Commands.Common.Authentication.Authentication; @@ -24,7 +23,6 @@ using Microsoft.Identity.Client; using Microsoft.Rest; using Microsoft.WindowsAzure.Commands.Common; - using System; using System.Collections.Generic; using System.Linq; @@ -41,6 +39,11 @@ public class AuthenticationFactory : IAuthenticationFactory DefaultMSILoginUri = "http://169.254.169.254/metadata/identity/oauth2/token", DefaultBackupMSILoginUri = "http://localhost:50342/oauth2/token"; + public const string TokenCacheParameterName = "tokenCache"; + public const string ResourceIdParameterName = "resourceId"; + public const string CmdletContextParameterName = "cmdletContext"; + public const string ClaimsChallengeParameterName = "claimsChallenge"; + public AuthenticationFactory() { _getKeyStore = () => @@ -90,17 +93,47 @@ public AzKeyStore KeyStore public ITokenProvider TokenProvider { get; set; } /// - /// + /// Authenticates an Azure account, service principal, or managed identity against Entra ID and obtains an access token. /// - /// - /// - /// - /// - /// - /// - /// - /// - /// + /// The Azure account to authenticate. + /// The Azure environment to authenticate against. + /// The tenant ID or name to authenticate with. + /// The password for the account, if applicable. + /// The prompt behavior to use during authentication. + /// Action to execute when a prompt is required. + /// The resource identifier to authenticate for. + /// An access token for the authenticated principal. + public IAccessToken Authenticate( + IAzureAccount account, + IAzureEnvironment environment, + string tenant, + SecureString password, + string promptBehavior, + Action promptAction, + string resourceId = AzureEnvironment.Endpoint.ActiveDirectoryServiceEndpointResourceId) + { + return Authenticate( + account, + environment, + tenant, password, + promptBehavior, + promptAction, + null, + resourceId); + } + + /// + /// Authenticates an Azure account, service principal, or managed identity against Entra ID and obtains an access token. + /// + /// The Azure account to authenticate. + /// The Azure environment to authenticate against. + /// The tenant ID or name to authenticate with. + /// The password for the account, if applicable. + /// The prompt behavior to use during authentication. + /// Action to execute when a prompt is required. + /// The token cache to use for caching authentication results. + /// The resource identifier to authenticate for. + /// An access token for the authenticated principal. public IAccessToken Authenticate( IAzureAccount account, IAzureEnvironment environment, @@ -119,21 +152,25 @@ public IAccessToken Authenticate( return Authenticate(account, environment, tenant, password, promptBehavior, promptAction, optionalParameters); } - public const string TokenCacheParameterName = "tokenCache"; - public const string ResourceIdParameterName = "resourceId"; - public const string CmdletContextParameterName = "cmdletContext"; - /// - /// + /// Authenticates an Azure account, service principal, or managed identity against Entra ID and obtains an access token. + /// This method supports claims challenge for conditional access scenarios and various authentication flows based on account type. /// - /// - /// - /// - /// - /// - /// - /// - /// + /// The Azure account to authenticate. + /// The Azure environment to authenticate against. + /// The tenant ID or name to authenticate with. + /// The password for the account, if applicable. + /// The prompt behavior to use during authentication. + /// Action to execute when a prompt is required. + /// Dictionary of optional parameters that may include: + /// + /// tokenCache (IAzureTokenCache): The token cache to use for caching authentication results. + /// resourceId (string): The resource identifier to authenticate for. + /// claimsChallenge (string): Claims challenge token from a previous authentication attempt requiring additional claims. + /// cmdletContext (ICmdletContext): Context for the cmdlet executing the authentication. + /// + /// + /// An access token for the authenticated principal. public IAccessToken Authenticate( IAzureAccount account, IAzureEnvironment environment, @@ -144,19 +181,28 @@ public IAccessToken Authenticate( IDictionary optionalParameters) { var resourceId = AzureEnvironment.Endpoint.ActiveDirectoryServiceEndpointResourceId; + string claimsChallenge = null; IAzureTokenCache tokenCache = null; ICmdletContext cmdletContext = null; AuthenticationTelemetry authenticationTelemetry = null; + if (optionalParameters != null) { if (optionalParameters.ContainsKey(ResourceIdParameterName)) { resourceId = optionalParameters[ResourceIdParameterName] as string; } + + if (optionalParameters.ContainsKey(ClaimsChallengeParameterName)) + { + claimsChallenge = optionalParameters[ClaimsChallengeParameterName] as string; + } + if (optionalParameters.ContainsKey(TokenCacheParameterName)) { tokenCache = optionalParameters[TokenCacheParameterName] as IAzureTokenCache; } + if (AzureSession.Instance.TryGetComponent(AuthenticationTelemetry.Name, out authenticationTelemetry)) { if (optionalParameters.ContainsKey(CmdletContextParameterName)) @@ -175,7 +221,7 @@ public IAccessToken Authenticate( Task authToken; var processAuthenticator = Builder.Authenticator; var retries = 5; - var authParamters = GetAuthenticationParameters(tokenCacheProvider, account, environment, tenant, password, promptBehavior, promptAction, tokenCache, resourceId); + var authParamters = GetAuthenticationParameters(tokenCacheProvider, account, environment, tenant, password, promptBehavior, promptAction, claimsChallenge, tokenCache, resourceId); IAccessToken token = null; while (retries-- > 0) @@ -283,26 +329,7 @@ private static AzPSAuthenticationFailedException AnalyzeMsalException(Exception private static bool NeedTenantArmPermission(IAzureEnvironment environment, string tenantId, string resourceId) { return !string.IsNullOrEmpty(tenantId) && !string.IsNullOrEmpty(resourceId) && - string.Equals(environment.GetEndpoint(resourceId), environment.GetEndpoint(AzureEnvironment.Endpoint.ActiveDirectoryServiceEndpointResourceId)); - } - - public IAccessToken Authenticate( - IAzureAccount account, - IAzureEnvironment environment, - string tenant, - SecureString password, - string promptBehavior, - Action promptAction, - string resourceId = AzureEnvironment.Endpoint.ActiveDirectoryServiceEndpointResourceId) - { - return Authenticate( - account, - environment, - tenant, password, - promptBehavior, - promptAction, - null, - resourceId); + string.Equals(environment.GetEndpoint(resourceId), environment.GetEndpoint(AzureEnvironment.Endpoint.ActiveDirectoryServiceEndpointResourceId)); } public SubscriptionCloudCredentials GetSubscriptionCloudCredentials(IAzureContext context) @@ -503,10 +530,8 @@ public void RemoveUser(IAzureAccount account, IAzureEnvironment environment) case AzureAccount.AccountType.ServicePrincipal: try { - KeyStore.RemoveSecureString(new ServicePrincipalKey(AzureAccount.Property.ServicePrincipalSecret, - account.Id, account.GetTenants().FirstOrDefault())); - KeyStore.RemoveSecureString(new ServicePrincipalKey(AzureAccount.Property.CertificatePassword, - account.Id, account.GetTenants().FirstOrDefault())); + KeyStore.RemoveSecureString(new ServicePrincipalKey(AzureAccount.Property.ServicePrincipalSecret, account.Id, account.GetTenants().FirstOrDefault())); + KeyStore.RemoveSecureString(new ServicePrincipalKey(AzureAccount.Property.CertificatePassword, account.Id, account.GetTenants().FirstOrDefault())); } catch { @@ -567,13 +592,11 @@ private void RemoveFromTokenCache(IAzureAccount account) } var publicClient = tokenCacheProvider.CreatePublicClient(); - var accounts = publicClient.GetAccountsAsync() - .ConfigureAwait(false).GetAwaiter().GetResult(); + var accounts = publicClient.GetAccountsAsync().ConfigureAwait(false).GetAwaiter().GetResult(); var tokenAccounts = accounts.Where(a => MatchCacheItem(account, a)); foreach (var tokenAccount in tokenAccounts) - { - publicClient.RemoveAsync(tokenAccount) - .ConfigureAwait(false).GetAwaiter().GetResult(); + { + publicClient.RemoveAsync(tokenAccount).ConfigureAwait(false).GetAwaiter().GetResult(); } } @@ -599,6 +622,20 @@ private bool MatchCacheItem(IAzureAccount account, IAccount tokenAccount) return result; } + /// + /// Creates the appropriate authentication parameters based on the account type and other inputs. + /// + /// Provider for token caching. + /// The Azure account to authenticate. + /// The Azure environment to authenticate against. + /// The tenant ID or name to authenticate with. + /// The password for the account, if applicable. + /// The prompt behavior to use during authentication. + /// Action to execute when a prompt is required. + /// Claims challenge token from a previous authentication attempt. + /// The token cache to use for caching authentication results. + /// The resource identifier to authenticate for. + /// Authentication parameters appropriate for the account type. private AuthenticationParameters GetAuthenticationParameters( PowerShellTokenCacheProvider tokenCacheProvider, IAzureAccount account, @@ -607,6 +644,7 @@ private AuthenticationParameters GetAuthenticationParameters( SecureString password, string promptBehavior, Action promptAction, + string claimsChallenge, IAzureTokenCache tokenCache, string resourceId = AzureEnvironment.Endpoint.ActiveDirectoryServiceEndpointResourceId) { @@ -617,7 +655,7 @@ private AuthenticationParameters GetAuthenticationParameters( { var homeAccountId = account.GetProperty(AzureAccount.Property.HomeAccountId) ?? ""; - if (!string.IsNullOrEmpty(account.Id)) + if (!string.IsNullOrEmpty(account.Id) && string.IsNullOrEmpty(claimsChallenge)) { return GetSilentParameters(tokenCacheProvider, account, environment, tenant, tokenCache, resourceId, homeAccountId); } @@ -630,7 +668,7 @@ private AuthenticationParameters GetAuthenticationParameters( { return new UsernamePasswordParameters(tokenCacheProvider, environment, tokenCache, tenant, resourceId, account.Id, password, homeAccountId); } - return GetInteractiveParameters(tokenCacheProvider, account, environment, tenant, promptAction, tokenCache, resourceId, homeAccountId); + return GetInteractiveParameters(tokenCacheProvider, account, environment, tenant, promptAction, claimsChallenge, tokenCache, resourceId, homeAccountId); } return new UsernamePasswordParameters(tokenCacheProvider, environment, tokenCache, tenant, resourceId, account.Id, password, null); @@ -647,8 +685,7 @@ private AuthenticationParameters GetAuthenticationParameters( { try { - password = KeyStore.GetSecureString(new ServicePrincipalKey(AzureAccount.Property.ServicePrincipalSecret -, account.Id, tenant)); + password = KeyStore.GetSecureString(new ServicePrincipalKey(AzureAccount.Property.ServicePrincipalSecret, account.Id, tenant)); } catch { @@ -683,11 +720,11 @@ private AuthenticationParameters GetAuthenticationParameters( } } - private static AuthenticationParameters GetInteractiveParameters(PowerShellTokenCacheProvider tokenCacheProvider, IAzureAccount account, IAzureEnvironment environment, string tenant, Action promptAction, IAzureTokenCache tokenCache, string resourceId, string homeAccountId) + private static AuthenticationParameters GetInteractiveParameters(PowerShellTokenCacheProvider tokenCacheProvider, IAzureAccount account, IAzureEnvironment environment, string tenant, Action promptAction, string claimsChallenge, IAzureTokenCache tokenCache, string resourceId, string homeAccountId) { return AzConfigReader.IsWamEnabled(environment.ActiveDirectoryAuthority) - ? new InteractiveWamParameters(tokenCacheProvider, environment, tokenCache, tenant, resourceId, account.GetProperty("LoginHint"), homeAccountId, promptAction) as AuthenticationParameters - : new InteractiveParameters(tokenCacheProvider, environment, tokenCache, tenant, resourceId, account.GetProperty("LoginHint"), homeAccountId, promptAction); + ? new InteractiveWamParameters(tokenCacheProvider, environment, tokenCache, tenant, resourceId, account.GetProperty("LoginHint"), homeAccountId, promptAction, claimsChallenge) as AuthenticationParameters + : new InteractiveParameters(tokenCacheProvider, environment, tokenCache, tenant, resourceId, account.GetProperty("LoginHint"), homeAccountId, promptAction, claimsChallenge); } private static AuthenticationParameters GetSilentParameters(PowerShellTokenCacheProvider tokenCacheProvider, IAzureAccount account, IAzureEnvironment environment, string tenant, IAzureTokenCache tokenCache, string resourceId, string homeAccountId) diff --git a/src/Accounts/Authentication/Properties/Resources.Designer.cs b/src/Accounts/Authentication/Properties/Resources.Designer.cs index a3422228b12e..53b43fdb5a3c 100644 --- a/src/Accounts/Authentication/Properties/Resources.Designer.cs +++ b/src/Accounts/Authentication/Properties/Resources.Designer.cs @@ -276,6 +276,19 @@ public static string ErrorMessageMsalInteractionRequiredWithTid { } } + /// + /// Looks up a localized string similar to {0} + /// + ///Run the cmdlet below to authenticate interactively; additional parameters may be added as needed. + /// + ///Connect-AzAccount -Tenant (Get-AzContext).Tenant.Id -ClaimsChallenge "{1}". + /// + public static string ErrorMessageOfClaimsChallengeRequired { + get { + return ResourceManager.GetString("ErrorMessageOfClaimsChallengeRequired", resourceCulture); + } + } + /// /// Looks up a localized string similar to Authentication failed against resource {0}. User interaction is required. This may be due to the conditional access policy settings such as multi-factor authentication (MFA). Please rerun 'Connect-AzAccount' with additional parameter '-AuthScope {0}'.. /// diff --git a/src/Accounts/Authentication/Properties/Resources.resx b/src/Accounts/Authentication/Properties/Resources.resx index 09a52feac630..2d06378d1b8d 100644 --- a/src/Accounts/Authentication/Properties/Resources.resx +++ b/src/Accounts/Authentication/Properties/Resources.resx @@ -426,4 +426,12 @@ The scope of authenticating for SSH is not set. Please run "Set-AzEnvironment -Name {0} -SshAuthScope ..." to set it first. 0 = environment name + + {0} + +Run the cmdlet below to authenticate interactively; additional parameters may be added as needed. + +Connect-AzAccount -Tenant (Get-AzContext).Tenant.Id -ClaimsChallenge "{1}" + 0 = error message about policy violation; 1 = claims challenge in base64 + \ No newline at end of file diff --git a/src/Accounts/Authentication/Utilities/ClaimsChallengeUtilities.cs b/src/Accounts/Authentication/Utilities/ClaimsChallengeUtilities.cs index 0a387a0f97a7..983e0ed8d330 100644 --- a/src/Accounts/Authentication/Utilities/ClaimsChallengeUtilities.cs +++ b/src/Accounts/Authentication/Utilities/ClaimsChallengeUtilities.cs @@ -12,17 +12,15 @@ // limitations under the License. // ---------------------------------------------------------------------------------- +using Microsoft.Azure.Commands.Common.Authentication.Properties; 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 @@ -33,7 +31,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 +44,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 +84,68 @@ 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)); + 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("message"); + } + catch + { + // If parsing fails, return the original content + return content; + } + } + + /// + /// Attempts to decode a base64 encoded claims challenge string. + /// + /// The base64 encoded claims challenge string. + /// When this method returns, contains the decoded JSON claims challenge string if decoding succeeded; otherwise, null. + /// + /// true if the base64 string was successfully decoded into a valid claims challenge; otherwise, false. + /// + public static bool TryParseClaimsChallenge(string base64Input, out string claimsChallenge) + { + claimsChallenge = null; + + if (string.IsNullOrWhiteSpace(base64Input)) + return false; + + try + { + byte[] data = Convert.FromBase64String(base64Input); + claimsChallenge = Encoding.UTF8.GetString(data); + + return true; + } + catch + { + claimsChallenge = null; + return false; + } + } } } diff --git a/src/Accounts/Authenticators/InteractiveUserAuthenticator.cs b/src/Accounts/Authenticators/InteractiveUserAuthenticator.cs index fd29c3c7ba9e..cc5ba463b6ad 100644 --- a/src/Accounts/Authenticators/InteractiveUserAuthenticator.cs +++ b/src/Accounts/Authenticators/InteractiveUserAuthenticator.cs @@ -14,12 +14,9 @@ using Azure.Core; using Azure.Identity; - using Hyak.Common; - using Microsoft.Azure.Commands.Common.Authentication; using Microsoft.Azure.Commands.Common.Authentication.Abstractions; - using System; using System.Net; using System.Net.Sockets; @@ -52,8 +49,9 @@ public override Task Authenticate(AuthenticationParameters paramet var resource = interactiveParameters.Environment.GetEndpoint(interactiveParameters.ResourceId) ?? interactiveParameters.ResourceId; var scopes = AuthenticationHelpers.GetScope(onPremise, resource); var clientId = Constants.PowerShellClientId; + var claimsChallenge = interactiveParameters.ClaimsChallenge; - var requestContext = new TokenRequestContext(scopes, isCaeEnabled: true); + var requestContext = new TokenRequestContext(scopes, claims: claimsChallenge, isCaeEnabled: true); var authority = interactiveParameters.Environment.ActiveDirectoryAuthority; var options = new InteractiveBrowserCredentialOptions() @@ -63,7 +61,7 @@ public override Task Authenticate(AuthenticationParameters paramet TokenCachePersistenceOptions = tokenCacheProvider.GetTokenCachePersistenceOptions(), AuthorityHost = new Uri(authority), RedirectUri = GetReplyUrl(onPremise, interactiveParameters.PromptAction), - LoginHint = interactiveParameters.UserId, + LoginHint = interactiveParameters.UserId }; options.DisableInstanceDiscovery = interactiveParameters.DisableInstanceDiscovery ?? options.DisableInstanceDiscovery; var browserCredential = new InteractiveBrowserCredential(options); diff --git a/src/Accounts/Authenticators/InteractiveWamAuthenticator.cs b/src/Accounts/Authenticators/InteractiveWamAuthenticator.cs index ca3f10a71c8c..f6761990c5a6 100644 --- a/src/Accounts/Authenticators/InteractiveWamAuthenticator.cs +++ b/src/Accounts/Authenticators/InteractiveWamAuthenticator.cs @@ -43,8 +43,9 @@ public override Task Authenticate(AuthenticationParameters paramet var resource = interactiveParameters.Environment.GetEndpoint(interactiveParameters.ResourceId) ?? interactiveParameters.ResourceId; var scopes = AuthenticationHelpers.GetScope(onPremise, resource); var clientId = Constants.PowerShellClientId; + var claimsChallenge = interactiveParameters.ClaimsChallenge; - var requestContext = new TokenRequestContext(scopes, isCaeEnabled: true); + var requestContext = new TokenRequestContext(scopes, claims: claimsChallenge, isCaeEnabled: true); var authority = interactiveParameters.Environment.ActiveDirectoryAuthority; var options = new InteractiveBrowserCredentialBrokerOptions(WindowHandleUtilities.GetConsoleOrTerminalWindow()) 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}'");