-
Notifications
You must be signed in to change notification settings - Fork 598
Add AccessDeniedPath support to the OIDC/OAuth2/Twitter providers #1887
Conversation
Looks legit to me, assuming that this is more than just a Twitter thing, and actually a proper OIDC thing. @PinpointTownes - can you confirm that? @Tratcher - can you take a look and we can discuss further on Thursday if needed? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's a good start but has some of the same gaps we've wrestled with in prior designs.
- It loses the AuthProperties context so the app can't flow data across the login attempt.
- This is almost the same functionality as the ExceptionHandler middleware except it's tailored to one specific error and a lot less flexible in how it's handled.
- Events would be useful. OIDC has MessageReceived where you could already pre-empt this, OAuth and Twitter don't. Implementing the whole thing as a distinct event would be an alternative to the new property and might help with the AuthProperties and flexibility issues.
- We should talk about what support for this looks like in the Identity and OIDC templates. You'd want to populate options and land on a page that said "We're sorry, we really need that information to continue, click here to try again?"
@@ -126,6 +127,17 @@ await context.SignOutAsync(OpenIdConnectDefaults.AuthenticationScheme, new Authe | |||
return; | |||
} | |||
|
|||
if (context.Request.Path.Equals("/access-denied-from-remote")) | |||
{ | |||
await context.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What's the signout for? You never finished signing in.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Bad copy/paste, I'll remove it 😅
// access_denied errors are handled differently if AccessDeniedPath was populated. | ||
if (Options.AccessDeniedPath.HasValue && StringValues.Equals(error, "access_denied")) | ||
{ | ||
Response.Redirect(BuildRedirectUri(Options.AccessDeniedPath)); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This drops the AuthProperties so the app can't correlate which login attempt this was.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Does this need an event?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yep, these were the 2 open questions:
Should we flow the RedirectUri attached to the AuthenticationProperties (when present) up to the access denied endpoint?
Do we want a new event allowing to control how the redirection is applied?
// approve the authorization demand requested by the remote authorization server. | ||
// Since it's a frequent scenario (that is not caused by incorrect configuration), | ||
// access_denied errors are handled differently if AccessDeniedPath was populated. | ||
if (Options.AccessDeniedPath.HasValue && StringValues.Equals(error, "access_denied")) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Elsewhere (CookieAuth) we've avoided changing behavior based on the presence or absence of a PathString property. However we never did provide an alternate pattern.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm fine changing that if you have something better in mind.
/// 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. | ||
/// If this property is not populated, an exception is thrown if an access_denied | ||
/// response is returned by the remote authorization server to the callback endpoint. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The property is not set by default.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Will add.
src/Microsoft.AspNetCore.Authentication.OpenIdConnect/OpenIdConnectHandler.cs
Show resolved
Hide resolved
// Since it's a frequent scenario (that is not caused by incorrect configuration), | ||
// access_denied errors are handled differently if AccessDeniedPath was populated. | ||
if (Options.AccessDeniedPath.HasValue && | ||
string.Equals(authorizationResponse.Error, "access_denied", StringComparison.Ordinal)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why ordinal for OIDC and not OAuth?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Because OIDC uses string.Equals
while OAuth uses StringValues.Equals
, that doesn't offer an overload accepting an explicit comparison type. I'm fine changing that if you don't like it.
We could certainly flow the state, but in practice, the access denied path would likely point either to the home page or to the login page (so that the user can start a new authentication flow), not really an endpoint controlled by the OIDC/social handlers. So if we flowed the state, developers would have to instantiate the properties reader + the data protection stuff to deserialize it and get back the The most interesting data is probably the return URL, that we could flow like what the cookies handler does.
You've once told me that exceptions were not for flow control: it seems gross to throw (and log) an exception every time a user doesn't consent to the authorization demand 😄 The fact the handlers throws an
The existing alternative is to use the If you only want something generic, we already have that in place. My question was more about something very specific (typically an
Yep, pretty much what I had in mind. Flowing the |
|
I was proposing a new dedicated event for the access denied scenario and the ability to intercept the response prior to an exception being thrown. |
… handling logic to RemoteAuthenticationHandler
@Tratcher I added the If the event is not marked as skipped or handled, then the RAT determines whether Let me know if you like this approach. If you like it, I'll add tests for the new events. |
Now, the remaining question is: what context information do we want to flow up to the access denied endpoint? Just the |
I took a look at how the different Security components handle URIs and woooo, things are insanely inconsistent:
|
@@ -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; |
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I copied it from the cookie handler: https://github.com/aspnet/Security/blob/master/src/Microsoft.AspNetCore.Authentication.Cookies/CookieAuthenticationHandler.cs#L425
Should I also update the cookie handler?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sure. FYI: #1730
Any guess what the WsFed variant of this would look like for https://github.com/aspnet/Security/issues/1891? |
@@ -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; |
There was a problem hiding this comment.
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.
// 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( |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 Exception
s in 2.0. I'd also have preferred a proper AuthentiationError
, but I guess that's too late for that.
There was a problem hiding this comment.
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
.
There was a problem hiding this comment.
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.
I'll have to re-read the WS-Fed specification as I don't remember if there's anything specific for access denied callbacks. |
I read it during the weekend (it's always a terrible experience!) and I didn't find any trace of such a thing (AFAICT, there's even no error callback mechanism). Just to highlight the fact it's not specific to the built-in OIDC/OAuth2/Twitter handlers, I'd likely use this feature in the OpenID 2.0 aspnet-contrib handler (where access denied errors are signaled using |
…r.HandleAccessDeniedErrorAsync()
@Tratcher based on your suggestion, I added a protected virtual Once the final design is approved, I'll add some tests for the new event. |
// 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 await HandleAccessDeniedErrorAsync(properties); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@Tratcher pretty much like RemoteFailure
, the fact AccessDenied
is protocol-agnostic prevents us from flowing things like the OIDC authorization response, which may limit extensibility. Not sure it's really a problem, but let me know if you'd prefer having multiple (package-specific) AccessDenied
events instead of a global one.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There's no other parameters in the message we'd be missing out on, right?
The most awkward bit seems to be having to downcast the Options if you wanted anything specific. Or could you flow the generic TOptions? I remember trying that and having lots of issues before.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There's no other parameters in the message we'd be missing out on, right?
We could update HandleAccessDeniedErrorAsync()
to take a message string containing the error/error_description/error_uri if you think it could be useful. Other than these parameters, nope, nothing else.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I can see those being useful if properly populated. It seems like you'd want to flow a generic property bag to the event rather than hardcoded parameters. I guess you could pass them through AuthProperties or some HttpContext field if you really needed to.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The most awkward bit seems to be having to downcast the Options if you wanted anything specific. Or could you flow the generic TOptions? I remember trying that and having lots of issues before.
Yep. RemoteAuthenticationEvents
is not generic so we can't easily flow TOptions
. We could have a generic AccessDeniedContext<TOptions>
class derived from AccessDeniedContext
but it wouldn't help much as it would still be exposed as AccessDeniedContext
by the events class.
It seems like you'd want to flow a generic property bag to the event rather than hardcoded parameters. I guess you could pass them through AuthProperties or some HttpContext field if you really needed to.
We can certainly do that, but I'm a bit reluctant as it would be fairly inconsistent (we don't do that anywhere else).
The design looks good. Go ahead and finish the tests. |
…ter from the AccessDenied event
I just pushed a commit allowing to customize the access denied path/return URL/return URL parameter from the |
Yup. It just has a better name and you'll be able to set manually it if even if |
@Tratcher assuming this PR won't be part of 2.2 (it's too late for that, right?), we should consider setting |
Yeah, this is coming in too late for 2.2. If there were to be a default I'd rather it go to the error page, or better yet a new login error page. That's something we'll need to work out with @blowdart and the templates. |
Not a huge fan of the default error page option as it gives absolutely no indication of what's wrong with the request, which is precisely what this PR tries to avoid. A separate "try again" page is probably a reasonable tradeoff.
@blowdart care to chime in or you're too busy posting random stuff on Twitter? |
@Tratcher - can you go ahead and merge this? Also please log a bug in the Docs repo to suggest adding/updating docs regarding this new feature. @PinpointTownes - thank you! |
My pleasure 😄 |
Fixes #710 (see also #1582 (comment) for more information).
This PR introduces native - but opt-in, to avoid a behavioral breaking change - support for automatic redirection for
access_denied
(OIDC/OAuth2) anddenied
(Twitter) errors.Open questions:
RedirectUri
attached to theAuthenticationProperties
(when present) up to the access denied endpoint?Note: I didn't add a test for OIDC because there's no existing test for the callback part (which is crazy, but hem...). Looks like I'm also going to contribute to the technical debt 😄
/cc @Eilon @Tratcher