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
@@ -2,6 +2,7 @@
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useWallet } from '../composables/useWallet'
|
||||
import { useNostr } from '../composables/useNostr'
|
||||
import { authFetch } from '../lib/nostr-auth'
|
||||
|
||||
const props = defineProps<{
|
||||
fightId: string
|
||||
@@ -65,7 +66,7 @@ async function placeBet() {
|
||||
body.cashuToken = cashuToken.value.trim()
|
||||
}
|
||||
|
||||
const res = await fetch('/api/bets/place', {
|
||||
const res = await authFetch('/api/bets/place', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* NIP-98 HTTP Auth + JWT session management.
|
||||
*
|
||||
* - buildNip98Token: Creates a signed NIP-98 (kind 27235) event for HTTP auth
|
||||
* - authFetch: Wrapper around fetch() that attaches JWT Bearer token
|
||||
* - Token storage: JWT persisted in localStorage as 'bf_token'
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JWT Token Storage
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let currentToken: string | null = localStorage.getItem('bf_token')
|
||||
|
||||
export function getToken(): string | null {
|
||||
return currentToken
|
||||
}
|
||||
|
||||
export function setToken(token: string | null) {
|
||||
currentToken = token
|
||||
if (token) localStorage.setItem('bf_token', token)
|
||||
else localStorage.removeItem('bf_token')
|
||||
}
|
||||
|
||||
/** Check if stored JWT is expired (without validating signature) */
|
||||
export function isTokenExpired(): boolean {
|
||||
if (!currentToken) return true
|
||||
try {
|
||||
const parts = currentToken.split('.')
|
||||
if (parts.length !== 3) return true
|
||||
const payload = JSON.parse(atob(parts[1]))
|
||||
if (!payload.exp) return true
|
||||
return payload.exp <= Math.floor(Date.now() / 1000)
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// NIP-98 Token Builder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface NostrSigner {
|
||||
getPublicKey(): Promise<string>
|
||||
signEvent(event: Record<string, unknown>): Promise<Record<string, unknown>>
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a NIP-98 HTTP Auth token (base64-encoded signed kind 27235 event).
|
||||
*
|
||||
* Uses window.nostr (NIP-07 extension / Amber) if available,
|
||||
* otherwise signs locally with the provided secret key bytes.
|
||||
*/
|
||||
export async function buildNip98Token(
|
||||
url: string,
|
||||
method: string,
|
||||
secretKeyHex?: string | null,
|
||||
): Promise<string> {
|
||||
const unsignedEvent = {
|
||||
kind: 27235,
|
||||
tags: [
|
||||
['u', url],
|
||||
['method', method.toUpperCase()],
|
||||
],
|
||||
content: '',
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
}
|
||||
|
||||
let signedEvent: Record<string, unknown>
|
||||
|
||||
const signer = (window as { nostr?: NostrSigner }).nostr
|
||||
if (signer) {
|
||||
// Sign via NIP-07 extension or Amber
|
||||
signedEvent = await signer.signEvent(unsignedEvent)
|
||||
} else if (secretKeyHex) {
|
||||
// Sign locally with secret key
|
||||
const { hexToBytes } = await import('nostr-tools/utils')
|
||||
const { finalizeEvent } = await import('nostr-tools')
|
||||
const sk = hexToBytes(secretKeyHex)
|
||||
const event = finalizeEvent(unsignedEvent, sk)
|
||||
signedEvent = event as unknown as Record<string, unknown>
|
||||
} else {
|
||||
throw new Error('No Nostr signer available. Install a NIP-07 extension or use a saved key.')
|
||||
}
|
||||
|
||||
return btoa(JSON.stringify(signedEvent))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Authenticated Fetch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Fetch wrapper that attaches the JWT Bearer token to requests.
|
||||
* If the server returns 401, clears the stored token.
|
||||
*/
|
||||
export async function authFetch(url: string, init?: RequestInit): Promise<Response> {
|
||||
const headers = new Headers(init?.headers)
|
||||
if (currentToken && !isTokenExpired()) {
|
||||
headers.set('Authorization', `Bearer ${currentToken}`)
|
||||
}
|
||||
const res = await fetch(url, { ...init, headers })
|
||||
if (res.status === 401) {
|
||||
// Token rejected — clear it
|
||||
setToken(null)
|
||||
}
|
||||
return res
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import SpritePreview from '../components/SpritePreview.vue'
|
||||
import { ensureAudioContext } from '../game/audio'
|
||||
import HumanPreview from '../components/HumanPreview.vue'
|
||||
import WalletConnect from '../components/WalletConnect.vue'
|
||||
import { authFetch } from '../lib/nostr-auth'
|
||||
|
||||
const router = useRouter()
|
||||
const { pubkey, bot, profilePicUrl, isLoggedIn, hasExtension, hasStoredKey, isLoading, login, generateLogin, loginWithNsec, registerBot, registerHuman, getStoredNsec, logout } = useNostr()
|
||||
@@ -184,6 +185,14 @@ async function handleLogin() {
|
||||
}
|
||||
}
|
||||
|
||||
function handleSignerLogin() {
|
||||
if (!window.nostr) {
|
||||
error.value = 'No Nostr signer detected. Install a NIP-07 extension (Nos2x, Alby) or use Amber on Android.'
|
||||
return
|
||||
}
|
||||
handleLogin()
|
||||
}
|
||||
|
||||
function handleGenerateLogin() {
|
||||
error.value = ''
|
||||
const { nsec } = generateLogin()
|
||||
@@ -432,7 +441,7 @@ async function fightRanked() {
|
||||
error.value = ''
|
||||
try {
|
||||
const paymentId = await payEntryFee(bot.value.id)
|
||||
const res = await fetch(`/api/queue/join-ranked/${bot.value.id}`, {
|
||||
const res = await authFetch(`/api/queue/join-ranked/${bot.value.id}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ paymentId, pubkey: pubkey.value }),
|
||||
@@ -540,7 +549,7 @@ function handleSignOut() {
|
||||
</div>
|
||||
|
||||
<div v-if="!showNsecBackup" class="space-y-3">
|
||||
<!-- Sign in with extension -->
|
||||
<!-- Sign in with extension (NIP-07) -->
|
||||
<button
|
||||
v-if="hasExtension"
|
||||
class="w-full py-4 bg-neon-purple/10 border-2 border-neon-purple/50 text-neon-purple
|
||||
@@ -552,7 +561,21 @@ function handleSignOut() {
|
||||
@click="handleLogin"
|
||||
>
|
||||
<span v-if="isLoading" class="w-5 h-5 border-2 border-neon-purple/30 border-t-neon-purple rounded-full animate-spin" />
|
||||
{{ isLoading ? 'CONNECTING...' : 'SIGN IN WITH NOSTR' }}
|
||||
{{ isLoading ? 'CONNECTING...' : 'SIGN IN WITH EXTENSION' }}
|
||||
</button>
|
||||
|
||||
<!-- Sign in with Nostr signer -->
|
||||
<button
|
||||
class="w-full py-4 bg-neon-yellow/10 border-2 border-neon-yellow/50 text-neon-yellow
|
||||
font-display font-black text-base tracking-widest
|
||||
hover:bg-neon-yellow/20 hover:border-neon-yellow transition-all
|
||||
disabled:opacity-50 disabled:cursor-wait
|
||||
flex items-center justify-center gap-3"
|
||||
:disabled="isLoading"
|
||||
@click="handleSignerLogin"
|
||||
>
|
||||
<span v-if="isLoading" class="w-5 h-5 border-2 border-neon-yellow/30 border-t-neon-yellow rounded-full animate-spin" />
|
||||
{{ isLoading ? 'CONNECTING...' : 'USE NOSTR SIGNER' }}
|
||||
</button>
|
||||
|
||||
<!-- Sign in with stored key -->
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { createHmac, randomBytes } from 'crypto'
|
||||
import { logger } from '../lib/logger.js'
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || randomBytes(32).toString('hex')
|
||||
const JWT_EXPIRY = 24 * 60 * 60 // 24 hours
|
||||
|
||||
if (!process.env.JWT_SECRET) {
|
||||
logger.warn('jwt', 'JWT_SECRET not set — tokens will invalidate on server restart')
|
||||
}
|
||||
|
||||
interface JwtPayload {
|
||||
sub: string // Nostr pubkey (hex)
|
||||
botId?: string // Bot ID if registered
|
||||
iat: number // Issued at
|
||||
exp: number // Expiration
|
||||
}
|
||||
|
||||
function b64url(data: string | Buffer): string {
|
||||
const buf = typeof data === 'string' ? Buffer.from(data) : data
|
||||
return buf.toString('base64url')
|
||||
}
|
||||
|
||||
export function createJwt(pubkey: string, botId?: string): string {
|
||||
const header = b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const payload: JwtPayload = {
|
||||
sub: pubkey,
|
||||
...(botId ? { botId } : {}),
|
||||
iat: now,
|
||||
exp: now + JWT_EXPIRY,
|
||||
}
|
||||
const payloadStr = b64url(JSON.stringify(payload))
|
||||
const signature = createHmac('sha256', JWT_SECRET)
|
||||
.update(`${header}.${payloadStr}`)
|
||||
.digest('base64url')
|
||||
|
||||
return `${header}.${payloadStr}.${signature}`
|
||||
}
|
||||
|
||||
export function verifyJwt(token: string): JwtPayload | null {
|
||||
try {
|
||||
const parts = token.split('.')
|
||||
if (parts.length !== 3) return null
|
||||
|
||||
const [header, payload, signature] = parts
|
||||
const expectedSig = createHmac('sha256', JWT_SECRET)
|
||||
.update(`${header}.${payload}`)
|
||||
.digest('base64url')
|
||||
|
||||
if (signature !== expectedSig) return null
|
||||
|
||||
const decoded = JSON.parse(Buffer.from(payload, 'base64url').toString()) as JwtPayload
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
if (decoded.exp && decoded.exp <= now) return null
|
||||
|
||||
return decoded
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Extract pubkey from JWT Bearer token in Authorization header, or return null */
|
||||
export function extractPubkeyFromAuth(authHeader: string | undefined): string | null {
|
||||
if (!authHeader?.startsWith('Bearer ')) return null
|
||||
const payload = verifyJwt(authHeader.slice(7))
|
||||
return payload?.sub || null
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { verifyEvent } from 'nostr-tools'
|
||||
|
||||
interface Nip98Result {
|
||||
valid: boolean
|
||||
pubkey?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a NIP-98 HTTP Auth event from the Authorization or nostr-authorization header.
|
||||
* Expects format: "Nostr <base64-encoded-signed-event>"
|
||||
*
|
||||
* Validates:
|
||||
* - Event kind is 27235
|
||||
* - URL path matches the request path
|
||||
* - HTTP method matches
|
||||
* - created_at is within 120 seconds of now
|
||||
* - Schnorr signature is valid
|
||||
*/
|
||||
export function verifyNip98Token(
|
||||
authHeader: string,
|
||||
requestPath: string,
|
||||
requestMethod: string,
|
||||
): Nip98Result {
|
||||
try {
|
||||
const match = authHeader.match(/^Nostr\s+(.+)$/i)
|
||||
if (!match) return { valid: false, error: 'Invalid auth header format' }
|
||||
|
||||
let eventJson: string
|
||||
try {
|
||||
eventJson = Buffer.from(match[1], 'base64').toString('utf-8')
|
||||
} catch {
|
||||
return { valid: false, error: 'Invalid base64 encoding' }
|
||||
}
|
||||
|
||||
const event = JSON.parse(eventJson)
|
||||
|
||||
// Verify kind 27235
|
||||
if (event.kind !== 27235) {
|
||||
return { valid: false, error: 'Wrong event kind (expected 27235)' }
|
||||
}
|
||||
|
||||
// Verify URL tag — compare path component only (works behind proxies)
|
||||
const urlTag = event.tags?.find((t: string[]) => t[0] === 'u')
|
||||
if (!urlTag || !urlTag[1]) {
|
||||
return { valid: false, error: 'Missing URL tag' }
|
||||
}
|
||||
try {
|
||||
const eventPath = new URL(urlTag[1]).pathname
|
||||
if (eventPath !== requestPath) {
|
||||
return { valid: false, error: `URL path mismatch: ${eventPath} !== ${requestPath}` }
|
||||
}
|
||||
} catch {
|
||||
return { valid: false, error: 'Invalid URL in event tag' }
|
||||
}
|
||||
|
||||
// Verify method tag
|
||||
const methodTag = event.tags?.find((t: string[]) => t[0] === 'method')
|
||||
if (!methodTag || methodTag[1].toUpperCase() !== requestMethod.toUpperCase()) {
|
||||
return { valid: false, error: 'Method mismatch' }
|
||||
}
|
||||
|
||||
// Verify created_at is recent (within 120 seconds)
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
if (Math.abs(now - event.created_at) > 120) {
|
||||
return { valid: false, error: 'Event expired or clock drift too large' }
|
||||
}
|
||||
|
||||
// Verify schnorr signature
|
||||
if (!verifyEvent(event)) {
|
||||
return { valid: false, error: 'Invalid signature' }
|
||||
}
|
||||
|
||||
return { valid: true, pubkey: event.pubkey }
|
||||
} catch (err) {
|
||||
return { valid: false, error: 'Failed to parse NIP-98 token' }
|
||||
}
|
||||
}
|
||||
@@ -361,3 +361,130 @@ authRouter.post('/update', rateLimit(60_000, 10), async (c) => {
|
||||
|
||||
return c.json({ updated: true })
|
||||
})
|
||||
|
||||
// --- NIP-98 Authenticated Session ---
|
||||
import { verifyNip98Token } from '../middleware/nip98.js'
|
||||
import { createJwt, extractPubkeyFromAuth } from '../middleware/jwt.js'
|
||||
|
||||
// POST /nostr/session — authenticate with NIP-98, receive JWT
|
||||
authRouter.post('/nostr/session', rateLimit(60_000, 30), async (c) => {
|
||||
// Extract NIP-98 token from headers (try multiple header names)
|
||||
const authHeader = c.req.header('Authorization')
|
||||
|| c.req.header('nostr-authorization')
|
||||
|| c.req.header('x-nostr-authorization')
|
||||
|
||||
if (!authHeader) {
|
||||
return c.json({ error: 'Missing NIP-98 authorization header' }, 401)
|
||||
}
|
||||
|
||||
// Verify the NIP-98 event signature, URL, method, and freshness
|
||||
const requestPath = new URL(c.req.url).pathname
|
||||
const result = verifyNip98Token(authHeader, requestPath, 'POST')
|
||||
|
||||
if (!result.valid || !result.pubkey) {
|
||||
return c.json({ error: result.error || 'NIP-98 verification failed' }, 401)
|
||||
}
|
||||
|
||||
const pubkey = result.pubkey
|
||||
|
||||
// Look up bot for this pubkey
|
||||
const rows = await db.select({
|
||||
id: schema.bots.id,
|
||||
name: schema.bots.name,
|
||||
avatarSeed: schema.bots.avatarSeed,
|
||||
archetype: schema.bots.archetype,
|
||||
profilePicUrl: schema.bots.profilePicUrl,
|
||||
eloRating: schema.bots.eloRating,
|
||||
wins: schema.bots.wins,
|
||||
losses: schema.bots.losses,
|
||||
winStreak: schema.bots.winStreak,
|
||||
bestStreak: schema.bots.bestStreak,
|
||||
tier: schema.bots.tier,
|
||||
isActive: schema.bots.isActive,
|
||||
customization: schema.bots.customization,
|
||||
webhookUrl: schema.bots.webhookUrl,
|
||||
satsWon: schema.bots.satsWon,
|
||||
satsWagered: schema.bots.satsWagered,
|
||||
}).from(schema.bots).where(eq(schema.bots.publicKey, pubkey)).limit(1)
|
||||
|
||||
let botData = null
|
||||
let botId: string | undefined
|
||||
|
||||
if (rows.length > 0) {
|
||||
const bot = rows[0]
|
||||
botId = bot.id
|
||||
const isHuman = bot.webhookUrl === 'http://human.local/'
|
||||
|
||||
// Auto-upgrade creator archetype
|
||||
if (pubkey === CREATOR_PUBKEY && bot.archetype !== "the_creator") {
|
||||
await db.update(schema.bots).set({ archetype: "the_creator" }).where(eq(schema.bots.id, bot.id))
|
||||
bot.archetype = "the_creator"
|
||||
}
|
||||
|
||||
botData = {
|
||||
id: bot.id,
|
||||
name: bot.name,
|
||||
avatarSeed: bot.avatarSeed,
|
||||
archetype: bot.archetype,
|
||||
profilePicUrl: bot.profilePicUrl,
|
||||
eloRating: bot.eloRating,
|
||||
wins: bot.wins,
|
||||
losses: bot.losses,
|
||||
winStreak: bot.winStreak,
|
||||
bestStreak: bot.bestStreak,
|
||||
tier: bot.tier,
|
||||
isActive: bot.isActive,
|
||||
isHuman,
|
||||
customization: bot.customization ? JSON.parse(bot.customization) : null,
|
||||
satsWon: bot.satsWon ?? 0,
|
||||
satsWagered: bot.satsWagered ?? 0,
|
||||
hasWallet: false,
|
||||
}
|
||||
} else if (pubkey === CREATOR_PUBKEY) {
|
||||
// Auto-create creator
|
||||
const id = nanoid(12)
|
||||
const secret = randomBytes(32).toString('hex')
|
||||
await db.insert(schema.bots).values({
|
||||
id,
|
||||
name: 'the_creator',
|
||||
webhookUrl: 'http://human.local/',
|
||||
avatarSeed: 'the_creator',
|
||||
archetype: 'the_creator',
|
||||
secretHash: createHash('sha256').update(secret).digest('hex'),
|
||||
publicKey: pubkey,
|
||||
profilePicUrl: null,
|
||||
customization: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
})
|
||||
botId = id
|
||||
botData = {
|
||||
id,
|
||||
name: 'the_creator',
|
||||
avatarSeed: 'the_creator',
|
||||
archetype: 'the_creator',
|
||||
profilePicUrl: null,
|
||||
eloRating: 1200,
|
||||
wins: 0,
|
||||
losses: 0,
|
||||
winStreak: 0,
|
||||
bestStreak: 0,
|
||||
tier: 0,
|
||||
isActive: true,
|
||||
isHuman: true,
|
||||
customization: null,
|
||||
satsWon: 0,
|
||||
satsWagered: 0,
|
||||
hasWallet: false,
|
||||
}
|
||||
}
|
||||
|
||||
// Issue JWT (valid for 24 hours)
|
||||
const token = createJwt(pubkey, botId)
|
||||
|
||||
return c.json({
|
||||
token,
|
||||
exists: !!botData,
|
||||
pubkey,
|
||||
bot: botData,
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user