From 60a5c4f90ff72ad009148d0f29258f69bd8dc244 Mon Sep 17 00:00:00 2001 From: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Date: Mon, 11 May 2026 18:14:44 -0700 Subject: [PATCH] fix(auth): treat missing backend session token as auth-expired (#1504) --- .../services/__tests__/coreRpcClient.test.ts | 7 +++++++ app/src/services/coreRpcClient.ts | 6 ++++++ src/core/jsonrpc.rs | 10 ++++++++++ src/core/jsonrpc_tests.rs | 19 +++++++++++++++++++ 4 files changed, 42 insertions(+) diff --git a/app/src/services/__tests__/coreRpcClient.test.ts b/app/src/services/__tests__/coreRpcClient.test.ts index e5ded5795..513c3b2ae 100644 --- a/app/src/services/__tests__/coreRpcClient.test.ts +++ b/app/src/services/__tests__/coreRpcClient.test.ts @@ -500,6 +500,13 @@ describe('classifyRpcError', () => { ['GET /teams failed (401 Unauthorized): {"success":false}', undefined, 'auth_expired'], ['Session expired. Please log in again.', undefined, 'auth_expired'], ['some prefix Session expired suffix', undefined, 'auth_expired'], + [ + 'composio unavailable: no backend session token. Sign in first (auth_store_session).', + undefined, + 'auth_expired', + ], + ['no backend session token; run auth_store_session first', undefined, 'auth_expired'], + ['NO BACKEND SESSION TOKEN', undefined, 'auth_expired'], ['HTTP 429 rate-limit exceeded', undefined, 'rate_limited'], ['Budget exceeded for current period', undefined, 'budget_exceeded'], ['Insufficient budget for request', undefined, 'budget_exceeded'], diff --git a/app/src/services/coreRpcClient.ts b/app/src/services/coreRpcClient.ts index 926553fee..0a27afe23 100644 --- a/app/src/services/coreRpcClient.ts +++ b/app/src/services/coreRpcClient.ts @@ -77,6 +77,12 @@ export function classifyRpcError(message: string, httpStatus?: number): CoreRpcE if (httpStatus === 401) return 'auth_expired'; if (httpStatus === 429) return 'rate_limited'; if (/\(401\b.*Unauthorized\)|Session expired/i.test(message)) return 'auth_expired'; + // Core-side "no backend session token" → the auth profile is gone but the + // frontend may still hold a stale sessionToken from an optimistic post-login + // patch. Treat as auth-expired so `CoreStateProvider` clears the session and + // `ProtectedRoute` bounces the user back to `/` (login) instead of trapping + // them on an onboarding step that polls a failing RPC every 5 s. + if (/no backend session token/i.test(message)) return 'auth_expired'; if (/429.*rate.?limit/i.test(message)) return 'rate_limited'; if (/Budget exceeded|Insufficient budget/i.test(message)) return 'budget_exceeded'; if (/error sending request|client error \(Connect\)|timed out|ECONNREFUSED/i.test(message)) { diff --git a/src/core/jsonrpc.rs b/src/core/jsonrpc.rs index acdf2a533..0b5b00445 100644 --- a/src/core/jsonrpc.rs +++ b/src/core/jsonrpc.rs @@ -118,10 +118,20 @@ pub async fn invoke_method(state: AppState, method: &str, params: Value) -> Resu } /// Helper to determine if an error message indicates an expired or invalid session. +/// +/// "No backend session token" is also treated as a session-expired signal: the +/// auth profile is missing entirely (the user was never signed in, or their +/// stored profile was wiped between login and the next RPC). The frontend may +/// still believe it holds a session token from an optimistic post-login patch, +/// so we want the same auto-cleanup + UI-level re-auth path to fire instead of +/// repeatedly reporting this as a hard error to Sentry. See #1465-ish: users +/// stuck on the onboarding `SkillsStep` would spam `composio_list_connections` +/// failures every 5 s without ever being bounced back to the login screen. fn is_session_expired_error(msg: &str) -> bool { let lower = msg.to_lowercase(); (lower.contains("401") && lower.contains("unauthorized")) || lower.contains("invalid token") + || lower.contains("no backend session token") || msg.contains("SESSION_EXPIRED") } diff --git a/src/core/jsonrpc_tests.rs b/src/core/jsonrpc_tests.rs index 193b1bfba..3aa845ff0 100644 --- a/src/core/jsonrpc_tests.rs +++ b/src/core/jsonrpc_tests.rs @@ -578,6 +578,25 @@ fn is_session_expired_error_does_not_match_unrelated_errors() { assert!(!is_session_expired_error("")); } +#[test] +fn is_session_expired_error_matches_missing_backend_session_token() { + // Composio / web search / billing / team / webhooks / referral all surface + // a "no backend session token" variant when the auth profile is gone. Each + // of these should funnel into the auto-cleanup path instead of being + // reported to Sentry as a fresh error on every 5 s poll. + assert!(is_session_expired_error( + "composio unavailable: no backend session token. Sign in first (auth_store_session)." + )); + assert!(is_session_expired_error( + "no backend session token; run auth_store_session first" + )); + assert!(is_session_expired_error( + "Web search unavailable: no backend session token. Sign in first so the server can proxy search." + )); + // Case-insensitive match — the helper lowercases first. + assert!(is_session_expired_error("NO BACKEND SESSION TOKEN")); +} + #[test] fn escape_html_escapes_all_special_chars() { let raw = r#""#;