jeroenheijmans/sample-angular-oauth2-oidc-with-auth-guards

Update sample to deal with stricter browser vendor rules for third-party cookies

jeroenheijmans opened this issue ยท 18 comments

Browser vendors are implementing increasingly strict rules around cookies.
This is increasingly problematic for SPA's with their Identity Server on a third-party domain.
Most notably problems occur if the "silent refresh via an iframe" technique is used.

This repository uses that technique currently, starting with a silentRefresh().
This will fire up an iframe to load an IDS page with noprompt, hoping cookies get sent along to so the IDS can see if a user is logged in.

Safari will block cookies from being sent, prompting a leading OAuth/OpenID community member to write "SPAs are dead!?".
In fact, if you fire up this sample repository on localhost, which talks to demo.identityserver.io (another domain!), and use it in Safari: you will notice that the silent refresh technique already fails!

Edit: starting with Chrome 83, they also have this behavior in Incognito mode.

Several prominent directions for solving this are available, and more too possibly:

  1. Run IDS on a subdomain where your SPA is also running (effectively making cookies first-party)
  2. Don't use "the iframe technique", but instead use refresh tokens with additional mitigations
  3. Switch to a BFF (Backend-for-frontend) setup

I'm not sure yet what route to pick for this sample, if any.

Wrote a more detailed blog post about this: https://infi.nl/nieuws/spa-necromancy/

Side effect of this is in Chrome incognito mode silent refresh won't work. But when we set "Allow All Cookies" in the settings, silent refresh will start working.

Aye, Chrome Incognito (and later on normal mode too) will exhibit this behavior, unfortunately.

Subdomain looks more secure than refresh tokens, right?

Unfortunately.... it depends. But in many cases I think the attack vectors for refresh tokens are more impactful than those for subdomains (e.g. the regular flow with redirects involved).

I see just one kind of attack, when eg Okta domain is available under login.example.com - user still able to register under this domain with Okta endpoints, even when only app registration is allowed.

I've thought about this issue for some time. And I've also had to deal with the issue in production for some time now. Current status quo seems to be that you just need the warning I already added. I think I will update that section to point to this blogpost where I explain all workarounds and leave it at that.

Yuk. I'm running into this myself now. Are there any more recent developments in solving this issue? Is there nowadays a usable server-side BFF that can act as a generic proxy for the necessary oauth requests?

I've not been involved in Angular apps recently, but in general in the OpenID/OAuth2 space for production projects at work I see a move towards dedicated BFF's that use a stack-specific OAuth2 integration. A paid Open Source solution for example is Duende's BFF Framework, and sometimes we use plain OpenID/OAuth libraries and a more custom BFF setup, part of the API projects.

Hope that helps? Otherwise I think a broader forum than this issue might be a better source of info, over here it' mostly just me supporting my sample's source code ๐Ÿ˜…

Right, this wouldn't just be an angular problem.

Am I understanding correctly that the problem could be solved by a rather generic proxy server that would run under the same domain where the SPA was served? Such that, when a request to the IDP needs to be made, it would be made to this proxy, then the proxy could simply forward the request to the IDP, and then return the response to the SPA client. Only, the OAuth2 specific part would be that it would have to rewrite the URLs/cookie-domain in the process (be they in the body content, or cookie domain). And as I think about it, it wouldn't have to proxy all requests, but only those that require cookies later on.

If so, I just have to think something like that has been developed is easily deployable. I didn't see anything in an initial search (not exactly knowing what to call it).

Much depends on the specifics of your situation, how much control you have over the moving parts, and if you're tied into a custom IDS (such as Keycloak or Duende) or a SAAS solution (such as Auth0 or Azure B2C). Regardless, a quick summary of the options I currently still see, taken from my blogpost on the subject:

  1. Host the IDS on a subdomain of your SPA. SAAS solutions often have paid support for this too.
  2. Proxy traffic to your IDS.
  3. BFF architecture, possibly with a framework or library.
  4. New browser API's.
  5. Refresh tokens.

I've personally had success with options 1, 3, and 5. Options 2 and 4 I've not tested in a production scenario, as the other scenarios were always more appealing.

Again, all of this is a bit out of scope for the repository where we're discussing this right now ๐Ÿ˜…

Yes agreed, out scope. :)

Hm... well my original goal for using angular-oaugh2-oidc rather than some products/3rd-party's specific libs (which often don't exist or aren't well maintained), was so that I could more easily choose/change the OIDC provider after the fact or in a pinch without having to re-write a bunch of stuff. So, this kinda stinks.

1, I have working, but don't to depend, long-term on that "happy accident" of an arrangement.
2. & 3. What's the difference? If auth is provided by some other service (i.e. OIDC), then what is 3. doing other than proxying calls to another service? Is it a different oauth2 flow?
4. I'm gonna have to read up on that. But still not ideal that it isnt' universal
5. Would work, but that, I expect, would tend to leave a lot of abandoned offline sessions on the IDP service. No? Or, is that a different concept? And I suppose the risk of a refresh token (living longer and being stored in local/session storage) is that it's subject to being stolen by malicious JS?

The difference between 2 and 3 is that with option 2 I meant something like a simple reverse proxy, and the BFF solution involves making authentication part of your API. The Duende-specific implementation I linked probably explains best.

The angular-oauth2-oidc library still has its original purpose by the way: if you rely on OpenID, this library can ensure the client side app talks to the server properly. That doesn't change. Just that if you opt to not use OpenID, then the library is of no real use.

Refresh tokens tend to have expiration. If you want to have a revokation mechanism on the server side then yes some trace of each refresh token might remain for a limited time, but it will be cleaned up too. Existing solutions can probably deal with that without a problem. But I'm not 100% sure how that works, you'll have to investigate.

I spent some time working a proxy server that would run on the same domain as where the SPA is hosted, but forward calls to the IDP, rewriting URLs and cookie domains as necessary.

However, I found very quickly this method not to work with keycloak: When you're redirected to keycloak's login page, when you submit the form, the response includes the 302 redirect but also a KEYCLOAK_IDENTITY cookie (along with others) which are, of course, associated with the IDPs domain, that must later be submitted in the /token request. And so later, using the iframe for a silent refresh, when the token request goes through the proxy, those cookies associate with the IDPs domain don't get submitted.

And if a cookie is the needed secure means to store such a session value needed for refreshing the id/access tokens, I image other IDPs must work the same way.

One would have to also proxy the login form itself, but that defeats the purpose of hosting the login form apart from the IDP and would probably not work with any social-mean type federated buttons on such a form, if proxied.

So 2 is probably a non-starter in general. :-/

Out of curiosity, what technology were you using for the proxy?

I personally had e.g. an NGINX reverse proxy or Azure Application Gateway in mind, where indeed all traffic would be proxied, and cookies should (I had presumed, perhaps incorrectly) also work. But then again, I've not used that for OpenID/OAuth2 purposes in practice.

Ah, no. I see what you mean there, but that would simply guard all requests forcing a login any time there's no token... Or rather, just put that forced login before fetching the SPA. But that wouldn't work well for a SPA which has authenticated and unauthenticated routes (as I do).

No, I wrote a little python script using async http requests. For instance, when the /openid-configuration request was made, I forwarded it to the IDP, then rewrote the URLs (except for the url for the login page itself) in the reponse body to make the original requester make all its calls back to the proxy.

Then, the rest was just to generically forward any GET and POST request as-is and return the response. That should have worked too if any cookies were set during one of those responses. But, alas, the all important session id cookie is set while on the IDP's own login page, so the proxy will never be able to proxy that.

I did have to tune the AuthConfig of the angular-oauth2-oidc to relax some of the checks, (like does the issuer of received tokens match the issuer in the original /openid-configuration response), but I was going to consider that impact later.

So, thinking more on this...

The problem is that the login page was fetched from an unrelated domain and so it's cookies are associated with that unrelated domain. If the custom proxy server above were to actually run from from a subdomain (e.g. auth.example.com) and then it also serve-up/proxy the login page too, then the cookies it sets (when returning to the SPA) would be related to yours.

I wonder if this browser change that's breaking the hidden iframe method would also block sending subdomain's cookies too?

If one were using auth0 with a custom domain (e.g. auth.example.com), is the iframe method broken in that scenario too?

The solution depends on the login page not submitting the form to the actual hosted domain (e.g. does it use window.location.origin? or does it use a hardcoded/configured value).