mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 13:53:12 -06:00
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>
88 lines
3.7 KiB
JavaScript
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}
|
|
`);
|