nodeSolidServer/oidc-auth-manager

Respect incoming returnToUrl from query string

Closed this issue · 21 comments

After nodeSolidServer/node-solid-server#648, node-solid-server will send the returnToUrl through a query string rather than using sessions (here's why).

While oidc-auth-manager initially “sees” this query string, it gets lost on the way and is no longer present as redirect_uri later on.

Tell me more - what's the behavior you'd like to see?

So src/handlers/select-provider-request.js will receive returnToUrl through req.query.returnToUrl, and needs to preserve that on redirect to providerAuthUrl.

authUrlFor will need to look into req.query.returnToUrl rather than this.session. I.e., authUrlFor is now implicitly using the key returnToUrl in this.session as a contract.

Ahh I think I see. K!

timbl commented

This I gather is the issue where currently you can go to (eg) https://timbl.com/timbl/Public/Test/2018/RWWCrew%20only/Meeting%20on%20Apples/index.ttl#this go through the login popups, and end being redirected to https://timbl.com/timbl/Public/Test/2018/RWWCrew%20only/Meeting%20on%20Apples/index.ttl without the trailing #this . This bug has been around for a long time. longer than this issue...

@RubenVerborgh Here's some more thoughts on the issue here.

Preventing malicious post-auth redirects is one of the core goals of the OIDC protocol. This is why any values in redirect_uri have to be pre-registered by the client. As in, when a Solid POD does a dynamic registration to the oidc IDP, it says "here is a valid list of redirect URIs that I expect". And the IDP checks against that list, and flags an error for an arbitrary value of redirect_uri.

So that's why the returnToUrl was being stored in session. Because there's no good way to pre-register all possible return-to URLs.

But I agree, at the moment this is breaking, because the server is not seeing the hash fragment, and so can't store it in the session. So I think the solution would be a modification of your proposal -- have the OIDC Manager look in req.query.returnToUrl, and store that in the session. (So that the post-auth callback can resume from there.)

when a Solid POD does a dynamic registration to the oidc IDP, it says "here is a valid list of redirect URIs that I expect". And the IDP checks against that list, and flags an error for an arbitrary value of redirect_uri.

And it can't do that at the point of redirection?

So that's why the returnToUrl was being stored in session.

Can we think of a scenario where someone can do something evil when returnToUrl is not stored in the session?

So I think the solution would be a modification of your proposal -- have the OIDC Manager look in req.query.returnToUrl, and store that in the session.

My main worry here is clean contracts: it should be clear how the URL is passed from A to B and how it can be maintained throughout the flow. Right now, the contract of it being in session was undocumented, and the redirect_uri values weren't really doing anything.

Also, I'd like to avoid using session as much as possible, because such global state makes things really difficult to reason about. Another option would be to send an encrypted version of redirect_uri as a URL parameter or a hidden HTML field, so the client cannot modify it (but at least the client maintains state then). But for my understanding, I'd like to first see a scenario where the client modifying it could indeed pose a problem.

I absolutely agree -- we should document and clarify the contract (and the usage of session).

As for why session usage is required at all (why not use redirect_uri) - the OAuth 2.0 Threat Model and Security Considerations doc can shed some light on it:

The TLDR; is -- flexible/open redirect_uri usage can lead to phishing & access tokens being stolen.

Here's a way to think about the contract / division of responsibilities. The redirect_uri is in charge of authenticating the client, and deals with secure handoff between zones of trust. So, it contains usually just one value, something like https://example.com/callback.

The session.returnToUrl has to do with -- after the OIDC workflow completes (and you're back at /callback), how to resume whatever URL within the zone of trust the user was trying to access. So that's more about UX / user convenience. Does that make more sense?

The only other way to do returnToUrl in OAuth2/OIDC without using an express session is to use the state parameter. Basically, the state parameter (which is kept through the OIDC redirect) is there for the client to implement a session-like mechanism (if they're not using session cookies or something).

So another option would be to encode (via JWT or something) the redirectToUrl into the state parameter. And the post-auth /callback handler can look inside that (instead of session.returnToUrl). I'm not quite clear that this method is preferable to just using session. What do you think?

Hi Dmitri,

Thanks for clarifying.

The redirect_uri is in charge of authenticating the client, and deals with secure handoff between zones of trust.

In the case of Solid, it can be any URL on the server though (including URLs with hashes, which caused this issue).

What we need is that /api/auth/select-provider only accepts a returnToUrl whose domain is equal to the current domain. E.g., https://abc/api/auth/select-provider?returnToUrl=… should validate that the returnToUrl value starts with https://abc/. Then we don't need session to store that URL on server A.

And then we just pass that URL to the OIDC endpoint on server B using the regular way (i.e., the JWS-encoded request URL parameter), and we're all good, right?

Yeah, that sounds good! (If I understand you correctly.)
The changes would be:

  1. We validate returnToUrl in the select-provider handler, to make sure it's on this POD.
  2. We pass the returnToUrl (I'm assuming you meant encoded the state parameter, yeah?) to the OIDC endpoint
  3. We tweak the auth-callback-request handler to decode the state param, and redirect the user to the returnToUrl?

How it currently works, with a concrete example:

{
  "redirect_uri": "https://drive.verborgh.org/common/popup.html",
  "display": "page",
  "nonce": "blabla",
  "key": {
    "alg": "RS256",
    "e": "AQAB",
    "ext": true,
    "key_ops": [
      "verify"
    ],
    "kty": "RSA",
    "n": "blabla
  }
}

We pass the returnToUrl (I'm assuming you meant the state parameter, yeah?) to the OIDC endpoint

So given the above, I did mean the request parameter.

Note that we don't do any registering as specified in https://tools.ietf.org/html/rfc6819#section-5.2.3.5. I.e., /common/popup.html isn't more special than any other URL.

Unless we want to make it special, but then we should register that URL when registering the client. And then we need to use state instead, so popup.html can read from there.

Quick question, before I go into explanation. Are you sure that in the example above, the "redirect_uri": property in the request param is "https://drive.verborgh.org/common/popup.html"? (As in, you decoded the JWS and that's what it was?)

Yes, I decoded it.

Ok, so in this particular case, popup.html is the Relying Party client. So it does actually dynamically register its own url with solid.community. If you clear the local storage & cookies, you should be able to observe the popup’s registration request.

So it does actually dynamically register its own url with solid.community.

From the client side then? Because the server doesn't know about popup.html.

But if that's the case, then any HTML page on the server can register itself with solid.community. So how meaningful is a registration then?

Also, note that authentication can and do happen from other pages, such as in nodeSolidServer/node-solid-server#571. Do they just all register themselves?

Do they just all register themselves?

Yes, any app (ie other pages) can dynamically register itself.

So how meaningful is a registration then?

Sure, that's a great question. :) So this is where we get into the disconnect between theory and current implementation.

In theory, the combination of dynamic registration + strict redirect_uri validation is still meaningful, when combined with the user consent screen. As in, the first time the user authenticates to that app, they are presented with an "Are you sure?" screen by the pod, which should include the pre-registered redirect uris so that the user can check that there's no dodgy redirects there. But beyond that first time, the server remembers the user's permission and continues the strict redirect_uri checking behind the scenes, and guards against phishing, malicious redirects etc.

In practice, the user consent page hasn't been implemented yet on Solid (we're skipping that step for various convenience reasons, but we should actually implement it at some point).

All that said, I still think that the right way (in terms of appropriate contract / division of responsibility) to solve this issue is to either a) Continue storing the returnToUrl in express session as currently, except fix it so that the hash fragment gets stored too (from the query param), or b) Encode the returnToUrl in the opaque state param, and have the client redirect the user after the auth flow ends.

As in, the first time the user authenticates to that app, they are presented with an "Are you sure?" screen by the pod, which should include the pre-registered redirect uris so that the user can check that there's no dodgy redirects there.

Good point—but does that make sense as long it's all on the same server? Because the session is shared across the whole server anyway.

a) Continue storing the returnToUrl in express session as currently, except fix it so that the hash fragment gets stored too (from the query param)

This would be my current preference. node-solid-server will be handing returnToUrl as a query parameter to /api/auth/select-provider (corresponding to SelectProviderRequest.get), which should extract it from there and verify the hostname is the same as the current host (and if not, block it).

does that make sense as long it's all on the same server? Because the session is shared across the whole server anyway.

Ahh, you're right. When we do end up implementing the consent screen, it should probably be skipped for apps on the same server.

This would be my current preference

Ok, copy that.

Implemented in #19.