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 });