diff --git a/packages/fluux-sdk/src/core/modules/Connection.test.ts b/packages/fluux-sdk/src/core/modules/Connection.test.ts index ebec0300..771a04e0 100644 --- a/packages/fluux-sdk/src/core/modules/Connection.test.ts +++ b/packages/fluux-sdk/src/core/modules/Connection.test.ts @@ -3509,6 +3509,116 @@ describe('XMPPClient Connection', () => { }) }) + // Regression: SASL2 inline SM resumption (XEP-0388 + XEP-0198). + // When ejabberd negotiates SM resumption inline inside the SASL2 + // element, xmpp.js does NOT emit a top-level nonza and does NOT + // emit the 'online' event. Only sm.emit("resumed") fires on the SM plugin. + // Before the fix, Connection.ts only listened for 'online' and nonza-based + // , so connect() would hang until the 30-second timeout. + describe('SASL2 inline SM resumption (XEP-0388)', () => { + it('should resolve connect() when sm "resumed" event fires without online or nonza', async () => { + const connectPromise = xmppClient.connect({ + jid: 'user@example.com', + password: 'secret', + server: 'example.com', + smState: { id: 'sasl2-sm-abc', inbound: 7, outbound: 3 }, + skipDiscovery: true, + }) + + // SASL2 inline resumption: xmpp.js sets SM state then emits 'resumed' on the + // SM plugin. No top-level 'online' and no nonza. + mockXmppClientInstance.streamManagement.id = 'sasl2-sm-abc' + mockXmppClientInstance.streamManagement.enabled = true + mockXmppClientInstance.streamManagement.inbound = 7 + mockXmppClientInstance.streamManagement.outbound = 3 + + mockXmppClientInstance._emitSM('resumed') + + // connect() must resolve — before the fix it would hang until the 30s timeout + await connectPromise + }) + + it('should cache SM state when resumption completes via SASL2 inline', async () => { + const connectPromise = xmppClient.connect({ + jid: 'user@example.com', + password: 'secret', + server: 'example.com', + smState: { id: 'sasl2-sm-def', inbound: 10, outbound: 5 }, + skipDiscovery: true, + }) + + mockXmppClientInstance.streamManagement.id = 'sasl2-sm-def' + mockXmppClientInstance.streamManagement.enabled = true + mockXmppClientInstance.streamManagement.inbound = 10 + mockXmppClientInstance.streamManagement.outbound = 5 + + mockXmppClientInstance._emitSM('resumed') + await connectPromise + + const smState = xmppClient.getStreamManagementState() + expect(smState).not.toBeNull() + expect(smState?.id).toBe('sasl2-sm-def') + expect(smState?.inbound).toBe(10) + }) + }) + + // Regression: duplicate SM suppression on SASL2+bind2 connections. + // After bind2 negotiates SM inline, ejabberd sends a second + // that still includes . xmpp.js's setupStreamFeature handler sees it and + // sends a duplicate , which the server rejects with . The catch + // block then clears sm.enabled, silently disabling SM for the session. + // The fix: a prependListener on 'element' strips from the features element + // before the middleware sees it, gated on a bind2SmEnabling flag that is set + // when the credentials callback detects the SASL2 path (fast !== undefined). + describe('bind2 SM duplicate-enable suppression (XEP-0386 + XEP-0198)', () => { + it('should strip from post-auth when SASL2 bind2 is active', async () => { + const connectPromise = xmppClient.connect({ + jid: 'user@example.com', + password: 'secret', + server: 'example.com', + skipDiscovery: true, + }) + + // Let createXmppClient() run so prependListener is registered + await vi.advanceTimersByTimeAsync(0) + + expect(mockXmppClientInstance.prependListener).toHaveBeenCalledWith('element', expect.any(Function)) + const prependCall = mockXmppClientInstance.prependListener.mock.calls.find(([event]) => event === 'element') + const elementHandler = prependCall?.[1] as ((el: unknown) => void) + expect(elementHandler).toBeDefined() + + // Invoke the credentials callback with fast !== undefined to set bind2SmEnabling=true + const credentialsFn = (mockClientFactory.mock.calls[mockClientFactory.mock.calls.length - 1] as any)[0].credentials + const mockAuthenticate = vi.fn().mockResolvedValue(undefined) + const mockFast = { fetch: vi.fn().mockResolvedValue(null) } + await credentialsFn(mockAuthenticate, ['SCRAM-SHA-256'], mockFast, { isSecure: () => true }) + + // Build a element with an child — what ejabberd sends post-auth + const featuresEl = createMockElement('features', { xmlns: 'http://etherx.jabber.org/streams' }, [ + { name: 'sm', attrs: { xmlns: 'urn:xmpp:sm:3' } }, + ]) + const smBefore = (featuresEl as any).children.find((c: any) => c.name === 'sm') + expect(smBefore).toBeDefined() + + // Call the interceptor — must be removed since bind2SmEnabling=true + elementHandler(featuresEl) + const smAfter = (featuresEl as any).children.find((c: any) => c.name === 'sm') + expect(smAfter).toBeUndefined() + + // bind2SmEnabling is now reset to false — a second must not be modified + const featuresEl2 = createMockElement('features', { xmlns: 'http://etherx.jabber.org/streams' }, [ + { name: 'sm', attrs: { xmlns: 'urn:xmpp:sm:3' } }, + ]) + elementHandler(featuresEl2) + const smAfter2 = (featuresEl2 as any).children.find((c: any) => c.name === 'sm') + expect(smAfter2).toBeDefined() + + // Finish the connection + mockXmppClientInstance._emit('online') + await connectPromise + }) + }) + describe('FAST token authentication (XEP-0484)', () => { /** * Helper to extract the credentials callback passed to the xmpp.js client factory. diff --git a/packages/fluux-sdk/src/core/modules/Connection.ts b/packages/fluux-sdk/src/core/modules/Connection.ts index aaf8be95..39293e8b 100644 --- a/packages/fluux-sdk/src/core/modules/Connection.ts +++ b/packages/fluux-sdk/src/core/modules/Connection.ts @@ -1421,6 +1421,13 @@ export class Connection extends BaseModule { const domain = getDomain(jid) const username = getLocalPart(jid) + // When SASL2+bind2 is used, SM is enabled inline inside /. + // ejabberd then sends a post-auth that still contains , + // causing xmpp.js's setupStreamFeature to send a duplicate (server rejects + // it with and the catch block disables sm.enabled). We suppress this by + // removing from the features stanza before the middleware sees it. + let bind2SmEnabling = false + const xmppClient = client({ service: wsUrl, domain, @@ -1490,6 +1497,11 @@ export class Connection extends BaseModule { const userAgent = buildUserAgentElement() const saslStart = Date.now() let saslTimeoutId: ReturnType | undefined + // SASL2 path: bind2 will negotiate SM inline. Mark that the post-auth + // with should be suppressed. + if (fast !== undefined) { + bind2SmEnabling = true + } await Promise.race([ Promise.resolve(authenticate(creds, mechanism, userAgent)).then(() => { clearTimeout(saslTimeoutId) @@ -1550,6 +1562,23 @@ export class Connection extends BaseModule { patchSmAckQueue(sm) } + // Intercept before middleware to strip when bind2 inline + // SM negotiation is in flight. Without this, setupStreamFeature sends a duplicate + // , the server rejects it, and the catch block clears sm.enabled. + const NS_JABBER_STREAM = 'http://etherx.jabber.org/streams' + const NS_SM = 'urn:xmpp:sm:3' + if (typeof (xmppClient as any).prependListener === 'function') { + ;(xmppClient as any).prependListener('element', (element: Element) => { + if (bind2SmEnabling && element.is('features', NS_JABBER_STREAM)) { + bind2SmEnabling = false + const smFeature = element.getChild('sm', NS_SM) + if (smFeature) { + ;(element as any).children = (element as any).children.filter((c: unknown) => c !== smFeature) + } + } + }) + } + return xmppClient } @@ -1635,6 +1664,42 @@ export class Connection extends BaseModule { } // Note: xmpp.js will fall back to new session and emit 'online' }) + + // SASL2 inline SM resumption: the is embedded inside so xmpp.js + // never emits a top-level 'nonza' for it. The SM module fires sm.emit("resumed") instead. + // For traditional stream-feature resumption, the nonza handler fires first (registered + // earlier) and sets resolved=true, making this a no-op there. + sm.on('resumed', () => { + if (resolved) return + const smLive = this.xmpp?.streamManagement as any + const previd = smLive?.id || '' + const inbound = smLive ? (smLive.inbound || 0) : 0 + const outbound = smLive + ? (smLive.outbound || 0) + (Array.isArray(smLive.outbound_q) ? smLive.outbound_q.length : 0) + : 0 + if (previd) { + this.stores.console.addEvent( + `Stream Management session resumed via SASL2 inline (id: ${previd.slice(0, 8)}...)`, + 'sm' + ) + logInfo(`SM session resumed via SASL2 inline (id: ${previd.slice(0, 8)}..., h: ${inbound}, out: ${outbound})`) + this.smResumeCompleted = true + this.smPersistence.updateCache(previd, inbound, outbound) + if (this.credentials?.jid) { + void this.smPersistence.persist(this.credentials.jid, this.credentials.resource || '') + } + setTimeout(() => { + const smObj = this.xmpp?.streamManagement as any + if (smObj) { + smObj.id = previd + smObj.enabled = true + smObj.inbound = inbound + } + }, 0) + } + logInfo(`Connection handshake complete: SM resumed via SASL2 inline (resolved=${resolved})`) + handleResult(true) + }) } // Listen for SM nonzas directly ( and ). diff --git a/packages/fluux-sdk/src/core/test-utils.ts b/packages/fluux-sdk/src/core/test-utils.ts index 6507ecca..eaa79a2b 100644 --- a/packages/fluux-sdk/src/core/test-utils.ts +++ b/packages/fluux-sdk/src/core/test-utils.ts @@ -248,6 +248,10 @@ export const createMockXmppClient = () => { } }), }, + // prependListener support — captures handlers for tests that verify the + // element interceptor used to strip from post-auth . + // The real xmpp.js client inherits this from Node's EventEmitter. + prependListener: vi.fn(), // Remove all event listeners — called by forceDestroyClient() during cleanup. // Must actually clear handlers so destroyed clients cannot emit stale events // into the Connection module (e.g., a belated 'online' after timeout).