Obviously the way to send a holiday letter to a limited audience is to make a PDF of it and attach it to a BCC email. But what would be the fun in that?. With immeasurable thanks to the ever-patient Sandrino Di Mattia from Auth0, who held my hand teaching me all of this, I now have passwordless Auth0 and Netlify Functions working together on the backend.
Securing Netlify Functions with serverless-jwt and Auth0
Sandrino Di Mattia ・ Jul 28 ・ 6 min read
Create a user
In Postman, I performed an authenticated POST HTTP request against Auth0's Management API at https://lftbs.us.auth0.com/api/v2/users
with a Content-Type
header of application/json
and a body of:
{
"email": "listed_example@mydomain.com",
"email_verified": true,
"app_metadata": {},
"given_name": "Katie",
"family_name": "Kodes",
"name": "Katie Kodes",
"nickname": "the Python lady",
"connection": "email",
"verify_email": false
}
At first, I received an HTTP response with the Bad Request
status code 400
, and a response body of:
{
"statusCode": 400,
"error": "Bad Request",
"message": "connection is disabled (client_id: my_management_client_id - connection: email)",
"errorCode": "auth0_idp_error"
}
I realized I'd turned off almost the app/API connections in https://manage.auth0.com/dashboard/us/my-username/connections/passwordless
on a "principle of least security" (if I can't remember why an authorization is enabled, disable it & see what breaks). I flipped the appropriate application back on and tried again.
This time, I received an HTTP response with the Created
status code 201
, and a response body of:
{
"created_at": "2020-12-07T22:29:40.755Z",
"email": "listed_example@mydomain.com",
"email_verified": true,
"family_name": "Kodes",
"given_name": "Katie",
"identities": [
{
"connection": "email",
"user_id": "876545678",
"provider": "email",
"isSocial": false
}
],
"name": "Katie Kodes",
"nickname": "the Python lady",
"picture": "https://s.gravatar.com/avatar/543212345?s=480&r=pg&d=https%3A%2F%2Fcdn.auth0.com%2Favatars%2Fkk.png",
"updated_at": "2020-12-07T22:29:40.755Z",
"user_id": "email|876545678"
}
(Note: in running it again, I got the same response, only now the created_at
and updated_at
timestamps were different. Indeed, there were not redundant records at https://manage.auth0.com/dashboard/us/my-username/users
.)
Create a rule
To get listed_example@mydomain.com
to be embedded in the access token used later in this process, I had to create a "rule" at https://manage.auth0.com/dashboard/us/my-username/rules
and fill it with the following code:
function (user, context, callback) {
if (user.email) {
context.accessToken['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress']=user.email;
}
return callback(null, user, context);
}
The actual URL of the "schemas" name isn't important, other than making sure I type the same thing later in my Netlify Function -- but it does seem to only work if it looks like a URL. I tried simpler values like user-email
and the email address failed to become embedded in my access token.
Request a magic link
To fake being prompted to log in, in Postman I performed an unauthenticated GET HTTP request against https://my-username.us.auth0.com/passwordless/start
with a Content-Type
header of application/json
and a body of:
{
"client_id": "my_app_client_id",
"connection": "email",
"email": "not_a_user@mydomain.com",
"send": "link",
"authParams": {
"scope": "openid profile email read:letters",
"audience": "my_api_audience"
}
}
All of the space-delimited words in authParams.scope
do separate things but are important (well, TBD if read:letters
will be important, but the other words ensure proper data comes back encoded in the access token I'll obtain later by clicking a magic link).
Including all of client_id
, connection
, and authParams.audience
was also really important -- thanks, Sandrino.
At first, I received an HTTP response with the Bad Request
status code 400
, and a response body of {"error": "bad.connection", "error_description": "Public signup is disabled"}
.
That's a good thing -- I don't want strangers asking to get in.
I changed the email address in the body from not_a_user@mydomain.com
to listed_example@mydomain.com
and tried again. This time, I received an HTTP response with the OK
status code 200
, and a response body of:
{
"_id": "876545678",
"email": "listed_example@mydomain.com",
"email_verified": false
}
Fetch a token from the magic link
I checked my e-mail and saw:
From: Katie Kodes <root@auth0.com>
To: listed_example@mydomain.com
Subject: Welcome to Letter From Katie
Date: Monday, December 07, 2020 6:37 PM
Size: 29 KB
Welcome to Letter From Katie!
Click and confirm that you want to sign in to Letter From Katie. This link will expire in five minutes:
https://my-username.us.auth0.com/passwordless/verify_redirect?scope=openid%20profile%20email%20read%3Aletters&response_type=token&redirect_uri=https%3A%2F%2Fmy-username.us.auth0.com%2Fauth0%2Fcallback&audience=my_api_audience&verification_code=987987&connection=email&client_id=my_app_client_id&email=listed_example%40mydomain.com
If you are having any issues with your account, please contact us through our Support Center .
Thanks!
Letter From Katie
At some point I'll have to figure out how to customize the wording of the email so as not to confuse tech-savvy people (I mean, I don't exactly have a "support center") -- plus Auth0 wants me to use someone else's SMTP for production, nottheirs.
Nevertheless, visiting this magic link from my email inbox, I'm redirected to https://my-username.us.auth0.com/auth0/callback#access_token=REALLY-LONG-TOKEN&scope=openid%20profile%20email%20read%3Aletters&expires_in=7200&token_type=Bearer
. Unless I try to visit the magic link a 2nd time, that is. In that case, I'm redirected to https://my-username.us.auth0.com/auth0/callback#error=unauthorized&error_description=Wrong%20email%20or%20verification%20code.
I won't expect real users to do this -- I still have to write front-end code to handle it for them -- but this works for testing purposes.
Inspect the token
Grabbing REALLY-LONG-TOKEN
out of that URL and pasting it into https://jwt.io/, I can see that its data payload is:
{
"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress": "listed_example@mydomain.com",
"iss": "https://my-username.us.auth0.com/",
"sub": "email|876545678",
"aud": [
"my_api_audience",
"https://my-username.us.auth0.com/userinfo"
],
"iat": 1607379133,
"exp": 1607386333,
"azp": "my_app_client_id",
"scope": "openid profile email read:letters",
"permissions": [
"read:letters"
]
}
That's great -- I see listed_example@mydomain.com
(Sandrino and I had to work through adding "email" to the initial link-sending API call and adding a Rule to Auth0 to get this working).
Summon a Netlify Function
Finally, I was ready to make a GET
-typed HTTP request to http://my-site.netlify.com/.netlify/functions/hiAuth
with an Authorization
header of Bearer REALLY-LONG-TOKEN
.
The JavaScript behind this function is straight from Sandrino's tutorial and is:
// /functions/hiAuth.js
const { NetlifyJwtVerifier } = require('@serverless-jwt/netlify');
const verifyJwt = NetlifyJwtVerifier({
issuer: process.env.AUTH0_JWT_ISSUER,
audience: process.env.AUTH0_JWT_AUDIENCE,
});
exports.handler = verifyJwt(async (event, context) => {
const { claims } = context.identityContext;
return {
statusCode: 200,
body: `Hi there ${claims['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress']}!`
};
});
I received an HTTP response with the OK
status code 200
and a response body of Hi there listed_example@mydomain.com!
.
Perfect.
Purposely deforming the token, I received an HTTP response with the Unauthorized
status code 401
, a Content-Type
header of application/json
, and a response body of {"error":"jwt_invalid","error_description":"Invalid token provided"}
.
Also good. I don't want people getting secret content without permission out of my Netlify Function. That said, it could probably use a nicer exception handler.
Purposely omitting the token altogether, I received an HTTP response with the Unauthorized
status code 401
, a Content-Type
header of application/json
, and a response body of {"error":"invalid_header","error_description":"The Authorization header is missing or empty"}
.
Also good -- with the caveat of needing to improve exception handling, more along the lines of this JavaScript that is meant to serve a similar function using Netlify Identity authentication instead of generic JWT authentication, based on Thor and Jason's tutorial on the Netlify blog:
// /functions/helloNetlify.js
// Begin HTTP-GET handler
exports.handler = async (event, context) => {
// "clientContext" is the magic of turning on "Identity" in Netlify -- all function calls from Netlify-hosted pages w/ the "widget" in them have it
const { user } = context.clientContext;
const roles = user ? user.app_metadata.roles : false;
// Begin bad-login short-circuit
if ( !roles || !roles.some((role) => ['fammy'].includes(role)) ) { // PRODUCTION LINE
//if (roles) { // DEBUG LINE ONLY
return {
statusCode: 402,
body: JSON.stringify({
message: `This content requires authentication.`,
}),
};
} // End bad-login short-circuit
// Begin returning secret content
return {
statusCode: 200,
body: JSON.stringify({
message: `HELLO, FRIEND OR FAMILY`,
}),
}; // End returning secret content
}; // End HTTP-GET handler
I'm quite happy with how everything turned out once Sandrino got involved.
I feel ready to move on to the front end and build a "callback" URL filled with JavaScript that can take care of transforming access tokens into cookies for me.