Skip to content

Commit 88588f8

Browse files
authored
[MSA] Pass the error messages from AADv2 to gallery (#6581)
1 parent fa40f54 commit 88588f8

8 files changed

Lines changed: 149 additions & 20 deletions

File tree

src/NuGetGallery/Authentication/Providers/AzureActiveDirectoryV2/AzureActiveDirectoryV2Authenticator.cs

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -45,10 +45,15 @@ public static class AuthenticationType
4545
public static readonly string Authority = "https://login.microsoftonline.com/{0}/v2.0";
4646

4747
private static string _callbackPath = "users/account/authenticate/return";
48-
private static HashSet<string> _errorMessageList = new HashSet<string> { "access_denied", "consent_required" };
4948
private static HashSet<string> _alternateSiteRootList;
5049
private const string SELECT_ACCOUNT = "select_account";
5150

51+
private static class ErrorQueryKeys
52+
{
53+
public const string Error = "error";
54+
public const string ErrorDescription = "errorDescription";
55+
}
56+
5257
/// <summary>
5358
/// The possible values returned by <see cref="V2Claims.ACR"/> claim, and also the possible token values to be sent
5459
/// for authentication to the common endpoint.
@@ -200,16 +205,23 @@ public override bool SupportsMultiFactorAuthentication()
200205
// error handling is done.
201206
private Task AuthenticationFailed(AuthenticationFailedNotification<OpenIdConnectMessage, OpenIdConnectAuthenticationOptions> notification)
202207
{
203-
if (_errorMessageList.Contains(notification.Exception.Message))
208+
// For every 'Challenge' sent to the external providers, we pass the 'State'
209+
// with the redirect uri where we intend to return after successful authentication.
210+
// Extract this "RedirectUri" property from this "State" object for redirecting on failed authentication as well.
211+
var authenticationProperties = GetAuthenticationPropertiesFromProtocolMessage(notification.ProtocolMessage, notification.Options);
212+
213+
// All authentication related exceptions will be handled.
214+
notification.HandleResponse();
215+
216+
// Pass the errors as the query string to show appropriate message to the user.
217+
var queryString = new List<KeyValuePair<string, string>>()
204218
{
205-
// For every 'Challenge' sent to the external providers, we store the 'State'
206-
// with the redirect uri where we intend to return after successful authentication.
207-
// Extract this "RedirectUri" property from this "State" object for redirecting on failed authentication as well.
208-
var authenticationProperties = GetAuthenticationPropertiesFromProtocolMessage(notification.ProtocolMessage, notification.Options);
219+
new KeyValuePair<string, string>(ErrorQueryKeys.Error, notification.ProtocolMessage.Error),
220+
new KeyValuePair<string, string>(ErrorQueryKeys.ErrorDescription, notification.ProtocolMessage.ErrorDescription)
221+
};
209222

210-
notification.HandleResponse();
211-
notification.Response.Redirect(authenticationProperties.RedirectUri);
212-
}
223+
var redirectUri = UriExtensions.AppendQueryStringToRelativeUri(authenticationProperties.RedirectUri, queryString);
224+
notification.Response.Redirect(redirectUri);
213225

214226
return Task.FromResult(0);
215227
}

src/NuGetGallery/Controllers/AuthenticationController.cs

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,12 @@
2222

2323
namespace NuGetGallery
2424
{
25+
public static class AuthenticationFailureErrors
26+
{
27+
public const string ACCESSS_DENIED = "access_denied";
28+
public const string CONSENT_REQUIRED = "consent_required";
29+
}
30+
2531
public partial class AuthenticationController
2632
: AppController
2733
{
@@ -172,7 +178,7 @@ public virtual async Task<ActionResult> SignIn(LogOnViewModel model, string retu
172178
authenticatedUser = loginUserDetails?.AuthenticatedUser;
173179
if (authenticatedUser == null)
174180
{
175-
return ExternalLinkExpired();
181+
return AuthenticationFailureOrExternalLinkExpired();
176182
}
177183

178184
usedMultiFactorAuthentication = loginUserDetails.UsedMultiFactorAuthentication;
@@ -264,7 +270,7 @@ public virtual async Task<ActionResult> Register(LogOnViewModel model, string re
264270
var result = await _authService.ReadExternalLoginCredential(OwinContext);
265271
if (result.ExternalIdentity == null)
266272
{
267-
return ExternalLinkExpired();
273+
return AuthenticationFailureOrExternalLinkExpired();
268274
}
269275

270276
usedMultiFactorAuthentication = result.LoginDetails?.WasMultiFactorAuthenticated ?? false;
@@ -497,15 +503,16 @@ public virtual async Task<ActionResult> LinkOrChangeExternalCredential(string re
497503
return SafeRedirect(returnUrl);
498504
}
499505

500-
public virtual async Task<ActionResult> LinkExternalAccount(string returnUrl)
506+
public virtual async Task<ActionResult> LinkExternalAccount(string returnUrl, string error = null, string errorDescription = null)
501507
{
502508
// Extract the external login info
503509
var result = await _authService.AuthenticateExternalLogin(OwinContext);
504510
if (result.ExternalIdentity == null)
505511
{
506512
// User got here without an external login cookie (or an expired one)
507513
// Send them to the logon action
508-
return ExternalLinkExpired();
514+
string errorMessage = GetAuthenticationFailureMessage(error, errorDescription);
515+
return AuthenticationFailureOrExternalLinkExpired(errorMessage);
509516
}
510517

511518
if (result.Authentication != null)
@@ -749,11 +756,11 @@ private string GetEmailAddressFromExternalLoginResult(AuthenticateExternalLoginR
749756
}
750757
}
751758

752-
private ActionResult ExternalLinkExpired()
759+
private ActionResult AuthenticationFailureOrExternalLinkExpired(string errorMessage = null)
753760
{
754761
// User got here without an external login cookie (or an expired one)
755762
// Send them to the logon action with a message
756-
TempData["Message"] = Strings.ExternalAccountLinkExpired;
763+
TempData["Message"] = string.IsNullOrEmpty(errorMessage) ? Strings.ExternalAccountLinkExpired : errorMessage;
757764
return Redirect(Url.LogOn(null, relativeUrl: false));
758765
}
759766

@@ -815,5 +822,24 @@ private ActionResult AuthenticationView(string viewName, LogOnViewModel existing
815822

816823
return View(viewName, existingModel);
817824
}
825+
826+
private string GetAuthenticationFailureMessage(string error, string errorDescription)
827+
{
828+
if (string.IsNullOrEmpty(error))
829+
{
830+
return Strings.AuthenticationFailure_UnkownError;
831+
}
832+
833+
switch (error)
834+
{
835+
case AuthenticationFailureErrors.ACCESSS_DENIED:
836+
case AuthenticationFailureErrors.CONSENT_REQUIRED:
837+
return Strings.ExternalAccountLinkExpired;
838+
default:
839+
return string.IsNullOrEmpty(errorDescription)
840+
? error
841+
: errorDescription;
842+
}
843+
}
818844
}
819845
}

src/NuGetGallery/Helpers/UriExtensions.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
33

44
using System;
5+
using System.Collections.Generic;
6+
using System.Web;
57

68
namespace NuGetGallery
79
{
@@ -57,5 +59,19 @@ public static Uri ToHttps(this Uri uri)
5759

5860
return uriBuilder.Uri;
5961
}
62+
63+
public static string AppendQueryStringToRelativeUri(string relativeUrl, IReadOnlyCollection<KeyValuePair<string, string>> queryStringCollection)
64+
{
65+
var tempUri = new Uri("http://www.nuget.org/");
66+
var builder = new UriBuilder(new Uri(tempUri, relativeUrl));
67+
var query = HttpUtility.ParseQueryString(builder.Query);
68+
foreach (var pair in queryStringCollection)
69+
{
70+
query[pair.Key] = pair.Value;
71+
}
72+
73+
builder.Query = query.ToString();
74+
return builder.Uri.PathAndQuery;
75+
}
6076
}
6177
}

src/NuGetGallery/Strings.Designer.cs

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

src/NuGetGallery/Strings.resx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1002,4 +1002,7 @@ The {1} Team</value>
10021002
<data name="UploadPackage_NotAcceptingPackagesWithLicense" xml:space="preserve">
10031003
<value>This package contains a &lt;license&gt; metadata which is currently not supported.</value>
10041004
</data>
1005+
<data name="AuthenticationFailure_UnkownError" xml:space="preserve">
1006+
<value>Unknown error!</value>
1007+
</data>
10051008
</root>

tests/NuGetGallery.Facts/Controllers/AuthenticationControllerFacts.cs

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1457,8 +1457,10 @@ public void WillCallChallengeAuthenticationForAADv2Provider()
14571457

14581458
public class TheLinkExternalAccountAction : TestContainer
14591459
{
1460-
[Fact]
1461-
public async Task GivenExpiredExternalAuth_ItRedirectsBackToLogOnWithExternalAuthExpiredMessage()
1460+
[Theory]
1461+
[InlineData("access_denied")]
1462+
[InlineData("consent_required")]
1463+
public async Task GivenExpiredExternalAuth_ItRedirectsBackToLogOnWithExternalAuthExpiredMessage(string error)
14621464
{
14631465
// Arrange
14641466
GetMock<AuthenticationService>(); // Force a mock to be created
@@ -1468,12 +1470,33 @@ public async Task GivenExpiredExternalAuth_ItRedirectsBackToLogOnWithExternalAut
14681470
.CompletesWith(new AuthenticateExternalLoginResult());
14691471

14701472
// Act
1471-
var result = await controller.LinkExternalAccount("theReturnUrl");
1473+
var result = await controller.LinkExternalAccount("theReturnUrl", error);
14721474

14731475
// Assert
14741476
VerifyExternalLinkExpiredResult(controller, result);
14751477
}
14761478

1479+
[Theory]
1480+
[InlineData("server_error", "The server encountered an unexpected error.")]
1481+
[InlineData("temporarily_unavailable", "The server is temporarily too busy to handle the request.")]
1482+
[InlineData("invalid_resource", "The target resource is invalid because either it does not exist, Azure AD cannot find it, or it is not correctly configured.")]
1483+
[InlineData("invalid_request", "Protocol error, such as a missing, required parameter.")]
1484+
public async Task GivenExpiredExternalAuth_ItRedirectsBackToLogOnWithPassedErrorMessage(string error, string errorDescription)
1485+
{
1486+
// Arrange
1487+
GetMock<AuthenticationService>(); // Force a mock to be created
1488+
var controller = GetController<AuthenticationController>();
1489+
GetMock<AuthenticationService>()
1490+
.Setup(x => x.AuthenticateExternalLogin(controller.OwinContext))
1491+
.CompletesWith(new AuthenticateExternalLoginResult());
1492+
1493+
// Act
1494+
var result = await controller.LinkExternalAccount("theReturnUrl", error, errorDescription);
1495+
1496+
// Assert
1497+
VerifyExternalLinkExpiredResult(controller, result, errorDescription);
1498+
}
1499+
14771500
[Fact]
14781501
public async Task GivenAssociatedLocalUser_ItCreatesASessionAndSafeRedirectsToReturnUrl()
14791502
{
@@ -2213,10 +2236,11 @@ public void VerifyShouldChallenge(string providerUsedForLogin, bool shouldChalle
22132236
}
22142237
}
22152238

2216-
public static void VerifyExternalLinkExpiredResult(AuthenticationController controller, ActionResult result)
2239+
public static void VerifyExternalLinkExpiredResult(AuthenticationController controller, ActionResult result, string expectedMessage = null)
22172240
{
2241+
expectedMessage = expectedMessage ?? Strings.ExternalAccountLinkExpired;
22182242
ResultAssert.IsRedirect(result, permanent: false, url: controller.Url.LogOn(relativeUrl: false));
2219-
Assert.Equal(Strings.ExternalAccountLinkExpired, controller.TempData["Message"]);
2243+
Assert.Equal(expectedMessage, controller.TempData["Message"]);
22202244
}
22212245

22222246
private static void EnableAllAuthenticators(AuthenticationService authService)

tests/NuGetGallery.Facts/NuGetGallery.Facts.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,7 @@
261261
<Compile Include="TestUtils\TestServiceUtility.cs" />
262262
<Compile Include="TestUtils\TestDataUtility.cs" />
263263
<Compile Include="TestUtils\TestUtility.cs" />
264+
<Compile Include="UriExtensionsFacts.cs" />
264265
<Compile Include="UrlHelperExtensionsFacts.cs" />
265266
<Compile Include="ViewModels\DependencySetsViewModelFacts.cs" />
266267
<Compile Include="ViewModels\DisplayPackageViewModelFacts.cs" />
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
using System.Collections.Generic;
5+
using System.Linq;
6+
using Xunit;
7+
8+
namespace NuGetGallery
9+
{
10+
public class UriExtensionsFacts
11+
{
12+
public class TheAppendQueryStringToRelativeUriMethod
13+
{
14+
[Theory]
15+
[InlineData("/abac/something?q1=123", "q2=ads&q3=4324", "/abac/something?q1=123&q2=ads&q3=4324")]
16+
[InlineData("/abac/something?q1=123", "q2=ads", "/abac/something?q1=123&q2=ads")]
17+
[InlineData("/abac/something", "q2=ads", "/abac/something?q2=ads")]
18+
[InlineData("/abac/something/", "", "/abac/something/")]
19+
public void ReturnsExpectedUrl(string url, string queryParameters, string expectedUrl)
20+
{
21+
// Arrange
22+
var queryString = string.IsNullOrEmpty(queryParameters)
23+
? new List<KeyValuePair<string, string>>()
24+
: queryParameters
25+
.Split('&')
26+
.Select(qv => qv.Split('='))
27+
.Select(qv => new KeyValuePair<string, string>(qv[0], qv[1]))
28+
.ToList();
29+
30+
// Act
31+
var returnUrl = UriExtensions.AppendQueryStringToRelativeUri(url, queryString);
32+
33+
// Assert
34+
Assert.Equal(expectedUrl, returnUrl);
35+
}
36+
}
37+
}
38+
}

0 commit comments

Comments
 (0)