This blog is a part 3 of my bluebird blog. See part 1 and part 2
I don’t think there’s an easy way to send email without dependencies. I suppose if I really wanted email, I can use some email-as-a-service API using NodeJS’ HTTP client. However, the email-as-a-service could be considered a dependency, so I won’t.
Traditionally, if the user forgets their password, they can get a password reset link via email. Without email, this obviously isn’t possible. For bluebird, my twitter-clone, forgetting your password results in losing access to your account.
Let’s create a signup and login process. Before I start writing HTML, I want to implement the authentication (signup + login) process in a module for easy testing.
The authentication process will be handled by a class named Auth
and will feature at least 2 methods: login
and signup
.
The signup method needs to create a user if it doesn’t already exists. The created user should be returned. If the user does exist, some kind of error is returned.
For a method to return either an error or a user, I’m going to make the function method return an object with either an error
property for failure, or a user
property for success.
Following the Red-Green-Refactor rule, I’m writing a failing test first, then write a minimal implementation to make it pass.
// test/auth.test.mjs
import { describe, it, beforeEach } from "node:test";
import { Auth } from "../src/auth.mjs";
import assert from "node:assert";
describe("Auth", () => {
describe("signup", () => {
it("returns created user", async () => {
const auth = new Auth();
const { user } = await auth.signup("foo", "bar");
assert(user);
});
});
});
// src/auth.mjs
export class Auth {
async signup(name, pass) {
return { user: { name } };
}
}
Now to check for existing users. Note how I use JSDoc for typing. JSDoc is completely ignored by NodeJS, but it does help in some editors when the type of a variable isn’t obvious.
// test/auth.test.mjs (partial)
describe("signup", () => {
/** @type {Auth} */
let auth;
beforeEach(() => {
auth = new Auth();
});
it("returns created user", async () => {
const { user } = await auth.signup("foo", "bar");
assert(user);
});
it("returns user-exists error if user already exists", async () => {
const { user } = await auth.signup("foo", "bar");
assert(user);
const { error } = await auth.signup("foo", "bar");
assert.equal(error, Auth.USER_EXISTS);
});
});
// src/auth.mjs
/**
* @typedef {Object} User
* @property {string} name
*/
export class Auth {
static USER_EXISTS = Symbol("USER_EXISTS");
/** @type {Map<string, User>} */
#users = new Map();
/**
*
* @param {string} name
* @param {string} pass
* @returns {Promise<{ user: User }|{ error: symbol }>}
*/
async signup(name, pass) {
if (this.#users.has(name))
return { error: Auth.USER_EXISTS };
const user = { name };
this.#users.set(name, user);
return { user };
}
}
Using the current implementation, I’m storing the created users in a variable. Later, I’ll find a way to persist them in some kind of database.
Also note how I haven’t implemented any password hashing. There is no way to test whether a password is stored or hashed properly without writing an additional method to verify the password, so that’s what I’m going to do now using the login-method.
I continue writing tests.
// test/auth.test.mjs (partial)
describe("login", () => {
it("for unknown user, return USER_NOT_FOUND", async () => {
const { error } = await auth.login("foo", "bar");
assert.equal(error, Auth.USER_NOT_FOUND);
});
});
// src/auth.mjs (partial)
class Auth {
/**
*
* @param {string} name
* @param {string} pass
* @returns {Promise<{ user: User }|{ error: symbol }>}
*/
async login(name, pass) {
if (!this.#users.has(name))
return { error: Auth.USER_NOT_FOUND };
}
}
OWASP considers it a security risk to let the user know whether a username exists or not. They suggest to respond with a generic error message regardless of wether the password was incorrect, the account doesn’t exist or the account is disabled.
However, in many implementations, there’s still other ways for any user to find whether a username exists or not. Most notably, the signup screen generally shows you a user with that username already exists, and password recovery often also returns some kind of error indicating whether the email address is known or not.
OWASP suggests the signup page and account recovery to not report whether a user exists or not and instead send an email to the user. For this project, I don’t have email available, so that’s not going to work.
Additionally, I don’t think the security issue is worth the hit on usability. I’m perfectly comfortable with rendering useful errors to users at the cost of this potential security risk. Let’s continue!
// test/auth.test.mjs (partial)
it("for known user but invalid password, return INVALID_PASSWORD", async () => {
await auth.signup("foo", "bar");
const { error } = await auth.login("foo", "invalid");
assert.equal(error, Auth.INVALID_PASSWORD);
});
// src/auth.mjs (partial)
class User {
async login(name, pass) {
if (!this.#users.has(name))
return { error: Auth.USER_NOT_FOUND };
return { error: Auth.INVALID_PASSWORD };
}
}
// test/auth.test.mjs (partial)
it("for known user and valid password, return user", async () => {
await auth.signup("foo", "bar");
const { user } = await auth.login("foo", "bar");
assert(user);
});
What’s the minimal implementation to check a password? Yep: plain text!
class User {
async signup(name, pass) {
if (this.#users.has(name))
return { error: Auth.USER_EXISTS };
const user = { name, pass }; // <-- Don't do this!
this.#users.set(name, user);
return { user };
}
async login(name, pass) {
const user = this.#users.get(name);
if (!user)
return { error: Auth.USER_NOT_FOUND };
if (user.pass != pass) // <-- Don't do this!
return { error: Auth.INVALID_PASSWORD };
return { user };
}
}
All tests pass. Now to find a way to make it not pass. First of all, I never want the password exposed. There’s no perfect way to test whether the password is exposed by the returned user object, but a very minimal, naive approach would be to check none of the returned properties has the password as a value.
I’m going to reuse the last test I wrote for that purpose, iterating all properties of the returned object and checking for the password.
it("for known user and valid password, return user", async () => {
await auth.signup("foo", "secret");
const { user } = await auth.login("foo", "secret");
assert(user);
for (const k in user)
assert.notEqual(user[k], "secret");
});
To hide the password, I’m going to hash the password in some way. The last time I hashed passwords myself, I used a single round of md5
. This was a long time ago. After that, I only used bcrypt.
But that’s a dependency. NodeJS seems to offer scrypt which is basically the same as bcrypt and only differs in one letter.
Unlike the bcrypt implementations I’m used to, scrypt requires you to bring your own salt. Scrypt seems to accept all kinds of parameters and options, so I’m going to wrap scrypt in my own hashing module which basically has a method for hashing a password and comparing a hash with a password.
I’m going to create a verify
and hash
method in the Pass
class and their behaviour is pretty simple.
import { describe, it, beforeEach } from "node:test";
import { Pass } from "../src/pass.mjs";
import assert from "node:assert";
describe("Pass", () => {
/** @type {Pass} */
let pass;
beforeEach(() => {
pass = new Pass({ cost: 2 });
});
it("hash creates a password hash", async () => {
const hash = await pass.hash("my-password");
assert.equal(typeof hash, "string");
});
it("Pass.verify returns true for the correct password", async () => {
const hash = await pass.hash("my-password");
assert.equal(await Pass.verify(hash, "my-password"), true);
});
it("Pass.verify returns false for any other password", async () => {
const hash = await pass.hash("my-password");
assert.equal(await Pass.verify(hash, "wrong-password"), false);
});
});
import crypto from "node:crypto";
import { promisify } from "node:util";
const scrypt = promisify(crypto.scrypt);
const randomBytes = promisify(crypto.randomBytes);
const pattern = /^\$s0\$(?<cost>\d+)\$(?<keylen>\d+)\$(?<salt>[^$]+)\$(?<key>[^$]+)$/
export class Pass {
#keylen = 60;
#cost = 16384;
constructor({ keylen, cost } = {}) {
this.#keylen = keylen ?? 60;
this.#cost = cost ?? 16384;
}
/**
*
* @param {string} password
* @returns {Promise<string>}
*/
async hash(password) {
return Pass.hash(password, await randomBytes(24), this.#keylen, this.#cost);
}
/**
*
* @param {string} hash
* @param {string} password
* @returns {Promise<boolean>}
*
*/
static async verify(hash, password) {
const result = hash.match(pattern);
if (!result) throw new Error(`Pass.verify was given an invalid string as a hash`);
const cost = parseInt(result.groups.cost, 10);
const keylen = parseInt(result.groups.keylen, 10);
const salt = Buffer.from(result.groups.salt, "base64");
const expectedHash = await Pass.hash(password, salt, keylen, cost);
return expectedHash === hash;
}
/**
*
* @param {string} password
* @param {Buffer} salt
* @param {number} keylen
* @param {number} cost
*/
static async hash(password, salt, keylen, cost) {
const key = await scrypt(password, salt, keylen, { cost });
return `$s0$${cost}$${keylen}$${salt.toString("base64")}$${key.toString("base64")}`;
}
}
I intentionally made Pass.verify
a static method to make sure it doesn’t rely on the config of a Pass
-instance. That way, the tests automatically prove that a generated hash can be verified regardless the settings used at creation,
since there is no way to access instance variables from a static method.
I put all required parameters inside the generated password hash string, like bcrypt
does. That way, I can safely change the parameters like cost, salt size or keylen in future releases without breaking existing password hashes.
Why $s0$
? I don’t know, I liked it. Bcrypt uses a similar prefix for versioning, so I thought I use it as well: s
for scrypt
and 0
for version 0
.
For testing, I’m going to set the cost
very low, but for real applications I would put the cost on a value to ensure password hashing takes about a second.
Back to the Auth
-class. It’s going to need an instance of Pass
for hashing passwords.
// test/auth.test.mjs (partial)
beforeEach(() => {
auth = new Auth({
pass: new Pass({ cost: 2 })
});
});
// src/auth.mjs
import { Pass } from "./pass.mjs";
/**
* @typedef {Object} User
* @property {string} name
* @property {string} passHash
*/
export class Auth {
static INVALID_PASSWORD = Symbol("INVALID_PASSWORD");
static USER_EXISTS = Symbol("USER_EXISTS");
static USER_NOT_FOUND = Symbol("USER_NOT_FOUND");
/** @type {Map<string, User>} */
#users = new Map();
/** @type {Pass} */
#pass;
/** @param opts */
constructor({ pass }) {
this.#pass = pass;
}
async signup(name, pass) {
if (this.#users.has(name))
return { error: Auth.USER_EXISTS };
const user = { name, passHash: await this.#pass.hash(pass) };
this.#users.set(name, user);
return { user };
}
async login(name, pass) {
const user = this.#users.get(name);
if (!user)
return { error: Auth.USER_NOT_FOUND };
if (!await Pass.verify(user.passHash, pass))
return { error: Auth.INVALID_PASSWORD };
return { user };
}
}
All tests now pass and the modules are ready for signup and login. Let’s create the login
and signup
routes in App
.
Our app already includes routes for login, specifically:
// src/app.mjs (partial)
export class App {
async "GET /login"() {
return render("login.ejs", { errorCode: null, params: { username: "" } });
}
async "POST /login"({ body }) {
const { username, password } = body;
const { user, error } = await this.#auth.login(username, password);
if (user)
return redirect("/profile", { cookies: { username: user.name } });
return render("login.ejs", {
errorCode: error.description,
params: { username }
});
}
}
These routes were part of my previous blog.
The signup routes are just as simple. The GET /signup
will just render a template with default params, and the POST /signup
will delegate the params to the model (the Auth
-module)
But before I write those, I’m going to write some failing tests first!
// app.test.mjs (partial)
describe("App", () => {
describe("GET /signup", () => {
it("renders signup template", async () => {
await GET("/signup");
assertRender("signup.ejs",
{ params: { username: "" }, errorCode: null });
});
});
describe("POST /signup", () => {
it("for existing user, return a USER_EXISTS error", async () => {
await POST("/signup", { username: "foo", password: "bar" });
assertRender("signup.ejs", {
params: { username: "foo" },
errorCode: "USER_EXISTS",
});
});
it("with valid params, sets cookie and redirects to profile", async () => {
await POST("/signup", { username: "foo", password: "bar" });
assertRedirect("/profile", { cookies: { username: "foo" } });
});
});
});
These tests are very similar to the tests for login. The implementation is also very similar. So similar, I might as well copy/paste them.
// src/app.mjs (partial)
export class App {
async "GET /signup"() {
return render("signup.ejs", { errorCode: null, params: { username: "" } });
}
async "POST /signup"({ body }) {
const { username, password } = body;
const { user, error } = await this.#auth.signup(username, password);
if (user)
return redirect("/profile", { cookies: { username: user.name } });
return render("signup.ejs", {
errorCode: error.description,
params: { username }
});
}
}
The template is just as interesting:
<!-- signup.ejs -->
<h1>Signup!</h1>
<form method="POST" action="/signup">
<% if (errorCode) { %>
<p><%= {
"USER_EXISTS": "User exists"
}[errorCode] ?? errorCode %></p>
<% } %>
<label>
<strong>Username</strong><br>
<input type="text" name="username" value="<%= params.username %>">
</label><br>
<label>
<strong>Password</strong><br>
<input type="password" name="password">
</label><br>
<button type="submit">Signup</button>
</form>
This completes a very basic signup feature. However, the /profile
page does not yet exist. To complete this blog, I’m going to implement that page!
I intent to make cookies readable like this:
// src/app.mjs (partial)
export class App {
"GET /profile"({ cookies }) {
return render("profile.ejs", { name: cookies.username });
}
}
<!-- templates/profile.ejs -->
<h1>Hello, <%= name %>!</h1>
This route and template serve as a placeholder while I implement the handling of cookies. Let’s first implement that any cookie gets passed to the request handler.
Let’s first update the AppRequest type definition.
// src/app.mjs (partial)
/**
* @typedef {Object} AppRequest
* @property {AppRequestMethod} method
* @property {string} path
* @property {AppRequestParams} body
* @property {Record<string, string|null>} cookies
*/
This has no use in NodeJS whatsoever, but it helps documenting the code and adds type hints to the editor. Now we just have to parse cookies from the cookie
header:
// src/main.mjs (partial)
import { parseCookies } from "./cookies.mjs";
// inside http.createServer's callback:
let { status, json, headers, template, variables, cookies, location } =
await app.handleRequest({
method: req.method,
path: url.pathname,
cookies: parseCookies(req.headers.cookie),
body,
});
I was too lazy to implement parseCookies
myself so I asked ChatGPT:
// cookies.mjs
export function parseCookies(cookieHeader) {
if (!cookieHeader) return {};
return cookieHeader.split(';').reduce((cookies, cookie) => {
const [key, value] = cookie.split('=').map(part => part.trim());
if (key) cookies[key] = decodeURIComponent(value || '');
return cookies;
}, {});
}
ChatGPT’s implementation seems totally reasonable so I’ll just go with it.
I have no intention to create a test for src/main.mjs
at the moment, so I’ll just manually test it (by starting the server and signing up via the browser). After a quick check, it seems to work perfectly fine.
I do intent to test /profile
, so let’s just break TDD’s rules and write a test after writing the implementation.
First, I need to modify my GET
and POST
methods to handle cookies. I add an overrides
param which can be used to include cookies. I use this new param in my new tests:
// app.test.mjs (partial)
describe("GET /profile", () => {
it("with cookie, returns user", async () => {
await GET("/profile", { cookies: { username: "foo" } });
assertRender("profile.ejs", { name: "foo" });
});
it("without cookie, redirects to login", async () => {
await GET("/profile");
assertRedirect("/login");
});
});
async function POST(path, body, overrides = {}) {
res = await app.handleRequest({ method: "POST", path, body, cookies: {}, ...overrides });
}
async function GET(path, overrides = {}) {
res = await app.handleRequest({ method: "GET", path, cookies: {}, ...overrides });
}
I need to handle the unauthenticated case in my GET /profile
method:
// app.mjs
"GET /profile"({ cookies, path }) {
if (!cookies.username)
return redirect("/login")
return render("profile.ejs", { name: cookies.username });
}
There is a huge flaw with this approach: Cookies are basically user input. Any visitor can alter the contents of the cookie, so anyone can now login as any user by just submitting a cookie with the user’s username.
To reject tainted cookies, or cookies that are otherwise manipulated, I’ll complete this blog by signing the cookies with a secret.
First, I’ll generate a secret and save it in my .envrc
:
echo "export SECRET='$(openssl rand -base64 48)'" >> .envrc
direnv allow
Now to sign the cookies with this secret using HMAC. I’m going to create two helper functions for this. I’ll also have all cookies signed by default, and verify all cookies in every request.
// src/cookies.mjs (partial)
import crypto from "node:crypto";
/**.
* @param {string} value
* @param {string} [secret=process.env.SECRET]
* @returns {string}
*/
function signCookie(value, secret = process.env.SECRET) {
const hmac = crypto.createHmac("sha256", secret);
hmac.update(value);
const signature = hmac.digest("base64url");
return `${value}.${signature}`;
}
/**
* @param {string} signedValue
* @param {string} [secret=process.env.SECRET]
* @returns {string|null}
*/
function verifyCookie(signedValue, secret = process.env.SECRET) {
const [value, signature] = signedValue.split(".");
if (!value || !signature) return null;
const expectedSignature = crypto
.createHmac("sha256", secret)
.update(value)
.digest("base64url");
if (crypto.timingSafeEqual(
Buffer.from(signature), Buffer.from(expectedSignature))) {
return value;
}
return null;
}
export function parseCookies(cookieHeader, secret = process.env.SECRET) {
if (!cookieHeader) return {};
return cookieHeader.split(';').reduce((cookies, cookie) => {
const [key, value] = cookie.split('=').map(part => part.trim());
if (key)
cookies[key] = verifyCookie(decodeURIComponent(value || ''), secret);
return cookies;
}, {});
}
// src/main.mjs (partial)
if (cookies)
for (const key in cookies)
if (cookies.hasOwnProperty(key)) {
const value = [
`${key}=${signCookie(cookies[key]) ?? ""}`,
`Path=/`,
`HttpOnly`,
`SameSite=Strict`,
cookies[key] ? `Max-Age=${YEAR_SECONDS}` : `Max-Age=-1`
].join("; ");
res.setHeader("set-cookie", value);
}
Since the signing and verifying of cookies is handled in src/main.mjs
, none of the request handlers need to change their implementation. The handlers receive verified cookies, and they can set cookies which are always signed automatically. I don’t have to change any of my handlers or their tests.
Again, I tested the change manually by checking if the browser gets assigned a signed cookie, and altering the cookie breaks the login. It did, so it works! In a real production-ready application, I would very likely add some tests specifically testing the HTTP request & response handling.