feat(mam-api): auth utilities — password hash/compare + token gen/hash/parse

This commit is contained in:
Zac Gaetano 2026-05-27 13:51:15 -04:00
parent 14931d6362
commit 3fc8116dd3
4 changed files with 90 additions and 0 deletions

View file

@ -0,0 +1,19 @@
// Thin bcrypt wrapper. Cost 12 per the spec (NIST SP 800-63B-friendly).
// comparePassword must never throw on a malformed hash — that path is hit
// by the seeded dev user's placeholder hash and by any partially-imported
// row. Throwing here would 500 on a wrong-password attempt.
import bcrypt from 'bcrypt';
const COST = 12;
export async function hashPassword(plain) {
return bcrypt.hash(plain, COST);
}
export async function comparePassword(plain, hash) {
try {
return await bcrypt.compare(plain, hash);
} catch {
return false;
}
}

View file

@ -0,0 +1,22 @@
import { randomBytes, createHash } from 'node:crypto';
const PREFIX = 'dfl_';
export function generateToken() {
return PREFIX + randomBytes(32).toString('hex');
}
export function hashToken(token) {
return createHash('sha256').update(token).digest('hex');
}
export function parseBearer(authorizationHeader) {
if (!authorizationHeader || typeof authorizationHeader !== 'string') return null;
const m = authorizationHeader.match(/^Bearer\s+(\S+)$/i);
return m ? m[1] : null;
}
export const TOKEN_PREFIX_DISPLAY_LEN = 8; // for api_tokens.token_prefix
export function tokenDisplayPrefix(token) {
return token.slice(0, TOKEN_PREFIX_DISPLAY_LEN);
}

View file

@ -0,0 +1,18 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { hashPassword, comparePassword } from '../../src/auth/passwords.js';
test('hashPassword returns a bcrypt string that comparePassword accepts', async () => {
const hash = await hashPassword('correct-horse-battery-staple');
assert.match(hash, /^\$2[aby]\$\d{2}\$/);
assert.equal(await comparePassword('correct-horse-battery-staple', hash), true);
});
test('comparePassword returns false for the wrong password', async () => {
const hash = await hashPassword('correct-horse-battery-staple');
assert.equal(await comparePassword('wrong', hash), false);
});
test('comparePassword returns false (not throws) for a malformed hash', async () => {
assert.equal(await comparePassword('anything', '!disabled-no-login!'), false);
});

View file

@ -0,0 +1,31 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { generateToken, hashToken, parseBearer } from '../../src/auth/tokens.js';
test('generateToken returns dfl_-prefixed 32-byte hex (68 chars total)', () => {
const t = generateToken();
assert.match(t, /^dfl_[0-9a-f]{64}$/);
});
test('generateToken returns distinct values on each call', () => {
assert.notEqual(generateToken(), generateToken());
});
test('hashToken returns a stable 64-char hex SHA-256', () => {
const t = 'dfl_' + 'a'.repeat(64);
const h = hashToken(t);
assert.match(h, /^[0-9a-f]{64}$/);
assert.equal(hashToken(t), h);
});
test('parseBearer returns the token when header is well-formed', () => {
assert.equal(parseBearer('Bearer dfl_abc'), 'dfl_abc');
assert.equal(parseBearer('bearer dfl_xyz'), 'dfl_xyz'); // case-insensitive scheme
});
test('parseBearer returns null for missing or malformed headers', () => {
assert.equal(parseBearer(undefined), null);
assert.equal(parseBearer(''), null);
assert.equal(parseBearer('Basic abc'), null);
assert.equal(parseBearer('Bearer'), null);
});