Server-Side authentication with Auth0 in Remix
While this works, I recommend you to don't do all of this yourself, instead use Remix Auth with the Auth0Strategy which will give you everything below so you can use it directly in your app.
Auth0 is a really good service to implement authentication in an app without a lot of code, they have all the infrastructure to add any social network login, or passwordless, or two factor authentication or anything we may want to add to our application, even connect to a custom database.
If you try to setup Auth0 in a project they also have great documentation on how to add it to a Node.js app for regular web apps, this means any non SPA basically, and you will see it requires Express.js, if you go the SPA way it will work but to save the user token you got from Auth0 you will have to submit it to an action so you can add it you the Remix session.
Let's see a way to build a non SPA login using Auth0 and without using Express, so we can use the Remix Dev Server or any other adapter that is not the Express one like Vercel or Fly.io.
First, let's create a file auth0.server.ts
in our application, I added it on app/services/auth0.server.ts
. Here we are going to add all the logic for our Auth0 integration, we add the .server
prefix so the file will never be shipped to the client, this will allow us to use environment variables and basically anything we want that doens't run on the browser.
The environment variables
We are going to need four env variables.
AUTH0_CLIENT_ID
AUTH0_CLIENT_SECRET
AUTH0_DOMAIN
AUTH0_CALLBACK_URL
You can add them to a .env
file and in your entry.server
file add the following code:
import dotenv from "dotenv";
dotenv.config();
This will load them when the app starts only on the server. Note you will only need this for the Remix Dev Server, other adapters like Vercel may add them automatically.
To get the values of these variables go to Auth0, create an application configured to be regular web app, in the Basic Information section you will have the client id, secret and domain. In the Application URIs section you can add the callback URL you want to use, I configured it to be localhost:3000/callback
for testing, you can use any pathname.
Reading the env variables
Now we can read them in our auth0.server.ts
file
// Read them from process.env
let { AUTH0_CLIENT_ID, AUTH0_CLIENT_SECRET, AUTH0_DOMAIN, AUTH0_CALLBACK_URL } =
process.env;
// Ensure they are defined and throw error if not
if (!AUTH0_DOMAIN) throw new Error("Missing Auth0 domain.");
if (!AUTH0_CLIENT_ID) throw new Error("Missing Auth0 client id.");
if (!AUTH0_CLIENT_SECRET) throw new Error("Missing Auth0 client secret.");
if (!AUTH0_CALLBACK_URL) throw new Error("Missing Auth0 redirect uri.");
// This object is just so we can do `auth0.clientId` or another attribute instead of using the all uppercase variables
let auth0 = {
clientId: AUTH0_CLIENT_ID,
clientSecret: AUTH0_CLIENT_SECRET,
domain: AUTH0_DOMAIN,
callbackUrl: AUTH0_CALLBACK_URL,
};
Authorize requests
The reason we want to use Auth0 is to be able to authorize the user, this means we want to authenticate it and if it's not redirect to the login. We are going to create an authorize
policy function, here we will get the session from the cookie, authenticate the user of the request and if it's not authenticated redirect to the login.
function redirectUrl(state: string) {
return "TODO";
}
export let authorize: Policy<{
user: User;
session: Session;
token: string;
}> = async (request, callback) => {
let session = await getSession(request.headers.get("Cookie"));
try {
// Here we are authenticating the user, this function receives the request and session and somehow get the user
// data and token, how we get these depends on our application, we could query the DB or fetch another API
let { user, token } = await authenticate(request, session);
// If we don't have a user or token we will throw an error
if (!user || !token) throw new Error("Unauthorized");
// if we have both user and token we will call the policy callback passing them along the session
return await callback({ user, session, token });
} catch {
// if we got an error we will unset the token from the session
session.unset("token");
// we will generate a uuid and encode it to be used in URLs then store it in a key of the session
// to generate this uuid we can use the uuid package from npm
let state = encodeURIComponent(uuid());
session.set("auth0:state", state);
// finally we will commit the session and redirect the user to the redirectUrl generated using the state
return redirect(redirectUrl(state), {
headers: { "Set-Cookie": await commitSession(session) },
});
}
};
With this code, we can go to any loader we want to authorize the user, call it passing the request and a callback, if the user is logged in our callback will run and we can use that to do whatever we want in the loader, if the user is not logged in then it will be redirected to the redirect URL.
Generating the redirect URL
If you haven't used Auth0 before, they provide you a URL where you will have your login page, here they will automatically add any login method you have enable for your application, so if you allow users to log in with Apple and Google here Auth0 will add those buttons, if you allow email and password the form to write them will be added here too.
When using the SPA JS SDK this URL is generated for you after you pass the domain, client id, and callback URL, in our case we need to manually format it.
function redirectUrl(state: string) {
// first we create a new URL instance using our auth0.domain as initial value
let url = new URL(auth0.domain);
// then we set the pathname to `/authorize`
url.pathname = "/authorize";
// and now we start adding search params (aka the query string), we need a response_type as code
url.searchParams.set("response_type", "code");
// we need the client_id
url.searchParams.set("client_id", auth0.clientId);
// the callback URL as redirect_uri
url.searchParams.set("redirect_uri", auth0.callbackUrl);
// the scope of the data we want, this is a list of words with a blank space between them
url.searchParams.set("scope", "openid profile email");
// the last one is the state, this is our UUID, it can technically be any random string
url.searchParams.set("state", state);
// finally we return the URL as a string
return url.toString();
}
This function will return a string similar to this one:
https://app.auth0.com/authorize?response_type=code&scope=openid%20profile%20email&client_id=SOME_ID&redirect_uri=http://localhost:3000/callback&state=SOME_RANDOM_STRING
The state (our UUID)
This UUID we are generating and calling state is any random string we can generate (it doesn't need to be a UUID), we need to send it to Auth0 in the URL and store it somehow, we are using the session in the cookie for this, then after the login Auth0 will go to our callback URL and it will add the state so we can compare and ensure the request is from the same flow the user initiated, this is a security measure.
Handling the redirect
The next step is to handle the redirect Auth0 does after the user completes the login, Auth0 will redirect the user back to our callback URL, so let's write a function for that.
export async function handleRedirect(request: Request) {
// first we will get the session from the cookies
let session = await getSession(request.headers.get("Cookie"));
// then we will get the URL from the request
let url = new URL(request.url);
// we check if we have a state in the URL
let state = url.searchParams.get("state");
// if we don't redirect to login, since it's an invalid request
if (!state) return redirect("/login");
// if we have a state we need to check if the state is valid
// the state is valid if it's the same we stored in the cookie in our `authorize` function
// if it's valid we will unset it from the session
if (session.get("auth0:state") === state) session.unset("auth0:state");
// if it's not valid redirect to /login
else return redirect("/login");
// now we check if we have a code in the URL and redirect to /login if we don't
let code = url.searchParams.get("code");
if (!code) return redirect("/login");
// if we have the code we will send a POST to our Auth0 domain with the pathname /oauth/token
// this request will be used to exchange the code we got in the URL and get the `idToken` we will use later
// here we need to send our Auth0 client secret, the callback URL, the client ID, the code we got and
// a key grant_type with the value `authorization_code`
let response = await fetch(new URL("/oauth/token", auth0.domain).toString(), {
method: "POST",
headers: new Headers([["Content-Type", "application/json"]]),
body: JSON.stringify({
client_cecret: auth0.clientSecret,
grant_type: "authorization_code",
redirect_uri: auth0.callbackUrl,
client_id: auth0.clientId,
code,
}),
});
// now we parse the response body as JSON, this body could be an object with `id_token` or `error`
let body = (await response.json()) as
| { id_token: string; error: never }
| { error: string };
// check if our body is an error (has the `error` key) and redirect to login
if (body.error) return redirect("/login");
// get the `idToken` from the body
let { id_token: idToken } = body as Auth0Body;
try {
// now we want to try to decode the JWT from Auth0 and validate it, redirect to login if it's invalid
// this decodeJwt function comes from the `jwt-decode` package in npm
let decoded = decodeJwt<JWT>(idToken);
// and we need to validate the schema of the JWT token, we can use Yup for this, we will write the schema after this
await jwtSchema.validate(decoded);
} catch {
// if it's not valid or we can't decode it we redirect to login
return redirect("/login");
}
// now that we have the idToken we can use it to get a token to store in the session or find the user or anything we want
let token = await getUserSessionTokenSomehow(idToken);
// we set this token in the session
session.set("token", token);
// and finally redirect the user to the /private route and commit the session
return redirect("/private", {
headers: new Headers([["Set-Cookie", await commitSession(session)]]),
});
}
JWT Schema validation
To validate the JWT shape we can use the following Yup schema definition, you can see in the error message what we are validating.
let jwtSchema = yup.object().shape({
iss: yup
.string()
.oneOf(
[auth0.domain, `${auth0.domain}/`],
`The token iss value doesn't match the AUTH0_DOMAIN (${auth0.domain})`
)
.required("The token didn't come with an iss value."),
aud: yup
.string()
.oneOf(
[auth0.clientId],
`The token aud value doesn't match the AUTH0_CLIENT_ID (${auth0.clientId})`
)
.required("The token didn't come with an aud value."),
exp: yup
.number()
.required()
.test(
"is_before_date",
"Token exp value is before current time.",
(value) => {
if (!value) return false;
if (value < Math.ceil(Date.now() / 1000)) return false;
return true;
}
),
iat: yup
.number()
.required()
.test(
"is_before_one_day",
"Token was issued before one day ago and is now invalid.",
(value) => {
if (!value) return false;
let date = new Date();
date.setDate(date.getDate() - 1);
if (value < Math.ceil(Number(date) / 1000)) return false;
return true;
}
),
});
Use our function in the routes
Now, we can go to our routes and use our authorize
and handleRequest
functions.
Inside app/routes/login.tsx
add the following code:
import { LoaderFunction, redirect } from "remix";
import { authorize } from "../services/auth0.server";
export let loader: LoaderFunction = async ({ request }) => {
return authorize(request, async () => {
// if the user is already logged-in and goes to /login we redirect to /private directly
return redirect("/private");
});
};
export default function View() {
return <h1>The user will never see this but Remix need it</h1>;
}
Inside app/routes/callback.tsx
add the following code:
import { LoaderFunction } from "remix";
import { handleRedirect } from "../services/auth0.server";
export let loader: LoaderFunction = async ({ request }) => {
return handleRedirect(request);
};
export default function View() {
return <h1>The user will never see this but Remix need it</h1>;
}
And in our app/routes/private.tsx
or any route we want to protect we can use authorize
as in the login.
import { LoaderFunction, redirect } from "remix";
import { authorize } from "../services/auth0.server";
export let loader: LoaderFunction = async ({ request }) => {
return authorize(request, async ({ user, token, session }) => {
// here we can get the data for this route and return it
});
};
export default function View() {
// here we can call useRouteData and show the route data
}
That's it, it may look like a lot of code but because we put everything in app/services/auth0.server.ts
we could centralize it in a single file and make our routes simpler.