Skip to content
Merged
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
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
67 changes: 67 additions & 0 deletions src/openhuman/security/credentials/ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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::<u64>().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<Value, String> {
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<Value, String> {
match client.fetch_current_user(token).await {
Ok(user) => Ok(user),
Expand Down
104 changes: 104 additions & 0 deletions src/openhuman/security/credentials/ops_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -326,6 +351,85 @@ 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. 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(2),
"store-time validation should be capped by the budget, took {:?}",
started.elapsed()
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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};
Expand Down
Loading