Want to skip the details? Try out the online demo.
Introduction
With the rise of JAMstack we've seen a lot of frameworks and platforms offer the ability to build and host Serverless functions as lightweight backends for your applications.
In the last few years the Node.js ecosystem has provided many solutions to handle authentication in your web applications through libraries like Passport.js and express-jwt. But due to the different programming model of Serverless functions (lambdas as opposed to full blown web servers) these libraries are not the perfect tool for the job.
This is where serverless-jwt comes in: a simple and lightweight solution focused at solving authentication for your Serverless functions. Here's an example of what it would take to protect a Netlify Function:
const { NetlifyJwtVerifier } = require('@serverless-jwt/netlify');
const verifyJwt = NetlifyJwtVerifier({
issuer: 'https://sandrino.auth0.com/',
audience: 'urn:my-api'
});
exports.handler = verifyJwt(async (event, context) => {
const { claims } = context.identityContext;
return {
statusCode: 200,
body: `Hi there ${claims.email}!`
};
});
Let's take a look at how we can use this library to secure Netlify Functions: we're going to create a very simple Gatsby application which will authenticate users with Auth0 and then interact with Netlify Functions.
Configuring Auth0
Before we can build our application we're going to configure our Auth0 account with 2 new entities:
- A Application representing the Gatsby application
- An API representing the API we'll build in Netlify Functions
Configuring the Gatsby client
In the Applications section we'll create a new Application:
- Application Type: Single Page Application
- Allowed Callback URL: http://localhost:8888
- Allowed CORS: http://localhost:8888
- Allowed Web Origins: http://localhost:8888
- Allowed Logout URL: http://localhost:8888
Configuring the API
In the APIs section we'll create a new API representing our backend. In this example we'll create a simple TV shows API so I'm filling in the following settings:
- Name: TV Shows
- Identifier: https://api/tv-shows
It doesn't really matter what you choose as the Identifier, this will simply be the identifier or audience of your API.
We'll also create a few scopes in this API. Later in the implementation we'll enforce the presence of certain scopes to restrict access to certain functions. Applications will be able to request the read:shows
and create:shows
scopes.
Creating an API using Netlify Functions
So we'll create 2 functions which can be called from the Gatsby application:
-
/.netlify/functions/me
: This function will require the user to be authenticated and simply return the current user information. -
/.netlify/functions/shows
: In addition to being authenticated, this function will also require theread:shows
scope to be available.
To start we'll create a file containing all of the authentication logic using @serverless-jwt/netlify
. This will create a JWT verifier for Netlify which we can use to secure our functions.
const { NetlifyJwtVerifier, removeNamespaces, claimToArray } = require('@serverless-jwt/netlify');
const verifyJwt = NetlifyJwtVerifier({
issuer: process.env.JWT_ISSUER,
audience: process.env.JWT_AUDIENCE,
mapClaims: (claims) => {
const user = claims;
user.scope = claimToArray(user.scope);
return user;
}
});
/**
* Require the request to be authenticated.
*/
module.exports.requireAuth = verifyJwt;
Let's also go ahead and create a .env
file containing the issuer and audience:
-
JWT_ISSUER
: Your Auth0 (Custom) Domain -
JWT_AUDIENCE
: The identifier of the API you created
JWT_ISSUER=https://sandrino.auth0.com/
JWT_AUDIENCE=https://api/tv-shows
And that's it. We can now import the verifier and secure our handler:
const { requireAuth } = require('../../lib/auth');
exports.handler = requireAuth(async (event, context) => {
try {
const { claims } = context.identityContext;
return {
statusCode: 200,
body: JSON.stringify({ claims })
};
} catch (err) {
return {
statusCode: 500,
body: JSON.stringify({ error_description: err.message })
};
}
});
This handler will now only execute if a valid token has been provided, otherwise it will fail and return an error message to the user. Let's go ahead and build a Gatsby application to test this out.
Building the Gatsby application
I will not go into too much detail on how to build the Gatsby application, since there's plenty of resources available covering this topic in depth. Now for this example to work we'll need to use Auth0's React SDK:
import React from 'react';
import { navigate } from 'gatsby';
import { Auth0Provider } from '@auth0/auth0-react';
import './src/styles/site.css';
const onRedirectCallback = (appState) => navigate(appState?.returnTo || '/');
export const wrapRootElement = ({ element }) => {
return (
<Auth0Provider
domain={process.env.GATSBY_AUTH0_DOMAIN}
clientId={process.env.GATSBY_AUTH0_CLIENT_ID}
audience={process.env.GATSBY_AUTH0_AUDIENCE}
scope={process.env.GATSBY_AUTH0_SCOPE}
redirectUri={window.location.origin}
onRedirectCallback={onRedirectCallback}
>
{element}
</Auth0Provider>
);
};
The environment variables come from my .env
file. The environment variables are prefixed with GATSBY_*
, making them available in the browser. Fill in the following settings:
-
GATSBY_AUTH0_DOMAIN
: Your Auth0 (Custom) Domain -
GATSBY_AUTH0_CLIENT_ID
: The Client ID of your Gatsby application -
GATSBY_AUTH0_AUDIENCE
: The identifier of the API you created -
GATSBY_AUTH0_SCOPE
: Theopenid profile
scopes are used for authentication while theread:shows
scope will be present in theaccess_token
which we'll provide to Netlify Functions.
GATSBY_AUTH0_DOMAIN=sandrino.auth0.com
GATSBY_AUTH0_CLIENT_ID=6qy0GxNC46tKvoYPc60mlW6X55jyrlaY
GATSBY_AUTH0_AUDIENCE=https://api/tv-shows
GATSBY_AUTH0_SCOPE=openid profile read:shows
Now that the Gatsby application has been configured we'll add a Login button to the header:
import React from 'react';
import { useAuth0 } from '@auth0/auth0-react';
export default () => {
const { loginWithRedirect } = useAuth0();
return (
<header>
<div>
<span className="text-xl">Gatsby & Netlify Functions</span>
<button onClick={() => loginWithRedirect()}>Login</button>
</div>
</header>
);
};
Clicking this button will redirect users to Auth0 where they can authenticate. Once they are authenticated they can call the API:
export default function Home() {
const [response, setResponse] = useState();
const { getAccessTokenSilently, isLoading, error, user } = useAuth0();
const getUserProfile = async () => {
try {
setResponse('Loading...');
const token = await getAccessTokenSilently({
audience: process.env.GATSBY_AUTH0_AUDIENCE
});
const api = await fetch('/.netlify/functions/me', {
headers: {
authorization: 'Bearer ' + token
}
});
const body = await api.json();
setResponse({
status: api.status,
statusText: api.statusText,
body
});
} catch (e) {
setResponse(e.message);
}
};
return <Layout>...</Layout>;
}
We'll first use the Auth0 React SDK to acquire an access_token
using the getAccessTokenSilently
method, which can then be presented to our first function. If the token is valid, the function will return the user and it will be rendered on the page.
Implementing Authorization
Let's now also add a simple authorization use case to our example. If you want to list the TV Shows that are available you might need to have a subscription or a specific role.
So we'll implement a helper which will require a scope to be present when a call is made to a function:
module.exports.requireScope = (scope, handler) =>
verifyJwt(async (event, context, cb) => {
const { claims } = context.identityContext;
// Require the token to contain a specific scope.
if (!claims || !claims.scope || claims.scope.indexOf(scope) === -1) {
return json(403, {
error: 'access_denied',
error_description: `Token does not contain the required '${scope}' scope`
});
}
// Continue.
return handler(event, context, cb);
});
If a valid token is presented, this will now require the existence of a given scope. And we can use this to implement our TV Shows endpoint. For this function, the user must present a token with a read:shows
claim.
const fetch = require('node-fetch');
const { requireScope } = require('../../lib/auth');
exports.handler = requireScope('read:shows', async (event, context) => {
try {
const res = await fetch('https://api.tvmaze.com/shows');
const shows = await res.json();
return {
statusCode: 200,
body: JSON.stringify(
shows.map((s) => ({
id: s.id,
url: s.url,
name: s.name
}))
)
};
} catch (err) {
return {
statusCode: 500,
body: JSON.stringify({ error_description: err.message })
};
}
});
By default Auth0 will allow applications to request any of the available scopes on an API. But we can change this by flipping the Enable RBAC toggle on our API. By doing this, only users with the necessary role or permission will receive this scope.
We can now create a role (eg: TV Shows Viewer), add permissions to this role (eg: the read:shows
permissions) and then finally assign this role to a user.
Users without this role will not receive the read:shows
scope in their access_token
. If they attempt to call the function it will result in the following error:
{
"error": "access_denied",
"error_description": "Token does not contain the required 'read:shows' scope"
}
Users with the scope will be able to call the API just fine.
โ Success!
And that's it. In a few simple steps we've configured Auth0, added authentication to our Gatsby application, secured our Netlify Functions and implemented RBAC. Phew! ๐
A demo application is available in which you can test the flow end to end: https://gatsby-auth0-netlify-functions.netlify.app/.
The full source for the example application is available on GitHub.