screentinker/scripts/reset-admin.js
ScreenTinker f289609380 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>
2026-07-26 14:49:03 -05:00

88 lines
3.7 KiB
JavaScript

#!/usr/bin/env node
/**
* Emergency admin access for self-hosted ScreenTinker.
* Run this on the server to get a temporary admin login token.
*
* 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 grants = require(path.join(__dirname, '..', 'server', 'lib', 'recovery-grant'));
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-' + jti, email: 'admin@localhost', role: 'admin', recovery: true, jti },
config.jwtSecret,
{ 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 single-use admin token has been generated. ║
║ Valid for 1 hour, or until it is used once. ║
╚══════════════════════════════════════════════════╝
grant id : ${jti}
expires : ${new Date(expiresAt * 1000).toISOString()}
token : ${outFile} (mode 0600 — deliberately NOT printed here)
Use it:
TOKEN="$(cat ${outFile})"
curl -H "Authorization: Bearer $TOKEN" http://localhost:${port}/api/devices
Or in the browser console on your instance:
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}
`);