feat(auth): add an admin-managed login allowlist

Lets the admin restrict which pubkeys may log in, enforced server-side
at /api/auth/login before a session is issued. Disabled by default;
the admin and the bootstrap (no-admin-claimed-yet) case always pass.
Manageable via the existing settings UI/API (npub or hex, one per line).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-09 18:33:21 +00:00
co-authored by Claude Sonnet 5
parent b4c2214c7a
commit 69241a28b1
5 changed files with 164 additions and 2 deletions
+72
View File
@@ -79,6 +79,78 @@ describe('auth', () => {
});
});
describe('login allowlist', () => {
const sk2 = generateSecretKey();
const pk2 = getPublicKey(sk2);
function nip98Header2(url: string, method: string): string {
const event = finalizeEvent(
{
kind: 27235,
created_at: Math.floor(Date.now() / 1000),
content: '',
tags: [['u', url], ['method', method], ['nonce', Math.random().toString(36).slice(2)]],
},
sk2,
);
return `Nostr ${Buffer.from(JSON.stringify(event)).toString('base64')}`;
}
it('rejects a non-listed pubkey once the allowlist is enabled, admin still logs in', async () => {
const enable = await app.inject({
method: 'PUT',
url: '/api/settings',
headers: { cookie, 'content-type': 'application/json' },
payload: { login_allowlist_enabled: true, login_allowlist: [] },
});
expect(enable.statusCode).toBe(200);
const blocked = await app.inject({
method: 'POST',
url: '/api/auth/login',
headers: { authorization: nip98Header2('http://localhost:8095/api/auth/login', 'POST') },
});
expect(blocked.statusCode).toBe(403);
const adminStillIn = await app.inject({
method: 'POST',
url: '/api/auth/login',
headers: { authorization: nip98Header('http://localhost:8095/api/auth/login', 'POST') },
});
expect(adminStillIn.statusCode).toBe(200);
expect(adminStillIn.json().isAdmin).toBe(true);
});
it('allows a pubkey once it is added to the allowlist', async () => {
const update = await app.inject({
method: 'PUT',
url: '/api/settings',
headers: { cookie, 'content-type': 'application/json' },
payload: { login_allowlist: [pk2] },
});
expect(update.statusCode).toBe(200);
expect(update.json().login_allowlist).toEqual([pk2]);
const res = await app.inject({
method: 'POST',
url: '/api/auth/login',
headers: { authorization: nip98Header2('http://localhost:8095/api/auth/login', 'POST') },
});
expect(res.statusCode).toBe(200);
expect(res.json().pubkey).toBe(pk2);
});
afterAll(async () => {
// Leave the allowlist disabled so later describe blocks aren't affected.
await app.inject({
method: 'PUT',
url: '/api/settings',
headers: { cookie, 'content-type': 'application/json' },
payload: { login_allowlist_enabled: false },
});
});
});
describe('podcasts, episodes, feed', () => {
let podcastId: string;
const sha = 'c'.repeat(64);