Some checks failed
Go / build (push) Has been cancelled
- Added a new compose tab for users to create, sign, and publish Nostr events. - Introduced functions for JSON reformatting, event signing, and publishing with WebSocket authentication. - Implemented clipboard functionality to copy event JSON directly from the UI. - Enhanced UI with buttons for reformatting, signing, and publishing events, improving user experience. - Created a new websocket-auth.js module for handling WebSocket authentication with Nostr relays. - Bumped version to v0.14.5.
252 lines
8.7 KiB
JavaScript
252 lines
8.7 KiB
JavaScript
/**
|
|
* WebSocket Authentication Module for Nostr Relays
|
|
* Implements NIP-42 authentication with proper challenge handling
|
|
*/
|
|
|
|
export class NostrWebSocketAuth {
|
|
constructor(relayUrl, userSigner, userPubkey) {
|
|
this.relayUrl = relayUrl;
|
|
this.userSigner = userSigner;
|
|
this.userPubkey = userPubkey;
|
|
this.ws = null;
|
|
this.challenge = null;
|
|
this.isAuthenticated = false;
|
|
this.authPromise = null;
|
|
}
|
|
|
|
/**
|
|
* Connect to relay and handle authentication
|
|
*/
|
|
async connect() {
|
|
return new Promise((resolve, reject) => {
|
|
this.ws = new WebSocket(this.relayUrl);
|
|
|
|
this.ws.onopen = () => {
|
|
console.log('WebSocket connected to relay:', this.relayUrl);
|
|
resolve();
|
|
};
|
|
|
|
this.ws.onmessage = async (message) => {
|
|
try {
|
|
const data = JSON.parse(message.data);
|
|
await this.handleMessage(data);
|
|
} catch (error) {
|
|
console.error('Error parsing relay message:', error);
|
|
}
|
|
};
|
|
|
|
this.ws.onerror = (error) => {
|
|
console.error('WebSocket error:', error);
|
|
reject(new Error('Failed to connect to relay'));
|
|
};
|
|
|
|
this.ws.onclose = () => {
|
|
console.log('WebSocket connection closed');
|
|
this.isAuthenticated = false;
|
|
this.challenge = null;
|
|
};
|
|
|
|
// Timeout for connection
|
|
setTimeout(() => {
|
|
if (this.ws.readyState !== WebSocket.OPEN) {
|
|
reject(new Error('Connection timeout'));
|
|
}
|
|
}, 10000);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Handle incoming messages from relay
|
|
*/
|
|
async handleMessage(data) {
|
|
const [messageType, ...params] = data;
|
|
|
|
switch (messageType) {
|
|
case 'AUTH':
|
|
// Relay sent authentication challenge
|
|
this.challenge = params[0];
|
|
console.log('Received AUTH challenge:', this.challenge);
|
|
await this.authenticate();
|
|
break;
|
|
|
|
case 'OK':
|
|
const [eventId, success, reason] = params;
|
|
if (eventId && success) {
|
|
console.log('Authentication successful for event:', eventId);
|
|
this.isAuthenticated = true;
|
|
if (this.authPromise) {
|
|
this.authPromise.resolve();
|
|
this.authPromise = null;
|
|
}
|
|
} else if (eventId && !success) {
|
|
console.error('Authentication failed:', reason);
|
|
if (this.authPromise) {
|
|
this.authPromise.reject(new Error(reason || 'Authentication failed'));
|
|
this.authPromise = null;
|
|
}
|
|
}
|
|
break;
|
|
|
|
case 'NOTICE':
|
|
console.log('Relay notice:', params[0]);
|
|
break;
|
|
|
|
default:
|
|
console.log('Unhandled message type:', messageType, params);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Authenticate with the relay using NIP-42
|
|
*/
|
|
async authenticate() {
|
|
if (!this.challenge) {
|
|
throw new Error('No challenge received from relay');
|
|
}
|
|
|
|
if (!this.userSigner) {
|
|
throw new Error('No signer available for authentication');
|
|
}
|
|
|
|
try {
|
|
// Create NIP-42 authentication event
|
|
const authEvent = {
|
|
kind: 22242, // ClientAuthentication kind
|
|
created_at: Math.floor(Date.now() / 1000),
|
|
tags: [
|
|
['relay', this.relayUrl],
|
|
['challenge', this.challenge]
|
|
],
|
|
content: '',
|
|
pubkey: this.userPubkey
|
|
};
|
|
|
|
// Sign the authentication event
|
|
const signedAuthEvent = await this.userSigner.signEvent(authEvent);
|
|
|
|
// Send AUTH message to relay
|
|
const authMessage = ["AUTH", signedAuthEvent];
|
|
this.ws.send(JSON.stringify(authMessage));
|
|
|
|
console.log('Sent authentication event to relay');
|
|
|
|
// Wait for authentication response
|
|
return new Promise((resolve, reject) => {
|
|
this.authPromise = { resolve, reject };
|
|
|
|
// Timeout for authentication
|
|
setTimeout(() => {
|
|
if (this.authPromise) {
|
|
this.authPromise.reject(new Error('Authentication timeout'));
|
|
this.authPromise = null;
|
|
}
|
|
}, 10000);
|
|
});
|
|
|
|
} catch (error) {
|
|
console.error('Authentication error:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Publish an event to the relay
|
|
*/
|
|
async publishEvent(event) {
|
|
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
throw new Error('WebSocket not connected');
|
|
}
|
|
|
|
return new Promise((resolve, reject) => {
|
|
// Send EVENT message
|
|
const eventMessage = ["EVENT", event];
|
|
this.ws.send(JSON.stringify(eventMessage));
|
|
|
|
// Set up message handler for this specific event
|
|
const originalOnMessage = this.ws.onmessage;
|
|
const timeout = setTimeout(() => {
|
|
this.ws.onmessage = originalOnMessage;
|
|
reject(new Error('Publish timeout'));
|
|
}, 15000);
|
|
|
|
this.ws.onmessage = async (message) => {
|
|
try {
|
|
const data = JSON.parse(message.data);
|
|
const [messageType, eventId, success, reason] = data;
|
|
|
|
if (messageType === 'OK' && eventId === event.id) {
|
|
clearTimeout(timeout);
|
|
this.ws.onmessage = originalOnMessage;
|
|
|
|
if (success) {
|
|
console.log('Event published successfully:', eventId);
|
|
resolve({ success: true, eventId, reason });
|
|
} else {
|
|
console.error('Event publish failed:', reason);
|
|
|
|
// Check if authentication is required
|
|
if (reason && reason.includes('auth-required')) {
|
|
console.log('Authentication required, attempting to authenticate...');
|
|
try {
|
|
await this.authenticate();
|
|
// Re-send the event after authentication
|
|
const retryMessage = ["EVENT", event];
|
|
this.ws.send(JSON.stringify(retryMessage));
|
|
// Don't resolve yet, wait for the retry response
|
|
return;
|
|
} catch (authError) {
|
|
reject(new Error(`Authentication failed: ${authError.message}`));
|
|
return;
|
|
}
|
|
}
|
|
|
|
reject(new Error(`Publish failed: ${reason}`));
|
|
}
|
|
} else {
|
|
// Handle other messages normally
|
|
await this.handleMessage(data);
|
|
}
|
|
} catch (error) {
|
|
clearTimeout(timeout);
|
|
this.ws.onmessage = originalOnMessage;
|
|
reject(error);
|
|
}
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Close the WebSocket connection
|
|
*/
|
|
close() {
|
|
if (this.ws) {
|
|
this.ws.close();
|
|
this.ws = null;
|
|
}
|
|
this.isAuthenticated = false;
|
|
this.challenge = null;
|
|
}
|
|
|
|
/**
|
|
* Check if currently authenticated
|
|
*/
|
|
getAuthenticated() {
|
|
return this.isAuthenticated;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Convenience function to publish an event with authentication
|
|
*/
|
|
export async function publishEventWithAuth(relayUrl, event, userSigner, userPubkey) {
|
|
const auth = new NostrWebSocketAuth(relayUrl, userSigner, userPubkey);
|
|
|
|
try {
|
|
await auth.connect();
|
|
const result = await auth.publishEvent(event);
|
|
return result;
|
|
} finally {
|
|
auth.close();
|
|
}
|
|
}
|