Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 31 additions & 2 deletions app/src/components/oauth/__tests__/oauthAuthReadiness.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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),
Expand Down Expand Up @@ -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'
);
});
});
24 changes: 21 additions & 3 deletions app/src/components/oauth/oauthAuthReadiness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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);
Expand Down Expand Up @@ -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.';
}
Expand Down
22 changes: 0 additions & 22 deletions app/src/lib/i18n/chunks/de-5.ts
Original file line number Diff line number Diff line change
Expand Up @@ -523,28 +523,6 @@ const de5: TranslationMap = {
'settings.mascot.colorYellow': 'Gelb',
'settings.mascot.libraryUnavailable': 'OpenHuman Bibliothek nicht verfügbar',
'settings.mascot.title': 'OpenHuman',
'settings.developerMenu.mcpServer.title': 'MCP-Server',
'settings.developerMenu.mcpServer.desc':
'Externe MCP-Clients zur Verbindung mit OpenHuman konfigurieren',
'settings.mcpServer.title': 'MCP-Server',
'settings.mcpServer.toolsSectionTitle': 'Verfügbare Tools',
'settings.mcpServer.toolsSectionDesc':
'Tools, die über den MCP-Stdio-Server bereitgestellt werden, wenn openhuman-core mcp ausgeführt wird',
'settings.mcpServer.configSectionTitle': 'Client-Konfiguration',
'settings.mcpServer.configSectionDesc':
'Wählen Sie Ihren MCP-Client aus, um den passenden Konfigurations-Schnipsel zu erzeugen',
'settings.mcpServer.copySnippet': 'In Zwischenablage kopieren',
'settings.mcpServer.copied': 'Kopiert!',
'settings.mcpServer.openConfigFile': 'Konfigurationsdatei öffnen',
'settings.mcpServer.binaryPathNotFound':
'OpenHuman-Binary nicht gefunden. Wenn Sie aus dem Quellcode arbeiten, bauen Sie mit: cargo build --bin openhuman-core',
'settings.mcpServer.openConfigError': 'Konfigurationsdatei konnte nicht geöffnet werden',
'settings.mcpServer.clientClaudeDesktop': 'Claude Desktop',
'settings.mcpServer.clientCursor': 'Cursor',
'settings.mcpServer.clientCodex': 'Codex',
'settings.mcpServer.clientZed': 'Zed',
'settings.mcpServer.configFilePath': 'Konfigurationsdatei',
'settings.mcpServer.clientSelectorAriaLabel': 'MCP-Client-Auswahl',
};

export default de5;
25 changes: 25 additions & 0 deletions app/src/providers/CoreStateProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
69 changes: 69 additions & 0 deletions app/src/providers/__tests__/CoreStateProvider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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([]);
Expand Down
6 changes: 6 additions & 0 deletions app/src/services/coreRpcClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
Expand Down
65 changes: 54 additions & 11 deletions app/src/utils/__tests__/desktopDeepLinkListener.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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 {
Expand All @@ -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();
Expand Down Expand Up @@ -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);
});

Expand All @@ -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);
});
});
22 changes: 19 additions & 3 deletions app/src/utils/desktopDeepLinkListener.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
completeDeepLinkAuthProcessing,
failDeepLinkAuthProcessing,
} from '../store/deepLinkAuthState';
import { getStoredCoreMode } from './configPersistence';
import { BILLING_DASHBOARD_URL } from './links';
import {
evaluateOAuthAppVersionGate,
Expand Down Expand Up @@ -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 } }));
};
Expand Down
Loading