fix(auth): keep a valid session across the first-login restart (corroborate before destructive logout) (#2758)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
sanil-23
2026-05-27 19:49:54 +05:30
committed by GitHub
co-authored by Claude Opus 4.7
parent fcb6dcfeb3
commit 8b702c726b
4 changed files with 284 additions and 14 deletions
+117 -7
View File
@@ -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<boolean> {
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<AuthExpiredReason | null>(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);
@@ -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(
<CoreStateProvider>
<Consumer />
</CoreStateProvider>
);
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(
<CoreStateProvider>
<Consumer />
</CoreStateProvider>
);
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(
<CoreStateProvider>
<Consumer />
</CoreStateProvider>
);
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' },
})
);
});
@@ -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();
+37 -4
View File
@@ -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<T>({
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<T>({
});
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')) {