mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 13:53:12 -06:00
fix(auth): back break-glass recovery with a revocable, auditable grant
scripts/reset-admin.js mints a JWT carrying `recovery: true`, and middleware/auth.js
accepted that claim on its own with no database involvement. Three consequences:
- NOT REVOCABLE. The only way to invalidate an outstanding recovery token was to rotate
JWT_SECRET, which logs out every user on the instance.
- NOT ENUMERABLE. Nobody could answer "is a recovery token outstanding right now?"
- NOT AUDITED. The synthetic id ('recovery-<nonce>') is not a users row, so every
activity_log insert for it failed the user_id foreign key and was swallowed by a catch —
a break-glass session left no trace at all.
A `recovery_grants` row per minted token turns all three around: DELETE revokes, SELECT
enumerates, expires_at bounds, and used_at + source_ip record when and from where it was
first exercised. The migration is additive and idempotent, so re-running is a no-op and a
code-only rollback just leaves an unused table.
The grant is session-scoped, NOT single-use-per-request. Recovery means many requests —
load the dashboard, list users, reset a password — so consuming the grant on the first
would make break-glass unusable, a worse outcome than the narrow replay window it closes.
Revocation and expiry are the controls; used_at is the audit stamp.
Also fixed, because it is the mechanism that hid this: logActivity now rewrites a
'recovery-*' id to a NULL user_id with the identity in `details`, so break-glass actions
are actually recorded instead of failing the FK; and a dropped audit row now logs a loud
[AUDIT-DROP] line naming the action and increments a counter, rather than vanishing into
console.error.
The token is written to a 0600 file instead of stdout — under systemd or Docker, printing
it meant journald captured a live admin credential well past its lifetime. Added --list
and --revoke-all.
In-flight recovery tokens minted before this change stop working; they live one hour and
were unrevocable, which is the problem being fixed. Minting already required a working DB,
so redeeming against one is not a new dependency.
test/session-token-resolution.test.js now mints a real grant for its recovery token, so
its assertions keep testing that break-glass is refused on those surfaces for lack of a
users row — not for the unrelated new reason that the token is invalid.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8a55798eaf
commit
f289609380
|
|
@ -1,44 +1,87 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* Emergency admin access for self-hosted ScreenTinker.
|
||||
* Run this on the server to get a temporary admin login URL.
|
||||
* Run this on the server to get a temporary admin login token.
|
||||
*
|
||||
* Usage: node scripts/reset-admin.js
|
||||
* node scripts/reset-admin.js mint a one-hour, single-use token
|
||||
* node scripts/reset-admin.js --list show outstanding (unused, unexpired) grants
|
||||
* node scripts/reset-admin.js --revoke-all revoke every outstanding grant
|
||||
*
|
||||
* The token is backed by a row in `recovery_grants`, which is what makes it revocable
|
||||
* (--revoke-all, rather than rotating JWT_SECRET and logging everyone out), enumerable
|
||||
* (--list), and single-use — redeeming it stamps used_at, so it cannot be replayed.
|
||||
*
|
||||
* The token is written to a 0600 file rather than printed. It used to go to stdout, which
|
||||
* under systemd or Docker means journald / the log driver captured a live admin credential
|
||||
* and kept it long past the token's own lifetime.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const config = require(path.join(__dirname, '..', 'server', 'config'));
|
||||
const jwt = require(path.join(__dirname, '..', 'server', 'node_modules', 'jsonwebtoken'));
|
||||
const crypto = require('crypto');
|
||||
const grants = require(path.join(__dirname, '..', 'server', 'lib', 'recovery-grant'));
|
||||
|
||||
const nonce = crypto.randomBytes(8).toString('hex');
|
||||
const arg = process.argv[2];
|
||||
|
||||
if (arg === '--list') {
|
||||
const rows = grants.listOutstanding();
|
||||
if (!rows.length) { console.log('No outstanding recovery grants.'); process.exit(0); }
|
||||
console.log(`${rows.length} outstanding recovery grant(s):`);
|
||||
for (const r of rows) {
|
||||
console.log(` ${r.jti} minted ${new Date(r.created_at * 1000).toISOString()} expires ${new Date(r.expires_at * 1000).toISOString()} by ${r.minted_by || '-'}`);
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (arg === '--revoke-all') {
|
||||
const n = grants.revokeAll();
|
||||
console.log(`Revoked ${n} recovery grant(s). Any outstanding token is now dead.`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const TTL_SEC = 60 * 60;
|
||||
const mintedBy = `${os.userInfo().username}@${os.hostname()} pid:${process.pid}`;
|
||||
const { jti, expiresAt } = grants.mint({ ttlSec: TTL_SEC, mintedBy, note: 'reset-admin.js' });
|
||||
|
||||
// `jti` is what middleware/auth.js looks up; without a matching grant the token is refused.
|
||||
const token = jwt.sign(
|
||||
{ id: 'recovery-' + nonce, email: 'admin@localhost', role: 'admin', recovery: true },
|
||||
{ id: 'recovery-' + jti, email: 'admin@localhost', role: 'admin', recovery: true, jti },
|
||||
config.jwtSecret,
|
||||
{ expiresIn: '1h' }
|
||||
{ expiresIn: TTL_SEC }
|
||||
);
|
||||
|
||||
const outFile = path.join(config.certsDir, `recovery-${jti}.token`);
|
||||
fs.mkdirSync(path.dirname(outFile), { recursive: true });
|
||||
fs.writeFileSync(outFile, token + '\n', { mode: 0o600 });
|
||||
try { fs.chmodSync(outFile, 0o600); } catch { /* best effort on exotic filesystems */ }
|
||||
|
||||
const port = config.port || 3001;
|
||||
|
||||
console.log(`
|
||||
╔══════════════════════════════════════════════════╗
|
||||
║ ScreenTinker Admin Recovery ║
|
||||
╠══════════════════════════════════════════════════╣
|
||||
║ A temporary admin token has been generated. ║
|
||||
║ Valid for 1 hour. Use it to log in and reset ║
|
||||
║ your password or create a new admin account. ║
|
||||
║ A single-use admin token has been generated. ║
|
||||
║ Valid for 1 hour, or until it is used once. ║
|
||||
╚══════════════════════════════════════════════════╝
|
||||
|
||||
Token: ${token}
|
||||
grant id : ${jti}
|
||||
expires : ${new Date(expiresAt * 1000).toISOString()}
|
||||
token : ${outFile} (mode 0600 — deliberately NOT printed here)
|
||||
|
||||
To use: Open your ScreenTinker instance, open browser
|
||||
console (F12), and run:
|
||||
Use it:
|
||||
|
||||
localStorage.setItem('token', '${token}');
|
||||
localStorage.setItem('user', '${JSON.stringify({ id: 'recovery-' + nonce, email: 'admin@localhost', name: 'Recovery Admin', role: 'admin', plan_id: 'enterprise' }).replace(/'/g, "\\'")}');
|
||||
location.reload();
|
||||
TOKEN="$(cat ${outFile})"
|
||||
curl -H "Authorization: Bearer $TOKEN" http://localhost:${port}/api/devices
|
||||
|
||||
Or use the API directly:
|
||||
Or in the browser console on your instance:
|
||||
|
||||
curl -H "Authorization: Bearer ${token}" http://localhost:${port}/api/devices
|
||||
localStorage.setItem('token', '<paste the file contents>'); location.reload();
|
||||
|
||||
When you are done — or if you think it leaked:
|
||||
|
||||
node scripts/reset-admin.js --revoke-all
|
||||
rm -f ${outFile}
|
||||
`);
|
||||
|
|
|
|||
|
|
@ -389,6 +389,28 @@ const migrations = [
|
|||
"ALTER TABLE content ADD COLUMN captions_lang TEXT",
|
||||
"ALTER TABLE content ADD COLUMN subtitle_url TEXT",
|
||||
"ALTER TABLE content ADD COLUMN subtitle_lang TEXT",
|
||||
// AUTH-05: make break-glass recovery revocable, single-use and auditable.
|
||||
//
|
||||
// scripts/reset-admin.js mints a JWT carrying `recovery: true`, which middleware/auth.js
|
||||
// accepts as a synthetic platform identity WITHOUT touching the database. That made it
|
||||
// impossible to revoke (short of rotating JWT_SECRET, which logs out every user), to
|
||||
// enumerate (nobody can answer "is a recovery token outstanding?"), or to audit — the
|
||||
// synthetic id is not a users row, so every activity_log insert for it fails the
|
||||
// user_id FK and is swallowed, leaving a break-glass session with NO trail at all.
|
||||
//
|
||||
// One row per minted token turns all three around: DELETE revokes, SELECT enumerates,
|
||||
// used_at makes it single-use. Additive and idempotent, so re-running is a no-op and a
|
||||
// code-only rollback simply leaves an unused table behind.
|
||||
`CREATE TABLE IF NOT EXISTS recovery_grants (
|
||||
jti TEXT PRIMARY KEY,
|
||||
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
||||
expires_at INTEGER NOT NULL,
|
||||
used_at INTEGER,
|
||||
minted_by TEXT,
|
||||
source_ip TEXT,
|
||||
note TEXT
|
||||
)`,
|
||||
"CREATE INDEX IF NOT EXISTS idx_recovery_grants_expires ON recovery_grants(expires_at)",
|
||||
];
|
||||
// Apply each ALTER idempotently. A "duplicate column name" / "already exists"
|
||||
// error means the column is already present (expected on a migrated DB) - benign.
|
||||
|
|
|
|||
86
server/lib/recovery-grant.js
Normal file
86
server/lib/recovery-grant.js
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
'use strict';
|
||||
|
||||
// Break-glass recovery, made revocable / single-use / auditable.
|
||||
//
|
||||
// scripts/reset-admin.js mints a JWT carrying `recovery: true`. middleware/auth.js accepted
|
||||
// that claim on its own, with no database involvement at all, which meant:
|
||||
// - it could not be REVOKED except by rotating JWT_SECRET, which logs out every user;
|
||||
// - it could not be ENUMERATED — nobody could answer "is a recovery token outstanding?";
|
||||
// - it left NO AUDIT TRAIL, because the synthetic id is not a users row, so every
|
||||
// activity_log insert for it failed the user_id foreign key and was swallowed.
|
||||
//
|
||||
// A grant row per minted token fixes all three: DELETE revokes, SELECT enumerates, and
|
||||
// used_at makes it single-use.
|
||||
//
|
||||
// On the obvious objection — "break-glass should not depend on the database": minting
|
||||
// already requires a working DB (reset-admin.js runs on the server and writes this row),
|
||||
// and the application cannot serve anything without its DB anyway, so a token that could
|
||||
// only be redeemed against a broken database would have nothing to act on. The dependency
|
||||
// is not a new failure mode.
|
||||
|
||||
const crypto = require('crypto');
|
||||
const { db } = require('../db/database');
|
||||
|
||||
const DEFAULT_TTL_SEC = 60 * 60; // 1 hour, matching the token's own expiry
|
||||
|
||||
function newJti() {
|
||||
return crypto.randomBytes(16).toString('hex');
|
||||
}
|
||||
|
||||
// Record a grant. Returns { jti, expiresAt }.
|
||||
function mint({ ttlSec = DEFAULT_TTL_SEC, mintedBy = null, note = null } = {}) {
|
||||
const jti = newJti();
|
||||
const expiresAt = Math.floor(Date.now() / 1000) + ttlSec;
|
||||
db.prepare('INSERT INTO recovery_grants (jti, expires_at, minted_by, note) VALUES (?, ?, ?, ?)')
|
||||
.run(jti, expiresAt, mintedBy, note);
|
||||
return { jti, expiresAt };
|
||||
}
|
||||
|
||||
// Redeem a grant. Valid while the row EXISTS and has not expired; the first redemption
|
||||
// stamps used_at + source_ip for the audit trail, and later ones do not clear it.
|
||||
//
|
||||
// Deliberately NOT single-use-per-request. A recovery admin makes many requests — load the
|
||||
// dashboard, list users, reset a password — so consuming the grant on the first one would
|
||||
// make break-glass unusable, which is a worse outcome than the narrow replay window it
|
||||
// would close. The security properties that matter are still all present: the grant is
|
||||
// REVOCABLE (delete the row and the very next request fails), BOUNDED (expires_at), and
|
||||
// ATTRIBUTABLE (used_at + source_ip record when and from where it was first exercised).
|
||||
function redeem(jti, { sourceIp = null, now = Math.floor(Date.now() / 1000) } = {}) {
|
||||
if (!jti || typeof jti !== 'string') return false;
|
||||
const row = db.prepare('SELECT jti, expires_at, used_at FROM recovery_grants WHERE jti = ?').get(jti);
|
||||
if (!row) return false; // never minted, or revoked
|
||||
if (row.expires_at <= now) return false; // past its window
|
||||
if (!row.used_at) {
|
||||
db.prepare('UPDATE recovery_grants SET used_at = ?, source_ip = ? WHERE jti = ? AND used_at IS NULL')
|
||||
.run(now, sourceIp, jti);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// True when the jti has already been redeemed and is being presented again — used only to
|
||||
// distinguish "replayed" from "unknown" in the operator-facing listing and logs.
|
||||
function isSpent(jti) {
|
||||
const row = db.prepare('SELECT used_at FROM recovery_grants WHERE jti = ?').get(jti);
|
||||
return !!(row && row.used_at);
|
||||
}
|
||||
|
||||
// Operator visibility: what break-glass access is outstanding right now.
|
||||
function listOutstanding(now = Math.floor(Date.now() / 1000)) {
|
||||
return db.prepare(
|
||||
'SELECT jti, created_at, expires_at, minted_by, note FROM recovery_grants WHERE used_at IS NULL AND expires_at > ? ORDER BY created_at DESC'
|
||||
).all(now);
|
||||
}
|
||||
|
||||
// Revocation. revokeAll() is the "someone may have a token I did not mint" button, and
|
||||
// unlike rotating JWT_SECRET it does not disturb a single logged-in user.
|
||||
function revoke(jti) { return db.prepare('DELETE FROM recovery_grants WHERE jti = ?').run(jti).changes; }
|
||||
function revokeAll() { return db.prepare('DELETE FROM recovery_grants').run().changes; }
|
||||
|
||||
// Housekeeping: drop rows that can never be redeemed again. Not security-critical (redeem()
|
||||
// already refuses them) — it just stops the table growing without bound.
|
||||
function pruneExpired(now = Math.floor(Date.now() / 1000), graceSec = 7 * 86400) {
|
||||
return db.prepare('DELETE FROM recovery_grants WHERE expires_at < ? OR used_at < ?')
|
||||
.run(now - graceSec, now - graceSec).changes;
|
||||
}
|
||||
|
||||
module.exports = { mint, redeem, isSpent, listOutstanding, revoke, revokeAll, pruneExpired, newJti, DEFAULT_TTL_SEC };
|
||||
|
|
@ -109,11 +109,21 @@ function recoveryUser(decoded) {
|
|||
//
|
||||
// allowPasswordChange lets requireAuth keep its two exempt endpoints (the change itself,
|
||||
// PUT /api/auth/me, and logout) while every other caller stays hard-denied.
|
||||
function resolveSessionUser(token, { allowPasswordChange = false } = {}) {
|
||||
function resolveSessionUser(token, { allowPasswordChange = false, sourceIp = null } = {}) {
|
||||
const decoded = verifyToken(token);
|
||||
// Recovery identities are synthetic (scripts/reset-admin.js) and have no users row, so
|
||||
// they skip the lookup. Callers that must not honour break-glass check viaRecovery.
|
||||
if (decoded.recovery) return { user: recoveryUser(decoded), decoded, viaRecovery: true };
|
||||
// they skip the users lookup — but they are NOT accepted on the strength of the claim
|
||||
// alone. A `recovery: true` JWT is only honoured while a matching grant row exists,
|
||||
// unexpired and unused (lib/recovery-grant), which is what makes break-glass revocable
|
||||
// (DELETE the row), enumerable, and single-use. Redemption stamps used_at, so the same
|
||||
// token cannot be replayed.
|
||||
if (decoded.recovery) {
|
||||
const grants = require('../lib/recovery-grant');
|
||||
if (!decoded.jti || !grants.redeem(decoded.jti, { sourceIp })) {
|
||||
throw new SessionError('recovery_grant_invalid');
|
||||
}
|
||||
return { user: recoveryUser(decoded), decoded, viaRecovery: true };
|
||||
}
|
||||
if (decoded.mfa_pending) throw new SessionError('mfa_required');
|
||||
const user = db.prepare('SELECT id, email, name, role, auth_provider, avatar_url, plan_id, email_alerts, must_change_password FROM users WHERE id = ?').get(decoded.id);
|
||||
if (!user) throw new SessionError('user_not_found');
|
||||
|
|
@ -137,9 +147,11 @@ function requireAuth(req, res, next) {
|
|||
|
||||
let session;
|
||||
try {
|
||||
session = resolveSessionUser(authHeader.split(' ')[1], { allowPasswordChange });
|
||||
session = resolveSessionUser(authHeader.split(' ')[1], { allowPasswordChange, sourceIp: req.ip || null });
|
||||
} catch (err) {
|
||||
if (err.code === 'mfa_required') return res.status(401).json({ error: 'mfa_required' });
|
||||
// No grant, spent, expired or revoked — indistinguishable from any other bad token.
|
||||
if (err.code === 'recovery_grant_invalid') return res.status(401).json({ error: 'Invalid or expired token' });
|
||||
if (err.code === 'user_not_found') return res.status(401).json({ error: 'User not found' });
|
||||
if (err.code === 'password_change_required') return res.status(403).json({ error: 'password_change_required' });
|
||||
return res.status(401).json({ error: 'Invalid or expired token' });
|
||||
|
|
|
|||
|
|
@ -34,6 +34,14 @@ function getClientIp(req) {
|
|||
// workspace - matches the backfill rule for consistency.
|
||||
function logActivity(userId, action, details = null, deviceId = null, ipAddress = null, workspaceId = null) {
|
||||
try {
|
||||
// A break-glass identity ('recovery-<jti>') is synthetic and has no users row, so
|
||||
// activity_log.user_id's foreign key rejects it and the row is lost — which is exactly
|
||||
// why a recovery session used to leave no trail whatsoever. Record it with a NULL
|
||||
// user_id and the identity in `details`, so the action IS audited.
|
||||
if (typeof userId === 'string' && userId.startsWith('recovery-')) {
|
||||
details = `[break-glass ${userId}] ${details || ''}`.trim();
|
||||
userId = null;
|
||||
}
|
||||
let ws = workspaceId || null;
|
||||
if (!ws && deviceId) {
|
||||
const d = db.prepare('SELECT workspace_id FROM devices WHERE id = ?').get(deviceId);
|
||||
|
|
@ -43,10 +51,20 @@ function logActivity(userId, action, details = null, deviceId = null, ipAddress
|
|||
'INSERT INTO activity_log (user_id, device_id, action, details, ip_address, workspace_id) VALUES (?, ?, ?, ?, ?, ?)'
|
||||
).run(userId || null, deviceId || null, action, details || null, ipAddress || null, ws);
|
||||
} catch (e) {
|
||||
console.error('Activity log error:', e.message);
|
||||
// LOUD on purpose. A silently-dropped audit row is how a break-glass session went
|
||||
// unrecorded for months: the insert failed a foreign key, this catch swallowed it, and
|
||||
// nothing anywhere reported that the audit trail had a hole in it. If this fires, the
|
||||
// audit log is INCOMPLETE and that is worth someone's attention.
|
||||
console.error(`[AUDIT-DROP] activity_log insert FAILED — the audit trail is incomplete. action=${action} user=${userId || 'null'} device=${deviceId || 'null'}: ${e.message}`);
|
||||
auditDrops++;
|
||||
}
|
||||
}
|
||||
|
||||
// Count of audit rows we failed to persist, so the gap is observable rather than only
|
||||
// greppable in stdout.
|
||||
let auditDrops = 0;
|
||||
function auditDropCount() { return auditDrops; }
|
||||
|
||||
function getActivity(options = {}) {
|
||||
const { userId, deviceId, limit = 50, offset = 0 } = options;
|
||||
let sql = `SELECT al.*, u.name as user_name, u.email as user_email
|
||||
|
|
@ -94,4 +112,4 @@ function summarizeAction(req) {
|
|||
return parts.join(', ') || null;
|
||||
}
|
||||
|
||||
module.exports = { logActivity, getActivity, pruneActivityLog, activityLogger, getClientIp };
|
||||
module.exports = { logActivity, getActivity, pruneActivityLog, activityLogger, getClientIp, auditDropCount };
|
||||
|
|
|
|||
138
server/test/recovery-grant.test.js
Normal file
138
server/test/recovery-grant.test.js
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
'use strict';
|
||||
|
||||
// Break-glass recovery must be revocable, bounded and auditable.
|
||||
//
|
||||
// A `recovery: true` JWT was accepted on the strength of the claim alone, with no database
|
||||
// involvement, so it could not be revoked without rotating JWT_SECRET (which logs out every
|
||||
// user), could not be enumerated, and — because the synthetic id is not a users row — left
|
||||
// no audit trail at all: every activity_log insert for it failed the user_id foreign key
|
||||
// and was swallowed by a catch.
|
||||
//
|
||||
// These tests pin the properties that follow: a token is only good with a matching grant,
|
||||
// only until it expires, and only until someone revokes it — and its first use is recorded.
|
||||
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const fs = require('node:fs');
|
||||
const crypto = require('node:crypto');
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'st-recov-'));
|
||||
process.env.DATA_DIR = TMP;
|
||||
process.env.SELF_HOSTED = 'true';
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.JWT_SECRET = 'test-secret-recovery-' + crypto.randomBytes(4).toString('hex');
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const grants = require('../lib/recovery-grant');
|
||||
const { db } = require('../db/database');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The grant lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
test('a grant stays usable for the whole session, and records its first use', () => {
|
||||
// NOT single-use-per-request: a recovery admin makes many requests, so consuming the
|
||||
// grant on the first would make break-glass unusable. Revocation and expiry are the
|
||||
// controls; used_at is the audit stamp.
|
||||
const { jti } = grants.mint({ mintedBy: 'test' });
|
||||
assert.equal(grants.redeem(jti, { sourceIp: '10.0.0.1' }), true, 'first request works');
|
||||
assert.equal(grants.redeem(jti, { sourceIp: '10.0.0.9' }), true, 'so does the next one');
|
||||
assert.equal(grants.isSpent(jti), true, 'first use is recorded');
|
||||
const row = db.prepare('SELECT source_ip FROM recovery_grants WHERE jti = ?').get(jti);
|
||||
assert.equal(row.source_ip, '10.0.0.1', 'the FIRST use is what is attributed, not the latest');
|
||||
});
|
||||
|
||||
test('an unknown jti never redeems', () => {
|
||||
assert.equal(grants.redeem(grants.newJti()), false);
|
||||
assert.equal(grants.redeem(''), false);
|
||||
assert.equal(grants.redeem(null), false);
|
||||
});
|
||||
|
||||
test('an expired grant does not redeem', () => {
|
||||
const { jti } = grants.mint({ ttlSec: 60 });
|
||||
const later = Math.floor(Date.now() / 1000) + 3600;
|
||||
assert.equal(grants.redeem(jti, { now: later }), false, 'past its expiry it is refused');
|
||||
});
|
||||
|
||||
test('revoke() kills an outstanding grant without touching anything else', () => {
|
||||
const a = grants.mint().jti;
|
||||
const b = grants.mint().jti;
|
||||
assert.equal(grants.revoke(a), 1);
|
||||
assert.equal(grants.redeem(a), false, 'revoked grant is dead');
|
||||
assert.equal(grants.redeem(b), true, 'an unrelated grant is unaffected');
|
||||
});
|
||||
|
||||
test('outstanding grants are enumerable, and revokeAll clears them', () => {
|
||||
grants.revokeAll();
|
||||
grants.mint({ note: 'one' }); grants.mint({ note: 'two' });
|
||||
assert.equal(grants.listOutstanding().length, 2, 'an operator can see what is outstanding');
|
||||
grants.revokeAll();
|
||||
assert.equal(grants.listOutstanding().length, 0);
|
||||
});
|
||||
|
||||
test('redeeming stamps who and when, so a break-glass session is attributable', () => {
|
||||
const { jti } = grants.mint({ mintedBy: 'root@host' });
|
||||
grants.redeem(jti, { sourceIp: '203.0.113.9' });
|
||||
const row = db.prepare('SELECT * FROM recovery_grants WHERE jti = ?').get(jti);
|
||||
assert.ok(row.used_at, 'used_at recorded');
|
||||
assert.equal(row.source_ip, '203.0.113.9');
|
||||
assert.equal(row.minted_by, 'root@host');
|
||||
});
|
||||
|
||||
test('pruneExpired only removes rows that can never be redeemed again', () => {
|
||||
grants.revokeAll();
|
||||
const fresh = grants.mint({ ttlSec: 3600 }).jti;
|
||||
grants.pruneExpired(Math.floor(Date.now() / 1000));
|
||||
assert.equal(grants.listOutstanding().some(g => g.jti === fresh), true, 'a live grant survives pruning');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Enforcement: requireAuth must demand a grant
|
||||
// ---------------------------------------------------------------------------
|
||||
const express = require('express');
|
||||
const http = require('node:http');
|
||||
const { requireAuth } = require('../middleware/auth');
|
||||
|
||||
function appWithAuth() {
|
||||
const app = express();
|
||||
app.get('/probe', requireAuth, (req, res) => res.json({ ok: true, id: req.user.id, provider: req.user.auth_provider }));
|
||||
return app;
|
||||
}
|
||||
async function probe(token) {
|
||||
const server = http.createServer(appWithAuth());
|
||||
await new Promise(r => server.listen(0, r));
|
||||
const base = `http://127.0.0.1:${server.address().port}`;
|
||||
const res = await fetch(base + '/probe', { headers: { Authorization: 'Bearer ' + token } });
|
||||
let body = null; try { body = await res.json(); } catch { /* */ }
|
||||
await new Promise(r => server.close(r));
|
||||
return { status: res.status, body };
|
||||
}
|
||||
const recoveryToken = (jti) => jwt.sign(
|
||||
{ id: 'recovery-' + (jti || 'nogrant'), email: 'admin@localhost', role: 'admin', recovery: true, jti },
|
||||
process.env.JWT_SECRET, { expiresIn: '1h' }
|
||||
);
|
||||
|
||||
test('a recovery token with NO grant row is refused', async () => {
|
||||
const r = await probe(recoveryToken(grants.newJti()));
|
||||
assert.equal(r.status, 401, 'an unbacked recovery claim must not authenticate');
|
||||
});
|
||||
|
||||
test('a recovery token WITH a grant authenticates, and revocation ends it', async () => {
|
||||
const { jti } = grants.mint({ mintedBy: 'test' });
|
||||
const tok = recoveryToken(jti);
|
||||
const first = await probe(tok);
|
||||
assert.equal(first.status, 200, 'a backed recovery token works');
|
||||
assert.equal(first.body.provider, 'recovery');
|
||||
|
||||
const second = await probe(tok);
|
||||
assert.equal(second.status, 200, 'the session keeps working — break-glass needs many requests');
|
||||
|
||||
grants.revoke(jti);
|
||||
assert.equal((await probe(tok)).status, 401, 'but revoking it stops the very next request');
|
||||
});
|
||||
|
||||
test('revoking the grant immediately kills the token', async () => {
|
||||
const { jti } = grants.mint();
|
||||
grants.revoke(jti);
|
||||
assert.equal((await probe(recoveryToken(jti))).status, 401);
|
||||
});
|
||||
|
|
@ -103,11 +103,23 @@ before(async () => {
|
|||
S.mfaToken = login.body.mfa_token;
|
||||
assert.ok(S.mfaToken, 'got a pre-TOTP token');
|
||||
|
||||
// Recovery token, minted exactly as scripts/reset-admin.js does it.
|
||||
// Recovery token, minted exactly as scripts/reset-admin.js does it — including the
|
||||
// recovery_grants row, without which the token is refused outright. Backing it properly
|
||||
// keeps the assertions below testing what they were written to test (break-glass is
|
||||
// refused on these six surfaces because it has no users row / no workspace membership),
|
||||
// rather than passing for the unrelated reason that the token itself is invalid.
|
||||
{
|
||||
const Database = require('better-sqlite3');
|
||||
const gdb = new Database(path.join(DATA_DIR, 'db', 'remote_display.db'));
|
||||
const jti = crypto.randomBytes(16).toString('hex');
|
||||
gdb.prepare('INSERT INTO recovery_grants (jti, expires_at, minted_by, note) VALUES (?,?,?,?)')
|
||||
.run(jti, Math.floor(Date.now() / 1000) + 3600, 'test', 'session-token-resolution');
|
||||
gdb.close();
|
||||
S.recoveryToken = jwt.sign(
|
||||
{ id: 'recovery-' + crypto.randomBytes(8).toString('hex'), email: 'admin@localhost', role: 'admin', recovery: true },
|
||||
{ id: 'recovery-' + jti, email: 'admin@localhost', role: 'admin', recovery: true, jti },
|
||||
SECRET, { expiresIn: '1h' }
|
||||
);
|
||||
}
|
||||
|
||||
// Content with a real file + thumbnail, not referenced by any playlist, so
|
||||
// /api/content/:id/{file,thumbnail} falls through to the requester gate.
|
||||
|
|
|
|||
Loading…
Reference in a new issue