Setting up production-ready ORY Hydra and Kratos oauth2 flow using Nuxt js for rendering login and consent screen
If only the time of development were to be considered then Oauth0 would be the best choice out of the three where using packages like Passport will come last, but that was not the only concern here, so we ended up going for the last options using Ory Hydra for oauth2 and Kratos as an identity provider as our business need required a bit more flexibility also it was important that we manage our user's list in our database.
At Sparc agency, we were faced with the challenge of how do we implement oauth2 flow for one of our software, there were a few options to choose from:
- Make use of authentication and authorization as a service like Oauth0
- Write our oauth2 server with available packages like Passport for Node JS or Passport for PHP
- Use an open-source Identity solution as Ory provides.
If only the time of development were to be considered then Oauth0 would be the best choice out of the three where using packages like Passport will come last, but that was not the only concern here, so we ended up going for the last options using Ory Hydra for oauth2 and Kratos as an identity provider as our business need required a bit more flexibility also it was important that we manage our user's list in our database.
The next challenge was then to find implement it with our stack:
- Express JS backend.
- Cockroach DB database.
- Nuxt JS login and consent screen.
Since there were not many examples on the internet yet with this kind of setup, we went back to the drawing board and came up with this: I will assume you are familiar with some oauth2 terms, and how it works, but just to refresh memory some of the terms used in this article are:
- Client: The client is the app that is attempting to act on the user’s behalf or access the user’s resources. Before the client can access the user’s account, it needs to obtain permission. The client will obtain permission by either directing the user to the authorization server or by asserting permission directly with the authorization server without interaction by the user.
- Authorization server: The authorization server is what the user interacts with when an application is requesting access to their account. This is the server that displays the OAuth prompt, and where the user approves or denies the access request. The authorization server is also responsible for granting access tokens after the user authorizes the application.
- User: The resource owner is the person who is giving access to some portion of their account. Any system that wants to act on behalf of the user must first get permission from them.
- Authorization Code: An authorization code is returned to the client after the authorization step, and then the client exchanges it for an access token.
- Access Token: An access token is the string used when making authenticated requests to the API. The string itself has no meaning to the application using it but represents that the user has authorized a third-party application to access their account.
- API: The spec refers to what you typically think of as the main API as the “resource server.” The resource server is the server that contains the user’s information that is being accessed by the third-party application.
Now, let's go through the flow:
The client initiates an oauth2 flow to the /oauth2/auth endpoint of the authorization server - Ory hydra, here the oauth2 CSRF cookie is saved which would be used later by hydra, the hydra redirects to our express backend service with the hydra_challenge, the backend service serves as an interface between Kratos our identity provider and hydra. Here I will split the flow into 3 assumptions:
-
The user is not logged in to our service and didn't ask hydra to remember their oauth2 consent: [A]The backend service makes gets the login request info from hydra using the hydra_challenge, since the user isn't logged in and haven't previously asked hydra to remember their consent decision, we redirect the user to the login screen, this is where Kratos comes in, but before redirecting to Kratos browser login flow:
- We generate and save an hydra_login_state session
- Using Kratos ability to receive a return_to URL: which is the URL to return to after successful login, we set the return_to URL to our backend service with the hydra_login_state and challenge as a query parameter
- Then we redirect the user to the Kratos /self-service/login/browser URL with the return_to param set to the URL generated in the previous step
[B]Once the Kratos server receives this request it creates a login flow and saves the return_to URL to the database, Kratos then redirects to the preconfigured login screen which can we a SPA or SSR app, in our case we are using a Nuxt app which renders the login screen based on the flow-id (You can read more on how Kratos login screen is rendered here https://www.ory.sh/kratos/docs/self-service/flows/user-login/ ), here is important to submit the form instead of making an AJAX request though it might be possible to use AJAX it doesn't make sense here since there will be a redirect anyway. Once the user is logged in, Kratos returns to the return_url previously set also with a set-cookie header including the ory_kratos_session cookie - this tells the backend service who is logged in.
[C]Now the backend server does another check, where it needs to check if a user is logged in(and they are because there is a Kratos session cookie available) also it looks for the hydra_login_state in the query and checks it against the one in the cookie that was saved on the first hit before redirecting to Kratos, if they are same and the user is logged in, we ask hydra to acceptLoginRequest with the using the challenge, and the user from Kratos session.
[D]Next action would be to redirect to the hydra URL returned by the request, here hydra ensures the cookie set at the beginning of the flow is still set it then returns to the preconfigured consent URL which is the backend service again in our case, here we get the consent information with the consent_challenge provided, if the user hasn't previously asked hydra to remember their consent decision, we redirect to our Nuxt app consent screen with the challenge, client, user, CSRF token(generated using csurf package) and requested scope.
[E]The Nuxt app renders the consent screen, the user authorizes the client, where we can use AJAX request, to our backend service with the granted scope challenge and CSRF token, we ask hydra to acceptConsentRequest, hydra returns the client's callback URL set by the client, with the authorization code, we then redirect back to the client app where the client can then exchange the authorization code for the access token and refresh token (if applicable) and get user info or perform other operations on our API using the access token.
-
**The user is logged in to our service but hasn't previously asked hydra to remember their oauth2 consent: [A]**In this case the flow starts as before but on fetching login request with the challenge provided, hydra detects the user is logged in as was set previously and returns the consent URL as the redirect URL, if not ( i.e they are logged in to our service but haven't consented to the client oauth2 request) the backend service performs the three steps in [A] of the previous point to generate the Kratos URL, and once Kratos receives this request it detects a valid session cookie that the user is logged in, so it doesn't bother rendering login state, it just redirects to return URL, which is the backend service URL that includes the hydra_login_state and hydra_challenge as before.
[B]From here the flow continues from [C] - accept login request, then [D] - initiate consent to [E] - accept consent request, of the previous point.
-
The user is logged in and had previously asked hydra to remember their oauth2 consent:
With this, the user wouldn't experience any page redirect on clicking login with our service. The flow is the same as the [A] of the previous point since the user is logged in, but is different when it comes to the consent step.
Once the backend service is hit with the request it performs [C] from the first point which is to accept login request but when its time to perform [D] hydra detects the user had previously checked the remember consent option, so we can skip consent, we accept consent request and redirect back to the client app with the authorization code where the client can then exchange the authorization code for the access token and refresh token (if applicable) and then go-ahead to get user info or perform other operations on our API using the access token.
And that pretty much wraps it up.