Compare commits
2
Commits
bbc3c7acff
...
635ee39373
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
635ee39373 | ||
|
|
e824f4ca7f |
@@ -0,0 +1,116 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { Hono } from 'hono'
|
||||
import { authRouter } from './auth.js'
|
||||
import { generateSecretKey, getPublicKey } from 'nostr-tools'
|
||||
import { createJwt, blacklistJwt } from '../middleware/jwt.js'
|
||||
import { db, schema } from '../db/index.js'
|
||||
import { createHash, randomBytes } from 'crypto'
|
||||
import { nanoid } from 'nanoid'
|
||||
|
||||
const app = new Hono()
|
||||
app.route('/api/auth', authRouter)
|
||||
|
||||
async function seedBot(pubkey: string, overrides: Partial<typeof schema.bots.$inferInsert> = {}) {
|
||||
const id = overrides.id || nanoid(12)
|
||||
const secret = randomBytes(32).toString('hex')
|
||||
await db.insert(schema.bots).values({
|
||||
id,
|
||||
name: overrides.name || `t${Date.now().toString(36).slice(-8)}`,
|
||||
webhookUrl: overrides.webhookUrl || 'http://poll.local/',
|
||||
avatarSeed: overrides.avatarSeed || 'seed',
|
||||
archetype: overrides.archetype || 'standard',
|
||||
secretHash: createHash('sha256').update(secret).digest('hex'),
|
||||
publicKey: pubkey,
|
||||
profilePicUrl: overrides.profilePicUrl ?? null,
|
||||
customization: overrides.customization ?? null,
|
||||
createdAt: overrides.createdAt || new Date().toISOString(),
|
||||
})
|
||||
return id
|
||||
}
|
||||
|
||||
describe('GET /api/auth/me', () => {
|
||||
it('rejects when no Authorization header is present', async () => {
|
||||
const res = await app.request('/api/auth/me')
|
||||
expect(res.status).toBe(401)
|
||||
const body = await res.json() as { error: string }
|
||||
expect(body.error).toBeDefined()
|
||||
})
|
||||
|
||||
it('rejects a malformed / garbage Bearer value', async () => {
|
||||
const res = await app.request('/api/auth/me', {
|
||||
headers: { Authorization: 'Bearer not-a-real-jwt' },
|
||||
})
|
||||
expect(res.status).toBe(401)
|
||||
const body = await res.json() as { error: string }
|
||||
expect(body.error).toBeDefined()
|
||||
})
|
||||
|
||||
it('rejects a token whose signature does not verify', async () => {
|
||||
const pk = getPublicKey(generateSecretKey())
|
||||
const token = createJwt(pk)
|
||||
const parts = token.split('.')
|
||||
// tamper one character of the signature segment
|
||||
const tamperedSig = (parts[2][0] === 'a' ? 'b' : 'a') + parts[2].slice(1)
|
||||
const tampered = `${parts[0]}.${parts[1]}.${tamperedSig}`
|
||||
|
||||
const res = await app.request('/api/auth/me', {
|
||||
headers: { Authorization: `Bearer ${tampered}` },
|
||||
})
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('rejects a blacklisted token', async () => {
|
||||
const pk = getPublicKey(generateSecretKey())
|
||||
const token = createJwt(pk)
|
||||
blacklistJwt(token)
|
||||
|
||||
const res = await app.request('/api/auth/me', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns exists:false for a valid token with no bot row', async () => {
|
||||
const pk = getPublicKey(generateSecretKey())
|
||||
const token = createJwt(pk)
|
||||
|
||||
const res = await app.request('/api/auth/me', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json() as { exists: boolean }
|
||||
expect(body.exists).toBe(false)
|
||||
})
|
||||
|
||||
it('returns exists:true with the bot for a valid token owning a bot row', async () => {
|
||||
const pk = getPublicKey(generateSecretKey())
|
||||
const name = `me${Date.now().toString(36).slice(-8)}`
|
||||
const botId = await seedBot(pk, { name })
|
||||
const token = createJwt(pk, botId)
|
||||
|
||||
const res = await app.request('/api/auth/me', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json() as { exists: boolean; bot: { id: string; name: string; isHuman: boolean; hasWallet: boolean } }
|
||||
expect(body.exists).toBe(true)
|
||||
expect(body.bot.id).toBe(botId)
|
||||
expect(body.bot.name).toBe(name)
|
||||
// Same key set as POST /login's 200 body
|
||||
expect(body.bot.isHuman).toBe(false)
|
||||
expect(body.bot.hasWallet).toBe(false)
|
||||
})
|
||||
|
||||
it('performs no writes: querying /me for an unregistered creator pubkey does not create a row', async () => {
|
||||
// A non-creator pubkey with a valid token and no bot row must stay exists:false
|
||||
// with zero side effects (GET /me never inserts/updates).
|
||||
const pk = getPublicKey(generateSecretKey())
|
||||
const token = createJwt(pk)
|
||||
|
||||
const before = await db.select({ id: schema.bots.id }).from(schema.bots)
|
||||
await app.request('/api/auth/me', { headers: { Authorization: `Bearer ${token}` } })
|
||||
const after = await db.select({ id: schema.bots.id }).from(schema.bots)
|
||||
|
||||
expect(after.length).toBe(before.length)
|
||||
})
|
||||
})
|
||||
@@ -9,6 +9,7 @@ import { testWebhook } from '../engine/webhook-test.js'
|
||||
import { rateLimit } from '../middleware/rate-limit.js'
|
||||
import { isCreatorPubkey } from '../lib/constants.js'
|
||||
import { loginSchema, registerSchema, registerHumanSchema, updateBotSchema, pubkeySchema, formatZodError } from '../lib/validators.js'
|
||||
import { extractPubkeyFromAuth } from '../middleware/jwt.js'
|
||||
|
||||
export const authRouter = new Hono()
|
||||
|
||||
@@ -25,6 +26,69 @@ authRouter.get("/check-name/:name", async (c) => {
|
||||
return c.json({ available: existing.length === 0 })
|
||||
})
|
||||
|
||||
// GET /me — restore the caller's own identity from their JWT alone.
|
||||
// This is the ONLY session-restore path: it derives the pubkey from a
|
||||
// verified, non-expired, non-blacklisted Bearer token (extractPubkeyFromAuth
|
||||
// delegates to verifyJwt, which covers all of those cases) and never trusts
|
||||
// a client-claimed pubkey. Read-only — performs no writes of any kind.
|
||||
// Covered by global /api/* rate limiting (see app.ts); no per-route limiter
|
||||
// needed for a session-restore call issued on every page load.
|
||||
authRouter.get('/me', async (c) => {
|
||||
const pubkey = extractPubkeyFromAuth(c.req.header('Authorization'))
|
||||
if (!pubkey) {
|
||||
return c.json({ error: 'Authentication required.' }, 401)
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
if (rows.length === 0) {
|
||||
return c.json({ exists: false })
|
||||
}
|
||||
|
||||
const bot = rows[0]
|
||||
const isHuman = bot.webhookUrl === 'http://human.local/'
|
||||
|
||||
return c.json({
|
||||
exists: true,
|
||||
bot: {
|
||||
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,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// Login with Nostr pubkey (rate limited: 10 per minute per IP)
|
||||
authRouter.post('/login', rateLimit(60_000, 10), async (c) => {
|
||||
const parsed = loginSchema.safeParse(await c.req.json().catch(() => ({})))
|
||||
@@ -369,7 +433,7 @@ authRouter.post('/update', rateLimit(60_000, 10), async (c) => {
|
||||
|
||||
// --- NIP-98 Authenticated Session ---
|
||||
import { verifyNip98Token } from '../middleware/nip98.js'
|
||||
import { createJwt, extractPubkeyFromAuth } from '../middleware/jwt.js'
|
||||
import { createJwt } from '../middleware/jwt.js'
|
||||
|
||||
// POST /nostr/session — authenticate with NIP-98, receive JWT
|
||||
authRouter.post('/nostr/session', rateLimit(60_000, 10), async (c) => {
|
||||
|
||||
Reference in New Issue
Block a user