Passwordless Auth0 and Netlify functions: backend
Katie
Posted on December 7, 2020
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.
Posted on December 7, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.