diff --git a/apps/fluux/src/components/Sidebar.tsx b/apps/fluux/src/components/Sidebar.tsx index 4183582b..64fc44f2 100644 --- a/apps/fluux/src/components/Sidebar.tsx +++ b/apps/fluux/src/components/Sidebar.tsx @@ -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 { @@ -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() diff --git a/apps/fluux/src/utils/clearLocalData.test.ts b/apps/fluux/src/utils/clearLocalData.test.ts new file mode 100644 index 00000000..264657ba --- /dev/null +++ b/apps/fluux/src/utils/clearLocalData.test.ts @@ -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() + }) +}) diff --git a/apps/fluux/src/utils/clearLocalData.ts b/apps/fluux/src/utils/clearLocalData.ts index 0c3bd08f..f7c38ca9 100644 --- a/apps/fluux/src/utils/clearLocalData.ts +++ b/apps/fluux/src/utils/clearLocalData.ts @@ -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. diff --git a/packages/fluux-sdk/src/core/fastTokenInvalidation.test.ts b/packages/fluux-sdk/src/core/fastTokenInvalidation.test.ts index 44bdce75..9a07d7e5 100644 --- a/packages/fluux-sdk/src/core/fastTokenInvalidation.test.ts +++ b/packages/fluux-sdk/src/core/fastTokenInvalidation.test.ts @@ -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({ diff --git a/packages/fluux-sdk/src/core/fastTokenInvalidation.ts b/packages/fluux-sdk/src/core/fastTokenInvalidation.ts index 5204ac9f..45b1ef95 100644 --- a/packages/fluux-sdk/src/core/fastTokenInvalidation.ts +++ b/packages/fluux-sdk/src/core/fastTokenInvalidation.ts @@ -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 { @@ -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' } } diff --git a/packages/fluux-sdk/src/core/modules/Connection.test.ts b/packages/fluux-sdk/src/core/modules/Connection.test.ts index 771a04e0..8df62cb4 100644 --- a/packages/fluux-sdk/src/core/modules/Connection.test.ts +++ b/packages/fluux-sdk/src/core/modules/Connection.test.ts @@ -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(() => {})) + + // 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 () => { @@ -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 }) @@ -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 () => { @@ -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 () => { diff --git a/packages/fluux-sdk/src/core/modules/Connection.ts b/packages/fluux-sdk/src/core/modules/Connection.ts index 068d8dda..6a6ac78f 100644 --- a/packages/fluux-sdk/src/core/modules/Connection.ts +++ b/packages/fluux-sdk/src/core/modules/Connection.ts @@ -718,6 +718,20 @@ export class Connection extends BaseModule { 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 } @@ -739,16 +753,18 @@ export class Connection extends BaseModule { // 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})` : ''}`) @@ -760,12 +776,6 @@ export class Connection extends BaseModule { 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()