Controlling access to your AWS API Gateway HTTP API with Auth0

SD

Sandrino Di Mattia / May 28, 2020

9 min read––– views

Want to skip the details? Try out the online demo.

Introduction#

A few weeks ago AWS API Gateway HTTP APIs became generally available - offering a simpler, faster and cheaper way to build APIs. One of the capabilities that has been simplified is the whole authorization story, which is what we'll be covering in this blog post.

For the traditional REST APIs you would often write your own Custom Authorizer using a Lambda function to support JWT authorization (mainly in the context of OpenID Connect and OAuth 2.0). This is no longer needed for HTTP APIs, which offers a JWT authorizer out of the box.

AWS API Gateway JWT Authorizer

What this means is that you can configure your own JWT authorizer by providing 3 simple settings instead of having to deploy and maintain your custom authorizers:

  • identitySource: Which refers to where the token can be found (eg: the Authorize header)
  • issuer: The issuer URI of the Identity Provider (eg: https://acme.auth0.com/)
  • audience: The audience of your API

Auth0 as a JWT Authorizer#

Let's see how we can configure Auth0 as a JWT Authorizer. We'll be building a simple API returning colors with public endpoints and private endpoints, requiring the user to authenticate first.

Configuring your Auth0 account#

In your account you'll want to represent your HTTP API as an API in Auth0, which you'll need to give a name and an identifier. The identifier is what will end up being the audience of your JWT Authorizer.

Configuring API settings in Auth0

I'm also going to create a "Single Page Application" under Applications since I'll write a small React application to interact with the HTTP API. Also here the configuration is relatively simple.

  • Allowed Callback URLs: http://localhost:3000
  • Allowed Web Origins: http://localhost:3000
  • Allowed Origins (CORS): http://localhost:3000

We configure the application with http://localhost:3000 so we can test everything locally.

And the last thing we'll do on the Auth0 side is write a rule which will add some custom claims to the access_token:

add_claims_to_access_token
function (user, context, callback) {
  context.accessToken['http://sandrino/roles'] =  [ 'admin', 'member' ];
  context.accessToken['http://sandrino/email'] =  user.email;
  context.accessToken['http://sandrino/email_verified'] =  user.email_verified.toString();
  return callback(null, user, context);
}

And that's actually all there is to it. Let's move on to the HTTP API.

Configuring your HTTP API#

There are plenty of resources out there (example on the auth0.com blog) which show you how to configure a JWT Authorizer in the AWS console, so in this post we'll use the Serverless framework instead.

In the configuration below you'll see a few things:

  • CORS is enabled for my API because we want the browser to be able to interact with it
  • Auth0 was configured as an authorizer, by simply providing the Issuer URL and the audience of the API we created in the Auth0 dashboard
  • We have two endpoints: /colors which is public (no authorizer configured), and /my/profile which will the request to be authorized
serverless.yml
service: http-api-jwt-example

provider:
  name: aws
  runtime: nodejs12.x
  httpApi:
    cors:
      allowedHeaders:
        - Content-Type
        - Authorization
      allowedMethods:
        - GET
        - OPTIONS
      allowedOrigins:
        - http://localhost:3000
    payload: '2.0'
    authorizers:
      accessTokenAuth0:
        identitySource: $request.header.Authorization
        issuerUrl: https://sandrino.auth0.com/
        audience:
          - urn:colors-api

functions:
  # List all colors (public endpoint)
  getColors:
    handler: handler.colors
    events:
      - httpApi:
          method: GET
          path: /colors
  # List my profile (requires authorization)
  myProfile:
    handler: handler.myProfile
    events:
      - httpApi:
          method: GET
          path: /my/profile
          authorizer:
            name: accessTokenAuth0

If we apply this configuration you'll see a new JWT Authorizer show up in your AWS console:

AWS API Gateway JWT Authorizer

When using Custom Domains in Auth0, you should configure that domain as the issuerUrl. So for example, you should use https://auth.sandrino.dev/ instead of https://sandrino.auth0.com/.

When a JWT Authorizer is configured for a route you won't have to worry about parsing and validating the token. If a valid token is provided, the claims will be available in the event - otherwise the request will fail. Below is an example of a function accessing the claims provided by the JWT Authorizer and also extracting any custom claims we might have added (using Auth0 Rules):

handler.js
module.exports.myProfile = async (event) => {
  const claims = event.requestContext.authorizer.jwt.claims;
  return {
    id: claims.sub,
    roles: claims['http://sandrino/roles'],
    claims
  };
};

Calling the API from the browser#

We'll also create a simple Next.js application which use auth0-spa-js to sign in from the browser.

lib/auth0.js
import { Auth0Client } from '@auth0/auth0-spa-js';

export default new Auth0Client({
  domain: 'https://sandrino.auth0.com', // Should be your Auth0 domain or your custom domain if you have configured it
  client_id: 'z9p3mE4Oc1PN4XKooapkPpn22nRONdJC',
  audience: 'urn:colors-api', // This should be the audience of the API you configured
  scope: 'openid profile email'
});

Our application will have a login button:

components/login.js
login = async () => {
  await auth0.loginWithRedirect({
    redirect_uri: window.location.href
  });
};

And after the user has signed in, we'll use a React hook to fetch the data from our API:

components/api.js
export default () => {
  const { data, error, isPending, get } = useApi(process.env.API_GATEWAY_BASE_URL);

  const getColors = () => get('/colors', { auth: false });
  const getMyProfile = () => get('/my/profile');

  return (
    <>
      <Stack mb={5} isInline spacing={4}>
        <Button size="md" onClick={getColors}>
          List All Colors
        </Button>
        <Button size="md" onClick={getMyProfile}>
          My Profile
        </Button>
      </Stack>
      <Snippet
        code={
          (isPending && '// Loading...') ||
          (error && JSON.stringify(error, null, 2)) ||
          (data && JSON.stringify(data, null, 2)) ||
          '// Press one of the buttons above to call the API Gateway'
        }
        language="json"
      />
    </>
  );
};

useApi is a custom React hook which retrieves the access_token from auth0-spa-js and attaches it to the Authorization header of the HTTP request. The implementation can be found in the sample project.

If we try this out we'll notice that unauthenticated calls fail:

Unauthenticated Call

But when we do sign in through Auth0 and the access_token is provided in the HTTP request the JWT Authorizer will allow the request to go through to our handler:

Authenticated Call

You'll notice that the issuer here is set to https://auth.sandrino.dev/ since I'm doing all of my tests with my Auth0 Custom Domain

Authorization Scopes and Role Based Access Control#

On each route you also have the option to configure Authorization Scopes, allowing you to define which scope should be present in the token to access the API route. In the example below you can see that I'm now requiring the read:colors scope for a new endpoint:

Authorization Scopes

If you're using the Serverless framework, you can just add the required scope to your route:

serverless.yml
myColors:
  handler: handler.myColors
  events:
    - httpApi:
        method: GET
        path: /my/colors
        authorizer:
          name: accessTokenAuth0
          scopes:
            - read:colors

No code changes are required to your Lambda function, everything is handled by the JWT Authorizer. The only change you'll need to make is to request this scope on the client side:

lib/auth0.js
import { Auth0Client } from '@auth0/auth0-spa-js';

export default new Auth0Client({
  domain: 'https://sandrino.auth0.com', // Should be your Auth0 domain or your custom domain if you have configured it
  client_id: 'z9p3mE4Oc1PN4XKooapkPpn22nRONdJC',
  audience: 'urn:colors-api', // This should be the audience of the API you configured
  scope: 'openid profile email read:colors' // We're requesting an additional scope here
});

Where it gets really interesting is the link with Auth0's Role Based Access Control features, where we can control access to certain scopes using roles and permissions. Let's look at an example. On the API we'll configure a new read:colors permission:

Configuring API scopes in Auth0

As part of this we'll also want to enable authorization policies for our API (the Enable RBAC toggle). This will require the user to have the right set of permissions:

Configuring API scopes in Auth0

With this setting enabled, the user needs to have the read:colors permission otherwise the scope will not be allowed. So even if a client requests openid profile email read:colors, the token will end up looking like this:

access_token
{
  "http://sandrino/email": "sandrino@auth0.com",
  "http://sandrino/email_verified": "true",
  "iss": "https://auth.sandrino.dev/",
  "sub": "auth0|593b80d5f8a341400599ce4f",
  "aud": [
    "urn:colors-api",
    "https://sandrino.auth0.com/userinfo"
  ],
  "iat": 1590666735,
  "exp": 1590753135,
  "azp": "z9p3mE4Oc1PN4XKooapkPpn22nRONdJC",
  "scope": "openid profile email"
}

Notice how the read:colors scope is not there, even though it was requested by the client

And as a result, a call to the new endpoint we created will fail because our token is missing the right scope:

Request failed due to missing scope

Let's go ahead and create a Role in Auth0 to which we'll add the read:colors permission. By adding the permission to this role, any user with this role assigned will be able to receive the read:colors scope.

Create Role

After creating the roles we can now assign it to a user:

Role assigned to user

Now when the user signs in you'll notice an important difference, the read:colors scope is available.

access_token
{
  "http://sandrino/email": "sandrino@auth0.com",
  "http://sandrino/email_verified": "true",
  "iss": "https://auth.sandrino.dev/",
  "sub": "auth0|593b80d5f8a341400599ce4f",
  "aud": [
    "urn:colors-api",
    "https://sandrino.auth0.com/userinfo"
  ],
  "iat": 1590678148,
  "exp": 1590764548,
  "azp": "z9p3mE4Oc1PN4XKooapkPpn22nRONdJC",
  "scope": "openid profile email read:colors"
}

When we try to access the new endpoint ... it works! With the required scope we are able to make a call to the new route:

Request succeeded

You users will need to retieve a new access token from Auth0 before such a change has effect in your application. This happens by signing in again, through Silent Authentication (using an iframe) or by using a Refresh Token.

✅ Success!#

As you've seen, with JWT Authorizers it becomes very easy to cover the authorization aspect of your API Gateway. And while this article has focused on end-user authentication, the same concepts of access_tokens and scope also apply to applications talking to your API on their own behalf.

The full sample of the Serverless framework project and the Next.js application can be found on GitHub.

Discuss on Twitter