fix(auth): deliver OAuth JWT to remote core in cloud mode (#2453)

Co-authored-by: Steven Enamakel <31011319+senamakel@users.noreply.github.com>
Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
Mega Mind
2026-05-22 19:34:21 -07:00
committed by GitHub
co-authored by Steven Enamakel Steven Enamakel
parent 2f5db77734
commit 27bea247cf
7 changed files with 225 additions and 19 deletions
@@ -4,7 +4,7 @@ import { getCoreStateSnapshot } from '../../../lib/coreState/store';
import { bootCheckTransport } from '../../../services/bootCheckService';
import { testCoreRpcConnection } from '../../../services/coreRpcClient';
import { isTauri } from '../../../services/webviewAccountService';
import { getStoredCoreMode } from '../../../utils/configPersistence';
import { getStoredCoreMode, getStoredCoreToken } from '../../../utils/configPersistence';
import {
oauthAuthReadinessUserMessage,
prepareOAuthLoginLaunch,
@@ -22,7 +22,10 @@ vi.mock('../../../services/bootCheckService', () => ({
bootCheckTransport: { invokeCmd: vi.fn().mockResolvedValue(undefined), callRpc: vi.fn() },
}));
vi.mock('../../../utils/configPersistence', () => ({ getStoredCoreMode: vi.fn() }));
vi.mock('../../../utils/configPersistence', () => ({
getStoredCoreMode: vi.fn(),
getStoredCoreToken: vi.fn().mockReturnValue(null),
}));
vi.mock('../../../services/webviewAccountService', () => ({
isTauri: vi.fn().mockReturnValue(true),
@@ -135,4 +138,30 @@ describe('oauthAuthReadiness', () => {
vi.useRealTimers();
}
});
it('returns cloud-specific message for core_unreachable when mode is cloud', () => {
vi.mocked(getStoredCoreMode).mockReturnValue('cloud');
const msg = oauthAuthReadinessUserMessage('core_unreachable');
expect(msg).toMatch(/remote.*cloud/i);
expect(msg).toMatch(/RPC URL/i);
});
it('returns local-specific message for core_unreachable when mode is local', () => {
vi.mocked(getStoredCoreMode).mockReturnValue('local');
const msg = oauthAuthReadinessUserMessage('core_unreachable');
expect(msg).toMatch(/local runtime/i);
expect(msg).toMatch(/Quit and reopen/i);
});
it('passes cloud token to testCoreRpcConnection when mode is cloud', async () => {
vi.mocked(getStoredCoreMode).mockReturnValue('cloud');
vi.mocked(getStoredCoreToken).mockReturnValue('cloud-bearer-token');
await waitForOAuthAuthReadiness(2_000);
expect(testCoreRpcConnection).toHaveBeenCalledWith(
'http://127.0.0.1:7788/rpc',
'cloud-bearer-token'
);
});
});
+21 -3
View File
@@ -4,7 +4,7 @@ import { getCoreStateSnapshot } from '../../lib/coreState/store';
import { bootCheckTransport } from '../../services/bootCheckService';
import { getCoreRpcUrl, testCoreRpcConnection } from '../../services/coreRpcClient';
import { isTauri } from '../../services/webviewAccountService';
import { getStoredCoreMode } from '../../utils/configPersistence';
import { getStoredCoreMode, getStoredCoreToken } from '../../utils/configPersistence';
const logPrefix = '[oauth-auth-readiness]';
const log = debug('oauth:auth-readiness');
@@ -27,7 +27,17 @@ const delay = (ms: number): Promise<void> =>
async function pingCoreRpc(): Promise<boolean> {
try {
const rpcUrl = await getCoreRpcUrl();
const response = await testCoreRpcConnection(rpcUrl);
// In cloud mode, pass the stored cloud token explicitly to avoid
// getCoreRpcToken() resolving to a stale local-core token. See issue #2377.
const cloudToken = getStoredCoreMode() === 'cloud' ? getStoredCoreToken() : null;
log(`${logPrefix} core.ping probe`, {
rpcUrl,
mode: getStoredCoreMode(),
hasCloudToken: Boolean(cloudToken),
});
const response = cloudToken
? await testCoreRpcConnection(rpcUrl, cloudToken)
: await testCoreRpcConnection(rpcUrl);
return response.ok;
} catch (err) {
log(`${logPrefix} core.ping probe failed`, err);
@@ -112,11 +122,19 @@ export function oauthAuthReadinessUserMessage(reason: OAuthAuthReadinessFailure)
'Finish choosing how OpenHuman runs (tap Continue on the setup screen), ' +
'then try signing in again.'
);
case 'core_unreachable':
case 'core_unreachable': {
const mode = getStoredCoreMode();
if (mode === 'cloud') {
return (
'OpenHuman could not reach its remote (cloud) runtime. ' +
'Check your RPC URL and token in Settings, then try signing in again.'
);
}
return (
'OpenHuman could not reach its local runtime. Quit and reopen the app, ' +
'then try signing in again.'
);
}
default:
return 'Sign-in is still starting up. Wait a few seconds and try again.';
}
+25
View File
@@ -617,6 +617,23 @@ export default function CoreStateProvider({ children }: { children: ReactNode })
);
const lastReauthAtRef = useRef(0);
const suppressReauthUntilRef = useRef(0);
// Listen for deep-link auth suppression signals so that an in-flight
// `auth_store_session` call (OAuth deep link) does not race with the
// `core-rpc-auth-expired` handler and clear the session mid-delivery.
// See issue #2377.
useEffect(() => {
const onSuppressReauth = (event: Event) => {
const until = (event as CustomEvent<{ until: number }>).detail?.until ?? 0;
suppressReauthUntilRef.current = until;
log('[CoreState] suppress-reauth updated until=%d', until);
};
window.addEventListener('core-state:suppress-reauth', onSuppressReauth as EventListener);
return () => {
window.removeEventListener('core-state:suppress-reauth', onSuppressReauth as EventListener);
};
}, []);
const clearSession = useCallback(async () => {
logoutGuardUntilRef.current = Date.now() + 5_000;
@@ -665,6 +682,14 @@ export default function CoreStateProvider({ children }: { children: ReactNode })
useEffect(() => {
const runReauth = (method: string, source: string) => {
const now = Date.now();
if (now < suppressReauthUntilRef.current) {
log(
'[CoreState] auth-expired suppressed during deep-link auth delivery (method=%s source=%s)',
method,
source
);
return;
}
if (now - lastReauthAtRef.current < 10_000) {
log('auth-expired debounced (method=%s source=%s)', method, source);
return;
@@ -550,6 +550,75 @@ describe('CoreStateProvider — identity-change cache clearing', () => {
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([]);
vi.mocked(tauriCommands.logout).mockReset();
vi.mocked(tauriCommands.logout).mockResolvedValue(undefined as never);
render(
<CoreStateProvider>
<Consumer />
</CoreStateProvider>
);
await waitFor(() => expect(screen.getByTestId('ready').textContent).toBe('ready'));
// Arm the suppress window so core-rpc-auth-expired is silenced.
await act(async () => {
window.dispatchEvent(
new CustomEvent('core-state:suppress-reauth', { detail: { until: Date.now() + 30_000 } })
);
});
// auth-expired during the suppress window must not call logout.
await act(async () => {
window.dispatchEvent(
new CustomEvent('core-rpc-auth-expired', {
detail: { method: 'openhuman.auth_store_session', source: 'rpc' },
})
);
});
expect(vi.mocked(tauriCommands.logout)).not.toHaveBeenCalled();
});
it('core-state:suppress-reauth with until=0 re-enables auth-expired handling after deep-link delivery (#2377)', async () => {
fetchSnapshot.mockResolvedValue(makeSnapshot({ userId: 'u1', sessionToken: 'tok1' }));
listTeams.mockResolvedValue([]);
vi.mocked(tauriCommands.logout).mockReset();
vi.mocked(tauriCommands.logout).mockResolvedValue(undefined as never);
render(
<CoreStateProvider>
<Consumer />
</CoreStateProvider>
);
await waitFor(() => expect(screen.getByTestId('ready').textContent).toBe('ready'));
// Arm then immediately disarm so clearSession is allowed again.
await act(async () => {
window.dispatchEvent(
new CustomEvent('core-state:suppress-reauth', { detail: { until: Date.now() + 30_000 } })
);
});
await act(async () => {
window.dispatchEvent(new CustomEvent('core-state:suppress-reauth', { detail: { until: 0 } }));
});
// auth-expired after suppress cleared must call logout.
await act(async () => {
window.dispatchEvent(
new CustomEvent('core-rpc-auth-expired', {
detail: { method: 'openhuman.team_get_usage', source: 'rpc' },
})
);
});
await waitFor(() => expect(vi.mocked(tauriCommands.logout)).toHaveBeenCalledTimes(1));
});
it('ignores forged session-token-updated events that do not match the core snapshot (#1937)', async () => {
fetchSnapshot.mockResolvedValue(makeSnapshot({ userId: 'u1', sessionToken: 'tok1' }));
listTeams.mockResolvedValue([]);
+6
View File
@@ -467,6 +467,12 @@ export async function callCoreRpc<T>({
try {
const [rpcUrl, token] = await Promise.all([getCoreRpcUrl(), getCoreRpcToken()]);
coreRpcLog('HTTP request', { id: payload.id, method: payload.method });
if (normalizedMethod === 'openhuman.auth_store_session') {
coreRpcLog('[rpc] auth_store_session routing', {
rpcUrl,
tokenSource: getStoredCoreToken() ? 'cloud-stored' : 'local-resolved',
});
}
if (coreIsTauri() && !token) {
throw new Error('Core RPC token unavailable in Tauri; local RPC auth cannot be satisfied');
}
@@ -2,14 +2,22 @@ import { isTauri } from '@tauri-apps/api/core';
import { getCurrent, onOpenUrl } from '@tauri-apps/plugin-deep-link';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { clearCoreRpcTokenCache, clearCoreRpcUrlCache } from '../../services/coreRpcClient';
import {
completeDeepLinkAuthProcessing,
getDeepLinkAuthState,
subscribeDeepLinkAuthState,
} from '../../store/deepLinkAuthState';
import { getStoredCoreMode } from '../configPersistence';
import { setupDesktopDeepLinkListener } from '../desktopDeepLinkListener';
import { storeSession } from '../tauriCommands';
vi.mock('../configPersistence', () => ({ getStoredCoreMode: vi.fn() }));
vi.mock('../../services/coreRpcClient', () => ({
clearCoreRpcUrlCache: vi.fn(),
clearCoreRpcTokenCache: vi.fn(),
}));
const waitForAuthSettled = (): Promise<void> =>
new Promise(resolve => {
if (!getDeepLinkAuthState().isProcessing) {
@@ -33,13 +41,6 @@ const waitForOAuthAuthReadiness = vi.hoisted(() =>
vi.fn().mockResolvedValue({ ready: true as const })
);
const coreRpcCache = vi.hoisted(() => ({
clearCoreRpcUrlCache: vi.fn(),
clearCoreRpcTokenCache: vi.fn(),
}));
vi.mock('../../services/coreRpcClient', () => coreRpcCache);
vi.mock('../oauthAppVersionGate', async importOriginal => {
const actual = await importOriginal<typeof import('../oauthAppVersionGate')>();
return {
@@ -66,8 +67,9 @@ describe('desktopDeepLinkListener', () => {
waitForOAuthAuthReadiness.mockResolvedValue({ ready: true });
vi.mocked(storeSession).mockReset();
vi.mocked(storeSession).mockResolvedValue(undefined);
coreRpcCache.clearCoreRpcUrlCache.mockClear();
coreRpcCache.clearCoreRpcTokenCache.mockClear();
vi.mocked(getStoredCoreMode).mockReturnValue(null);
vi.mocked(clearCoreRpcUrlCache).mockClear();
vi.mocked(clearCoreRpcTokenCache).mockClear();
windowControls.show.mockClear();
windowControls.unminimize.mockClear();
windowControls.setFocus.mockClear();
@@ -181,8 +183,6 @@ describe('desktopDeepLinkListener', () => {
await waitForAuthSettled();
expect(storeSession).toHaveBeenCalledWith('abc', {});
expect(coreRpcCache.clearCoreRpcUrlCache).toHaveBeenCalledTimes(1);
expect(coreRpcCache.clearCoreRpcTokenCache).toHaveBeenCalledTimes(1);
expect(getDeepLinkAuthState().isProcessing).toBe(false);
});
@@ -205,4 +205,47 @@ describe('desktopDeepLinkListener', () => {
'OAuth sign-in failed before OpenHuman received authorization. Check the provider app settings and try again.',
});
});
it('busts RPC caches before storeSession in cloud mode', async () => {
vi.mocked(getStoredCoreMode).mockReturnValue('cloud');
vi.mocked(getCurrent).mockResolvedValue(['openhuman://auth?token=abc&key=auth']);
await setupDesktopDeepLinkListener();
await waitForAuthSettled();
expect(clearCoreRpcUrlCache).toHaveBeenCalledTimes(1);
expect(clearCoreRpcTokenCache).toHaveBeenCalledTimes(1);
expect(storeSession).toHaveBeenCalledWith('abc', {});
});
it('does NOT bust RPC caches before storeSession in local mode', async () => {
vi.mocked(getStoredCoreMode).mockReturnValue('local');
vi.mocked(getCurrent).mockResolvedValue(['openhuman://auth?token=abc&key=auth']);
await setupDesktopDeepLinkListener();
await waitForAuthSettled();
expect(clearCoreRpcUrlCache).not.toHaveBeenCalled();
expect(clearCoreRpcTokenCache).not.toHaveBeenCalled();
expect(storeSession).toHaveBeenCalledWith('abc', {});
});
it('dispatches suppress-reauth before storeSession and clears it after in cloud mode', async () => {
vi.mocked(getStoredCoreMode).mockReturnValue('cloud');
vi.mocked(getCurrent).mockResolvedValue(['openhuman://auth?token=abc&key=auth']);
const suppressEvents: Array<{ until: number }> = [];
window.addEventListener('core-state:suppress-reauth', event => {
suppressEvents.push((event as CustomEvent<{ until: number }>).detail);
});
await setupDesktopDeepLinkListener();
await waitForAuthSettled();
// First event: non-zero until (suppress on)
expect(suppressEvents.length).toBeGreaterThanOrEqual(2);
expect(suppressEvents[0].until).toBeGreaterThan(0);
// Last event: until=0 (suppress cleared)
expect(suppressEvents[suppressEvents.length - 1].until).toBe(0);
});
});
+19 -3
View File
@@ -10,6 +10,7 @@ import {
completeDeepLinkAuthProcessing,
failDeepLinkAuthProcessing,
} from '../store/deepLinkAuthState';
import { getStoredCoreMode } from './configPersistence';
import { BILLING_DASHBOARD_URL } from './links';
import {
evaluateOAuthAppVersionGate,
@@ -77,9 +78,24 @@ const focusMainWindow = async () => {
};
const applySessionToken = async (sessionToken: string): Promise<void> => {
clearCoreRpcUrlCache();
clearCoreRpcTokenCache();
await storeSession(sessionToken, {});
// In cloud mode, bust any stale RPC URL/token caches so auth_store_session
// targets the user's configured remote core. See issue #2377.
const currentCoreMode = getStoredCoreMode();
if (currentCoreMode === 'cloud') {
console.debug('[DeepLink] cloud mode: busting RPC caches before session delivery');
clearCoreRpcUrlCache();
clearCoreRpcTokenCache();
}
// Signal CoreStateProvider to hold off clearing session during token delivery.
window.dispatchEvent(
new CustomEvent('core-state:suppress-reauth', { detail: { until: Date.now() + 15_000 } })
);
try {
await storeSession(sessionToken, {});
} finally {
window.dispatchEvent(new CustomEvent('core-state:suppress-reauth', { detail: { until: 0 } }));
}
patchCoreStateSnapshot({ snapshot: { sessionToken } });
window.dispatchEvent(new CustomEvent(SESSION_TOKEN_UPDATED_EVENT, { detail: { sessionToken } }));
};