Adding OAuth & OIDC to a Remix App
While there are a handful of plugin architectures trying to make the auth developer experience in Remix as easy as dropping in a library, I found them completely unnecessary, especially for an auth solution as simple as OAuth2. For this post, we’re going to implement it ourselves using the openid-client
node module, just as you would in any other express application, but using the Remix practices and APIs.
Requirements
I’m going to assume you’ve used Remix before, or are getting started with learning it and are familiar with the technologies it uses such as React, React Router, and Node.js.
If you haven’t yet done so, have a Remix app ready. You can do this by following the setup guide on the Remix website which will have you ready in no time.
Add the following node modules to your package.json
:
OAuth2 & OIDC Provider
Since you’re reading this post, you likely have an OAuth2 provider you use and have a client registered with the provider. If not, I recommend Auth0 for this example because of its free access and ease of use. Since OAuth2 and OIDC are open standards, the same principles apply anywhere on how to configure your application, but the steps necessary would depend on the software you’re using as the authorization server.
Complete Example
Prefer to review a complete example? Check out the repository below which is the MVP required to add OAuth2/OIDC to a Remix app.
Server configuration
Our server-side implementation for Remix is going to require some secret values and configuration that will change based on the environment the app is deployed in. Let’s define a module at the top level of our app to handle this configuration (app/config.server.ts
):
export const config = {
sessionSecret: process.env.SESSION_SECRET || "",
oidcIssuer: process.env.OIDC_ISSUER || "",
oidcClientID: process.env.OIDC_CLIENT_ID || "",
oidcClientSecret: process.env.OIDC_CLIENT_SECRET || "",
oidcAudience: process.env.OIDC_AUDIENCE || "",
oidcRedirectBase: process.env.OIDC_REDIRECT_BASE
}
If you do not have these variables set in your shell env, create a .env
file in the root directory of the project. This will be loaded by Remix on app start—just make sure that the file is added to your .gitignore
.
A special circumstance if you’re used to OAuth2 with SPAs is the OIDC_REDIRECT_BASE
value. In the SPA flow, most implementations use window.origin
to determine the base URL to append any configured pathnames to. However, the Remix authorization logic is executed server-side like in a traditional web application (to use the Auth0 terminology). For this reason, we need to tell the server what this value should be.
Requiring user session
Each route that requires the session will gain access to it through a function called in the route’s LoaderFunction
. Here’s an example of such a loader that executes our desired behavior:
import type { LoaderFunction } from "@remix-run/node"
import { requireUserSession } from "~/auth/session.server"
export const loader: LoaderFunction = async ({ request }) => {
await requireUserSession(request)
return null
}
You can add this loader to any route—for example, the file at app/routes/index.tsx
. We haven’t yet defined requireUserSession
, so let’s do that next. Create a file called app/auth/session.server.ts
. Add the following contents to the file:
import { generators } from "openid-client"
import { config } from "~/config.server"
import { redirect } from "@remix-run/node"
export async function requireUserSession(request: Request) {
const client = await getClient()
const currentCookie = request.headers.get("cookie")
const session = await sessionStorage.getSession(currentCookie)
if (!session.has("access_token")) {
const codeVerifier = generators.codeVerifier()
session.set("code_verifier", codeVerifier)
session.set("return_to", request.url)
const cookie = await sessionStorage.commitSession(session)
throw redirect(
client.authorizationUrl({
scope: "openid",
audience: config.oidcAudience,
code_challenge: generators.codeChallenge(codeVerifier),
code_challenge_method: "S256",
}),
{
headers: {
"Set-Cookie": cookie,
},
}
)
}
return session
}
This function performs these operations:
- Ensure we have an instance of a client from the
openid-client
node module. - Get the cookie string from the request, and use it to get the current session out of session storage.
- If the user session does not aready have an
access_token
, start the authorization transaction and throw a redirect. - We use the
code_challenge
to enable PKCE. Any authorization code flow should use PKCE. Since we create acode_verifier
that must be persisted for subsequent requests, we store this in the user’s session. - Storing the
return_to
for the duration of the roundtrip will allow us to redirect the user to the original route they loaded.
The throw redirect()
may look a little odd, but this is a Remix-ism. We can throw the result from calling redirect()
in a Remix route loader to change the flow of how this route is handled. In my opinion, it’s an odd design solution, since error handling should be for… well, errors.
There are several variables and functions in the snippet above we have yet to define. Let’s start with getClient()
. We want this function to behave like a singleton that will initialize an OIDC client from the openid-client node module. The client instance will handle implementation details of the OIDC interaction.
Let’s define that function in the same file (app/auth/session.server.ts
), using a variable local to the module to store the singleton instance:
import { Issuer } from "openid-client"
import type { Client } from "openid-client"
let client: Client
async function getClient(): Promise<Client> {
if (client !== undefined) {
return client
}
const issuer = await Issuer.discover(config.oidcIssuer)
client = new issuer.Client({
client_id: config.oidcClientID,
client_secret: config.oidcClientSecret,
redirect_uris: [`${config.oidcRedirectBase}/auth/callback`],
response_types: ['code'],
})
return client
}
Now is a good time to install openid-client
into your package.json
in case you haven’t already. The code above will create a new client using /auth/callback
as the redirect URI.
The redirect URI above can be modified to match the redirect URI configured on your OIDC/OAuth2 client. Just be sure to keep note of that for when we define the callback route implementation later. You can also modify your client registration with your provider to match the /auth/callback
route used in the example.
The next missing component that our requireUserSession()
function depends on is the session storage instance. Let’s create this and store it in a variable in the same file using the code below:
import { createCookieSessionStorage } from "@remix-run/node"
const sessionStorage = createCookieSessionStorage({
cookie: {
name: "_session",
sameSite: "lax",
path: "/",
httpOnly: true,
secrets: [config.sessionSecret],
secure: process.env.NODE_ENV === "production",
},
})
The options provided are merely a suggestion for getting started, and the choices made here will have security implications that are very specific to your app and deployment. Selecting which session storage implementation to use is not in the scope of this tutorial.
With this addition, our implementation of requireUserSession()
is ready! Given the app is configured correctly with the correct environment variables, you should be able to perform the first leg of the 3-legged authorization code flow (redirecting to the authorize endpoint on route load).
Handling the callback
As part of the code above, we informed the authorization server to return the user to /auth/callback
when the authorization server roundtrip is complete. Let’s implement that route. We’ll implement it just like any other route in remix, using a file of the same path: app/routes/auth/callback.tsx
import type { LoaderFunction } from "@remix-run/node"
import { authorizeUser } from "~/auth/session.server"
export const loader: LoaderFunction = async ({ request }) => {
await authorizeUser(request)
}
export default function AuthCallback() {
// todo: show custom messages for auth issues
}
We’re using a LoaderFunction
like before to handle any processing before this route is rendered, and to encapsulate the logic in one place, we are calling a function from the session.server.ts
file we created before called authorizeUser
. Let’s implement that function:
import jwt from "jsonwebtoken"
export async function authorizeUser(request: Request) {
const client = await getClient()
const currentCookie = request.headers.get("cookie")
const session = await sessionStorage.getSession(currentCookie)
const codeVerifier = session.get("code_verifier")
if (typeof codeVerifier !== "string" || codeVerifier.length === 0) {
// you may want to log this at a warn level
throw new Error("unauthorized")
}
const params = client.callbackParams(request.url)
const tokenSet = await client.callback(
`${config.oidcRedirectBase}/auth/callback`,
params,
{ code_verifier: codeVerifier }
)
if (tokenSet.access_token) {
session.set("access_token", tokenSet.access_token)
session.set("id_token", tokenSet.id_token)
// provide any session data you wish to have after the user is authenticated
session.set("user", jwt.decode(tokenSet.access_token))
}
let redirectLocation = "/"
if (session.has("return_to")) {
redirectLocation = session.get("return_to")
session.unset("return_to")
}
const cookie = await sessionStorage.commitSession(session)
throw redirect(redirectLocation, {
headers: {
"Set-Cookie": cookie,
},
})
}
Authorizing a user in the example above is done in a few steps:
- Get the OIDC client singleton like we did in
requireUserSession()
. - Read the user’s current session.
- Read the OIDC params from the request URL. This includes values such as
code
to be passed to the token endpoint. - Make the HTTP request to the token endpoint with
client.callback()
- Handle the storing of the token response in the session, and redirect the user back to the original route. Notice here that the value will default to the index route if it happens to be unset between the two requests. Feel free to specify your own default value if you need to use a different route.
You may notice that we are not using state
as part of the OAuth2 flow. This can be omitted when using PKCE as it accomplishes the same goal as the state
option in a more secure way.
Trying it out
At this point, we can say that the authorization code flow is complete! You should be able to try it end-to-end, given your app and client registration is configured correctly. If you’re having an issue in any step of the flow, double-check all options on your client registration such as allowed redirect_uri
’s or allowed grant types.
Logout
This one is fairly straightforward, but may depend on some aspects of your OIDC provider of choice. Documented here is what works for Auth0, and can work for most providers with small changes.
First, we need to implement a logout route. We direct users here using <Link />
or navigate()
. Let’s create a new file at app/routes/auth/logout.tsx
and implement the route:
import type { LoaderFunction } from "@remix-run/node"
import { logoutUser } from "~/auth/session.server"
export const loader: LoaderFunction = async ({ request }) => {
await logoutUser(request)
}
export default function Logout() {
// todo: could show custom messages for auth issues
}
With the same logic as before, we’ll define our logoutUser()
function in the session.server.ts
file.
export async function logoutUser(request: Request) {
const session = await sessionStorage.getSession(request.headers.get("cookie"))
const cookie = await sessionStorage.destroySession(session)
const returnTo = encodeURIComponent(`${config.oidcRedirectBase}/auth/logout-success`)
// This is a non-standard logout, since Auth0 does not have the end_session_url in their openid-configuration
throw redirect(`${config.oidcIssuer}/v2/logout?client_id=${config.oidcClientID}&returnTo=${returnTo}`, {
headers: {
"Set-Cookie": cookie
}
})
}
Once the user has had their session cleared and the redirect to the OIDC server has completed its own redirect, the user will be returned to a route we defined at /auth/logout-success
. This is my desired UX after logout, so yours may be different. This route can be implemented in a app/routes/auth/logout-success.tsx
file as such:
import { Link } from "@remix-run/react"
export default function LogoutSuccess() {
return (
<main>
<h2>Logout successful</h2>
<p>
You have been successfully logged out. You can <Link to="/">login</Link> again.
</p>
</main>
)
}
Conclusion
At this point, you should have enough of a starting point to modify and tweak based on your own use case. Using the access token to call another API is as simple as reading it from the session storage. I hope this was helpful, and I will do my best to keep it updated should the Remix APIs change!