mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-30 23:14:37 +00:00
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:
co-authored by
Claude Opus 4.7
parent
fcb6dcfeb3
commit
8b702c726b
@@ -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();
|
||||
|
||||
|
||||
@@ -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')) {
|
||||
|
||||
Reference in New Issue
Block a user