feat(mam-api): auth utilities — password hash/compare + token gen/hash/parse
This commit is contained in:
parent
14931d6362
commit
3fc8116dd3
4 changed files with 90 additions and 0 deletions
19
services/mam-api/src/auth/passwords.js
Normal file
19
services/mam-api/src/auth/passwords.js
Normal 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;
|
||||
}
|
||||
}
|
||||
22
services/mam-api/src/auth/tokens.js
Normal file
22
services/mam-api/src/auth/tokens.js
Normal 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);
|
||||
}
|
||||
18
services/mam-api/test/auth/passwords.test.js
Normal file
18
services/mam-api/test/auth/passwords.test.js
Normal 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);
|
||||
});
|
||||
31
services/mam-api/test/auth/tokens.test.js
Normal file
31
services/mam-api/test/auth/tokens.test.js
Normal 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);
|
||||
});
|
||||
Loading…
Reference in a new issue