mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
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:
co-authored by
Steven Enamakel
Steven Enamakel
parent
2f5db77734
commit
27bea247cf
@@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.';
|
||||
}
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 } }));
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user