use std::sync::{ atomic::{AtomicBool, Ordering}, Arc, }; use anyhow::anyhow; use chrono::{DateTime, Duration, Utc}; use parking_lot::RwLock; use uuid::Uuid; use warp_core::channel::{Channel, ChannelState}; use warp_graphql::object_permissions::OwnerType; use warpui::{AppContext, Entity, SingletonEntity}; use crate::{ cloud_object::{GenericStringObjectFormat, JsonObjectType, ObjectType}, report_error, }; use super::{ anonymous_id::get_or_create_anonymous_id, auth_manager::user_persistence::PersistedUser, credentials::Credentials, user::{AnonymousUserType, FirebaseAuthTokens, PersonalObjectLimits, PrincipalType, User}, UserUid, API_KEY_PREFIX, }; const ANONYMOUS_USER_NOTIFICATION_BLOCK_TIMER: Duration = Duration::days(7); /// Describes what persistence action to take based on the current auth state. pub(super) 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"))] 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(Credentials::Test)), } } /// 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"))] state.set_credentials(Some(Credentials::Test)); 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:?}"); } } state } fn should_use_test_user() -> bool { cfg!(any(test, feature = "skip_login")) || ChannelState::channel() == Channel::Integration } /// Determines the appropriate persistence action based on the current auth state. pub(super) 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::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. 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(), }; *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(super) fn set_user(&self, user: Option) { *self.user.write() = user; } /// Returns the current credentials. pub fn credentials(&self) -> Option { self.credentials.read().clone() } /// Sets the credentials. Should only be called within the auth module. pub(super) fn set_credentials(&self, credentials: Option) { *self.credentials.write() = credentials; } /// Updates the Firebase auth tokens within the current credentials. /// Reports an error if the current credentials are not Firebase. pub(crate) 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 { self.credentials.read().is_some() } /// 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.is_logged_in() || 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 { self.user.read().as_ref().map(|user| user.is_onboarded) } /// 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 { self.user .read() .as_ref() .map(|user| user.is_user_anonymous()) } /// 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 { self.user.read().as_ref().map(|user| { user.anonymous_user_type() == Some(AnonymousUserType::WebClientAnonymousUser) && user.linked_at().is_none() }) } /// Returns whether or not the user is a feature gated anonymous user. pub fn is_anonymous_user_feature_gated(&self) -> Option { self.user.read().as_ref().map(|user| { if !self.is_user_anonymous().unwrap_or_default() { return false; } matches!( user.anonymous_user_type(), Some(AnonymousUserType::NativeClientAnonymousUserFeatureGated) ) }) } /// Returns whether or not the anonymous user is past any of their Warp Drive object limits. pub fn is_anonymous_user_past_object_limit( &self, object_type: ObjectType, num_objects: usize, ) -> Option { self.user.read().as_ref().map(|user| { if !self.is_anonymous_user_feature_gated().unwrap_or_default() { return false; } if let Some(limits) = user.personal_object_limits() { match object_type { ObjectType::Notebook => num_objects > limits.notebook_limit, ObjectType::Workflow => num_objects > limits.workflow_limit, ObjectType::GenericStringObject(GenericStringObjectFormat::Json( JsonObjectType::EnvVarCollection, )) => num_objects > limits.env_var_limit, _ => false, } } else { 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 { self.user.read().as_ref().map(|user| user.needs_sso_link) } /// 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 { self.needs_reauth.load(Ordering::Relaxed) } /// Sets whether a reauth is required for the current user. /// Returns whether or not the reauth state was changed from false to true. pub(super) 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 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 [`warp_managed_secrets`] crate, which needs to access the current user. impl warp_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(test)] 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(test)] pub fn new_logged_out_for_test() -> Self { Self { auth_state: Arc::new(AuthState { user: RwLock::new(None), anonymous_id: Uuid::new_v4(), needs_reauth: AtomicBool::new(false), credentials: RwLock::new(None), }), } } pub fn get(&self) -> &Arc { &self.auth_state } } impl Entity for AuthStateProvider { type Event = (); } impl SingletonEntity for AuthStateProvider {}