conductor

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

commit 1aad5175bf9bd46971983f2e0856a87f2ec7c5c4
parent 17b05cd934a619318309c4855052f2073f08118c
Author: finwo <finwo@pm.me>
Date:   Sat, 26 Sep 2026 00:43:40 +0200

Keep a session alive with a stored refresh token

Diffstat:
Mconductor.example.yaml | 21+++++++++++++++------
Amigrations/mysql/005_refresh_tokens.sql | 16++++++++++++++++
Amigrations/postgres/005_refresh_tokens.sql | 16++++++++++++++++
Amigrations/sqlite/005_refresh_tokens.sql | 16++++++++++++++++
Msrc/lib/auth/oidc.js | 108++++++++++---------------------------------------------------------------------
Asrc/lib/auth/refresh.js | 81+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/lib/config.js | 35+++++++++++++++++++++++++++--------
Msrc/lib/ids.js | 1+
Mtest/config.test.js | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atest/refresh.test.js | 212+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
10 files changed, 457 insertions(+), 109 deletions(-)

diff --git a/conductor.example.yaml b/conductor.example.yaml @@ -43,17 +43,26 @@ auth: # Signs built-in session tokens. Generated per boot when unset, which logs # everyone out on restart, so set it in production. session_secret: null - session_ttl: 43200 - # Setting a discovery url switches authentication to OIDC. Built-in + access_ttl: 900 + refresh_ttl: 2592000 + # False expires a session refresh_ttl after signing in rather than after + # last use. + refresh_sliding: true + # The built-in login provider. Derived from oidc.discovery_url when unset; + # setting it true uses the built-in one even with a provider configured. + local: + enabled: null + # Where the app is allowed to be sent back to, when it is not served + # from server.public_url. Comma separated. + redirect_uris: null + # Setting a discovery url switches authentication to the provider. Built-in # accounts are used when it is unset. Everything else about the provider, # including its issuer and every endpoint, is read from this document. oidc: discovery_url: null - # The client this conductor is registered as. The redirect uri to - # register is <server.public_url>/oidc/callback. + # The client this conductor is registered as. Register it as a public + # client with PKCE, and the app's origin as a redirect uri. client_id: null - # Only for a confidential client; leave unset for a public one. - client_secret: null scopes: openid profile email admin_role: conductor-admin # Created on first boot, only while the users table is empty, and only diff --git a/migrations/mysql/005_refresh_tokens.sql b/migrations/mysql/005_refresh_tokens.sql @@ -0,0 +1,16 @@ +-- 005_refresh_tokens.sql - revocable refresh tokens, built-in provider only + +CREATE TABLE refresh_tokens ( + id VARCHAR(64) NOT NULL PRIMARY KEY, + user_id VARCHAR(64) NOT NULL, + token_hash CHAR(64) NOT NULL UNIQUE, + issued_at BIGINT NOT NULL, + expires_at BIGINT NOT NULL, + last_used_at BIGINT, + revoked_at BIGINT, + user_agent VARCHAR(255), + ip VARCHAR(64), + KEY idx_refresh_tokens_user (user_id), + KEY idx_refresh_tokens_expires (expires_at), + CONSTRAINT fk_refresh_tokens_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/migrations/postgres/005_refresh_tokens.sql b/migrations/postgres/005_refresh_tokens.sql @@ -0,0 +1,16 @@ +-- 005_refresh_tokens.sql - revocable refresh tokens, built-in provider only + +CREATE TABLE refresh_tokens ( + id TEXT NOT NULL PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + issued_at BIGINT NOT NULL, + expires_at BIGINT NOT NULL, + last_used_at BIGINT, + revoked_at BIGINT, + user_agent TEXT, + ip TEXT +); + +CREATE INDEX idx_refresh_tokens_user ON refresh_tokens (user_id); +CREATE INDEX idx_refresh_tokens_expires ON refresh_tokens (expires_at); diff --git a/migrations/sqlite/005_refresh_tokens.sql b/migrations/sqlite/005_refresh_tokens.sql @@ -0,0 +1,16 @@ +-- 005_refresh_tokens.sql - revocable refresh tokens, built-in provider only + +CREATE TABLE refresh_tokens ( + id TEXT NOT NULL PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + issued_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + last_used_at INTEGER, + revoked_at INTEGER, + user_agent TEXT, + ip TEXT +); + +CREATE INDEX idx_refresh_tokens_user ON refresh_tokens (user_id); +CREATE INDEX idx_refresh_tokens_expires ON refresh_tokens (expires_at); diff --git a/src/lib/auth/oidc.js b/src/lib/auth/oidc.js @@ -1,24 +1,11 @@ -// src/lib/auth/oidc.js - OIDC discovery, browser login and token verification +// src/lib/auth/oidc.js - discovery and access token verification // -// Engaged when auth.oidc.discovery_url is configured. The provider's OpenID -// configuration document owns every detail of the provider: its issuer, the -// authorization and token endpoints, the key set and the end session -// endpoint. A Keycloak realm serves them under /protocol/openid-connect/..., -// but that is a Keycloak detail and nothing else follows it; guessing it -// works with one provider and silently fails with every other. -// -// The browser signs in through the authorization code flow and the session -// cookie is the id_token the provider issued. Every request re-verifies it -// against the discovered issuer and key set, so nothing is re-signed here. -// -// Role mapping deliberately looks in several places. Keycloak puts realm -// roles under realm_access.roles, other providers use a flat roles claim or -// groups, and there is no standard. +// The app runs the code flow against the provider itself. All that happens +// here is verifying the tokens it comes back with. const ADMIN = 'admin'; const USER_ROLE = 'user'; const DISCOVERY_TIMEOUT = 10000; -const TOKEN_TIMEOUT = 10000; export function collectRoles(claims) { const roles = new Set(); @@ -68,7 +55,6 @@ export function createOidc(cfg) { const { discovery_url: discoveryUrl, client_id: clientId, - client_secret: clientSecret, scopes, admin_role: adminRole, } = cfg.auth.oidc; @@ -119,89 +105,21 @@ export function createOidc(cfg) { return load(); }, - // Verifies the provider's token: signature, issuer and, for the - // id_token the browser presents, the client it was issued to. - async verify(token) { - const document = await load(); - const { payload } = await jwtVerify(token, jwks, { - issuer: document.issuer, - audience: clientId, - }); - return profileFrom(payload, document.issuer); - }, + clientId, + scopes, - // Where to send the browser to sign in. - async authorizationUrl({ redirectUri, state }) { + // An access token names the client in azp; aud is the resource server, + // which for Keycloak is "account" rather than us. + async verify(token) { const document = await load(); - const url = new URL(document.authorization_endpoint); - url.searchParams.set('response_type', 'code'); - url.searchParams.set('client_id', clientId); - url.searchParams.set('redirect_uri', redirectUri); - url.searchParams.set('scope', scopes); - url.searchParams.set('state', state); - return url.toString(); - }, + const { payload } = await jwtVerify(token, jwks, { issuer: document.issuer }); - // Redeems the authorization code at the discovered token endpoint. A - // confidential client authenticates with HTTP Basic. - async exchangeCode({ code, redirectUri }) { - const document = await load(); - const headers = { 'content-type': 'application/x-www-form-urlencoded' }; - if (clientSecret) { - headers.authorization = `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}`; + const aud = Array.isArray(payload.aud) ? payload.aud : [payload.aud]; + if (payload.azp !== clientId && !aud.includes(clientId)) { + throw new Error(`token was issued to ${payload.azp ?? aud.join(', ')}, not ${clientId}`); } - let res; - try { - res = await fetch(document.token_endpoint, { - method: 'POST', - headers, - body: new URLSearchParams({ - grant_type: 'authorization_code', - code, - redirect_uri: redirectUri, - client_id: clientId, - }), - signal: AbortSignal.timeout(TOKEN_TIMEOUT), - }); - } catch (e) { - throw new Error( - `could not reach the OIDC token endpoint at ${document.token_endpoint}: ${e.message}`, - { cause: e } - ); - } - - const text = await res.text(); - let payload = null; - try { - payload = JSON.parse(text); - } catch { - // Reported below, together with the status. - } - - if (!res.ok) { - const detail = payload?.error_description ?? payload?.error ?? text; - throw new Error( - `OIDC token exchange failed: ${res.status} ${res.statusText}${detail ? `: ${detail}` : ''}` - ); - } - if (payload === null || typeof payload !== 'object') { - throw new Error('OIDC token endpoint did not return JSON'); - } - return payload; - }, - - // Where to send the browser to end the provider session, or null when - // the provider does not advertise one. - async endSessionUrl({ postLogoutRedirectUri } = {}) { - const document = await load(); - if (typeof document.end_session_endpoint !== 'string' || document.end_session_endpoint.length === 0) { - return null; - } - const url = new URL(document.end_session_endpoint); - url.searchParams.set('client_id', clientId); - if (postLogoutRedirectUri) url.searchParams.set('post_logout_redirect_uri', postLogoutRedirectUri); - return url.toString(); + return profileFrom(payload, document.issuer); }, }; } diff --git a/src/lib/auth/refresh.js b/src/lib/auth/refresh.js @@ -0,0 +1,81 @@ +// src/lib/auth/refresh.js - refresh tokens the built-in provider issued + +import { hashToken } from '../ids.js'; +import { paginateOffset } from '../page.js'; + +// user_agent and ip are shown to tell sessions apart. Never validated. +const COLUMNS = 'id, user_id, issued_at, expires_at, last_used_at, revoked_at, user_agent, ip'; + +export function createRefreshTokens({ db }) { + return { + async issue({ id, userId, token, expiresAt, userAgent = null, ip = null, now = Date.now() }) { + await db.run( + `INSERT INTO refresh_tokens (id, user_id, token_hash, issued_at, expires_at, user_agent, ip) + VALUES ({id}, {user}, {hash}, {now}, {expires}, {agent}, {ip})`, + { id, user: userId, hash: hashToken(token), now, expires: expiresAt, agent: userAgent, ip } + ); + return { id, user_id: userId, issued_at: now, expires_at: expiresAt, user_agent: userAgent, ip }; + }, + + async claim(token, { now = Date.now() } = {}) { + if (typeof token !== 'string' || token.length === 0) return null; + const row = await db.get( + `SELECT ${COLUMNS} FROM refresh_tokens WHERE token_hash = {hash}`, + { hash: hashToken(token) } + ); + if (!row || row.revoked_at !== null || row.expires_at <= now) return null; + return row; + }, + + // Records the use. Leaving expiresAt out keeps the original deadline, + // which is what a non-sliding session wants. + async touch({ id, expiresAt = null, userAgent = null, ip = null, now = Date.now() }) { + const moved = expiresAt === null ? '' : ', expires_at = {expires}'; + const res = await db.run( + `UPDATE refresh_tokens SET last_used_at = {now}, user_agent = {agent}, ip = {ip}${moved} + WHERE id = {id} AND revoked_at IS NULL`, + { id, now, agent: userAgent, ip, expires: expiresAt } + ); + return res.changes > 0; + }, + + async revoke(id, { now = Date.now() } = {}) { + const res = await db.run( + 'UPDATE refresh_tokens SET revoked_at = {now} WHERE id = {id} AND revoked_at IS NULL', + { id, now } + ); + return res.changes > 0; + }, + + async revokeAllFor(userId, { now = Date.now() } = {}) { + const res = await db.run( + 'UPDATE refresh_tokens SET revoked_at = {now} WHERE user_id = {user} AND revoked_at IS NULL', + { user: userId, now } + ); + return res.changes; + }, + + async get(id) { + return db.get(`SELECT ${COLUMNS} FROM refresh_tokens WHERE id = {id}`, { id }); + }, + + // Live sessions only. + async page({ userId = null, offset, limit, now = Date.now() }) { + const scoped = userId ? 'user_id = {user} AND ' : ''; + return paginateOffset(db, { + select: COLUMNS, + from: 'refresh_tokens', + where: `${scoped}revoked_at IS NULL AND expires_at > {now}`, + order: 'issued_at DESC', + params: userId ? { user: userId, now } : { now }, + offset, + limit, + }); + }, + + async sweep(cutoff) { + const res = await db.run('DELETE FROM refresh_tokens WHERE expires_at < {cutoff}', { cutoff }); + return res.changes; + }, + }; +} diff --git a/src/lib/config.js b/src/lib/config.js @@ -45,16 +45,20 @@ const DEFAULTS = { }, }, auth: { - // Signs built-in session tokens. Random per boot when unset, which - // invalidates existing sessions on restart. + // Random per boot when unset, which invalidates existing sessions. session_secret: null, - session_ttl: 43200, + access_ttl: 900, + refresh_ttl: 2592000, + refresh_sliding: true, + local: { + enabled: null, + redirect_uris: null, + }, oidc: { // The provider's OpenID configuration document. Its issuer and every // endpoint are read from here rather than configured separately. discovery_url: null, client_id: null, - client_secret: null, scopes: 'openid profile email', admin_role: 'conductor-admin', }, @@ -130,9 +134,13 @@ const ENV_MAP = [ ['CONDUCTOR_S3_SECRET_ACCESS_KEY', 'storage.s3.secret_access_key', String], ['CONDUCTOR_S3_FORCE_PATH_STYLE', 'storage.s3.force_path_style', toBool], ['CONDUCTOR_SESSION_SECRET', 'auth.session_secret', String], + ['CONDUCTOR_ACCESS_TTL', 'auth.access_ttl', toInt], + ['CONDUCTOR_REFRESH_TTL', 'auth.refresh_ttl', toInt], + ['CONDUCTOR_REFRESH_SLIDING', 'auth.refresh_sliding', toBool], + ['CONDUCTOR_LOCAL_AUTH', 'auth.local.enabled', toBool], + ['CONDUCTOR_LOCAL_REDIRECT_URIS', 'auth.local.redirect_uris', String], ['CONDUCTOR_OIDC_DISCOVERY_URL', 'auth.oidc.discovery_url', String], ['CONDUCTOR_OIDC_CLIENT_ID', 'auth.oidc.client_id', String], - ['CONDUCTOR_OIDC_CLIENT_SECRET', 'auth.oidc.client_secret', String], ['CONDUCTOR_OIDC_SCOPES', 'auth.oidc.scopes', String], ['CONDUCTOR_OIDC_ADMIN_ROLE', 'auth.oidc.admin_role', String], ['CONDUCTOR_ADMIN_USERNAME', 'auth.bootstrap_admin.username', String], @@ -195,6 +203,13 @@ function deepFreeze(obj) { return Object.freeze(obj); } +// Comma separated from the environment, a list from a config file. +function redirectUris(value, publicUrl) { + const given = Array.isArray(value) ? value : String(value ?? '').split(','); + const uris = given.map((u) => String(u).trim().replace(/\/+$/, '')).filter(Boolean); + return uris.length > 0 ? uris : [publicUrl.replace(/\/+$/, '')]; +} + // Reads a 32 byte key given as hex or base64. Returns a Buffer, or null. export function parseKey(value, label) { if (!value) return null; @@ -243,8 +258,8 @@ function validate(cfg) { if (!cfg.auth.oidc.client_id) { errors.push('auth.oidc.client_id is required when auth.oidc.discovery_url is set'); } - } else if (cfg.auth.oidc.client_id) { - errors.push('auth.oidc.client_id is set but auth.oidc.discovery_url is not'); + } else if (cfg.auth.local.enabled === false) { + errors.push('auth.local.enabled is false and no auth.oidc.discovery_url is set, so nobody could sign in'); } try { @@ -340,7 +355,11 @@ export function loadConfig(explicitPath) { cfg.source = source; cfg.database.dialect = dialectFromUrl(cfg.database.url); cfg.storage.driver = cfg.storage.s3.bucket ? 's3' : 'local'; - cfg.auth.mode = cfg.auth.oidc.discovery_url ? 'oidc' : 'local'; + + // The discovery document names one provider, so these two always agree. + if (cfg.auth.local.enabled === null) cfg.auth.local.enabled = !cfg.auth.oidc.discovery_url; + cfg.auth.mode = cfg.auth.local.enabled ? 'local' : 'oidc'; + cfg.auth.local.redirect_uris = redirectUris(cfg.auth.local.redirect_uris, cfg.server.public_url); if (!cfg.auth.session_secret) { cfg.auth.session_secret = crypto.randomBytes(32).toString('hex'); diff --git a/src/lib/ids.js b/src/lib/ids.js @@ -37,6 +37,7 @@ export const newArtifactId = () => randomId(24); export const newUserId = () => randomId(16); export const newWorkerTokenId = () => randomId(16); export const newWorkerAgentId = () => randomId(16); +export const newRefreshTokenId = () => randomId(16); // Task ids carry no structure at all. A worker holding one cannot tell which // job or project it belongs to, which is the point: a worker is given a diff --git a/test/config.test.js b/test/config.test.js @@ -75,6 +75,66 @@ test('an oidc discovery url without a client id is rejected', async () => { }); }); +test('the built-in provider is on by default and answers at public_url', async () => { + await withEnv({ CONDUCTOR_PUBLIC_URL: 'https://ci.example.com/' }, () => { + const cfg = loadConfig(); + assert.equal(cfg.auth.local.enabled, true); + assert.deepEqual(cfg.auth.local.redirect_uris, ['https://ci.example.com']); + }); +}); + +test('configuring a provider turns the built-in one off', async () => { + await withEnv({ + CONDUCTOR_OIDC_DISCOVERY_URL: 'https://idp.example.com/realms/ci/.well-known/openid-configuration', + CONDUCTOR_OIDC_CLIENT_ID: 'conductor', + }, () => { + assert.equal(loadConfig().auth.local.enabled, false); + }); +}); + +// Only one provider can be advertised, so asking for the built-in one wins. +test('enabling the built-in provider overrides a configured one', async () => { + await withEnv({ + CONDUCTOR_OIDC_DISCOVERY_URL: 'https://idp.example.com/realms/ci/.well-known/openid-configuration', + CONDUCTOR_OIDC_CLIENT_ID: 'conductor', + CONDUCTOR_LOCAL_AUTH: 'true', + }, () => { + const cfg = loadConfig(); + assert.equal(cfg.auth.mode, 'local'); + assert.equal(cfg.auth.local.enabled, true); + }); +}); + +test('disabling the built-in provider with no other one is rejected', async () => { + await withEnv({ CONDUCTOR_LOCAL_AUTH: 'false' }, () => { + assert.throws(() => loadConfig(), /nobody could sign in/); + }); +}); + +test('redirect uris are split on commas and lose trailing slashes', async () => { + await withEnv({ + CONDUCTOR_LOCAL_REDIRECT_URIS: 'https://app.example.com/ , https://alt.example.com', + }, () => { + assert.deepEqual(loadConfig().auth.local.redirect_uris, [ + 'https://app.example.com', + 'https://alt.example.com', + ]); + }); +}); + +test('token lifetimes are read from the environment as integers', async () => { + await withEnv({ + CONDUCTOR_ACCESS_TTL: '300', + CONDUCTOR_REFRESH_TTL: '86400', + CONDUCTOR_REFRESH_SLIDING: 'off', + }, () => { + const cfg = loadConfig(); + assert.equal(cfg.auth.access_ttl, 300); + assert.equal(cfg.auth.refresh_ttl, 86400); + assert.equal(cfg.auth.refresh_sliding, false); + }); +}); + test('a database url selects its dialect', async () => { await withEnv({ CONDUCTOR_DATABASE_URL: 'postgres://u:p@h:5432/ci' }, () => { assert.equal(loadConfig().database.dialect, 'postgres'); diff --git a/test/refresh.test.js b/test/refresh.test.js @@ -0,0 +1,212 @@ +// test/refresh.test.js - the refresh token store + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { openDatabase, runMigrations } from '../src/lib/db/index.js'; +import { createRefreshTokens } from '../src/lib/auth/refresh.js'; +import { newToken, newRefreshTokenId, hashToken } from '../src/lib/ids.js'; + +const HOUR = 3600_000; + +async function fixture() { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'conductor-refresh-')); + const db = await openDatabase({ database: { dialect: 'sqlite', path: path.join(dir, 'test.db') } }); + await runMigrations(db, { logger: () => {} }); + + await db.run( + `INSERT INTO users (id, username, password_hash, role, disabled, created_at) + VALUES ({id}, {u}, 'x', 'user', 0, {now})`, + { id: 'user-1', u: 'alice', now: Date.now() } + ); + await db.run( + `INSERT INTO users (id, username, password_hash, role, disabled, created_at) + VALUES ({id}, {u}, 'x', 'user', 0, {now})`, + { id: 'user-2', u: 'bob', now: Date.now() } + ); + + return { + db, + store: createRefreshTokens({ db }), + cleanup: async () => { await db.close(); await fs.rm(dir, { recursive: true, force: true }); }, + }; +} + +// Issues a session and hands back the raw token, which is never stored. +async function session(store, { userId = 'user-1', ttl = HOUR, now = Date.now(), ...rest } = {}) { + const token = newToken(); + const row = await store.issue({ + id: newRefreshTokenId(), userId, token, expiresAt: now + ttl, now, ...rest, + }); + return { token, row }; +} + +test('a token is stored only as its hash', async () => { + const { db, store, cleanup } = await fixture(); + const { token, row } = await session(store); + + const stored = await db.get('SELECT token_hash FROM refresh_tokens WHERE id = {id}', { id: row.id }); + assert.equal(stored.token_hash, hashToken(token)); + assert.notEqual(stored.token_hash, token); + await cleanup(); +}); + +test('claiming finds the row by the raw token alone', async () => { + const { store, cleanup } = await fixture(); + const { token, row } = await session(store); + + const claimed = await store.claim(token); + assert.equal(claimed.id, row.id); + assert.equal(claimed.user_id, 'user-1'); + await cleanup(); +}); + +test('an unknown, empty or non-string token claims nothing', async () => { + const { store, cleanup } = await fixture(); + await session(store); + + assert.equal(await store.claim(newToken()), null); + assert.equal(await store.claim(''), null); + assert.equal(await store.claim(undefined), null); + assert.equal(await store.claim(null), null); + await cleanup(); +}); + +test('an expired token claims nothing', async () => { + const { store, cleanup } = await fixture(); + const now = Date.now(); + const { token } = await session(store, { ttl: HOUR, now: now - 2 * HOUR }); + + assert.equal(await store.claim(token, { now }), null); + await cleanup(); +}); + +// The token is the session. Refreshing does not mint a new one, so two tabs +// refreshing at once cannot race each other into a logout. +test('the token survives a refresh and stays usable', async () => { + const { store, cleanup } = await fixture(); + const { token, row } = await session(store); + + assert.equal(await store.touch({ id: row.id }), true); + assert.equal(await store.touch({ id: row.id }), true); + + const again = await store.claim(token); + assert.equal(again.id, row.id); + assert.ok(again.last_used_at); + await cleanup(); +}); + +test('a sliding session moves its deadline forward on use', async () => { + const { store, cleanup } = await fixture(); + const now = Date.now(); + const { token, row } = await session(store, { ttl: HOUR, now }); + + await store.touch({ id: row.id, expiresAt: now + 4 * HOUR, now: now + HOUR / 2 }); + + const claimed = await store.claim(token, { now: now + 2 * HOUR }); + assert.equal(claimed.expires_at, now + 4 * HOUR); + await cleanup(); +}); + +test('an absolute session keeps its original deadline across uses', async () => { + const { store, cleanup } = await fixture(); + const now = Date.now(); + const { token, row } = await session(store, { ttl: HOUR, now }); + + await store.touch({ id: row.id, now: now + HOUR / 2 }); + + assert.equal((await store.claim(token, { now: now + HOUR / 2 })).expires_at, now + HOUR); + assert.equal(await store.claim(token, { now: now + 2 * HOUR }), null); + await cleanup(); +}); + +test('touching a revoked session reports that it is gone', async () => { + const { store, cleanup } = await fixture(); + const { row } = await session(store); + + await store.revoke(row.id); + assert.equal(await store.touch({ id: row.id }), false); + await cleanup(); +}); + +test('revoking is idempotent and only affects the named row', async () => { + const { store, cleanup } = await fixture(); + const { token, row } = await session(store); + const other = await session(store); + + assert.equal(await store.revoke(row.id), true); + assert.equal(await store.revoke(row.id), false); + assert.equal(await store.claim(token), null); + assert.ok(await store.claim(other.token)); + await cleanup(); +}); + +test('revokeAllFor reports how many sessions it ended', async () => { + const { store, cleanup } = await fixture(); + await session(store); + await session(store); + await session(store, { userId: 'user-2' }); + + assert.equal(await store.revokeAllFor('user-1'), 2); + assert.equal(await store.revokeAllFor('user-1'), 0); + await cleanup(); +}); + +test('paging lists live sessions, newest first, scoped or not', async () => { + const { store, cleanup } = await fixture(); + const now = Date.now(); + await session(store, { now: now - 2000 }); + const recent = await session(store, { now: now - 1000 }); + await session(store, { userId: 'user-2', now }); + const dead = await session(store); + await store.revoke(dead.row.id); + + const mine = await store.page({ userId: 'user-1', offset: 0, limit: 10, now }); + assert.equal(mine.total, 2); + assert.equal(mine.rows[0].id, recent.row.id); + + const all = await store.page({ offset: 0, limit: 10, now }); + assert.equal(all.total, 3); + await cleanup(); +}); + +test('the agent and address are kept for display', async () => { + const { store, cleanup } = await fixture(); + await session(store, { userAgent: 'Firefox/1', ip: '10.0.0.1' }); + + const listed = await store.page({ userId: 'user-1', offset: 0, limit: 10 }); + assert.equal(listed.rows[0].user_agent, 'Firefox/1'); + assert.equal(listed.rows[0].ip, '10.0.0.1'); + await cleanup(); +}); + +// A train moves between networks and a browser updates itself. Neither is a +// security signal, so both are accepted and the row follows them. +test('roaming to another address and browser still refreshes', async () => { + const { store, cleanup } = await fixture(); + const { token, row } = await session(store, { userAgent: 'Firefox/1', ip: '10.0.0.1' }); + + assert.ok(await store.claim(token)); + await store.touch({ id: row.id, userAgent: 'Firefox/2', ip: '192.0.2.77' }); + + assert.ok(await store.claim(token)); + + const listed = await store.page({ userId: 'user-1', offset: 0, limit: 10 }); + assert.equal(listed.total, 1); + assert.equal(listed.rows[0].user_agent, 'Firefox/2'); + assert.equal(listed.rows[0].ip, '192.0.2.77'); + await cleanup(); +}); + +test('sweeping drops expired rows and leaves live ones', async () => { + const { store, cleanup } = await fixture(); + const now = Date.now(); + await session(store, { ttl: HOUR, now: now - 4 * HOUR }); + const live = await session(store, { ttl: HOUR, now }); + + assert.equal(await store.sweep(now - HOUR), 1); + assert.ok(await store.claim(live.token, { now })); + await cleanup(); +});