mirror of
https://github.com/nbd-wtf/nostr-tools.git
synced 2026-10-05 14:38:23 +00:00
relay: a failed auth attempt rejects and is not cached
auth() caught a throwing signer, logged it and never settled the promise, which stayed cached for the connection: every later auth() on that relay got the same pending promise, so a refused challenge left auth-required subscriptions unreported and publishes waiting forever. A relay's OK false or a timeout stayed cached the same way. Now a failed attempt rejects (the pool reports it through onclose and the publish rejection as before) and the next call tries again. The automatic path on AUTH logs the failure instead of rethrowing it. The mock relay speaks NIP-42 for the tests.
This commit is contained in:
+9
-6
@@ -324,7 +324,7 @@ export class AbstractRelay {
|
||||
if (!challenge) throw new Error("can't perform auth, no challenge was received")
|
||||
if (this.authPromise) return this.authPromise
|
||||
|
||||
this.authPromise = new Promise<string>(async (resolve, reject) => {
|
||||
const attempt = new Promise<string>(async (resolve, reject) => {
|
||||
try {
|
||||
let evt = await signAuthEvent(makeAuthEvent(this.url, challenge))
|
||||
let timeout = setTimeout(() => {
|
||||
@@ -337,10 +337,15 @@ export class AbstractRelay {
|
||||
this.openEventPublishes.set(evt.id, { resolve, reject, timeout })
|
||||
this.send('["AUTH",' + JSON.stringify(evt) + ']')
|
||||
} catch (err) {
|
||||
console.warn('subscribe auth function failed:', err)
|
||||
reject(err)
|
||||
}
|
||||
})
|
||||
return this.authPromise
|
||||
// a failed attempt is dropped so the next call retries
|
||||
attempt.catch(() => {
|
||||
if (this.authPromise === attempt) this.authPromise = undefined
|
||||
})
|
||||
this.authPromise = attempt
|
||||
return attempt
|
||||
}
|
||||
|
||||
public async publish(event: Event): Promise<string> {
|
||||
@@ -570,9 +575,7 @@ export class AbstractRelay {
|
||||
// If the connection closed before auth could be sent, just ignore it.
|
||||
// This is a race condition when relays close connections quickly
|
||||
// (e.g., WoT-enforced relays that reject unknown pubkeys).
|
||||
if (!(err instanceof SendingOnClosedConnection)) {
|
||||
throw err // re-throw other errors
|
||||
}
|
||||
if (!(err instanceof SendingOnClosedConnection)) console.warn('automatic auth failed:', err)
|
||||
})
|
||||
}
|
||||
return
|
||||
|
||||
+77
-1
@@ -4,11 +4,17 @@ import { afterAll, expect, test } from 'bun:test'
|
||||
|
||||
import { makeAuthEvent } from './nip42.ts'
|
||||
import { Relay, useWebSocketImplementation } from './relay.ts'
|
||||
import { SimplePool, useWebSocketImplementation as usePoolWebSocketImplementation } from './pool.ts'
|
||||
import { finalizeEvent, generateSecretKey, type Event, type EventTemplate, type VerifiedEvent } from './pure.ts'
|
||||
import { MockRelay, MockWebSocketClient } from './test-helpers.ts'
|
||||
|
||||
const originalWebSocket = WebSocket
|
||||
useWebSocketImplementation(MockWebSocketClient)
|
||||
afterAll(() => useWebSocketImplementation(originalWebSocket))
|
||||
usePoolWebSocketImplementation(MockWebSocketClient)
|
||||
afterAll(() => {
|
||||
useWebSocketImplementation(originalWebSocket)
|
||||
usePoolWebSocketImplementation(originalWebSocket)
|
||||
})
|
||||
|
||||
test('auth flow', async () => {
|
||||
const mockRelay = new MockRelay()
|
||||
@@ -20,3 +26,73 @@ test('auth flow', async () => {
|
||||
expect(auth.tags[1]).toEqual(['challenge', 'chachacha'])
|
||||
expect(auth.kind).toEqual(22242)
|
||||
})
|
||||
|
||||
const sk = generateSecretKey()
|
||||
const sign = async (t: EventTemplate) => finalizeEvent(t, sk)
|
||||
const refuse = async (): Promise<VerifiedEvent> => {
|
||||
throw new Error('not now')
|
||||
}
|
||||
const until = async (f: () => boolean) => {
|
||||
for (let i = 0; i < 200 && !f(); i++) await new Promise(r => setTimeout(r, 5))
|
||||
expect(f()).toBeTrue()
|
||||
}
|
||||
|
||||
test('auth can be retried after the signer refuses', async () => {
|
||||
const mockRelay = new MockRelay()
|
||||
mockRelay.authRequired = true
|
||||
|
||||
// refuse the challenge that comes with the connection
|
||||
const relay = new Relay(mockRelay.url)
|
||||
let refused = 0
|
||||
relay.onauth = () => {
|
||||
refused++
|
||||
return refuse()
|
||||
}
|
||||
await relay.connect()
|
||||
await until(() => refused === 1)
|
||||
|
||||
// refuse again, directly
|
||||
await expect(relay.auth(refuse)).rejects.toThrow('not now')
|
||||
expect(refused).toEqual(1)
|
||||
|
||||
// then sign
|
||||
await expect(relay.auth(sign)).resolves.toEqual('')
|
||||
relay.close()
|
||||
})
|
||||
|
||||
test('pool: a refused auth closes the subscription, a later one authenticates', async () => {
|
||||
const mockRelay = new MockRelay()
|
||||
mockRelay.authRequired = true
|
||||
const pool = new SimplePool()
|
||||
|
||||
const reasons = await new Promise<{ url: string; reason: string }[]>(resolve => {
|
||||
pool.subscribeMany(
|
||||
[mockRelay.url],
|
||||
{ kinds: [1] },
|
||||
{
|
||||
onevent() {},
|
||||
onauth: refuse,
|
||||
onclose: resolve,
|
||||
},
|
||||
)
|
||||
})
|
||||
expect(reasons).toHaveLength(1)
|
||||
expect(reasons[0].reason).toStartWith('auth was required and attempted, but failed with:')
|
||||
|
||||
const events: Event[] = []
|
||||
await new Promise<void>(resolve => {
|
||||
pool.subscribeMany(
|
||||
[mockRelay.url],
|
||||
{ kinds: [1] },
|
||||
{
|
||||
onevent: e => {
|
||||
events.push(e)
|
||||
},
|
||||
onauth: sign,
|
||||
oneose: resolve,
|
||||
},
|
||||
)
|
||||
})
|
||||
expect(events.length).toBeGreaterThan(0)
|
||||
pool.close([mockRelay.url])
|
||||
})
|
||||
|
||||
@@ -27,6 +27,8 @@ export class MockRelay {
|
||||
public secretKeys: Uint8Array[]
|
||||
public preloadedEvents: Event[]
|
||||
public unresponsive: boolean = false
|
||||
public authRequired: boolean = false
|
||||
public challenge: string = 'mock-challenge'
|
||||
|
||||
constructor(url?: string | undefined) {
|
||||
serial++
|
||||
@@ -47,15 +49,31 @@ export class MockRelay {
|
||||
this._server = new Server(this.url)
|
||||
this._server.on('connection', (conn: any) => {
|
||||
let subs: { [subId: string]: { conn: any; filters: Filter[] } } = {}
|
||||
let authenticated = false
|
||||
if (this.authRequired) conn.send(JSON.stringify(['AUTH', this.challenge]))
|
||||
|
||||
conn.on('message', (message: string) => {
|
||||
if (this.unresponsive) return
|
||||
const data = JSON.parse(message)
|
||||
|
||||
switch (data[0]) {
|
||||
case 'AUTH': {
|
||||
let event = data[1]
|
||||
const ok =
|
||||
event.kind === 22242 && event.tags.some((t: string[]) => t[0] === 'challenge' && t[1] === this.challenge)
|
||||
if (ok) authenticated = true
|
||||
conn.send(JSON.stringify(['OK', event.id, ok, ok ? '' : 'auth-required: bad challenge']))
|
||||
|
||||
break
|
||||
}
|
||||
case 'REQ': {
|
||||
let subId = data[1]
|
||||
let filters = data.slice(2)
|
||||
if (this.authRequired && !authenticated) {
|
||||
conn.send(JSON.stringify(['CLOSED', subId, 'auth-required: you need to authenticate']))
|
||||
|
||||
break
|
||||
}
|
||||
subs[subId] = { conn, filters }
|
||||
|
||||
this.preloadedEvents.forEach(event => {
|
||||
|
||||
Reference in New Issue
Block a user