
Written on 2025-01-05 by Marek Jędryka
Implementing session management is a crucial aspect of building robust web applications.
While frameworks like Express provide built-in session handling, understanding how to create basic session middleware from scratch can be incredibly valuable.
In this article, we'll explore how to create a simple session middleware for the core Node.js http module.
After completing this basic implementation, I wanted to tackle a more challenging task.
I decided to create a simple login guard for specific URL paths using serve-handler for a nice UI for directory views, generate a simple HTML login form using Pug at the server-side, and implement a login guard using OTP (specifically HOTP) from the otpauth npm package, persisted in MongoDB using the basic Node.js driver for database connection.
References:
I would like share non-public files in convenient way. I have a web server witch can run Node and Deno runtimes, but let's focus on Node.js this time. As the files is confidential, I can't just serve them publicly, without any access control. I think a while about it, and the most convenient approach is no-password authorization for me.
So all we need to login are user name/identity/email (no matter what as long as it's unique) and one-time-password (OTP) generated by special app. I use FreeOTP application on my Android phone for many years and I'm satisfied about them.
And additional, my personal motivation is that my dream to try do it myself, from the basics, almost from scratch.
Let's start quickly by writting middlewares code!
Basically, we need to implement parsing request body middleware.
It's needed to be able to process data sent in the body of POST HTTP request during login process.
The parseJSONBody function was created to do it.
It parses JSON string as default behaviour, and parses query string in parseKeyValueString function as fallback.
After succesfull login, we need to parse cookies from the request.
We do it in parseCookies function, and set cookie field to request object.
Finally, we have to verify our cookie.
We perform it in setSession function by checking if session from cookie is passing to our database contents.
The session field will be set to request object in case of possitive verification only.
See the code below.
The external constant export is dependency injection approach described in separated article.
import { scheduler } from 'timers/promises'
import { ObjectId } from 'mongodb'
export const external = {
abortController: new AbortController,
scheduler,
session: new Map,
}
const parseKeyValueString = (str, separator = ';') => {
const result = {}
for (const kv of str?.split(separator) ?? []) {
let [name, ...rest] = kv.split(`=`)
name = name?.trim()
if (!name) continue
const value = rest.join(`=`).trim()
if (!value) continue
result[name] = decodeURIComponent(value)
}
return result
}
export const parseCookies = async (request) => {
request.cookies = parseKeyValueString(request.headers?.cookie, ';')
}
export const parseJSONBody = (request) => {
const { scheduler } = external
return new Promise((resolve, reject) => {
const data = [];
request.on('data', (chunk) => {
data.push(chunk.toString('utf8'))
})
request.on('end', () => {
const str = data.join('')
try {
request.body = JSON.parse(str)
} catch {
request.body = parseKeyValueString(str, '&')
} finally {
resolve()
}
})
scheduler.wait(1000).then(() => {
reject()
})
})
}
export const setSession = async request => {
const { collection } = await import('./otp.js')
const { cookies } = request
if (await collection.findOne({ _id: new ObjectId(cookies.session) })) {
request.session = {
id: cookies.session,
}
}
}
As you can see, we import otp.js file in setSession middleware function.
Let's go to that file now.
Before implementation details description, I would like to introduce OTP basics. OTP is a time-synchronous password generator that provides a shared secret key for generating passwords. It's widely used in multi-factor authentication systems for enhancing security.
Key features of OTP:
Single-use : Each password is used only once.
Time-based : Passwords change periodically (usually every 30 seconds).
Shared secret : Both parties share a secret key.
No synchronization required : The time difference between devices doesn't affect functionality.
There are two types of OTP:
HOTP (HMAC-based One-Time Password) : HOTP is a type of OTP that uses a hash function to generate passwords.
TOTP (Time-based One-Time Password) : TOTP is a type of OTP that uses a time-based counter to generate passwords.
Comparison of HOTP and TOTP:
| Feature | HOTP | TOTP |
|---|---|---|
| Time-based | No | Yes |
| Counter management | Yes | No |
| Implementation complexity | Simpler | More complex |
HOTP uses a counter to generate passwords, while TOTP uses a time-based counter.
HOTP is simpler to implement than TOTP because it doesn't require a time-based counter. However, TOTP is more trustworthy because it's time-based, but time difference between devices can affect functionality. That's why TOTP is more complex to implement than HOTP.
HOTP is a type of OTP that uses a hash function to generate passwords. It's a simpler implementation than TOTP because it doesn't require a time-based counter.
The HOTP algorithm is based on the HMAC algorithm.
HMAC:
Key Characteristics:
How it works:
Advantages:
Disadvantages:
TOTP is a type of OTP that uses a time-based counter to generate passwords. It's more complex to implement than HOTP because it requires a time-based counter.
Key Characteristics:
How it works:
Advantages:
Disadvantages:
That's theory basics of OTP. Now, let's implement it in Node.js!
import { createHash } from 'crypto'
import { readFile, rm } from 'fs/promises'
import { env } from 'process'
import { MongoClient } from 'mongodb'
import { HOTP, Secret } from 'otpauth'
import QRCode from 'qrcode'
import { DummyOTP } from './DummyOTP.js'
const updateCounter = async (issuer, counter) => {
const { acknowledged, matchedCount } = await collection.updateOne({ issuer }, { $set: { counter } })
if (acknowledged && matchedCount === 1) return
throw Error('OTP: update counter failure')
}
class HashBasedOneTimePassword extends HOTP {
#id
constructor (id, data) {
super(data)
this.#id = id
}
get session () {
return this.#id
}
async qrCode () {
const file = `/tmp/${this.issuer}.svg`
await QRCode.toFile(file, this.toString())
const promise = readFile(file, 'utf8')
promise.then(() => rm(file))
return promise
}
async setCounter (value) {
await updateCounter(this.issuer, value)
this.counter = value
}
async validate (token) {
const delta = super.validate({ token, window: 4 })
const success = delta !== null
if (success) await updateCounter(this.issuer, this.counter)
return success
}
}
export const client = new MongoClient(env.MONGO_URI || '')
export const collection = client.db().collection('otp')
export const findOTP = async issuer => {
if (!issuer || typeof issuer !== 'string') return new DummyOTP
if (issuer.trim().length === 0) return new DummyOTP
let {_id, ...data} = await collection.findOne({ issuer }) ?? {}
if (!_id) {
data = {
issuer,
secret: Secret.fromHex(
createHash('sha256')
.update(`${issuer}@${Date.now()}`, 'utf-8')
.digest('hex')
),
algorithm: 'SHA512',
digits: 6,
counter: 0,
}
const { acknowledged, insertedId } = await collection.insertOne({
...data,
secret: data.secret.hex,
})
if (acknowledged) _id = insertedId
} else {
data.secret = Secret.fromHex(data.secret)
}
return new HashBasedOneTimePassword(_id, data)
}
The HashBasedOneTimePassword class is a simple wrapper around the otpauth library.
It's a simple class that extends the HOTP class from the otpauth library.
It provides a qrCode method that generates a QR code image from the OTP secret as an extension of the HOTP class.
It also provides a setCounter method that updates the counter value in the database.
Finally, it provides a validate method that validates the OTP token.
The findOTP function is a simple wrapper around the collection database.
It's a simple function that returns a new HashBasedOneTimePassword instance.
For invalid input, it returns a new DummyOTP instance.
The DummyOTP class is extremely simple and it's just a placeholder for the HashBasedOneTimePassword class.
import { HOTP } from 'otpauth'
export class DummyOTP extends HOTP {
get session () {
return ''
}
async qrCode () {
return ''
}
async setCounter (value) {
this.counter = value
}
async validate () {
return false
}
}
Let's implement the authentication middleware now. It will be a simple middleware that checks if the OTP token is valid. If it's valid, it will set the session cookie and return the next middleware. Otherwise, it will return a 301 redirection to the login page.
See auth.js file implementation:
import { join } from 'path'
import { dirname } from 'dirname-filename-esm'
import { minimatch } from 'minimatch'
import pug from 'pug'
import { findOTP } from './otp.js'
export const external = {
findOTP,
}
export class AuthConfig {
#opening = '/otp'
#patterns = []
constructor (patterns, opening = '') {
if (opening) this.#opening = opening
const openingPage = this.#opening
.replace(/\/$/, '')
.replace(/^\//, '')
this.#opening = new RegExp(`^/${openingPage}(\/qr)?\/?$`)
this.#patterns = patterns.map(p => {
if (p instanceof RegExp) return p
return minimatch.makeRe(p)
}).filter(Boolean)
}
isGuarded (path) {
return this.#patterns.some(p => p.test(path))
}
isOpening (path) {
return this.#opening.test(path)
}
}
const sendQrCode = async (request, response) => {
const { issuer = '' } = request.body ?? {}
const { findOTP } = external
const otp = await findOTP(issuer)
return response
.setHeader('Content-Type', 'image/svg+xml')
.end(await otp.qrCode())
}
const redirectLocation = (request, path) => {
const { socket, headers } = request
return `http${socket.encrypted ? 's' : ''}://${headers.host}/${path}`
}
const validate = async (request, response) => {
const { issuer = '', token = '' } = request.body ?? {}
const { findOTP } = external
const otp = await findOTP(issuer)
if (await otp.validate(token)) return response
.writeHead(303, {
'Set-Cookie': [
`session=${otp.session}; Secure; HttpOnly`,
'guarded=; expires=Thu, 01 Jan 1970 00:00:00 GMT',
],
Location: redirectLocation(request, request.cookies?.guarded ?? ''),
})
.end()
return response.writeHead(403).end()
}
const form = pug.compileFile(join(dirname(import.meta), 'ui/login.pug'))
const sendLoginForm = (_, response) => response
.setHeader('Content-Type', 'text/html')
.end(form())
const open = async (request, response) => {
const { method, url } = request
switch (method) {
case 'PUT':
if (url.endsWith('/qr')) return sendQrCode(request, response)
break
case 'POST':
return validate(request, response)
case 'GET':
return sendLoginForm(request, response)
}
}
export const authenticate = config => async (request, response) => {
const { session, url, path } = request
if (config.isOpening(url)) return open(request, response)
if (!config.isGuarded(url)) return
if (session?.id) return
response
.writeHead(301, {
Location: redirectLocation(request, 'otp'),
'Set-Cookie': `guarded=${path}`,
})
.end()
}
It exposes two statements:
authenticate function that is a middleware that checks if the request is guarded by the authentication middlewareAuthConfig class that is a configuration class for the authentication middlewareLet's implement the login page now. It will be a simple page that allows the user to enter the OTP issuer and the OTP token.
doctype html
html(lang="en")
head
meta(charset="UTF-8")
meta(name="viewport", content="width=device-width, initial-scale=1.0")
title Login Form
body
form(action="", method="post")
input(type="text", name="issuer")
input(type="text", name="token")
input(type="submit", value="Login")
The login.pug file is a simple Pug template that renders a login form.
Let's implement the guard middleware now. It will be a simple middleware that checks if the request is guarded by the authentication middleware.
See guard.js file implementation:
import { AuthConfig, authenticate } from './auth.js'
export const guard = patterns => authenticate(new AuthConfig(patterns))
The guard middleware is a simple wrapper around the authenticate middleware.
It takes an array of patterns as an argument and creates an AuthConfig instance with the patterns.
Then, it passes the AuthConfig instance to the authenticate middleware.
We have all the pieces now, so let's put them together.
We are going to create a simple HTTP server that will handle the middlewares described above.
See app.js file implementation:
import { createServer } from 'http'
import 'dotenv/config'
import serve from 'serve-handler'
import { guard } from './guard.js'
import { parseCookies, parseJSONBody, setSession } from './middleware.js'
const app = ({ auth }) => async (request, response) => {
await Promise.all([
parseCookies(request),
parseJSONBody(request),
])
await setSession(request, response)
await auth(request, response)
if (response.finished) return
return serve(request, response, {
public: 'src',
})
}
const server = createServer(app({
auth: guard([
'/ui/',
'/ui/*',
]),
})).listen(5200)
console.log('Listening HTTP on 5200 port')
console.log(server.address())
The app function takes an object with the auth property.
The auth property is a middleware that is a guard for the authentication middleware in our case.
First, it parses the cookies from the request and JSON body from the request.
It can be happened concurrently as the parseCookies and parseJSONBody middlewares are independent.
Then, it sets the session cookie and calls the authentication middleware.
It checks if the response has been finished by given authentication middleware next.
If it's not finished, it serves requested files from the src directory finally, so it serves own sources as static files.
The server can be started by running the app.js file.
$ node src/app.js
Listening HTTP on 5200 port
{ address: '::', family: 'IPv6', port: 5200 }
Tests are important part of the development process. I could say that it's at least as important as the implementation itself. I'm big fan of unit testing especially using ava runner. I'm not sure if it's good idea to show all the tests in this article because of test cases amount, so I'll just show the most important ones.
By the way, it's really good example of how to use test-listen library to create a test server and how to write tests in ava in general.
See otp.test.js file implementation with a tests set for the otp.js file.
import { access, constants } from 'fs/promises'
import { env } from 'process'
import { scheduler } from 'timers/promises'
import test from 'ava'
import { MongoMemoryServer } from 'mongodb-memory-server'
import { Secret } from 'otpauth'
import { stub, restore } from 'sinon'
test.before('start database server', async t => {
const mongodb = await MongoMemoryServer.create()
env.MONGO_URI = mongodb.getUri()
t.context = {
module: await import('../src/otp.js'),
mongodb,
}
t.timeout(1_000)
})
test.afterEach.always(() => {
restore()
})
test.after.always('stop database server', async t => {
const { module: { client }, mongodb } = t.context
await client.close(true)
await mongodb.stop()
})
test.serial('object should be restored properly from database', async t => {
const { collection, findOTP } = t.context.module
const issuer = 'test'
const otp = await findOTP(issuer)
const data = await collection.findOne({ issuer })
data.secret = Secret.fromHex(data.secret)
data.secret.hex // call getter to set hex property
t.like(data, {
issuer,
secret: otp.secret,
algorithm: 'SHA512',
digits: 6,
counter: 0,
})
})
test.serial('update counter method success', async t => {
const { collection, findOTP } = t.context.module
const issuer = 'André'
const counter = 1
const otp = await findOTP(issuer)
await otp.setCounter(counter)
const data = await collection.findOne({ issuer })
const values = {
issuer,
counter,
}
t.like(data, values)
t.like(otp, values)
})
test.serial('update counter method failure', async t => {
const { collection, findOTP } = t.context.module
const issuer = 'Gennadius Thrasyboulos'
const otp = await findOTP(issuer)
stub(collection, 'updateOne').resolves({
acknowledged: true,
matchedCount: 0,
})
await t.throwsAsync(otp.setCounter(500))
const data = await collection.findOne({ issuer })
t.like(data, {
issuer,
counter: 0,
})
})
test.serial('validate method should pass window option', async t => {
const { findOTP } = t.context.module
const otp = await findOTP('Dionysius Hilarion')
const token = otp.generate()
await otp.setCounter(otp.counter + 3)
t.true(await otp.validate(token))
await otp.setCounter(otp.counter + 5)
t.false(await otp.validate(token))
})
test.serial('validate method should update counter if succeed', async t => {
const { collection, findOTP } = t.context.module
const issuer = 'André'
const otp = await findOTP(issuer)
const { counter } = otp
const token = otp.generate()
await otp.validate(token)
const data = await collection.findOne({ issuer })
t.is(otp.counter, counter + 1)
t.is(data.counter, otp.counter)
})
test.serial('validate method should not update counter if failed', async t => {
const { collection, findOTP } = t.context.module
const issuer = 'Hesiodos Anastasia'
const otp = await findOTP(issuer)
const token = otp.generate()
const expected = otp.counter + 5
await otp.setCounter(expected)
await otp.validate(token)
const data = await collection.findOne({ issuer })
t.is(otp.counter, expected)
t.is(data.counter, expected)
})
const dummyMacro = test.macro({
exec: async (t, issuer) => {
const { findOTP } = t.context.module
const otp = await findOTP(issuer)
t.is(otp.constructor.name, 'DummyOTP')
await otp.setCounter(2)
t.false(await otp.validate())
t.is(otp.counter, 2)
t.is(await otp.qrCode(), '')
},
title: (t, issuer) => `${JSON.stringify(issuer)} issuer should find DummyOTP object`,
})
for (const issuer of [null, '', -1, ' ']) test(dummyMacro, issuer)
test.serial('QR code', async t => {
const { findOTP } = t.context.module
const issuer = 'Gennadius Thrasyboulos'
const otp = await findOTP(issuer)
const svg = await otp.qrCode()
t.true(svg.length > 100)
await scheduler.wait(5)
await t.throwsAsync(access(`/tmp/${issuer}.svg`, constants.F_OK))
})
Before running the tests, we need to start the database server.
We can do it by create MongoMemoryServer instance and start it.
Only when the database server is running can we import our module, because internally it establishes a connection with the database.
That's why we need to start the database server before module import.
As we use sinon library for mocking, we need to restore it's stubs after each test.
No matter if the test is successful or not, we need to restore the stubs.
After all the tests are finished, we need to stop the database server.
Our setup is ready, so let's run the tests.
I skip a few first tests they are just for checking basic functionalities.
The first interesting test is in dummyMacro macro.
It's a macro that is used to test the DummyOTP class in specific circumstances.
It's executed with some falsy values as an argument.
The last one is the most interesting test. We are checking if the QR code is generated properly and if temporary file will be removed eventually. The SVG contents couldn't be checked strictly as secret of OTP is genereted as different every time. So I can't use snapshot testing in that case, and checking SVG contents length is only thing we can do to ensure that the generated SVG is not empty.
Let's analyze some server tests now.
See serve.test.js file implementation with a tests set for the middleware.js file and serve-handler 3rd party library.
import { readFile } from 'fs/promises'
import test from 'ava'
import got from 'got'
import serve from 'serve-handler'
import { restore, stub } from 'sinon'
import { CookieJar } from 'tough-cookie'
import { setup } from './common.js'
import { parseCookies, parseJSONBody, setSession } from '../../src/middleware.js'
const app = ({ auth }) => async (request, response) => {
await Promise.all([
parseCookies(request),
parseJSONBody(request),
])
await setSession(request, response)
await auth(request, response)
if (response.finished) return
return serve(request, response, {
rewrites: [
{ source: 'protected/:sub/:file', destination: 'test/:sub/:file' }
]
})
}
setup(test, app)
test.afterEach.always(() => {
restore()
})
const serveMacro = test.macro({
exec: async (t, path) => {
const target = path.replace(/^\//g, '')
const cookieJar = new CookieJar()
const options = {
cookieJar,
prefixUrl: t.context.url
}
await cookieJar.setCookie('session=670979dc6997812b94b9a594', t.context.url)
const { collection } = await import('../../src/otp.js')
stub(collection, 'findOne').resolves(true)
const response = await got(target, options)
t.is(response.body, await readFile(path.replace('protected/', 'test/'), 'utf8'))
},
title: (_, path) => `serve scenario at "${path}" for valid OTP`
})
for (const path of [
'protected/auth/common.js',
]) test.serial(serveMacro, path)
We have an simple server app function that is a wrapper around the serve-handler library.
The function has been set up to be used by the setup function call described in the next section.
In the test file, we have a serveMacro macro that is used to test the serve function.
First, we create a CookieJar instance and set a session cookie to it and then we call the got function to simulate a request to the server.
In validation, we check if the response body is equal to the file contents.
The common.js file is a helper file that is used to set up the common test environment.
import { createServer } from 'http'
import { env } from 'process'
import listen from 'test-listen'
import { external } from '../../src/middleware.js'
export const urls ={
public: [
'/',
'/favicon.png',
'/style.css',
'/catalog/123/read.pdf',
],
protected: [
'/protected/list/1',
'/private/photos/1.jpg',
'/confidential/data/document.odt',
],
}
export const setup = (test, app) => {
test.serial.before('common setup before hook', async t => {
if (!env.MONGO_URI) env.MONGO_URI = 'mongodb://localhost/nodb'
const [{ guard }, { external }] = await Promise.all([
import('../../src/guard.js'),
import('../../src/auth.js'),
])
const auth = guard([
'/protected/**/*',
'/private/**/*',
'/confidential/**/*',
])
const server = createServer(app({
auth,
}))
t.context = {
...t.context,
auth,
external,
server,
url: await listen(server),
}
})
test.after.always('common setup after.always hook', async t => {
t.context.server.close()
external.abortController.abort()
const { client } = await import('../../src/otp.js')
await client.close()
})
}
It exports a setup function that is used to set up test server environment and a urls object that contains the list of paths that are used in the tests.
See get.test.js file implementation with a tests set for checking unauthenticated access to the protected resources.
import test from 'ava'
import got from 'got'
import { setup, urls } from './common.js'
const app = ({ auth }) => (request, response) => {
auth(request, response).then(() => {
if (response.finished) return
response.end('OK')
})
}
setup(test, app)
const getMacro = test.macro({
exec: async (t, path, statusCode) => {
const request = got(path.replace(/^\//g, ''), {
prefixUrl: t.context.url
})
if (statusCode === 200) return t.notThrowsAsync(request)
const { headers, url } = await request
t.is(headers['content-type'], 'text/html')
t.is(url, `${t.context.url}/otp`)
},
title: (_, path, statusCode) => `GET at "${path}" should result ${statusCode} status code`
})
for (const path of urls.public) test(getMacro, path, 200)
for (const path of urls.protected) test(getMacro, path, 301)
The getMacro uses extremely simple app server function.
It checks status code returned as response to GET request.
It should be 200 for public resources and 301 for protected resources as we have guards for them and unauthenticated requests should be redirected to the login page.
Let's run described tests.
$ yarn ava test/otp.test.js test/server/{serve,get}.test.js
✔ server › get › GET at "/" should result 200 status code
✔ server › get › GET at "/favicon.png" should result 200 status code
✔ server › get › GET at "/style.css" should result 200 status code
✔ server › get › GET at "/catalog/123/read.pdf" should result 200 status code
✔ server › get › GET at "/protected/list/1" should result 301 status code
✔ server › get › GET at "/private/photos/1.jpg" should result 301 status code
✔ server › get › GET at "/confidential/data/document.odt" should result 301 status code
✔ server › serve › serve scenario at "protected/auth/common.js" for valid OTP
✔ otp › object should be restored properly from database
✔ otp › update counter method success
✔ otp › update counter method failure
✔ otp › validate method should pass window option
✔ otp › validate method should update counter if succeed
✔ otp › validate method should not update counter if failed
✔ otp › QR code
✔ otp › null issuer should find DummyOTP object
✔ otp › "" issuer should find DummyOTP object
✔ otp › -1 issuer should find DummyOTP object
✔ otp › " " issuer should find DummyOTP object
─
19 tests passed
Nice, we have all the tests passed. I love avas output, it's very clean and readable. It's also very easy to understand what is going on.
There is a lot of tests, much more than has been described in this article.
$ yarn ava --tap | yarn tap-dot
.............................................................................
48 tests
77 passed
Pass!
It's good idea to share coverage report of all the tests as well. It's really good coverage result.
$ yarn coverage
# skipped tests output...
---------------|---------|----------|---------|---------|-------------------
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
---------------|---------|----------|---------|---------|-------------------
All files | 98.85 | 95.4 | 96.29 | 98.85 |
DummyOTP.js | 89.47 | 100 | 75 | 89.47 | 5-6
auth.js | 98.98 | 92.1 | 100 | 98.98 | 78
guard.js | 100 | 100 | 100 | 100 |
middleware.js | 100 | 94.73 | 100 | 100 | 19
otp.js | 100 | 100 | 100 | 100 |
---------------|---------|----------|---------|---------|-------------------
We have above 95% coverage for all the files. But keep in mind that it's no guarantee that all situations are covered even if coverage would be 100%.
Note that src/app.js is not included in the coverage report as all tests have implemented own server application.
I could cover it with tests by perform E2E tests, but I don't want to do it as I consider it's not necessary.
I've created a live demo of the implementation as video screencast.
I've described how to implement session management in Node.js with basic OTP authentication using core Node.js http module.
I've also described how to implement a simple login guard using otpauth npm package. I
I've described how to implement a simple login page using Pug template engine.
I also showed hot to use MongoDB for storing OTP secrets.
The implementation is very simple and it's not a production ready code. It's just a proof of concept. But it's a good starting point for implementing more complex authentication system.
However, it's crucial to remember that this implementation is primarily intended for educational purposes. Due to potential security vulnerabilities and scalability limitations, it should never be deployed in production environments. Instead, prioritize the use of well-vetted, community-supported solutions for critical components of your real, production-ready web application.
You can consider mature solutions like Passport or Keycloak as a good starting point for implementing authentication in Node.js. You can use good established and well-vetted libraries like express-session for session management if you want to use express framework.