conductor

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

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