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
9 changes: 8 additions & 1 deletion apps/fluux/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ import {
} from 'lucide-react'
import { clearSession } from '@/hooks/useSessionPersistence'
import { deleteCredentials } from '@/utils/keychain'
import { clearLocalData } from '@/utils/clearLocalData'
import { clearLocalData, clearAutoReconnectCredentials } from '@/utils/clearLocalData'

// Import extracted sidebar components
import {
Expand Down Expand Up @@ -541,6 +541,13 @@ export function Sidebar({ onSelectContact, onStartChat, onManageUser, adminCateg
// clearLocalData() clears session at the end of cleanup.
await clearLocalData().catch(() => {})
} else {
// Drop the FAST token synchronously so the post-logout webview
// reload (LoginScreen's WRY workaround) can't trip the
// auto-reconnect path. The SDK's disconnect() also clears it,
// but only after an async server round-trip that the reload
// can outrace. (clearLocalData handles this in the clean path.)
clearAutoReconnectCredentials(jid)

// Reset connection store so App re-renders and routes to LoginScreen.
// (clearLocalData already resets stores in the clean path.)
connectionStore.getState().reset()
Expand Down
33 changes: 33 additions & 0 deletions apps/fluux/src/utils/clearLocalData.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { hasFastToken, getBareJid } from '@fluux/sdk'
import { clearAutoReconnectCredentials } from './clearLocalData'

const FAST_TOKEN_PREFIX = 'fluux:fast-token:'

function seedFastToken(bareJid: string): void {
const expiry = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString()
localStorage.setItem(
`${FAST_TOKEN_PREFIX}${bareJid}`,
JSON.stringify({ mechanism: 'HT-SHA-256-NONE', token: 'tok', expiry })
)
}

describe('clearAutoReconnectCredentials', () => {
beforeEach(() => {
localStorage.clear()
})

it('removes the FAST token so post-logout auto-reconnect cannot re-authenticate', () => {
const jid = 'user@example.com'
seedFastToken(getBareJid(jid))
expect(hasFastToken(jid)).toBe(true)

clearAutoReconnectCredentials(`${jid}/resource`)

expect(hasFastToken(jid)).toBe(false)
})

it('is a no-op when jid is null', () => {
expect(() => clearAutoReconnectCredentials(null)).not.toThrow()
})
})
18 changes: 18 additions & 0 deletions apps/fluux/src/utils/clearLocalData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,24 @@ const USER_DATA_KEYS = [
'xmpp-has-saved-credentials',
]

/**
* Drop the credential that enables silent auto-reconnection for one account
* (the XEP-0484 FAST token).
*
* Synchronous and reload-safe by design: the logout flow must run this BEFORE
* the Tauri webview reload triggered by LoginScreen (the WRY event-delivery
* workaround). That reload resets `useSessionPersistence`'s once-per-startup
* guard, so without dropping the token first the post-reload auto-connect path
* would silently re-authenticate the user we just logged out.
*
* Unlike `clearLocalData`, this preserves messages, cache, roster, and the
* `xmpp-last-jid` / `xmpp-last-server` login-form prefill.
*/
export function clearAutoReconnectCredentials(jid: string | null): void {
if (!jid) return
deleteFastToken(getBareJid(jid))
}

interface ClearLocalDataOptions {
/**
* When true, clears all local account data.
Expand Down
18 changes: 18 additions & 0 deletions packages/fluux-sdk/src/core/fastTokenInvalidation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,24 @@ describe('invalidateFastTokenOnServer', () => {
expect(mockClientFactory).not.toHaveBeenCalled()
})

it('uses a pre-fetched token without reading localStorage', async () => {
const inst = createMockInstance()
mockClientFactory.mockImplementation(() => inst)

const p = invalidateFastTokenOnServer({
jid: 'alice@example.com',
server: 'wss://example.com/ws',
token: TOKEN,
})
await Promise.resolve()
inst._emit('online')
const result = await p

expect(result.ok).toBe(true)
// The caller already deleted the local copy; we must not depend on it.
expect(mockFetchFastToken).not.toHaveBeenCalled()
})

it('returns {ok:false, invalid-jid} when JID lacks a localpart', async () => {
mockFetchFastToken.mockReturnValue(TOKEN)
const result = await invalidateFastTokenOnServer({
Expand Down
9 changes: 8 additions & 1 deletion packages/fluux-sdk/src/core/fastTokenInvalidation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,13 @@ export interface InvalidateFastTokenOptions {
server: string
/** Override the default invalidation timeout (ms) */
timeoutMs?: number
/**
* Pre-fetched token to use for the invalidation session. When provided,
* this function does not read localStorage — letting the caller delete the
* client-side token synchronously (e.g. before a webview reload) while still
* invalidating it server-side. Falls back to a localStorage lookup when omitted.
*/
token?: FastToken | null
}

export interface InvalidateFastTokenResult {
Expand Down Expand Up @@ -78,7 +85,7 @@ export async function invalidateFastTokenOnServer(
const { jid, server, timeoutMs = FAST_TOKEN_INVALIDATION_TIMEOUT_MS } = options

const bareJid = getBareJid(jid)
const token: FastToken | null = fetchFastToken(bareJid)
const token: FastToken | null = options.token ?? fetchFastToken(bareJid)
if (!token) {
return { ok: false, reason: 'no-token' }
}
Expand Down
61 changes: 61 additions & 0 deletions packages/fluux-sdk/src/core/modules/Connection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -457,22 +457,67 @@ describe('XMPPClient Connection', () => {
mockXmppClientInstance._emit('online')
await connectPromise

const fakeToken = {
mechanism: 'HT-SHA-256-NONE',
token: 'tok',
expiry: new Date(Date.now() + 86_400_000).toISOString(),
}
mockInvalidateFastTokenOnServer.mockClear()
mockInvalidateFastTokenOnServer.mockResolvedValue({ ok: true })
mockDeleteFastToken.mockClear()
mockFetchFastToken.mockReturnValue(fakeToken)

await xmppClient.disconnect({ invalidateFastToken: true })

// The captured token is threaded through so the server round-trip can
// still authenticate to invalidate it, even though the local copy was
// already deleted synchronously.
expect(mockInvalidateFastTokenOnServer).toHaveBeenCalledWith(
expect.objectContaining({
jid: 'user@example.com',
server: expect.stringContaining('example.com'),
token: fakeToken,
})
)
// Client-side token must also be removed so auto-reconnect paths
// cannot silently log the user back in after an explicit logout.
expect(mockDeleteFastToken).toHaveBeenCalledWith('user@example.com')
expect(mockStores.connection.setStatus).toHaveBeenCalledWith('disconnected')

mockFetchFastToken.mockReturnValue(null)
})

it('removes the client-side FAST token synchronously, before the server round-trip settles', async () => {
const connectPromise = xmppClient.connect({
jid: 'user@example.com',
password: 'secret',
server: 'example.com',
skipDiscovery: true,
})
mockXmppClientInstance._emit('online')
await connectPromise

const fakeToken = {
mechanism: 'HT-SHA-256-NONE',
token: 'tok',
expiry: new Date(Date.now() + 86_400_000).toISOString(),
}
mockDeleteFastToken.mockClear()
mockFetchFastToken.mockReturnValue(fakeToken)
// Server invalidation that never resolves — simulates a slow/stalled
// round-trip (or, on Tauri, the webview reload tearing down the JS
// context before it can settle).
mockInvalidateFastTokenOnServer.mockReturnValue(new Promise<never>(() => {}))

// Intentionally not awaited: disconnect() stays pending on the stalled
// server call. The local token deletion happens in the synchronous
// phase, before that await is reached.
void xmppClient.disconnect({ invalidateFastToken: true })

expect(mockDeleteFastToken).toHaveBeenCalledWith('user@example.com')

mockFetchFastToken.mockReturnValue(null)
mockInvalidateFastTokenOnServer.mockResolvedValue({ ok: true })
})

it('continues disconnect cleanup when FAST token invalidation fails', async () => {
Expand All @@ -485,9 +530,15 @@ describe('XMPPClient Connection', () => {
mockXmppClientInstance._emit('online')
await connectPromise

const fakeToken = {
mechanism: 'HT-SHA-256-NONE',
token: 'tok',
expiry: new Date(Date.now() + 86_400_000).toISOString(),
}
mockInvalidateFastTokenOnServer.mockClear()
mockInvalidateFastTokenOnServer.mockRejectedValueOnce(new Error('network down'))
mockDeleteFastToken.mockClear()
mockFetchFastToken.mockReturnValue(fakeToken)

await expect(
xmppClient.disconnect({ invalidateFastToken: true })
Expand All @@ -499,6 +550,8 @@ describe('XMPPClient Connection', () => {
// failed — user intent is clear, and a lingering entry would re-enable
// auto-reconnect after logout.
expect(mockDeleteFastToken).toHaveBeenCalledWith('user@example.com')

mockFetchFastToken.mockReturnValue(null)
})

it('removes client-side FAST token when server returns not-ok result', async () => {
Expand All @@ -511,13 +564,21 @@ describe('XMPPClient Connection', () => {
mockXmppClientInstance._emit('online')
await connectPromise

const fakeToken = {
mechanism: 'HT-SHA-256-NONE',
token: 'tok',
expiry: new Date(Date.now() + 86_400_000).toISOString(),
}
mockInvalidateFastTokenOnServer.mockClear()
mockInvalidateFastTokenOnServer.mockResolvedValue({ ok: false, reason: 'no-token' })
mockDeleteFastToken.mockClear()
mockFetchFastToken.mockReturnValue(fakeToken)

await xmppClient.disconnect({ invalidateFastToken: true })

expect(mockDeleteFastToken).toHaveBeenCalledWith('user@example.com')

mockFetchFastToken.mockReturnValue(null)
})

it('should resolve disconnect even when client.stop never settles', async () => {
Expand Down
36 changes: 23 additions & 13 deletions packages/fluux-sdk/src/core/modules/Connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -718,6 +718,20 @@
const serverForInvalidation = this.credentials?.server
const shouldInvalidateFastToken = !!options.invalidateFastToken

// Drop the client-side FAST token synchronously, in the same tick the UI
// transitions to 'disconnected'. Leaving it behind lets the app's
// post-disconnect auto-reconnect path silently re-authenticate after
// logout — and on Tauri the LoginScreen webview reload can outrace any
// async cleanup. We capture the token first so the (best-effort,
// network-bound) server-side invalidation below can still use it.
const bareJidForFastToken =
shouldInvalidateFastToken && jidForSmCleanup ? getBareJid(jidForSmCleanup) : null
const capturedFastToken = bareJidForFastToken ? fetchFastToken(bareJidForFastToken) : null
if (bareJidForFastToken) {
deleteFastToken(bareJidForFastToken)
logDisconnect('FAST token removed from local storage (sync)')
}

if (clientToStop) {
this.xmpp = null
}
Expand All @@ -739,16 +753,18 @@
// SM persistence, room message flush, and XMPP stream close.
// Safe to run after UI has transitioned.

// Best-effort FAST token invalidation (XEP-0484 §6). Runs before the
// transport is torn down so the short-lived invalidation session can
// reach the server while we still have network path established.
if (shouldInvalidateFastToken && jidForSmCleanup && serverForInvalidation) {
const bareJid = getBareJid(jidForSmCleanup)
logDisconnect(`attempting FAST token invalidation for ${bareJid}`)
// Best-effort server-side FAST token invalidation (XEP-0484 §6). Runs
// before the transport is torn down so the short-lived invalidation
// session can reach the server while we still have a network path. The
// local token was already deleted synchronously above; we pass the
// captured copy so this session can still authenticate to invalidate it.
if (bareJidForFastToken && serverForInvalidation && capturedFastToken) {
logDisconnect(`attempting FAST token invalidation for ${bareJidForFastToken}`)
try {
const result = await invalidateFastTokenOnServer({
jid: bareJid,
jid: bareJidForFastToken,
server: serverForInvalidation,
token: capturedFastToken,
})
if (result.ok) {
logDisconnect(`FAST token invalidated on server${result.reason ? ` (${result.reason})` : ''}`)
Expand All @@ -760,12 +776,6 @@
const message = err instanceof Error ? err.message : String(err)
logDisconnect(`FAST token invalidation threw (${message})`)
}

// Remove the client-side token regardless of server-side outcome.
// Leaving it behind lets the app's post-disconnect auto-reconnect path
// silently re-authenticate after logout.
deleteFastToken(bareJid)
logDisconnect('FAST token removed from local storage')
}

this.smPersistence.clearCache()
Expand Down Expand Up @@ -991,7 +1001,7 @@
if (!clientAtStart) return 'dead'

try {
const sm = clientAtStart.streamManagement as any

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 (sm?.enabled) {
const ackReceived = await this.waitForSmAck(timeoutMs)
if (this.xmpp && this.xmpp !== clientAtStart) {
Expand All @@ -1001,7 +1011,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 1014 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 +1069,8 @@
// Cleanup helper
const cleanup = (timeoutId: ReturnType<typeof setTimeout>) => {
clearTimeout(timeoutId)
;(client as any)?.off?.('nonza', handleNonza)

Check warning on line 1072 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 1073 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
Loading