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:
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();
+});