Creating an Identity Service with Node.js Part 2
September 14, 2020
Table of Contents
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:
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:
npm install --save level-ttl
We will need to replace our store creation to include ttl
:
// 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:
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.
// 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:
// 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
:
npm install --save ms
// 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
:
// 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:
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:
{
"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:
npm install --save express-bearer-token
In our verify-bearer.js
file:
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:
// 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"
:
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.
Fabian Cook
Software Engineer @ Dovetail
JavaScript Developer.
Read similar articles
A beginner's guide to configuring a Discord Bot in Node.js
Check out our tutorialImplementing and Testing a Mongoose Model with CI/CD Integration
Check out our tutorialSecuring our Docker image
Check out our tutorial