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
+127
View File
@@ -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,
})
})