From 2412ec7afc9897c0613c53b131e1c13b40fc787c Mon Sep 17 00:00:00 2001 From: Sam Date: Mon, 22 Jun 2026 22:33:09 +0200 Subject: [PATCH] [codex] fix auth session persistence on transient /auth/me (#3746) Co-authored-by: Sami Rusani Co-authored-by: Steven Enamakel --- .../__tests__/desktopDeepLinkListener.test.ts | 16 +- app/src/utils/__tests__/tauriCommands.test.ts | 9 + app/src/utils/desktopDeepLinkListener.ts | 2 +- app/src/utils/tauriCommands/auth.ts | 15 +- src/api/jwt.rs | 21 +- src/core/jsonrpc.rs | 12 +- src/openhuman/agentbox/disabled_mode_tests.rs | 15 +- src/openhuman/agentbox/status.rs | 5 +- src/openhuman/app_state/ops.rs | 531 ++++++++++- src/openhuman/credentials/ops.rs | 180 +++- src/openhuman/credentials/ops_tests.rs | 264 ++++++ src/openhuman/credentials/schemas.rs | 22 +- src/openhuman/credentials/schemas_tests.rs | 10 + src/openhuman/wallet/ops.rs | 17 +- src/openhuman/wallet/test_support.rs | 17 +- .../app_state_credentials_raw_coverage_e2e.rs | 881 +++++++++++++++++- 16 files changed, 1917 insertions(+), 100 deletions(-) diff --git a/app/src/utils/__tests__/desktopDeepLinkListener.test.ts b/app/src/utils/__tests__/desktopDeepLinkListener.test.ts index 82bc070ea..5c6e9b49b 100644 --- a/app/src/utils/__tests__/desktopDeepLinkListener.test.ts +++ b/app/src/utils/__tests__/desktopDeepLinkListener.test.ts @@ -181,7 +181,11 @@ describe('desktopDeepLinkListener', () => { ); await waitForAuthSettled(); - expect(storeSession).toHaveBeenCalledWith('web-token', {}); + expect(storeSession).toHaveBeenCalledWith( + 'web-token', + {}, + { allowPendingBackendValidation: true } + ); }); it('rejects an auth deep link whose state nonce does not match a pending one', async () => { @@ -206,7 +210,7 @@ describe('desktopDeepLinkListener', () => { vi.mocked(getCurrent).mockResolvedValue([url]); await setupDesktopDeepLinkListener(); await waitForAuthSettled(); - expect(storeSession).toHaveBeenCalledWith('abc', {}); + expect(storeSession).toHaveBeenCalledWith('abc', {}, { allowPendingBackendValidation: true }); // Replay the exact same deep link — the nonce was consumed, so it fails. vi.mocked(storeSession).mockClear(); @@ -251,7 +255,7 @@ describe('desktopDeepLinkListener', () => { await waitForAuthSettled(); // store WAS attempted (we reached the persistence call)... - expect(storeSession).toHaveBeenCalledWith('abc', {}); + expect(storeSession).toHaveBeenCalledWith('abc', {}, { allowPendingBackendValidation: true }); // ...but it FAILED, so the session-applied event was never dispatched... expect(sessionTokenUpdated).not.toHaveBeenCalled(); // ...and we never navigated to /home (ProtectedRoute/PublicRoute keep signin). @@ -291,7 +295,7 @@ describe('desktopDeepLinkListener', () => { resolveReadiness({ ready: true }); await waitForAuthSettled(); - expect(storeSession).toHaveBeenCalledWith('abc', {}); + expect(storeSession).toHaveBeenCalledWith('abc', {}, { allowPendingBackendValidation: true }); expect(getDeepLinkAuthState().isProcessing).toBe(false); }); @@ -324,7 +328,7 @@ describe('desktopDeepLinkListener', () => { expect(clearCoreRpcUrlCache).toHaveBeenCalledTimes(1); expect(clearCoreRpcTokenCache).toHaveBeenCalledTimes(1); - expect(storeSession).toHaveBeenCalledWith('abc', {}); + expect(storeSession).toHaveBeenCalledWith('abc', {}, { allowPendingBackendValidation: true }); }); it('does NOT bust RPC caches before storeSession in local mode', async () => { @@ -336,7 +340,7 @@ describe('desktopDeepLinkListener', () => { expect(clearCoreRpcUrlCache).not.toHaveBeenCalled(); expect(clearCoreRpcTokenCache).not.toHaveBeenCalled(); - expect(storeSession).toHaveBeenCalledWith('abc', {}); + expect(storeSession).toHaveBeenCalledWith('abc', {}, { allowPendingBackendValidation: true }); }); it('dispatches suppress-reauth before storeSession and clears it after in cloud mode', async () => { diff --git a/app/src/utils/__tests__/tauriCommands.test.ts b/app/src/utils/__tests__/tauriCommands.test.ts index af394047a..830d0cbfa 100644 --- a/app/src/utils/__tests__/tauriCommands.test.ts +++ b/app/src/utils/__tests__/tauriCommands.test.ts @@ -68,6 +68,15 @@ describe('tauriCommands', () => { }); }); + test('storeSession can request deferred backend validation for trusted callbacks', async () => { + await storeSession('jwt-token', {}, { allowPendingBackendValidation: true }); + + expect(mockCallCoreRpc).toHaveBeenCalledWith({ + method: 'openhuman.auth_store_session', + params: { token: 'jwt-token', user: {}, allowPendingBackendValidation: true }, + }); + }); + test('resetOpenHumanDataAndRestartCore invokes the destructive Tauri command', async () => { await resetOpenHumanDataAndRestartCore(); diff --git a/app/src/utils/desktopDeepLinkListener.ts b/app/src/utils/desktopDeepLinkListener.ts index 494e242d6..1deef5577 100644 --- a/app/src/utils/desktopDeepLinkListener.ts +++ b/app/src/utils/desktopDeepLinkListener.ts @@ -167,7 +167,7 @@ const applySessionToken = async (sessionToken: string): Promise => { new CustomEvent('core-state:suppress-reauth', { detail: { until: Date.now() + 15_000 } }) ); try { - await storeSession(sessionToken, {}); + await storeSession(sessionToken, {}, { allowPendingBackendValidation: true }); } finally { window.dispatchEvent(new CustomEvent('core-state:suppress-reauth', { detail: { until: 0 } })); } diff --git a/app/src/utils/tauriCommands/auth.ts b/app/src/utils/tauriCommands/auth.ts index 4fed14604..66a97b0ae 100644 --- a/app/src/utils/tauriCommands/auth.ts +++ b/app/src/utils/tauriCommands/auth.ts @@ -58,8 +58,19 @@ export async function logout(): Promise { /** * Store session in secure storage */ -export async function storeSession(token: string, user: object): Promise { - await callCoreRpc({ method: 'openhuman.auth_store_session', params: { token, user } }); +export async function storeSession( + token: string, + user: object, + options?: { allowPendingBackendValidation?: boolean } +): Promise { + await callCoreRpc({ + method: 'openhuman.auth_store_session', + params: { + token, + user, + ...(options?.allowPendingBackendValidation ? { allowPendingBackendValidation: true } : {}), + }, + }); } export async function openhumanEncryptSecret(plaintext: string): Promise> { diff --git a/src/api/jwt.rs b/src/api/jwt.rs index cc421df1c..75ab3b02f 100644 --- a/src/api/jwt.rs +++ b/src/api/jwt.rs @@ -11,6 +11,18 @@ pub fn bearer_authorization_value(token: &str) -> String { format!("Bearer {}", token.trim()) } +/// Best-effort decode of a JWT payload without verifying the signature. +pub fn decode_jwt_payload(token: &str) -> Option { + // JWT = header.payload.signature (base64url, no padding). Only the payload + // segment is needed. + let payload_b64 = token.trim().split('.').nth(1)?; + let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(payload_b64) + .or_else(|_| base64::engine::general_purpose::URL_SAFE.decode(payload_b64)) + .ok()?; + serde_json::from_slice(&bytes).ok() +} + /// Best-effort decode of a JWT's `exp` (expiry) claim into a UTC timestamp. /// /// The backend app-session token is a JWT but is stored bare — the client @@ -26,14 +38,7 @@ pub fn bearer_authorization_value(token: &str) -> String { /// non-JWT / malformed / `exp`-less token, in which case expiry tracking /// degrades to the previous behaviour (no local precheck). pub fn decode_jwt_exp(token: &str) -> Option> { - // JWT = header.payload.signature (base64url, no padding). Only the payload - // segment is needed. - let payload_b64 = token.trim().split('.').nth(1)?; - let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD - .decode(payload_b64) - .or_else(|_| base64::engine::general_purpose::URL_SAFE.decode(payload_b64)) - .ok()?; - let claims: serde_json::Value = serde_json::from_slice(&bytes).ok()?; + let claims = decode_jwt_payload(token)?; // `exp` is a NumericDate (seconds since epoch); accept int or float shapes. let exp = claims .get("exp") diff --git a/src/core/jsonrpc.rs b/src/core/jsonrpc.rs index 49526d501..50c74428a 100644 --- a/src/core/jsonrpc.rs +++ b/src/core/jsonrpc.rs @@ -801,7 +801,11 @@ async fn telegram_auth_handler( }; // Store the resulting session token in the local configuration. - match crate::openhuman::credentials::ops::store_session(&config, &jwt_token, None, None).await { + match crate::openhuman::credentials::ops::store_session_with_deferred_validation( + &config, &jwt_token, None, None, + ) + .await + { Ok(outcome) => { for msg in &outcome.logs { log::info!("[auth:telegram] {msg}"); @@ -918,7 +922,11 @@ async fn desktop_auth_handler( } }; - match crate::openhuman::credentials::ops::store_session(&config, &jwt_token, None, None).await { + match crate::openhuman::credentials::ops::store_session_with_deferred_validation( + &config, &jwt_token, None, None, + ) + .await + { Ok(outcome) => { for msg in &outcome.logs { log::info!("[auth:desktop] {msg}"); diff --git a/src/openhuman/agentbox/disabled_mode_tests.rs b/src/openhuman/agentbox/disabled_mode_tests.rs index af9f1ad65..3146dda4e 100644 --- a/src/openhuman/agentbox/disabled_mode_tests.rs +++ b/src/openhuman/agentbox/disabled_mode_tests.rs @@ -1,17 +1,10 @@ //! Verifies that with `OPENHUMAN_AGENTBOX_MODE` unset (the desktop default), //! the core HTTP router does NOT expose `/run` or `/jobs/{id}`. //! -//! Env vars are process-global, so if any other test sets this var in -//! parallel the assertion could flap. The repo standard for env-mutating -//! tests is to use the `serial_test` crate; if it's available, mark this -//! test `#[serial]`. Otherwise the test is best-effort and a fallback -//! `#[ignore]` is acceptable. -//! -//! `serial_test` is NOT a dev-dependency in this workspace, and a grep -//! across `src/` and `tests/` confirms no other test sets -//! `OPENHUMAN_AGENTBOX_MODE`, so unsetting the var inline is sufficient -//! today. Authoritative coverage of the disabled-mode contract lives in -//! the E2E test (Task 12) which boots a fresh process. +//! Env vars are process-global, so this test holds the AgentBox test env lock +//! while it clears `OPENHUMAN_AGENTBOX_MODE` and builds the router. +//! Authoritative coverage of the disabled-mode contract lives in the E2E test +//! (Task 12) which boots a fresh process. use axum::body::Body; use axum::http::{Request, StatusCode}; diff --git a/src/openhuman/agentbox/status.rs b/src/openhuman/agentbox/status.rs index ae610eba1..58c65d0c8 100644 --- a/src/openhuman/agentbox/status.rs +++ b/src/openhuman/agentbox/status.rs @@ -43,8 +43,9 @@ mod tests { use super::*; use crate::openhuman::agentbox::env::AGENTBOX_MODE_ENV_VAR; - // Env vars are process-global; these toggles are restored on exit and no - // other test mutates the same keys concurrently (see disabled_mode_tests). + // Env vars are process-global; hold the AgentBox test env lock so these + // tests do not race each other or other AgentBox mode tests in the full + // lib suite. fn with_clean_env(f: F) { let _lock = super::super::test_support::test_env_lock(); let prior_mode = std::env::var(AGENTBOX_MODE_ENV_VAR).ok(); diff --git a/src/openhuman/app_state/ops.rs b/src/openhuman/app_state/ops.rs index 702875d64..fd62ad578 100644 --- a/src/openhuman/app_state/ops.rs +++ b/src/openhuman/app_state/ops.rs @@ -1,3 +1,4 @@ +use std::collections::{BTreeMap, HashMap}; use std::fs; #[cfg(unix)] use std::fs::File; @@ -16,6 +17,7 @@ use tempfile::NamedTempFile; use crate::api::config::effective_backend_api_url; use crate::api::jwt::bearer_authorization_value; +use crate::api::rest::user_id_from_profile_payload; use crate::openhuman::autocomplete::AutocompleteStatus; use crate::openhuman::config::rpc as config_rpc; use crate::openhuman::config::Config; @@ -23,6 +25,7 @@ use crate::openhuman::credentials::session_support::{ is_local_session_token, load_app_session_profile, session_state_from_profile, session_token_from_profile, }; +use crate::openhuman::credentials::{AuthService, APP_SESSION_PROVIDER, DEFAULT_AUTH_PROFILE_NAME}; use crate::openhuman::inference::LocalAiStatus; use crate::openhuman::screen_intelligence::AccessibilityStatus; use crate::openhuman::service::{ServiceState, ServiceStatus}; @@ -35,6 +38,8 @@ const RUNTIME_SNAPSHOT_TTL: Duration = Duration::from_secs(2); const AUTH_FETCH_TIMEOUT: Duration = Duration::from_secs(5); const RUNTIME_SNAPSHOT_TIMEOUT: Duration = Duration::from_secs(10); const SNAPSHOT_SUB_OP_TIMEOUT: Duration = Duration::from_secs(5); +const PENDING_BACKEND_VALIDATION_FIELD: &str = "pendingBackendValidation"; +const AUTH_ME_REVALIDATION_TRANSIENT_STATUSES: &[u16] = &[408, 429, 500, 502, 503, 504, 520]; static APP_STATE_FILE_LOCK: Lazy> = Lazy::new(|| Mutex::new(())); static CURRENT_USER_CACHE: Lazy>> = Lazy::new(|| Mutex::new(None)); static RUNTIME_SNAPSHOT_CACHE: Lazy>> = @@ -55,6 +60,41 @@ struct CachedCurrentUser { user: Value, } +#[derive(Debug, Clone)] +enum SnapshotCurrentUser { + User(Option), + DeferredSessionRejected, +} + +impl SnapshotCurrentUser { + fn user(user: Option) -> Self { + Self::User(user) + } +} + +type SnapshotCurrentUserResult = (SnapshotCurrentUser, Option>); + +fn snapshot_current_user_result(user: Option) -> SnapshotCurrentUserResult { + (SnapshotCurrentUser::user(user), None) +} + +#[derive(Debug, Clone)] +enum CurrentUserFetchError { + Rejected(String), + TransientResponse(String), + FetchFailed(String), +} + +impl CurrentUserFetchError { + fn message(&self) -> &str { + match self { + CurrentUserFetchError::Rejected(message) + | CurrentUserFetchError::TransientResponse(message) + | CurrentUserFetchError::FetchFailed(message) => message, + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] pub struct StoredOnboardingTasks { @@ -288,32 +328,39 @@ fn resolve_base(config: &Config) -> Result { Ok(parsed) } -async fn fetch_current_user(config: &Config, token: &str) -> Result, String> { - let client = build_client()?; - let base = resolve_base(config)?; +async fn fetch_current_user( + config: &Config, + token: &str, +) -> Result, CurrentUserFetchError> { + let client = build_client().map_err(CurrentUserFetchError::FetchFailed)?; + let base = resolve_base(config).map_err(CurrentUserFetchError::FetchFailed)?; let url = base .join("auth/me") - .map_err(|e| format!("build URL failed: {e}"))?; + .map_err(|e| CurrentUserFetchError::FetchFailed(format!("build URL failed: {e}")))?; let response = client .request(Method::GET, url.clone()) .header(AUTHORIZATION, bearer_authorization_value(token)) .send() .await - .map_err(|e| format!("request failed: {e}"))?; + .map_err(|e| CurrentUserFetchError::FetchFailed(format!("request failed: {e}")))?; let status = response.status(); - let text = response - .text() - .await - .map_err(|e| format!("failed to read backend response body: {e}"))?; + let text = response.text().await.map_err(|e| { + CurrentUserFetchError::FetchFailed(format!("failed to read backend response body: {e}")) + })?; debug!("{LOG_PREFIX} GET /auth/me -> {}", status); if !status.is_success() { + let message = format!("{status} {text}"); warn!( "{LOG_PREFIX} current user fetch failed: {} {}", status, text ); - return Ok(None); + return if AUTH_ME_REVALIDATION_TRANSIENT_STATUSES.contains(&status.as_u16()) { + Err(CurrentUserFetchError::TransientResponse(message)) + } else { + Err(CurrentUserFetchError::Rejected(message)) + }; } let raw: Value = @@ -334,13 +381,282 @@ fn sanitize_snapshot_user(user: Option) -> Option { } } -async fn fetch_current_user_cached(config: &Config, token: &str) -> Result, String> { +fn snapshot_user_pending_backend_validation(user: Option<&Value>) -> bool { + user.and_then(Value::as_object) + .and_then(|obj| obj.get(PENDING_BACKEND_VALIDATION_FIELD)) + .and_then(Value::as_bool) + .unwrap_or(false) +} + +fn clear_pending_backend_validation_flag(mut user: Value) -> Value { + if let Value::Object(ref mut map) = user { + map.remove(PENDING_BACKEND_VALIDATION_FIELD); + } + user +} + +fn pending_session_user_id_for_cleanup( + stored_user: Option<&Value>, + metadata: &BTreeMap, +) -> Option { + stored_user + .and_then(user_id_from_profile_payload) + .or_else(|| { + metadata + .get("user_id") + .map(String::as_str) + .map(str::trim) + .filter(|user_id| !user_id.is_empty()) + .map(str::to_string) + }) +} + +fn config_state_dir(config: &Config) -> Option { + config.config_path.parent().map(Path::to_path_buf) +} + +fn same_config_state_dir(a: &Config, b: &Config) -> bool { + config_state_dir(a) == config_state_dir(b) +} + +fn config_dir_for_workspace_env() -> Option { + let workspace = std::env::var_os("OPENHUMAN_WORKSPACE")?; + if workspace.as_os_str().is_empty() { + return None; + } + + let workspace_dir = PathBuf::from(workspace); + let workspace_config_dir = workspace_dir.clone(); + if workspace_config_dir.join("config.toml").exists() { + return Some(workspace_config_dir); + } + + if let Some(parent) = workspace_dir.parent() { + let legacy_dir = parent.join(".openhuman"); + if legacy_dir.join("config.toml").exists() + || workspace_dir + .file_name() + .is_some_and(|name| name == std::ffi::OsStr::new("workspace")) + { + return Some(legacy_dir); + } + } + + Some(workspace_config_dir) +} + +fn config_is_workspace_env_scoped(config: &Config) -> bool { + let Some(config_dir) = config_state_dir(config) else { + return false; + }; + config_dir_for_workspace_env() + .as_deref() + .is_some_and(|env_config_dir| env_config_dir == config_dir) +} + +async fn activate_revalidated_user_dir(user_id: &str) -> Result { + let root_dir = crate::openhuman::config::default_root_openhuman_dir() + .map_err(|error| format!("failed to locate default root: {error}"))?; + let previous_active = crate::openhuman::config::read_active_user_id(&root_dir); + let user_dir = crate::openhuman::config::user_openhuman_dir(&root_dir, user_id); + fs::create_dir_all(&user_dir).map_err(|error| { + format!("failed to create user directory for revalidated pending session user_id={user_id}: {error}") + })?; + crate::openhuman::config::write_active_user_id(&root_dir, user_id).map_err(|error| { + format!("failed to write active_user.toml for revalidated pending session user_id={user_id}: {error}") + })?; + + debug!( + "{LOG_PREFIX} activated user directory for revalidated pending session user_id={user_id}" + ); + if previous_active.is_none() { + let pre_ws = crate::openhuman::config::pre_login_user_dir(&root_dir).join("workspace"); + if let Err(error) = crate::openhuman::memory_conversations::purge_threads(pre_ws) { + debug!( + "{LOG_PREFIX} pre-login conversation purge skipped after pending session revalidation: {error}" + ); + } + } + + Config::load_from_default_paths().await.map_err(|error| { + format!("failed to reload config after pending session user activation: {error}") + }) +} + +async fn finish_revalidated_user_activation( + target_config: &Config, + user_id: &str, + service_rebind_source: Option<&Config>, +) { + if let Err(error) = crate::openhuman::memory::global::init(target_config.workspace_dir.clone()) + { + warn!( + "{LOG_PREFIX} failed to bind memory client after pending session revalidation: {error}" + ); + } + crate::openhuman::memory_conversations::register_conversation_persistence_subscriber( + target_config.workspace_dir.clone(), + ); + if let Err(error) = crate::openhuman::subconscious::global::bootstrap_after_login().await { + warn!("{LOG_PREFIX} subconscious bootstrap failed after pending session revalidation: {error}"); + } + if let Some(source_config) = service_rebind_source { + crate::openhuman::credentials::stop_login_gated_services(source_config).await; + crate::openhuman::credentials::start_login_gated_services(target_config).await; + } else { + debug!( + "{LOG_PREFIX} pending session revalidation left login-gated services running without restart" + ); + } + crate::openhuman::scheduler_gate::set_signed_out(false); + crate::openhuman::credentials::sentry_scope::bind(user_id); +} + +async fn remove_revalidated_source_profile(config: &Config) -> Result<(), String> { + let config = config.clone(); + tokio::task::spawn_blocking(move || { + AuthService::from_config(&config) + .remove_profile(APP_SESSION_PROVIDER, DEFAULT_AUTH_PROFILE_NAME) + .map(|_| ()) + .map_err(|e| e.to_string()) + }) + .await + .unwrap_or_else(|e| { + Err(format!( + "{LOG_PREFIX} revalidated source profile remove task panicked: {e}" + )) + }) +} + +async fn persist_revalidated_session_user( + config: &Config, + token: &str, + base_metadata: BTreeMap, + user: Value, +) -> Result, String> { + let user_id = user_id_from_profile_payload(&user) + .ok_or_else(|| "backend user id required before clearing pending validation".to_string())?; + let workspace_env_scoped = config_is_workspace_env_scoped(config); + let target_config = if !workspace_env_scoped { + activate_revalidated_user_dir(&user_id).await? + } else { + debug!( + "{LOG_PREFIX} keeping revalidated pending session in OPENHUMAN_WORKSPACE-scoped config" + ); + config.clone() + }; + let source_config = config.clone(); + let source_moved = !same_config_state_dir(config, &target_config); + let token = token.to_string(); + let mut metadata: HashMap = base_metadata.into_iter().collect(); + metadata.insert("user_id".to_string(), user_id.clone()); + metadata.insert("user_json".to_string(), user.to_string()); + + let config_for_store = target_config.clone(); + tokio::task::spawn_blocking(move || { + AuthService::from_config(&config_for_store) + .store_provider_token( + APP_SESSION_PROVIDER, + DEFAULT_AUTH_PROFILE_NAME, + &token, + metadata, + true, + ) + .map(|_| ()) + .map_err(|e| e.to_string()) + }) + .await + .unwrap_or_else(|e| { + Err(format!( + "{LOG_PREFIX} revalidated session persist task panicked: {e}" + )) + })?; + + if source_moved { + if let Err(error) = remove_revalidated_source_profile(&source_config).await { + warn!( + "{LOG_PREFIX} failed to remove source pending session profile after user activation: {error}" + ); + } + } + + finish_revalidated_user_activation( + &target_config, + &user_id, + source_moved.then_some(&source_config), + ) + .await; + + Ok(Box::new(target_config)) +} + +async fn clear_deferred_session_after_backend_rejection( + config: &Config, + pending_user_id: Option<&str>, +) -> Result<(), String> { + let workspace_env_scoped = config_is_workspace_env_scoped(config); + let config_for_remove = config.clone(); + let clear_result = tokio::task::spawn_blocking(move || { + AuthService::from_config(&config_for_remove) + .remove_profile(APP_SESSION_PROVIDER, DEFAULT_AUTH_PROFILE_NAME) + .map(|_| ()) + .map_err(|e| e.to_string()) + }) + .await + .unwrap_or_else(|e| { + Err(format!( + "{LOG_PREFIX} deferred session clear task panicked: {e}" + )) + }); + + *CURRENT_USER_CACHE.lock() = None; + crate::openhuman::scheduler_gate::set_signed_out(true); + + match crate::openhuman::config::default_root_openhuman_dir() { + Ok(root_dir) => { + let active_user = crate::openhuman::config::read_active_user_id(&root_dir); + let should_clear_active_user = if workspace_env_scoped { + pending_user_id.is_some_and(|pending| active_user.as_deref() == Some(pending)) + } else { + true + }; + if should_clear_active_user { + if let Err(error) = crate::openhuman::config::clear_active_user(&root_dir) { + warn!( + "{LOG_PREFIX} failed to clear active_user.toml for rejected pending session: {error}" + ); + } + } else { + debug!( + "{LOG_PREFIX} preserving default active_user.toml for rejected OPENHUMAN_WORKSPACE-scoped pending session" + ); + } + } + Err(error) if !workspace_env_scoped => { + warn!( + "{LOG_PREFIX} failed to locate default root while clearing rejected pending session: {error}" + ); + } + Err(_) => {} + } + crate::openhuman::credentials::stop_login_gated_services(config).await; + crate::openhuman::subconscious::global::reset_engine_for_user_switch().await; + crate::openhuman::credentials::sentry_scope::clear(); + + clear_result +} + +async fn fetch_current_user_cached( + config: &Config, + token: &str, + allow_cache: bool, +) -> Result, CurrentUserFetchError> { let api_base = effective_backend_api_url(&config.api_url) .trim() .trim_end_matches('/') .to_string(); - { + if allow_cache { let cache = CURRENT_USER_CACHE.lock(); if let Some(entry) = cache.as_ref() { if entry.api_base == api_base @@ -590,8 +906,16 @@ pub async fn snapshot() -> Result, String> { .await .unwrap_or_else(|e| Err(format!("[app_state] auth profile load task panicked: {e}")))?; let mut auth = session_state_from_profile(session_profile.as_ref()); - let session_token = session_token_from_profile(session_profile.as_ref()); + let mut session_token = session_token_from_profile(session_profile.as_ref()); let stored_user = sanitize_snapshot_user(auth.user.clone()); + let pending_backend_validation = snapshot_user_pending_backend_validation(stored_user.as_ref()); + let session_metadata = session_profile + .as_ref() + .map(|profile| profile.metadata.clone()) + .unwrap_or_default(); + let pending_session_user_id = pending_backend_validation + .then(|| pending_session_user_id_for_cleanup(stored_user.as_ref(), &session_metadata)) + .flatten(); let auth_ms = t_auth.elapsed().as_millis(); // Resolve the live current-user refresh and the runtime snapshot @@ -603,34 +927,124 @@ pub async fn snapshot() -> Result, String> { // OpenHuman" (the FE clears `isBootstrapping` on this call). `tokio::join!` // polls both on the current task — no extra threads. let t_enrich = Instant::now(); - let current_user_future = async { + let current_user_future = Box::pin(async { let Some(token) = session_token.clone().filter(|t| !t.trim().is_empty()) else { - return stored_user.clone(); + return snapshot_current_user_result(stored_user.clone()); }; if is_local_session_token(&token) { - return stored_user.clone(); + return snapshot_current_user_result(stored_user.clone()); } match tokio::time::timeout( AUTH_FETCH_TIMEOUT, - fetch_current_user_cached(&config, &token), + fetch_current_user_cached(&config, &token, !pending_backend_validation), ) .await { - Ok(Ok(fresh_user)) => fresh_user.or(stored_user.clone()), + Ok(Ok(Some(fresh_user))) => { + if pending_backend_validation && user_id_from_profile_payload(&fresh_user).is_none() + { + warn!( + "{LOG_PREFIX} pending current user refresh returned a user without an id; keeping stored pending session for retry" + ); + return snapshot_current_user_result(stored_user.clone()); + } + let fresh_user = clear_pending_backend_validation_flag(fresh_user); + if pending_backend_validation { + let snapshot_config = match persist_revalidated_session_user( + &config, + &token, + session_metadata.clone(), + fresh_user.clone(), + ) + .await + { + Ok(snapshot_config) => { + debug!( + "{LOG_PREFIX} cleared pending backend validation after successful current user refresh" + ); + snapshot_config + } + Err(error) => { + warn!( + "{LOG_PREFIX} failed to persist cleared pending backend validation: {error}" + ); + return snapshot_current_user_result(stored_user.clone()); + } + }; + return ( + SnapshotCurrentUser::user(Some(fresh_user)), + Some(snapshot_config), + ); + } + snapshot_current_user_result(Some(fresh_user)) + } + Ok(Ok(None)) if pending_backend_validation => { + warn!( + "{LOG_PREFIX} backend returned empty user for pending session revalidation; clearing stored app session" + ); + if let Err(error) = clear_deferred_session_after_backend_rejection( + &config, + pending_session_user_id.as_deref(), + ) + .await + { + warn!("{LOG_PREFIX} failed to clear rejected pending session: {error}"); + } + (SnapshotCurrentUser::DeferredSessionRejected, None) + } + Ok(Ok(None)) => snapshot_current_user_result(stored_user.clone()), + Ok(Err(CurrentUserFetchError::Rejected(error))) if pending_backend_validation => { + warn!( + "{LOG_PREFIX} pending current user refresh was rejected; clearing stored app session: {error}" + ); + if let Err(clear_error) = clear_deferred_session_after_backend_rejection( + &config, + pending_session_user_id.as_deref(), + ) + .await + { + warn!("{LOG_PREFIX} failed to clear rejected pending session: {clear_error}"); + } + (SnapshotCurrentUser::DeferredSessionRejected, None) + } + Ok(Err(CurrentUserFetchError::FetchFailed(error))) if pending_backend_validation => { + warn!( + "{LOG_PREFIX} pending current user refresh failed before a backend response; keeping stored pending session for retry: {error}" + ); + snapshot_current_user_result(stored_user.clone()) + } + Ok(Err(CurrentUserFetchError::TransientResponse(error))) + if pending_backend_validation => + { + warn!( + "{LOG_PREFIX} pending current user refresh received transient backend response; keeping stored pending session: {error}" + ); + snapshot_current_user_result(stored_user.clone()) + } Ok(Err(error)) => { - warn!("{LOG_PREFIX} current user refresh failed; using stored snapshot fallback: {error}"); - stored_user.clone() + warn!( + "{LOG_PREFIX} current user refresh failed; using stored snapshot fallback: {}", + error.message() + ); + snapshot_current_user_result(stored_user.clone()) + } + Err(_) if pending_backend_validation => { + warn!( + "{LOG_PREFIX} pending current user fetch timed out after {}s; keeping stored pending session for retry", + AUTH_FETCH_TIMEOUT.as_secs() + ); + snapshot_current_user_result(stored_user.clone()) } Err(_) => { warn!( "{LOG_PREFIX} current user fetch timed out after {}s; using stored snapshot fallback", AUTH_FETCH_TIMEOUT.as_secs() ); - stored_user.clone() + snapshot_current_user_result(stored_user.clone()) } } - }; - let runtime_future = async { + }); + let runtime_future = Box::pin(async { match tokio::time::timeout( RUNTIME_SNAPSHOT_TIMEOUT, build_runtime_snapshot(&config, req_id), @@ -647,13 +1061,60 @@ pub async fn snapshot() -> Result, String> { degraded_runtime_snapshot(&config) } } - }; - let (current_user, runtime) = tokio::join!(current_user_future, runtime_future); + }); + let (current_user_result, runtime) = tokio::join!(current_user_future, runtime_future); let enrich_ms = t_enrich.elapsed().as_millis(); - auth.user = current_user.clone(); + let (current_user, revalidated_config) = current_user_result; + let mut snapshot_config = config.clone(); + if let Some(revalidated_config) = revalidated_config { + snapshot_config = *revalidated_config; + } + let current_user = match current_user { + SnapshotCurrentUser::User(current_user) => { + if pending_backend_validation { + if let Some(user_id) = current_user.as_ref().and_then(user_id_from_profile_payload) + { + auth.user_id = Some(user_id); + } + } + auth.user = current_user.clone(); + current_user + } + SnapshotCurrentUser::DeferredSessionRejected => { + auth.is_authenticated = false; + auth.user_id = None; + auth.user = None; + auth.profile_id = None; + session_token = None; + None + } + }; + let runtime = if same_config_state_dir(&config, &snapshot_config) { + runtime + } else { + warn!( + "{LOG_PREFIX} pending session revalidation changed config scope; rebuilding runtime snapshot with activated user config" + ); + match tokio::time::timeout( + RUNTIME_SNAPSHOT_TIMEOUT, + build_runtime_snapshot(&snapshot_config, req_id), + ) + .await + { + Ok(snapshot) => snapshot, + Err(_) => { + warn!( + "{LOG_PREFIX} activated-config runtime snapshot timed out after {}s req_id={}; returning degraded runtime snapshot", + RUNTIME_SNAPSHOT_TIMEOUT.as_secs(), + req_id + ); + degraded_runtime_snapshot(&snapshot_config) + } + } + }; let t_local_state = Instant::now(); - let local_state = load_stored_app_state(&config)?; + let local_state = load_stored_app_state(&snapshot_config)?; crate::openhuman::keyring_consent::policy::initialize(local_state.keyring_consent.clone()); let local_state_ms = t_local_state.elapsed().as_millis(); @@ -667,10 +1128,10 @@ pub async fn snapshot() -> Result, String> { "{LOG_PREFIX} snapshot req_id={} auth={} onboarding={} chat_onboarding={} analytics={} meet_handoff={} si_active={} local_ai_state={} autocomplete_phase={} service_state={:?}", req_id, auth.is_authenticated, - config.onboarding_completed, - config.chat_onboarding_completed, - config.observability.analytics_enabled, - config.meet.auto_orchestrator_handoff, + snapshot_config.onboarding_completed, + snapshot_config.chat_onboarding_completed, + snapshot_config.observability.analytics_enabled, + snapshot_config.meet.auto_orchestrator_handoff, runtime.screen_intelligence.session.active, runtime.local_ai.state, runtime.autocomplete.phase, @@ -684,10 +1145,10 @@ pub async fn snapshot() -> Result, String> { auth, session_token, current_user, - onboarding_completed: config.onboarding_completed, - chat_onboarding_completed: config.chat_onboarding_completed, - analytics_enabled: config.observability.analytics_enabled, - meet_auto_orchestrator_handoff: config.meet.auto_orchestrator_handoff, + onboarding_completed: snapshot_config.onboarding_completed, + chat_onboarding_completed: snapshot_config.chat_onboarding_completed, + analytics_enabled: snapshot_config.observability.analytics_enabled, + meet_auto_orchestrator_handoff: snapshot_config.meet.auto_orchestrator_handoff, local_state, keyring_status, runtime, diff --git a/src/openhuman/credentials/ops.rs b/src/openhuman/credentials/ops.rs index f1a62e944..e7d900a1c 100644 --- a/src/openhuman/credentials/ops.rs +++ b/src/openhuman/credentials/ops.rs @@ -1,9 +1,10 @@ //! JSON-RPC / CLI controller surface for credentials and app session auth. -use serde_json::json; +use serde_json::{json, Value}; +use std::time::Duration; use crate::api::config::effective_backend_api_url; -use crate::api::jwt::get_session_token; +use crate::api::jwt::{decode_jwt_exp, get_session_token}; use crate::api::rest::{user_id_from_profile_payload, BackendOAuthClient}; use crate::openhuman::config::Config; use crate::openhuman::credentials::session_support::{ @@ -20,6 +21,9 @@ use crate::openhuman::config::{ }; use crate::openhuman::memory_conversations as 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]; + /// Start all login-gated background services (local AI, voice, screen /// intelligence, autocomplete). Called both from the initial boot path /// (when an existing session is detected) and from `store_session()` on @@ -131,6 +135,28 @@ pub async fn store_session( token: &str, user_id: Option, user: Option, +) -> Result, String> { + store_session_inner(config, token, user_id, user, false).await +} + +/// Store a session from a callback flow that already exchanged a backend +/// login token. Generic callers should use `store_session`, which requires +/// immediate `/auth/me` proof before persisting remote JWTs. +pub async fn store_session_with_deferred_validation( + config: &Config, + token: &str, + user_id: Option, + user: Option, +) -> Result, String> { + store_session_inner(config, token, user_id, user, true).await +} + +async fn store_session_inner( + config: &Config, + token: &str, + user_id: Option, + user: Option, + allow_pending_backend_validation: bool, ) -> Result, String> { let trimmed_token = token.trim(); if trimmed_token.is_empty() { @@ -140,6 +166,7 @@ pub async fn store_session( let api_url = effective_backend_api_url(&config.api_url); let local_session = is_local_session_token(trimmed_token); let local_user_id = local_session.then(local_session_user_id); + let mut session_validation_logs = Vec::new(); let settings = if local_session { sanitize_stored_session_user(user.clone()) .map(|value| { @@ -151,24 +178,68 @@ pub async fn store_session( .ok_or_else(|| "local session requires a user payload".to_string())? } else { let client = BackendOAuthClient::new(&api_url).map_err(|e| e.to_string())?; - match client.fetch_current_user(trimmed_token).await { - Ok(user) => user, - Err(e) => { + match fetch_current_user_for_session_store(&client, trimmed_token).await { + Ok(fetched_user) => { + session_validation_logs.push(format!( + "session JWT verified via GET /auth/me on {}", + api_url.trim_end_matches('/') + )); + fetched_user + } + Err(reason) => { // This is the store-time validation gate: if it fails the profile // is NEVER persisted, so the user bounces straight back to the // signin page after a "successful" OAuth. Timeouts/gateway 5xx are // otherwise dropped by the Sentry transient classifier, so log an // explicit, grep-friendly WARN to the app log regardless. - let reason = format!("{e:#}"); + if !auth_me_store_failure_is_transient(&reason) { + tracing::warn!( + domain = "credentials", + operation = "store_session", + "[credentials][auth-store] GET /auth/me validation FAILED on {} — session NOT persisted; user will bounce to signin: {reason}", + api_url.trim_end_matches('/') + ); + return Err(format!( + "Session validation failed (GET /auth/me): {reason}" + )); + } + + if !allow_pending_backend_validation { + tracing::warn!( + domain = "credentials", + operation = "store_session", + "[credentials][auth-store] GET /auth/me transient validation failed on {} — session NOT persisted; backend proof required before storing remote JWT: {reason}", + api_url.trim_end_matches('/') + ); + return Err(format!( + "Session validation failed (GET /auth/me): {reason}" + )); + } + + let Some(exp) = jwt_exp_live_at(trimmed_token, chrono::Utc::now()) else { + tracing::warn!( + domain = "credentials", + operation = "store_session", + "[credentials][auth-store] GET /auth/me transient validation failed on {} but JWT has no live local exp — session NOT persisted: {reason}", + api_url.trim_end_matches('/') + ); + return Err(format!( + "Session validation failed (GET /auth/me): {reason}" + )); + }; + tracing::warn!( domain = "credentials", operation = "store_session", - "[credentials][auth-store] GET /auth/me validation FAILED on {} — session NOT persisted; user will bounce to signin: {reason}", + exp = %exp, + "[credentials][auth-store] GET /auth/me transient validation failed on {} — persisting caller-authorized pending session for backend revalidation: {reason}", api_url.trim_end_matches('/') ); - return Err(format!( - "Session validation failed (GET /auth/me): {reason}" + session_validation_logs.push(format!( + "session JWT accepted with deferred GET /auth/me validation on {} after transient failure", + api_url.trim_end_matches('/') )); + fallback_session_user_for_deferred_validation() } } }; @@ -186,7 +257,12 @@ pub async fn store_session( } { metadata.insert("user_id".to_string(), uid); } - let user_for_store = if local_session { + let pending_backend_validation = settings + .as_object() + .and_then(|map| map.get("pendingBackendValidation")) + .and_then(Value::as_bool) + .unwrap_or(false); + let user_for_store = if local_session || pending_backend_validation { settings.clone() } else { sanitize_stored_session_user(user).unwrap_or(settings) @@ -199,7 +275,7 @@ pub async fn store_session( // `exp`; `decode_jwt_exp` returns None for them and the key is simply omitted // (presence-only check + the `flatten_authed_error` 401 net still apply). if !local_session { - match crate::api::jwt::decode_jwt_exp(trimmed_token) { + match decode_jwt_exp(trimmed_token) { Some(exp) => { metadata.insert( crate::openhuman::credentials::session_support::SESSION_EXPIRES_AT_META @@ -222,16 +298,32 @@ pub async fn store_session( // Determine user_id so we can scope the openhuman directory to this user. let resolved_user_id = metadata.get("user_id").cloned(); + if pending_backend_validation && resolved_user_id.is_none() { + if let Ok(root_dir) = default_root_openhuman_dir() { + if let Some(active_user_id) = read_active_user_id(&root_dir) { + let active_user_dir = user_openhuman_dir(&root_dir, &active_user_id); + if config.config_path.parent() == Some(active_user_dir.as_path()) { + tracing::warn!( + domain = "credentials", + operation = "store_session", + active_user_id = %active_user_id, + "[credentials][auth-store] unresolved pending session would replace active user's app-session; session NOT persisted" + ); + return Err( + "Session validation failed (GET /auth/me): backend user id required before replacing the active session" + .to_string(), + ); + } + } + } + } // If we know the user_id, activate the user-scoped directory BEFORE storing // the auth profile so that credentials land in the correct place. let mut logs = if local_session { vec!["local session accepted without backend validation".to_string()] } else { - vec![format!( - "session JWT verified via GET /auth/me on {}", - api_url.trim_end_matches('/') - )] + session_validation_logs }; if let Some(ref uid) = resolved_user_id { @@ -381,6 +473,64 @@ pub async fn store_session( Ok(RpcOutcome::new(summarize_auth_profile(&profile), logs)) } +async fn fetch_current_user_for_session_store( + client: &BackendOAuthClient, + token: &str, +) -> Result { + match client.fetch_current_user(token).await { + Ok(user) => Ok(user), + Err(first) => { + let first_reason = format!("{first:#}"); + if !auth_me_store_failure_is_transient(&first_reason) { + return Err(first_reason); + } + + tokio::time::sleep(AUTH_ME_STORE_RETRY_DELAY).await; + tracing::debug!( + domain = "credentials", + operation = "fetch_current_user_for_session_store", + reason = %first_reason, + "[credentials][auth-store] retrying GET /auth/me after transient failure" + ); + client + .fetch_current_user(token) + .await + .map_err(|second| format!("{second:#}")) + } + } +} + +fn auth_me_store_failure_is_transient(reason: &str) -> bool { + if let Some(status) = auth_me_failure_status(reason) { + return AUTH_ME_STORE_TRANSIENT_STATUSES.contains(&status); + } + + crate::core::observability::contains_transient_transport_phrase(reason) +} + +fn auth_me_failure_status(reason: &str) -> Option { + let lower = reason.to_ascii_lowercase(); + (100..600).find(|status| { + let status = status.to_string(); + lower.contains(&format!("({status}")) + || lower.contains(&format!("http {status}")) + || lower.contains(&format!("status {status}")) + || lower.contains(&format!("status code {status}")) + }) +} + +fn jwt_exp_live_at( + token: &str, + now: chrono::DateTime, +) -> Option> { + let exp = decode_jwt_exp(token)?; + (exp > now).then_some(exp) +} + +fn fallback_session_user_for_deferred_validation() -> Value { + json!({ "pendingBackendValidation": true }) +} + fn sanitize_stored_session_user(user: Option) -> Option { match user { Some(serde_json::Value::Object(map)) if map.is_empty() => None, diff --git a/src/openhuman/credentials/ops_tests.rs b/src/openhuman/credentials/ops_tests.rs index 9dcaeb522..0e36932dd 100644 --- a/src/openhuman/credentials/ops_tests.rs +++ b/src/openhuman/credentials/ops_tests.rs @@ -1,7 +1,13 @@ use super::*; use crate::openhuman::credentials::session_support::local_session_user_id; +use axum::http::StatusCode; +use axum::routing::get; +use axum::Router; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine; use serde_json::json; use tempfile::TempDir; +use tokio::net::TcpListener; struct EnvVarGuard { key: &'static str, @@ -36,6 +42,21 @@ fn test_config(tmp: &TempDir) -> Config { } } +fn jwt_with_payload(payload: serde_json::Value) -> String { + let payload = URL_SAFE_NO_PAD.encode(payload.to_string()); + format!("eyJhbGciOiJIUzI1NiJ9.{payload}.sig") +} + +async fn spawn_auth_me_status(status: StatusCode) -> String { + let app = Router::new().route("/auth/me", get(move || async move { status })); + 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}") +} + // ── secret_store_for_config ──────────────────────────────────── #[test] @@ -99,6 +120,249 @@ fn sanitize_stored_session_user_discards_empty_objects() { ); } +#[test] +fn auth_me_store_failure_classifier_only_accepts_transient_shapes() { + assert!(auth_me_store_failure_is_transient( + "GET /auth/me failed (503 Service Unavailable): overloaded" + )); + assert!(auth_me_store_failure_is_transient( + "GET /auth/me failed (503 Service Unavailable): session timeout" + )); + assert!(auth_me_store_failure_is_transient( + "GET /auth/me: error sending request for url" + )); + assert!(!auth_me_store_failure_is_transient( + "GET /auth/me failed (401 Unauthorized): bad token" + )); + assert!(!auth_me_store_failure_is_transient( + "GET /auth/me failed (401 Unauthorized): session timeout" + )); + assert!(!auth_me_store_failure_is_transient( + "GET /auth/me failed (403 Forbidden): connection reset" + )); +} + +#[tokio::test] +async fn store_session_rejects_live_jwt_when_auth_me_transient() { + let _env_guard = crate::openhuman::config::TEST_ENV_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + 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_status(StatusCode::SERVICE_UNAVAILABLE).await); + let token = jwt_with_payload(json!({ + "exp": (chrono::Utc::now() + chrono::Duration::hours(1)).timestamp() + })); + + let err = store_session(&config, &token, None, Some(json!({}))) + .await + .unwrap_err(); + + assert!( + err.contains("Session validation failed (GET /auth/me)"), + "expected auth/me validation error, got: {err}" + ); + let state = auth_get_state(&config).await.unwrap().value; + assert!(!state.is_authenticated); + assert!(state.user.is_none()); +} + +#[tokio::test] +async fn store_session_rejects_supplied_user_when_auth_me_transient() { + let _env_guard = crate::openhuman::config::TEST_ENV_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + 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_status(StatusCode::SERVICE_UNAVAILABLE).await); + let token = jwt_with_payload(json!({ + "exp": (chrono::Utc::now() + chrono::Duration::hours(1)).timestamp() + })); + + let err = store_session( + &config, + &token, + None, + Some(json!({ + "name": "Callback User", + "email": "callback@example.test" + })), + ) + .await + .unwrap_err(); + + assert!( + err.contains("Session validation failed (GET /auth/me)"), + "expected auth/me validation error, got: {err}" + ); + let state = auth_get_state(&config).await.unwrap().value; + assert!(!state.is_authenticated); + assert!(state.user.is_none()); +} + +#[tokio::test] +async fn store_session_rejects_non_object_user_when_auth_me_transient() { + let _env_guard = crate::openhuman::config::TEST_ENV_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + 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_status(StatusCode::SERVICE_UNAVAILABLE).await); + let token = jwt_with_payload(json!({ + "exp": (chrono::Utc::now() + chrono::Duration::hours(1)).timestamp() + })); + + let err = store_session(&config, &token, None, Some(json!("callback-user"))) + .await + .unwrap_err(); + + assert!( + err.contains("Session validation failed (GET /auth/me)"), + "expected auth/me validation error, got: {err}" + ); + let state = auth_get_state(&config).await.unwrap().value; + assert!(!state.is_authenticated); + assert!(state.user.is_none()); +} + +#[tokio::test] +async fn store_session_defers_minimal_live_jwt_when_auth_me_transient_and_allowed() { + let _env_guard = crate::openhuman::config::TEST_ENV_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + 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_status(StatusCode::SERVICE_UNAVAILABLE).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 result = store_session_with_deferred_validation( + &config, + &token, + None, + Some(json!({ + "id": "supplied-callback-user", + "name": "Supplied Callback User" + })), + ) + .await + .unwrap(); + + 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, got: {log_text}" + ); + let state = auth_get_state(&config).await.unwrap().value; + assert!(state.is_authenticated); + assert_eq!( + state.user, + Some(json!({ "pendingBackendValidation": true })), + "deferred fallback must not copy identity claims from an unverified JWT or callback payload" + ); +} + +#[tokio::test] +async fn deferred_session_without_user_id_does_not_replace_active_user_profile() { + let _env_guard = crate::openhuman::config::TEST_ENV_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + let tmp = TempDir::new().unwrap(); + let _home = EnvVarGuard::set_to_path("HOME", tmp.path()); + let root_dir = default_root_openhuman_dir().unwrap(); + let active_user_id = "existing-active-user"; + write_active_user_id(&root_dir, active_user_id).unwrap(); + let active_user_dir = user_openhuman_dir(&root_dir, active_user_id); + std::fs::create_dir_all(active_user_dir.join("workspace")).unwrap(); + let mut config = Config { + config_path: active_user_dir.join("config.toml"), + workspace_dir: active_user_dir.join("workspace"), + action_dir: active_user_dir.join("workspace"), + ..Config::default() + }; + config.api_url = Some(spawn_auth_me_status(StatusCode::SERVICE_UNAVAILABLE).await); + let mut metadata = std::collections::HashMap::new(); + metadata.insert("user_id".to_string(), active_user_id.to_string()); + metadata.insert( + "user_json".to_string(), + json!({ + "id": active_user_id, + "name": "Existing Active User" + }) + .to_string(), + ); + AuthService::from_config(&config) + .store_provider_token( + APP_SESSION_PROVIDER, + DEFAULT_AUTH_PROFILE_NAME, + "existing.active.session", + metadata, + true, + ) + .unwrap(); + let pending_token = jwt_with_payload(json!({ + "exp": (chrono::Utc::now() + chrono::Duration::hours(1)).timestamp() + })); + + let err = + store_session_with_deferred_validation(&config, &pending_token, None, Some(json!({}))) + .await + .unwrap_err(); + + assert!( + err.contains("backend user id required before replacing the active session"), + "expected active-session protection error, got: {err}" + ); + let state = auth_get_state(&config).await.unwrap().value; + assert!(state.is_authenticated); + let token = auth_get_session_token_json(&config).await.unwrap().value; + assert_eq!(token.get("token"), Some(&json!("existing.active.session"))); + assert_eq!(state.user_id.as_deref(), Some(active_user_id)); + assert_eq!( + state.user.as_ref().and_then(|value| value.get("id")), + Some(&json!(active_user_id)) + ); +} + +#[tokio::test] +async fn store_session_rejects_live_jwt_when_auth_me_unauthorized() { + let _env_guard = crate::openhuman::config::TEST_ENV_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + 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_status(StatusCode::UNAUTHORIZED).await); + let token = jwt_with_payload(json!({ + "exp": (chrono::Utc::now() + chrono::Duration::hours(1)).timestamp() + })); + + let err = store_session(&config, &token, None, None) + .await + .unwrap_err(); + + assert!( + err.contains("Session validation failed (GET /auth/me)"), + "expected auth/me validation error, got: {err}" + ); + let state = auth_get_state(&config).await.unwrap().value; + assert!(!state.is_authenticated); +} + // ── store_session (local session) ───────────────────────────── /// A local session token requires a non-empty user payload — the backend diff --git a/src/openhuman/credentials/schemas.rs b/src/openhuman/credentials/schemas.rs index e1530ada7..5e804a224 100644 --- a/src/openhuman/credentials/schemas.rs +++ b/src/openhuman/credentials/schemas.rs @@ -14,6 +14,8 @@ struct AuthStoreSessionParams { user_id: Option, #[serde(default)] user: Option, + #[serde(default, alias = "allowPendingBackendValidation")] + allow_pending_backend_validation: Option, } #[derive(Debug, Deserialize)] @@ -183,6 +185,10 @@ pub fn schemas(function: &str) -> ControllerSchema { required_string("token", "Session JWT token."), optional_json("user_id", "Optional user id hint."), optional_json("user", "Optional user payload."), + optional_bool( + "allowPendingBackendValidation", + "Allow trusted callback flows to defer backend validation after transient auth/me failure.", + ), ], outputs: vec![json_output("profile", "Stored auth profile summary.")], }, @@ -323,15 +329,25 @@ fn handle_auth_store_session(params: Map) -> ControllerFuture { Box::pin(async move { let config = config_rpc::load_config_with_timeout().await?; let payload = deserialize_params::(params)?; - to_json( + let allow_pending_backend_validation = + payload.allow_pending_backend_validation.unwrap_or(false); + to_json(if allow_pending_backend_validation { + crate::openhuman::credentials::rpc::store_session_with_deferred_validation( + &config, + &payload.token, + payload.user_id, + payload.user, + ) + .await? + } else { crate::openhuman::credentials::rpc::store_session( &config, &payload.token, payload.user_id, payload.user, ) - .await?, - ) + .await? + }) }) } diff --git a/src/openhuman/credentials/schemas_tests.rs b/src/openhuman/credentials/schemas_tests.rs index 7016939c1..450caf6a8 100644 --- a/src/openhuman/credentials/schemas_tests.rs +++ b/src/openhuman/credentials/schemas_tests.rs @@ -163,6 +163,7 @@ fn deserialize_params_parses_valid_object_into_struct() { assert_eq!(parsed.token, "abc"); assert!(parsed.user_id.is_none()); assert!(parsed.user.is_none()); + assert_eq!(parsed.allow_pending_backend_validation, None); } #[test] @@ -174,6 +175,15 @@ fn deserialize_params_honours_userid_alias() { assert_eq!(parsed.user_id.as_deref(), Some("u1")); } +#[test] +fn deserialize_params_honours_allow_pending_backend_validation_alias() { + let mut m = Map::new(); + m.insert("token".into(), Value::String("abc".into())); + m.insert("allowPendingBackendValidation".into(), Value::Bool(true)); + let parsed: AuthStoreSessionParams = deserialize_params(m).unwrap(); + assert_eq!(parsed.allow_pending_backend_validation, Some(true)); +} + #[test] fn deserialize_params_reports_missing_required_fields() { // `token` is required — an empty object must fail. diff --git a/src/openhuman/wallet/ops.rs b/src/openhuman/wallet/ops.rs index 189a9780a..8d4181264 100644 --- a/src/openhuman/wallet/ops.rs +++ b/src/openhuman/wallet/ops.rs @@ -988,17 +988,18 @@ mod tests { // ── reveal_recovery_phrase unit tests ──────────────────────────────────── // These use tokio::test and OPENHUMAN_WORKSPACE env var to wire up the full - // async path including config loading. The TEST_LOCK from test_support - // serialises all wallet tests that mutate env vars. + // async path including config loading. TEST_LOCK serializes wallet globals; + // TEST_ENV_LOCK serializes the process-wide workspace env var. #[tokio::test] async fn reveal_recovery_phrase_returns_error_when_no_wallet() { let temp = tempfile::tempdir().expect("temp dir"); - let _lock = crate::openhuman::wallet::test_support::TEST_LOCK.lock(); - let _env_guard = crate::openhuman::config::TEST_ENV_LOCK + let _wallet_lock = crate::openhuman::wallet::test_support::TEST_LOCK.lock(); + let _env_lock = crate::openhuman::config::TEST_ENV_LOCK .lock() .unwrap_or_else(|e| e.into_inner()); - std::env::set_var("OPENHUMAN_WORKSPACE", temp.path()); + let _workspace = + crate::openhuman::wallet::test_support::WorkspaceEnvGuard::set(temp.path()); let result = reveal_recovery_phrase().await; let err = result.expect_err("should error when no wallet configured"); assert!( @@ -1010,10 +1011,12 @@ mod tests { #[tokio::test] async fn reveal_recovery_phrase_returns_phrase_for_existing_wallet() { let temp = tempfile::tempdir().expect("temp dir"); - let _lock = crate::openhuman::wallet::test_support::TEST_LOCK.lock(); - let _env_guard = crate::openhuman::config::TEST_ENV_LOCK + let _wallet_lock = crate::openhuman::wallet::test_support::TEST_LOCK.lock(); + let _env_lock = crate::openhuman::config::TEST_ENV_LOCK .lock() .unwrap_or_else(|e| e.into_inner()); + let _workspace = + crate::openhuman::wallet::test_support::WorkspaceEnvGuard::set(temp.path()); crate::openhuman::wallet::test_support::setup_wallet_in(&temp) .await .expect("setup wallet"); diff --git a/src/openhuman/wallet/test_support.rs b/src/openhuman/wallet/test_support.rs index f629cff90..e86b74507 100644 --- a/src/openhuman/wallet/test_support.rs +++ b/src/openhuman/wallet/test_support.rs @@ -2,12 +2,15 @@ //! //! Provides: //! - [`TEST_LOCK`]: serializes wallet tests that mutate the global quote store -//! and per-chain env-var overrides. +//! and per-chain env-var overrides. Tests that mutate +//! `OPENHUMAN_WORKSPACE` also hold the config `TEST_ENV_LOCK`. //! - [`setup_wallet_in`]: writes a configured wallet state into a //! [`tempfile::TempDir`] using the standard "abandon × 11 about" mnemonic //! so every chain's signer derives a deterministic address. //! - Sample addresses corresponding to that mnemonic (one per chain). +use std::path::Path; + use once_cell::sync::Lazy; use parking_lot::Mutex; use tempfile::TempDir; @@ -60,6 +63,14 @@ pub(crate) struct WorkspaceEnvGuard { prev: Option, } +impl WorkspaceEnvGuard { + pub(crate) fn set(path: impl AsRef) -> Self { + let prev = std::env::var_os("OPENHUMAN_WORKSPACE"); + std::env::set_var("OPENHUMAN_WORKSPACE", path.as_ref()); + Self { prev } + } +} + impl Drop for WorkspaceEnvGuard { fn drop(&mut self) { match self.prev.take() { @@ -71,8 +82,8 @@ impl Drop for WorkspaceEnvGuard { pub(crate) async fn setup_wallet_in(temp: &TempDir) -> Result<(), String> { // We intentionally leak the env-var change for the duration of the test - // (wallet state lookups rely on it). The shared `TEST_LOCK` mutex - // serializes tests so concurrent tests can't race on the var. + // (wallet state lookups rely on it). Callers that run under the full lib + // suite hold the repo-wide config TEST_ENV_LOCK while this env var is set. std::env::set_var("OPENHUMAN_WORKSPACE", temp.path()); let config = config_rpc::load_config_with_timeout().await?; let encrypted = crate::openhuman::encryption::rpc::encrypt_secret(&config, TEST_MNEMONIC) diff --git a/tests/app_state_credentials_raw_coverage_e2e.rs b/tests/app_state_credentials_raw_coverage_e2e.rs index 83af1fb32..3c31d7e3b 100644 --- a/tests/app_state_credentials_raw_coverage_e2e.rs +++ b/tests/app_state_credentials_raw_coverage_e2e.rs @@ -1,6 +1,8 @@ use std::collections::{BTreeMap, HashMap}; use std::path::{Path, PathBuf}; -use std::sync::{Mutex, OnceLock}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::Duration; use chrono::Utc; use openhuman_core::openhuman::app_state::{ @@ -153,6 +155,33 @@ fn setup(api_url: &str) -> Harness { } } +fn setup_default_paths(api_url: &str) -> Harness { + let tmp = tempdir(); + let guards = vec![ + EnvGuard::set_to_path("HOME", tmp.path()), + EnvGuard::unset("OPENHUMAN_WORKSPACE"), + EnvGuard::unset("BACKEND_URL"), + EnvGuard::unset("VITE_BACKEND_URL"), + EnvGuard::unset("OPENHUMAN_API_URL"), + EnvGuard::unset("OPENHUMAN_CORE_RPC_URL"), + EnvGuard::unset("OPENHUMAN_CORE_PORT"), + EnvGuard::set("OPENHUMAN_KEYRING_BACKEND", "file"), + EnvGuard::set("OPENHUMAN_MEMORY_EMBED_STRICT", "false"), + EnvGuard::set("OPENHUMAN_MEMORY_EMBED_ENDPOINT", ""), + EnvGuard::set("OPENHUMAN_MEMORY_EMBED_MODEL", ""), + ]; + let default_root = openhuman_core::openhuman::config::default_root_openhuman_dir() + .expect("default openhuman root"); + let root = openhuman_core::openhuman::config::pre_login_user_dir(&default_root); + write_min_config(&root, api_url); + + Harness { + _tmp: tmp, + root, + _guards: guards, + } +} + async fn auth_me_server( body: &'static str, ) -> ( @@ -189,10 +218,7 @@ async fn auth_me_server( (url, task, shutdown_tx) } -/// Like `auth_me_server` but always replies HTTP 500, so `store_session`'s -/// `GET /auth/me` validation gate fails — exercising the WARN + `Err` path that -/// leaves the session unpersisted (the "OAuth succeeded but app is back on the -/// signin page" bug). +/// Like `auth_me_server` but always replies HTTP 500. async fn auth_me_failing_server() -> ( String, tokio::task::JoinHandle<()>, @@ -226,6 +252,91 @@ async fn auth_me_failing_server() -> ( (url, task, shutdown_tx) } +async fn auth_me_rejected_server() -> ( + String, + tokio::task::JoinHandle<()>, + tokio::sync::oneshot::Sender<()>, +) { + auth_me_status_sequence_server(vec![( + 401, + "Unauthorized", + "{\"error\":\"revoked session\"}", + )]) + .await +} + +async fn auth_me_status_sequence_server( + responses: Vec<(u16, &'static str, &'static str)>, +) -> ( + String, + tokio::task::JoinHandle<()>, + tokio::sync::oneshot::Sender<()>, +) { + assert!(!responses.is_empty(), "status sequence must not be empty"); + let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)) + .await + .expect("bind sequenced auth/me listener"); + let url = format!("http://{}", listener.local_addr().expect("listener addr")); + let responses = Arc::new(responses); + let next_response = Arc::new(AtomicUsize::new(0)); + let (shutdown_tx, mut shutdown_rx) = tokio::sync::oneshot::channel::<()>(); + let task = tokio::spawn(async move { + loop { + tokio::select! { + _ = &mut shutdown_rx => break, + accepted = listener.accept() => { + let Ok((mut stream, _)) = accepted else { break; }; + let mut req = [0_u8; 2048]; + let _ = stream.read(&mut req).await; + let idx = next_response.fetch_add(1, Ordering::SeqCst); + let (status, reason, body) = responses + .get(idx) + .or_else(|| responses.last()) + .copied() + .expect("non-empty response sequence"); + let response = format!( + "HTTP/1.1 {status} {reason}\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", + body.len(), + body + ); + let _ = stream.write_all(response.as_bytes()).await; + let _ = stream.shutdown().await; + } + } + } + }); + (url, task, shutdown_tx) +} + +async fn auth_me_hanging_server() -> ( + String, + tokio::task::JoinHandle<()>, + tokio::sync::oneshot::Sender<()>, +) { + let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)) + .await + .expect("bind hanging auth/me listener"); + let url = format!("http://{}", listener.local_addr().expect("listener addr")); + let (shutdown_tx, mut shutdown_rx) = tokio::sync::oneshot::channel::<()>(); + let task = tokio::spawn(async move { + loop { + tokio::select! { + _ = &mut shutdown_rx => break, + accepted = listener.accept() => { + let Ok((mut stream, _)) = accepted else { break; }; + tokio::spawn(async move { + let mut req = [0_u8; 2048]; + let _ = stream.read(&mut req).await; + tokio::time::sleep(Duration::from_secs(30)).await; + let _ = stream.shutdown().await; + }); + } + } + } + }); + (url, task, shutdown_tx) +} + /// Store-time `/auth/me` failure must surface as `Err` (and NOT persist a /// profile), which is what bounces the user back to signin after a "successful" /// OAuth. Covers the WARN/`Err` gate added in `credentials::ops::store_session`. @@ -416,6 +527,766 @@ async fn round14_snapshot_uses_stored_user_when_backend_user_is_empty_or_unreach let _ = server_task.await; } +#[tokio::test] +async fn snapshot_clears_pending_backend_validation_after_successful_revalidation() { + let _lock = env_lock(); + let (api_url, server_task, shutdown_tx) = auth_me_server( + r#"{"data":{"id":"fresh-pending-user","name":"Fresh Pending","email":"fresh-pending@example.test"}}"#, + ) + .await; + let harness = setup(&api_url); + let config = harness.config().await; + let active_user_root = openhuman_core::openhuman::config::default_root_openhuman_dir() + .expect("default openhuman root"); + + let mut metadata = HashMap::new(); + metadata.insert("user_id".to_string(), "pending-user".to_string()); + metadata.insert( + "user_json".to_string(), + json!({ + "id": "pending-user", + "name": "Pending User", + "email": "pending@example.test", + "pendingBackendValidation": true + }) + .to_string(), + ); + AuthService::from_config(&config) + .store_provider_token( + APP_SESSION_PROVIDER, + DEFAULT_AUTH_PROFILE_NAME, + "round14.pending.success", + metadata, + true, + ) + .expect("seed pending app session"); + + let snap = snapshot() + .await + .expect("snapshot with successful pending revalidation") + .value; + assert!(snap.auth.is_authenticated); + assert_eq!( + snap.session_token.as_deref(), + Some("round14.pending.success") + ); + assert_eq!( + snap.current_user.as_ref().and_then(|v| v.get("id")), + Some(&json!("fresh-pending-user")) + ); + assert!( + snap.current_user + .as_ref() + .and_then(|v| v.get("pendingBackendValidation")) + .is_none(), + "fresh snapshot user must not keep pendingBackendValidation" + ); + + let profile = AuthService::from_config(&config) + .get_profile(APP_SESSION_PROVIDER, None) + .expect("read profile") + .expect("profile remains in env-scoped config after successful revalidation"); + assert_eq!( + profile.metadata.get("user_id").map(String::as_str), + Some("fresh-pending-user") + ); + let stored_user: Value = serde_json::from_str( + profile + .metadata + .get("user_json") + .expect("persisted user_json"), + ) + .expect("stored user json"); + assert_eq!( + stored_user.get("id").and_then(Value::as_str), + Some("fresh-pending-user") + ); + assert!( + stored_user.get("pendingBackendValidation").is_none(), + "successful revalidation must clear persisted pendingBackendValidation" + ); + assert_eq!( + openhuman_core::openhuman::config::read_active_user_id(&active_user_root).as_deref(), + None, + "env-scoped revalidation must not move auth state into default active_user.toml" + ); + let next_snap = snapshot() + .await + .expect("next env-scoped snapshot remains authenticated") + .value; + assert!(next_snap.auth.is_authenticated); + assert_eq!( + next_snap.auth.user_id.as_deref(), + Some("fresh-pending-user"), + "next env-scoped snapshot must keep loading the revalidated profile" + ); + + let _ = shutdown_tx.send(()); + let _ = server_task.await; +} + +#[tokio::test] +async fn snapshot_preserves_pending_session_when_revalidation_user_lacks_id() { + let _lock = env_lock(); + let (api_url, server_task, shutdown_tx) = + auth_me_server(r#"{"data":{"name":"No Id User","email":"no-id@example.test"}}"#).await; + let harness = setup(&api_url); + let config = harness.config().await; + + let mut metadata = HashMap::new(); + metadata.insert("user_id".to_string(), "pending-no-id-user".to_string()); + metadata.insert( + "user_json".to_string(), + json!({ + "id": "pending-no-id-user", + "name": "Pending No Id User", + "email": "pending-no-id@example.test", + "pendingBackendValidation": true + }) + .to_string(), + ); + AuthService::from_config(&config) + .store_provider_token( + APP_SESSION_PROVIDER, + DEFAULT_AUTH_PROFILE_NAME, + "round14.pending.no-id", + metadata, + true, + ) + .expect("seed no-id pending app session"); + + let snap = snapshot() + .await + .expect("snapshot with no-id pending revalidation") + .value; + assert!( + snap.auth.is_authenticated, + "pending session remains locally authenticated while backend identity lacks id" + ); + assert_eq!(snap.session_token.as_deref(), Some("round14.pending.no-id")); + assert_eq!( + snap.current_user.as_ref().and_then(|v| v.get("id")), + Some(&json!("pending-no-id-user")) + ); + assert_eq!( + snap.current_user + .as_ref() + .and_then(|v| v.get("pendingBackendValidation")), + Some(&json!(true)), + "no-id revalidation must not clear pendingBackendValidation" + ); + + let profile = AuthService::from_config(&config) + .get_profile(APP_SESSION_PROVIDER, None) + .expect("read profile after no-id revalidation") + .expect("profile remains pending after no-id revalidation"); + assert_eq!( + profile.metadata.get("user_id").map(String::as_str), + Some("pending-no-id-user"), + "stale pending user id remains marked pending rather than validated" + ); + let stored_user: Value = serde_json::from_str( + profile + .metadata + .get("user_json") + .expect("persisted no-id user_json"), + ) + .expect("stored no-id user json"); + assert_eq!( + stored_user.get("pendingBackendValidation"), + Some(&json!(true)), + "persisted no-id session must stay pending" + ); + + let _ = shutdown_tx.send(()); + let _ = server_task.await; +} + +#[tokio::test] +async fn snapshot_activates_user_dir_after_pending_revalidation_without_initial_user_id() { + let _lock = env_lock(); + let (api_url, server_task, shutdown_tx) = auth_me_server( + r#"{"data":{"id":"fresh-activated-user","name":"Fresh Activated","email":"fresh-activated@example.test"}}"#, + ) + .await; + let harness = setup_default_paths(&api_url); + let config = harness.config().await; + let active_user_root = openhuman_core::openhuman::config::default_root_openhuman_dir() + .expect("default openhuman root"); + assert_eq!( + openhuman_core::openhuman::config::read_active_user_id(&active_user_root), + None, + "test must start without active_user.toml" + ); + update_local_state(StoredAppStatePatch { + keyring_consent: None, + encryption_key: Some(Some("pre-login-key".to_string())), + onboarding_tasks: None, + }) + .await + .expect("seed pre-login local state"); + let activated_user_dir = openhuman_core::openhuman::config::user_openhuman_dir( + &active_user_root, + "fresh-activated-user", + ); + write_min_config(&activated_user_dir, &api_url); + let activated_state_dir = activated_user_dir.join("workspace/state"); + std::fs::create_dir_all(&activated_state_dir).expect("create activated user state dir"); + std::fs::write( + activated_state_dir.join("app-state.json"), + r#"{"encryptionKey":"activated-user-key"}"#, + ) + .expect("seed activated user local state"); + + let mut metadata = HashMap::new(); + metadata.insert( + "user_json".to_string(), + json!({ + "pendingBackendValidation": true + }) + .to_string(), + ); + AuthService::from_config(&config) + .store_provider_token( + APP_SESSION_PROVIDER, + DEFAULT_AUTH_PROFILE_NAME, + "round14.pending.activate", + metadata, + true, + ) + .expect("seed pending app session without user id"); + + let snap = snapshot() + .await + .expect("snapshot with successful pending activation") + .value; + assert!(snap.auth.is_authenticated); + assert_eq!( + snap.session_token.as_deref(), + Some("round14.pending.activate") + ); + assert_eq!(snap.auth.user_id.as_deref(), Some("fresh-activated-user")); + assert_eq!( + snap.current_user.as_ref().and_then(|v| v.get("id")), + Some(&json!("fresh-activated-user")) + ); + assert!( + snap.current_user + .as_ref() + .and_then(|v| v.get("pendingBackendValidation")) + .is_none(), + "activated snapshot user must not keep pendingBackendValidation" + ); + assert_eq!( + openhuman_core::openhuman::config::read_active_user_id(&active_user_root).as_deref(), + Some("fresh-activated-user"), + "successful pending revalidation must activate the backend user" + ); + assert_eq!( + snap.local_state.encryption_key.as_deref(), + Some("activated-user-key"), + "first successful activation snapshot must reload the activated user's local state" + ); + + let active_config = openhuman_core::openhuman::config::Config::load_from_default_paths() + .await + .expect("load activated user config"); + let active_profile = AuthService::from_config(&active_config) + .get_profile(APP_SESSION_PROVIDER, None) + .expect("read active profile") + .expect("profile must be written under activated user config"); + assert_eq!( + active_profile.metadata.get("user_id").map(String::as_str), + Some("fresh-activated-user") + ); + let stored_user: Value = serde_json::from_str( + active_profile + .metadata + .get("user_json") + .expect("persisted activated user_json"), + ) + .expect("activated stored user json"); + assert_eq!( + stored_user.get("id").and_then(Value::as_str), + Some("fresh-activated-user") + ); + assert!( + stored_user.get("pendingBackendValidation").is_none(), + "activated persisted user must not keep pendingBackendValidation" + ); + let source_profile = AuthService::from_config(&config) + .get_profile(APP_SESSION_PROVIDER, None) + .expect("read source profile after activation"); + assert!( + source_profile.is_none(), + "source pre-login pending profile must be removed after activated persist succeeds" + ); + + let _ = shutdown_tx.send(()); + let _ = server_task.await; +} + +#[tokio::test] +async fn snapshot_preserves_pending_session_when_revalidated_user_activation_fails() { + let _lock = env_lock(); + let (api_url, server_task, shutdown_tx) = auth_me_server( + r#"{"data":{"id":"fresh-activation-failure","name":"Activation Failure","email":"activation-failure@example.test"}}"#, + ) + .await; + let harness = setup_default_paths(&api_url); + let config = harness.config().await; + let active_user_root = openhuman_core::openhuman::config::default_root_openhuman_dir() + .expect("default openhuman root"); + assert_eq!( + openhuman_core::openhuman::config::read_active_user_id(&active_user_root), + None, + "test must start without active_user.toml" + ); + std::fs::create_dir_all(active_user_root.join("active_user.toml")) + .expect("block active user marker write"); + + let mut metadata = HashMap::new(); + metadata.insert( + "user_json".to_string(), + json!({ + "pendingBackendValidation": true + }) + .to_string(), + ); + AuthService::from_config(&config) + .store_provider_token( + APP_SESSION_PROVIDER, + DEFAULT_AUTH_PROFILE_NAME, + "round14.pending.activation-failure", + metadata, + true, + ) + .expect("seed activation-failure pending app session"); + + let snap = snapshot() + .await + .expect("snapshot with failed pending activation") + .value; + assert!( + snap.auth.is_authenticated, + "activation write failure should keep the pending local session for retry" + ); + assert_eq!( + snap.session_token.as_deref(), + Some("round14.pending.activation-failure") + ); + assert_eq!( + snap.current_user + .as_ref() + .and_then(|v| v.get("pendingBackendValidation")), + Some(&json!(true)), + "failed activation must not return a validated backend user" + ); + assert!( + active_user_root.join("active_user.toml").is_dir(), + "failed active_user.toml write must not be treated as an activated user" + ); + + let profile = AuthService::from_config(&config) + .get_profile(APP_SESSION_PROVIDER, None) + .expect("read profile after failed activation") + .expect("source pending profile remains available for retry"); + let stored_user: Value = serde_json::from_str( + profile + .metadata + .get("user_json") + .expect("persisted activation-failure user_json"), + ) + .expect("activation-failure stored user json"); + assert_eq!( + stored_user.get("pendingBackendValidation"), + Some(&json!(true)), + "activation failure must not clear persisted pendingBackendValidation" + ); + + let _ = shutdown_tx.send(()); + let _ = server_task.await; +} + +#[tokio::test] +async fn snapshot_clears_pending_session_when_backend_revalidation_is_rejected() { + let _lock = env_lock(); + let (api_url, server_task, shutdown_tx) = auth_me_rejected_server().await; + let harness = setup(&api_url); + let config = harness.config().await; + + let mut metadata = HashMap::new(); + metadata.insert("user_id".to_string(), "pending-reject-user".to_string()); + metadata.insert( + "user_json".to_string(), + json!({ + "id": "pending-reject-user", + "email": "pending-reject@example.test", + "pendingBackendValidation": true + }) + .to_string(), + ); + AuthService::from_config(&config) + .store_provider_token( + APP_SESSION_PROVIDER, + DEFAULT_AUTH_PROFILE_NAME, + "round14.pending.rejected", + metadata, + true, + ) + .expect("seed rejected pending app session"); + + let snap = snapshot() + .await + .expect("snapshot with rejected pending revalidation") + .value; + assert!( + !snap.auth.is_authenticated, + "pending session must fail closed when backend revalidation is rejected" + ); + assert!(snap.auth.user.is_none()); + assert!(snap.current_user.is_none()); + assert!(snap.session_token.is_none()); + let profile = AuthService::from_config(&config) + .get_profile(APP_SESSION_PROVIDER, None) + .expect("read profile after rejection"); + assert!( + profile.is_none(), + "rejected pending session profile must be cleared" + ); + + let _ = shutdown_tx.send(()); + let _ = server_task.await; +} + +#[tokio::test] +async fn snapshot_preserves_default_active_user_when_env_scoped_revalidation_is_rejected() { + let _lock = env_lock(); + let (api_url, server_task, shutdown_tx) = auth_me_rejected_server().await; + let harness = setup(&api_url); + let config = harness.config().await; + let active_user_root = openhuman_core::openhuman::config::default_root_openhuman_dir() + .expect("default openhuman root"); + openhuman_core::openhuman::config::write_active_user_id(&active_user_root, "desktop-user") + .expect("seed default active user"); + + let mut metadata = HashMap::new(); + metadata.insert("user_id".to_string(), "pending-env-rejected".to_string()); + metadata.insert( + "user_json".to_string(), + json!({ + "id": "pending-env-rejected", + "email": "pending-env-rejected@example.test", + "pendingBackendValidation": true + }) + .to_string(), + ); + AuthService::from_config(&config) + .store_provider_token( + APP_SESSION_PROVIDER, + DEFAULT_AUTH_PROFILE_NAME, + "round14.pending.env.rejected", + metadata, + true, + ) + .expect("seed env-scoped rejected pending app session"); + + let snap = snapshot() + .await + .expect("snapshot with env-scoped rejected pending revalidation") + .value; + assert!( + !snap.auth.is_authenticated, + "env-scoped pending session must fail closed when backend revalidation is rejected" + ); + assert!(snap.auth.user.is_none()); + assert!(snap.current_user.is_none()); + assert!(snap.session_token.is_none()); + let profile = AuthService::from_config(&config) + .get_profile(APP_SESSION_PROVIDER, None) + .expect("read env-scoped profile after rejection"); + assert!( + profile.is_none(), + "rejected env-scoped pending session profile must be cleared" + ); + assert_eq!( + openhuman_core::openhuman::config::read_active_user_id(&active_user_root).as_deref(), + Some("desktop-user"), + "env-scoped rejection cleanup must not clear the default active user" + ); + + let _ = shutdown_tx.send(()); + let _ = server_task.await; +} + +#[tokio::test] +async fn snapshot_preserves_pending_session_when_backend_revalidation_is_transient() { + let _lock = env_lock(); + let (api_url, server_task, shutdown_tx) = auth_me_failing_server().await; + let harness = setup(&api_url); + let config = harness.config().await; + + let mut metadata = HashMap::new(); + metadata.insert("user_id".to_string(), "pending-transient-user".to_string()); + metadata.insert( + "user_json".to_string(), + json!({ + "id": "pending-transient-user", + "email": "pending-transient@example.test", + "pendingBackendValidation": true + }) + .to_string(), + ); + AuthService::from_config(&config) + .store_provider_token( + APP_SESSION_PROVIDER, + DEFAULT_AUTH_PROFILE_NAME, + "round14.pending.transient", + metadata, + true, + ) + .expect("seed transient pending app session"); + + let snap = snapshot() + .await + .expect("snapshot with transient pending revalidation") + .value; + assert!( + snap.auth.is_authenticated, + "pending session must stay authenticated when revalidation is transient" + ); + assert_eq!( + snap.session_token.as_deref(), + Some("round14.pending.transient") + ); + assert_eq!( + snap.current_user.as_ref().and_then(|v| v.get("id")), + Some(&json!("pending-transient-user")) + ); + assert_eq!( + snap.current_user + .as_ref() + .and_then(|v| v.get("pendingBackendValidation")), + Some(&json!(true)) + ); + let profile = AuthService::from_config(&config) + .get_profile(APP_SESSION_PROVIDER, None) + .expect("read profile after transient revalidation"); + assert!( + profile.is_some(), + "transient pending session profile must be preserved for retry" + ); + + let _ = shutdown_tx.send(()); + let _ = server_task.await; +} + +#[tokio::test] +async fn snapshot_clears_supplied_user_pending_session_after_revalidation_rejection() { + let _lock = env_lock(); + let (api_url, server_task, shutdown_tx) = auth_me_rejected_server().await; + let harness = setup(&api_url); + let config = harness.config().await; + let user_id = "callback-user"; + let active_user_root = openhuman_core::openhuman::config::default_root_openhuman_dir() + .expect("default openhuman root"); + openhuman_core::openhuman::config::write_active_user_id(&active_user_root, user_id) + .expect("seed active user marker for supplied pending session"); + + let mut metadata = HashMap::new(); + metadata.insert("user_id".to_string(), user_id.to_string()); + metadata.insert( + "user_json".to_string(), + json!({ + "id": user_id, + "name": "Callback User", + "email": "callback@example.test", + "pendingBackendValidation": true + }) + .to_string(), + ); + AuthService::from_config(&config) + .store_provider_token( + APP_SESSION_PROVIDER, + DEFAULT_AUTH_PROFILE_NAME, + "round14.pending.callback", + metadata, + true, + ) + .expect("seed supplied pending app session"); + assert_eq!( + openhuman_core::openhuman::config::read_active_user_id(&active_user_root).as_deref(), + Some(user_id), + "test must start with active_user.toml pointing at the supplied pending user" + ); + + let active_config = harness.config().await; + let profile = AuthService::from_config(&active_config) + .get_profile(APP_SESSION_PROVIDER, None) + .expect("read deferred profile") + .expect("deferred profile is persisted"); + let stored_user: Value = serde_json::from_str( + profile + .metadata + .get("user_json") + .expect("persisted user_json"), + ) + .expect("stored deferred user json"); + assert_eq!( + stored_user.get("pendingBackendValidation"), + Some(&json!(true)), + "supplied callback user must keep the pending marker" + ); + assert_eq!( + stored_user.get("email").and_then(Value::as_str), + Some("callback@example.test") + ); + + let snap = snapshot() + .await + .expect("snapshot with supplied pending user") + .value; + assert!( + !snap.auth.is_authenticated, + "supplied pending user must fail closed when revalidation is rejected" + ); + assert!(snap.auth.user.is_none()); + assert!(snap.current_user.is_none()); + assert!(snap.session_token.is_none()); + assert_eq!( + openhuman_core::openhuman::config::read_active_user_id(&active_user_root), + None, + "rejected supplied-user pending session must clear active_user.toml" + ); + let profile = AuthService::from_config(&active_config) + .get_profile(APP_SESSION_PROVIDER, None) + .expect("read profile after supplied-user rejection"); + assert!( + profile.is_none(), + "rejected supplied-user pending session profile must be cleared" + ); + + let _ = shutdown_tx.send(()); + let _ = server_task.await; +} + +#[tokio::test] +async fn snapshot_preserves_pending_session_when_revalidation_request_errors() { + let _lock = env_lock(); + let harness = setup("http://127.0.0.1:9"); + let config = harness.config().await; + + let mut metadata = HashMap::new(); + metadata.insert("user_id".to_string(), "pending-error-user".to_string()); + metadata.insert( + "user_json".to_string(), + json!({ + "id": "pending-error-user", + "email": "pending-error@example.test", + "pendingBackendValidation": true + }) + .to_string(), + ); + AuthService::from_config(&config) + .store_provider_token( + APP_SESSION_PROVIDER, + DEFAULT_AUTH_PROFILE_NAME, + "round14.pending.error", + metadata, + true, + ) + .expect("seed errored pending app session"); + + let snap = snapshot() + .await + .expect("snapshot with errored pending revalidation") + .value; + assert!( + snap.auth.is_authenticated, + "pending session must stay authenticated when revalidation request errors before a backend response" + ); + assert_eq!(snap.session_token.as_deref(), Some("round14.pending.error")); + assert_eq!( + snap.current_user.as_ref().and_then(|v| v.get("id")), + Some(&json!("pending-error-user")) + ); + assert_eq!( + snap.current_user + .as_ref() + .and_then(|v| v.get("pendingBackendValidation")), + Some(&json!(true)) + ); + let profile = AuthService::from_config(&config) + .get_profile(APP_SESSION_PROVIDER, None) + .expect("read profile after request error"); + assert!( + profile.is_some(), + "errored pending session profile must be preserved for retry" + ); +} + +#[tokio::test] +async fn snapshot_preserves_pending_session_when_revalidation_times_out() { + let _lock = env_lock(); + let (api_url, server_task, shutdown_tx) = auth_me_hanging_server().await; + let harness = setup(&api_url); + let config = harness.config().await; + + let mut metadata = HashMap::new(); + metadata.insert("user_id".to_string(), "pending-timeout-user".to_string()); + metadata.insert( + "user_json".to_string(), + json!({ + "id": "pending-timeout-user", + "email": "pending-timeout@example.test", + "pendingBackendValidation": true + }) + .to_string(), + ); + AuthService::from_config(&config) + .store_provider_token( + APP_SESSION_PROVIDER, + DEFAULT_AUTH_PROFILE_NAME, + "round14.pending.timeout", + metadata, + true, + ) + .expect("seed timed-out pending app session"); + + let snap = snapshot() + .await + .expect("snapshot with timed-out pending revalidation") + .value; + assert!( + snap.auth.is_authenticated, + "pending session must stay authenticated when revalidation times out before a backend response" + ); + assert_eq!( + snap.session_token.as_deref(), + Some("round14.pending.timeout") + ); + assert_eq!( + snap.current_user.as_ref().and_then(|v| v.get("id")), + Some(&json!("pending-timeout-user")) + ); + assert_eq!( + snap.current_user + .as_ref() + .and_then(|v| v.get("pendingBackendValidation")), + Some(&json!(true)) + ); + let profile = AuthService::from_config(&config) + .get_profile(APP_SESSION_PROVIDER, None) + .expect("read profile after timeout"); + assert!( + profile.is_some(), + "timed-out pending session profile must be preserved for retry" + ); + + let _ = shutdown_tx.send(()); + let _ = server_task.await; +} + #[test] fn round14_profiles_cover_oauth_token_selection_schema_and_quarantine_edges() { let _lock = env_lock();