From e59909038ee3922daa06c08d2ef63e613ffa1c49 Mon Sep 17 00:00:00 2001 From: Tiago Balas Date: Sat, 19 Sep 2026 10:24:16 +0100 Subject: [PATCH] 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. --- abstract-relay.ts | 15 +++++---- nip42.test.ts | 78 ++++++++++++++++++++++++++++++++++++++++++++++- test-helpers.ts | 18 +++++++++++ 3 files changed, 104 insertions(+), 7 deletions(-) diff --git a/abstract-relay.ts b/abstract-relay.ts index 8db80e0..59284c0 100644 --- a/abstract-relay.ts +++ b/abstract-relay.ts @@ -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(async (resolve, reject) => { + const attempt = new Promise(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 { @@ -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 diff --git a/nip42.test.ts b/nip42.test.ts index 36ebfc8..b98ef5d 100644 --- a/nip42.test.ts +++ b/nip42.test.ts @@ -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 => { + 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(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]) +}) diff --git a/test-helpers.ts b/test-helpers.ts index 73c509e..b959aca 100644 --- a/test-helpers.ts +++ b/test-helpers.ts @@ -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 => {