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
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user