mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
[codex] fix auth session persistence on transient /auth/me (#3746)
Co-authored-by: Sami Rusani <sr@samirusani> Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
co-authored by
Sami Rusani
Steven Enamakel
parent
df94647df3
commit
2412ec7afc
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -167,7 +167,7 @@ const applySessionToken = async (sessionToken: string): Promise<void> => {
|
||||
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 } }));
|
||||
}
|
||||
|
||||
@@ -58,8 +58,19 @@ export async function logout(): Promise<void> {
|
||||
/**
|
||||
* Store session in secure storage
|
||||
*/
|
||||
export async function storeSession(token: string, user: object): Promise<void> {
|
||||
await callCoreRpc({ method: 'openhuman.auth_store_session', params: { token, user } });
|
||||
export async function storeSession(
|
||||
token: string,
|
||||
user: object,
|
||||
options?: { allowPendingBackendValidation?: boolean }
|
||||
): Promise<void> {
|
||||
await callCoreRpc({
|
||||
method: 'openhuman.auth_store_session',
|
||||
params: {
|
||||
token,
|
||||
user,
|
||||
...(options?.allowPendingBackendValidation ? { allowPendingBackendValidation: true } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function openhumanEncryptSecret(plaintext: string): Promise<CommandResponse<string>> {
|
||||
|
||||
+13
-8
@@ -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<serde_json::Value> {
|
||||
// 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<DateTime<Utc>> {
|
||||
// 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")
|
||||
|
||||
+10
-2
@@ -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}");
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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: FnOnce()>(f: F) {
|
||||
let _lock = super::super::test_support::test_env_lock();
|
||||
let prior_mode = std::env::var(AGENTBOX_MODE_ENV_VAR).ok();
|
||||
|
||||
+496
-35
@@ -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<Mutex<()>> = Lazy::new(|| Mutex::new(()));
|
||||
static CURRENT_USER_CACHE: Lazy<Mutex<Option<CachedCurrentUser>>> = Lazy::new(|| Mutex::new(None));
|
||||
static RUNTIME_SNAPSHOT_CACHE: Lazy<Mutex<Option<CachedRuntimeSnapshot>>> =
|
||||
@@ -55,6 +60,41 @@ struct CachedCurrentUser {
|
||||
user: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
enum SnapshotCurrentUser {
|
||||
User(Option<Value>),
|
||||
DeferredSessionRejected,
|
||||
}
|
||||
|
||||
impl SnapshotCurrentUser {
|
||||
fn user(user: Option<Value>) -> Self {
|
||||
Self::User(user)
|
||||
}
|
||||
}
|
||||
|
||||
type SnapshotCurrentUserResult = (SnapshotCurrentUser, Option<Box<Config>>);
|
||||
|
||||
fn snapshot_current_user_result(user: Option<Value>) -> 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<Url, String> {
|
||||
Ok(parsed)
|
||||
}
|
||||
|
||||
async fn fetch_current_user(config: &Config, token: &str) -> Result<Option<Value>, String> {
|
||||
let client = build_client()?;
|
||||
let base = resolve_base(config)?;
|
||||
async fn fetch_current_user(
|
||||
config: &Config,
|
||||
token: &str,
|
||||
) -> Result<Option<Value>, 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<Value>) -> Option<Value> {
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_current_user_cached(config: &Config, token: &str) -> Result<Option<Value>, 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<String, String>,
|
||||
) -> Option<String> {
|
||||
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<PathBuf> {
|
||||
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<PathBuf> {
|
||||
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<Config, String> {
|
||||
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<String, String>,
|
||||
user: Value,
|
||||
) -> Result<Box<Config>, 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<String, String> = 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<Option<Value>, 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<RpcOutcome<AppStateSnapshot>, 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<RpcOutcome<AppStateSnapshot>, 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<RpcOutcome<AppStateSnapshot>, 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<RpcOutcome<AppStateSnapshot>, 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<RpcOutcome<AppStateSnapshot>, 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,
|
||||
|
||||
@@ -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<String>,
|
||||
user: Option<serde_json::Value>,
|
||||
) -> Result<RpcOutcome<super::responses::AuthProfileSummary>, 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<String>,
|
||||
user: Option<serde_json::Value>,
|
||||
) -> Result<RpcOutcome<super::responses::AuthProfileSummary>, String> {
|
||||
store_session_inner(config, token, user_id, user, true).await
|
||||
}
|
||||
|
||||
async fn store_session_inner(
|
||||
config: &Config,
|
||||
token: &str,
|
||||
user_id: Option<String>,
|
||||
user: Option<serde_json::Value>,
|
||||
allow_pending_backend_validation: bool,
|
||||
) -> Result<RpcOutcome<super::responses::AuthProfileSummary>, 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<Value, String> {
|
||||
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<u16> {
|
||||
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<chrono::Utc>,
|
||||
) -> Option<chrono::DateTime<chrono::Utc>> {
|
||||
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<serde_json::Value>) -> Option<serde_json::Value> {
|
||||
match user {
|
||||
Some(serde_json::Value::Object(map)) if map.is_empty() => None,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -14,6 +14,8 @@ struct AuthStoreSessionParams {
|
||||
user_id: Option<String>,
|
||||
#[serde(default)]
|
||||
user: Option<serde_json::Value>,
|
||||
#[serde(default, alias = "allowPendingBackendValidation")]
|
||||
allow_pending_backend_validation: Option<bool>,
|
||||
}
|
||||
|
||||
#[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<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let config = config_rpc::load_config_with_timeout().await?;
|
||||
let payload = deserialize_params::<AuthStoreSessionParams>(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?
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<std::ffi::OsString>,
|
||||
}
|
||||
|
||||
impl WorkspaceEnvGuard {
|
||||
pub(crate) fn set(path: impl AsRef<Path>) -> 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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user