Some checks are pending
CI Pipeline / Run Tests (push) Waiting to run
CI Pipeline / Lint Code (push) Waiting to run
CI Pipeline / Security Scan (push) Waiting to run
CI Pipeline / Build Docker Images (push) Blocked by required conditions
CI Pipeline / E2E Tests (push) Blocked by required conditions
364 lines
12 KiB
JavaScript
364 lines
12 KiB
JavaScript
// Nostr Authentication Module
|
|
class NostrAuth {
|
|
constructor() {
|
|
this.sessionToken = localStorage.getItem('session_token');
|
|
this.pubkey = localStorage.getItem('user_pubkey');
|
|
}
|
|
|
|
// Check if user is authenticated
|
|
isAuthenticated() {
|
|
return !!this.sessionToken && !!this.pubkey;
|
|
}
|
|
|
|
// Get current user pubkey
|
|
getCurrentUser() {
|
|
return this.pubkey;
|
|
}
|
|
|
|
// NIP-07 Login via browser extension (Alby, nos2x, etc.)
|
|
async loginNIP07() {
|
|
try {
|
|
if (!window.nostr) {
|
|
return {
|
|
success: false,
|
|
message: 'No Nostr extension detected. Please install a Nostr browser extension like Alby or nos2x, then refresh the page.'
|
|
};
|
|
}
|
|
|
|
// Get challenge from server
|
|
const challengeResponse = await fetch('/api/auth/challenge');
|
|
|
|
if (!challengeResponse.ok) {
|
|
if (challengeResponse.status === 429) {
|
|
return {
|
|
success: false,
|
|
message: 'Too many login attempts. Please wait a moment and try again.'
|
|
};
|
|
}
|
|
return {
|
|
success: false,
|
|
message: 'Server unavailable. Please try again later.'
|
|
};
|
|
}
|
|
|
|
const challengeData = await challengeResponse.json();
|
|
if (!challengeData.challenge) {
|
|
return {
|
|
success: false,
|
|
message: 'Invalid server response. Please try again.'
|
|
};
|
|
}
|
|
|
|
// Get pubkey from extension
|
|
let pubkey;
|
|
try {
|
|
pubkey = await window.nostr.getPublicKey();
|
|
} catch (error) {
|
|
return {
|
|
success: false,
|
|
message: 'Extension denied access or is locked. Please unlock your Nostr extension and try again.'
|
|
};
|
|
}
|
|
|
|
if (!pubkey || pubkey.length !== 64) {
|
|
return {
|
|
success: false,
|
|
message: 'Invalid public key from extension. Please check your Nostr extension setup.'
|
|
};
|
|
}
|
|
|
|
// Create authentication event (using kind 27235 for HTTP auth per NIP-98)
|
|
const authEvent = {
|
|
kind: 27235,
|
|
created_at: Math.floor(Date.now() / 1000),
|
|
tags: [
|
|
['u', window.location.origin + '/api/auth/login'],
|
|
['method', 'POST'],
|
|
['challenge', challengeData.challenge]
|
|
],
|
|
content: '',
|
|
pubkey: pubkey
|
|
};
|
|
|
|
// Sign the event
|
|
let signedEvent;
|
|
try {
|
|
signedEvent = await window.nostr.signEvent(authEvent);
|
|
} catch (error) {
|
|
return {
|
|
success: false,
|
|
message: 'Signing was cancelled or failed. Please try again and approve the signature request.'
|
|
};
|
|
}
|
|
|
|
if (!signedEvent || !signedEvent.sig) {
|
|
return {
|
|
success: false,
|
|
message: 'Invalid signature from extension. Please try again.'
|
|
};
|
|
}
|
|
|
|
// Send to server for validation
|
|
const loginResponse = await fetch('/api/auth/login', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
auth_type: 'nip07',
|
|
auth_event: JSON.stringify({
|
|
event: signedEvent,
|
|
challenge: challengeData.challenge
|
|
})
|
|
})
|
|
});
|
|
|
|
const loginData = await loginResponse.json();
|
|
|
|
if (!loginResponse.ok) {
|
|
if (loginResponse.status === 429) {
|
|
return {
|
|
success: false,
|
|
message: 'Too many login attempts. Please wait a minute and try again.'
|
|
};
|
|
} else if (loginResponse.status === 401) {
|
|
return {
|
|
success: false,
|
|
message: 'Authentication failed. Please check your Nostr extension and try again.'
|
|
};
|
|
} else if (loginResponse.status >= 500) {
|
|
return {
|
|
success: false,
|
|
message: 'Server error. Please try again later.'
|
|
};
|
|
}
|
|
return {
|
|
success: false,
|
|
message: loginData.message || 'Login failed. Please try again.'
|
|
};
|
|
}
|
|
|
|
// Store session info
|
|
this.sessionToken = loginData.session_token;
|
|
this.pubkey = loginData.pubkey;
|
|
localStorage.setItem('session_token', this.sessionToken);
|
|
localStorage.setItem('user_pubkey', this.pubkey);
|
|
|
|
return {
|
|
success: true,
|
|
pubkey: this.pubkey,
|
|
message: 'Successfully logged in via NIP-07'
|
|
};
|
|
|
|
} catch (error) {
|
|
console.error('NIP-07 login failed:', error);
|
|
return {
|
|
success: false,
|
|
message: error.message
|
|
};
|
|
}
|
|
}
|
|
|
|
// NIP-46 Login via bunker URL
|
|
async loginNIP46(bunkerURL) {
|
|
try {
|
|
if (!bunkerURL || (!bunkerURL.startsWith('bunker://') && !bunkerURL.startsWith('nostrconnect://'))) {
|
|
throw new Error('Invalid bunker URL format. Expected: bunker://... or nostrconnect://...');
|
|
}
|
|
|
|
// Send bunker URL to server for validation
|
|
const loginResponse = await fetch('/api/auth/login', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
auth_type: 'nip46',
|
|
bunker_url: bunkerURL
|
|
})
|
|
});
|
|
|
|
const loginData = await loginResponse.json();
|
|
|
|
if (!loginResponse.ok) {
|
|
throw new Error(loginData.message || 'NIP-46 login failed');
|
|
}
|
|
|
|
// Store session info
|
|
this.sessionToken = loginData.session_token;
|
|
this.pubkey = loginData.pubkey;
|
|
localStorage.setItem('session_token', this.sessionToken);
|
|
localStorage.setItem('user_pubkey', this.pubkey);
|
|
|
|
return {
|
|
success: true,
|
|
pubkey: this.pubkey,
|
|
message: 'Successfully logged in via NIP-46'
|
|
};
|
|
|
|
} catch (error) {
|
|
console.error('NIP-46 login failed:', error);
|
|
return {
|
|
success: false,
|
|
message: error.message
|
|
};
|
|
}
|
|
}
|
|
|
|
// Logout
|
|
async logout() {
|
|
try {
|
|
await fetch('/api/auth/logout', {
|
|
method: 'POST',
|
|
credentials: 'include'
|
|
});
|
|
} catch (error) {
|
|
console.error('Logout request failed:', error);
|
|
}
|
|
|
|
// Clear local storage
|
|
this.sessionToken = null;
|
|
this.pubkey = null;
|
|
localStorage.removeItem('session_token');
|
|
localStorage.removeItem('user_pubkey');
|
|
}
|
|
|
|
// Get user statistics
|
|
async getUserStats() {
|
|
if (!this.isAuthenticated()) {
|
|
throw new Error('Not authenticated');
|
|
}
|
|
|
|
const response = await fetch('/api/users/me/stats', {
|
|
credentials: 'include',
|
|
headers: {
|
|
'Authorization': `Bearer ${this.sessionToken}`
|
|
}
|
|
});
|
|
|
|
if (!response.ok) {
|
|
if (response.status === 401 || response.status === 403) {
|
|
// Clear invalid session data
|
|
this.sessionToken = null;
|
|
this.pubkey = null;
|
|
localStorage.removeItem('session_token');
|
|
localStorage.removeItem('user_pubkey');
|
|
throw new Error(`${response.status} Unauthorized - session expired`);
|
|
}
|
|
throw new Error(`Failed to get user stats (${response.status})`);
|
|
}
|
|
|
|
return await response.json();
|
|
}
|
|
|
|
// Get user's files
|
|
async getUserFiles() {
|
|
if (!this.isAuthenticated()) {
|
|
throw new Error('Not authenticated');
|
|
}
|
|
|
|
const response = await fetch('/api/users/me/files', {
|
|
credentials: 'include',
|
|
headers: {
|
|
'Authorization': `Bearer ${this.sessionToken}`
|
|
}
|
|
});
|
|
|
|
if (!response.ok) {
|
|
if (response.status === 401 || response.status === 403) {
|
|
// Clear invalid session data
|
|
this.sessionToken = null;
|
|
this.pubkey = null;
|
|
localStorage.removeItem('session_token');
|
|
localStorage.removeItem('user_pubkey');
|
|
throw new Error(`${response.status} Unauthorized - session expired`);
|
|
}
|
|
throw new Error(`Failed to get user files (${response.status})`);
|
|
}
|
|
|
|
return await response.json();
|
|
}
|
|
|
|
// Delete a file
|
|
async deleteFile(hash) {
|
|
if (!this.isAuthenticated()) {
|
|
throw new Error('Not authenticated');
|
|
}
|
|
|
|
const response = await fetch(`/api/users/me/files/${hash}`, {
|
|
method: 'DELETE',
|
|
credentials: 'include',
|
|
headers: {
|
|
'Authorization': `Bearer ${this.sessionToken}`
|
|
}
|
|
});
|
|
|
|
if (!response.ok) {
|
|
if (response.status === 401 || response.status === 403) {
|
|
// Clear invalid session data
|
|
this.sessionToken = null;
|
|
this.pubkey = null;
|
|
localStorage.removeItem('session_token');
|
|
localStorage.removeItem('user_pubkey');
|
|
throw new Error(`${response.status} Unauthorized - session expired`);
|
|
}
|
|
const errorData = await response.text();
|
|
throw new Error(errorData || `Failed to delete file (${response.status})`);
|
|
}
|
|
|
|
return await response.json();
|
|
}
|
|
|
|
// Update file access level
|
|
async updateFileAccess(hash, accessLevel) {
|
|
if (!this.isAuthenticated()) {
|
|
throw new Error('Not authenticated');
|
|
}
|
|
|
|
const response = await fetch(`/api/users/me/files/${hash}/access`, {
|
|
method: 'PUT',
|
|
credentials: 'include',
|
|
headers: {
|
|
'Authorization': `Bearer ${this.sessionToken}`,
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({ access_level: accessLevel })
|
|
});
|
|
|
|
if (!response.ok) {
|
|
if (response.status === 401 || response.status === 403) {
|
|
// Clear invalid session data
|
|
this.sessionToken = null;
|
|
this.pubkey = null;
|
|
localStorage.removeItem('session_token');
|
|
localStorage.removeItem('user_pubkey');
|
|
throw new Error(`${response.status} Unauthorized - session expired`);
|
|
}
|
|
const errorData = await response.text();
|
|
throw new Error(errorData || `Failed to update file access (${response.status})`);
|
|
}
|
|
|
|
return await response.json();
|
|
}
|
|
|
|
// Make authenticated request
|
|
async makeAuthenticatedRequest(url, options = {}) {
|
|
if (!this.isAuthenticated()) {
|
|
throw new Error('Not authenticated');
|
|
}
|
|
|
|
const authOptions = {
|
|
...options,
|
|
credentials: 'include',
|
|
headers: {
|
|
...options.headers,
|
|
'Authorization': `Bearer ${this.sessionToken}`
|
|
}
|
|
};
|
|
|
|
return fetch(url, authOptions);
|
|
}
|
|
}
|
|
|
|
// Global instance
|
|
window.nostrAuth = new NostrAuth(); |