use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use anyhow::anyhow; use chrono::{DateTime, Duration, Utc}; use galaxy_core::channel::{Channel, ChannelState}; use galaxy_core::report_error; use galaxy_graphql::object_permissions::OwnerType; use galaxyui_core::{AppContext, Entity, SingletonEntity}; use parking_lot::RwLock; use uuid::Uuid; use super::anonymous_id::get_or_create_anonymous_id; use super::credentials::Credentials; #[cfg(any(not(target_family = "wasm"), test, feature = "test-util"))] use super::user::UserMetadata; use super::user::persistence::PersistedUser; use super::user::{ AnonymousUserType, FirebaseAuthTokens, PersonalObjectLimits, PrincipalType, User, }; use super::{API_KEY_PREFIX, UserUid}; const ANONYMOUS_USER_NOTIFICATION_BLOCK_TIMER: Duration = Duration::days(7); /// Describes what persistence action to take based on the current auth state. pub enum PersistAction { /// The user has Firebase credentials and should be persisted to secure storage. Persist(Box), /// The user has been logged out and should be removed from secure storage. Remove, /// No persistence action is needed (e.g. API key or test credentials). DoNothing, } /// AuthState holds information about the currently-logged in user. /// If you need to access AuthState, you can use the AuthStateProvider singleton model. pub struct AuthState { /// The currently logged-in User. None if the user isn't logged in currently. user: RwLock>, /// An anonymous UUID. Can be used to consistently identify an anonymous user who is not logged in. anonymous_id: Uuid, /// State that indicates whether the current user's refresh token has been /// invalidated, meaning a reauth is required. needs_reauth: AtomicBool, /// The current authentication credentials. credentials: RwLock>, } impl AuthState { fn new(ctx: &AppContext) -> Self { Self { user: RwLock::new(None), anonymous_id: get_or_create_anonymous_id(ctx), needs_reauth: AtomicBool::new(false), credentials: RwLock::new(None), } } #[cfg(any( test, feature = "integration_tests", feature = "skip_login", feature = "test-util" ))] fn test_credentials() -> Credentials { #[cfg(any(test, feature = "integration_tests", feature = "skip_login"))] { Credentials::Test } #[cfg(all( feature = "test-util", not(any(test, feature = "integration_tests", feature = "skip_login")) ))] { Credentials::SessionCookie } } #[cfg(any(test, feature = "integration_tests", feature = "test-util"))] pub fn new_for_test() -> Self { Self { user: RwLock::new(Some(User::test())), anonymous_id: Uuid::new_v4(), needs_reauth: AtomicBool::new(false), credentials: RwLock::new(Some(Self::test_credentials())), } } #[cfg(any(test, feature = "test-util"))] pub fn new_logged_out_for_test() -> Self { Self { user: RwLock::new(None), anonymous_id: Uuid::new_v4(), needs_reauth: AtomicBool::new(false), credentials: RwLock::new(None), } } #[cfg(any(test, feature = "test-util"))] pub fn new_anonymous_for_test() -> Self { use super::user::AnonymousUserType; Self { user: RwLock::new(Some(User { anonymous_user_type: Some(AnonymousUserType::NativeClientAnonymousUserFeatureGated), ..User::test() })), anonymous_id: Uuid::new_v4(), needs_reauth: AtomicBool::new(false), credentials: RwLock::new(Some(Self::test_credentials())), } } /// Creates and initializes auth state. Checks, in order: /// 1. Test user (test/integration/skip_login builds) /// 2. Provided API key /// 3. WARP_USER_SECRET environment variable /// 4. Persisted user from secure storage #[cfg_attr(target_family = "wasm", allow(dead_code))] pub fn initialize(ctx: &AppContext, api_key: Option) -> Self { let state = Self::new(ctx); if Self::should_use_test_user() { state.set_user(Some(User::test())); #[cfg(any( test, feature = "integration_tests", feature = "skip_login", feature = "test-util" ))] state.set_credentials(Some(Self::test_credentials())); return state; } if let Some(api_key_value) = api_key { log::info!("Authenticating via API key"); let formatted = if api_key_value.starts_with(API_KEY_PREFIX) { api_key_value } else { format!("{API_KEY_PREFIX}{api_key_value}") }; state.set_credentials(Some(Credentials::ApiKey { key: formatted, owner_type: None, })); return state; } // Try WARP_USER_SECRET environment variable. if let Some(persisted) = option_env!("WARP_USER_SECRET") .and_then(|s| serde_json::from_str::(s).ok()) { state.apply_persisted_user(persisted); return state; } // Try reading from secure storage. match PersistedUser::from_secure_storage(ctx) { Ok(persisted) => { if persisted.auth_tokens.refresh_token.is_empty() { log::warn!( "Found persisted user with empty refresh token; clearing secure storage entry" ); let _ = PersistedUser::remove_from_secure_storage(ctx).map_err(|err| { log::warn!("Unable to clear invalid user from secure storage: {err:?}"); }); } else { state.apply_persisted_user(persisted); } } Err(err) => { log::info!("Unable to read user from secure storage: {err:?}"); } } if state.user.read().is_none() { let channel = ChannelState::channel(); if channel == Channel::Oss || channel == Channel::Local || channel == Channel::Dev { log::info!( "No user found in secure storage on dev/oss/local channel; falling back to test user" ); state.set_user(Some(User::test())); } } state } #[allow(dead_code)] fn should_use_test_user() -> bool { cfg!(any(test, feature = "skip_login", feature = "test-util")) || ChannelState::channel() == Channel::Integration } /// Determines the appropriate persistence action based on the current auth state. pub fn persist_action(&self) -> PersistAction { let user = self.user.read().clone(); let credentials = self.credentials.read().clone(); match (user, credentials) { (Some(user), Some(Credentials::Firebase(firebase_tokens))) => { let anonymous_user_type = user.anonymous_user_type(); let linked_at = user.linked_at(); let personal_object_limits = user.personal_object_limits(); #[allow(deprecated)] let persisted = PersistedUser { auth_tokens: firebase_tokens, refresh_token: String::new(), local_id: user.local_id, metadata: user.metadata, is_onboarded: user.is_onboarded, needs_sso_link: user.needs_sso_link, anonymous_user_type, linked_at, personal_object_limits, is_on_work_domain: user.is_on_work_domain, }; PersistAction::Persist(Box::new(persisted)) } // Remove persisted auth state if it is unset in-memory. (None, None) => PersistAction::Remove, // Do not persist if using API keys, session cookies, or test credentials. (Some(_), Some(Credentials::ApiKey { .. })) => PersistAction::DoNothing, (Some(_), Some(Credentials::Bearer(_))) => PersistAction::DoNothing, (Some(_), Some(Credentials::SessionCookie)) => PersistAction::DoNothing, #[cfg(any(test, feature = "integration_tests", feature = "skip_login"))] (Some(_), Some(Credentials::Test)) => PersistAction::DoNothing, // Credentials without a user, or user without credentials - transient states // during initialization or refresh; no persistence action needed. (None, Some(_)) | (Some(_), None) => PersistAction::DoNothing, } } /// Applies a deserialized PersistedUser, splitting it into User and Credentials. #[allow(dead_code)] fn apply_persisted_user(&self, persisted: PersistedUser) { let user = User { is_onboarded: persisted.is_onboarded, local_id: persisted.local_id, metadata: persisted.metadata, needs_sso_link: persisted.needs_sso_link, anonymous_user_type: persisted.anonymous_user_type, is_on_work_domain: persisted.is_on_work_domain, linked_at: persisted.linked_at, personal_object_limits: persisted.personal_object_limits, principal_type: PrincipalType::default(), global_skills: Vec::new(), }; *self.user.write() = Some(user); if persisted.auth_tokens.refresh_token.is_empty() { log::warn!("Skipping credentials update due to empty refresh token"); return; } *self.credentials.write() = Some(Credentials::Firebase(persisted.auth_tokens)); } /// Sets the user. This should only be called by the AuthManager, to ensure /// side-effects are handled properly (e.g. notifying other models, persisting /// the user to secure storage, etc.). pub fn set_user(&self, user: Option) { *self.user.write() = user; } /// Returns the current credentials. pub fn credentials(&self) -> Option { self.credentials.read().clone() } /// Returns whether requests to authenticated server endpoints can be made. /// /// Galaxy's local-first experience treats the user as logged in even when no /// Warp server credentials are configured, so `is_logged_in` is not sufficient /// for guarding authenticated network requests. pub fn has_server_credentials(&self) -> bool { self.credentials.read().is_some() } /// Sets the credentials. Should only be called within the auth module. pub fn set_credentials(&self, credentials: Option) { *self.credentials.write() = credentials; } /// Applies auth data received by the remote server daemon handshake. /// /// Empty values are authoritative: an empty token clears bearer credentials, and an empty user /// ID clears the daemon user identity. #[cfg(any(not(target_family = "wasm"), test, feature = "test-util"))] pub fn apply_remote_server_auth_context( &self, auth_token: String, user_id: String, user_email: String, ) { self.set_remote_server_bearer_token(auth_token); self.set_remote_server_user(user_id, user_email); } /// Applies bearer-token credentials received from the remote server daemon. #[cfg(any(not(target_family = "wasm"), test, feature = "test-util"))] pub fn set_remote_server_bearer_token(&self, auth_token: String) { if auth_token.is_empty() { self.set_credentials(None); return; } self.set_credentials(Some(Credentials::Bearer(auth_token))); } #[cfg(any(not(target_family = "wasm"), test, feature = "test-util"))] fn set_remote_server_user(&self, user_id: String, user_email: String) { let mut user = self.user.write(); if user_id.is_empty() { *user = None; return; } match user.as_mut() { Some(user) => { user.local_id = UserUid::new(&user_id); user.metadata.email = user_email; } None => { *user = Some(User { local_id: UserUid::new(&user_id), metadata: UserMetadata { email: user_email, display_name: None, photo_url: None, }, is_onboarded: false, needs_sso_link: false, anonymous_user_type: None, is_on_work_domain: false, linked_at: None, personal_object_limits: None, principal_type: PrincipalType::default(), global_skills: Vec::new(), }); } } } /// Updates the Firebase auth tokens within the current credentials. /// Reports an error if the current credentials are not Firebase. pub fn update_firebase_tokens(&self, new_auth_tokens: FirebaseAuthTokens) { let mut write_lock = self.credentials.write(); if let Some(Credentials::Firebase(tokens)) = write_lock.as_mut() { *tokens = new_auth_tokens; } else { report_error!(anyhow!( "Tried to update Firebase tokens without Firebase credentials" )); } } /// Determines whether the user should be considered as logged in. pub fn is_logged_in(&self) -> bool { true } /// Returns whether the user should be treated as not having a full account. /// True if the user is anonymous OR if there is no user at all (fully logged out). /// /// Note: uses `unwrap_or(true)` intentionally (not `unwrap_or_default()`) so that /// during the transient state where credentials exist but user data hasn't loaded /// yet, the user is conservatively treated as lacking a full account. pub fn is_anonymous_or_logged_out(&self) -> bool { self.user.read().is_none() || self.is_user_anonymous().unwrap_or(true) } /// Returns the cached access token, if any exists. This method *will not* check if the JWT is /// still valid! Usually, you want to use [`ServerApi::get_or_refresh_access_token`] instead! pub fn get_access_token_ignoring_validity(&self) -> Option { let credentials = self.credentials.read(); credentials.as_ref()?.bearer_token().bearer_token() } /// Returns the user's display name. pub fn username_for_display(&self) -> Option { Some(self.user.read().as_ref()?.username_for_display().to_owned()) } /// Returns the user's display name, does NOT fall back to email. pub fn display_name(&self) -> Option { self.user .read() .as_ref() .and_then(|user| user.display_name().to_owned()) } /// Returns the user's email. Note the non-obvious semantics of this function: /// If the user is logged in and not anonymous, the email will always be populated. /// If the user is logged in and anonymous, their email will be an empty string. /// If the user is not logged in, their email will be `None`. pub fn user_email(&self) -> Option { self.user .read() .as_ref() .map(|user| user.metadata.email.clone()) } /// Returns whether the user considered onboarded to Warp. pub fn is_onboarded(&self) -> Option { Some(true) } /// Returns the user's email domain (anything after the @ sign of their email). pub fn user_email_domain(&self) -> Option { self.user.read().as_ref().map(|user| { user.metadata .email .clone() .split('@') .nth(1) .unwrap_or("") .to_string() }) } /// Returns whether or not the user is anonymous. /// Anonymous users are real Warp users, but have no providers linked in Firebase. /// Returns `None` if there is no user data. pub fn is_user_anonymous(&self) -> Option { Some(false) } /// Returns whether or not the user is a "web client anonymous user", aka their account /// originated from viewing Warp on web. pub fn is_user_web_anonymous_user(&self) -> Option { Some(false) } /// Returns whether or not the user is a feature gated anonymous user. pub fn is_anonymous_user_feature_gated(&self) -> Option { Some(false) } /// Returns the user's photo URL from Firebase, /// typically acquired from linking a provider like Google/GitHub. pub fn user_photo_url(&self) -> Option { self.user .read() .as_ref() .and_then(|user| user.metadata.photo_url.clone()) } /// Returns whether or not the user needs to link their account to an SSO provider. /// The actual value is calculated on the server to avoid additional RPCs to Firebase. pub fn needs_sso_link(&self) -> Option { Some(false) } /// Returns the anonymous user type. /// Note that a `Some()` value here does NOT mean the user is still anonymous; /// they might have since signed up, but we keep their anonymous user type around. pub fn anonymous_user_type(&self) -> Option { self.user .read() .as_ref() .and_then(|user| user.anonymous_user_type()) } /// Returns the personal object limits the user has. /// Currently, only anonymous users have limits. pub fn personal_object_limits(&self) -> Option { self.user .read() .as_ref() .and_then(|user| user.personal_object_limits()) } /// Set whether or not the user is onboarded. pub fn set_is_onboarded(&self, is_onboarded: bool) { if let Some(user) = self.user.write().as_mut() { user.is_onboarded = is_onboarded; } } /// If the user is logged in, returns their Firebase UID. Otherwise, returns None. pub fn user_id(&self) -> Option { self.user.read().as_ref().map(|user| user.local_id) } /// Returns the user's anonymous id. /// The anonymous id will be consistent across the app's lifetime. It is a random UUID. pub fn anonymous_id(&self) -> String { self.anonymous_id.to_string() } /// Returns whether a reauth is required for the current user given the state /// of their refresh token. pub fn needs_reauth(&self) -> bool { false } /// Sets whether a reauth is required for the current user. /// Returns whether or not the reauth state was changed from false to true. pub fn set_needs_reauth(&self, new_needs_reauth: bool) -> bool { let prev_needs_reauth = self.needs_reauth.swap(new_needs_reauth, Ordering::Relaxed); !prev_needs_reauth && new_needs_reauth } /// Returns whether or not the renotification block to encourage anonymous users to sign up /// has expired. pub fn anonymous_user_renotification_block_expired( &self, last_time_opt: Option, ) -> bool { self.is_anonymous_user_feature_gated().unwrap_or_default() && last_time_opt .and_then(|last_time_string| last_time_string.parse::>().ok()) .is_none_or(|last_time| { Utc::now() - ANONYMOUS_USER_NOTIFICATION_BLOCK_TIMER >= last_time }) } /// Returns whether or not the user is on a work domain. /// This calculation is done on the server, using a list of pub fn is_on_work_domain(&self) -> Option { self.user.read().as_ref().map(|user| user.is_on_work_domain) } /// Returns whether the current user is authenticated via API key. pub fn is_api_key_authenticated(&self) -> bool { matches!( self.credentials.read().as_ref(), Some(Credentials::ApiKey { .. }) ) } /// Returns the API key if using API key authentication. pub fn api_key(&self) -> Option { let credentials = self.credentials.read(); credentials.as_ref()?.as_api_key().map(|s| s.to_owned()) } /// Returns the type of principal (user or service account). pub fn principal_type(&self) -> Option { self.user.read().as_ref().map(|user| user.principal_type) } /// Returns whether the authenticated principal is a service account. pub fn is_service_account(&self) -> bool { matches!(self.principal_type(), Some(PrincipalType::ServiceAccount)) } /// Returns the cached global skill specs for the current user. pub fn global_skills(&self) -> Vec { self.user .read() .as_ref() .map(|user| user.global_skills.clone()) .unwrap_or_default() } /// Returns the owner type of the currently-authenticated API key. pub fn api_key_owner_type(&self) -> Option { self.credentials.read().as_ref()?.api_key_owner_type() } } // Adapter for the [`galaxy_managed_secrets`] crate, which needs to access the current user. impl galaxy_managed_secrets::ActorProvider for AuthState { fn actor_uid(&self) -> Option { self.user_id().map(|uid| uid.as_string()) } } /// AuthStateProvider is a singleton model which provides a reference to the global AuthState. pub struct AuthStateProvider { auth_state: Arc, } impl AuthStateProvider { pub fn new(auth_state: Arc) -> Self { Self { auth_state } } #[cfg(any(test, feature = "test-util"))] pub fn new_for_test() -> Self { Self { auth_state: Arc::new(AuthState::new_for_test()), } } /// Constructs a provider backed by a fully logged-out `AuthState` (no user, /// no credentials). Used by unit tests that need to exercise code paths /// gated on `AuthState::user_id()` / `UserWorkspaces::personal_drive()` /// returning `None`. #[cfg(any(test, feature = "test-util"))] pub fn new_logged_out_for_test() -> Self { Self { auth_state: Arc::new(AuthState::new_logged_out_for_test()), } } #[cfg(any(test, feature = "test-util"))] pub fn new_anonymous_for_test() -> Self { Self { auth_state: Arc::new(AuthState::new_anonymous_for_test()), } } pub fn get(&self) -> &Arc { &self.auth_state } } impl Entity for AuthStateProvider { type Event = (); } impl SingletonEntity for AuthStateProvider {}