conductor

CI task system
git clone git://git.finwo.net/app/conductor
Log | Files | Refs | README | LICENSE

auth.test.js (6626B)


      1 // test/auth.test.js - password hashing, session tokens and role mapping
      2 
      3 import test from 'node:test';
      4 import assert from 'node:assert/strict';
      5 import { hashPassword, verifyPassword, validatePassword, generatePassword } from '../src/lib/auth/password.js';
      6 import { issueToken, verifyToken, parseCookies, sessionCookie, SESSION_COOKIE } from '../src/lib/auth/token.js';
      7 import { createState, verifyState } from '../src/lib/auth/state.js';
      8 import { collectRoles } from '../src/lib/auth/oidc.js';
      9 import { maskBuffer } from '../src/lib/variables.js';
     10 
     11 // scrypt is intentionally slow, so these use the smallest sane parameters.
     12 const FAST = { N: 1024, r: 8, p: 1, keylen: 32 };
     13 
     14 test('a password round trips and a wrong one is refused', async () => {
     15   const stored = await hashPassword('correct horse battery', FAST);
     16   assert.ok(stored.startsWith('scrypt$1024$8$1$'));
     17   assert.ok(!stored.includes('correct horse battery'));
     18 
     19   assert.equal(await verifyPassword('correct horse battery', stored), true);
     20   assert.equal(await verifyPassword('wrong', stored), false);
     21 });
     22 
     23 test('the same password hashes differently each time', async () => {
     24   const a = await hashPassword('same password', FAST);
     25   const b = await hashPassword('same password', FAST);
     26   assert.notEqual(a, b, 'a per password salt is required');
     27   assert.equal(await verifyPassword('same password', a), true);
     28   assert.equal(await verifyPassword('same password', b), true);
     29 });
     30 
     31 test('parameters are read back from the stored hash', async () => {
     32   // A hash written with one cost must still verify after the default changes.
     33   const stored = await hashPassword('portable', { N: 2048, r: 8, p: 1, keylen: 32 });
     34   assert.ok(stored.startsWith('scrypt$2048$'));
     35   assert.equal(await verifyPassword('portable', stored), true);
     36 });
     37 
     38 test('a malformed stored hash is refused rather than throwing', async () => {
     39   for (const bad of ['', 'nonsense', 'scrypt$x$8$1$aa$bb', 'bcrypt$1$2$3$4$5', 'scrypt$1024$8$1$$']) {
     40     assert.equal(await verifyPassword('anything', bad), false, `${JSON.stringify(bad)} should be refused`);
     41   }
     42 });
     43 
     44 test('password length is bounded', () => {
     45   assert.match(validatePassword('short'), /at least 8/);
     46   assert.match(validatePassword('x'.repeat(2000)), /at most/);
     47   assert.equal(validatePassword('long enough'), null);
     48   assert.ok(generatePassword().length >= 20);
     49 });
     50 
     51 test('a session token round trips its claims', () => {
     52   const token = issueToken('secret', { sub: 'u1', name: 'alice', role: 'admin' });
     53   const claims = verifyToken('secret', token);
     54   assert.equal(claims.sub, 'u1');
     55   assert.equal(claims.name, 'alice');
     56   assert.equal(claims.role, 'admin');
     57 });
     58 
     59 test('a token signed with another secret is refused', () => {
     60   const token = issueToken('secret', { sub: 'u1', name: 'a', role: 'admin' });
     61   assert.equal(verifyToken('other-secret', token), null);
     62 });
     63 
     64 test('a tampered payload is refused', () => {
     65   const token = issueToken('secret', { sub: 'u1', name: 'a', role: 'user' });
     66   const [header, payload, signature] = token.split('.');
     67   const forged = Buffer.from(JSON.stringify({
     68     ...JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')),
     69     role: 'admin',
     70   })).toString('base64url');
     71 
     72   assert.equal(verifyToken('secret', `${header}.${forged}.${signature}`), null);
     73 });
     74 
     75 test('an expired token is refused', () => {
     76   const token = issueToken('secret', { sub: 'u1', name: 'a', role: 'admin' }, { ttl: 60 });
     77   assert.ok(verifyToken('secret', token));
     78   assert.equal(verifyToken('secret', token, { now: Date.now() + 61_000 }), null);
     79 });
     80 
     81 test('a malformed token is refused rather than throwing', () => {
     82   for (const bad of ['', 'a.b', 'a.b.c.d', 'not-a-token', null, undefined, 12]) {
     83     assert.equal(verifyToken('secret', bad), null);
     84   }
     85 });
     86 
     87 test('cookies are parsed and issued', () => {
     88   const cookies = parseCookies(`a=1; ${SESSION_COOKIE}=abc%20def; b=2`);
     89   assert.equal(cookies[SESSION_COOKIE], 'abc def');
     90   assert.deepEqual(parseCookies(undefined), {});
     91 
     92   const header = sessionCookie('tok', { ttl: 60, secure: true });
     93   assert.ok(header.includes('HttpOnly'));
     94   assert.ok(header.includes('SameSite=Lax'));
     95   assert.ok(header.includes('Secure'));
     96   assert.ok(header.includes('Max-Age=60'));
     97   assert.ok(!sessionCookie('tok', { secure: false }).includes('Secure'));
     98 });
     99 
    100 test('the oidc login state round trips with its return path', () => {
    101   const now = 1_700_000_000_000;
    102   const state = createState('secret', '/projects?tab=1', { now });
    103   assert.deepEqual(verifyState('secret', state, { now }), { iat: 1_700_000_000, url: '/projects?tab=1' });
    104 });
    105 
    106 test('a missing return path means the root', () => {
    107   const state = createState('secret', undefined, { now: 1000 });
    108   assert.deepEqual(verifyState('secret', state, { now: 1000 }), { iat: 1, url: '/' });
    109 });
    110 
    111 test('the oidc login state is refused when forged, stale or off-site', () => {
    112   const now = 1_700_000_000_000;
    113   const state = createState('secret', '/', { now });
    114 
    115   assert.equal(verifyState('other-secret', state, { now }), null, 'a wrong secret');
    116   assert.equal(verifyState('secret', `${state}x`, { now }), null, 'a tampered signature');
    117   assert.equal(verifyState('secret', state, { now: now + 301_000 }), null, 'older than five minutes');
    118   assert.equal(verifyState('secret', 'garbage', { now }), null, 'malformed');
    119   assert.equal(verifyState('secret', createState('secret', '//evil.example', { now }), { now }), null, 'off-site');
    120 });
    121 
    122 test('oidc roles are collected from the usual claim shapes', () => {
    123   assert.deepEqual(collectRoles({ roles: ['a'] }), ['a']);
    124   assert.deepEqual(collectRoles({ realm_access: { roles: ['keycloak-admin'] } }), ['keycloak-admin']);
    125   assert.deepEqual(collectRoles({ groups: 'single' }), ['single']);
    126 
    127   const combined = collectRoles({
    128     roles: ['a'],
    129     groups: ['b'],
    130     realm_access: { roles: ['c'] },
    131     resource_access: { app: { roles: ['d'] } },
    132   });
    133   assert.deepEqual(combined.sort(), ['a', 'b', 'c', 'd']);
    134   assert.deepEqual(collectRoles({}), []);
    135 });
    136 
    137 test('mask replaces secrets, longest first', () => {
    138   const out = maskBuffer(Buffer.from('token=abcdef and abc12345 here'), ['abcdef', 'abc12345']).toString();
    139   assert.ok(!out.includes('abcdef'));
    140   assert.ok(!out.includes('abc12345'));
    141   assert.equal(out, 'token=[masked] and [masked] here');
    142 });
    143 
    144 test('mask ignores very short values and leaves clean output alone', () => {
    145   assert.equal(maskBuffer(Buffer.from('a b c'), ['a']).toString(), 'a b c');
    146   const clean = Buffer.from('nothing to hide');
    147   assert.equal(maskBuffer(clean, ['absent-secret']), clean);
    148   assert.equal(maskBuffer(clean, []), clean);
    149 });