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);
+3
View File
@@ -24,6 +24,9 @@ export default async function authRoutes(app: FastifyInstance) {
if (err instanceof Nip98Error) return reply.code(401).send({ error: err.message });
throw err;
}
if (!settings.isLoginAllowed(pubkey)) {
return reply.code(403).send({ error: 'this account is not on the login allowlist' });
}
upsertUser.run(pubkey, nowSecs(), nowSecs());
settings.claimAdminIfUnset(pubkey);
+16 -1
View File
@@ -6,6 +6,8 @@ const updateSchema = z.object({
relays: z.array(z.string().regex(/^wss?:\/\//)).optional(),
public_url: z.string().url().optional(),
cashu_mint_url: z.string().url().optional(),
login_allowlist_enabled: z.boolean().optional(),
login_allowlist: z.array(z.string().regex(/^[0-9a-f]{64}$/i)).optional(),
});
export default async function settingsRoutes(app: FastifyInstance) {
@@ -33,11 +35,24 @@ export default async function settingsRoutes(app: FastifyInstance) {
}
const parsed = updateSchema.safeParse(req.body);
if (!parsed.success) return reply.code(400).send({ error: parsed.error.message });
const { blossom_url, relays, public_url, cashu_mint_url } = parsed.data;
const {
blossom_url,
relays,
public_url,
cashu_mint_url,
login_allowlist_enabled,
login_allowlist,
} = parsed.data;
if (blossom_url !== undefined) settings.set('blossom_url', blossom_url);
if (relays !== undefined) settings.set('relays', JSON.stringify(relays));
if (public_url !== undefined) settings.set('public_url', public_url);
if (cashu_mint_url !== undefined) settings.set('cashu_mint_url', cashu_mint_url);
if (login_allowlist_enabled !== undefined) {
settings.set('login_allowlist_enabled', login_allowlist_enabled ? 'true' : 'false');
}
if (login_allowlist !== undefined) {
settings.set('login_allowlist', JSON.stringify(login_allowlist.map((pk) => pk.toLowerCase())));
}
return settings.all();
});
}
+17
View File
@@ -7,6 +7,8 @@ export interface Settings {
public_url: string;
admin_pubkey: string | null;
cashu_mint_url: string;
login_allowlist_enabled: boolean;
login_allowlist: string[];
}
export class SettingsService {
@@ -32,6 +34,8 @@ export class SettingsService {
public_url: this.get('public_url') ?? this.config.PUBLIC_URL,
admin_pubkey: this.get('admin_pubkey'),
cashu_mint_url: this.get('cashu_mint_url') ?? this.config.CASHU_MINT_URL_DEFAULT,
login_allowlist_enabled: this.get('login_allowlist_enabled') === 'true',
login_allowlist: JSON.parse(this.get('login_allowlist') ?? '[]'),
};
}
@@ -64,4 +68,17 @@ export class SettingsService {
isAdmin(pubkey: string): boolean {
return this.get('admin_pubkey') === pubkey;
}
/**
* Whether a pubkey may log in. Disabled by default (everyone allowed). When enabled,
* the admin and no-admin-claimed-yet bootstrap case always pass, otherwise the pubkey
* must be in the allowlist.
*/
isLoginAllowed(pubkey: string): boolean {
if (this.get('login_allowlist_enabled') !== 'true') return true;
if (!this.get('admin_pubkey')) return true;
if (this.isAdmin(pubkey)) return true;
const list: string[] = JSON.parse(this.get('login_allowlist') ?? '[]');
return list.includes(pubkey);
}
}