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:
Tiago Balas
2026-09-19 07:54:00 -03:00
committed by fiatjaf
parent f32b76a11f
commit e59909038e
3 changed files with 104 additions and 7 deletions
+9 -6
View File
@@ -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
View File
@@ -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])
})
+18
View File
@@ -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 => {