From 87fd774ec8ca4ded2d4d63d11d8101083a969b2a Mon Sep 17 00:00:00 2001 From: shanu Date: Mon, 3 Aug 2026 17:08:08 +0530 Subject: [PATCH 1/2] fix(auth): bound store-time /auth/me validation so a slow backend defers instead of bouncing sign-in (#5166) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The store-time GET /auth/me validation ran on the shared backend client's 120s request timeout, but the desktop sign-in RPC that drives auth_store_session gives up after 25s x 2 retries. A reachable-but-slow backend therefore let /auth/me hang past the frontend's patience: the RPC timed out and bounced a genuinely-authenticated user back to sign-in *before* the existing deferred-validation fallback could fire — the auth_me_timeout error in Sentry TAURI-REACT-1V. Cap store-time validation at a 12s budget (overridable via OPENHUMAN_AUTH_ME_STORE_TIMEOUT_MS), well under the frontend budget, and return a transient-classified timeout on exhaustion so a live-exp JWT routes into the caller-authorized pending-session path. The user lands in the app with deferred revalidation instead of being bounced. --- .env.example | 6 + src/openhuman/security/credentials/ops.rs | 67 ++++++++++++ .../security/credentials/ops_tests.rs | 103 ++++++++++++++++++ 3 files changed, 176 insertions(+) diff --git a/.env.example b/.env.example index 750621e21f..a589426b3f 100644 --- a/.env.example +++ b/.env.example @@ -118,6 +118,12 @@ OPENHUMAN_TEMPERATURE=0.7 # research turns). The window resets on every token, so valid long responses are # never cut. Leave unset to use the default. # OPENHUMAN_INFERENCE_STREAM_IDLE_TIMEOUT_SECS= +# [optional] Wall-clock budget (milliseconds) for the store-time GET /auth/me +# validation during sign-in (default 12000). Kept well under the desktop sign-in +# RPC timeout so a reachable-but-slow backend fails fast into deferred +# revalidation for a live-exp JWT instead of hanging until the RPC bounces the +# user back to sign-in (#5166). Leave unset to use the default. +# OPENHUMAN_AUTH_ME_STORE_TIMEOUT_MS=12000 # [optional] Headless update restart contract: self_replace | supervisor # OPENHUMAN_AUTO_UPDATE_RESTART_STRATEGY=self_replace # [optional] Allow bearer-authenticated RPC callers to invoke update.apply/update.run diff --git a/src/openhuman/security/credentials/ops.rs b/src/openhuman/security/credentials/ops.rs index cda9b95241..f6a56de0c1 100644 --- a/src/openhuman/security/credentials/ops.rs +++ b/src/openhuman/security/credentials/ops.rs @@ -24,6 +24,25 @@ use crate::openhuman::memory::conversations; const AUTH_ME_STORE_RETRY_DELAY: Duration = Duration::from_millis(150); const AUTH_ME_STORE_TRANSIENT_STATUSES: &[u16] = &[408, 429, 500, 502, 503, 504, 520]; +/// Wall-clock budget for the store-time `GET /auth/me` validation (issue #5166). +/// +/// The shared backend client allows a 120s request timeout + 15s connect timeout +/// (`api::rest`), but the desktop sign-in RPC that drives `auth_store_session` +/// gives up far sooner — `AUTH_STORE_TIMEOUT_MS` (25s) × `AUTH_STORE_RETRIES` in +/// `desktopDeepLinkListener.ts`. If the backend is reachable but slow, that 120s +/// ceiling lets `/auth/me` hang past the frontend's patience: the RPC times out +/// and bounces a genuinely-authenticated user back to sign-in *before* the +/// deferred-validation fallback in `store_session_inner` ever gets a chance to +/// fire (the exact `auth_me_timeout` bounce in Sentry `TAURI-REACT-1V`). +/// +/// Capping store-time validation well under the frontend budget makes a slow +/// backend fail *fast* into the caller-authorized pending-session path (for a +/// live-`exp` JWT), so the user lands in the app with deferred revalidation +/// instead of being bounced. Overridable via `OPENHUMAN_AUTH_ME_STORE_TIMEOUT_MS` +/// for ops tuning and tests. +const AUTH_ME_STORE_VALIDATION_BUDGET: Duration = Duration::from_secs(12); +const AUTH_ME_STORE_VALIDATION_BUDGET_ENV: &str = "OPENHUMAN_AUTH_ME_STORE_TIMEOUT_MS"; + /// Start all login-gated background services (local AI, voice, and /// orchestration). Called both from the initial boot path (when an existing /// session is detected) and from `store_session()` on fresh login. @@ -610,9 +629,57 @@ async fn store_session_inner( Ok(RpcOutcome::new(summarize_auth_profile(&profile), logs)) } +/// Store-time `GET /auth/me` budget resolver. Reads the +/// `OPENHUMAN_AUTH_ME_STORE_TIMEOUT_MS` override (positive integer milliseconds), +/// otherwise the `AUTH_ME_STORE_VALIDATION_BUDGET` default. +fn auth_me_store_validation_budget() -> Duration { + std::env::var(AUTH_ME_STORE_VALIDATION_BUDGET_ENV) + .ok() + .and_then(|raw| raw.trim().parse::().ok()) + .filter(|ms| *ms > 0) + .map(Duration::from_millis) + .unwrap_or(AUTH_ME_STORE_VALIDATION_BUDGET) +} + +/// Validate the freshly minted session token against `GET /auth/me`, bounded by +/// `auth_me_store_validation_budget()`. On budget exhaustion returns a +/// transient-classified timeout error so `store_session_inner` routes a +/// live-`exp` JWT into the deferred-validation fallback rather than hanging until +/// the desktop sign-in RPC times out and bounces the user (issue #5166). async fn fetch_current_user_for_session_store( client: &BackendOAuthClient, token: &str, +) -> Result { + let budget = auth_me_store_validation_budget(); + match tokio::time::timeout( + budget, + fetch_current_user_for_session_store_inner(client, token), + ) + .await + { + Ok(result) => result, + Err(_elapsed) => { + // Message must contain a `TRANSIENT_TRANSPORT_PHRASES` phrase + // ("timeout") so `auth_me_store_failure_is_transient` buckets it as + // transient and the deferred-validation path can take over. + let reason = format!( + "GET /auth/me validation timeout after {}ms (store-time budget exceeded)", + budget.as_millis() + ); + tracing::warn!( + domain = "credentials", + operation = "fetch_current_user_for_session_store", + budget_ms = budget.as_millis() as u64, + "[credentials][auth-store] {reason}" + ); + Err(reason) + } + } +} + +async fn fetch_current_user_for_session_store_inner( + client: &BackendOAuthClient, + token: &str, ) -> Result { match client.fetch_current_user(token).await { Ok(user) => Ok(user), diff --git a/src/openhuman/security/credentials/ops_tests.rs b/src/openhuman/security/credentials/ops_tests.rs index b56a8c683f..0d2fb9cdee 100644 --- a/src/openhuman/security/credentials/ops_tests.rs +++ b/src/openhuman/security/credentials/ops_tests.rs @@ -20,6 +20,12 @@ impl EnvVarGuard { unsafe { std::env::set_var(key, path) }; Self { key, previous } } + + fn set(key: &'static str, value: &str) -> Self { + let previous = std::env::var_os(key); + unsafe { std::env::set_var(key, value) }; + Self { key, previous } + } } impl Drop for EnvVarGuard { @@ -68,6 +74,25 @@ async fn spawn_auth_me_status(status: StatusCode) -> String { format!("http://{addr}") } +/// A backend that accepts the connection but never answers `/auth/me`, modelling +/// a reachable-but-slow backend whose request hangs far past the store-time +/// validation budget (issue #5166). +async fn spawn_auth_me_hang() -> String { + let app = Router::new().route( + "/auth/me", + get(|| async { + tokio::time::sleep(std::time::Duration::from_secs(60)).await; + StatusCode::OK + }), + ); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + format!("http://{addr}") +} + /// Persist a live (unexpired) app-session profile for `user_id` and return the /// user-scoped `Config` that reads it back, mirroring the on-disk state of an /// already-signed-in install. @@ -326,6 +351,84 @@ async fn store_session_defers_minimal_live_jwt_when_auth_me_transient_and_allowe ); } +#[tokio::test] +async fn store_session_defers_live_jwt_when_auth_me_hangs_past_budget() { + // Issue #5166: a reachable-but-slow backend must not hang store-time + // validation until the desktop sign-in RPC times out. Capping the budget + // makes the hang fail fast (as transient) into the deferred-validation path + // so a live-`exp` JWT still persists instead of bouncing the user. + let _env_guard = crate::openhuman::config::TEST_ENV_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + let _budget = EnvVarGuard::set(AUTH_ME_STORE_VALIDATION_BUDGET_ENV, "200"); + let tmp = TempDir::new().unwrap(); + std::fs::create_dir_all(tmp.path().join("workspace")).unwrap(); + let _home = EnvVarGuard::set_to_path("HOME", tmp.path()); + let mut config = test_config(&tmp); + config.api_url = Some(spawn_auth_me_hang().await); + let token = jwt_with_payload(json!({ + "sub": "unverified-jwt-user", + "email": "jwt@example.test", + "name": "Unverified JWT User", + "exp": (chrono::Utc::now() + chrono::Duration::hours(1)).timestamp() + })); + + let started = std::time::Instant::now(); + let result = store_session_with_deferred_validation(&config, &token, None, Some(json!({}))) + .await + .unwrap(); + // The 200ms budget must cap the 60s hang — proves the timeout fired rather + // than the store awaiting the backend. + assert!( + started.elapsed() < std::time::Duration::from_secs(10), + "store-time validation should be capped by the budget, took {:?}", + started.elapsed() + ); + + assert!(result.value.has_token); + let log_text = result.logs.join(" "); + assert!( + log_text.contains("session JWT accepted with deferred GET /auth/me validation"), + "expected deferred validation log after budget timeout, got: {log_text}" + ); + let state = auth_get_state(&config).await.unwrap().value; + assert!(state.is_authenticated); + assert_eq!( + state.user, + Some(json!({ "pendingBackendValidation": true })), + "budget-timeout fallback must not copy identity claims from an unverified JWT" + ); +} + +#[test] +fn auth_me_store_validation_budget_reads_env_override() { + let _env_guard = crate::openhuman::config::TEST_ENV_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + { + let _guard = EnvVarGuard::set(AUTH_ME_STORE_VALIDATION_BUDGET_ENV, "750"); + assert_eq!( + auth_me_store_validation_budget(), + std::time::Duration::from_millis(750) + ); + } + // Invalid / non-positive overrides fall back to the compiled default. + { + let _guard = EnvVarGuard::set(AUTH_ME_STORE_VALIDATION_BUDGET_ENV, "0"); + assert_eq!( + auth_me_store_validation_budget(), + AUTH_ME_STORE_VALIDATION_BUDGET + ); + } + { + let _guard = EnvVarGuard::set(AUTH_ME_STORE_VALIDATION_BUDGET_ENV, "not-a-number"); + assert_eq!( + auth_me_store_validation_budget(), + AUTH_ME_STORE_VALIDATION_BUDGET + ); + } +} + #[tokio::test] async fn store_session_requeues_reembed_backfill_after_login() { use crate::openhuman::memory::store::chunks::store::{upsert_chunks, upsert_staged_chunks_tx}; From 477106f7219c90a4d0d3f052a8de6c20cf76bd0d Mon Sep 17 00:00:00 2001 From: shanu Date: Mon, 3 Aug 2026 17:55:59 +0530 Subject: [PATCH 2/2] test(auth): tighten store-time budget assertion to <2s (#5166) CodeRabbit: the <10s upper bound let a multi-second timeout regression pass. With a 200ms configured budget and 12s default, bound at <2s so a regression fails while allowing normal timing variance. --- src/openhuman/security/credentials/ops_tests.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/openhuman/security/credentials/ops_tests.rs b/src/openhuman/security/credentials/ops_tests.rs index 0d2fb9cdee..3b93038fdb 100644 --- a/src/openhuman/security/credentials/ops_tests.rs +++ b/src/openhuman/security/credentials/ops_tests.rs @@ -378,9 +378,10 @@ async fn store_session_defers_live_jwt_when_auth_me_hangs_past_budget() { .await .unwrap(); // The 200ms budget must cap the 60s hang — proves the timeout fired rather - // than the store awaiting the backend. + // than the store awaiting the backend. Bound safely above the 200ms budget + // but below the 12s default so a multi-second regression still fails. assert!( - started.elapsed() < std::time::Duration::from_secs(10), + started.elapsed() < std::time::Duration::from_secs(2), "store-time validation should be capped by the budget, took {:?}", started.elapsed() );