conductor

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

auth.test.js (6107B)


      1 // test/auth.test.js - password hashing, access 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 { signToken, verifyToken } from '../src/lib/auth/token.js';
      7 import { collectRoles } from '../src/lib/auth/oidc.js';
      8 import { maskBuffer } from '../src/lib/variables.js';
      9 
     10 // scrypt is intentionally slow, so these use the smallest sane parameters.
     11 const FAST = { N: 1024, r: 8, p: 1, keylen: 32 };
     12 
     13 test('a password round trips and a wrong one is refused', async () => {
     14   const stored = await hashPassword('correct horse battery', FAST);
     15   assert.ok(stored.startsWith('scrypt$1024$8$1$'));
     16   assert.ok(!stored.includes('correct horse battery'));
     17 
     18   assert.equal(await verifyPassword('correct horse battery', stored), true);
     19   assert.equal(await verifyPassword('wrong', stored), false);
     20 });
     21 
     22 test('the same password hashes differently each time', async () => {
     23   const a = await hashPassword('same password', FAST);
     24   const b = await hashPassword('same password', FAST);
     25   assert.notEqual(a, b, 'a per password salt is required');
     26   assert.equal(await verifyPassword('same password', a), true);
     27   assert.equal(await verifyPassword('same password', b), true);
     28 });
     29 
     30 test('parameters are read back from the stored hash', async () => {
     31   // A hash written with one cost must still verify after the default changes.
     32   const stored = await hashPassword('portable', { N: 2048, r: 8, p: 1, keylen: 32 });
     33   assert.ok(stored.startsWith('scrypt$2048$'));
     34   assert.equal(await verifyPassword('portable', stored), true);
     35 });
     36 
     37 test('a malformed stored hash is refused rather than throwing', async () => {
     38   for (const bad of ['', 'nonsense', 'scrypt$x$8$1$aa$bb', 'bcrypt$1$2$3$4$5', 'scrypt$1024$8$1$$']) {
     39     assert.equal(await verifyPassword('anything', bad), false, `${JSON.stringify(bad)} should be refused`);
     40   }
     41 });
     42 
     43 test('password length is bounded', () => {
     44   assert.match(validatePassword('short'), /at least 8/);
     45   assert.match(validatePassword('x'.repeat(2000)), /at most/);
     46   assert.equal(validatePassword('long enough'), null);
     47   assert.ok(generatePassword().length >= 20);
     48 });
     49 
     50 const ACCESS = { use: 'access', iss: 'https://ci.example', aud: 'conductor' };
     51 
     52 test('an access token round trips its claims', () => {
     53   const token = signToken('secret', { ...ACCESS, sub: 'u1', name: 'alice', role: 'admin', ttl: 900 });
     54   const claims = verifyToken('secret', token, ACCESS);
     55   assert.equal(claims.sub, 'u1');
     56   assert.equal(claims.name, 'alice');
     57   assert.equal(claims.role, 'admin');
     58 });
     59 
     60 test('a token signed with another secret is refused', () => {
     61   const token = signToken('secret', { ...ACCESS, sub: 'u1', ttl: 900 });
     62   assert.equal(verifyToken('other-secret', token, ACCESS), null);
     63 });
     64 
     65 test('a tampered payload is refused', () => {
     66   const token = signToken('secret', { ...ACCESS, sub: 'u1', role: 'user', ttl: 900 });
     67   const [header, payload, signature] = token.split('.');
     68   const forged = Buffer.from(JSON.stringify({
     69     ...JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')),
     70     role: 'admin',
     71   })).toString('base64url');
     72 
     73   assert.equal(verifyToken('secret', `${header}.${forged}.${signature}`, ACCESS), null);
     74 });
     75 
     76 test('an expired token is refused', () => {
     77   const token = signToken('secret', { ...ACCESS, sub: 'u1', ttl: 60 });
     78   assert.ok(verifyToken('secret', token, ACCESS));
     79   assert.equal(verifyToken('secret', token, { ...ACCESS, now: Date.now() + 61_000 }), null);
     80 });
     81 
     82 test('a malformed token is refused rather than throwing', () => {
     83   for (const bad of ['', 'a.b', 'a.b.c.d', 'not-a-token', null, undefined, 12]) {
     84     assert.equal(verifyToken('secret', bad, ACCESS), null);
     85   }
     86 });
     87 
     88 // A worker token shares the Authorization header with these.
     89 test('an opaque worker token never parses as an access token', () => {
     90   assert.equal(verifyToken('secret', 'a'.repeat(64), ACCESS), null);
     91 });
     92 
     93 // Both are signed with the same secret, so only _tu keeps them apart.
     94 test('an authorization code cannot be spent as an access token', () => {
     95   const code = signToken('secret', { ...ACCESS, use: 'code', sub: 'u1', ttl: 60 });
     96 
     97   assert.ok(verifyToken('secret', code, { ...ACCESS, use: 'code' }));
     98   assert.equal(verifyToken('secret', code, ACCESS), null);
     99 });
    100 
    101 test('an access token cannot be redeemed as an authorization code', () => {
    102   const token = signToken('secret', { ...ACCESS, sub: 'u1', ttl: 900 });
    103   assert.equal(verifyToken('secret', token, { ...ACCESS, use: 'code' }), null);
    104 });
    105 
    106 test('a token minted for another issuer or client is refused', () => {
    107   const token = signToken('secret', { ...ACCESS, sub: 'u1', ttl: 900 });
    108   assert.equal(verifyToken('secret', token, { ...ACCESS, iss: 'https://evil.example' }), null);
    109   assert.equal(verifyToken('secret', token, { ...ACCESS, aud: 'other-client' }), null);
    110 });
    111 
    112 test('oidc roles are collected from the usual claim shapes', () => {
    113   assert.deepEqual(collectRoles({ roles: ['a'] }), ['a']);
    114   assert.deepEqual(collectRoles({ realm_access: { roles: ['keycloak-admin'] } }), ['keycloak-admin']);
    115   assert.deepEqual(collectRoles({ groups: 'single' }), ['single']);
    116 
    117   const combined = collectRoles({
    118     roles: ['a'],
    119     groups: ['b'],
    120     realm_access: { roles: ['c'] },
    121     resource_access: { app: { roles: ['d'] } },
    122   });
    123   assert.deepEqual(combined.sort(), ['a', 'b', 'c', 'd']);
    124   assert.deepEqual(collectRoles({}), []);
    125 });
    126 
    127 test('mask replaces secrets, longest first', () => {
    128   const out = maskBuffer(Buffer.from('token=abcdef and abc12345 here'), ['abcdef', 'abc12345']).toString();
    129   assert.ok(!out.includes('abcdef'));
    130   assert.ok(!out.includes('abc12345'));
    131   assert.equal(out, 'token=[masked] and [masked] here');
    132 });
    133 
    134 test('mask ignores very short values and leaves clean output alone', () => {
    135   assert.equal(maskBuffer(Buffer.from('a b c'), ['a']).toString(), 'a b c');
    136   const clean = Buffer.from('nothing to hide');
    137   assert.equal(maskBuffer(clean, ['absent-secret']), clean);
    138   assert.equal(maskBuffer(clean, []), clean);
    139 });