auth

Main authentication module for my projects
git clone git://git.finwo.net/app/auth
Log | Files | Refs

session.test.js (14276B)


      1 import test from 'node:test';
      2 import assert from 'node:assert/strict';
      3 import crypto from 'node:crypto';
      4 import fs from 'node:fs/promises';
      5 import os from 'node:os';
      6 import path from 'node:path';
      7 import {
      8   closeRealms,
      9   createRealm,
     10   getSystemDatabase,
     11   initRealms,
     12   openRealmDatabase,
     13 } from '../src/lib/realm.js';
     14 import {
     15   SESSION_COOKIE,
     16   SHORT_SESSION_TTL,
     17   authenticateRequest,
     18   bindSessionRealm,
     19   createHostSession,
     20   destroySession,
     21   getSessionByToken,
     22   listSessionAccounts,
     23   selectSessionAccount,
     24 } from '../src/lib/session.js';
     25 import { createUser, ensureMembership } from '../src/lib/users.js';
     26 
     27 function realmContext(realm, host = 'acme.localhost') {
     28   return { mode: 'realm', host, realm };
     29 }
     30 
     31 function clientContext(realm, host = 'app.acme.localhost', clientId = 'internal-app') {
     32   return { mode: 'client', host, realm, clientId };
     33 }
     34 
     35 function canonicalContext(host = 'identity.test') {
     36   return { mode: 'canonical', host };
     37 }
     38 
     39 async function setup() {
     40   const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'auth-session-'));
     41   const cfg = {
     42     database: {
     43       dialect: 'sqlite',
     44       path: path.join(dir, 'system.sqlite'),
     45       realms: path.join(dir, 'realms'),
     46     },
     47   };
     48   await initRealms(cfg);
     49   const realm = await createRealm({ slug: 'acme', displayName: 'Acme' });
     50   const realmDb = await openRealmDatabase(realm);
     51   const systemDb = getSystemDatabase();
     52   const userId = await createUser(systemDb, { username: 'alice', displayName: 'Alice' });
     53   await ensureMembership(realmDb, userId, 'active');
     54   return { dir, realm, realmDb, systemDb, userId };
     55 }
     56 
     57 function mockRequest(cookie = '') {
     58   return {
     59     headers: { cookie },
     60     socket: { remoteAddress: '127.0.0.1' },
     61   };
     62 }
     63 
     64 function mockResponse() {
     65   const headers = {};
     66   return {
     67     headers,
     68     getHeader(name) {
     69       return headers[name.toLowerCase()];
     70     },
     71     setHeader(name, value) {
     72       headers[name.toLowerCase()] = value;
     73     },
     74   };
     75 }
     76 
     77 test('sessions store only the group token hash and authenticate the user', async () => {
     78   const { dir, realm, realmDb, systemDb, userId } = await setup();
     79   try {
     80     const context = realmContext(realm);
     81     const { token, group, session } = await createHostSession(systemDb, {
     82       context,
     83       userId,
     84       ttl: SHORT_SESSION_TTL,
     85       userAgent: 'test',
     86       ip: '127.0.0.1',
     87     });
     88     const id = crypto.createHash('sha256').update(token).digest('hex');
     89 
     90     assert.equal(group.id, id);
     91     assert.notEqual(session.public_id, token);
     92 
     93     const found = await getSessionByToken(systemDb, token);
     94     assert.equal(found.session.id, session.id);
     95 
     96     const req = mockRequest(`${SESSION_COOKIE}=${token}`);
     97     const res = mockResponse();
     98     const auth = await authenticateRequest({
     99       systemDb,
    100       realmDb,
    101       req,
    102       res,
    103       context,
    104     });
    105     assert.equal(auth.user.id, userId);
    106     assert.equal(auth.membership.status, 'active');
    107   } finally {
    108     await closeRealms();
    109     await fs.rm(dir, { recursive: true, force: true });
    110   }
    111 });
    112 
    113 test('host scopes are stored from the request context', async () => {
    114   const { dir, realm, systemDb, userId } = await setup();
    115   try {
    116     const global = await createHostSession(systemDb, {
    117       context: canonicalContext(),
    118       userId,
    119       ttl: SHORT_SESSION_TTL,
    120     });
    121     assert.equal(global.session.scope_type, 'global');
    122     assert.equal(global.session.scope_host, 'identity.test');
    123     assert.equal(global.session.realm_id, null);
    124 
    125     const client = await createHostSession(systemDb, {
    126       context: clientContext(realm),
    127       userId,
    128       ttl: SHORT_SESSION_TTL,
    129     });
    130     assert.equal(client.session.scope_type, 'client');
    131     assert.equal(client.session.scope_host, 'app.acme.localhost');
    132     assert.equal(client.session.realm_id, realm.id);
    133     assert.equal(client.session.client_id, 'internal-app');
    134   } finally {
    135     await closeRealms();
    136     await fs.rm(dir, { recursive: true, force: true });
    137   }
    138 });
    139 
    140 test('sliding sessions extend expiry and reissue the cookie', async () => {
    141   const { dir, realm, realmDb, systemDb, userId } = await setup();
    142   try {
    143     const context = realmContext(realm);
    144     const { token, session } = await createHostSession(
    145       systemDb,
    146       { context, userId, ttl: SHORT_SESSION_TTL },
    147       1000
    148     );
    149     const req = mockRequest(`${SESSION_COOKIE}=${token}`);
    150     const res = mockResponse();
    151 
    152     await authenticateRequest({
    153       systemDb,
    154       realmDb,
    155       req,
    156       res,
    157       context,
    158       now: 1000 + 61_000,
    159     });
    160 
    161     const updated = await systemDb.get(
    162       'SELECT expires_at, last_seen_at FROM sessions WHERE id = {id}',
    163       { id: session.id }
    164     );
    165     assert.equal(updated.expires_at, 1000 + 61_000 + SHORT_SESSION_TTL * 1000);
    166     assert.ok(res.getHeader('set-cookie'));
    167   } finally {
    168     await closeRealms();
    169     await fs.rm(dir, { recursive: true, force: true });
    170   }
    171 });
    172 
    173 test('expired sessions are rejected and their group is removed', async () => {
    174   const { dir, realm, realmDb, systemDb, userId } = await setup();
    175   try {
    176     const context = realmContext(realm);
    177     const { token, group } = await createHostSession(
    178       systemDb,
    179       { context, userId, ttl: 1 },
    180       1000
    181     );
    182 
    183     assert.equal(await getSessionByToken(systemDb, token, 3000), null);
    184     await authenticateRequest({
    185       systemDb,
    186       realmDb,
    187       req: mockRequest(`${SESSION_COOKIE}=${token}`),
    188       res: mockResponse(),
    189       context,
    190       now: 3000,
    191     });
    192     const row = await systemDb.get('SELECT id FROM session_groups WHERE id = {id}', {
    193       id: group.id,
    194     });
    195     assert.equal(row, undefined);
    196   } finally {
    197     await closeRealms();
    198     await fs.rm(dir, { recursive: true, force: true });
    199   }
    200 });
    201 
    202 test('disabled users lose access and their session group is removed', async () => {
    203   const { dir, realm, realmDb, systemDb, userId } = await setup();
    204   try {
    205     const context = realmContext(realm);
    206     const { token, group } = await createHostSession(systemDb, {
    207       context,
    208       userId,
    209       ttl: SHORT_SESSION_TTL,
    210     });
    211 
    212     await systemDb.run('UPDATE users SET status = {status} WHERE id = {id}', {
    213       status: 'disabled',
    214       id: userId,
    215     });
    216     assert.equal(
    217       await authenticateRequest({
    218         systemDb,
    219         realmDb,
    220         req: mockRequest(`${SESSION_COOKIE}=${token}`),
    221         res: mockResponse(),
    222         context,
    223       }),
    224       null
    225     );
    226 
    227     const row = await systemDb.get('SELECT id FROM session_groups WHERE id = {id}', {
    228       id: group.id,
    229     });
    230     assert.equal(row, undefined);
    231   } finally {
    232     await closeRealms();
    233     await fs.rm(dir, { recursive: true, force: true });
    234   }
    235 });
    236 
    237 test('disabling the active account keeps other accounts in the group', async () => {
    238   const { dir, realm, systemDb, userId } = await setup();
    239   try {
    240     const context = realmContext(realm);
    241     const first = await createHostSession(systemDb, {
    242       context,
    243       userId,
    244       ttl: SHORT_SESSION_TTL,
    245     });
    246     const otherId = await createUser(systemDb, { username: 'bob', displayName: 'Bob' });
    247     const second = await createHostSession(systemDb, {
    248       context,
    249       token: first.token,
    250       userId: otherId,
    251       ttl: SHORT_SESSION_TTL,
    252     });
    253 
    254     await systemDb.run('UPDATE users SET status = {status} WHERE id = {id}', {
    255       status: 'disabled',
    256       id: otherId,
    257     });
    258 
    259     const res = mockResponse();
    260     assert.equal(
    261       await authenticateRequest({
    262         systemDb,
    263         realmDb: null,
    264         req: mockRequest(`${SESSION_COOKIE}=${first.token}`),
    265         res,
    266         context,
    267       }),
    268       null
    269     );
    270 
    271     const found = await getSessionByToken(systemDb, first.token);
    272     assert.equal(found.session.user_id, userId);
    273 
    274     const accounts = await listSessionAccounts(systemDb, second.group);
    275     assert.equal(accounts.length, 1);
    276     assert.equal(accounts[0].user.id, userId);
    277   } finally {
    278     await closeRealms();
    279     await fs.rm(dir, { recursive: true, force: true });
    280   }
    281 });
    282 
    283 test('a session is rejected on a different host scope', async () => {
    284   const { dir, realm, realmDb, systemDb, userId } = await setup();
    285   try {
    286     const context = realmContext(realm);
    287     const { token, group } = await createHostSession(systemDb, {
    288       context,
    289       userId,
    290       ttl: SHORT_SESSION_TTL,
    291     });
    292 
    293     assert.equal(
    294       await authenticateRequest({
    295         systemDb,
    296         realmDb,
    297         req: mockRequest(`${SESSION_COOKIE}=${token}`),
    298         res: mockResponse(),
    299         context: clientContext(realm),
    300       }),
    301       null
    302     );
    303 
    304     const row = await systemDb.get('SELECT id FROM session_groups WHERE id = {id}', {
    305       id: group.id,
    306     });
    307     assert.equal(row.id, group.id);
    308   } finally {
    309     await closeRealms();
    310     await fs.rm(dir, { recursive: true, force: true });
    311   }
    312 });
    313 
    314 test('the same user reuses a session and a different user adds an account', async () => {
    315   const { dir, realm, systemDb, userId } = await setup();
    316   try {
    317     const context = realmContext(realm);
    318     const first = await createHostSession(systemDb, {
    319       context,
    320       userId,
    321       ttl: SHORT_SESSION_TTL,
    322     });
    323     const second = await createHostSession(systemDb, {
    324       context,
    325       token: first.token,
    326       userId,
    327       ttl: SHORT_SESSION_TTL,
    328     });
    329     assert.equal(second.group.id, first.group.id);
    330     assert.equal(second.token, first.token);
    331     assert.equal(second.session.id, first.session.id);
    332 
    333     const otherId = await createUser(systemDb, { username: 'bob', displayName: 'Bob' });
    334     const third = await createHostSession(systemDb, {
    335       context,
    336       token: first.token,
    337       userId: otherId,
    338       ttl: SHORT_SESSION_TTL,
    339     });
    340     assert.equal(third.group.id, first.group.id);
    341     assert.equal(third.token, first.token);
    342     assert.equal(third.session.user_id, otherId);
    343 
    344     const old = await systemDb.get('SELECT id FROM sessions WHERE id = {id}', {
    345       id: first.session.id,
    346     });
    347     assert.equal(old.id, first.session.id);
    348 
    349     const accounts = await listSessionAccounts(systemDb, third.group);
    350     assert.deepEqual(
    351       accounts.map((account) => account.user.username),
    352       ['alice', 'bob']
    353     );
    354     assert.equal(accounts.find((account) => account.user.id === otherId).active, true);
    355   } finally {
    356     await closeRealms();
    357     await fs.rm(dir, { recursive: true, force: true });
    358   }
    359 });
    360 
    361 test('selecting an account updates the active session', async () => {
    362   const { dir, realm, systemDb, userId } = await setup();
    363   try {
    364     const context = realmContext(realm);
    365     const first = await createHostSession(systemDb, {
    366       context,
    367       userId,
    368       ttl: SHORT_SESSION_TTL,
    369     });
    370     const otherId = await createUser(systemDb, { username: 'bob', displayName: 'Bob' });
    371     const second = await createHostSession(systemDb, {
    372       context,
    373       token: first.token,
    374       userId: otherId,
    375       ttl: SHORT_SESSION_TTL,
    376     });
    377 
    378     const selected = await selectSessionAccount(systemDb, {
    379       token: first.token,
    380       context,
    381       publicId: first.session.public_id,
    382     });
    383     assert.equal(selected.user.id, userId);
    384     assert.equal(selected.session.id, first.session.id);
    385 
    386     const found = await getSessionByToken(systemDb, first.token);
    387     assert.equal(found.session.id, first.session.id);
    388 
    389     const rejectedHost = await selectSessionAccount(systemDb, {
    390       token: first.token,
    391       context: clientContext(realm),
    392       publicId: second.session.public_id,
    393     });
    394     assert.equal(rejectedHost, null);
    395 
    396     const rejectedAccount = await selectSessionAccount(systemDb, {
    397       token: first.token,
    398       context,
    399       publicId: 'missing',
    400     });
    401     assert.equal(rejectedAccount, null);
    402   } finally {
    403     await closeRealms();
    404     await fs.rm(dir, { recursive: true, force: true });
    405   }
    406 });
    407 
    408 test('logout deletes the active session and group', async () => {
    409   const { dir, realm, systemDb, userId } = await setup();
    410   try {
    411     const { token, group } = await createHostSession(systemDb, {
    412       context: realmContext(realm),
    413       userId,
    414       ttl: SHORT_SESSION_TTL,
    415     });
    416 
    417     const result = await destroySession(systemDb, token);
    418     assert.equal(result.groupDeleted, true);
    419     assert.equal(await getSessionByToken(systemDb, token), null);
    420 
    421     const row = await systemDb.get('SELECT id FROM session_groups WHERE id = {id}', {
    422       id: group.id,
    423     });
    424     assert.equal(row, undefined);
    425   } finally {
    426     await closeRealms();
    427     await fs.rm(dir, { recursive: true, force: true });
    428   }
    429 });
    430 
    431 test('logout keeps the group when another account remains', async () => {
    432   const { dir, realm, systemDb, userId } = await setup();
    433   try {
    434     const context = realmContext(realm);
    435     const first = await createHostSession(systemDb, {
    436       context,
    437       userId,
    438       ttl: SHORT_SESSION_TTL,
    439     });
    440     const otherId = await createUser(systemDb, { username: 'bob', displayName: 'Bob' });
    441     const second = await createHostSession(systemDb, {
    442       context,
    443       token: first.token,
    444       userId: otherId,
    445       ttl: SHORT_SESSION_TTL,
    446     });
    447 
    448     const result = await destroySession(systemDb, first.token);
    449     assert.equal(result.groupDeleted, false);
    450 
    451     const found = await getSessionByToken(systemDb, first.token);
    452     assert.equal(found.session.id, first.session.id);
    453 
    454     const accounts = await listSessionAccounts(systemDb, second.group);
    455     assert.equal(accounts.length, 1);
    456     assert.equal(accounts[0].user.id, userId);
    457   } finally {
    458     await closeRealms();
    459     await fs.rm(dir, { recursive: true, force: true });
    460   }
    461 });
    462 
    463 test('realm bindings are inserted once and updated on repeat', async () => {
    464   const { dir, realm, systemDb, userId } = await setup();
    465   try {
    466     const { session } = await createHostSession(systemDb, {
    467       context: canonicalContext(),
    468       userId,
    469       ttl: SHORT_SESSION_TTL,
    470     });
    471 
    472     await bindSessionRealm(systemDb, session.id, realm.id, 1000);
    473     await bindSessionRealm(systemDb, session.id, realm.id, 2000);
    474 
    475     const rows = await systemDb.all(
    476       `SELECT session_id, realm_id, last_seen_at
    477          FROM session_realms
    478         WHERE session_id = {sessionId}`,
    479       { sessionId: session.id }
    480     );
    481     assert.equal(rows.length, 1);
    482     assert.equal(rows[0].last_seen_at, 2000);
    483   } finally {
    484     await closeRealms();
    485     await fs.rm(dir, { recursive: true, force: true });
    486   }
    487 });