Help setting up OIDC for a backend API + frontend SPA

Sorry for the wall of text, but I’m struggling to figure out how to solve this, and I figured I’d provide as much detail as I can.

I’ve got a project using a Django backend, with Django Rest Framework to serve an API, and a Vue.js frontend SPA to consume the API. I’m running into some kind of CORS issue during authentication.

I’ve been using mozilla-django-oidc to implement the Authorization Code flow with Okta. This works fine pretty much out of the box, and if I navigate to the API in my browser, I can login to Okta and I get a Django session. I’ve also enabled SessionAuthentication for DRF, which allows the same session cookies generated by Django to be accessible by the SPA (both SPA and API are on the same domain), provided I login first directly through the API. This all works fine until the id token expires. In Django, when the id token expires, I get a redirect to https://mydomain.okta.com/oauth2/v1/authorize?..., the Authorization Code flow completes and I get sent on through to the originally requested page. Where things fail is in an ajax request from the SPA to the API with an expired id token. I get the same redirect, but this time it fails due to CORS.

Access to XMLHttpRequest at 'https://mydomain.okta.com/oauth2/v1/authorize?response_type=code&client_id=X&redirect_uri=http%3A%2F%2F127.0.0.1%3A8000%2Foidc%2Fcallback%2F&state=X&scope=openid+email+profile&prompt=none&nonce=X' (redirected from 'http://127.0.0.1:8080/api/X') from origin 'http://127.0.0.1:8080' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource.

I’ve tried to identify why it’s failing.

On local development, I’m running my API on 127.0.0.1:8000 and my SPA on 127.0.0.1:8080, so clearly the origins don’t match. I have Vue setup with a proxy so it looks like requests are coming from 8080, but the redirect_uri in the request to Okta is still using 8000.

When I deploy to a test server, I’m using docker containers for the API and SPA and a reverse proxy to route requests and also for SSL. In this case, the API and SPA have the same origin (I think). Yet I still get the same error message.

Access to XMLHttpRequest at 'https://mydomain.okta.com/oauth2/v1/authorize?response_type=code&client_id=X&redirect_uri=http%3A%2F%2Fmydomain.com%2Foidc%2Fcallback%2F&state=X&scope=openid+email+profile&prompt=none&nonce=X' (redirected from 'https://mydomain.com/api/X') from origin 'https://mydomain.com' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource.

If you notice, the redirect_uri is http, not https. I suspect that is why this is failing. Though I’m not entirely confident because if I navigate my browser to the API, I am on https, but the redirect_uri is still http, and it still successfully authenticates.

Any insight would be really helpful.

  • What am I doing wrong or missing here?
  • Am I approaching the authentication flow all wrong for an API+SPA app? Should I do authentication on the SPA instead? How does the API then know who’s logged in?

compare your initial request, when you just log into your application with that one which is failing to see the difference

Right. Here’s what I see when I inspect in Chrome:

  • The request URL is identical
  • The query params for response_type, client_id, redirect_uri, scope, and prompt are identical
  • The query params for state and nonce are different, since those are randomly generated each time a request is made
  • There are only “provisional” request headers, since the request itself gets blocked. DNT and User-Agent are identical. The failed request has a Referer header whereas the successful one does not. The successful one has several additional headers Host, Accept, Accept-Encoding, Accept-Language, Connection, Cookie, and Upgrade-Insecure-Requests

In the failed case, the browser does actually make a second successful request, but it is an OPTIONS request rather than GET. There is no such OPTIONS request in the successful case, just the successful GET.

I think the problem is that your backend is configured the way that it has “redirectURL” configured to use back-end host:port, and if it’s the only entry whitelisted in Okta, your requests would CORS-failed, when you send them to okta from your front-end (when your id_token is expired). I guess it’s sort of a problem of you having 2 places communicating to Okta. If you are using authorization_code flow with your backend, it should be the only place initiating/maintaining Okta conversations.

Let me ask you, if you have your own session management in Django (which I’m not familiar with), why do you need your SPA to talk to Okta? You should be able to finish your initial authn/authz exchange and then establish a session with your back-end and use it as your pass to your API. Or am I missing anything?

Sorry, yeah, I guess I wasn’t totally clear on what was happening. So the SPA does not talk to Okta at all, you’re right that only the backend is doing any communication with Okta. Here is a more detailed description of what I believe is happening. I might just be fundamentally setting things up wrong, so tell me if I’m way off. There are two scenarios:

Scenario 1:

  • SPA loads and user is not logged in
  • User clicks login link and browser is directed to backend login route. This is served directly by Django. On local dev, it’s by having the URL have the appropriate port; on server, it’s routed by nginx.
  • Django does the authorization code flow with Okta, and on success, creates a session
  • Django redirects user back to SPA (again, either by having the right port, or nginx). In both cases, the domain is the same, so the session that Django created is valid on the SPA
  • User is now logged in and the SPA can access the API using the session token

Scenario 2:

  • User has already gone through Scenario 1 and is logged in with an active Django session
  • Time passes to where the id token has expired (default life is 15 min), but the Django session is still active (default life is 14 days)
  • User makes an AJAX request for some data
  • Django recognizes the session is still valid, so it does not kick the user to the login page, but since the id token is expired, it tries to renew it by sending a 302 redirect (to Okta) as the response to the AJAX. This, by the way is where I am the least clear on what is happening and why, because mozilla-django-oidc is doing it.
  • The redirect fails due to CORS
  • User is stilled logged into Django (i.e., does not receive a 401 response), but SPA cannot access any resources on the API unless they refresh the browser or do something that takes them back to Scenario 1

One solution I’ve come up with is to match the session cookie age to the id token age. Thus, the user does receive a 401 response when the id token has expired, because the session has also expired. Then I can catch the 401 on the SPA and redirect to the login route (i.e. initiate Scenario 1). The main downsides of this are that the user loses any unsaved data they had been working on prior to the redirect, and you have to reload the entire SPA.

A similar solution I’ve come up with is to re-authenticate in a separate browser window, which would create a new session without destroying the SPA and the unsaved data on the page:

  • SPA makes AJAX request and receives 401
  • SPA opens a new browser window to the Django login page
  • In the new window, Django completes the authorization code flow, redirects to a dummy page, and closes itself
  • SPA detects the new browser window has closed (I think I actually have to detect unload not close)
  • SPA repeats the original AJAX request

This seems reasonably unobtrusive to the user, but I’m wondering if there’s something stupid I’m missing and this is a terrible idea.

Ok, I see now what’s happening. So I’m still not sure, why do you need to send id_token to the server, if it’s your server who hands the tokens (id and access ones) to your SPA :slight_smile: By the way, I think you maybe mixing id_token and access_token. The id_token returned by Okta is always valid for 1 hour, but access_token lifetime is regulated by the policy. Please double-check what we are talking about

  1. when you backend exchanges code for token with Okta, it knows the expiration time of access_token, so it can limit user session accordingly (first option)
  2. again, theoretically, you don’t need any token, b/c you only talk to your backend, which you have a session established with. Does your server performs introspection of the token with Okta on each of your API calls? If not, I don’t see, why you need them at all, after you finish your authentication with Okta and your server got all the information about the user.
  3. there is a note in the documentation …

Note: When making requests to the /authorize endpoint, the browser (user agent) should be redirected to the endpoint. You can’t use AJAX with this endpoint.

so
Sorry, that I’m not giving you a solution to your problem, as I think you overcomplicated your architecture, hence those weird situations with refresh.

Well the other approach would be to have your SPA doing communication to Okta to obtain tokens and then communicate to your Web API, protected by OAuth. If your back-end is really a Web API server it would make sense to go this route.

Yeah, I guess this is the downside with using a package instead of trying to implement it myself. I thought it would be simpler…

So in mozilla-django-oidc, there is a setting variable OIDC_RENEW_ID_TOKEN_EXPIRY_SECONDS for the lifetime of the id_token, which in now digging into the source, seems like it’s just used by Django to decide whether the token is still valid. Odd that it doesn’t just set that value based on the token it actually receives from the IdP (your point #1). I had mistakenly thought that when I set that value, mozilla-django-oidc was using it to pass a request to okta for a token that would live that long, but it’s actually just used internally.

Now that I think about what you said in point #2, I think you’re right. If I set that value to be something longer (like 24hr), my Django session will be 24hr, even if the id_token had expired after 1hr. So theoretically, the security risk there is a user who was authenticated at 9am, then for whatever reason had their authorization revoked on the Okta side at 10am (or group permissions changed, etc), they would still have access until 8:59am the next day. I believe there’s a way to flush all sessions in Django, so I could force users to reauthenticate then if I really needed to.

In other words, use Okta to authenticate the user, but use my app to decide how often that authentication ought to happen based on my own security risk tolerance. Does that sound right?

Sorry if that was obvious, and the source of my issue this whole time. I had it in my head that I needed to be constantly checking in with Okta to make sure the user was still authenticated/authorized.

It’s your choice and depends on your security policy. You can go either way as you know now.

Just make sure you understand the difference between id_token and access_token, what each one represents and when should be used. id_token only tells you, who a user is. It does not represent anything about what user has access to. So your statement about “their authorization … been revoked” technically would not be reflected in id_token in this situation.

Other than that, you grasped the idea :slight_smile:

Got it. Ok thanks for your help! This is making more sense to me now. At the moment, I don’t actually need to manage permissions, so identity is enough. But at some point I’ll have to dive in more on how this package handles and hands off to Django the access_token info.

1 Like