Files
BelgianBitcoinEmbassy/backend/src/services/auth.ts
T
bbeandCursor 70e3e0633d feat: roles/permissions system and Nostr profile display on admin users
Introduce granular role-based permissions with SuperAdmin env override, admin roles UI, and permission-gated API routes. Fix admin user Nostr metadata by batching relay profile fetches, normalizing npub pubkeys to hex, and adding reusable NostrAvatar/useNostrProfile components.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 08:46:56 +02:00

134 lines
3.6 KiB
TypeScript

import jwt from 'jsonwebtoken';
import { v4 as uuidv4 } from 'uuid';
import { verifyEvent, type VerifiedEvent, nip19 } from 'nostr-tools';
import { prisma } from '../db/prisma';
import {
ALL_PERMISSION_KEYS,
isAssignableRole,
type EffectiveRole,
} from '../constants/permissions';
const JWT_SECRET = process.env.JWT_SECRET || 'change-me-in-production';
const CHALLENGE_TTL_MS = 5 * 60 * 1000;
export interface ResolvedAccess {
pubkey: string;
role: EffectiveRole;
isSuperAdmin: boolean;
permissions: Set<string>;
}
// Reads the SuperAdmin pubkey list from the environment, decoding npub to hex.
// Falls back to the legacy ADMIN_PUBKEYS variable so existing deployments keep
// working. The env-admin concept is now SuperAdmin.
function getSuperadminPubkeys(): string[] {
const raw = process.env.SUPERADMIN_PUBKEYS ?? process.env.ADMIN_PUBKEYS ?? '';
return raw
.split(',')
.map((p) => p.trim())
.filter(Boolean)
.map((p) => {
if (p.startsWith('npub1')) {
try {
const { data } = nip19.decode(p);
return data as string;
} catch {
return p;
}
}
return p;
});
}
export function isSuperadmin(pubkey: string): boolean {
return getSuperadminPubkeys().includes(pubkey);
}
interface StoredChallenge {
challenge: string;
expiresAt: number;
}
const challenges = new Map<string, StoredChallenge>();
// Periodically clean up expired challenges
setInterval(() => {
const now = Date.now();
for (const [key, value] of challenges) {
if (value.expiresAt < now) {
challenges.delete(key);
}
}
}, 60_000);
export const authService = {
createChallenge(pubkey: string): string {
const challenge = uuidv4();
challenges.set(pubkey, {
challenge,
expiresAt: Date.now() + CHALLENGE_TTL_MS,
});
return challenge;
},
verifySignature(pubkey: string, signedEvent: VerifiedEvent): boolean {
const stored = challenges.get(pubkey);
if (!stored) return false;
if (stored.expiresAt < Date.now()) {
challenges.delete(pubkey);
return false;
}
// Verify the event signature
if (!verifyEvent(signedEvent)) return false;
// Kind 22242 is the NIP-42 auth kind
if (signedEvent.kind !== 22242) return false;
if (signedEvent.pubkey !== pubkey) return false;
// Check that the challenge tag matches
const challengeTag = signedEvent.tags.find(
(t) => t[0] === 'challenge'
);
if (!challengeTag || challengeTag[1] !== stored.challenge) return false;
challenges.delete(pubkey);
return true;
},
generateToken(pubkey: string, role: string): string {
return jwt.sign({ pubkey, role }, JWT_SECRET, { expiresIn: '7d' });
},
isSuperadmin,
// Resolves the effective role and permission set for a pubkey, live, from the
// env SuperAdmin list plus the database. This is the authoritative source for
// authorization. The role baked into a JWT is only used for display.
async resolveAccess(pubkey: string): Promise<ResolvedAccess> {
if (isSuperadmin(pubkey)) {
return {
pubkey,
role: 'superadmin',
isSuperAdmin: true,
permissions: new Set(ALL_PERMISSION_KEYS),
};
}
const user = await prisma.user.findUnique({ where: { pubkey } });
const role: EffectiveRole = isAssignableRole(user?.role) ? user!.role : 'guest';
if (role === 'guest') {
return { pubkey, role, isSuperAdmin: false, permissions: new Set() };
}
const rows = await prisma.rolePermission.findMany({ where: { role } });
return {
pubkey,
role,
isSuperAdmin: false,
permissions: new Set(rows.map((r) => r.permission)),
};
},
};