Tutorial – Auth using Auth0

Goal for this step 🏁: Enabling log in with Auth0 in the frontend and authenticating all REST API calls.

In order to integrate with Auth0 for authentication and authorization we need to configure an application and an API in Auth0.

Create a new tenant at Auth0.

Create a new application for the tenant, and set "Application Type" to "Single Page Application", and set "Allowed Callback URLs", "Allowed Logout URLs" and "Allowed Web Origins" to http://localhost:5173.

Create a new API for the tenant, and set the identifier to https://tutorial.dossierhq.dev.

We now have all the settings we need for the frontend and the backend. In .env we set:

AUTH0_DOMAIN=dossierhq-tutorial.eu.auth0.com
AUTH0_AUDIENCE=https://tutorial.dossierhq.dev

VITE_AUTH0_DOMAIN=$AUTH0_DOMAIN
VITE_AUTH0_CLIENT_ID=igsCrqQlAbURC0vyP4R8QcJwwr7muHAt
VITE_AUTH0_AUDIENCE=$AUTH0_AUDIENCE

To configure for your own Auth0 tenant, create a .env.local file with your own settings.

Install the dependencies needed:

  • npm install @auth0/auth0-react dotenv express-jwt jwks-rsa

In src/App.tsx we can now configure using an Auth0Provider:

import { Auth0Provider } from '@auth0/auth0-react';

export default function App() {
return (
<NotificationContainer>
<Auth0Provider
domain={import.meta.env.VITE_AUTH0_DOMAIN}
clientId={import.meta.env.VITE_AUTH0_CLIENT_ID}
authorizationParams={{
audience: import.meta.env.VITE_AUTH0_AUDIENCE,
redirect_uri: window.location.origin,
}}
>
<AppAdminProvider>
<AppPublishedProvider>
<RouterProvider router={router} />
</AppPublishedProvider>
</AppAdminProvider>
</Auth0Provider>
</NotificationContainer>
);
}

To log in and log out we create src/LogInOutButton.tsx:

import { useAuth0 } from '@auth0/auth0-react';
import { Button } from '@dossierhq/design';

export function LogInOutButton() {
const { loginWithRedirect, isAuthenticated, isLoading, logout } = useAuth0();

if (isLoading) {
return null;
}
if (isAuthenticated) {
return (
<Button onClick={() => logout({ logoutParams: { returnTo: window.location.origin } })}>
Log out
</Button>
);
}
return <Button onClick={() => loginWithRedirect()}>Log in</Button>;
}

And include it in src/Navbar.tsx:

import { LogInOutButton } from './LogInOutButton.js';

export function Navbar({ current }: Props) {
const [active, setActive] = useState(false);
const screenChangesMessage = useContext(ScreenChangesContext);

const handleLinkClick = useCallback(
(event: MouseEvent) => {
if (screenChangesMessage && !window.confirm(screenChangesMessage)) {
event.preventDefault();
}
},
[screenChangesMessage]
);

useBeforeUnload(screenChangesMessage);

return (
<DesignNavbar>
<DesignNavbar.Brand>
<DesignNavbar.Item active={current === 'home'}>
{NavItemRender('Home', '/', handleLinkClick)}
</DesignNavbar.Item>
<DesignNavbar.Burger active={active} onClick={() => setActive(!active)} />
</DesignNavbar.Brand>
<DesignNavbar.Menu active={active}>
<DesignNavbar.Start>
<DesignNavbar.Item active={current === 'admin-entities'}>
{NavItemRender('Entities', '/admin-entities', handleLinkClick)}
</DesignNavbar.Item>
<DesignNavbar.Item active={current === 'published-entities'}>
{NavItemRender('Published entities', '/published-entities', handleLinkClick)}
</DesignNavbar.Item>
<DesignNavbar.Item active={current === 'schema'}>
{NavItemRender('Schema', '/schema', handleLinkClick)}
</DesignNavbar.Item>
</DesignNavbar.Start>
<DesignNavbar.End>
<DesignNavbar.Item>
{({ className }) => (
<div className={className}>
<LogInOutButton />
</div>
)}
</DesignNavbar.Item>
</DesignNavbar.End>
</DesignNavbar.Menu>
</DesignNavbar>
);
}

It's now possible to log in on Auth0's login screen. You will need to sign up the first time.

Auth0's login screen

To expose the JSON Web Token (JWT) to the backend as the Authentication HTTP header, we need to update src/ClientUtils.ts:

import { useAuth0, type Auth0ContextInterface } from '@auth0/auth0-react';

export function useDossierClient(): AppDossierClient | null {
const cachingAdminMiddleware = useCachingDossierMiddleware();
const { isLoading, isAuthenticated, getAccessTokenSilently } = useAuth0();

return useMemo(
() =>
isLoading
? null
: createBaseDossierClient<ClientContext, AppDossierClient>({
context: { logger },
pipeline: [
cachingAdminMiddleware,
createAdminBackendMiddleware(isAuthenticated, getAccessTokenSilently),
],
}),
[isLoading, isAuthenticated, getAccessTokenSilently, cachingAdminMiddleware]
);
}

function createAdminBackendMiddleware(
isAuthenticated: boolean,
getAccessTokenSilently: Auth0ContextInterface['getAccessTokenSilently']
) {
return async (context: ClientContext, operation: DossierClientOperation): Promise<void> => {
const authHeader: { Authorization?: string } = {};
if (isAuthenticated) {
const accessToken = await getAccessTokenSilently();
authHeader['Authorization'] = `Bearer ${accessToken}`;
}

let response: Response;
if (operation.modifies) {
response = await fetch(`/api/admin/${operation.name}`, {
method: 'PUT',
headers: { 'content-type': 'application/json', ...authHeader },
body: JSON.stringify(operation.args),
});
} else {
response = await fetch(
`/api/admin/${operation.name}?${encodeObjectToURLSearchParams(
{ args: operation.args },
{ keepEmptyObjects: true }
)}`,
{ headers: authHeader }
);
}

const result = await getBodyAsJsonResult(response);
operation.resolve(convertJsonDossierClientResult(operation.name, result));
};
}

export function usePublishedClient(): AppPublishedDossierClient | null {
const { isLoading, isAuthenticated, getAccessTokenSilently } = useAuth0();

return useMemo(
() =>
isLoading
? null
: createBasePublishedDossierClient<ClientContext, AppPublishedDossierClient>({
context: { logger },
pipeline: [createPublishedBackendMiddleware(isAuthenticated, getAccessTokenSilently)],
}),
[isLoading, isAuthenticated, getAccessTokenSilently]
);
}

function createPublishedBackendMiddleware(
isAuthenticated: boolean,
getAccessTokenSilently: Auth0ContextInterface['getAccessTokenSilently']
) {
return async (context: ClientContext, operation: PublishedDossierClientOperation): Promise<void> => {
const authHeader: { Authorization?: string } = {};
if (isAuthenticated) {
const accessToken = await getAccessTokenSilently();
authHeader['Authorization'] = `Bearer ${accessToken}`;
}

const response = await fetch(
`/api/published/${operation.name}?${encodeObjectToURLSearchParams(
{ args: operation.args },
{ keepEmptyObjects: true }
)}`,
{ headers: authHeader }
);
const result = await getBodyAsJsonResult(response);
operation.resolve(convertJsonPublishedDossierClientResult(operation.name, result));
};
}

We will now send the Authorization header when we're authenticated with Auth0, and no header when we're not authenticated.

To verify the JWT in the backend and create a session that is unique for the Auth0 user (using the sub property of the JWT), we use the expressjwt middleware which verifies that the header is correct and sets req.auth. In backend/main.ts:

import { config } from 'dotenv';
import { expressjwt, type GetVerificationKey, type Request } from 'express-jwt';
import { expressJwtSecret } from 'jwks-rsa';

// prefer .env.local file if exists, over .env file
config({ path: '.env.local' });
config({ path: '.env' });

app.use(
expressjwt({
secret: expressJwtSecret({
cache: true,
rateLimit: true,
jwksRequestsPerMinute: 5,
jwksUri: `https://${process.env.AUTH0_DOMAIN}/.well-known/jwks.json`,
}) as GetVerificationKey,
audience: process.env.AUTH0_AUDIENCE,
issuer: `https://${process.env.AUTH0_DOMAIN}/`,
algorithms: ['RS256'],
credentialsRequired: false,
})
);

And in backend/server.ts:

import type { Request } from 'express-jwt';

async function createSessionForRequest(server: Server, req: Request) {
let provider = 'sys';
let identifier = 'anonymous';
if (req.auth && req.auth.sub) {
provider = 'auth0';
identifier = req.auth.sub;
}
return await server.createSession({
provider,
identifier,
defaultAuthKeys: ['none', 'subject'],
logger: null,
databasePerformance: null,
});
}

We haven't made any changes to the authorization, so all users are able to perform all operations. However, we can verify that the auth key works. By creating a Message entity with the auth key "User private" (none) as an anonymous user, you can check that you can't see it when you're logged in as an Auth0 user, and vice versa. Different Auth0 users can't see each other "User private" entities either.

Screenshot of a Message entity, created by an anonymous user with User private auth key

 

Screenshot of empty entities list when logged in filtering on User private auth key