Skip to content
This repository has been archived by the owner on Dec 13, 2018. It is now read-only.

Add AccessDeniedPath support to the OIDC/OAuth2/Twitter providers #1887

Merged
merged 9 commits into from Nov 15, 2018
11 changes: 11 additions & 0 deletions samples/OpenIdConnectSample/Startup.cs
Expand Up @@ -63,6 +63,7 @@ public void ConfigureServices(IServiceCollection services)
o.ResponseType = OpenIdConnectResponseType.CodeIdToken;
o.SaveTokens = true;
o.GetClaimsFromUserInfoEndpoint = true;
o.AccessDeniedPath = "/access-denied-from-remote";

o.ClaimActions.MapAllExcept("aud", "iss", "iat", "nbf", "exp", "aio", "c_hash", "uti", "nonce");

Expand Down Expand Up @@ -126,6 +127,16 @@ await context.SignOutAsync(OpenIdConnectDefaults.AuthenticationScheme, new Authe
return;
}

if (context.Request.Path.Equals("/access-denied-from-remote"))
{
await WriteHtmlAsync(response, async res =>
{
await res.WriteAsync($"<h1>Access Denied error received from the remote authorization server</h1>");
await res.WriteAsync("<a class=\"btn btn-default\" href=\"/\">Home</a>");
});
return;
}

if (context.Request.Path.Equals("/Account/AccessDenied"))
{
await context.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
Expand Down
13 changes: 12 additions & 1 deletion src/Microsoft.AspNetCore.Authentication.OAuth/OAuthHandler.cs
Expand Up @@ -63,6 +63,17 @@ protected override async Task<HandleRequestResult> HandleRemoteAuthenticateAsync
var error = query["error"];
if (!StringValues.IsNullOrEmpty(error))
{
// Note: access_denied errors are special protocol errors indicating the user didn't
kevinchalet marked this conversation as resolved.
Show resolved Hide resolved
// approve the authorization demand requested by the remote authorization server.
// Since it's a frequent scenario (that is not caused by incorrect configuration),
// denied errors are handled differently using a special "access denied" exception.
// Visit https://tools.ietf.org/html/rfc6749#section-4.1.2.1 for more information.
if (StringValues.Equals(error, "access_denied"))
{
return HandleRequestResult.Fail(new AccessDeniedException(
"Access was denied by the resource owner or by the remote server."), properties);
}

var failureMessage = new StringBuilder();
failureMessage.Append(error);
var errorDescription = query["error_description"];
Expand Down Expand Up @@ -194,7 +205,7 @@ protected override async Task HandleChallengeAsync(AuthenticationProperties prop
{
if (string.IsNullOrEmpty(properties.RedirectUri))
{
properties.RedirectUri = CurrentUri;
properties.RedirectUri = OriginalPathBase + Request.Path + Request.QueryString;
Copy link
Contributor Author

Choose a reason for hiding this comment

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

@Tratcher stricto sensu, these changes are not required, but if we end up flowing the ReturnUrl up to the access denied endpoint, we'll likely want it to be captured as a relative URL so helpers like IUrlHelper.IsLocalUrl and RedirectToLocal() work correctly (they don't deal with absolute URLs, even if the domain matches the current one).

Copy link
Member

Choose a reason for hiding this comment

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

This should be OriginalPathBase + OriginalPath.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Copy link
Member

Choose a reason for hiding this comment

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

Sure. FYI: #1730

}

// OAuth2 10.12 CSRF
Expand Down
6 changes: 2 additions & 4 deletions src/Microsoft.AspNetCore.Authentication.OAuth/OAuthOptions.cs
Expand Up @@ -3,11 +3,9 @@

using System;
using System.Collections.Generic;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.OAuth;
using Microsoft.AspNetCore.Authentication.OAuth.Claims;
using Microsoft.AspNetCore.Http.Authentication;
using System.Globalization;
using Microsoft.AspNetCore.Authentication.OAuth.Claims;
using Microsoft.AspNetCore.Http;

namespace Microsoft.AspNetCore.Authentication.OAuth
{
Expand Down
Expand Up @@ -186,7 +186,7 @@ public async virtual Task SignOutAsync(AuthenticationProperties properties)
properties.RedirectUri = BuildRedirectUriIfRelative(Options.SignedOutRedirectUri);
if (string.IsNullOrWhiteSpace(properties.RedirectUri))
{
properties.RedirectUri = CurrentUri;
properties.RedirectUri = OriginalPathBase + Request.Path + Request.QueryString;
}
}
Logger.PostSignOutRedirect(properties.RedirectUri);
Expand Down Expand Up @@ -312,7 +312,7 @@ protected override async Task HandleChallengeAsync(AuthenticationProperties prop
// 2. CurrentUri if RedirectUri is not set)
if (string.IsNullOrEmpty(properties.RedirectUri))
{
properties.RedirectUri = CurrentUri;
properties.RedirectUri = OriginalPathBase + Request.Path + Request.QueryString;
}
Logger.PostAuthenticationLocalRedirect(properties.RedirectUri);

Expand Down Expand Up @@ -520,6 +520,17 @@ protected override async Task<HandleRequestResult> HandleRemoteAuthenticateAsync
// if any of the error fields are set, throw error null
if (!string.IsNullOrEmpty(authorizationResponse.Error))
{
// Note: access_denied errors are special protocol errors indicating the user didn't
kevinchalet marked this conversation as resolved.
Show resolved Hide resolved
// approve the authorization demand requested by the remote authorization server.
// Since it's a frequent scenario (that is not caused by incorrect configuration),
// denied errors are handled differently using a special "access denied" exception.
// Visit https://tools.ietf.org/html/rfc6749#section-4.1.2.1 for more information.
if (string.Equals(authorizationResponse.Error, "access_denied", StringComparison.Ordinal))
{
return HandleRequestResult.Fail(new AccessDeniedException(
Copy link
Member

@Tratcher Tratcher Oct 24, 2018

Choose a reason for hiding this comment

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

I'd rather invoke the AccessDenied event here than create a new AccessDeniedException type that's never actually thrown.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

That's possible, but it means we'll have the same (duplicated) logic in all the handlers. It will also be a little weird, as RemoteAuthenticationEvents/RemoteAuthenticationOptions will have events/options for something that is actually implemented in subclasses.

It's unfortunate, but errors are represented by Exceptions in 2.0. I'd also have preferred a proper AuthentiationError, but I guess that's too late for that.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

If you don't like having a new exception for that, I guess we could use Exception and store a boolean marker in Exception.Data.

Copy link
Member

Choose a reason for hiding this comment

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

Calling the event in a subclass is fine, OAuth CreatingTicket is a lot like that. You could share the code via a protected method if you like.

"Access was denied by the resource owner or by the remote server."), properties);
}

return HandleRequestResult.Fail(CreateOpenIdConnectProtocolException(authorizationResponse, response: null), properties);
}

Expand Down
Expand Up @@ -55,12 +55,14 @@ protected override async Task<HandleRequestResult> HandleRemoteAuthenticateAsync

var properties = requestToken.Properties;

// REVIEW: see which of these are really errors

var denied = query["denied"];
if (!StringValues.IsNullOrEmpty(denied))
{
return HandleRequestResult.Fail("The user denied permissions.", properties);
// Note: denied errors are special protocol errors indicating the user didn't
// approve the authorization demand requested by the remote authorization server.
// Since it's a frequent scenario (that is not caused by incorrect configuration),
// denied errors are handled differently using a special "access denied" exception.
return HandleRequestResult.Fail(new AccessDeniedException("The user denied permissions."), properties);
}

var returnedToken = query["oauth_token"];
Expand Down Expand Up @@ -130,7 +132,7 @@ protected override async Task HandleChallengeAsync(AuthenticationProperties prop
{
if (string.IsNullOrEmpty(properties.RedirectUri))
{
properties.RedirectUri = CurrentUri;
properties.RedirectUri = OriginalPathBase + Request.Path + Request.QueryString;
}

// If CallbackConfirmed is false, this will throw
Expand Down
Expand Up @@ -2,8 +2,8 @@
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.

using System;
using System.Security.Claims;
using System.Globalization;
using System.Security.Claims;
using Microsoft.AspNetCore.Authentication.OAuth.Claims;
using Microsoft.AspNetCore.Http;

Expand Down
Expand Up @@ -83,7 +83,7 @@ protected override async Task HandleChallengeAsync(AuthenticationProperties prop
// Save the original challenge URI so we can redirect back to it when we're done.
if (string.IsNullOrEmpty(properties.RedirectUri))
{
properties.RedirectUri = CurrentUri;
properties.RedirectUri = OriginalPathBase + Request.Path + Request.QueryString;
}

var wsFederationMessage = new WsFederationMessage()
Expand Down
@@ -0,0 +1,33 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.

using Microsoft.AspNetCore.Http;

namespace Microsoft.AspNetCore.Authentication
{
/// <summary>
/// Provides access denied failure context information to handler providers.
/// </summary>
public class AccessDeniedContext : HandleRequestContext<RemoteAuthenticationOptions>
{
public AccessDeniedContext(
HttpContext context,
AuthenticationScheme scheme,
RemoteAuthenticationOptions options,
AccessDeniedException failure)
: base(context, scheme, options)
{
Failure = failure;
}

/// <summary>
/// User friendly error message for the error.
/// </summary>
public AccessDeniedException Failure { get; set; }

/// <summary>
/// Additional state values for the authentication session.
/// </summary>
public AuthenticationProperties Properties { get; set; }
}
}
Expand Up @@ -8,12 +8,18 @@ namespace Microsoft.AspNetCore.Authentication
{
public class RemoteAuthenticationEvents
{
public Func<AccessDeniedContext, Task> OnAccessDenied { get; set; } = context => Task.CompletedTask;
public Func<RemoteFailureContext, Task> OnRemoteFailure { get; set; } = context => Task.CompletedTask;

public Func<TicketReceivedContext, Task> OnTicketReceived { get; set; } = context => Task.CompletedTask;

/// <summary>
/// Invoked when there is a remote failure
/// Invoked when an access denied error was returned by the remote server.
/// </summary>
public virtual Task AccessDenied(AccessDeniedContext context) => OnAccessDenied(context);

/// <summary>
/// Invoked when there is a remote failure.
/// </summary>
public virtual Task RemoteFailure(RemoteFailureContext context) => OnRemoteFailure(context);

Expand Down
@@ -0,0 +1,16 @@
using System;

namespace Microsoft.AspNetCore.Authentication
{
/// <summary>
/// Represents a special exception thrown when an external
/// authorization demand was denied by the remote server.
/// </summary>
public class AccessDeniedException : Exception
{
public AccessDeniedException(string message)
: base(message)
{
}
}
}
32 changes: 21 additions & 11 deletions src/Microsoft.AspNetCore.Authentication/LoggingExtensions.cs
Expand Up @@ -7,17 +7,18 @@ namespace Microsoft.Extensions.Logging
{
internal static class LoggingExtensions
{
private static Action<ILogger, string, Exception> _authSchemeAuthenticated;
private static Action<ILogger, string, Exception> _authSchemeNotAuthenticated;
private static Action<ILogger, string, string, Exception> _authSchemeNotAuthenticatedWithFailure;
private static Action<ILogger, string, Exception> _authSchemeChallenged;
private static Action<ILogger, string, Exception> _authSchemeForbidden;
private static Action<ILogger, string, Exception> _remoteAuthenticationError;
private static Action<ILogger, Exception> _signInHandled;
private static Action<ILogger, Exception> _signInSkipped;
private static Action<ILogger, string, Exception> _correlationPropertyNotFound;
private static Action<ILogger, string, Exception> _correlationCookieNotFound;
private static Action<ILogger, string, string, Exception> _unexpectedCorrelationCookieValue;
private static readonly Action<ILogger, string, Exception> _authSchemeAuthenticated;
private static readonly Action<ILogger, string, Exception> _authSchemeNotAuthenticated;
private static readonly Action<ILogger, string, string, Exception> _authSchemeNotAuthenticatedWithFailure;
private static readonly Action<ILogger, string, Exception> _authSchemeChallenged;
private static readonly Action<ILogger, string, Exception> _authSchemeForbidden;
private static readonly Action<ILogger, string, Exception> _remoteAuthenticationError;
private static readonly Action<ILogger, Exception> _signInHandled;
private static readonly Action<ILogger, Exception> _signInSkipped;
private static readonly Action<ILogger, string, Exception> _correlationPropertyNotFound;
private static readonly Action<ILogger, string, Exception> _correlationCookieNotFound;
private static readonly Action<ILogger, string, string, Exception> _unexpectedCorrelationCookieValue;
private static readonly Action<ILogger, Exception> _accessDeniedError;

static LoggingExtensions()
{
Expand Down Expand Up @@ -65,6 +66,10 @@ static LoggingExtensions()
eventId: 16,
logLevel: LogLevel.Warning,
formatString: "The correlation cookie value '{CorrelationCookieName}' did not match the expected value '{CorrelationCookieValue}'.");
_accessDeniedError = LoggerMessage.Define(
eventId: 17,
logLevel: LogLevel.Information,
formatString: "Access was denied by the resource owner or by the remote server.");
}

public static void AuthenticationSchemeAuthenticated(this ILogger logger, string authenticationScheme)
Expand Down Expand Up @@ -121,5 +126,10 @@ public static void UnexpectedCorrelationCookieValue(this ILogger logger, string
{
_unexpectedCorrelationCookieValue(logger, cookieName, cookieValue, null);
}

public static void AccessDeniedError(this ILogger logger)
{
_accessDeniedError(logger, null);
}
}
}
Expand Up @@ -5,6 +5,7 @@
using System.Security.Cryptography;
using System.Text.Encodings.Web;
using System.Threading.Tasks;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;

Expand Down Expand Up @@ -80,6 +81,46 @@ public virtual async Task<bool> HandleRequestAsync()

if (exception != null)
{
if (exception is AccessDeniedException ex)
{
Logger.AccessDeniedError();
var accessDeniedContext = new AccessDeniedContext(Context, Scheme, Options, ex)
{
Properties = properties
};
await Events.AccessDenied(accessDeniedContext);

if (accessDeniedContext.Result != null)
{
if (accessDeniedContext.Result.Handled)
{
return true;
}
else if (accessDeniedContext.Result.Skipped)
{
return false;
}
else if (accessDeniedContext.Result.Failure != null)
{
throw new Exception("An error was returned from the AccessDenied event.", accessDeniedContext.Result.Failure);
}
}

// If an access denied endpoint was specified, redirect the user agent.
// Otherwise, invoke the RemoteFailure event for further processing.
if (Options.AccessDeniedPath.HasValue)
{
string uri = Options.AccessDeniedPath;
if (!string.IsNullOrEmpty(Options.ReturnUrlParameter) && !string.IsNullOrEmpty(properties?.RedirectUri))
{
uri = QueryHelpers.AddQueryString(uri, Options.ReturnUrlParameter, properties.RedirectUri);
}
Response.Redirect(uri);

return true;
}
}

Logger.RemoteAuthenticationError(exception.Message);
var errorContext = new RemoteFailureContext(Context, Scheme, Options, exception)
{
Expand Down
Expand Up @@ -89,6 +89,22 @@ public override void Validate()
/// </summary>
public PathString CallbackPath { get; set; }

/// <summary>
/// Gets or sets the optional path the user agent is redirected to if the user
/// doesn't approve the authorization demand requested by the remote server.
/// This property is not set by default. In this case, an exception is thrown
/// if an access_denied response is returned by the remote authorization server.
/// </summary>
public PathString AccessDeniedPath { get; set; }

/// <summary>
/// Gets or sets the name of the parameter used to convey the original location
/// of the user before the remote challenge was triggered up to the access denied page.
/// This property is only used when the <see cref="AccessDeniedPath"/> is explicitly specified.
/// </summary>
// Note: this deliberately matches the default parameter name used by the cookie handler.
public string ReturnUrlParameter { get; set; } = "ReturnUrl";

/// <summary>
/// Gets or sets the authentication scheme corresponding to the middleware
/// responsible of persisting user's identity after a successful authentication.
Expand Down
4 changes: 2 additions & 2 deletions test/Microsoft.AspNetCore.Authentication.Test/GoogleTests.cs
Expand Up @@ -378,7 +378,7 @@ public async Task ReplyPathWithErrorFails(bool redirect)
} : new OAuthEvents();
});
var sendTask = server.SendAsync("https://example.com/signin-google?error=OMG&error_description=SoBad&error_uri=foobar&state=protected_state",
".AspNetCore.Correlation.Google.corrilationId=N");
".AspNetCore.Correlation.Google.correlationId=N");
if (redirect)
{
var transaction = await sendTask;
Expand Down Expand Up @@ -1205,7 +1205,7 @@ public AuthenticationProperties Unprotect(string protectedText)
Assert.Equal("protected_state", protectedText);
var properties = new AuthenticationProperties(new Dictionary<string, string>()
{
{ ".xsrf", "corrilationId" },
{ ".xsrf", "correlationId" },
{ "testkey", "testvalue" }
});
properties.RedirectUri = "http://testhost/redirect";
Expand Down