If you followed along in the previous tutorial, Create a REST API on AWS Lambda using Lambdasync, you should have a simple API for a note taker app, hosted on AWS Lambda, backed by a MongoDB database hosted on mLab.
Sounds great? Sure, but it does however have a pretty major flaw, it has no concept of users and everyone calling the API will have access to the same notes. Makes for a very simple getting started example, but not especially useful in a real world scenario.
Let's fix that! By the end of this tutorial you will have:
- A login/sign up flow through Auth0
- An authenticated API that Creates, Reads, Updates and Deletes
notes
records for the authenticated user
This post is a stand alone follow up to the previous post mentioned above. You will recognize some of the code if you followed along with the previous tutorial, but it's not required.
Authentication as a service
My main fascination with AWS Lambda and Function as a Service in general is the idea of focus.
By leaving server config and scaling to companies that focus on that part, like AWS, it frees me up to focus only on what should happen when a user's request reaches my API.
In the same way I can delegate a lot of my user management and authentication issues to a third party focusing on that.
Auth0 does this particularly well, with products like Lock that handles the registration/login process on the client side, producing a JSON Web Token that we can use on the server side to establish the identity of the user making the request.
We will build a note taking app, using Auth0 to authenticate the user through email/password, Facebook or Google, and let the user create, read, update and delete only his/her own notes.
The focus will be on the API side, there will be client side consisting of a html file and an express server to serve the html file on port 3001, but we won't cover any details about that except for the Auth0 client side parts.
Prerequisites
You will need an AWS account and credentials, instructions on how to get your credentials can be found here.
You will also need to have Lambdasync installed globally. We'll use it to deploy our Lambda function:
npm install -g lambdasync
You will also need a MongoDB database and a mongodb://
URL to connect to before you get started, instructions on how to set up a free MongoDB database can be found here, it's a fairly quick process that takes a few minutes.
In case you are only here for the basic Auth0 setup help, you're in luck, since that is exactly where we will start.
Auth0 set up
Make an account at auth0.com, they will ask you a bunch of stuff and set up a Default app
for you.
I recommend you set up a new Client for this though, start by clicking the + New Client
button on the dashboard:
Give it an appropriate name, select Single Page Web Applications
and click Create
:
Select JavaScript
on the next screen of front end frameworks´.
Congratulations! You have created a new Auth0 Client. You are even given some quick start code that tells you how to implement a login on your site, very helpful.
Ignore that for now though and jump over to the settings tab.
Make note of your Domain
, Client ID
and Client Secret
here, you will need them soon.
Before we get to any coding we need to first set the Allowed Callback URLs
, Allowed Logout URLs
and Allowed Origins (CORS)
. This is so that no malicious sites can hijack your user's logins.
For Callback and Logout enter http://localhost:3001
and for CORS just enter localhost:3001
without the protocol.
If you know the URL of your production environment you may as well add that too, it's just comma separated lists.
Save and head over to the Connections
tab to make sure you have Facebook, Google and Username-Password-Authentication turned on, these should be Auth0 defaults, but it doesn't hurt to confirm.
If you just signed up for Auth0, however, these connections have not been activated yet. Click on Connections
on the left hand menu, and then on Social
and it should look something like this:
Click the little !
-icon for Google and facebook and activate them using the defaults.
Client side login/signup code
Auth0 makes this part extremely easy with a library called Lock
, you can install it from npm as auth0-lock
but for this project we will just get it from a CDN.
<script src="https://cdn.auth0.com/js/lock/10.6/lock.min.js"></script>
<script>
var lock = new Auth0Lock(
YOUR_AUTH0_CLIENT_ID_HERE,
YOUR_AUTH0_DOMAIN_HERE
);
lock.on("authenticated", function(authResult) {
// Use the token in authResult to getProfile() and save it to localStorage
lock.getProfile(authResult.idToken, function(error, profile) {
if (error) {
// Handle error
return;
}
localStorage.setItem('idToken', authResult.idToken);
localStorage.setItem('profile', JSON.stringify(profile));
});
});
// This is the part that shows the login dialog
lock.show();
</script>
You initialize Lock with your client id and domain from Auth0, and then call lock.show()
whenever you want to show the login dialog.
You can listen to an authenticated
event which gives you the idToken that you will send to the API for every request requiring authentication (which is why we save it to localStorage).
Lock also makes it easy to get the profile data on the user by just passing the idToken to lock.getProfile
.
There isn't really much else we will need to worry about concerning login or signup. Our app will have a Login or sign up button triggering lock.show()
, and we will check localStorage for an idToken
to know if a user is logged in or not.
Copy the code from the client directory from here https://github.com/lambdasync/lambdasync-example-auth0/blob/master/client/ and simply npm install
and start it with node index.js
.
API Code
There's not much point to try out the client until we have an API.
Let's start by looking at the authentication, the main reason we're here. Create a src
folder and create an auth.js
file.
src/auth.js
'use strict';
const jwt = require('jsonwebtoken');
const AUTH0_DOMAIN = process.env.AUTH0_DOMAIN || null;
const AUTH0_CLIENT = process.env.AUTH0_CLIENT || null;
const AUTH0_SECRET = process.env.AUTH0_SECRET || null;
const credentials = {
audience: AUTH0_CLIENT,
issuer: `https://${AUTH0_DOMAIN}/`
};
function authenticate(token) {
return new Promise((resolve, reject) => {
if (!token) {
reject('Authorization token missing.');
}
jwt.verify(
token.replace('Bearer ', ''),
AUTH0_SECRET,
credentials,
(err, res) => {
if (err) {
return reject(err);
}
return resolve(res.sub);
}
);
});
}
module.exports = authenticate;
Authentication with JSON Web Tokens
The token you get from the Auth0 login i a JSON Web Token, or JWT. A JWT is a hash consistsing of 3 parts, separated by dots.
The first 2 parts are base64 encoded JSON objects, a header
and a payload
object. The header contains an alg
field, which is the hashing algorithm and typ
, the token type, and typically looks like this:
{
"alg": "HS256",
"typ": "JWT"
}
The payload will typically contain iss
, the issuer which in our case will be the Auth0 domain, sub
, the subject which will be the userId, aud
, the audience which will the the Auth0 client id, and exp
which is the timestamp when the token expires.
The first two parts are easily decodable, it's the third part that is the secure signature part. The signature can be verified using the secret it was created with, which also verifies that the headers and payload are the same as the signature was created for.
Verifying a token
The authenticate
function we created above returns a promise, either with an error or the decoded payload.
To verify the token on the API side, we make use of jsonwebtoken's verify
method, which takes the token, and the secret as mandatory arguments. As a bonus it lets us pass an options object with aditional things to verify, so we pass it a third argument to verify that the audience and issuer fields are what we expect.
The verify
method will call our callback with either an error or the decoded payload, and we use that to reject or resolve our promise.
That's it for authentication. We'll continue adding building blocks and utility functions before we get to the business logic.
MongoDB connection
The next file we'll add will handle connecting to our MongoDB database:
src/db.js
'use strict';
const MongoClient = require('mongodb').MongoClient;
function connect() {
const MONGO_URL = process.env.MONGO_URL || null;
return new Promise((resolve, reject) => {
MongoClient.connect(MONGO_URL, function (err, db) {
if (err) {
return reject(err);
}
return resolve(db);
});
});
}
module.exports = connect;
Again this is a promise that will either reject with an error or resolve with the db connection.
Util functions and constants
src/constants.js
'use strict';
const COLLECTION_NOTES = 'notes';
const SUCCESS_RESPONSE = {
result: 'success'
};
const STATUS_CODES = {
OK: 200,
BAD_REQUEST: 400,
INTERNAL_SERVER_ERROR: 500
};
module.exports = {
COLLECTION_NOTES,
SUCCESS_RESPONSE,
STATUS_CODES
};
src/util.js
'use strict';
const CONSTANTS = require('./constants');
function isResponseObject(subject) {
return typeof response === 'object' && response.statusCode && response.headers && response.body;
}
function formatResponse(statusCode, response) {
// Check if we have a valid response object, and if so, return it
if (isResponseObject(response)) {
return response;
}
let body = '';
try {
if (response) {
body = JSON.stringify(response);
} else {
body = JSON.stringify('');
}
} catch(err) {
body = JSON.stringify(response.toString())
}
return {
statusCode: statusCode || CONSTANTS.STATUS_CODES.OK,
headers: {
'Access-Control-Allow-Origin': '*'
},
body
};
}
function respondAndClose(db, callback, response, statusCode) {
db.close();
return callback(null, formatResponse(statusCode, response));
}
const idRegex = /^\/api\/(.*?)(\/|$)/;
function getIdFromPath(path) {
const match = idRegex.exec(path);
return match && match[1];
}
module.exports = {
respondAndClose,
getIdFromPath,
formatResponse
};
Most of the util functions are related to the format of the response object, which for a Lambda proxy integration should be a {httpMethod, headers, body}
object.
respondAndClose
is a little helper function that closes the db connection, and formats the response correctly so that we don't have to worry about that in the app logic.
getIdFromPath
will pick out the noteId from a path like /cd062d26-f62c-4ef5-8083-83e4fd86b4b9
.
With all of this in place, let's get down to business!
Entrypoint
Lambdasync mandates that the entrypoint to our app should be index.js
in the root folder, so we create a minimal one, that only loads and returns an app
module;
index.js
'use strict';
const app = require('./src/app');
exports.handler = app;
Adding notes
src/app.js
'use strict';
const note = require('./note');
const util = require('./util');
const auth = require('./auth');
const connect = require('./db');
const CONSTANTS = require('./constants');
const STATUS_CODES = CONSTANTS.STATUS_CODES;
const respondAndClose = util.respondAndClose;
function app(event, context, callback) {
if (!event || !event.headers) {
callback({
error: 'Not a valid event object'
});
}
let userId;
return auth(event.headers.Authorization)
.then(id => userId = id)
.then(connect)
.then(db => handleRequest(db, userId, event, callback))
.catch(err => callback(null, util.formatResponse(500, err.message)));
}
function handleRequest(db, userId, event, callback) {
const noteId = util.getIdFromPath(event.path);
let body = null;
try {
body = JSON.parse(event.body);
} catch(err) {
// meh
}
switch (event.httpMethod) {
case 'POST':
return note.addNote(db, userId, body)
.then(res => respondAndClose(db, callback, res))
.catch(err => respondAndClose(db, callback, err.message, STATUS_CODES.INTERNAL_SERVER_ERROR));
break;
default:
respondAndClose(db, callback, 'unhandled request', STATUS_CODES.BAD_REQUEST);
}
}
module.exports = app;
We get an event from Lambda, pass the Authorization
header to the auth
function. If the request is successfully authorized, we save the userId to an outside variable.
Next we call connect
to get a db connection and if that all worked we pass on everything to a new function handleRequest
.
This will be our routing function, separating requests by http method and calling the appropriate CRUD functions, and relaying the response or error back.
We start with the logic for adding notes, handling POST requests and passing them onto the addNote
function, which we will create next:
src/note.js
'use strict';
const uuid = require('node-uuid').v4;
const CONSTANTS = require('./constants');
function addNote(db, userId, note) {
if (!userId || !note) {
return Promise.reject('No userId or note to add.');
}
return new Promise((resolve, reject) => {
db
.collection(CONSTANTS.COLLECTION_NOTES)
.insertOne(
Object.assign({}, note, {
id: uuid(),
userId,
pinned: false
}),
err => {
if (err) {
reject(err);
}
resolve(CONSTANTS.SUCCESS_RESPONSE);
}
);
});
}
module.exports = {
addNote
};
addNote
returns a promise and tries to insert a new object into our database, the incoming note object will contain title
and text
, and we add some required fields to it, id
created by the node-uuid
library, userId
and pinned
.
If successful we'll return our standard success response, otherwise reject with an error.
Now that we have all the pieces in place we should be able to deploy this, first install our 3 dependencies:
npm install -SE jsonwebtoken mongodb node-uuid
Once that's done run lambdasync
to deploy your changes.
Testing in the client
With the API in place it's time to fire up the client. In client/index.html
edit these lines:
var API_URL = 'YOUR_API_URL_HERE';
var AUTH0_CLIENT = 'YOUR_AUTH0_CLIENT_ID_HERE';
var AUTH0_DOMAIN = 'YOUR_AUTH0_DOMAIN_HERE';
Then head to http://localhost:3001. Click the login button and login with whatever method you prefer.
Once logged in fill in a title and some text for a note and save, if you look at the network tab you should see a successful 200 response, but of course no notes on the page yet. Our API needs to be able to list notes for that. 🙂
Listing notes
src/app.js
...
case 'GET':
return note.getNotes(db, userId)
.then(res => respondAndClose(db, callback, res))
.catch(err => respondAndClose(db, callback, err.message, STATUS_CODES.INTERNAL_SERVER_ERROR));
src/note.js
...
function getNotes(db, userId) {
const filter = {userId};
return new Promise((resolve, reject) => {
db.collection(CONSTANTS.COLLECTION_NOTES).find(filter).toArray((err, data) => {
if (err) {
reject(err);
}
resolve(data);
});
});
}
module.exports = {
getNotes,
addNote
};
Add another clause to the switch in src/app.js
to handle GET requests.
Then add a getNotes function to src/note.js
. We don't have to do much other than pass a filter object with the userId to the find
method of the MongoClient and return the result (or error).
If you deploy this with lambdasync
and refresh the client app you should be able to see the note you saved before.
Editing notes
src/app.js
...
case 'PUT':
if (!noteId) {
return respondAndClose(db, callback, 'Missing id parameter', STATUS_CODES.BAD_REQUEST);
}
return note.updateNote(db, userId, noteId, body)
.then(res => respondAndClose(db, callback, res))
.catch(err => respondAndClose(db, callback, err.message, STATUS_CODES.INTERNAL_SERVER_ERROR));
src/note.js
...
function getNote(db, id, userId) {
const filter = {id};
return new Promise((resolve, reject) => {
db.collection(CONSTANTS.COLLECTION_NOTES).findOne(filter, (err, data) => {
if (err) {
return reject(err);
}
// If a usedId is supplied check ownership
if (typeof userId !== 'undefined' && data.userId !== userId) {
return reject('Not authorized');
}
return resolve(data);
});
});
}
function updateNote(db, userId, noteId, note) {
if (!note || !noteId) {
return Promise.reject();
}
return getNote(db, noteId, userId)
.then(currentNote => new Promise((resolve, reject) => {
db
.collection(CONSTANTS.COLLECTION_NOTES)
.updateOne({id: noteId}, Object.assign({}, currentNote, note), err => {
if (err) {
return reject(err);
}
return resolve(CONSTANTS.SUCCESS_RESPONSE);
});
}));
}
module.exports = {
updateNote,
getNotes,
addNote
};
Add a PUT clause to src/app.js
and 2 new functions to src/note.js
.
We use the getNote
both as an authentication check (to see if the userId owns it) and to return an existing note.
The updateNote
function will merge the changes of the existing note with the object passed to it.
You should now be able to edit notes in the client (after a deploy and a refresh).
Deleting notes
src/app.js
...
case 'DELETE':
if (!noteId) {
return respondAndClose(db, callback, 'Missing id parameter', STATUS_CODES.BAD_REQUEST);
}
return note.deleteNote(db, userId, noteId)
.then(res => respondAndClose(db, callback, res))
.catch(err => respondAndClose(db, callback, err.message, STATUS_CODES.INTERNAL_SERVER_ERROR));
src/note.js
...
function deleteNote(db, userId, noteId) {
if (!noteId) {
return Promise.reject('Missing note id');
}
// Make sure we have access to the note we are trying to delete
return getNote(db, noteId, userId)
.then(() => new Promise((resolve, reject) => {
db
.collection(CONSTANTS.COLLECTION_NOTES)
.deleteOne({id: noteId}, err => {
if (err) {
return reject(err);
}
return resolve(JSON.stringify(CONSTANTS.SUCCESS_RESPONSE));
});
}));
}
module.exports = {
deleteNote,
updateNote,
getNotes,
addNote
};
Last up is the ability to delete notes, same procedure here. New switch case and a new function. This time using deleteOne
from MongoClient.
That's it, you can now create, read, update and delete notes on your shiny new Lambda API.
The example code for this project can be found here: https://github.com/lambdasync/lambdasync-example-auth0/