The missing library for authentication on the server with Remix
Please contribute!
This is a library for authentication with Auth0 on the server with Remix.
It's built as part of the efforts to deliver the trance-stack. As such, the initial release of this library only covers the MVP needs of the stack. It will keep evolving over time and with your help.
Other solutions out there seem to miss the actual token validation and basic security measures. This library attempts to bridge that gap and also provide a convenient interface to use.
- utilise the STATE parameter to prevent CSRF
- failed events should remove the user from the session automatically
- see if we can handle the callback while maintaining the session from before the login
- opt out of the session handling
Everything below assumes that you're using Remix and Auth0 and you're familiar with how they work.
npm install auth0-remix-server
Some steps below might be familiar to anyone who attempted this with the remix-auth-auth0 package.
Environment variables are not required for the library, the examples only use them when configuring the authenticator. I do recommend using environment variables for sensitive information like client secrets and domain names.
AUTH0_DOMAIN
- The domain name of your Auth0 tenantAUTH0_CLIENT_ID
- The client ID of your Auth0 applicationAUTH0_CLIENT_SECRET
- The client secret of your Auth0 applicationAPP_DOMAIN
- The domain name of your application (http://localhost:3333 for local development)
// src/auth.server.ts
import { Auth0RemixServer } from 'auth0-remix-server';
import { getSessionStorage } from './sessionStorage.server'; // this is where your session storage is configured
export const authenticator = new Auth0RemixServer({
clientDetails: {
domain: process.env.AUTH0_DOMAIN,
clientID: process.env.AUTH0_CLIENT_ID,
clientSecret: process.env.AUTH0_CLIENT_SECRET,
usePost: true // optional, defaults to true
},
callbackURL: `${process.env.APP_DOMAIN}/auth/callback`,
refreshTokenRotationEnabled: true,
failedLoginRedirect: '/',
session: {
store: getSessionStorage(),
key: 'user' //optional
},
credentialsCallback: (credentials) => {
// this gets called upon a successful callback or a credentials refresh event
} //optional
});
// src/routes/login.tsx
import { Form } from '@remix-run/react';
import { redirect } from '@remix-run/node';
export default () => {
return (
<Form action="/auth/auth0" method="post">
<button>Login</button>
</Form>
);
};
// src/routes/auth/auth0.ts
import { authenticator } from '../../auth.server';
import type { ActionFunction } from '@remix-run/node';
export const action: ActionFunction = () => {
authenticator.authorize();
};
Note You can modify the behaviour of the
authorize
method. More on that here
// src/routes/auth/callback.tsx
import { authenticator } from '../../auth.server';
import type { ActionFunction } from '@remix-run/node';
export const action: ActionFunction = async ({ request }) => {
await authenticator.handleCallback(request, {
onSuccessRedirect: '/dashboard' // change this to be wherever you want to redirect to after a successful login
});
};
import { authenticator } from '../auth.server';
import { destroySession, getSessionFromRequest } from '../session.server';
import type { ActionFunction } from '@remix-run/node';
export const action: ActionFunction = async ({ request }) => {
const session = await getSessionFromRequest(request);
await authenticator.logout(process.env.APP_DOMAIN, {
'Set-Cookie': await destroySession(session) // this is where you destroy the session
});
};
export const loader = action; // this to allow you to hit /logout directly in the browser
import { json } from '@remix-run/node';
import { Form, useLoaderData } from '@remix-run/react';
import { authenticator } from '../auth.server';
import type { LoaderFunction } from '@remix-run/node';
export const loader: LoaderFunction = async ({ request, context }) => {
const user = await authenticator.getUser(request, context); // this is what determines if the user is logged in or not
return json({
user: user
});
};
export default () => {
const { user } = useLoaderData<typeof loader>();
return (
<div>
<div>Dashboard for {user.nickname || user.givenName || user.name}</div>
<Form action="/logout" method="post">
<button>Logout</button>
</Form>
</div>
);
};
When you're constructing the authorizer, you can customize how the library behaves when refreshing the tokens.
// src/auth.server.ts
...
session: {
store: getSessionStorage(),
strategy: SessionStrategy.Browser, // <----- This line here
key: 'user' //optional
},
...
The authenticator uses a session strategy to determine how to handle the case of refreshing the tokens.
The default setting SessionStrategy.Browser
assumes that you store all your tokens in a cookie and that
you need to send the browser cookie headers whenever you refresh the tokens.
This results in a literal page refresh, which is not ideal.
:note: This is most likely NOT going to be your use case.
Ideally you will only store the session id in the cookie and leave the session data to be stored in a database.
This is where the SessionStrategy.Server
comes in. It assumes that you have a session id stored in a cookie
and that once the ID is set upon login, you will be able to retrieve - and update - the session data from a database.
Unfortunately with the current implementation of Remix and the lack of a proper middleware, every loader runs in parallel, and they can generate a lot of noise towards Auth0.
In this situation you might want to have some sort of a "user profile caching" in place. Redis, Dyanmo, in-memory, you name it.
The auth0-remix-server
offers you a way to do this.
// src/auth.server.ts
import {Auth0RemixServer} from 'auth0-remix-server';
import {getSessionStorage} from './sessionStorage.server';
import {UserProfile} from "./Auth0RemixTypes"; // this is where your session storage is configured
export const authenticator = new Auth0RemixServer({
...,
profileCacheGet: async (accessToken: string): Promise<UserProfile> => {
//return a UserProfile or throw an error if not found
},
profileCacheSet: async (accessToken: string, profile: UserProfile, expiresAt: number): Promise<void> => {
// use the expiresAt as a TTL value if your cache storage supports such a thing.
// Otherwise use it to invalidate the record yourself.
},
...,
});
If you're using the contents of the tokens, you should always make sure that they're valid and haven't been tampered with.
You can quickly verify the validity of the tokens by using the verifyToken
and isValid
methods on the authenticator
described in the Validating Tokens section.
But if you want to decode the tokens and use the contents, you should use the decodeToken
method .
import { authenticator } from './auth.server';
import { Token } from 'auth0-remix-server';
const decodedToken = await authenticator.decodeToken('your id token here', Token.ID);
The decodedToken
will contain the contents of the IDToken but at this point you can be sure that it passed
the cryptographic validation checks.
If the verification fails, the decodeToken
method will throw the same set of errors as the verifyToken
method throws.
You can see the list of errors in the Errors section.
ID and Access Tokens can be decoded easily by anyone but in order to make sure that the data hasn't been tampered with, it's advisable to validate the tokens against the public keys provided by Auth0.
You can do this by using the verifyToken
and the isValid
methods on the authenticator.
They both take a Token
as a second argument because the validation process is different for each type of token.
The isValid
function is a quick yes/no answer to whether or not the token is valid.
import { authenticator } from './auth.server';
import { Token } from 'auth0-remix-server';
await authenticator.isValid('your access token here', Token.AccessToken); // returns true or false
The verifyToken
function will resolve if the token is valid and will reject if it's not.
import { authenticator } from './auth.server';
import { Token } from 'auth0-remix-server';
try {
await authenticator.verifyToken('your id token here', Token.ID);
} catch (error) {
// handle the error
const { code, message } = error as TokenError;
}
During the authrization process, if the user is already logged into Auth0, they will not be asked to log in again.
You can change that behaviour by passing in the forceLogin
option to the authorize
method.
// src/routes/auth/auth0.ts
import { authenticator } from '../../auth.server';
import type { ActionFunction } from '@remix-run/node';
export const action: ActionFunction = () => {
authenticator.authorize({
forceLogin: true
});
};
You can force the user to the sign-up page by passing in the forceSignup
option to the authorize
method.
// src/routes/auth/auth0.ts
import { authenticator } from '../../auth.server';
import type { ActionFunction } from '@remix-run/node';
export const action: ActionFunction = () => {
authenticator.authorize({
forceSignup: true
});
};
You can force the user to the sign-up page by passing in the forceSignup
option to the authorize
method.
// src/routes/auth/auth0.ts
import { authenticator } from '../../auth.server';
import type { ActionFunction } from '@remix-run/node';
export const action: ActionFunction = () => {
authenticator.authorize({
silentAuth: true
});
};
Combining the forceLogin
, forceSignup
and silentAuth
parameters to control the behavior of the authorization request produce the following results:
parameter | No existing session | Existing session |
---|---|---|
{forceSignup: true} |
Shows the signup page | Redirects to the callback url |
{forceLogin: true} |
Shows the login page | Shows the login page |
{forceSignup: true, forceLogin: true} |
Shows the signup page | Shows the signup page |
{silentAuth: true, forceLogin: true} |
Type Error / Silent auth | Type Error / Silent auth |
{silentAuth: true, forceSignup: true} |
Needs testing | Needs testing |
You can also specify the name of the connection configured to your application.
// src/routes/auth/auth0.ts
import { authenticator } from '../../auth.server';
import type { ActionFunction } from '@remix-run/node';
export const action: ActionFunction = () => {
authenticator.authorize({
connection: 'google'
});
};
You can also specify custom parameters to be added to the redirect url.
// src/routes/auth/auth0.ts
import { authenticator } from '../../auth.server';
import type { ActionFunction } from '@remix-run/node';
export const action: ActionFunction = () => {
authenticator.authorize({
callbackParams: {
foo: 'bar'
}
});
};
You can also specify a redirect url to be used for each authorization request. This will override the default redirect url that you specified when you created the authenticator.
// src/routes/auth/callback.tsx
import { authenticator } from '../../auth.server';
import type { ActionFunction } from '@remix-run/node';
export const action: ActionFunction = async ({ request }) => {
await authenticator.handleCallback(request, {
onSuccessRedirect: '/dashboard', // change this to be wherever you want to redirect to after a successful login
onFailureRedirect: '/login' // change this to be wherever you want to redirect to after a failed login
});
};
When the authorization process fails, the failure redirect url will be called with an error
query parameter that
contains the error code auth0 has given us.
The verification errors each have a code
property that you can use to determine what went wrong.
Code | Description |
---|---|
ERR_JWT_CLAIM_VALIDATION_FAILED | The JWT claim validation failed. |
ERR_JWT_EXPIRED | The JWT has expired. |
ERR_JWT_INVALID | The JWT is invalid. |
ERR_JWKS_INVALID | The JWKS is invalid. |
ERR_JWKS_NO_MATCHING_KEY | No matching key was found. |
ERR_JWKS_MULTIPLE_MATCHING_KEYS | Multiple matching keys were found. |
When you instantiate the authenticator, you can pass in a credentialsCallback
function. This function will be called
when the user is successfully authenticated or when the access token is refreshed.
It will contain the credentials obtained from Auth0.
The credentials object looks like this:
{
accessToken: string; // the access token
refreshToken: string; // the refresh token
expiresIn: number; // the number of seconds until the access token expires
expiresAt: number; // the timestamp when the access token expires
lastRefreshed: number; // the timestamp when the access token was last refreshed
}
The refreshTokenRotationEnabled
option is set to false
by default. This is because it's off by default in Auth0.
When it's set to true
, the refresh tokens will be appended to the session. This is secure and makes it easier to manage
the refresh tokens.
Please see this post and this one for more information.
Until this issue in Remix is shipped, you'll need to pass in
the context from the loaders and actions to the getUser
method.
This ensures (in an awkward way) that the refresh only happens once.
It's not pretty but once we have proper middleware in Remix, it should clean up.