websocket-auth.js raw
1 /**
2 * WebSocket Authentication Module for Nostr Relays
3 * Implements NIP-42 authentication with proper challenge handling
4 */
5
6 export class NostrWebSocketAuth {
7 constructor(relayUrl, userSigner, userPubkey) {
8 this.relayUrl = relayUrl;
9 this.userSigner = userSigner;
10 this.userPubkey = userPubkey;
11 this.ws = null;
12 this.challenge = null;
13 this.isAuthenticated = false;
14 this.authPromise = null;
15 }
16
17 /**
18 * Connect to relay and handle authentication
19 */
20 async connect() {
21 return new Promise((resolve, reject) => {
22 this.ws = new WebSocket(this.relayUrl);
23
24 this.ws.onopen = () => {
25 console.log('WebSocket connected to relay:', this.relayUrl);
26 resolve();
27 };
28
29 this.ws.onmessage = async (message) => {
30 try {
31 const data = JSON.parse(message.data);
32 await this.handleMessage(data);
33 } catch (error) {
34 console.error('Error parsing relay message:', error);
35 }
36 };
37
38 this.ws.onerror = (error) => {
39 console.error('WebSocket error:', error);
40 reject(new Error('Failed to connect to relay'));
41 };
42
43 this.ws.onclose = () => {
44 console.log('WebSocket connection closed');
45 this.isAuthenticated = false;
46 this.challenge = null;
47 };
48
49 // Timeout for connection
50 setTimeout(() => {
51 if (this.ws.readyState !== WebSocket.OPEN) {
52 reject(new Error('Connection timeout'));
53 }
54 }, 10000);
55 });
56 }
57
58 /**
59 * Handle incoming messages from relay
60 */
61 async handleMessage(data) {
62 const [messageType, ...params] = data;
63
64 switch (messageType) {
65 case 'AUTH':
66 // Relay sent authentication challenge
67 this.challenge = params[0];
68 console.log('Received AUTH challenge:', this.challenge);
69 await this.authenticate();
70 break;
71
72 case 'OK':
73 const [eventId, success, reason] = params;
74 if (eventId && success) {
75 console.log('Authentication successful for event:', eventId);
76 this.isAuthenticated = true;
77 if (this.authPromise) {
78 this.authPromise.resolve();
79 this.authPromise = null;
80 }
81 } else if (eventId && !success) {
82 console.error('Authentication failed:', reason);
83 if (this.authPromise) {
84 this.authPromise.reject(new Error(reason || 'Authentication failed'));
85 this.authPromise = null;
86 }
87 }
88 break;
89
90 case 'NOTICE':
91 console.log('Relay notice:', params[0]);
92 break;
93
94 default:
95 console.log('Unhandled message type:', messageType, params);
96 }
97 }
98
99 /**
100 * Authenticate with the relay using NIP-42
101 */
102 async authenticate() {
103 if (!this.challenge) {
104 throw new Error('No challenge received from relay');
105 }
106
107 if (!this.userSigner) {
108 throw new Error('No signer available for authentication');
109 }
110
111 try {
112 // Create NIP-42 authentication event
113 const authEvent = {
114 kind: 22242, // ClientAuthentication kind
115 created_at: Math.floor(Date.now() / 1000),
116 tags: [
117 ['relay', this.relayUrl],
118 ['challenge', this.challenge]
119 ],
120 content: '',
121 pubkey: this.userPubkey
122 };
123
124 // Sign the authentication event
125 const signedAuthEvent = await this.userSigner.signEvent(authEvent);
126
127 // Send AUTH message to relay
128 const authMessage = ["AUTH", signedAuthEvent];
129 this.ws.send(JSON.stringify(authMessage));
130
131 console.log('Sent authentication event to relay');
132
133 // Wait for authentication response
134 return new Promise((resolve, reject) => {
135 this.authPromise = { resolve, reject };
136
137 // Timeout for authentication
138 setTimeout(() => {
139 if (this.authPromise) {
140 this.authPromise.reject(new Error('Authentication timeout'));
141 this.authPromise = null;
142 }
143 }, 10000);
144 });
145
146 } catch (error) {
147 console.error('Authentication error:', error);
148 throw error;
149 }
150 }
151
152 /**
153 * Publish an event to the relay
154 */
155 async publishEvent(event) {
156 if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
157 throw new Error('WebSocket not connected');
158 }
159
160 return new Promise((resolve, reject) => {
161 // Send EVENT message
162 const eventMessage = ["EVENT", event];
163 this.ws.send(JSON.stringify(eventMessage));
164
165 // Set up message handler for this specific event
166 const originalOnMessage = this.ws.onmessage;
167 const timeout = setTimeout(() => {
168 this.ws.onmessage = originalOnMessage;
169 reject(new Error('Publish timeout'));
170 }, 15000);
171
172 this.ws.onmessage = async (message) => {
173 try {
174 const data = JSON.parse(message.data);
175 const [messageType, eventId, success, reason] = data;
176
177 if (messageType === 'OK' && eventId === event.id) {
178 if (success) {
179 clearTimeout(timeout);
180 this.ws.onmessage = originalOnMessage;
181 console.log('Event published successfully:', eventId);
182 resolve({ success: true, eventId, reason });
183 } else {
184 console.error('Event publish failed:', reason);
185
186 // Check if authentication is required
187 if (reason && reason.includes('auth-required')) {
188 console.log('Authentication required, waiting for AUTH challenge...');
189 // Don't restore original handler yet - we need to receive the AUTH challenge
190 // The AUTH message will be handled by the else branch below
191 return;
192 }
193
194 clearTimeout(timeout);
195 this.ws.onmessage = originalOnMessage;
196 reject(new Error(`Publish failed: ${reason}`));
197 }
198 } else if (messageType === 'AUTH') {
199 // Handle AUTH challenge during publish flow
200 this.challenge = data[1];
201 console.log('Received AUTH challenge during publish:', this.challenge);
202
203 try {
204 await this.authenticate();
205 console.log('Authentication successful, retrying event publish...');
206 // Re-send the event after authentication
207 const retryMessage = ["EVENT", event];
208 this.ws.send(JSON.stringify(retryMessage));
209 // Don't resolve yet, wait for the retry response
210 } catch (authError) {
211 clearTimeout(timeout);
212 this.ws.onmessage = originalOnMessage;
213 reject(new Error(`Authentication failed: ${authError.message}`));
214 }
215 } else {
216 // Handle other messages normally
217 await this.handleMessage(data);
218 }
219 } catch (error) {
220 clearTimeout(timeout);
221 this.ws.onmessage = originalOnMessage;
222 reject(error);
223 }
224 };
225 });
226 }
227
228 /**
229 * Close the WebSocket connection
230 */
231 close() {
232 if (this.ws) {
233 this.ws.close();
234 this.ws = null;
235 }
236 this.isAuthenticated = false;
237 this.challenge = null;
238 }
239
240 /**
241 * Check if currently authenticated
242 */
243 getAuthenticated() {
244 return this.isAuthenticated;
245 }
246 }
247
248 /**
249 * Convenience function to publish an event with authentication
250 */
251 export async function publishEventWithAuth(relayUrl, event, userSigner, userPubkey) {
252 const auth = new NostrWebSocketAuth(relayUrl, userSigner, userPubkey);
253
254 try {
255 await auth.connect();
256 const result = await auth.publishEvent(event);
257 return result;
258 } finally {
259 auth.close();
260 }
261 }
262