From 1b3a40695ee5a1eb13341eb545a787a948236c84 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 26 Aug 2026 21:11:19 +0000 Subject: [PATCH 1/2] fix(desktop): clear session on Sign Out and remount AuthGate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings is a separate renderer, so Account Sign Out never told the main window to drop the session. Logout now clears TokenStorage, leftover localStorage token keys, and local identity, broadcasts auth:signed-out, and closes Settings so the main window shows AuthGate. Requesting a magic link still does not authenticate. Co-authored-by: Tomás Maritano --- .../src/main/handlers/authSyncHandlers.ts | 4 +- apps/desktop/src/main/index.ts | 2 + .../main/services/__tests__/session.test.ts | 9 +- apps/desktop/src/main/services/session.ts | 14 +- apps/desktop/src/main/windows/register.ts | 10 +- .../src/main/windows/settingsWindow.ts | 6 + apps/desktop/src/renderer/App.tsx | 3 + .../renderer/hooks/useAuthSessionEvents.ts | 62 +++++++ .../src/renderer/hooks/useDeepLinks.ts | 18 +- .../renderer/pages/settings/SettingsApp.tsx | 4 +- .../settings/sections/AccountSection.tsx | 6 +- .../sections/__tests__/accountSignOut.test.ts | 30 ++++ .../stores/__tests__/authStore.test.ts | 157 ++++++++++++++++++ .../stores/__tests__/authTokenKeys.test.ts | 61 +++++++ apps/desktop/src/renderer/stores/authStore.ts | 67 ++++++-- .../src/renderer/stores/authTokenKeys.ts | 70 ++++++++ 16 files changed, 471 insertions(+), 52 deletions(-) create mode 100644 apps/desktop/src/renderer/hooks/useAuthSessionEvents.ts create mode 100644 apps/desktop/src/renderer/pages/settings/sections/__tests__/accountSignOut.test.ts create mode 100644 apps/desktop/src/renderer/stores/__tests__/authStore.test.ts create mode 100644 apps/desktop/src/renderer/stores/__tests__/authTokenKeys.test.ts create mode 100644 apps/desktop/src/renderer/stores/authTokenKeys.ts diff --git a/apps/desktop/src/main/handlers/authSyncHandlers.ts b/apps/desktop/src/main/handlers/authSyncHandlers.ts index 4ce95916..0281d0a0 100644 --- a/apps/desktop/src/main/handlers/authSyncHandlers.ts +++ b/apps/desktop/src/main/handlers/authSyncHandlers.ts @@ -26,6 +26,7 @@ export interface AuthSyncHandlerDeps { encryptionService: EncryptionService | null; localIdentity: LocalIdentity; broadcastToWindows: BroadcastFn; + closeSettingsWindow?: () => void; } const EmailSchema = z.string().email().max(254); @@ -117,7 +118,6 @@ export function registerAuthSyncHandlers(deps: AuthSyncHandlerDeps): void { getCurrentUser: () => client.getCurrentUser(), getAccessToken: () => storage.getAccessToken(), clearTokens: () => storage.clearTokens(), - readLocal: () => localIdentity.read(), }), }); @@ -129,6 +129,8 @@ export function registerAuthSyncHandlers(deps: AuthSyncHandlerDeps): void { sync?.stopAutoSync(); await storage.clearTokens(); await localIdentity.clear(); + deps.broadcastToWindows('auth:signed-out'); + deps.closeSettingsWindow?.(); return { success: true }; } catch (error) { return { diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index c306a811..85046b81 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -84,6 +84,7 @@ import { createMainWindow, registerQuickCaptureShortcut, registerWindowHandlers, + closeSettingsWindow, } from './windows/register.js'; import { applyDevelopmentModeFromSettings, @@ -541,6 +542,7 @@ app encryptionService, localIdentity: new LocalIdentity(dataPaths.root), broadcastToWindows, + closeSettingsWindow, }); log.info( { encryptionAvailable: safeStorage.isEncryptionAvailable() }, diff --git a/apps/desktop/src/main/services/__tests__/session.test.ts b/apps/desktop/src/main/services/__tests__/session.test.ts index c5b333f5..a3940b4b 100644 --- a/apps/desktop/src/main/services/__tests__/session.test.ts +++ b/apps/desktop/src/main/services/__tests__/session.test.ts @@ -30,7 +30,6 @@ describe('resolveSession', () => { getCurrentUser: async () => user, getAccessToken: async () => jwt({ sub: user.id, email: user.email }), clearTokens: vi.fn(), - readLocal: async () => null, }); expect(session).toEqual({ user }); }); @@ -44,7 +43,6 @@ describe('resolveSession', () => { }, getAccessToken: async () => jwt({ sub: user.id, email: user.email }), clearTokens, - readLocal: async () => null, }); expect(session).toEqual({ user }); expect(clearTokens).not.toHaveBeenCalled(); @@ -59,21 +57,18 @@ describe('resolveSession', () => { }, getAccessToken: async () => jwt({ sub: user.id, email: user.email }), clearTokens, - readLocal: async () => null, }); expect(session).toBeNull(); expect(clearTokens).toHaveBeenCalledOnce(); }); - it('falls back to local identity when there are no tokens', async () => { - const local = { id: 'local', email: 'me@local' }; + it('does not treat leftover local identity as a session', async () => { const session = await resolveSession({ hasTokens: async () => false, getCurrentUser: async () => user, getAccessToken: async () => null, clearTokens: vi.fn(), - readLocal: async () => local, }); - expect(session).toEqual({ user: local }); + expect(session).toBeNull(); }); }); diff --git a/apps/desktop/src/main/services/session.ts b/apps/desktop/src/main/services/session.ts index 7fecc2bb..bef7426b 100644 --- a/apps/desktop/src/main/services/session.ts +++ b/apps/desktop/src/main/services/session.ts @@ -3,6 +3,8 @@ * * Tokens stay on disk unless the API says they are expired (401). * A network blip must not log the user out. + * Leftover continue-locally identity is not a session — AuthGate requires + * a consumed magic-link (JWT in TokenStorage). */ import { ApiError } from './apiClient.js'; @@ -38,12 +40,10 @@ export async function resolveSession(deps: { getCurrentUser: () => Promise; getAccessToken: () => Promise; clearTokens: () => Promise; - readLocal: () => Promise; }): Promise<{ user: SessionUser } | null> { const hasTokens = await deps.hasTokens(); if (!hasTokens) { - const local = await deps.readLocal(); - return local ? { user: local } : null; + return null; } try { @@ -51,15 +51,11 @@ export async function resolveSession(deps: { } catch (error) { if (isUnauthorizedError(error)) { await deps.clearTokens(); - const local = await deps.readLocal(); - return local ? { user: local } : null; + return null; } const token = await deps.getAccessToken(); const fromJwt = token ? userFromAccessToken(token) : null; - if (fromJwt) return { user: fromJwt }; - - const local = await deps.readLocal(); - return local ? { user: local } : null; + return fromJwt ? { user: fromJwt } : null; } } diff --git a/apps/desktop/src/main/windows/register.ts b/apps/desktop/src/main/windows/register.ts index 55dfbfee..aafd3728 100644 --- a/apps/desktop/src/main/windows/register.ts +++ b/apps/desktop/src/main/windows/register.ts @@ -4,7 +4,7 @@ import { isClosable } from './closable.js'; import { createMainWindow } from './mainWindow.js'; import { createNoteWindow } from './noteWindow.js'; import { createQuickCaptureWindow } from './quickCaptureWindow.js'; -import { createSettingsWindow } from './settingsWindow.js'; +import { createSettingsWindow, closeSettingsWindow } from './settingsWindow.js'; import { applyFrosted, rememberFrosted } from './vibrancy.js'; export function registerWindowHandlers(): void { @@ -66,4 +66,10 @@ export function registerQuickCaptureShortcut(): void { } } -export { createMainWindow, createNoteWindow, createQuickCaptureWindow, createSettingsWindow }; +export { + createMainWindow, + createNoteWindow, + createQuickCaptureWindow, + createSettingsWindow, + closeSettingsWindow, +}; diff --git a/apps/desktop/src/main/windows/settingsWindow.ts b/apps/desktop/src/main/windows/settingsWindow.ts index d4c52927..71bd3762 100644 --- a/apps/desktop/src/main/windows/settingsWindow.ts +++ b/apps/desktop/src/main/windows/settingsWindow.ts @@ -53,3 +53,9 @@ export function createSettingsWindow(): BrowserWindow { return settingsWindow; } + +export function closeSettingsWindow(): void { + if (settingsWindow && !settingsWindow.isDestroyed()) { + settingsWindow.close(); + } +} diff --git a/apps/desktop/src/renderer/App.tsx b/apps/desktop/src/renderer/App.tsx index 3226e531..85e6603f 100644 --- a/apps/desktop/src/renderer/App.tsx +++ b/apps/desktop/src/renderer/App.tsx @@ -49,6 +49,7 @@ import { historyForward, visitNote, } from './utils/noteHistory'; +import { useAuthSessionEvents } from './hooks/useAuthSessionEvents'; import { useDeepLinks } from './hooks/useDeepLinks'; import { useAutoSave } from './hooks/useAutoSave'; import { useNoteActions } from './hooks/useNoteActions'; @@ -76,6 +77,8 @@ function NotesApp() { const isAuthenticated = useAuthStore(selectIsAuthenticated); const isE2E = window.dripnex?.app?.isE2E?.() === true; + useAuthSessionEvents({ consumeMagicLink: true }); + useEffect(() => { void useAuthStore.getState().loadSession(); }, []); diff --git a/apps/desktop/src/renderer/hooks/useAuthSessionEvents.ts b/apps/desktop/src/renderer/hooks/useAuthSessionEvents.ts new file mode 100644 index 00000000..ae8ba512 --- /dev/null +++ b/apps/desktop/src/renderer/hooks/useAuthSessionEvents.ts @@ -0,0 +1,62 @@ +import { useEffect } from 'react'; +import { applySignedOut, useAuthStore } from '../stores/authStore'; + +function authVerifyToken(value: unknown): string | null { + if (typeof value === 'string' && value.length > 0) return value; + if (!value || typeof value !== 'object') return null; + const rec = value as { kind?: unknown; token?: unknown }; + if (rec.kind === 'auth-verify' && typeof rec.token === 'string' && rec.token.length > 0) { + return rec.token; + } + return null; +} + +function consumeToken(token: string): void { + void useAuthStore + .getState() + .verifyToken(token) + .catch(error => { + console.error('Deep link auth verification failed:', error); + }); +} + +/** + * Settings is a separate renderer from the main window. + * Sign Out must clear that shell too, and AuthGate must consume magic-link + * tokens even before SignedInApp (and useDeepLinks) mounts. + */ +export function useAuthSessionEvents(options?: { consumeMagicLink?: boolean }): void { + const consumeMagicLink = options?.consumeMagicLink === true; + + useEffect(() => { + const ipc = window.dripnex?.ipc; + if (!ipc?.on) return; + + const offSignedOut = ipc.on('auth:signed-out', () => { + applySignedOut(); + }); + + if (!consumeMagicLink) { + return () => { + offSignedOut(); + }; + } + + const offVerify = ipc.on('auth:verify-token', (...args: unknown[]) => { + const token = authVerifyToken(args[0]); + if (token) consumeToken(token); + }); + + const onLocal = (event: Event) => { + const token = authVerifyToken((event as CustomEvent).detail); + if (token) consumeToken(token); + }; + window.addEventListener('dripnex:open', onLocal); + + return () => { + offSignedOut(); + offVerify(); + window.removeEventListener('dripnex:open', onLocal); + }; + }, [consumeMagicLink]); +} diff --git a/apps/desktop/src/renderer/hooks/useDeepLinks.ts b/apps/desktop/src/renderer/hooks/useDeepLinks.ts index 030a8ce1..06a122a6 100644 --- a/apps/desktop/src/renderer/hooks/useDeepLinks.ts +++ b/apps/desktop/src/renderer/hooks/useDeepLinks.ts @@ -1,5 +1,4 @@ import { useEffect } from 'react'; -import { useAuthStore } from '../stores/authStore'; import type { DripnexDeepLink } from '../utils/parseDripnexUrl'; import { dispatchCommand } from './useCommandRegistry'; import { useNavigationActions } from './useNavigation'; @@ -18,15 +17,9 @@ export function useDeepLinks() { useEffect(() => { const apply = (link: DripnexDeepLink) => { - if (link.kind === 'auth-verify') { - void useAuthStore - .getState() - .verifyToken(link.token) - .catch(error => { - console.error('Deep link auth verification failed:', error); - }); - return; - } + // Auth tokens are consumed in NotesApp via useAuthSessionEvents so + // AuthGate can verify before SignedInApp mounts. + if (link.kind === 'auth-verify') return; if (link.kind === 'note') { void dispatchCommand('app:open-note', { noteId: link.noteId, @@ -53,15 +46,10 @@ export function useDeepLinks() { }; const offIpc = window.dripnex.ipc.on('app:deep-link', onIpc); - const offAuth = window.dripnex.ipc.on('auth:verify-token', (...args: unknown[]) => { - const token = typeof args[0] === 'string' ? args[0] : ''; - if (token) apply({ kind: 'auth-verify', token }); - }); window.addEventListener('dripnex:open', onLocal); return () => { offIpc(); - offAuth(); window.removeEventListener('dripnex:open', onLocal); }; }, [goToNotebook, goToTag]); diff --git a/apps/desktop/src/renderer/pages/settings/SettingsApp.tsx b/apps/desktop/src/renderer/pages/settings/SettingsApp.tsx index b710b134..509bf10a 100644 --- a/apps/desktop/src/renderer/pages/settings/SettingsApp.tsx +++ b/apps/desktop/src/renderer/pages/settings/SettingsApp.tsx @@ -1,8 +1,9 @@ import { useState } from 'react'; import { LayoutZone, useThemeOverrides } from '@dripnex/plugin-api'; import { useAppearanceSettings } from '../../hooks/useAppearanceSettings'; -import { usePerformanceMode } from '../../hooks/usePerformanceMode'; +import { useAuthSessionEvents } from '../../hooks/useAuthSessionEvents'; import { useOfficialThemes } from '../../hooks/useOfficialThemes'; +import { usePerformanceMode } from '../../hooks/usePerformanceMode'; import { Toaster } from '../../ui/primitives'; import styles from './SettingsApp.module.css'; import { SettingsSidebar } from './components/SettingsSidebar'; @@ -29,6 +30,7 @@ export function SettingsApp() { useOfficialThemes(); useThemeOverrides(); useAppearanceSettings(); + useAuthSessionEvents(); const [activeSection, setActiveSection] = useState('general'); const renderSection = () => { diff --git a/apps/desktop/src/renderer/pages/settings/sections/AccountSection.tsx b/apps/desktop/src/renderer/pages/settings/sections/AccountSection.tsx index d88e3ae8..bd354a9f 100644 --- a/apps/desktop/src/renderer/pages/settings/sections/AccountSection.tsx +++ b/apps/desktop/src/renderer/pages/settings/sections/AccountSection.tsx @@ -20,7 +20,6 @@ import { useAuthStore, selectUser, selectIsAuthenticated, - selectIsLoading, selectError, } from '../../../stores/authStore'; import { @@ -53,7 +52,6 @@ function formatBytes(bytes: number): string { export function AccountSection() { const user = useAuthStore(selectUser); const isAuthenticated = useAuthStore(selectIsAuthenticated); - const isLoading = useAuthStore(selectIsLoading); const authError = useAuthStore(selectError); const logout = useAuthStore(state => state.logout); const loadSession = useAuthStore(state => state.loadSession); @@ -118,7 +116,6 @@ export function AccountSection() { setMessage(null); try { await logout(); - setMessage('Signed out successfully'); } catch (error) { setMessage(`Sign out failed: ${error instanceof Error ? error.message : 'Unknown error'}`); } @@ -249,8 +246,7 @@ export function AccountSection() { variant="danger" size="sm" icon={} - onClick={handleSignOut} - disabled={isLoading} + onClick={() => void handleSignOut()} > Sign Out diff --git a/apps/desktop/src/renderer/pages/settings/sections/__tests__/accountSignOut.test.ts b/apps/desktop/src/renderer/pages/settings/sections/__tests__/accountSignOut.test.ts new file mode 100644 index 00000000..45430bf7 --- /dev/null +++ b/apps/desktop/src/renderer/pages/settings/sections/__tests__/accountSignOut.test.ts @@ -0,0 +1,30 @@ +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +const here = dirname(fileURLToPath(import.meta.url)); +const account = readFileSync(join(here, '../AccountSection.tsx'), 'utf8'); +const app = readFileSync(join(here, '../../../../App.tsx'), 'utf8'); +const settingsApp = readFileSync(join(here, '../../SettingsApp.tsx'), 'utf8'); +const handlers = readFileSync( + join(here, '../../../../../main/handlers/authSyncHandlers.ts'), + 'utf8' +); + +describe('Settings → Account Sign Out', () => { + it('calls a real logout and does not disable Sign Out while session hydrates', () => { + expect(account).toContain('handleSignOut'); + expect(account).toContain('await logout()'); + expect(account).toContain('Sign Out'); + expect(account).not.toMatch(/disabled=\{isLoading\}/); + }); + + it('main and settings windows listen for auth:signed-out so AuthGate remounts', () => { + expect(app).toContain('useAuthSessionEvents({ consumeMagicLink: true })'); + expect(settingsApp).toContain('useAuthSessionEvents()'); + expect(handlers).toContain("broadcastToWindows('auth:signed-out')"); + expect(handlers).toContain('clearTokens()'); + expect(handlers).toContain('localIdentity.clear()'); + }); +}); diff --git a/apps/desktop/src/renderer/stores/__tests__/authStore.test.ts b/apps/desktop/src/renderer/stores/__tests__/authStore.test.ts new file mode 100644 index 00000000..86ce54f7 --- /dev/null +++ b/apps/desktop/src/renderer/stores/__tests__/authStore.test.ts @@ -0,0 +1,157 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { leftoverAuthKeys } from '../authTokenKeys'; +import { applySignedOut, useAuthStore } from '../authStore'; +import { resolveAppShell } from '../../utils/appShell'; + +function memoryStorage(initial: Record = {}): Storage { + const map = new Map(Object.entries(initial)); + return { + get length() { + return map.size; + }, + clear() { + map.clear(); + }, + getItem(key) { + return map.has(key) ? map.get(key)! : null; + }, + key(index) { + return [...map.keys()][index] ?? null; + }, + removeItem(key) { + map.delete(key); + }, + setItem(key, value) { + map.set(key, String(value)); + }, + }; +} + +function signedInShell(isAuthenticated: boolean, sessionHydrated = true) { + return resolveAppShell({ + onboardingComplete: true, + isAuthenticated, + sessionHydrated, + }); +} + +describe('authStore sign-out and magic-link request', () => { + const user = { id: 'u1', email: 'tomas@dripnex.app' }; + let storage: Storage; + let auth: { + requestMagicLink: ReturnType; + logout: ReturnType; + getSession: ReturnType; + }; + + beforeEach(() => { + storage = memoryStorage(); + auth = { + requestMagicLink: vi.fn(async () => ({ success: true })), + logout: vi.fn(async () => ({ success: true })), + getSession: vi.fn(async () => null), + }; + vi.stubGlobal('window', { + localStorage: storage, + dripnex: { + auth, + sync: { + stopAutoSync: vi.fn(async () => ({ success: true })), + startAutoSync: vi.fn(async () => ({ success: true })), + }, + license: { getState: vi.fn(async () => ({ status: 'free' })) }, + encryption: { isReady: vi.fn(async () => ({ ready: false })) }, + }, + }); + useAuthStore.setState({ + user: null, + isAuthenticated: false, + isLoading: false, + sessionHydrated: false, + error: null, + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('sign-out clears leftover token keys and leaves an unauthenticated shell', async () => { + storage.setItem('dripnex-auth', JSON.stringify({ accessToken: 'a', refreshToken: 'r' })); + storage.setItem( + 'auth-storage', + JSON.stringify({ state: { accessToken: 'a', refreshToken: 'r' } }) + ); + useAuthStore.setState({ + user, + isAuthenticated: true, + sessionHydrated: true, + isLoading: false, + error: null, + }); + expect(signedInShell(true)).toBe('workspace'); + + await useAuthStore.getState().logout(); + + const state = useAuthStore.getState(); + expect(state.isAuthenticated).toBe(false); + expect(state.user).toBeNull(); + expect(state.sessionHydrated).toBe(true); + expect(leftoverAuthKeys(storage)).toEqual([]); + expect(auth.logout).toHaveBeenCalledOnce(); + expect(signedInShell(state.isAuthenticated, state.sessionHydrated)).toBe('auth'); + }); + + it('applySignedOut from another window also empties leftover token keys', () => { + storage.setItem( + 'dripnex-auth-storage', + JSON.stringify({ accessToken: 'a', refreshToken: 'r' }) + ); + useAuthStore.setState({ + user, + isAuthenticated: true, + sessionHydrated: true, + }); + + applySignedOut(); + + expect(useAuthStore.getState().isAuthenticated).toBe(false); + expect(leftoverAuthKeys(storage)).toEqual([]); + expect(signedInShell(false)).toBe('auth'); + }); + + it('requesting a magic link does not set authenticated', async () => { + await useAuthStore.getState().requestMagicLink('tomas@dripnex.app'); + + const state = useAuthStore.getState(); + expect(auth.requestMagicLink).toHaveBeenCalledWith('tomas@dripnex.app'); + expect(state.isAuthenticated).toBe(false); + expect(state.user).toBeNull(); + expect(leftoverAuthKeys(storage)).toEqual([]); + expect(signedInShell(state.isAuthenticated, true)).toBe('auth'); + }); + + it('does not restore a session from an in-flight getSession after sign-out', async () => { + let resolveSession!: (value: { user: typeof user } | null) => void; + auth.getSession.mockReturnValue( + new Promise(resolve => { + resolveSession = resolve; + }) + ); + + useAuthStore.setState({ + user, + isAuthenticated: true, + sessionHydrated: true, + }); + + const pending = useAuthStore.getState().loadSession(); + await useAuthStore.getState().logout(); + resolveSession({ user }); + await pending; + + expect(useAuthStore.getState().isAuthenticated).toBe(false); + expect(useAuthStore.getState().user).toBeNull(); + expect(signedInShell(false)).toBe('auth'); + }); +}); diff --git a/apps/desktop/src/renderer/stores/__tests__/authTokenKeys.test.ts b/apps/desktop/src/renderer/stores/__tests__/authTokenKeys.test.ts new file mode 100644 index 00000000..6bbcf80c --- /dev/null +++ b/apps/desktop/src/renderer/stores/__tests__/authTokenKeys.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; +import { + LEFTOVER_AUTH_STORAGE_KEYS, + clearRendererAuthTokens, + leftoverAuthKeys, + looksLikeAuthTokenBlob, +} from '../authTokenKeys'; + +function memoryStorage(initial: Record = {}): Storage { + const map = new Map(Object.entries(initial)); + return { + get length() { + return map.size; + }, + clear() { + map.clear(); + }, + getItem(key) { + return map.has(key) ? map.get(key)! : null; + }, + key(index) { + return [...map.keys()][index] ?? null; + }, + removeItem(key) { + map.delete(key); + }, + setItem(key, value) { + map.set(key, String(value)); + }, + }; +} + +describe('leftover auth token keys', () => { + it('detects persist blobs with access and refresh tokens', () => { + expect(looksLikeAuthTokenBlob(JSON.stringify({ accessToken: 'a', refreshToken: 'r' }))).toBe( + true + ); + expect( + looksLikeAuthTokenBlob( + JSON.stringify({ state: { accessToken: 'a', refreshToken: 'r' }, version: 0 }) + ) + ).toBe(true); + expect(looksLikeAuthTokenBlob(JSON.stringify({ theme: 'dark' }))).toBe(false); + }); + + it('clears known leftover keys and token-shaped values', () => { + const storage = memoryStorage({ + 'dripnex-auth': JSON.stringify({ accessToken: 'a', refreshToken: 'r' }), + 'dripnex-settings': JSON.stringify({ appearance: {} }), + other: JSON.stringify({ state: { accessToken: 'x', refreshToken: 'y' } }), + }); + + expect(leftoverAuthKeys(storage).sort()).toEqual(['dripnex-auth', 'other']); + clearRendererAuthTokens(storage); + expect(leftoverAuthKeys(storage)).toEqual([]); + expect(storage.getItem('dripnex-settings')).toBeTruthy(); + for (const key of LEFTOVER_AUTH_STORAGE_KEYS) { + expect(storage.getItem(key)).toBeNull(); + } + }); +}); diff --git a/apps/desktop/src/renderer/stores/authStore.ts b/apps/desktop/src/renderer/stores/authStore.ts index 47f0baeb..51b0155f 100644 --- a/apps/desktop/src/renderer/stores/authStore.ts +++ b/apps/desktop/src/renderer/stores/authStore.ts @@ -1,4 +1,5 @@ import { create } from 'zustand'; +import { clearRendererAuthTokens } from './authTokenKeys'; // ============================================================================ // Types @@ -34,6 +35,21 @@ interface AuthState { clearError: () => void; } +// ============================================================================ +// Signed-out reset +// ============================================================================ + +/** Bumped on Sign Out so an in-flight getSession cannot restore the shell. */ +let sessionEpoch = 0; + +function rendererStorage(): Storage | null { + try { + return typeof window !== 'undefined' ? window.localStorage : null; + } catch { + return null; + } +} + // ============================================================================ // Store Implementation // ============================================================================ @@ -146,22 +162,24 @@ export const useAuthStore = create()(set => ({ }, /** - * Logout and clear tokens + * Logout and clear tokens. Clears this window immediately so AuthGate + * remounts even if Settings is a separate renderer from the main shell. */ logout: async () => { - set({ isLoading: true, error: null }); + applySignedOut(); try { - // Stop auto-sync before logout - await window.dripnex.sync.stopAutoSync(); + try { + await window.dripnex.sync.stopAutoSync(); + } catch { + // Sync may already be stopped; tokens still need to be cleared. + } - await window.dripnex.auth.logout(); - set({ - user: null, - isAuthenticated: false, - isLoading: false, - }); + const result = await window.dripnex.auth.logout(); + if (!result?.success) { + throw new Error(result?.error || 'Failed to logout'); + } } catch (error) { - set({ + useAuthStore.setState({ isLoading: false, error: error instanceof Error ? error.message : 'Failed to logout', }); @@ -173,9 +191,11 @@ export const useAuthStore = create()(set => ({ * Load existing session on app start */ loadSession: async () => { + const epoch = sessionEpoch; set({ isLoading: true, error: null }); try { const session = await window.dripnex.auth.getSession(); + if (epoch !== sessionEpoch) return; if (session) { set({ user: session.user, @@ -186,9 +206,15 @@ export const useAuthStore = create()(set => ({ await startCloudSyncIfReady(); } else { - set({ isLoading: false, sessionHydrated: true }); + set({ + user: null, + isAuthenticated: false, + isLoading: false, + sessionHydrated: true, + }); } } catch (error) { + if (epoch !== sessionEpoch) return; set({ isLoading: false, sessionHydrated: true, @@ -203,6 +229,23 @@ export const useAuthStore = create()(set => ({ clearError: () => set({ error: null }), })); +/** + * Drop renderer session state and leftover token keys. + * Main-process JWTs are cleared by `auth:logout`. Other windows hear `auth:signed-out`. + */ +export function applySignedOut(): void { + sessionEpoch += 1; + const storage = rendererStorage(); + if (storage) clearRendererAuthTokens(storage); + useAuthStore.setState({ + user: null, + isAuthenticated: false, + isLoading: false, + error: null, + sessionHydrated: true, + }); +} + // ============================================================================ // Selectors // ============================================================================ diff --git a/apps/desktop/src/renderer/stores/authTokenKeys.ts b/apps/desktop/src/renderer/stores/authTokenKeys.ts new file mode 100644 index 00000000..4e6cd113 --- /dev/null +++ b/apps/desktop/src/renderer/stores/authTokenKeys.ts @@ -0,0 +1,70 @@ +/** + * Renderer leftovers that must not skip AuthGate after Sign Out. + * + * Session JWTs live in main-process TokenStorage (`auth.encrypted`), not + * localStorage. Older builds still wrote persist blobs here; Sign Out + * deletes those keys so hydrating cannot see a session. + */ + +export const LEFTOVER_AUTH_STORAGE_KEYS = [ + 'dripnex-auth', + 'dripnex-auth-storage', + 'auth-storage', +] as const; + +export function looksLikeAuthTokenBlob(raw: string): boolean { + try { + return hasAccessAndRefresh(JSON.parse(raw) as unknown); + } catch { + return false; + } +} + +function hasAccessAndRefresh(value: unknown): boolean { + if (!value || typeof value !== 'object') return false; + const rec = value as Record; + if (typeof rec.accessToken === 'string' && typeof rec.refreshToken === 'string') { + return rec.accessToken.length > 0 || rec.refreshToken.length > 0; + } + if (rec.state && typeof rec.state === 'object') { + return hasAccessAndRefresh(rec.state); + } + return false; +} + +/** Drop known leftover keys and any localStorage value that still holds JWTs. */ +export function clearRendererAuthTokens(storage: Storage): void { + for (const key of LEFTOVER_AUTH_STORAGE_KEYS) { + storage.removeItem(key); + } + + const extra: string[] = []; + for (let i = 0; i < storage.length; i += 1) { + const key = storage.key(i); + if (!key) continue; + const raw = storage.getItem(key); + if (raw && looksLikeAuthTokenBlob(raw)) { + extra.push(key); + } + } + for (const key of extra) { + storage.removeItem(key); + } +} + +export function leftoverAuthKeys(storage: Storage): string[] { + const keys: string[] = []; + for (let i = 0; i < storage.length; i += 1) { + const key = storage.key(i); + if (!key) continue; + if ((LEFTOVER_AUTH_STORAGE_KEYS as readonly string[]).includes(key)) { + keys.push(key); + continue; + } + const raw = storage.getItem(key); + if (raw && looksLikeAuthTokenBlob(raw)) { + keys.push(key); + } + } + return keys; +} From 0791ea8cf8a23bb5c34616448327d01be447f803 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 26 Aug 2026 21:25:48 +0000 Subject: [PATCH 2/2] fix(desktop): ignore in-flight verifyToken after Sign Out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sessionEpoch now guards verifyToken and continueLocally the same way as loadSession, so a magic-link consume that started before Sign Out cannot restore the signed-in shell. Co-authored-by: Tomás Maritano --- .../stores/__tests__/authStore.test.ts | 31 +++++++++++++++++++ apps/desktop/src/renderer/stores/authStore.ts | 8 ++++- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/renderer/stores/__tests__/authStore.test.ts b/apps/desktop/src/renderer/stores/__tests__/authStore.test.ts index 86ce54f7..4776d6d5 100644 --- a/apps/desktop/src/renderer/stores/__tests__/authStore.test.ts +++ b/apps/desktop/src/renderer/stores/__tests__/authStore.test.ts @@ -42,6 +42,8 @@ describe('authStore sign-out and magic-link request', () => { requestMagicLink: ReturnType; logout: ReturnType; getSession: ReturnType; + verifyToken: ReturnType; + continueLocally: ReturnType; }; beforeEach(() => { @@ -50,6 +52,8 @@ describe('authStore sign-out and magic-link request', () => { requestMagicLink: vi.fn(async () => ({ success: true })), logout: vi.fn(async () => ({ success: true })), getSession: vi.fn(async () => null), + verifyToken: vi.fn(async () => ({ success: true, user })), + continueLocally: vi.fn(async () => ({ success: true, user })), }; vi.stubGlobal('window', { localStorage: storage, @@ -154,4 +158,31 @@ describe('authStore sign-out and magic-link request', () => { expect(useAuthStore.getState().user).toBeNull(); expect(signedInShell(false)).toBe('auth'); }); + + it('does not restore a session from an in-flight verifyToken after sign-out', async () => { + let resolveVerify!: (value: { success: boolean; user: typeof user }) => void; + auth.verifyToken.mockReturnValue( + new Promise(resolve => { + resolveVerify = resolve; + }) + ); + + storage.setItem('dripnex-auth', JSON.stringify({ accessToken: 'a', refreshToken: 'r' })); + useAuthStore.setState({ + user, + isAuthenticated: true, + sessionHydrated: true, + }); + + const pending = useAuthStore.getState().verifyToken('magic-link-token'); + await useAuthStore.getState().logout(); + resolveVerify({ success: true, user }); + await pending; + + const state = useAuthStore.getState(); + expect(state.isAuthenticated).toBe(false); + expect(state.user).toBeNull(); + expect(leftoverAuthKeys(storage)).toEqual([]); + expect(signedInShell(state.isAuthenticated, state.sessionHydrated)).toBe('auth'); + }); }); diff --git a/apps/desktop/src/renderer/stores/authStore.ts b/apps/desktop/src/renderer/stores/authStore.ts index 51b0155f..514dc862 100644 --- a/apps/desktop/src/renderer/stores/authStore.ts +++ b/apps/desktop/src/renderer/stores/authStore.ts @@ -39,7 +39,7 @@ interface AuthState { // Signed-out reset // ============================================================================ -/** Bumped on Sign Out so an in-flight getSession cannot restore the shell. */ +/** Bumped on Sign Out so in-flight loadSession / verifyToken / continueLocally cannot restore the shell. */ let sessionEpoch = 0; function rendererStorage(): Storage | null { @@ -100,9 +100,11 @@ export const useAuthStore = create()(set => ({ * Create a local-only identity when the cloud API is unreachable. */ continueLocally: async (email: string) => { + const epoch = sessionEpoch; set({ isLoading: true, error: null }); try { const result = await window.dripnex.auth.continueLocally(email); + if (epoch !== sessionEpoch) return; if (!result.success || !result.user) { throw new Error(result.error || 'Failed to continue locally'); } @@ -112,6 +114,7 @@ export const useAuthStore = create()(set => ({ isLoading: false, }); } catch (error) { + if (epoch !== sessionEpoch) return; set({ isLoading: false, error: error instanceof Error ? error.message : 'Failed to continue locally', @@ -124,9 +127,11 @@ export const useAuthStore = create()(set => ({ * Verify magic link token and authenticate */ verifyToken: async (token: string) => { + const epoch = sessionEpoch; set({ isLoading: true, error: null }); try { const result = await window.dripnex.auth.verifyToken(token); + if (epoch !== sessionEpoch) return; if (result.success && result.user) { set({ user: result.user, @@ -139,6 +144,7 @@ export const useAuthStore = create()(set => ({ throw new Error(result.error || 'Verification failed'); } } catch (error) { + if (epoch !== sessionEpoch) return; let errorMessage = 'Failed to verify token'; if (error instanceof Error) {