Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 110 additions & 0 deletions packages/fluux-sdk/src/core/modules/Connection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <success>
// element, xmpp.js does NOT emit a top-level <resumed/> 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
// <resumed/>, 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 <resumed/> 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 <enable> suppression on SASL2+bind2 connections.
// After bind2 negotiates SM inline, ejabberd sends a second <stream:features>
// that still includes <sm>. xmpp.js's setupStreamFeature handler sees it and
// sends a duplicate <enable>, which the server rejects with <failed>. The catch
// block then clears sm.enabled, silently disabling SM for the session.
// The fix: a prependListener on 'element' strips <sm> 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 <sm> from post-auth <stream:features> 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 <stream:features> element with an <sm> 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 — <sm> 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 <stream:features> 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.
Expand Down
65 changes: 65 additions & 0 deletions packages/fluux-sdk/src/core/modules/Connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -991,7 +991,7 @@
if (!clientAtStart) return 'dead'

try {
const sm = clientAtStart.streamManagement as any

Check warning on line 994 in packages/fluux-sdk/src/core/modules/Connection.ts

View workflow job for this annotation

GitHub Actions / Test

Unexpected any. Specify a different type
if (sm?.enabled) {
const ackReceived = await this.waitForSmAck(timeoutMs)
if (this.xmpp && this.xmpp !== clientAtStart) {
Expand All @@ -1001,7 +1001,7 @@
if (!ackReceived) return 'dead'
} else {
// Fallback: send a ping IQ and wait for response
const iqCaller = (clientAtStart as any).iqCaller

Check warning on line 1004 in packages/fluux-sdk/src/core/modules/Connection.ts

View workflow job for this annotation

GitHub Actions / Test

Unexpected any. Specify a different type
if (iqCaller) {
const ping = xml(
'iq',
Expand Down Expand Up @@ -1059,8 +1059,8 @@
// Cleanup helper
const cleanup = (timeoutId: ReturnType<typeof setTimeout>) => {
clearTimeout(timeoutId)
;(client as any)?.off?.('nonza', handleNonza)

Check warning on line 1062 in packages/fluux-sdk/src/core/modules/Connection.ts

View workflow job for this annotation

GitHub Actions / Test

Unexpected any. Specify a different type
;(client as any)?.off?.('disconnect', handleDisconnect)

Check warning on line 1063 in packages/fluux-sdk/src/core/modules/Connection.ts

View workflow job for this annotation

GitHub Actions / Test

Unexpected any. Specify a different type
}

// Listen for <a/> nonza - defined as a hoisted function for cleanup reference
Expand Down Expand Up @@ -1421,6 +1421,13 @@
const domain = getDomain(jid)
const username = getLocalPart(jid)

// When SASL2+bind2 is used, SM is enabled inline inside <authenticate>/<success>.
// ejabberd then sends a post-auth <stream:features> that still contains <sm>,
// causing xmpp.js's setupStreamFeature to send a duplicate <enable> (server rejects
// it with <failed> and the catch block disables sm.enabled). We suppress this by
// removing <sm> from the features stanza before the middleware sees it.
let bind2SmEnabling = false

const xmppClient = client({
service: wsUrl,
domain,
Expand Down Expand Up @@ -1490,6 +1497,11 @@
const userAgent = buildUserAgentElement()
const saslStart = Date.now()
let saslTimeoutId: ReturnType<typeof setTimeout> | undefined
// SASL2 path: bind2 will negotiate SM inline. Mark that the post-auth
// <stream:features> with <sm> should be suppressed.
if (fast !== undefined) {
bind2SmEnabling = true
}
await Promise.race([
Promise.resolve(authenticate(creds, mechanism, userAgent)).then(() => {
clearTimeout(saslTimeoutId)
Expand Down Expand Up @@ -1550,6 +1562,23 @@
patchSmAckQueue(sm)
}

// Intercept <stream:features> before middleware to strip <sm> when bind2 inline
// SM negotiation is in flight. Without this, setupStreamFeature sends a duplicate
// <enable>, 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
}

Expand Down Expand Up @@ -1635,6 +1664,42 @@
}
// Note: xmpp.js will fall back to new session and emit 'online'
})

// SASL2 inline SM resumption: the <resumed> is embedded inside <success> 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 (<enabled/> and <resumed/>).
Expand Down
4 changes: 4 additions & 0 deletions packages/fluux-sdk/src/core/test-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,10 @@ export const createMockXmppClient = () => {
}
}),
},
// prependListener support — captures handlers for tests that verify the
// element interceptor used to strip <sm> from post-auth <stream:features>.
// 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).
Expand Down
Loading