auth.ts raw
1 import { getDb } from './db';
2 import bcrypt from 'bcryptjs';
3 import crypto from 'crypto';
4
5 const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
6 const CHALLENGE_TTL_MS = 5 * 60 * 1000;
7
8 export function verifyPassword(email: string, password: string): string | null {
9 const db = getDb();
10 const user = db.query('SELECT id, password_hash FROM users WHERE email = ?').get(email) as any;
11 if (!user || !user.password_hash) return null;
12 return bcrypt.compareSync(password, user.password_hash) ? user.id : null;
13 }
14
15 export function createUser(email: string, password: string, nostrPubkey?: string): string {
16 const db = getDb();
17 const id = crypto.randomUUID();
18 const hash = password ? bcrypt.hashSync(password, 10) : null;
19 db.query('INSERT INTO users (id, email, password_hash, nostr_pubkey) VALUES (?, ?, ?, ?)').run(id, email, hash, nostrPubkey || null);
20 return id;
21 }
22
23 export function findOrCreateNostrUser(pubkey: string): { userId: string; created: boolean } {
24 const db = getDb();
25 const user = db.query('SELECT id FROM users WHERE nostr_pubkey = ?').get(pubkey) as any;
26 if (user) return { userId: user.id, created: false };
27 const id = crypto.randomUUID();
28 db.query('INSERT INTO users (id, nostr_pubkey) VALUES (?, ?)').run(id, pubkey);
29 return { userId: id, created: true };
30 }
31
32 export function createSession(userId: string): string {
33 const db = getDb();
34 const token = crypto.randomBytes(32).toString('hex');
35 db.query('INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)').run(token, userId, Date.now() + SESSION_TTL_MS);
36 return token;
37 }
38
39 export function getUserFromSession(token: string): any {
40 const db = getDb();
41 const session = db.query('SELECT user_id, expires_at FROM sessions WHERE token = ?').get(token) as any;
42 if (!session) return null;
43 if (session.expires_at < Date.now()) {
44 db.query('DELETE FROM sessions WHERE token = ?').run(token);
45 return null;
46 }
47 return db.query('SELECT id, email, nostr_pubkey, is_admin, delivery_prefs, name, phone, password_hash FROM users WHERE id = ?').get(session.user_id);
48 }
49
50 export function deleteSession(token: string) {
51 getDb().query('DELETE FROM sessions WHERE token = ?').run(token);
52 }
53
54 export function updateUserPrefs(userId: string, prefs: { name?: string; phone?: string; email?: string; delivery_prefs?: string }): any {
55 const db = getDb();
56 const sets: string[] = [];
57 const vals: any[] = [];
58 if (prefs.name !== undefined && prefs.name !== '') { sets.push('name = ?'); vals.push(prefs.name); }
59 if (prefs.phone !== undefined && prefs.phone !== '') { sets.push('phone = ?'); vals.push(prefs.phone); }
60 if (prefs.email !== undefined && prefs.email !== '') { sets.push('email = ?'); vals.push(prefs.email); }
61 if (prefs.delivery_prefs !== undefined) { sets.push('delivery_prefs = ?'); vals.push(prefs.delivery_prefs); }
62 if (sets.length === 0) return null;
63 vals.push(userId);
64 db.query(`UPDATE users SET ${sets.join(', ')} WHERE id = ?`).run(...vals);
65 return db.query('SELECT id, email, nostr_pubkey, is_admin, delivery_prefs, name, phone FROM users WHERE id = ?').get(userId);
66 }
67
68 export function setUserPassword(userId: string, password: string): boolean {
69 if (!password || password.length < 6) return false;
70 const hash = bcrypt.hashSync(password, 10);
71 getDb().query('UPDATE users SET password_hash = ? WHERE id = ?').run(hash, userId);
72 return true;
73 }
74
75 export function issueNostrChallenge(): string {
76 const db = getDb();
77 const challenge = crypto.randomBytes(32).toString('hex');
78 db.query('DELETE FROM nostr_challenges WHERE created_at < ?').run(Date.now() - CHALLENGE_TTL_MS);
79 db.query('INSERT INTO nostr_challenges (challenge, created_at) VALUES (?, ?)').run(challenge, Date.now());
80 return challenge;
81 }
82
83 function consumeChallenge(challenge: string): boolean {
84 const db = getDb();
85 const row = db.query('SELECT created_at FROM nostr_challenges WHERE challenge = ?').get(challenge) as any;
86 if (!row) return false;
87 db.query('DELETE FROM nostr_challenges WHERE challenge = ?').run(challenge);
88 return row.created_at >= Date.now() - CHALLENGE_TTL_MS;
89 }
90
91 // verifies a NIP-07 signed auth event: kind 22242, a ["challenge", <challenge>]
92 // tag matching a server-issued challenge, signed by `pubkey`. The event id is
93 // recomputed server-side; the client-supplied id is ignored.
94 export function verifyNostrAuthEvent(ev: any): string | null {
95 try {
96 if (!ev || ev.kind !== 22242) return null;
97 if (typeof ev.pubkey !== 'string' || !/^[0-9a-f]{64}$/.test(ev.pubkey)) return null;
98 if (typeof ev.sig !== 'string' || !/^[0-9a-f]{128}$/.test(ev.sig)) return null;
99 if (!Array.isArray(ev.tags)) return null;
100 const tag = ev.tags.find((t: any) => Array.isArray(t) && t[0] === 'challenge' && typeof t[1] === 'string');
101 if (!tag) return null;
102 if (Math.abs(Date.now() / 1000 - ev.created_at) > 600) return null;
103 if (!consumeChallenge(tag[1])) return null;
104 const serialized = JSON.stringify([0, ev.pubkey, ev.created_at, ev.kind, ev.tags, ev.content ?? '']);
105 const id = crypto.createHash('sha256').update(new TextEncoder().encode(serialized)).digest();
106 const { schnorr } = require('@noble/curves/secp256k1.js');
107 const ok = schnorr.verify(Buffer.from(ev.sig, 'hex'), id, Buffer.from(ev.pubkey, 'hex'));
108 return ok ? ev.pubkey : null;
109 } catch {
110 return null;
111 }
112 }
113