Creating an Identity Service with Node.js Part 2

Creating an Identity Service with Node.js Part 2

This is a continuation of Creating an Identity Service with Node.js Part 1, we're going to jump right in to where we left off from there.

Now we have a way to create usernames and passwords, we want to have a way to exchange these credentials for a bearer token which the user can then use to make requests on behalf of the associated identity.

For our tokens we're going to utilise JSON Web Tokens, these will allow us to sign & verify our tokens using a key pair, and also allow us to identify what identity the token is associated with, without needing to look up details about the token within our store.

We'll install the JSON Web Token module for later use:

bash
npm install --save jsonwebtoken $$

To create tokens we're going to need a key pair, instead of using a single key pair across our service, we're going to generate a key pair on the fly, store the cerificate in our store, and then once we sign our token with the created private key we can discard it. Doing this means we never have a way to recreate a token, but always have a way to validate it.

For production use it is recommended to use software like HashiCorp's Vault, Vault provides a PKI Secrets Engine which allows for key pairs to be generated which can be used to sign our token, the public key generated then just needs to be retained for later checks.

Additionally, there is a JSON Web Token plugin for Vault.

We don't want our database filling up with public keys though, so we're going to utilise the npm module level-ttl so that we can expire certificates after a specified amount of time, to install this module run:

bash
npm install --save level-ttl $$

We will need to replace our store creation to include ttl:

js
// At the top of our file import ttl from "level-ttl"; // Replacing assignment of app.locals.store app.locals.store = level("./store"); app.locals.ttl = ttl(app.locals.store);

Now we need to create our handler to exchange credentials, we're going to accept a JSON request where the body is a object containing the key from with the value username-password, the key to with the value bearer , and our keys for our username and password that was used to create our initial credentials.

In our exchange-credentials.js file:

js
import express from "express"; async function getIdentityForUsernamePasswordCredentials(request, response) { if (typeof request.body.username !== "string" || request.body.username.length < 1) { return response.sendStatus(400); } // We aren't validating the length here except for requiring at least one character // the verification that it is correct will be done by comparing the hash if (typeof request.body.password !== "string" || request.body.password.length < 1) { return response.sendStatus(400); } // We're going to validate our password here and then return the associated identity } async function getIdentityForCredentials(request, response) { switch(request.body.from) { case "username-password": return getIdentityForUsernamePasswordCredentials(request, response); default: response.sendStatus(400); } } async function createKeyPair() { // We're going to create a key pair for use with jsonwebtoken here } async function generateBearerTokenCredentials(request, response, identity) { // We're going to generate our token here } async function generateCredentials(request, response, identity) { switch(request.body.to) { case "bearer": return generateBearerTokenCredentials(request, response, identity); default: response.sendStatus(400); } } function handleExchangeCredentialsRoute(request, response, next) { if (!request.body) { return response.sendStatus(400); } getIdentityForCredentials(request, response) .then(identity => { if (!identity) { // Already handled return; }; return generateCredentials(request, response, identity); }) .catch(next); } // 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(), handleExchangeCredentialsRoute ];

In our getIdentityForUsernamePasswordCredentials function we're going to want to see if we can find our username is valid, which we will also be able to use to retreive the hash of the associated password. Once we have our hashed password we will be able to compare the provided password and the hash to see if the requestor is authorised to access the associated identity, if we return undefined no credentials will be generated, and instead we will need to handle the response ourselves, this is helpful as we will be able to return 401 so the requestor knows the credentials provided was invalid.

js
// At the top of our file import argon from "argon2"; // In our `getIdentityForUsernamePasswordCredentials` function: const store = request.app.locals.store; const username = request.body.username.trim(); const lowerCaseUsername = username.toLowerCase(); const credentialsKey = `credentials:${lowerCaseUsername}`; const passwordInformation = await store.get(credentialsKey).catch(() => undefined); if (!(passwordInformation && passwordInformation.hash && passwordInformation.identity)) { response.sendStatus(401); // Already handled return undefined; } const match = await argon.verify(passwordInformation.hash, request.body.password); if (!match) { response.sendStatus(401); // Already handled return undefined; } return passwordInformation.identity;

In our createKeyPair function we're going to utilise crypto to generate a key pair using the generateKeyPair method, this is more of a convenience method so we can use generateKeyPair with a promise:

js
// At the top of our file import crypto from "crypto"; // In our `createKeyPair` function: return new Promise( (resolve, reject) => crypto.generateKeyPair( 'rsa', { modulusLength: 2048, publicKeyEncoding: { type: "spki", format: "pem" }, privateKeyEncoding: { type: "pkcs8", format: "pem" } }, (error, publicKey, privateKey) => error ? reject(error) : resolve({ publicKey, privateKey }) ) );

Now that we have our generateBearerTokenCredentials function we want to create a JSON Web Token, within our payload we're going to use the sub (short for subject) key to identifty the associated identity, we're also going to want to expire our tokens, for this article we're going to use a short expiry of 1 day, but feel free to use an expiry time that suits you. We're going to also utilise the module ms to specify when the token expires, this is the same module that jsonwebtoken uses internally to calculate the exp value is expiresIn is provided as an option, however we're going to utilise this value to expire the publick key.

It should be noted that JSON Web Tokens allow no expiry, in this article we're using an expiry as we want to be able to clear out our database of old public keys using level-ttl.

Lets install the ms module and then add some code to generateBearerTokenCredentials:

bash
npm install --save ms $$
js
// At the top of our file import JSONWebToken from "jsonwebtoken"; import ms from "ms"; // We're going to use this to identify our certificate import UUID from "pure-uuid"; // In our `generateBearerTokenCredentials` function: const { publicKey, privateKey } = await createKeyPair(); const expiryInMS = ms("1 day"); const expiresAtInMS = Date.now() + expiryInMS; const payload = { sub: identity, // exp is in **seconds**, not milliseconds // Floor so that we have an integer exp: Math.floor(expiresAtInMS / 1000) }; const keyid = new UUID(4).format(); const algorithm = "RS256"; const token = await new Promise( (resolve, reject) => JSONWebToken.sign( payload, privateKey, { algorithm, keyid }, (error, token) => error ? reject(error) : resolve(token) ) ); const store = request.app.locals.store; // This can be retrieved later for validation await store.put(`jwt-key:${keyid}`, JSON.stringify({ algorithm, publicKey })); // Mark our value for expiry: await new Promise( (resolve, reject) => request.app.locals.ttl.ttl( `jwt-key:${keyid}`, expiryInMS, (error) => error ? reject(error) : resolve() ) ); response.json({ token, tokenType: "bearer", expiresAt: expiresAtInMS });

We also need to hook up this route, so in our index.js file we will add the route POST /exchange-credentials:

js
// At the top of our file import exchangeCredentialsHandler from "./handlers/exchange-credentials"; // After our last route app.post("/exchange-credentials", exchangeCredentialsHandler);

Now when we start our service, we should be able to run this curl command, which will return a token:

bash
curl -XPOST http://localhost:8080/exchange-credentials -H "Content-Type: application/json" -d '{"from":"username-password","to":"bearer","username":"John Doe","password":"correcthorsebatterystaple"}' $$$$

When I ran this request I received the response:

json
{ "token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjZkYWZiMTZiLWJhOGUtNDhiNC04NTBiLTA0Y2JmZGMxZTZiMCJ9.eyJzdWIiOiI2MWQ0ZGIzMy03NGViLTQwMWMtYTY3OC1iZjg0Y2Y4Nzc0NTQiLCJleHAiOjE1NTU3NTMzMTUsImlhdCI6MTU1NTY2NjkxNX0.DFG1XH9dW_pOQyAROFcjxXJnG6rIybcOFBpbLekwXe9G8Su3FJNuRHqHIkloFcFo0S_c5ukgiMqBysT6AwwaS4jeuKOVj1RC2ykrUHIv7C_xBrWKxFSCcEablkCFt7CUvDDyb-RRCkGAV-6NALC0raz0K9E7Gnf1NgJT7a69EKAcy3n2vgV9Th-ZCARqoCSApd4Ye5Gm40OEMXnbgdHw6bKUKJ1rFTefp4kDM3h3TELQjplPICBqQpfkLNR8_4-d9J-REKFuFApe2i60DbJAbmX-vyYxBTV-QsG2x1TQ9Tsa5MjxUzYGxv2bwJDY9FLtkLakOhzR5J4fdy2cBj-fyg", "tokenType": "bearer", "expiresAt": 1555753315923 }

You can take that token and check it using jwt.io, you'll see we have a kid value within the header that represents our key identifier, and a sub value within our payload.

Now that we have a way to generate tokens, we want a way to be able to utilise them, for this we're going to create a middleware handler that will return the status code 401 if the request doesn't have a valid bearer token, if the token is valid then the handler will invoke the next function of express.

We're going to utilise the npm module express-bearer-token:

bash
npm install --save express-bearer-token $$$$

In our verify-bearer.js file:

js
import JSONWebToken from "jsonwebtoken"; import bearerToken from "express-bearer-token"; async function verify(request, response, next) { if (!request.token) { // No token, no pass return response.sendStatus(401); } // Decode our token so we can const decoded = JSONWebToken.decode(request.token, { complete: true }); if (!decoded.header.kid) { // No kid, no pass, we didn't generate this return response.sendStatus(401); } const store = request.app.locals.store; const keyInformation = await store.get(`jwt-key:${decoded.header.kid}`) .then(value => JSON.parse(value)) .catch(() => undefined); if (!(keyInformation && keyInformation.algorithm && keyInformation.publicKey)) { // No key information to compare to return response.sendStatus(401); } const verified = await new Promise( resolve => JSONWebToken.verify( request.token, keyInformation.publicKey, { algorithms: [ // Only allow the one that was stored with the key keyInformation.algorithm ] }, (error, verified) => resolve(error ? undefined : verified) ) ); if (!verified) { // Not valid return response.sendStatus(401); } // Add our identity to the request, use `user` here as thats pretty standard request.user = { id: verified.sub }; // Ready to rol; next(undefined); } function handler(request, response, next) { verify(request, response, next) .catch(next); } export default [ bearerToken(), handler ];

Now we can use our verify bearer middleware to authenticate our requests, to test this we're going to create a route that returns the phrase You're authenticated! if the verification was successful, we're going to add this to our index.js file:

js
// At the top of our file import bearer from "./middleware/verify-bearer.js"; // After our routes app.get("/check-authentication", bearer, (request, response) => response.send("`You're authenticated!"));

You can check this by first exchanging credentials by using the previous curl example, and replacing <token> in the command below with the value from "token":

bash
curl http://localhost:8080/check-authentication -H "Authorization: Bearer <token>" $$$

With that, we've created a identity service that we can:

  • Create an identity by submitting a username & password pair
  • Exchange a username & password pair for a bearer token
  • Validate a request to ensure its authenticated with a bearer token

We will continue on this theme in future articles, next covering WebAuthn.

Read similar articles
Sep 14, 2020
Share