refresh.test.js (7662B)
1 // test/refresh.test.js - the refresh token store 2 3 import test from 'node:test'; 4 import assert from 'node:assert/strict'; 5 import fs from 'node:fs/promises'; 6 import os from 'node:os'; 7 import path from 'node:path'; 8 import { openDatabase, runMigrations } from '../src/lib/db/index.js'; 9 import { createRefreshTokens } from '../src/lib/auth/refresh.js'; 10 import { newToken, newRefreshTokenId, hashToken } from '../src/lib/ids.js'; 11 12 const HOUR = 3600_000; 13 14 async function fixture() { 15 const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'conductor-refresh-')); 16 const db = await openDatabase({ database: { dialect: 'sqlite', path: path.join(dir, 'test.db') } }); 17 await runMigrations(db, { logger: () => {} }); 18 19 await db.run( 20 `INSERT INTO users (id, username, password_hash, role, disabled, created_at) 21 VALUES ({id}, {u}, 'x', 'user', 0, {now})`, 22 { id: 'user-1', u: 'alice', now: Date.now() } 23 ); 24 await db.run( 25 `INSERT INTO users (id, username, password_hash, role, disabled, created_at) 26 VALUES ({id}, {u}, 'x', 'user', 0, {now})`, 27 { id: 'user-2', u: 'bob', now: Date.now() } 28 ); 29 30 return { 31 db, 32 store: createRefreshTokens({ db }), 33 cleanup: async () => { await db.close(); await fs.rm(dir, { recursive: true, force: true }); }, 34 }; 35 } 36 37 // Issues a session and hands back the raw token, which is never stored. 38 async function session(store, { userId = 'user-1', ttl = HOUR, now = Date.now(), ...rest } = {}) { 39 const token = newToken(); 40 const row = await store.issue({ 41 id: newRefreshTokenId(), userId, token, expiresAt: now + ttl, now, ...rest, 42 }); 43 return { token, row }; 44 } 45 46 test('a token is stored only as its hash', async () => { 47 const { db, store, cleanup } = await fixture(); 48 const { token, row } = await session(store); 49 50 const stored = await db.get('SELECT token_hash FROM refresh_tokens WHERE id = {id}', { id: row.id }); 51 assert.equal(stored.token_hash, hashToken(token)); 52 assert.notEqual(stored.token_hash, token); 53 await cleanup(); 54 }); 55 56 test('claiming finds the row by the raw token alone', async () => { 57 const { store, cleanup } = await fixture(); 58 const { token, row } = await session(store); 59 60 const claimed = await store.claim(token); 61 assert.equal(claimed.id, row.id); 62 assert.equal(claimed.user_id, 'user-1'); 63 await cleanup(); 64 }); 65 66 test('an unknown, empty or non-string token claims nothing', async () => { 67 const { store, cleanup } = await fixture(); 68 await session(store); 69 70 assert.equal(await store.claim(newToken()), null); 71 assert.equal(await store.claim(''), null); 72 assert.equal(await store.claim(undefined), null); 73 assert.equal(await store.claim(null), null); 74 await cleanup(); 75 }); 76 77 test('an expired token claims nothing', async () => { 78 const { store, cleanup } = await fixture(); 79 const now = Date.now(); 80 const { token } = await session(store, { ttl: HOUR, now: now - 2 * HOUR }); 81 82 assert.equal(await store.claim(token, { now }), null); 83 await cleanup(); 84 }); 85 86 // The token is the session. Refreshing does not mint a new one, so two tabs 87 // refreshing at once cannot race each other into a logout. 88 test('the token survives a refresh and stays usable', async () => { 89 const { store, cleanup } = await fixture(); 90 const { token, row } = await session(store); 91 92 assert.equal(await store.touch({ id: row.id }), true); 93 assert.equal(await store.touch({ id: row.id }), true); 94 95 const again = await store.claim(token); 96 assert.equal(again.id, row.id); 97 assert.ok(again.last_used_at); 98 await cleanup(); 99 }); 100 101 test('a sliding session moves its deadline forward on use', async () => { 102 const { store, cleanup } = await fixture(); 103 const now = Date.now(); 104 const { token, row } = await session(store, { ttl: HOUR, now }); 105 106 await store.touch({ id: row.id, expiresAt: now + 4 * HOUR, now: now + HOUR / 2 }); 107 108 const claimed = await store.claim(token, { now: now + 2 * HOUR }); 109 assert.equal(claimed.expires_at, now + 4 * HOUR); 110 await cleanup(); 111 }); 112 113 test('an absolute session keeps its original deadline across uses', async () => { 114 const { store, cleanup } = await fixture(); 115 const now = Date.now(); 116 const { token, row } = await session(store, { ttl: HOUR, now }); 117 118 await store.touch({ id: row.id, now: now + HOUR / 2 }); 119 120 assert.equal((await store.claim(token, { now: now + HOUR / 2 })).expires_at, now + HOUR); 121 assert.equal(await store.claim(token, { now: now + 2 * HOUR }), null); 122 await cleanup(); 123 }); 124 125 test('touching a revoked session reports that it is gone', async () => { 126 const { store, cleanup } = await fixture(); 127 const { row } = await session(store); 128 129 await store.revoke(row.id); 130 assert.equal(await store.touch({ id: row.id }), false); 131 await cleanup(); 132 }); 133 134 test('revoking is idempotent and only affects the named row', async () => { 135 const { store, cleanup } = await fixture(); 136 const { token, row } = await session(store); 137 const other = await session(store); 138 139 assert.equal(await store.revoke(row.id), true); 140 assert.equal(await store.revoke(row.id), false); 141 assert.equal(await store.claim(token), null); 142 assert.ok(await store.claim(other.token)); 143 await cleanup(); 144 }); 145 146 test('revokeAllFor reports how many sessions it ended', async () => { 147 const { store, cleanup } = await fixture(); 148 await session(store); 149 await session(store); 150 await session(store, { userId: 'user-2' }); 151 152 assert.equal(await store.revokeAllFor('user-1'), 2); 153 assert.equal(await store.revokeAllFor('user-1'), 0); 154 await cleanup(); 155 }); 156 157 test('paging lists live sessions, newest first, scoped or not', async () => { 158 const { store, cleanup } = await fixture(); 159 const now = Date.now(); 160 await session(store, { now: now - 2000 }); 161 const recent = await session(store, { now: now - 1000 }); 162 await session(store, { userId: 'user-2', now }); 163 const dead = await session(store); 164 await store.revoke(dead.row.id); 165 166 const mine = await store.page({ userId: 'user-1', offset: 0, limit: 10, now }); 167 assert.equal(mine.total, 2); 168 assert.equal(mine.rows[0].id, recent.row.id); 169 170 const all = await store.page({ offset: 0, limit: 10, now }); 171 assert.equal(all.total, 3); 172 await cleanup(); 173 }); 174 175 test('the agent and address are kept for display', async () => { 176 const { store, cleanup } = await fixture(); 177 await session(store, { userAgent: 'Firefox/1', ip: '10.0.0.1' }); 178 179 const listed = await store.page({ userId: 'user-1', offset: 0, limit: 10 }); 180 assert.equal(listed.rows[0].user_agent, 'Firefox/1'); 181 assert.equal(listed.rows[0].ip, '10.0.0.1'); 182 await cleanup(); 183 }); 184 185 // A train moves between networks and a browser updates itself. Neither is a 186 // security signal, so both are accepted and the row follows them. 187 test('roaming to another address and browser still refreshes', async () => { 188 const { store, cleanup } = await fixture(); 189 const { token, row } = await session(store, { userAgent: 'Firefox/1', ip: '10.0.0.1' }); 190 191 assert.ok(await store.claim(token)); 192 await store.touch({ id: row.id, userAgent: 'Firefox/2', ip: '192.0.2.77' }); 193 194 assert.ok(await store.claim(token)); 195 196 const listed = await store.page({ userId: 'user-1', offset: 0, limit: 10 }); 197 assert.equal(listed.total, 1); 198 assert.equal(listed.rows[0].user_agent, 'Firefox/2'); 199 assert.equal(listed.rows[0].ip, '192.0.2.77'); 200 await cleanup(); 201 }); 202 203 test('sweeping drops expired rows and leaves live ones', async () => { 204 const { store, cleanup } = await fixture(); 205 const now = Date.now(); 206 await session(store, { ttl: HOUR, now: now - 4 * HOUR }); 207 const live = await session(store, { ttl: HOUR, now }); 208 209 assert.equal(await store.sweep(now - HOUR), 1); 210 assert.ok(await store.claim(live.token, { now })); 211 await cleanup(); 212 });