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.
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: theAuthorize
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.
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
:
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
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:
When using Custom Domains in Auth0, you should configure that domain as the
issuerUrl
. So for example, you should usehttps://auth.sandrino.dev/
instead ofhttps://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):
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.
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:
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:
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 theaccess_token
fromauth0-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:
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:
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:
If you're using the Serverless framework, you can just add the required scope to your route:
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:
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:
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:
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:
{
"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:
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.
After creating the roles we can now assign it to a user:
Now when the user signs in you'll notice an important difference, the read:colors
scope is available.
{
"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:
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.