diff --git a/app/src/providers/CoreStateProvider.tsx b/app/src/providers/CoreStateProvider.tsx index dae720b42..20be39887 100644 --- a/app/src/providers/CoreStateProvider.tsx +++ b/app/src/providers/CoreStateProvider.tsx @@ -16,6 +16,7 @@ import { setCoreStateSnapshot, } from '../lib/coreState/store'; import { syncAnalyticsConsent } from '../services/analytics'; +import type { AuthExpiredReason } from '../services/coreRpcClient'; import { fetchCoreAppSnapshot, getTeamInvites, @@ -30,6 +31,7 @@ import { loadThreads, resetThreadCachesPreservingSelection } from '../store/thre import { getActiveUserId, setActiveUserId } from '../store/userScopedStorage'; import { isLocalSessionToken } from '../utils/localSession'; import { + getSessionToken, openhumanUpdateAnalyticsSettings, openhumanUpdateMeetSettings, restartApp, @@ -63,6 +65,53 @@ function sanitizeError(error: unknown): { message?: string; code?: string; statu return { message: String(error) }; } +/** + * Positively confirm the on-disk session token is gone before an `auth_expired` + * signal is allowed to trigger the *destructive* `clearSession` (which calls + * `auth_clear_session` → removes the auth profile from disk). + * + * Reads the cheap disk-only `auth_get_session_token` RPC — no `auth/me` network + * call, not subject to `app_state_snapshot`'s 5s/10s timeouts. Right after the + * identity-flip restart the token IS on disk, but a token-gated RPC can briefly + * report "session jwt required" before the profile finishes loading; a short + * retry rides out that boot-load window. + * + * Returns `true` ONLY when every attempt reads an empty token. A token that is + * present, or an RPC failure (inconclusive), returns `false` — biasing toward + * keeping the session rather than destroying a valid one. + */ +async function confirmSessionTokenGone(): Promise { + const ATTEMPTS = 3; + const RETRY_DELAY_MS = 300; + for (let attempt = 1; attempt <= ATTEMPTS; attempt++) { + let token: string | null; + try { + token = await getSessionToken(); + } catch (err) { + log( + 'auth-expired corroboration inconclusive (attempt %d/%d) — keeping session: %O', + attempt, + ATTEMPTS, + sanitizeError(err) + ); + return false; + } + if (token && token.trim() !== '') { + log( + 'auth-expired corroboration: session token still on disk (attempt %d/%d) — keeping session', + attempt, + ATTEMPTS + ); + return false; + } + if (attempt < ATTEMPTS) { + await new Promise(resolve => setTimeout(resolve, RETRY_DELAY_MS)); + } + } + log('auth-expired corroboration: session token confirmed absent after %d attempts', ATTEMPTS); + return true; +} + export function coreStatePollFailureWarningMessage(failureCount: number): string | null { if (failureCount <= 0) { return null; @@ -601,6 +650,12 @@ export default function CoreStateProvider({ children }: { children: ReactNode }) ); const lastReauthAtRef = useRef(0); + // Reason that claimed the current debounce slot, and a monotonic attempt id. + // Together they let a `confirmed` expiry break through a slot held by an + // `unconfirmed` probe, while preventing an in-flight unconfirmed + // corroboration from clearing after a newer attempt has superseded it. + const lastReauthReasonRef = useRef(null); + const reauthAttemptIdRef = useRef(0); const suppressReauthUntilRef = useRef(0); // Listen for deep-link auth suppression signals so that an in-flight @@ -664,7 +719,7 @@ export default function CoreStateProvider({ children }: { children: ReactNode }) // latest closure; `clearSession`'s own deps are stable `useCallback`s, // so re-registers are rare. useEffect(() => { - const runReauth = (method: string, source: string) => { + const runReauth = async (method: string, source: string, reason: AuthExpiredReason) => { if (isLocalSessionToken(getCoreStateSnapshot().snapshot.sessionToken)) { log('auth-expired ignored for local session (method=%s source=%s)', method, source); return; @@ -678,20 +733,74 @@ export default function CoreStateProvider({ children }: { children: ReactNode }) ); return; } - if (now - lastReauthAtRef.current < 10_000) { - log('auth-expired debounced (method=%s source=%s)', method, source); + // Debounce coalesces a burst of auth-expired events. EXCEPTION: a + // `confirmed` expiry must NOT be suppressed by a slot claimed by an + // earlier `unconfirmed` probe (which may have bailed without clearing) — + // otherwise a real 401 / `auth:session_expired` landing right after a + // transient boot-race signal would be silently dropped for up to 10s, + // keeping an actually-expired session alive. + const withinDebounce = now - lastReauthAtRef.current < 10_000; + const confirmedOverridesUnconfirmed = + reason === 'confirmed' && lastReauthReasonRef.current === 'unconfirmed'; + if (withinDebounce && !confirmedOverridesUnconfirmed) { + log('auth-expired debounced (method=%s source=%s reason=%s)', method, source, reason); return; } + // Claim the debounce slot before the (async) corroboration so a burst of + // events in the same frame can't all run the check / clear twice. + const attemptId = ++reauthAttemptIdRef.current; lastReauthAtRef.current = now; - log('auth-expired: clearing session (method=%s source=%s)', method, source); + lastReauthReasonRef.current = reason; + + // An `unconfirmed` reason ("session jwt required" / "no backend session + // token") means the core has no token *loaded* — which fires transiently + // right after the identity-flip restart, before the on-disk auth profile + // is read. `clearSession()` is destructive (auth_clear_session removes the + // profile from disk), so corroborate first and only sign out if the token + // is genuinely gone. A hard 401 / explicit expiry (`confirmed`) skips this. + if (reason === 'unconfirmed') { + const gone = await confirmSessionTokenGone(); + // A newer reauth attempt superseded this one while we were awaiting + // (e.g. a `confirmed` 401 broke through the debounce) — don't double- + // clear or stomp the newer attempt's outcome. + if (attemptId !== reauthAttemptIdRef.current) { + log( + 'auth-expired corroboration superseded by a newer attempt — skipping (method=%s source=%s)', + method, + source + ); + return; + } + if (!gone) { + log( + 'auth-expired NOT cleared — unconfirmed signal but session token still present (method=%s source=%s)', + method, + source + ); + return; + } + } + + // Reaching here means we're committing to a real sign-out. Mark the slot + // `confirmed` so a follow-up `confirmed` event inside the debounce window + // is coalesced (no double-clear) rather than breaking through again. + lastReauthReasonRef.current = 'confirmed'; + log('auth-expired: clearing session (method=%s source=%s reason=%s)', method, source, reason); void clearSession().catch(err => { log('clearSession failed after auth-expired: %O', sanitizeError(err)); }); }; const onRpcExpired = (event: Event) => { - const detail = (event as CustomEvent<{ method?: string; source?: string }>).detail; - runReauth(detail?.method ?? 'unknown', detail?.source ?? 'core-rpc-auth-expired'); + const detail = ( + event as CustomEvent<{ method?: string; source?: string; reason?: AuthExpiredReason }> + ).detail; + // Default to 'unconfirmed' (corroborate, don't destroy) when no reason is present. + void runReauth( + detail?.method ?? 'unknown', + detail?.source ?? 'core-rpc-auth-expired', + detail?.reason ?? 'unconfirmed' + ); }; const onSocketExpired = (event: Event) => { @@ -703,7 +812,8 @@ export default function CoreStateProvider({ children }: { children: ReactNode }) typeof (event.detail as { source?: unknown }).source === 'string' ? (event.detail as { source: string }).source : 'unknown'; - runReauth('socket.session_expired', source); + // The socket `auth:session_expired` push is an explicit backend expiry → confirmed. + void runReauth('socket.session_expired', source, 'confirmed'); }; window.addEventListener('core-rpc-auth-expired', onRpcExpired as EventListener); diff --git a/app/src/providers/__tests__/CoreStateProvider.test.tsx b/app/src/providers/__tests__/CoreStateProvider.test.tsx index 6187a78e4..3b26733d5 100644 --- a/app/src/providers/__tests__/CoreStateProvider.test.tsx +++ b/app/src/providers/__tests__/CoreStateProvider.test.tsx @@ -579,11 +579,13 @@ describe('CoreStateProvider — identity-change cache clearing', () => { await waitFor(() => expect(screen.getByTestId('ready').textContent).toBe('ready')); - // First dispatch should clear the session. + // First dispatch should clear the session. `reason: 'confirmed'` (a real + // 401 / explicit expiry) skips the disk-token corroboration and clears + // immediately. await act(async () => { window.dispatchEvent( new CustomEvent('core-rpc-auth-expired', { - detail: { method: 'openhuman.team_get_usage', source: 'rpc' }, + detail: { method: 'openhuman.team_get_usage', source: 'rpc', reason: 'confirmed' }, }) ); }); @@ -607,6 +609,109 @@ describe('CoreStateProvider — identity-change cache clearing', () => { expect(vi.mocked(tauriCommands.logout)).toHaveBeenCalledTimes(1); }); + it('does NOT clear the session on an unconfirmed auth-expired when the token is still on disk (restart boot-race guard)', async () => { + fetchSnapshot.mockResolvedValue(makeSnapshot({ userId: 'u1', sessionToken: 'tok1' })); + listTeams.mockResolvedValue([]); + vi.mocked(tauriCommands.logout).mockReset(); + vi.mocked(tauriCommands.logout).mockResolvedValue(undefined as never); + // The cheap disk-only token read still finds the persisted token — this is + // the transient "session jwt required" right after the identity-flip + // restart (token on disk, just not loaded by the racing RPC), not a real + // expiry. The destructive clearSession MUST be skipped. + vi.mocked(tauriCommands.getSessionToken).mockReset(); + vi.mocked(tauriCommands.getSessionToken).mockResolvedValue('tok1'); + + render( + + + + ); + await waitFor(() => expect(screen.getByTestId('ready').textContent).toBe('ready')); + + await act(async () => { + window.dispatchEvent( + new CustomEvent('core-rpc-auth-expired', { + detail: { method: 'openhuman.auth_get_me', source: 'rpc', reason: 'unconfirmed' }, + }) + ); + }); + // Flush the corroboration microtasks. + await act(async () => {}); + + expect(vi.mocked(tauriCommands.getSessionToken)).toHaveBeenCalled(); + expect(vi.mocked(tauriCommands.logout)).not.toHaveBeenCalled(); + }); + + it('clears the session on an unconfirmed auth-expired only after corroborating the token is gone', async () => { + fetchSnapshot.mockResolvedValue(makeSnapshot({ userId: 'u1', sessionToken: 'tok1' })); + listTeams.mockResolvedValue([]); + vi.mocked(tauriCommands.logout).mockReset(); + vi.mocked(tauriCommands.logout).mockResolvedValue(undefined as never); + // Disk read confirms the token is genuinely gone → a real sign-out, so the + // destructive clearSession is allowed to proceed. + vi.mocked(tauriCommands.getSessionToken).mockReset(); + vi.mocked(tauriCommands.getSessionToken).mockResolvedValue(null); + + render( + + + + ); + await waitFor(() => expect(screen.getByTestId('ready').textContent).toBe('ready')); + + await act(async () => { + window.dispatchEvent( + new CustomEvent('core-rpc-auth-expired', { + detail: { method: 'openhuman.auth_get_me', source: 'rpc', reason: 'unconfirmed' }, + }) + ); + }); + + await waitFor(() => expect(vi.mocked(tauriCommands.logout)).toHaveBeenCalledTimes(1), { + timeout: 3000, + }); + }); + + it('lets a confirmed expiry break through a debounce slot claimed by an unconfirmed probe', async () => { + fetchSnapshot.mockResolvedValue(makeSnapshot({ userId: 'u1', sessionToken: 'tok1' })); + listTeams.mockResolvedValue([]); + vi.mocked(tauriCommands.logout).mockReset(); + vi.mocked(tauriCommands.logout).mockResolvedValue(undefined as never); + // Unconfirmed probe still finds the token → bails (keeps session) but would + // otherwise hold the 10s debounce slot. + vi.mocked(tauriCommands.getSessionToken).mockReset(); + vi.mocked(tauriCommands.getSessionToken).mockResolvedValue('tok1'); + + render( + + + + ); + await waitFor(() => expect(screen.getByTestId('ready').textContent).toBe('ready')); + + // 1) Transient unconfirmed signal — must NOT clear, but claims the slot. + await act(async () => { + window.dispatchEvent( + new CustomEvent('core-rpc-auth-expired', { + detail: { method: 'openhuman.auth_get_me', source: 'rpc', reason: 'unconfirmed' }, + }) + ); + }); + await act(async () => {}); + expect(vi.mocked(tauriCommands.logout)).not.toHaveBeenCalled(); + + // 2) A real 401 within the debounce window MUST still sign out. + await act(async () => { + window.dispatchEvent( + new CustomEvent('core-rpc-auth-expired', { + detail: { method: 'openhuman.team_get_usage', source: 'rpc', reason: 'confirmed' }, + }) + ); + }); + + await waitFor(() => expect(vi.mocked(tauriCommands.logout)).toHaveBeenCalledTimes(1)); + }); + it('core-state:suppress-reauth suppresses auth-expired clearSession during deep-link delivery (#2377)', async () => { fetchSnapshot.mockResolvedValue(makeSnapshot({ userId: 'u1', sessionToken: 'tok1' })); listTeams.mockResolvedValue([]); @@ -668,7 +773,7 @@ describe('CoreStateProvider — identity-change cache clearing', () => { await act(async () => { window.dispatchEvent( new CustomEvent('core-rpc-auth-expired', { - detail: { method: 'openhuman.team_get_usage', source: 'rpc' }, + detail: { method: 'openhuman.team_get_usage', source: 'rpc', reason: 'confirmed' }, }) ); }); diff --git a/app/src/services/__tests__/coreRpcClient.test.ts b/app/src/services/__tests__/coreRpcClient.test.ts index 0b366f902..af2c7ce88 100644 --- a/app/src/services/__tests__/coreRpcClient.test.ts +++ b/app/src/services/__tests__/coreRpcClient.test.ts @@ -6,6 +6,7 @@ import { CORE_RPC_TIMEOUT_MS } from '../../utils/config'; import type { AccessibilityStatus, CommandResponse } from '../../utils/tauriCommands'; import { callCoreRpc, + classifyAuthExpiredReason, classifyRpcError, CoreRpcError, isThreadNotFoundCoreRpcError, @@ -721,6 +722,27 @@ describe('classifyRpcError', () => { }); }); +describe('classifyAuthExpiredReason', () => { + test.each([ + // Confirmed server-side rejection → safe to sign out immediately. + ['anything', 401, 'confirmed'], + ['Session expired. Please log in again.', undefined, 'confirmed'], + ['SESSION_EXPIRED', undefined, 'confirmed'], + ['GET /teams failed (401 Unauthorized): {"success":false}', undefined, 'confirmed'], + // "Token not loaded yet" → unconfirmed: fires transiently right after the + // restart, before the on-disk auth profile is read. Must NOT be treated as + // a confirmed expiry — `CoreStateProvider` corroborates before logging out. + ['session jwt required', undefined, 'unconfirmed'], + ['SESSION JWT REQUIRED', undefined, 'unconfirmed'], + ['no backend session token; run auth_store_session first', undefined, 'unconfirmed'], + ['composio unavailable: no backend session token', undefined, 'unconfirmed'], + // Unknown auth-expired-ish message defaults to the safe (verify) path. + ['some opaque auth failure', undefined, 'unconfirmed'], + ] as const)('%s (status=%s) => %s', (message, status, expected) => { + expect(classifyAuthExpiredReason(message, status)).toBe(expected); + }); +}); + describe('coreRpcClient — typed errors + auth-expired event', () => { const authExpiredHandler = vi.fn(); diff --git a/app/src/services/coreRpcClient.ts b/app/src/services/coreRpcClient.ts index bf030b591..a1eb0e0b3 100644 --- a/app/src/services/coreRpcClient.ts +++ b/app/src/services/coreRpcClient.ts @@ -173,6 +173,34 @@ export function classifyRpcError( return 'unknown'; } +/** + * Whether an `auth_expired` classification is a *confirmed* server-side + * session rejection versus an *unconfirmed* "no JWT loaded right now" signal. + * + * - `confirmed`: a real HTTP 401, an explicit `Session expired` marker, or a + * backend-path 401 (`GET /path failed (401 Unauthorized)`). The server told + * us the token is invalid → safe to sign out. + * - `unconfirmed`: `session jwt required` / `no backend session token`. These + * mean the core has no token *loaded* — which fires transiently right after + * the identity-flip restart, before the on-disk auth profile is read. Acting + * on it destroys a still-valid session, so `CoreStateProvider` corroborates + * (cheap disk-only `auth_get_session_token`) before logging out. We default + * to `unconfirmed` so the safe "verify, don't destroy" path wins. + */ +export type AuthExpiredReason = 'confirmed' | 'unconfirmed'; + +export function classifyAuthExpiredReason(message: string, httpStatus?: number): AuthExpiredReason { + if (httpStatus === 401) return 'confirmed'; + if (/Session expired|SESSION_EXPIRED/i.test(message)) return 'confirmed'; + if ( + /^(GET|POST|PUT|DELETE|PATCH)\s+\//.test(message) && + /401/.test(message) && + /unauthorized/i.test(message) + ) + return 'confirmed'; + return 'unconfirmed'; +} + function isThreadNotFoundRpcData(data: unknown): boolean { if (!data || typeof data !== 'object') return false; // The server only ever emits kind === 'ThreadNotFound' (see @@ -199,11 +227,11 @@ export function isThreadNotFoundCoreRpcError( return !errorThreadId || errorThreadId === threadId; } -function dispatchAuthExpired(method: string): void { +function dispatchAuthExpired(method: string, reason: AuthExpiredReason): void { if (typeof window === 'undefined') return; try { window.dispatchEvent( - new CustomEvent(AUTH_EXPIRED_EVENT, { detail: { method, source: 'rpc' } }) + new CustomEvent(AUTH_EXPIRED_EVENT, { detail: { method, source: 'rpc', reason } }) ); } catch { // jsdom in some test paths can throw on CustomEvent constructor edge @@ -542,7 +570,11 @@ export async function callCoreRpc({ const text = await response.text(); const httpMessage = `Core RPC HTTP ${response.status}: ${text || response.statusText}`; const kind = classifyRpcError(text || response.statusText, response.status); - if (kind === 'auth_expired') dispatchAuthExpired(payload.method); + if (kind === 'auth_expired') + dispatchAuthExpired( + payload.method, + classifyAuthExpiredReason(text || response.statusText, response.status) + ); throw new CoreRpcError(httpMessage, kind, response.status); } @@ -556,7 +588,8 @@ export async function callCoreRpc({ }); const rawMessage = json.error.message || 'Core RPC returned an error'; const kind = classifyRpcError(rawMessage, undefined, json.error.data); - if (kind === 'auth_expired') dispatchAuthExpired(payload.method); + if (kind === 'auth_expired') + dispatchAuthExpired(payload.method, classifyAuthExpiredReason(rawMessage, undefined)); throw new CoreRpcError(rawMessage, kind, undefined, json.error.data); } if (!Object.prototype.hasOwnProperty.call(json, 'result')) {