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