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:
Dorian
2026-03-09 12:31:25 +00:00
co-authored by Claude Opus 4.6
parent ad96d1158f
commit 3ba05a66b4
8 changed files with 532 additions and 105 deletions
+108
View File
@@ -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
}