Securing Netlify Functions with serverless-jwt and Auth0

Sandrino Di Mattia - Jul 28 '20 - - Dev Community

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}!`
  };
});
Enter fullscreen mode Exit fullscreen mode

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.

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:

Create application in Auth0

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:

Create API in Auth0

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.

Create scopes in Auth0

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 the read: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;
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 })
    };
  }
});
Enter fullscreen mode Exit fullscreen mode

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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: The openid profile scopes are used for authentication while the read:shows scope will be present in the access_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
Enter fullscreen mode Exit fullscreen mode

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 &amp; Netlify Functions</span>
        <button onClick={() => loginWithRedirect()}>Login</button>
      </div>
    </header>
  );
};
Enter fullscreen mode Exit fullscreen mode

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>;
}
Enter fullscreen mode Exit fullscreen mode

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);
  });
Enter fullscreen mode Exit fullscreen mode

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 })
    };
  }
});
Enter fullscreen mode Exit fullscreen mode

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.

Enable RBAC

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.

Add roles to 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"
}
Enter fullscreen mode Exit fullscreen mode

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.

. . . . . .