Creating an Identity Service with Node.js Part 1
A common pattern that is required when writing applications is to provide a way for users to authenticate, sometimes we can utilise an OAuth2 provider like Auth0 or Okta, but this isn't always the most suitable solution given contraints like a short budget or wanting to have authentication as part of your core service.
This article is going to cover a simple setup for identity, for enterprise grade identity where the implementation has been heavily tested & verified, use a service like Auth0 or Okta is possible.The code in this article was written with the best intentions in mind, and we will identify as part of this article points that need to be considered before implementing this code, or any authentication mechanisms, within your project.
For a minimum viable product we need to be able to exchange credentials for a bearer token, and to be able to validate those bearer tokens at a later time. There are many other features we could implement in our identity service, for example WebAuthn support, however we will cover additions to the service we're creating in future articles.
For this article I am assuming you have both Node.js and the npm cli installed, and are familiar with the command line.
I am running these examples in the directory
~/src/identity-service
on macOS 10.14 with Node.js 11.13.0 and npm cli version 6.7.0
We're going to first create our package, and install express
which we will use to route requests to handlers within our service, we're also going to utilise esm
to support the esm syntax while its in the experimental phase in Node.js
bashnpm init # Create your package.json file npm install --save express npm install --save-dev esm
$$$
We're going to house all our service files in a service
folder, within our service
folder we're also going to need a few files that we're going to use for our setup & handlers
bashmkdir service touch service/index.js mkdir service/handlers touch service/handlers/create-credentials.js touch service/handlers/exchange-credentials.js mkdir service/middleware touch service/middleware/verify-bearer.js
$$$$$$$
We're going to set up a bear bones express instance that we will later add our routes to, this will be in our service/index.js
file:
jsimport express from "express"; const app express(); // IIFE so we don't need to define `port` as `let` ¯\_(ツ)_/¯ const port = (() => { if (/^\d+$/.test(process.env.PORT)) { return +process.env.PORT; } // Maybe you have other defaults you want to check here to decide on the port return 8080; })(); app.listen(port, () => console.log(`Listening on port ${port}`));
We can run our service now using node -r esm service/index.js
, you should see Listening on port 8080
in your console.
While developing we won't want to restart our service every time we make a change, so instead we're going to utilise the npm module nodemon, this will restart our service each time a file is changed in the service directory, we can install this by running:
bashnpm install --save-dev nodemon
$
And then in our package.json
file we're going to add a start
key to our scripts
object with the value nodemon --watch service -- -r esm service/index.js`:
json{ "scripts": { "start": "nodemon --watch service -- -r esm service/index.js" } }
Now instead of running our full node
command, we can run:
bashnpm start
$
In your console you should now see something similar to this:
bash> identity-service@1.0.0 start ~/src/identity-service > nodemon --watch service -- -r esm service/index.js [nodemon] 1.18.11 [nodemon] to restart at any time, enter `rs` [nodemon] watching: ~/src/identity-service/service/**/* [nodemon] starting `node -r esm service/index.js` Listening on 8080
$$$$$$$$
Now we have a service we can write code in we're going to create a handle to create new credentials, but first we're going to need a database to store said credentials in, for this we're going to utilise the npm module level, this will give us a database with no setup, ideal for development:
bashnpm install --save level
$
We're going to import these modules and then create an instance that will be stored on our express instance using locals
, this can be later accessed in our route handlers using request.app.locals
:
js// These will be at the top of your index.js file: import level from "level"; // After `const app = express();` in our index.js file: app.locals.store = level("./store");
Now we're going to create a handler to accept new credentials, we're going to expect a JSON request where the body is an object containing the key type
with the value username-password
, if the type doesn't match we will return the status code 400
for "Bad Request", if the type matches we're also going to expect a string value for username
, and a string for password
. For our username
we're going to require a length of at least 1 (no reason we can't have a single character username), and for our password
we're going to require a length of at least 8 characters (this seems to be the general number used for minimum length)
In our create-credentials.js
file:
jsimport express from "express"; async function handleCreateCredentialsUsernamePassword(request, response) { if (typeof request.body.username !== "string" || request.body.username.length < 1) { return response.sendStatus(400); } if (typeof request.body.password !== "string" || request.body.password.length < 8) { return response.sendStatus(400); } // We're going to create our credentials here! } function handleCreateCredentialsRoute(request, response, next) { if (!request.body) { return response.sendStatus(400); } // In future articles we're going to add new credential types! So lets prepare for that using a switch here switch(request.body.type) { case "username-password": return handleCreateCredentialsUsernamePassword(request, response).catch(next); default: response.sendStatus(400); } } // express allows a "handler" to be an array, as it will flatten out the // list of handlers and invoke them in serial export default [ // Parse our body as json express.json(), handleCreateCredentialsRoute ];
We're going to be receiving a plain text password, meaning it if we were to store the password as is anyone with access to our store will be able to read the password and know its value, so before saving the password in our database we're going to hash the password, hashing provides a way to retreive a representation of a password that can be verified against only if the original value is provided.
To hash our passwords we're going to utilise argon2 (recommended here), we're also going to create an identifier for our identity using pure-uuid:
bashnpm install --save argon2 pure-uuid
$
In our handleCreateCredentialsUsernamePassword
function we will hash our password, and then store our credentials in our store using the key credentials:lowerCaseUsername
and the value as our password, we're also going to create an "identity" that can be accessed using the provided credentials.
js// At the top of our file import argon from "argon2"; import UUID from "pure-uuid"; // Within our `handleCreateCredentialsUsernamePassword` function const hash = await argon.hash(request.body.password); const username = request.body.username.trim(); const lowerCaseUsername = username.toLowerCase(); const credentialsKey = `credentials:${lowerCaseUsername}`; const store = request.app.locals.store; // Ensure it doesn't already exist // Use catch to return falsy if the key doesn't exist if (await store.get(credentialsKey).catch(() => undefined)) { return response.sendStatus(409); // Conflict } // Create identifier scoped to our host const uuid = new UUID(4).format(); const identity = { id: uuid, primaryUsername: username }; // Store our identity await store.put(`identity:${uuid}`, JSON.stringify(identity)); // Store our new credentials await store.put(credentialsKey, JSON.stringify({ hash, identity: uuid })); // Return 201, return our new id, it doesn't let the caller // do any additional with it, they will next need to invoke exchange-credentials // to make use of their new identity return response.status(201).json({ id: uuid });
We also need to hook up this route, so in our index.js
file we will add the route POST /create-credentials
:
js// At the top of our file import createCredentialsHandler from "./handlers/create-credentials"; // After `app.locals.store = levelup(leveldown("./store"));` app.post("/create-credentials", createCredentialsHandler);
Now when we start our service, we should be able to run this curl
command, which will return our new identifier:
bashcurl -XPOST http://localhost:8080/create-credentials -H "Content-Type: application/json" -d '{"type": "username-password", "username": "John Doe","password":"correcthorsebatterystaple"}'
$
If we run the same request again, we will receive the status 409 Conflict
, meaning we have to select a different username.