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:
bashnpm 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:
bashnpm 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:
jsimport 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
:
bashnpm 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:
bashcurl -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:
bashnpm install --save express-bearer-token
$$$$
In our verify-bearer.js
file:
jsimport 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"
:
bashcurl 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.