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