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 => {