diff --git a/services/mam-api/src/auth/passwords.js b/services/mam-api/src/auth/passwords.js new file mode 100644 index 0000000..2d88990 --- /dev/null +++ b/services/mam-api/src/auth/passwords.js @@ -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; + } +} diff --git a/services/mam-api/src/auth/tokens.js b/services/mam-api/src/auth/tokens.js new file mode 100644 index 0000000..15d2b36 --- /dev/null +++ b/services/mam-api/src/auth/tokens.js @@ -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); +} diff --git a/services/mam-api/test/auth/passwords.test.js b/services/mam-api/test/auth/passwords.test.js new file mode 100644 index 0000000..5679f43 --- /dev/null +++ b/services/mam-api/test/auth/passwords.test.js @@ -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); +}); diff --git a/services/mam-api/test/auth/tokens.test.js b/services/mam-api/test/auth/tokens.test.js new file mode 100644 index 0000000..ec9f906 --- /dev/null +++ b/services/mam-api/test/auth/tokens.test.js @@ -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); +});