The admin key used to stay in the URL fragment and was sent with every
request. Now the key is exchanged once for a server-side session:
- POST /api/admin/login {key} sets bingo_admin=<random token>
(HttpOnly, Secure, SameSite=Strict, no Path so it is scoped to the
admin API directory, also behind a subpath proxy)
- POST /api/admin/logout ends the session
- sessions live in memory as sha256(token) -> expiry, absolute lifetime
ADMIN_SESSION_HOURS (default 8); restart logs everyone out
- /admin#k=KEY still works as a login link; the page strips the fragment
from the URL and history before logging in, then shows plain /admin
- wrong key answers 401 after 500 ms; Bearer header still works for scripts
- COOKIE_SECURE=0 for plain-http testing only
Tests: 11 passing (login, cookie flags, logout, expiry, admin disabled).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FhHiznVwtQ55ZSguTBWrxn
45 lines
1.8 KiB
JavaScript
45 lines
1.8 KiB
JavaScript
// Session expiry and the "admin disabled" case, each on its own server instance.
|
|
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { startServer, ADMIN_KEY } from './helpers.mjs';
|
|
|
|
const post = (base, path, body, headers = {}) => fetch(base + path, {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json', ...headers }, body: JSON.stringify(body),
|
|
});
|
|
|
|
test('session expires after ADMIN_SESSION_HOURS', async () => {
|
|
const srv = await startServer({ ADMIN_SESSION_HOURS: String(1.5 / 3600) }); // 1.5 seconds
|
|
try {
|
|
const res = await post(srv.base, '/api/admin/login', { key: ADMIN_KEY });
|
|
assert.equal(res.status, 200);
|
|
const cookie = res.headers.get('set-cookie').split(';')[0];
|
|
assert.equal((await fetch(srv.base + '/api/admin/stats', { headers: { Cookie: cookie } })).status, 200);
|
|
await new Promise((r) => setTimeout(r, 1800));
|
|
assert.equal((await fetch(srv.base + '/api/admin/stats', { headers: { Cookie: cookie } })).status, 401);
|
|
} finally {
|
|
srv.stop();
|
|
}
|
|
});
|
|
|
|
test('without ADMIN_KEY the admin API is disabled', async () => {
|
|
const srv = await startServer({ ADMIN_KEY: '' });
|
|
try {
|
|
const res = await post(srv.base, '/api/admin/login', { key: 'anything-anything-anything' });
|
|
assert.equal(res.status, 403);
|
|
assert.equal((await res.json()).error, 'admin_disabled');
|
|
assert.equal((await (await fetch(srv.base + '/api/admin/stats')).json()).error, 'admin_disabled');
|
|
} finally {
|
|
srv.stop();
|
|
}
|
|
});
|
|
|
|
test('COOKIE_SECURE=0 drops the Secure flag (plain-http testing only)', async () => {
|
|
const srv = await startServer({ COOKIE_SECURE: '0' });
|
|
try {
|
|
const res = await post(srv.base, '/api/admin/login', { key: ADMIN_KEY });
|
|
assert.doesNotMatch(res.headers.get('set-cookie'), /Secure/);
|
|
} finally {
|
|
srv.stop();
|
|
}
|
|
});
|