feat: NIP-98 + JWT authentication with signer support
Replace insecure raw-pubkey auth with cryptographic NIP-98 signed requests and server-issued JWT sessions. Logout now fully clears all state including nsec. Add yellow "Use Nostr Signer" button for Amber/NIP-07 remote signers. - Server: JWT middleware (HMAC-SHA256, 24h expiry), NIP-98 verification - Server: POST /api/auth/nostr/session endpoint - Frontend: NIP-98 token builder + authFetch wrapper with JWT Bearer - Frontend: All authenticated API calls use authFetch - Security: logout clears JWT, pubkey, bot, nsec, and profile pic Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
ad96d1158f
commit
3ba05a66b4
@@ -1,7 +1,8 @@
|
||||
import { ref, readonly, computed } from 'vue'
|
||||
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools'
|
||||
import { generateSecretKey, getPublicKey } from 'nostr-tools'
|
||||
import { bytesToHex, hexToBytes } from 'nostr-tools/utils'
|
||||
import { nsecEncode } from 'nostr-tools/nip19'
|
||||
import { buildNip98Token, setToken, getToken, isTokenExpired, authFetch } from '../lib/nostr-auth'
|
||||
|
||||
interface BotCustomization {
|
||||
archetype?: string
|
||||
@@ -65,7 +66,7 @@ function normalizeBotData(data: Record<string, unknown>): BotData {
|
||||
} as BotData
|
||||
}
|
||||
|
||||
/** Clear all auth-related state */
|
||||
/** Clear all auth-related state including JWT and nsec */
|
||||
function clearAllState() {
|
||||
pubkey.value = null
|
||||
bot.value = null
|
||||
@@ -73,6 +74,8 @@ function clearAllState() {
|
||||
store('bf_pubkey', null)
|
||||
store('bf_bot', null)
|
||||
store('bf_pic', null)
|
||||
setToken(null)
|
||||
localStorage.removeItem('bf_nsec')
|
||||
}
|
||||
|
||||
const pubkey = ref<string | null>(loadStored('bf_pubkey'))
|
||||
@@ -85,89 +88,109 @@ let autoRestoreRan = false
|
||||
// Flag: skip relay pic fetch for freshly generated keys (no profile exists)
|
||||
let freshlyGenerated = false
|
||||
|
||||
// Auto-restore session from JWT on first load
|
||||
if (!autoRestoreRan && pubkey.value && !bot.value && getToken() && !isTokenExpired()) {
|
||||
autoRestoreRan = true
|
||||
authFetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ pubkey: pubkey.value }),
|
||||
}).then(r => r.json()).then(data => {
|
||||
if (data.exists) {
|
||||
bot.value = normalizeBotData(data.bot)
|
||||
store('bf_bot', bot.value)
|
||||
}
|
||||
}).catch(() => {})
|
||||
} else if (!autoRestoreRan && pubkey.value && !getToken()) {
|
||||
// No JWT — clear stale pubkey from before the JWT migration
|
||||
autoRestoreRan = true
|
||||
pubkey.value = null
|
||||
store('bf_pubkey', null)
|
||||
}
|
||||
|
||||
export function useNostr() {
|
||||
const isLoggedIn = computed(() => !!pubkey.value && !!bot.value)
|
||||
const hasExtension = computed(() => !!window.nostr)
|
||||
|
||||
// Restore session on first load — re-verify with server (once only)
|
||||
const autoRestoreController = new AbortController()
|
||||
if (!autoRestoreRan && pubkey.value && !bot.value) {
|
||||
autoRestoreRan = true
|
||||
fetch('/api/auth/login', {
|
||||
/**
|
||||
* Authenticate with the server using NIP-98 signed request.
|
||||
* Works with NIP-07 extension, Amber signer, or local nsec.
|
||||
* Returns JWT session token + bot data.
|
||||
*/
|
||||
async function authenticateSession(secretKeyHex?: string | null): Promise<{ pubkey: string; bot: BotData | null }> {
|
||||
const sessionUrl = new URL('/api/auth/nostr/session', window.location.origin).toString()
|
||||
const nip98Token = await buildNip98Token(sessionUrl, 'POST', secretKeyHex)
|
||||
|
||||
const res = await fetch('/api/auth/nostr/session', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ pubkey: pubkey.value }),
|
||||
signal: autoRestoreController.signal,
|
||||
}).then(r => r.json()).then(data => {
|
||||
if (data.exists) {
|
||||
bot.value = normalizeBotData(data.bot)
|
||||
store('bf_bot', bot.value)
|
||||
}
|
||||
}).catch(() => {})
|
||||
headers: {
|
||||
'Authorization': `Nostr ${nip98Token}`,
|
||||
},
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({ error: 'Authentication failed' }))
|
||||
throw new Error(data.error || 'NIP-98 authentication failed')
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
|
||||
// Store JWT
|
||||
setToken(data.token)
|
||||
|
||||
// Store pubkey
|
||||
pubkey.value = data.pubkey
|
||||
store('bf_pubkey', data.pubkey)
|
||||
|
||||
if (data.exists && data.bot) {
|
||||
bot.value = normalizeBotData(data.bot)
|
||||
store('bf_bot', bot.value)
|
||||
return { pubkey: data.pubkey, bot: bot.value }
|
||||
}
|
||||
|
||||
bot.value = null
|
||||
store('bf_bot', null)
|
||||
return { pubkey: data.pubkey, bot: null }
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign in using NIP-07 extension or Amber signer (window.nostr).
|
||||
* Authenticates via NIP-98 and receives a JWT session.
|
||||
*/
|
||||
async function login(): Promise<{ pubkey: string; bot: BotData | null }> {
|
||||
let pk: string
|
||||
|
||||
if (window.nostr) {
|
||||
try {
|
||||
pk = await window.nostr.getPublicKey()
|
||||
} catch {
|
||||
throw new Error('Nostr extension denied access. Approve the request and try again.')
|
||||
}
|
||||
} else {
|
||||
if (!window.nostr) {
|
||||
// Fall back to stored nsec if available
|
||||
const storedNsec = localStorage.getItem('bf_nsec')
|
||||
if (storedNsec) {
|
||||
try {
|
||||
const secretKey = hexToBytes(storedNsec)
|
||||
pk = getPublicKey(secretKey)
|
||||
} catch {
|
||||
throw new Error('Stored key is corrupt. Generate a new identity.')
|
||||
}
|
||||
} else {
|
||||
throw new Error('No Nostr extension found and no saved key. Use "Generate Login" to create one.')
|
||||
return loginWithNsec(storedNsec)
|
||||
}
|
||||
throw new Error('No Nostr signer found. Install a browser extension or use "Generate New Identity".')
|
||||
}
|
||||
|
||||
// Verify extension is responsive
|
||||
try {
|
||||
await window.nostr.getPublicKey()
|
||||
} catch {
|
||||
throw new Error('Nostr signer denied access. Approve the request and try again.')
|
||||
}
|
||||
|
||||
isLoading.value = true
|
||||
try {
|
||||
pubkey.value = pk
|
||||
store('bf_pubkey', pk)
|
||||
const result = await authenticateSession()
|
||||
|
||||
// Fetch Nostr profile pic from relay (skip for freshly generated keys — no profile exists)
|
||||
if (freshlyGenerated) {
|
||||
freshlyGenerated = false
|
||||
profilePicUrl.value = null
|
||||
store('bf_pic', null)
|
||||
} else {
|
||||
// Non-blocking: start fetch but don't block login on it
|
||||
fetchNostrProfilePic(pk).then(pic => {
|
||||
// Fetch Nostr profile pic (non-blocking)
|
||||
if (!freshlyGenerated) {
|
||||
fetchNostrProfilePic(result.pubkey).then(pic => {
|
||||
profilePicUrl.value = pic
|
||||
store('bf_pic', pic)
|
||||
}).catch(() => {})
|
||||
} else {
|
||||
freshlyGenerated = false
|
||||
profilePicUrl.value = null
|
||||
store('bf_pic', null)
|
||||
}
|
||||
|
||||
// Check if this pubkey has a bot
|
||||
const res = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ pubkey: pk }),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
if (data.exists) {
|
||||
bot.value = normalizeBotData(data.bot)
|
||||
store('bf_bot', bot.value)
|
||||
return { pubkey: pk, bot: bot.value }
|
||||
}
|
||||
}
|
||||
|
||||
// No bot for this key — clear stale state
|
||||
bot.value = null
|
||||
store('bf_bot', null)
|
||||
|
||||
return { pubkey: pk, bot: null }
|
||||
return result
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
@@ -186,7 +209,7 @@ export function useNostr() {
|
||||
// Mark as freshly generated so login() skips relay pic fetch
|
||||
freshlyGenerated = true
|
||||
|
||||
// Store new key
|
||||
// Store new key (will be cleared on logout)
|
||||
localStorage.setItem('bf_nsec', nsecHex)
|
||||
pubkey.value = pk
|
||||
store('bf_pubkey', pk)
|
||||
@@ -194,7 +217,7 @@ export function useNostr() {
|
||||
return { pubkey: pk, nsec: nsecBech32 }
|
||||
}
|
||||
|
||||
/** Login with an existing nsec (hex) */
|
||||
/** Login with an existing nsec (hex). Signs NIP-98 locally. */
|
||||
async function loginWithNsec(nsecHex: string): Promise<{ pubkey: string; bot: BotData | null }> {
|
||||
let secretKey: Uint8Array
|
||||
try {
|
||||
@@ -202,7 +225,7 @@ export function useNostr() {
|
||||
} catch {
|
||||
throw new Error('Invalid secret key format.')
|
||||
}
|
||||
const pk = getPublicKey(secretKey)
|
||||
getPublicKey(secretKey) // validate key
|
||||
|
||||
// Clear stale state before switching identity
|
||||
bot.value = null
|
||||
@@ -211,33 +234,18 @@ export function useNostr() {
|
||||
store('bf_pic', null)
|
||||
|
||||
localStorage.setItem('bf_nsec', nsecHex)
|
||||
pubkey.value = pk
|
||||
store('bf_pubkey', pk)
|
||||
|
||||
isLoading.value = true
|
||||
try {
|
||||
const result = await authenticateSession(nsecHex)
|
||||
|
||||
// Fetch Nostr profile pic (non-blocking)
|
||||
fetchNostrProfilePic(pk).then(pic => {
|
||||
fetchNostrProfilePic(result.pubkey).then(pic => {
|
||||
profilePicUrl.value = pic
|
||||
store('bf_pic', pic)
|
||||
}).catch(() => {})
|
||||
|
||||
const res = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ pubkey: pk }),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
if (data.exists) {
|
||||
bot.value = normalizeBotData(data.bot)
|
||||
store('bf_bot', bot.value)
|
||||
return { pubkey: pk, bot: bot.value }
|
||||
}
|
||||
}
|
||||
|
||||
return { pubkey: pk, bot: null }
|
||||
return result
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
@@ -246,7 +254,7 @@ export function useNostr() {
|
||||
async function registerBot(name: string, webhookUrl: string, archetype: string): Promise<BotData> {
|
||||
if (!pubkey.value) throw new Error('Not logged in')
|
||||
|
||||
const res = await fetch('/api/auth/register', {
|
||||
const res = await authFetch('/api/auth/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -260,7 +268,6 @@ export function useNostr() {
|
||||
|
||||
const data = await res.json()
|
||||
if (!res.ok) {
|
||||
// Include retry timer info for rate limits
|
||||
let msg = data.details ? `${data.error} ${data.details}` : (data.error || 'Registration failed')
|
||||
if (res.status === 429 && data.retryAfterSec) msg += ` (${data.retryAfterSec}s)`
|
||||
throw new Error(msg)
|
||||
@@ -285,13 +292,21 @@ export function useNostr() {
|
||||
}
|
||||
store('bf_bot', bot.value)
|
||||
|
||||
// Re-authenticate to get fresh JWT with botId
|
||||
try {
|
||||
const nsec = localStorage.getItem('bf_nsec')
|
||||
await authenticateSession(nsec)
|
||||
} catch {
|
||||
// Non-critical: existing JWT still works, just missing botId
|
||||
}
|
||||
|
||||
return bot.value
|
||||
}
|
||||
|
||||
async function updateCustomization(customization: BotCustomization): Promise<void> {
|
||||
if (!pubkey.value) throw new Error('Not logged in')
|
||||
|
||||
const res = await fetch('/api/auth/update', {
|
||||
const res = await authFetch('/api/auth/update', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ pubkey: pubkey.value, customization }),
|
||||
@@ -315,7 +330,7 @@ export function useNostr() {
|
||||
async function updateWebhook(newUrl: string): Promise<{ latencyMs: number }> {
|
||||
if (!pubkey.value) throw new Error('Not logged in')
|
||||
|
||||
const res = await fetch('/api/auth/update', {
|
||||
const res = await authFetch('/api/auth/update', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ pubkey: pubkey.value, webhookUrl: newUrl }),
|
||||
@@ -333,7 +348,7 @@ export function useNostr() {
|
||||
async function registerHuman(name: string, avatarSeed?: string): Promise<BotData> {
|
||||
if (!pubkey.value) throw new Error('Not logged in')
|
||||
|
||||
const res = await fetch('/api/auth/register-human', {
|
||||
const res = await authFetch('/api/auth/register-human', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -371,13 +386,20 @@ export function useNostr() {
|
||||
}
|
||||
store('bf_bot', bot.value)
|
||||
|
||||
// Re-authenticate to get fresh JWT with botId
|
||||
try {
|
||||
const nsec = localStorage.getItem('bf_nsec')
|
||||
await authenticateSession(nsec)
|
||||
} catch {
|
||||
// Non-critical
|
||||
}
|
||||
|
||||
return bot.value
|
||||
}
|
||||
|
||||
/** Sign out — clears everything including JWT, nsec, and all stored state */
|
||||
function logout() {
|
||||
autoRestoreController.abort()
|
||||
clearAllState()
|
||||
// Don't clear bf_nsec on logout — user may want to log back in
|
||||
}
|
||||
|
||||
/** Check if user has a locally stored key (no extension needed) */
|
||||
|
||||
@@ -4,6 +4,7 @@ import { finalizeEvent } from 'nostr-tools'
|
||||
import * as nip04 from 'nostr-tools/nip04'
|
||||
import * as nip44 from 'nostr-tools/nip44'
|
||||
import { hexToBytes, bytesToHex } from 'nostr-tools/utils'
|
||||
import { authFetch } from '../lib/nostr-auth'
|
||||
|
||||
type WalletMethod = 'nwc' | 'lnaddress' | null
|
||||
type PaymentStatus = 'idle' | 'invoiced' | 'paying' | 'confirmed' | 'failed'
|
||||
@@ -59,7 +60,7 @@ export function useWallet() {
|
||||
// Validate the NWC URL format
|
||||
parseNwcUrl(connectionString)
|
||||
|
||||
const res = await fetch('/api/payments/connect-wallet', {
|
||||
const res = await authFetch('/api/payments/connect-wallet', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -88,7 +89,7 @@ export function useWallet() {
|
||||
throw new Error('Invalid Lightning Address format')
|
||||
}
|
||||
|
||||
const res = await fetch('/api/payments/connect-wallet', {
|
||||
const res = await authFetch('/api/payments/connect-wallet', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -111,7 +112,7 @@ export function useWallet() {
|
||||
async function disconnectWallet(): Promise<void> {
|
||||
if (!pubkey.value) return
|
||||
|
||||
await fetch('/api/payments/disconnect-wallet', {
|
||||
await authFetch('/api/payments/disconnect-wallet', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ pubkey: pubkey.value }),
|
||||
@@ -128,7 +129,7 @@ export function useWallet() {
|
||||
async function checkWalletStatus(): Promise<void> {
|
||||
if (!pubkey.value) return
|
||||
|
||||
const res = await fetch(`/api/payments/wallet-status?pubkey=${pubkey.value}`)
|
||||
const res = await authFetch(`/api/payments/wallet-status?pubkey=${pubkey.value}`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
isWalletConnected.value = data.connected
|
||||
@@ -144,7 +145,7 @@ export function useWallet() {
|
||||
|
||||
try {
|
||||
// Create invoice
|
||||
const invoiceRes = await fetch('/api/payments/create-invoice', {
|
||||
const invoiceRes = await authFetch('/api/payments/create-invoice', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ botId, pubkey: pubkey.value }),
|
||||
@@ -175,7 +176,7 @@ export function useWallet() {
|
||||
const preimage = await payViaNWC(nwcUrl, bolt11)
|
||||
|
||||
// Tell server payment is confirmed (skip lookup_invoice polling)
|
||||
await fetch(`/api/payments/confirm/${paymentId}`, {
|
||||
await authFetch(`/api/payments/confirm/${paymentId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ preimage, pubkey: pubkey.value }),
|
||||
@@ -187,7 +188,7 @@ export function useWallet() {
|
||||
// No NWC — poll for confirmation (manual payment / QR code flow)
|
||||
for (let i = 0; i < 30; i++) {
|
||||
await new Promise(r => setTimeout(r, 2000))
|
||||
const checkRes = await fetch(`/api/payments/check/${paymentId}`)
|
||||
const checkRes = await authFetch(`/api/payments/check/${paymentId}`)
|
||||
if (checkRes.ok) {
|
||||
const { status } = await checkRes.json()
|
||||
if (status === 'confirmed') {
|
||||
@@ -212,7 +213,7 @@ export function useWallet() {
|
||||
}
|
||||
|
||||
async function submitCashuToken(botId: string, token: string): Promise<string> {
|
||||
const res = await fetch('/api/payments/submit-cashu', {
|
||||
const res = await authFetch('/api/payments/submit-cashu', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ botId, token }),
|
||||
|
||||
Reference in New Issue
Block a user