diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/users/login_challenges.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/users/login_challenges.rs new file mode 100644 index 0000000000..5fe75abe9f --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/users/login_challenges.rs @@ -0,0 +1,73 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +#[derive(Deserialize, Serialize, ToSchema)] +pub struct CreateLoginChallengeRequest {} + +#[derive(Deserialize, Serialize, ToSchema)] +pub struct ReviewLoginChallengeRequest { + pub approval_token: String, +} + +#[derive(Deserialize, Serialize, ToSchema)] +pub struct DecideLoginChallengeRequest { + pub approval_token: String, + pub approve: bool, +} + +#[derive(Deserialize, Serialize, ToSchema)] +pub struct PollLoginChallengeRequest { + pub device_token: String, +} + +#[derive(Deserialize, Serialize, ToSchema)] +pub struct CreateLoginChallengeResponse { + pub success: bool, + pub device_token: String, + pub verification_url: String, + pub confirmation_code: String, + pub expires_at: DateTime, + pub poll_interval_seconds: u32, +} + +#[derive(Deserialize, Serialize, ToSchema)] +pub struct ReviewLoginChallengeResponse { + pub success: bool, + pub status: LoginChallengeState, + pub maybe_failure_type: Option, + pub confirmation_code: String, + pub requesting_ip: String, + pub expires_at: DateTime, + pub username: String, +} + +#[derive(Deserialize, Serialize, ToSchema)] +pub struct LoginChallengeResponse { + pub success: bool, + pub status: LoginChallengeState, + pub maybe_failure_type: Option, + /// Present only to the device after the redemption transaction commits. + #[serde(skip_serializing_if = "Option::is_none")] + pub maybe_signed_session: Option, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum LoginChallengeState { + Pending, + Approved, + Redeemed, + Failed, + #[serde(other)] + Unknown, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum LoginChallengeFailure { + UserDeclined, + Expired, + #[serde(other)] + Unknown, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/users/mod.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/users/mod.rs index a2a81c8136..22a849dbb5 100644 --- a/crates/api_clients/artcraft/artcraft_api_defs/src/users/mod.rs +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/users/mod.rs @@ -2,4 +2,5 @@ pub mod change_password; pub mod edit_email; pub mod edit_username; pub mod login; -pub mod session_info; \ No newline at end of file +pub mod session_info; +pub mod login_challenges; diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/users/session_info.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/users/session_info.rs index a4baac7c97..09b6d26a40 100644 --- a/crates/api_clients/artcraft/artcraft_api_defs/src/users/session_info.rs +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/users/session_info.rs @@ -1,11 +1,11 @@ use crate::common::responses::user_details_light::UserDetailsLight; use enums::by_table::users::user_feature_flag::UserFeatureFlag; -use serde_derive::Serialize; +use serde_derive::{Deserialize, Serialize}; use std::collections::BTreeSet; use tokens::tokens::users::UserToken; use utoipa::ToSchema; -#[derive(Serialize, Copy, Clone, ToSchema)] +#[derive(Serialize, Deserialize, Copy, Clone, ToSchema)] #[serde(rename_all = "snake_case")] pub enum FakeYouPlan { Free, @@ -14,7 +14,7 @@ pub enum FakeYouPlan { Pro, } -#[derive(Serialize, Copy, Clone, ToSchema)] +#[derive(Serialize, Deserialize, Copy, Clone, ToSchema)] #[serde(rename_all = "snake_case")] pub enum StorytellerStreamPlan { Free, @@ -23,7 +23,7 @@ pub enum StorytellerStreamPlan { Pro, } -#[derive(Serialize, ToSchema)] +#[derive(Serialize, Deserialize, ToSchema)] pub struct SessionUserInfo { pub core_info: UserDetailsLight, @@ -76,7 +76,7 @@ pub struct SessionUserInfo { pub can_delete_users: bool, } -#[derive(Serialize, ToSchema)] +#[derive(Serialize, Deserialize, ToSchema)] pub struct SessionOnboardingState { /// If true, the user hasn't set their email. pub email_not_set: bool, @@ -91,7 +91,7 @@ pub struct SessionOnboardingState { pub username_not_customized: bool, } -#[derive(Serialize, ToSchema)] +#[derive(Serialize, Deserialize, ToSchema)] pub struct SessionInfoSuccessResponse { pub success: bool, pub logged_in: bool, diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/mod.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/mod.rs index 7c92178019..aae0a14e44 100644 --- a/crates/api_clients/artcraft/artcraft_client/src/endpoints/mod.rs +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/mod.rs @@ -7,3 +7,4 @@ pub mod media_files; pub mod prompts; pub mod stripe_artcraft; pub mod subscriptions; +pub mod users; diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/users/login_challenges.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/users/login_challenges.rs new file mode 100644 index 0000000000..2116c826cf --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/users/login_challenges.rs @@ -0,0 +1,200 @@ +use std::time::Duration; + +use artcraft_api_defs::users::login_challenges::{ + CreateLoginChallengeRequest, CreateLoginChallengeResponse, LoginChallengeResponse, + PollLoginChallengeRequest, +}; +use artcraft_api_defs::users::session_info::SessionInfoSuccessResponse; +use log::{info, warn}; +use reqwest::header::SET_COOKIE; +use reqwest::{Client, Method, RequestBuilder}; +use serde::de::DeserializeOwned; +use url::Url; + +use crate::utils::api_host::ApiHost; +use crate::utils::constants::USER_AGENT; + +pub const CREATE_PATH: &str = "/v1/login_challenges/create"; +pub const POLL_PATH: &str = "/v1/login_challenges/poll"; +pub const SESSION_PATH: &str = "/v1/session"; + +/// Native transport for the session bridge. No request/response bodies, cookies, +/// or challenge credentials are logged, even at debug level. Never follows redirects. +#[derive(Clone)] +pub struct LoginChallengeClient { + client: Client, + api_host: ApiHost, +} + +// Deliberately no Debug/Serialize: the wire result contains a bearer credential. +pub struct PollLoginChallengeResult { + pub response: LoginChallengeResponse, + pub session_set_cookie: Option, +} + +#[derive(Debug)] +pub struct LoginChallengeClientError { + pub status: Option, + pub message: &'static str, +} + +impl LoginChallengeClient { + pub fn new(api_host: &ApiHost) -> Result { + let mut builder = Client::builder(); + if matches!(api_host, ApiHost::Localhost { .. }) { + builder = builder.no_proxy(); + } + let client = builder + .timeout(Duration::from_secs(15)) + .redirect(reqwest::redirect::Policy::none()) + .user_agent(USER_AGENT) + .build() + .map_err(|_| LoginChallengeClientError::invalid("Unable to initialize login client"))?; + Ok(Self { + client, + api_host: api_host.clone(), + }) + } + + pub fn api_origin(&self) -> String { + self.api_host.to_api_hostname_and_scheme() + } + + pub fn api_url(&self) -> Url { + Url::parse(&format!("{}/", self.api_origin())).expect("ApiHost must produce a valid origin") + } + + pub fn allows_verification_url(&self, value: &str) -> bool { + let Ok(url) = Url::parse(value) else { + return false; + }; + let origin_allowed = match self.api_host { + ApiHost::Localhost { .. } => ["http://localhost:4201", "http://127.0.0.1:4201"] + .contains(&url.origin().ascii_serialization().as_str()), + ApiHost::Storyteller => url.origin().ascii_serialization() == "https://app.getartcraft.com", + ApiHost::FakeYou => false, + }; + origin_allowed + && url.username().is_empty() + && url.password().is_none() + && url.path() == "/login/desktop" + && url.query().is_none() + && url + .fragment() + .and_then(|f| f.strip_prefix("approval_token=")) + .map(|s| { + s.len() == 43 + && s + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-') + }) + .unwrap_or(false) + } + + pub async fn create(&self) -> Result { + let request = self + .request(Method::POST, CREATE_PATH) + .json(&CreateLoginChallengeRequest {}); + let (result, _) = self.send(request, CREATE_PATH).await?; + Ok(result) + } + + pub async fn poll( + &self, + device_token: &str, + ) -> Result { + let request = self + .request(Method::POST, POLL_PATH) + .json(&PollLoginChallengeRequest { + device_token: device_token.to_owned(), + }); + let (response, session_set_cookie) = self.send(request, POLL_PATH).await?; + Ok(PollLoginChallengeResult { + response, + session_set_cookie, + }) + } + + pub async fn session( + &self, + signed_session: &str, + ) -> Result { + let request = self + .request(Method::GET, SESSION_PATH) + .header("Cookie", format!("session={signed_session}")); + let (result, _) = self.send(request, SESSION_PATH).await?; + Ok(result) + } + + fn request(&self, method: Method, path: &str) -> RequestBuilder { + self + .client + .request(method, format!("{}{path}", self.api_origin())) + .header("Accept", "application/json") + } + + async fn send( + &self, + request: RequestBuilder, + path: &str, + ) -> Result<(T, Option), LoginChallengeClientError> { + let origin = self.api_origin(); + info!("Website login request: origin={} path={}", origin, path); + let response = request.send().await.map_err(|_| { + warn!( + "Website login network failure: origin={} path={}", + origin, path + ); + LoginChallengeClientError::invalid("Unable to reach the login server") + })?; + let status = response.status(); + if !status.is_success() { + warn!( + "Website login HTTP failure: origin={} path={} status={}", + origin, + path, + status.as_u16() + ); + return Err(LoginChallengeClientError { + status: Some(status.as_u16()), + message: "Login server rejected the request", + }); + } + let session_cookie = response + .headers() + .get_all(SET_COOKIE) + .iter() + .filter_map(|v| v.to_str().ok()) + .find(|v| { + cookie::Cookie::parse(*v) + .map(|c| c.name() == "session") + .unwrap_or(false) + }) + .map(str::to_owned); + let body = response.json().await.map_err(|_| { + warn!( + "Website login invalid response: origin={} path={}", + origin, path + ); + LoginChallengeClientError::invalid("Invalid response from login server") + })?; + Ok((body, session_cookie)) + } +} + +impl LoginChallengeClientError { + pub fn invalid(message: &'static str) -> Self { + Self { + status: None, + message, + } + } +} + +impl std::fmt::Display for LoginChallengeClientError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{} (HTTP {:?})", self.message, self.status) + } +} + +impl std::error::Error for LoginChallengeClientError {} diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/users/mod.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/users/mod.rs new file mode 100644 index 0000000000..e3e166c558 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/users/mod.rs @@ -0,0 +1 @@ +pub mod login_challenges; diff --git a/crates/desktop/artcraft/src/lib.rs b/crates/desktop/artcraft/src/lib.rs index 612e51bd91..3590a7e985 100644 --- a/crates/desktop/artcraft/src/lib.rs +++ b/crates/desktop/artcraft/src/lib.rs @@ -63,7 +63,7 @@ use crate::services::sora::commands::sora_logout_command::sora_logout_command; use crate::services::sora::state::sora_credential_manager::SoraCredentialManager; use crate::services::sora::state::sora_task_queue::SoraTaskQueue; use crate::services::sora::threads::sora_task_polling::sora_task_polling_thread::sora_task_polling_thread; -use crate::services::storyteller::commands::storyteller_sync_login_session_command::storyteller_sync_login_session_command; +use crate::services::storyteller::commands::storyteller_login_bridge_commands::{DesktopLoginBridgeState, storyteller_create_login_challenge_command, storyteller_poll_login_challenge_command, storyteller_cancel_login_challenge_command}; use crate::services::storyteller::commands::storyteller_get_credits_command::storyteller_get_credits_command; use crate::services::storyteller::commands::storyteller_get_subscription_command::storyteller_get_subscription_command; use crate::services::storyteller::commands::storyteller_purge_credentials_command::storyteller_purge_credentials_command; @@ -202,6 +202,7 @@ pub fn run() { }) .manage(app_data_root) .manage(app_env_configs) + .manage(DesktopLoginBridgeState::default()) .manage(app_preferences) .manage(artcraft_platform_info) .manage(artcraft_usage_tracker) @@ -260,7 +261,9 @@ pub fn run() { set_provider_order_command, sora_get_credential_info_command, sora_logout_command, - storyteller_sync_login_session_command, + storyteller_create_login_challenge_command, + storyteller_poll_login_challenge_command, + storyteller_cancel_login_challenge_command, storyteller_get_credits_command, storyteller_get_subscription_command, storyteller_open_credits_purchase_command, diff --git a/crates/desktop/artcraft/src/services/storyteller/commands/mod.rs b/crates/desktop/artcraft/src/services/storyteller/commands/mod.rs index ee3115e9dc..3f2e58e412 100644 --- a/crates/desktop/artcraft/src/services/storyteller/commands/mod.rs +++ b/crates/desktop/artcraft/src/services/storyteller/commands/mod.rs @@ -3,4 +3,4 @@ pub mod storyteller_get_credits_command; pub mod storyteller_get_subscription_command; pub mod stripe_checkout; pub mod stripe_customer_portal; -pub mod storyteller_sync_login_session_command; +pub mod storyteller_login_bridge_commands; diff --git a/crates/desktop/artcraft/src/services/storyteller/commands/storyteller_login_bridge_commands.rs b/crates/desktop/artcraft/src/services/storyteller/commands/storyteller_login_bridge_commands.rs new file mode 100644 index 0000000000..bbd4538588 --- /dev/null +++ b/crates/desktop/artcraft/src/services/storyteller/commands/storyteller_login_bridge_commands.rs @@ -0,0 +1,274 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use artcraft_api_defs::users::login_challenges::{LoginChallengeFailure, LoginChallengeState}; +use artcraft_api_defs::users::session_info::SessionUserInfo; +use artcraft_client::endpoints::users::login_challenges::{ + LoginChallengeClient, LoginChallengeClientError, +}; +use artcraft_client::utils::api_host::ApiHost; +use chrono::{DateTime, Utc}; +use log::{info, warn}; +use serde::Serialize; +use tauri::{AppHandle, Manager, State}; +use tauri_plugin_http::reqwest_cookie_store::{CookieStoreMutex, RawCookie}; +use tauri_plugin_http::Http; +use tokio::sync::Mutex; +use uuid_utils::uuid::generate_random_uuid; + +use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs; +use crate::core::threads::main_window_thread::persist_storyteller_cookies_task::get_credentials_from_cookie_store; +use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager; + +const MAX_PENDING_CHALLENGES: usize = 8; + +/// All bearer credentials stay in Rust. The UI holds only a local lookup handle. +#[derive(Default)] +pub struct DesktopLoginBridgeState { + pending: Mutex>, +} + +#[derive(Clone)] +struct PendingChallenge { + device_token: String, + expires_at: DateTime, + client: LoginChallengeClient, +} + +#[derive(Serialize)] +pub struct DesktopLoginChallenge { + pub challenge_id: String, + pub verification_url: String, + pub confirmation_code: String, + pub expires_at: DateTime, + pub poll_interval_seconds: u32, +} + +#[derive(Serialize)] +pub struct DesktopLoginOutcome { + pub status: LoginChallengeState, + pub maybe_failure_type: Option, + pub maybe_user: Option, +} + +#[derive(Debug, Serialize)] +pub struct DesktopLoginError { + pub status: Option, + pub message: String, + pub retryable: bool, +} + +#[tauri::command] +pub async fn storyteller_create_login_challenge_command( + config: State<'_, AppEnvConfigs>, + state: State<'_, DesktopLoginBridgeState>, +) -> Result { + begin_challenge(&config.storyteller_host, &state).await +} + +#[tauri::command] +pub async fn storyteller_poll_login_challenge_command( + app: AppHandle, + state: State<'_, DesktopLoginBridgeState>, + manager: State<'_, StorytellerCredentialManager>, + challenge_id: String, +) -> Result { + let jar = app + .try_state::() + .ok_or_else(|| DesktopLoginError::local("HTTP cookie store unavailable"))? + .cookies_jar + .clone(); + poll_challenge(&state, &manager, &jar, &challenge_id).await +} + +#[tauri::command] +pub async fn storyteller_cancel_login_challenge_command( + state: State<'_, DesktopLoginBridgeState>, + challenge_id: String, +) -> Result<(), DesktopLoginError> { + state.pending.lock().await.remove(&challenge_id); + Ok(()) +} + +async fn begin_challenge( + host: &ApiHost, + state: &DesktopLoginBridgeState, +) -> Result { + let client = LoginChallengeClient::new(host) + .map_err(|e| DesktopLoginError::from_client(&host.to_api_hostname_and_scheme(), e))?; + let created = client + .create() + .await + .map_err(|e| DesktopLoginError::from_client(&client.api_origin(), e))?; + if !created.success + || !client.allows_verification_url(&created.verification_url) + || created.device_token.len() != 43 + || created.expires_at <= Utc::now() + { + return Err(DesktopLoginError::local( + "Invalid login challenge or website/API environment mismatch", + )); + } + let mut pending = state.pending.lock().await; + pending.retain(|_, value| value.expires_at > Utc::now()); + if pending.len() >= MAX_PENDING_CHALLENGES { + return Err(DesktopLoginError::local("Too many pending login attempts")); + } + let challenge_id = generate_random_uuid(); + pending.insert( + challenge_id.clone(), + PendingChallenge { + device_token: created.device_token, + expires_at: created.expires_at, + client, + }, + ); + Ok(DesktopLoginChallenge { + challenge_id, + verification_url: created.verification_url, + confirmation_code: created.confirmation_code, + expires_at: created.expires_at, + poll_interval_seconds: created.poll_interval_seconds.max(5), + }) +} + +async fn poll_challenge( + state: &DesktopLoginBridgeState, + manager: &StorytellerCredentialManager, + jar: &Arc, + challenge_id: &str, +) -> Result { + let challenge = { + let mut pending = state.pending.lock().await; + let challenge = pending.get(challenge_id).cloned().ok_or_else(|| { + DesktopLoginError::local("Login attempt was cancelled or is no longer available") + })?; + if challenge.expires_at <= Utc::now() { + pending.remove(challenge_id); + return Ok(expired()); + } + challenge + }; + let origin = challenge.client.api_origin(); + let polled = challenge + .client + .poll(&challenge.device_token) + .await + .map_err(|e| DesktopLoginError::from_client(&origin, e))?; + if !polled.response.success || polled.response.status == LoginChallengeState::Unknown { + return Err(DesktopLoginError::local("Unrecognized login response")); + } + if polled.response.status != LoginChallengeState::Redeemed { + if polled.response.status == LoginChallengeState::Failed { + state.pending.lock().await.remove(challenge_id); + } + return Ok(DesktopLoginOutcome { + status: polled.response.status, + maybe_failure_type: polled.response.maybe_failure_type, + maybe_user: None, + }); + } + let signed = polled + .response + .maybe_signed_session + .ok_or_else(|| DesktopLoginError::local("Login server did not issue a session"))?; + let set_cookie = polled + .session_set_cookie + .ok_or_else(|| DesktopLoginError::local("Login server did not set a session cookie"))?; + let cookie = RawCookie::parse(set_cookie) + .map_err(|_| DesktopLoginError::local("Invalid session cookie"))? + .into_owned(); + if cookie.name() != "session" || cookie.value() != signed { + return Err(DesktopLoginError::local( + "Session cookie did not match the redeemed session", + )); + } + // Validate on the SAME native API host before publishing any new credentials. + let session = challenge + .client + .session(&signed) + .await + .map_err(|e| DesktopLoginError::from_client(&origin, e))?; + let user = session + .user + .filter(|_| session.success && session.logged_in) + .ok_or_else(|| DesktopLoginError::local("The redeemed session could not authenticate"))?; + { + let pending = state.pending.lock().await; + // A cancellation during either HTTP request must prevent local installation. + if !pending.contains_key(challenge_id) { + return Err(DesktopLoginError::local("Login attempt was cancelled")); + } + if challenge.expires_at <= Utc::now() { + return Ok(expired()); + } + let mut store = jar + .store + .lock() + .map_err(|_| DesktopLoginError::local("HTTP cookie store unavailable"))?; + let mut candidate = store.clone(); + candidate.store_response_cookies([cookie].into_iter(), &challenge.client.api_url()); + let credentials = get_credentials_from_cookie_store(&candidate, &challenge.client.api_url()) + .map_err(|_| DesktopLoginError::local("Unable to read session cookie"))?; + if credentials.session.as_ref().map(|c| c.as_str()) != Some(signed.as_str()) { + return Err(DesktopLoginError::local( + "Session cookie does not apply to the configured API host", + )); + } + manager + .set_credentials(&credentials) + .map_err(|_| DesktopLoginError::local("Unable to store native login credentials"))?; + *store = candidate; + } + // Keep the challenge for idempotent IPC retries; it expires on its original deadline. + let saved = jar + .request_save() + .map_err(|_| DesktopLoginError::local("Unable to persist login cookie"))?; + tokio::task::spawn_blocking(move || saved.recv()) + .await + .map_err(|_| DesktopLoginError::local("Unable to persist login cookie"))? + .map_err(|_| DesktopLoginError::local("Unable to persist login cookie"))?; + info!("Website login complete: origin={}", origin); + Ok(DesktopLoginOutcome { + status: LoginChallengeState::Redeemed, + maybe_failure_type: None, + maybe_user: Some(user), + }) +} + +fn expired() -> DesktopLoginOutcome { + DesktopLoginOutcome { + status: LoginChallengeState::Failed, + maybe_failure_type: Some(LoginChallengeFailure::Expired), + maybe_user: None, + } +} + +impl DesktopLoginError { + fn local(message: &str) -> Self { + Self { + status: None, + message: message.to_owned(), + retryable: false, + } + } + + fn from_client(origin: &str, error: LoginChallengeClientError) -> Self { + warn!( + "Website login failed: origin={} status={:?} reason={}", + origin, error.status, error.message + ); + Self { + retryable: error + .status + .map(|s| s == 429 || s >= 500) + .unwrap_or(error.message == "Unable to reach the login server"), + status: error.status, + message: format!("{}: {} (HTTP {:?})", origin, error.message, error.status), + } + } +} + +#[cfg(test)] +#[path = "storyteller_login_bridge_tests.rs"] +mod login_bridge_tests; diff --git a/crates/desktop/artcraft/src/services/storyteller/commands/storyteller_login_bridge_tests.rs b/crates/desktop/artcraft/src/services/storyteller/commands/storyteller_login_bridge_tests.rs new file mode 100644 index 0000000000..205062d3be --- /dev/null +++ b/crates/desktop/artcraft/src/services/storyteller/commands/storyteller_login_bridge_tests.rs @@ -0,0 +1,387 @@ +//! Commands' native implementation + real ArtCraft bindings + loopback HTTP. +//! All listeners bind to an ephemeral local port; no production host is contacted. +use super::*; +use crate::core::state::data_dir::app_data_root::AppDataRoot; +use artcraft_client::credentials::storyteller_avt_cookie::StorytellerAvtCookie; +use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet; +use serde_json::{json, Value}; +use std::io::{Read, Write}; +use std::net::TcpListener; +use std::thread; +use std::time::Duration; +use tauri_plugin_http::reqwest_cookie_store::CookieStore; + +const SIGNED: &str = "signed_native_session_fixture"; +const DEVICE: &str = "DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD"; + +#[tokio::test] +async fn approved_session_is_verified_installed_persisted_and_retried_entirely_in_rust() { + let server = server(vec![ + create(), + pending(), + redeemed(), + session(), + redeemed(), + session(), + ]); + let h = harness(); + // Native installation must preserve visitor cookies and avoid importing other origins. + h.jar.store.lock().unwrap().store_response_cookies( + [RawCookie::parse("visitor=existing; Path=/; Max-Age=3600") + .unwrap() + .into_owned()] + .into_iter(), + &server_url(&server), + ); + h.manager + .set_credentials(&StorytellerCredentialSet::initialize_with_just_avt( + StorytellerAvtCookie::new("existing".into()), + )) + .unwrap(); + let created = begin_challenge(&server.host, &h.state).await.unwrap(); + let public = serde_json::to_string(&created).unwrap(); + assert!(!public.contains(DEVICE)); + assert!(!public.contains("device_token")); + assert!(h + .manager + .get_credentials_required() + .unwrap() + .session + .is_none()); + let wait = poll_challenge(&h.state, &h.manager, &h.jar, &created.challenge_id) + .await + .unwrap(); + assert_eq!(wait.status, LoginChallengeState::Pending); + assert!(h + .manager + .get_credentials_required() + .unwrap() + .session + .is_none()); + for _ in 0..2 { + let result = poll_challenge(&h.state, &h.manager, &h.jar, &created.challenge_id) + .await + .unwrap(); + assert_eq!(result.status, LoginChallengeState::Redeemed); + assert_eq!(result.maybe_user.as_ref().unwrap().username, "google_user"); + assert!(!serde_json::to_string(&result).unwrap().contains(SIGNED)); + let credentials = h.manager.get_credentials_required().unwrap(); + assert_eq!(credentials.session.unwrap().as_str(), SIGNED); + assert_eq!(credentials.avt.unwrap().as_str(), "existing"); + assert!(std::fs::read_to_string(&h.jar.path) + .unwrap() + .contains(SIGNED)); + } + server.thread.join().unwrap(); +} + +#[tokio::test] +async fn rejection_and_timeout_never_install_a_session() { + for expired in [false, true] { + let mut steps = vec![create()]; + if !expired { + steps.push(step( + "/v1/login_challenges/poll", + json!({"success":true,"status":"failed","maybe_failure_type":"user_declined"}), + )); + } + let server = server(steps); + let h = harness(); + let created = begin_challenge(&server.host, &h.state).await.unwrap(); + if expired { + h.state + .pending + .lock() + .await + .get_mut(&created.challenge_id) + .unwrap() + .expires_at = Utc::now(); + } + let result = poll_challenge(&h.state, &h.manager, &h.jar, &created.challenge_id) + .await + .unwrap(); + assert_eq!(result.status, LoginChallengeState::Failed); + assert_eq!( + result.maybe_failure_type, + Some(if expired { + LoginChallengeFailure::Expired + } else { + LoginChallengeFailure::UserDeclined + }) + ); + assert!(h.manager.get_credentials().unwrap().is_none()); + assert!(h + .jar + .store + .lock() + .unwrap() + .iter_unexpired() + .next() + .is_none()); + server.thread.join().unwrap(); + } +} + +#[tokio::test] +async fn invalid_downstream_session_is_not_installed_and_error_has_host_without_credentials() { + let mut invalid = session(); + invalid.status = 401; + invalid.body = json!({"echo": SIGNED}); + let server = server(vec![create(), redeemed(), invalid]); + let h = harness(); + let created = begin_challenge(&server.host, &h.state).await.unwrap(); + let error = poll_challenge(&h.state, &h.manager, &h.jar, &created.challenge_id) + .await + .err() + .unwrap(); + assert_eq!(error.status, Some(401)); + assert!(!error.retryable); + assert!(error + .message + .contains(&server.host.to_api_hostname_and_scheme())); + assert!(!format!("{error:?}").contains(SIGNED)); + assert!(h.manager.get_credentials().unwrap().is_none()); + assert!(h + .jar + .store + .lock() + .unwrap() + .iter_unexpired() + .next() + .is_none()); + server.thread.join().unwrap(); +} + +#[tokio::test] +async fn cancelling_during_http_prevents_late_cookie_installation() { + let mut delayed = redeemed(); + delayed.delay = Duration::from_millis(150); + let server = server(vec![create(), delayed, session()]); + let h = harness(); + let created = begin_challenge(&server.host, &h.state).await.unwrap(); + let (result, _) = tokio::join!( + poll_challenge(&h.state, &h.manager, &h.jar, &created.challenge_id), + async { + tokio::time::sleep(Duration::from_millis(30)).await; + h.state.pending.lock().await.remove(&created.challenge_id); + } + ); + assert!(result.is_err()); + assert!(h.manager.get_credentials().unwrap().is_none()); + assert!(h + .jar + .store + .lock() + .unwrap() + .iter_unexpired() + .next() + .is_none()); + server.thread.join().unwrap(); +} + +#[tokio::test] +async fn unknown_states_and_redirects_fail_closed() { + for redirect in [false, true] { + let mut result = step( + "/v1/login_challenges/poll", + json!({"success":true,"status":"future_state","maybe_signed_session":SIGNED}), + ); + if redirect { + result.status = 302; + result.headers = "Location: https://must-never-be-contacted.invalid/\r\n".into(); + } + let server = server(vec![create(), result]); + let h = harness(); + let created = begin_challenge(&server.host, &h.state).await.unwrap(); + let error = poll_challenge(&h.state, &h.manager, &h.jar, &created.challenge_id) + .await + .err() + .unwrap(); + assert!(!error.retryable); + if redirect { + assert_eq!(error.status, Some(302)); + } + assert!(h.manager.get_credentials().unwrap().is_none()); + server.thread.join().unwrap(); + } +} + +#[test] +fn website_origin_must_match_native_api_environment() { + let local = LoginChallengeClient::new(&ApiHost::Localhost { port: 12345 }).unwrap(); + let prod = LoginChallengeClient::new(&ApiHost::Storyteller).unwrap(); + let fragment = format!("#approval_token={}", "A".repeat(43)); + assert!(local.allows_verification_url(&format!("http://localhost:4201/login/desktop{fragment}"))); + assert!(!local.allows_verification_url(&format!( + "https://app.getartcraft.com/login/desktop{fragment}" + ))); + assert!(prod.allows_verification_url(&format!( + "https://app.getartcraft.com/login/desktop{fragment}" + ))); + assert!(!prod.allows_verification_url(&format!("http://localhost:4201/login/desktop{fragment}"))); +} + +struct Harness { + _directory: tempfile::TempDir, + state: DesktopLoginBridgeState, + manager: StorytellerCredentialManager, + jar: Arc, +} + +struct Server { + host: ApiHost, + thread: thread::JoinHandle<()>, +} + +struct Step { + path: &'static str, + status: u16, + body: Value, + headers: String, + delay: Duration, +} + +fn harness() -> Harness { + let directory = tempfile::tempdir().unwrap(); + let root = AppDataRoot::create_existing(directory.path()).unwrap(); + let manager = StorytellerCredentialManager::initialize_empty(&root); + let jar = Arc::new(CookieStoreMutex::new( + directory.path().join("cookies.json"), + CookieStore::default(), + )); + Harness { + _directory: directory, + state: DesktopLoginBridgeState::default(), + manager, + jar, + } +} + +fn server_url(server: &Server) -> reqwest::Url { + reqwest::Url::parse(&format!("{}/", server.host.to_api_hostname_and_scheme())).unwrap() +} + +fn server(steps: Vec) -> Server { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let thread = thread::spawn(move || { + for step in steps { + let (mut stream, _) = listener.accept().unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(10))) + .unwrap(); + let mut data = Vec::new(); + let mut buffer = [0; 4096]; + loop { + let count = stream.read(&mut buffer).unwrap(); + assert!(count > 0); + data.extend_from_slice(&buffer[..count]); + if let Some(end) = data.windows(4).position(|v| v == b"\r\n\r\n") { + let headers = String::from_utf8_lossy(&data[..end]).to_lowercase(); + let length: usize = headers + .lines() + .find_map(|s| s.strip_prefix("content-length:")) + .map(|s| s.trim().parse().unwrap()) + .unwrap_or(0); + if data.len() >= end + 4 + length { + break; + } + } + } + let request = String::from_utf8(data).unwrap(); + assert_eq!(request.split_whitespace().nth(1), Some(step.path)); + assert!(request + .to_lowercase() + .contains(&format!("host: localhost:{port}"))); + if step.path == "/v1/session" { + assert!(request.starts_with("GET ")); + assert!(request.contains(&format!("session={SIGNED}"))); + } else { + assert!(request.starts_with("POST ")); + assert!(!request.to_lowercase().contains("cookie:")); + if step.path.ends_with("/poll") { + let body: Value = + serde_json::from_str(request.split_once("\r\n\r\n").unwrap().1).unwrap(); + assert_eq!(body, json!({"device_token":DEVICE})); + } + } + thread::sleep(step.delay); + let body = step.body.to_string(); + write!(stream, "HTTP/1.1 {} Test\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n{}\r\n{}", step.status, body.len(), step.headers, body).unwrap(); + } + }); + Server { + host: ApiHost::Localhost { port: port.into() }, + thread, + } +} + +fn step(path: &'static str, body: Value) -> Step { + Step { + path, + body, + status: 200, + headers: String::new(), + delay: Duration::ZERO, + } +} + +fn create() -> Step { + step( + "/v1/login_challenges/create", + json!({"success":true,"device_token":DEVICE,"verification_url":format!("http://localhost:4201/login/desktop#approval_token={}","A".repeat(43)),"confirmation_code":"WDJBMJHT","expires_at":Utc::now()+chrono::Duration::minutes(20),"poll_interval_seconds":5}), + ) +} + +fn pending() -> Step { + step( + "/v1/login_challenges/poll", + json!({"success":true,"status":"pending","maybe_failure_type":null}), + ) +} + +fn redeemed() -> Step { + let mut response = step( + "/v1/login_challenges/poll", + json!({"success":true,"status":"redeemed","maybe_failure_type":null,"maybe_signed_session":SIGNED}), + ); + response.headers = format!("Set-Cookie: session={SIGNED}; HttpOnly; Path=/; Max-Age=3600\r\n"); + response +} + +fn session() -> Step { + let mut user = json!({ + "user_token":"u_native_test", "username":"google_user", "display_name":"Google User", "email_gravatar_hash":"fixture", + "core_info":{"user_token":"u_native_test","username":"google_user","display_name":"Google User","gravatar_hash":"fixture","default_avatar":{"image_index":1,"color_index":1}}, + "onboarding":{"email_not_set":false,"email_not_confirmed":false,"password_not_set":true,"username_not_customized":false}, + "maybe_feature_flags":[],"fakeyou_plan":"free","storyteller_stream_plan":"free" + }); + for field in [ + "can_access_studio", + "can_use_tts", + "can_use_w2l", + "can_delete_own_tts_results", + "can_delete_own_w2l_results", + "can_delete_own_account", + "can_upload_tts_models", + "can_upload_w2l_templates", + "can_delete_own_tts_models", + "can_delete_own_w2l_templates", + "can_approve_w2l_templates", + "can_edit_other_users_profiles", + "can_edit_other_users_tts_models", + "can_edit_other_users_w2l_templates", + "can_delete_other_users_tts_models", + "can_delete_other_users_w2l_templates", + "can_delete_other_users_tts_results", + "can_delete_other_users_w2l_results", + "can_ban_users", + "can_delete_users", + ] { + user[field] = json!(false); + } + step( + "/v1/session", + json!({"success":true,"logged_in":true,"user":user}), + ) +} diff --git a/crates/desktop/artcraft/src/services/storyteller/commands/storyteller_sync_login_session_command.rs b/crates/desktop/artcraft/src/services/storyteller/commands/storyteller_sync_login_session_command.rs deleted file mode 100644 index bcc2bc8fd3..0000000000 --- a/crates/desktop/artcraft/src/services/storyteller/commands/storyteller_sync_login_session_command.rs +++ /dev/null @@ -1,23 +0,0 @@ -use log::warn; -use tauri::{AppHandle, Manager, State}; -use tauri_plugin_http::Http; - -use crate::core::threads::main_window_thread::persist_storyteller_cookies_task::{get_credentials_from_cookie_store, sync_tauri_credentials, storyteller_cookie_url}; -use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager; - -/// Synchronize the HTTP plugin's newly received session with native API clients. -/// No cookie is accepted from the webview/IPC caller or created by this command. -#[tauri::command] -pub fn storyteller_sync_login_session_command(app: AppHandle, manager: State<'_, StorytellerCredentialManager>) -> Result<(), String> { - let http = app.try_state::().ok_or("HTTP client unavailable")?; - let jar = http.cookies_jar.store.lock().map_err(|_| "HTTP cookie store unavailable")?; - let url = storyteller_cookie_url(&app).map_err(|_| "API configuration unavailable")?; - let credentials = get_credentials_from_cookie_store(&jar, &url).map_err(|_| "Unable to read login session")?; - if credentials.session.is_none() { - return Err("The API did not set a desktop session cookie".into()); - } - sync_tauri_credentials(&jar, &manager, &url).map_err(|error| { - warn!("Unable to synchronize desktop login credentials: {}", error); - "Unable to synchronize desktop login".into() - }) -} diff --git a/crates/desktop/artcraft/src/services/storyteller/threads/storyteller_activity_thread.rs b/crates/desktop/artcraft/src/services/storyteller/threads/storyteller_activity_thread.rs index dae9c58222..c5de1af300 100644 --- a/crates/desktop/artcraft/src/services/storyteller/threads/storyteller_activity_thread.rs +++ b/crates/desktop/artcraft/src/services/storyteller/threads/storyteller_activity_thread.rs @@ -3,9 +3,10 @@ use crate::core::state::artcraft_platform_info::ArtcraftPlatformInfo; use crate::core::state::artcraft_usage_tracker::artcraft_usage_tracker::ArtcraftUsageTracker; use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager; use anyhow::anyhow; -use artcraft_api_defs::analytics::log_active_user::LogAppActiveUserRequest; +use artcraft_api_defs::analytics::log_active_user::{LogAppActiveUserRequest, LOG_ACTIVE_USER_V2_PATH}; use artcraft_client::endpoints::analytics::log_active_user_v2::log_active_user_v2; use artcraft_client::error::api_error::ApiError; +use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet; use artcraft_client::error::storyteller_error::StorytellerError; use errors::AnyhowResult; use log::{debug, error, info}; @@ -38,7 +39,7 @@ pub async fn storyteller_activity_thread( &app_session_token, ).await; if let Err(err) = res { - error!("An error occurred: {:?}", err); + error!("Activity request failed: origin={} path={} error={:?}", app_env_configs.storyteller_host.to_api_hostname_and_scheme(), LOG_ACTIVE_USER_V2_PATH, err); } // NB: Sleep if an error occurs. tokio::time::sleep(std::time::Duration::from_millis(ERROR_SLEEP_MILLIS)).await; @@ -62,7 +63,7 @@ async fn polling_loop( continue; } Some(creds) => { - if creds.is_empty() { + if !has_activity_session(&creds) { tokio::time::sleep(std::time::Duration::from_millis(5_000)).await; continue; } @@ -123,7 +124,31 @@ async fn polling_loop( }; // Wait at least a minute, no matter what the server tells us. - let wait_millis = std::cmp::min(wait_millis, 60_000); + let wait_millis = std::cmp::max(wait_millis, 60_000); tokio::time::sleep(std::time::Duration::from_millis(wait_millis)).await; } } + +// Anonymous visitor tracking is not authentication for active_user_v2. +fn has_activity_session(creds: &StorytellerCredentialSet) -> bool { + creds.session.as_ref().map(|session| !session.as_str().is_empty()).unwrap_or(false) +} + +#[cfg(test)] +mod login_bridge_tests { + use super::has_activity_session; + use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet; + use artcraft_client::credentials::storyteller_avt_cookie::StorytellerAvtCookie; + use artcraft_client::credentials::storyteller_session_cookie::StorytellerSessionCookie; + + #[test] + fn anonymous_visitor_cookie_does_not_authorize_activity_requests() { + assert!(!has_activity_session(&StorytellerCredentialSet::empty())); + let visitor_only = StorytellerCredentialSet::initialize_with_just_avt(StorytellerAvtCookie::new("visitor_fixture".into())); + assert!(!has_activity_session(&visitor_only)); + let empty_session = StorytellerCredentialSet::initialize_with_just_cookie(StorytellerSessionCookie::new(String::new())); + assert!(!has_activity_session(&empty_session)); + let signed_in = StorytellerCredentialSet::initialize_with_just_cookie(StorytellerSessionCookie::new("signed_session_fixture".into())); + assert!(has_activity_session(&signed_in)); + } +} diff --git a/frontend/libs/api/src/index.ts b/frontend/libs/api/src/index.ts index 6307cd34a6..7286c75276 100644 --- a/frontend/libs/api/src/index.ts +++ b/frontend/libs/api/src/index.ts @@ -21,7 +21,6 @@ export * from "./lib/PasswordResetApi.js"; export * from "./lib/UserApiKeysApi.js"; export * from "./lib/UserReferralCodesApi.js"; export * from "./lib/UsersApi.js"; -export * from "./lib/LoginChallengesApi.js"; export * from "./lib/VideoApi.js"; export * from "./lib/WeightsApi.js"; export * from "./lib/GetCdnOrigin.js"; diff --git a/frontend/libs/api/src/lib/LoginChallengesApi.ts b/frontend/libs/api/src/lib/LoginChallengesApi.ts deleted file mode 100644 index a347b6e99c..0000000000 --- a/frontend/libs/api/src/lib/LoginChallengesApi.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { ApiManager, storeSignedSession } from "./ApiManager.js"; - -export interface LoginChallenge { - success: boolean; - device_token: string; - verification_url: string; - confirmation_code: string; - expires_at: string; - poll_interval_seconds: number; -} - -export interface LoginChallengeResult { - success: boolean; - // Kept open to future values: callers must fail closed on an unknown state. - status: string; - maybe_failure_type: string | null; - maybe_signed_session?: string; -} - -export interface LoginChallengeReview extends LoginChallengeResult { - confirmation_code: string; - requesting_ip: string; - expires_at: string; - username: string; -} - -export class LoginChallengesApi extends ApiManager { - create(): Promise { - return this.request("create", {}); - } - - review(approvalToken: string): Promise { - return this.request("review", { approval_token: approvalToken }); - } - - decide(approvalToken: string, approve: boolean): Promise { - return this.request("decide", { approval_token: approvalToken, approve }); - } - - poll(deviceToken: string): Promise { - return this.request("poll", { device_token: deviceToken }); - } - - acceptSession(result: LoginChallengeResult): void { - if (result.status !== "redeemed" || !result.success || !result.maybe_signed_session) { - throw new Error("The desktop login has not completed."); - } - storeSignedSession(result.maybe_signed_session); - } - - private request(action: string, body: Record): Promise { - return this.fetch(`${this.getApiSchemeAndHost()}/v1/login_challenges/${action}`, { - method: "POST", body, - }); - } -} diff --git a/frontend/libs/components/login-modal/src/lib/DesktopLoginBridge.spec.tsx b/frontend/libs/components/login-modal/src/lib/DesktopLoginBridge.spec.tsx index a2501ff8ae..5999fd7027 100644 --- a/frontend/libs/components/login-modal/src/lib/DesktopLoginBridge.spec.tsx +++ b/frontend/libs/components/login-modal/src/lib/DesktopLoginBridge.spec.tsx @@ -2,18 +2,13 @@ import { act, cleanup, fireEvent, render, screen } from "@testing-library/react" import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { DesktopLoginBridge } from "./DesktopLoginBridge"; -const { transport, native } = vi.hoisted(() => ({ transport: vi.fn(), native: vi.fn() })); -vi.mock("@storyteller/tauri-utils", () => ({ FetchProxy: transport })); +const { native, forbiddenFetch } = vi.hoisted(() => ({ native: vi.fn(), forbiddenFetch: vi.fn(() => { throw new Error("JavaScript must never call HTTP for login"); }) })); vi.mock("@tauri-apps/api/core", () => ({ invoke: native })); -vi.mock("@storyteller/tauri-api", async () => await import("../../../../tauri-api/src/lib/opener/OpenUrl")); -vi.mock("@storyteller/api", async () => ({ - ...(await import("../../../../api/src/lib/LoginChallengesApi")), - ...(await import("../../../../api/src/lib/UsersApi")), - HttpApiError: (await import("../../../../api/src/lib/ApiManager")).HttpApiError, -})); +vi.mock("@storyteller/tauri-utils", () => ({ FetchProxy: forbiddenFetch })); +vi.mock("@storyteller/api", () => { throw new Error("Login UI must only import API types"); }); vi.mock("qrcode.react", () => ({ QRCodeSVG: ({ value }: { value: string }) => })); -const DEVICE = "D".repeat(43); +const CHALLENGE_ID = "local-handle"; const APPROVAL_URL = `https://app.getartcraft.com/login/desktop#approval_token=${"A".repeat(43)}`; const USER = { user_token: "user_fixture", username: "google_user" }; let outcome: Record; @@ -22,123 +17,129 @@ let expiresIn: number; beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-09-25T12:00:00Z")); + vi.stubGlobal("fetch", forbiddenFetch); localStorage.clear(); - transport.mockReset(); - native.mockReset().mockResolvedValue(undefined); + native.mockReset(); + forbiddenFetch.mockClear(); expiresIn = 1_200_000; - outcome = { success: true, status: "pending", maybe_failure_type: null }; - transport.mockImplementation(async (url: string) => { - if (url.endsWith("/create")) return response({ - success: true, device_token: DEVICE, verification_url: APPROVAL_URL, + outcome = { status: "pending", maybe_failure_type: null, maybe_user: null }; + native.mockImplementation(async (command: string) => { + if (command === "storyteller_create_login_challenge_command") return { + challenge_id: CHALLENGE_ID, verification_url: APPROVAL_URL, confirmation_code: "WDJBMJHT", expires_at: new Date(Date.now() + expiresIn).toISOString(), poll_interval_seconds: 5, - }); - if (url.endsWith("/poll")) return response(outcome); - if (url.endsWith("/v1/session")) return response({ success: true, logged_in: true, user: USER }); - throw new Error("Unexpected test URL"); + }; + if (command === "storyteller_poll_login_challenge_command") return outcome; + if (command === "storyteller_cancel_login_challenge_command" || command === "plugin:opener|open_url") return; + throw new Error(`Unexpected native command: ${command}`); }); }); -afterEach(() => { cleanup(); vi.useRealTimers(); }); +afterEach(() => { + cleanup(); + expect(forbiddenFetch).not.toHaveBeenCalled(); + expect(localStorage.getItem("artcraft_signed_session")).toBeNull(); + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); -describe("desktop login bridge integration", () => { - it("opens the native browser with only the approval token and displays the matching QR/code", async () => { +describe("desktop native login bridge integration", () => { + it("creates through IPC and opens the native browser with the matching QR/code", async () => { render(); await start("Login with Website"); + expect(native).toHaveBeenCalledWith("storyteller_create_login_challenge_command"); expect(native).toHaveBeenCalledWith("plugin:opener|open_url", { url: APPROVAL_URL }); expect(screen.getByRole("img", { name: "Login QR" }).getAttribute("data-value")).toBe(APPROVAL_URL); expect(screen.getByText("WDJB-MJHT")).toBeTruthy(); - expect(document.body.textContent).not.toContain(DEVICE); - expect(localStorage.getItem("artcraft_signed_session")).toBeNull(); }); - it("polls until redeemed, synchronizes native credentials, then verifies the downstream session", async () => { + it("polls with only a local handle and accepts the natively verified user without seeing credentials", async () => { const success = vi.fn(); render(); await start("Scan to Login"); - expect(native).not.toHaveBeenCalled(); await tick(5000); expect(success).not.toHaveBeenCalled(); - expect(localStorage.getItem("artcraft_signed_session")).toBeNull(); - outcome = { success: true, status: "redeemed", maybe_failure_type: null, maybe_signed_session: "signed_session_from_api" }; + outcome = { status: "redeemed", maybe_failure_type: null, maybe_user: USER }; await tick(5000); - expect(native).toHaveBeenCalledWith("storyteller_sync_login_session_command"); - expect(localStorage.getItem("artcraft_signed_session")).toBe("signed_session_from_api"); + expect(native).toHaveBeenCalledWith("storyteller_poll_login_challenge_command", { challengeId: CHALLENGE_ID }); expect(success).toHaveBeenCalledWith(USER); - const sessionCall = transport.mock.calls.find(([url]) => url.endsWith("/v1/session")); - expect(sessionCall?.[1].headers.session).toBe("signed_session_from_api"); - const count = transport.mock.calls.length; + expect(native).toHaveBeenCalledWith("storyteller_cancel_login_challenge_command", { challengeId: CHALLENGE_ID }); + const count = calls("storyteller_poll_login_challenge_command"); await tick(30_000); - expect(transport).toHaveBeenCalledTimes(count); + expect(calls("storyteller_poll_login_challenge_command")).toBe(count); }); - it("stops after decline without persisting or synchronizing a session", async () => { + it("stops after decline", async () => { const success = vi.fn(); - outcome = { success: true, status: "failed", maybe_failure_type: "user_declined" }; + outcome = { status: "failed", maybe_failure_type: "user_declined" }; render(); await start("Scan to Login"); await tick(5000); expect(screen.getByText("Login was declined on the website.")).toBeTruthy(); expect(success).not.toHaveBeenCalled(); - expect(native).not.toHaveBeenCalled(); - expect(localStorage.getItem("artcraft_signed_session")).toBeNull(); await tick(60_000); - expect(transport).toHaveBeenCalledTimes(2); + expect(calls("storyteller_poll_login_challenge_command")).toBe(1); }); - it("expires locally without automatically creating another challenge", async () => { + it("expires locally and cancels the native attempt without creating another", async () => { expiresIn = 5000; render(); await start("Scan to Login"); await tick(5000); expect(screen.getByText("Login request expired. Start a new request.")).toBeTruthy(); - expect(transport).toHaveBeenCalledTimes(1); + expect(calls("storyteller_create_login_challenge_command")).toBe(1); + expect(calls("storyteller_poll_login_challenge_command")).toBe(0); + expect(calls("storyteller_cancel_login_challenge_command")).toBe(1); }); - it("backs off a network failure and retries the same device token", async () => { + it("backs off a native network failure and retries the same local handle", async () => { render(); await start("Scan to Login"); - transport.mockRejectedValueOnce(new Error("Disconnected")); + native.mockRejectedValueOnce({ message: "Disconnected", status: null, retryable: true }); await tick(5000); expect(screen.getByText("Connection interrupted. Retrying…")).toBeTruthy(); await tick(9999); - expect(transport).toHaveBeenCalledTimes(2); + expect(calls("storyteller_poll_login_challenge_command")).toBe(1); await tick(1); - expect(transport).toHaveBeenCalledTimes(3); - expect(JSON.parse(transport.mock.calls[2][1].body)).toEqual({ device_token: DEVICE }); + expect(calls("storyteller_poll_login_challenge_command")).toBe(2); }); - it("does not accept an in-flight response after the dialog unmounts", async () => { - let resolve: (value: Response) => void = () => {}; + it("cancels on unmount and ignores a late command result", async () => { + let resolve: (value: unknown) => void = () => {}; const success = vi.fn(); const view = render(); await start("Scan to Login"); - transport.mockImplementationOnce(() => new Promise((done) => { resolve = done; })); + native.mockImplementationOnce(() => new Promise((done) => { resolve = done; })); await tick(5000); view.unmount(); - await act(async () => { resolve(response({ success: true, status: "redeemed", maybe_signed_session: "late_session" })); }); + expect(native).toHaveBeenCalledWith("storyteller_cancel_login_challenge_command", { challengeId: CHALLENGE_ID }); + await act(async () => { resolve({ status: "redeemed", maybe_user: USER }); }); expect(success).not.toHaveBeenCalled(); - expect(native).not.toHaveBeenCalled(); - expect(localStorage.getItem("artcraft_signed_session")).toBeNull(); }); - it("fails closed on a future status instead of accepting its session value", async () => { - outcome = { success: true, status: "future_state", maybe_signed_session: "untrusted_session" }; + it("fails closed on a future status", async () => { + outcome = { status: "future_state", maybe_user: USER }; render(); await start("Scan to Login"); await tick(5000); expect(screen.getByText("This login request could not be completed. Start a new request.")).toBeTruthy(); - expect(localStorage.getItem("artcraft_signed_session")).toBeNull(); - expect(native).not.toHaveBeenCalled(); + }); + + it("shows the native host/status diagnostic and stops on terminal HTTP errors", async () => { + render(); + await start("Scan to Login"); + native.mockRejectedValueOnce({ message: "http://localhost:12345: Login server rejected the request (HTTP 401)", status: 401, retryable: false }); + await tick(5000); + expect(screen.getByText(/localhost:12345.*401/)).toBeTruthy(); + await tick(60_000); + expect(calls("storyteller_poll_login_challenge_command")).toBe(1); }); }); async function start(label: string) { await act(async () => { fireEvent.click(screen.getByRole("button", { name: label })); }); } - async function tick(ms: number) { await act(async () => { await vi.advanceTimersByTimeAsync(ms); }); } - -function response(body: unknown) { - return new Response(JSON.stringify(body), { status: 200, headers: { "Content-Type": "application/json" } }); +function calls(command: string) { + return native.mock.calls.filter(([name]) => name === command).length; } diff --git a/frontend/libs/components/login-modal/src/lib/DesktopLoginBridge.tsx b/frontend/libs/components/login-modal/src/lib/DesktopLoginBridge.tsx index c293bc24a2..aab4de7d20 100644 --- a/frontend/libs/components/login-modal/src/lib/DesktopLoginBridge.tsx +++ b/frontend/libs/components/login-modal/src/lib/DesktopLoginBridge.tsx @@ -1,12 +1,13 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { QRCodeSVG } from "qrcode.react"; import { invoke } from "@tauri-apps/api/core"; -import { LoginChallengesApi, UsersApi, HttpApiError, type LoginChallenge, type UserInfo } from "@storyteller/api"; -import { OpenUrl } from "@storyteller/tauri-api"; +import type { UserInfo } from "@storyteller/api"; +import { createDesktopLoginChallenge, pollDesktopLoginChallenge, cancelDesktopLoginChallenge, isDesktopLoginError, type DesktopLoginChallenge } from "./NativeLoginBridge"; + +const OpenUrl = (url: string) => invoke("plugin:opener|open_url", { url }); export function DesktopLoginBridge({ onSuccess }: { onSuccess: (user: UserInfo) => void }) { - const api = useMemo(() => new LoginChallengesApi(), []); - const [challenge, setChallenge] = useState(null); + const [challenge, setChallenge] = useState(null); const [busy, setBusy] = useState(false); const [message, setMessage] = useState(""); const [now, setNow] = useState(Date.now); @@ -35,28 +36,21 @@ export function DesktopLoginBridge({ onSuccess }: { onSuccess: (user: UserInfo) if (!active) return; if (Date.now() >= deadline) { finish("Login request expired. Start a new request."); return; } try { - const result = await api.poll(challenge.device_token); + const result = await pollDesktopLoginChallenge(challenge.challenge_id); if (!active) return; if (result.status === "redeemed") { - api.acceptSession(result); - // The response's Set-Cookie is already in Tauri's HTTP cookie jar. - // Synchronize the native API client before declaring login complete. - await invoke("storyteller_sync_login_session_command"); - if (!active) return; - const session = await new UsersApi().GetSession(); - if (!session.success || !session.data?.loggedIn || !session.data.user) throw new Error("Session verification failed"); - if (!active) return; + if (!result.maybe_user) throw new Error("Native session verification did not return a user"); active = false; setChallenge(null); setMessage("Signed in successfully."); - success.current(session.data.user); + success.current(result.maybe_user); return; } if (result.status === "failed") { finish(result.maybe_failure_type === "user_declined" ? "Login was declined on the website." : result.maybe_failure_type === "expired" ? "Login request expired. Start a new request." : "Login failed. Start a new request."); return; } - if (!result.success || !["pending", "approved"].includes(result.status)) { + if (!["pending", "approved"].includes(result.status)) { finish("This login request could not be completed. Start a new request."); return; } @@ -64,8 +58,8 @@ export function DesktopLoginBridge({ onSuccess }: { onSuccess: (user: UserInfo) setMessage("Waiting for your confirmation on the website…"); } catch (error) { if (!active) return; - if (error instanceof HttpApiError && [400, 401, 403, 404].includes(error.status)) { - finish("This login request is no longer valid. Start a new request."); + if (isDesktopLoginError(error) && !error.retryable) { + finish(error.message); return; } delay = Math.min(delay * 2, 30_000); @@ -74,8 +68,12 @@ export function DesktopLoginBridge({ onSuccess }: { onSuccess: (user: UserInfo) if (active) timer = setTimeout(poll, Math.min(delay, Math.max(0, deadline - Date.now()))); }; timer = setTimeout(poll, Math.min(delay, Math.max(0, deadline - Date.now()))); - return () => { active = false; clearTimeout(timer); }; - }, [api, challenge]); + return () => { + active = false; + clearTimeout(timer); + void cancelDesktopLoginChallenge(challenge.challenge_id).catch(() => {}); + }; + }, [challenge]); const start = async (openBrowser: boolean) => { if (busy) return; @@ -83,18 +81,18 @@ export function DesktopLoginBridge({ onSuccess }: { onSuccess: (user: UserInfo) setBusy(true); setMessage(""); try { - const created = await api.create(); - if (current !== generation.current) return; - const url = new URL(created.verification_url); - if (!created.success || url.origin !== "https://app.getartcraft.com" || url.pathname !== "/login/desktop" || !Number.isFinite(Date.parse(created.expires_at))) { - throw new Error("Invalid login challenge"); + const created = await createDesktopLoginChallenge(); + if (current !== generation.current) { + void cancelDesktopLoginChallenge(created.challenge_id).catch(() => {}); + return; } + // Rust validates the approval URL against the configured API environment. setNow(Date.now()); setChallenge(created); setMessage("Waiting for your confirmation on the website…"); if (openBrowser) await OpenUrl(created.verification_url); - } catch { - if (current === generation.current) setMessage("Unable to open website login. Try again or scan the QR code."); + } catch (error) { + if (current === generation.current) setMessage(isDesktopLoginError(error) ? error.message : "Unable to open website login. Try again or scan the QR code."); } finally { if (current === generation.current) setBusy(false); } diff --git a/frontend/libs/components/login-modal/src/lib/NativeLoginBridge.ts b/frontend/libs/components/login-modal/src/lib/NativeLoginBridge.ts new file mode 100644 index 0000000000..70f5a631b9 --- /dev/null +++ b/frontend/libs/components/login-modal/src/lib/NativeLoginBridge.ts @@ -0,0 +1,30 @@ +import { invoke } from "@tauri-apps/api/core"; +import type { UserInfo } from "@storyteller/api"; + +export interface DesktopLoginChallenge { + challenge_id: string; + verification_url: string; + confirmation_code: string; + expires_at: string; + poll_interval_seconds: number; +} + +export interface DesktopLoginOutcome { + status: string; + maybe_failure_type: string | null; + maybe_user: UserInfo | null; +} + +export interface DesktopLoginError { + status: number | null; + message: string; + retryable: boolean; +} + +export const createDesktopLoginChallenge = () => invoke("storyteller_create_login_challenge_command"); +export const pollDesktopLoginChallenge = (challengeId: string) => invoke("storyteller_poll_login_challenge_command", { challengeId }); +export const cancelDesktopLoginChallenge = (challengeId: string) => invoke("storyteller_cancel_login_challenge_command", { challengeId }); + +export function isDesktopLoginError(error: unknown): error is DesktopLoginError { + return typeof error === "object" && error !== null && "message" in error && "retryable" in error; +}