first pass of merging in warp (doesn't build)
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
use galaxy_core::user_preferences::GetUserPreferences;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Key used to persist the anonymous id to user defaults. We use "ExperimentId" as the key
|
||||
/// since we use the persisted id to determine experiment groups, and we want to avoid
|
||||
/// associating it with telemetry.
|
||||
const ANONYMOUS_ID_KEY: &str = "ExperimentId";
|
||||
|
||||
/// Reads the persisted anonymous id from user defaults, if it exists and is a
|
||||
/// valid uuid.
|
||||
fn get_persisted_anonymous_id(ctx: &dyn GetUserPreferences) -> Option<Uuid> {
|
||||
let anonymous_id = ctx
|
||||
.private_user_preferences()
|
||||
.read_value(ANONYMOUS_ID_KEY)
|
||||
.unwrap_or_default()?;
|
||||
match Uuid::parse_str(&anonymous_id) {
|
||||
Ok(uuid) => Some(uuid),
|
||||
Err(e) => {
|
||||
log::warn!("Error parsing persisted anonymous id from user defaults: {e:?}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets the persisted anonymous id if possible, otherwise generates a new uuid
|
||||
/// and saves it to user defaults.
|
||||
pub fn get_or_create_anonymous_id(ctx: &dyn GetUserPreferences) -> Uuid {
|
||||
get_persisted_anonymous_id(ctx).unwrap_or_else(|| {
|
||||
let uuid = Uuid::new_v4();
|
||||
let _ = ctx
|
||||
.private_user_preferences()
|
||||
.write_value(ANONYMOUS_ID_KEY, uuid.to_string());
|
||||
uuid
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,596 @@
|
||||
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_graphql::object_permissions::OwnerType;
|
||||
use galaxyui::{AppContext, Entity, SingletonEntity};
|
||||
use parking_lot::RwLock;
|
||||
use uuid::Uuid;
|
||||
use galaxy_core::report_error;
|
||||
use galaxyui_core::{AppContext, Entity, SingletonEntity};
|
||||
|
||||
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<PersistedUser>),
|
||||
/// 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<Option<User>>,
|
||||
|
||||
/// 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<Option<Credentials>>,
|
||||
}
|
||||
|
||||
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<String>) -> 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::<PersistedUser>(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
|
||||
}
|
||||
|
||||
#[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<User>) {
|
||||
*self.user.write() = user;
|
||||
}
|
||||
|
||||
/// Returns the current credentials.
|
||||
pub fn credentials(&self) -> Option<Credentials> {
|
||||
self.credentials.read().clone()
|
||||
}
|
||||
|
||||
/// Sets the credentials. Should only be called within the auth module.
|
||||
pub fn set_credentials(&self, credentials: Option<Credentials>) {
|
||||
*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 {
|
||||
false
|
||||
}
|
||||
|
||||
/// 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<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<bool> {
|
||||
Some(true)
|
||||
}
|
||||
|
||||
/// Returns the user's email domain (anything after the @ sign of their email).
|
||||
pub fn user_email_domain(&self) -> Option<String> {
|
||||
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<bool> {
|
||||
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<bool> {
|
||||
Some(false)
|
||||
}
|
||||
|
||||
/// Returns whether or not the user is a feature gated anonymous user.
|
||||
pub fn is_anonymous_user_feature_gated(&self) -> Option<bool> {
|
||||
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<String> {
|
||||
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<bool> {
|
||||
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<AnonymousUserType> {
|
||||
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<PersonalObjectLimits> {
|
||||
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<UserUid> {
|
||||
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<String>,
|
||||
) -> bool {
|
||||
self.is_anonymous_user_feature_gated().unwrap_or_default()
|
||||
&& last_time_opt
|
||||
.and_then(|last_time_string| last_time_string.parse::<DateTime<Utc>>().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<bool> {
|
||||
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<String> {
|
||||
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<PrincipalType> {
|
||||
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<String> {
|
||||
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<OwnerType> {
|
||||
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<String> {
|
||||
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<AuthState>,
|
||||
}
|
||||
|
||||
impl AuthStateProvider {
|
||||
pub fn new(auth_state: Arc<AuthState>) -> 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<AuthState> {
|
||||
&self.auth_state
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for AuthStateProvider {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl SingletonEntity for AuthStateProvider {}
|
||||
@@ -0,0 +1,246 @@
|
||||
//! Representation of Warp user credentials.
|
||||
//!
|
||||
//! The primary representation is [`Credentials`], which is the source of truth for how a user is
|
||||
//! authenticated to Warp.
|
||||
//!
|
||||
//! Credentials can be split into two halves:
|
||||
//! * [`LoginToken`], which is a long-lived token that we use to fetch user information.
|
||||
//! When using Firebase, this is an OAuth2 refresh token.
|
||||
//! * [`AuthToken`], which is a short-lived token that's included in all other server requests.
|
||||
//! When using Firebase, this is an OAuth2 access token.
|
||||
use galaxy_graphql::object_permissions::OwnerType;
|
||||
|
||||
use super::user::FirebaseAuthTokens;
|
||||
|
||||
/// Represents the different ways a user can authenticate with Warp.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum Credentials {
|
||||
/// Firebase authentication with ID token and refresh token.
|
||||
Firebase(FirebaseAuthTokens),
|
||||
/// API key for direct server authentication.
|
||||
ApiKey {
|
||||
key: String,
|
||||
/// The owner type for this API key. Only set after user info is fetched from the server.
|
||||
owner_type: Option<OwnerType>,
|
||||
},
|
||||
/// Request-scoped or externally managed bearer token.
|
||||
Bearer(String),
|
||||
/// Authentication derived from an ambient browser session cookie.
|
||||
SessionCookie,
|
||||
/// Test credentials used in unit tests, integration tests, and skip_login builds.
|
||||
#[cfg(any(test, feature = "integration_tests", feature = "skip_login"))]
|
||||
Test,
|
||||
}
|
||||
|
||||
impl Credentials {
|
||||
/// Returns the Firebase auth tokens if this is a Firebase credential.
|
||||
pub fn as_firebase(&self) -> Option<&FirebaseAuthTokens> {
|
||||
match self {
|
||||
Credentials::Firebase(tokens) => Some(tokens),
|
||||
Credentials::ApiKey { .. } => None,
|
||||
Credentials::Bearer(_) => None,
|
||||
Credentials::SessionCookie => None,
|
||||
#[cfg(any(test, feature = "integration_tests", feature = "skip_login"))]
|
||||
Credentials::Test => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the API key string if this is an API key credential.
|
||||
pub fn as_api_key(&self) -> Option<&str> {
|
||||
match self {
|
||||
Credentials::ApiKey { key, .. } => Some(key),
|
||||
Credentials::Firebase(_) => None,
|
||||
Credentials::Bearer(_) => None,
|
||||
Credentials::SessionCookie => None,
|
||||
#[cfg(any(test, feature = "integration_tests", feature = "skip_login"))]
|
||||
Credentials::Test => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the owner type if this is an API key credential.
|
||||
pub fn api_key_owner_type(&self) -> Option<OwnerType> {
|
||||
match self {
|
||||
Credentials::ApiKey { owner_type, .. } => *owner_type,
|
||||
Credentials::Firebase(_) => None,
|
||||
Credentials::Bearer(_) => None,
|
||||
Credentials::SessionCookie => None,
|
||||
#[cfg(any(test, feature = "integration_tests", feature = "skip_login"))]
|
||||
Credentials::Test => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the Firebase refresh token if this is a Firebase credential.
|
||||
pub fn refresh_token(&self) -> Option<&str> {
|
||||
match self {
|
||||
Credentials::Firebase(tokens) => Some(&tokens.refresh_token),
|
||||
Credentials::ApiKey { .. } => None,
|
||||
Credentials::Bearer(_) => None,
|
||||
Credentials::SessionCookie => None,
|
||||
#[cfg(any(test, feature = "integration_tests", feature = "skip_login"))]
|
||||
Credentials::Test => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the short-lived token to use in HTTP requests to the server.
|
||||
pub fn bearer_token(&self) -> AuthToken {
|
||||
match self {
|
||||
Credentials::Firebase(tokens) => AuthToken::Firebase(tokens.id_token.clone()),
|
||||
Credentials::ApiKey { key, .. } => AuthToken::ApiKey(key.clone()),
|
||||
Credentials::Bearer(token) => AuthToken::Bearer(token.clone()),
|
||||
Credentials::SessionCookie => AuthToken::NoAuth,
|
||||
#[cfg(any(test, feature = "integration_tests", feature = "skip_login"))]
|
||||
Credentials::Test => AuthToken::NoAuth,
|
||||
}
|
||||
}
|
||||
/// Returns whether these credentials are externally managed and should not trigger local token
|
||||
/// refresh or reauth flows.
|
||||
pub fn is_externally_managed(&self) -> bool {
|
||||
match self {
|
||||
Credentials::Bearer(_) => true,
|
||||
Credentials::Firebase(_) | Credentials::ApiKey { .. } | Credentials::SessionCookie => {
|
||||
false
|
||||
}
|
||||
#[cfg(any(test, feature = "integration_tests", feature = "skip_login"))]
|
||||
Credentials::Test => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the long-lived login token for these credentials. Returns `None` if there is no such token.
|
||||
pub fn login_token(&self) -> Option<LoginToken> {
|
||||
match self {
|
||||
Credentials::Firebase(tokens) => Some(LoginToken::Firebase(FirebaseToken::Refresh(
|
||||
RefreshToken::new(&tokens.refresh_token),
|
||||
))),
|
||||
Credentials::ApiKey { key, .. } => Some(LoginToken::ApiKey(key.clone())),
|
||||
Credentials::Bearer(_) => None,
|
||||
Credentials::SessionCookie => Some(LoginToken::SessionCookie),
|
||||
#[cfg(any(test, feature = "integration_tests", feature = "skip_login"))]
|
||||
Credentials::Test => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents different types of authentication tokens.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum AuthToken {
|
||||
/// Firebase short-lived access token.
|
||||
Firebase(String),
|
||||
/// API key for direct server authentication.
|
||||
ApiKey(String),
|
||||
/// Request-scoped or externally managed bearer token.
|
||||
Bearer(String),
|
||||
/// No authentication token available (e.g. session cookie auth or test credentials).
|
||||
#[cfg_attr(
|
||||
not(any(
|
||||
test,
|
||||
feature = "integration_tests",
|
||||
feature = "skip_login",
|
||||
feature = "test-util"
|
||||
)),
|
||||
allow(dead_code)
|
||||
)]
|
||||
NoAuth,
|
||||
}
|
||||
|
||||
impl AuthToken {
|
||||
/// Returns the token string to use in an Authorization header, or `None` if auth is not
|
||||
/// header-based (e.g. session cookie) or there is no auth.
|
||||
pub fn as_bearer_token(&self) -> Option<&str> {
|
||||
match self {
|
||||
AuthToken::Firebase(token) => Some(token),
|
||||
AuthToken::ApiKey(key) => Some(key),
|
||||
AuthToken::Bearer(token) => Some(token),
|
||||
AuthToken::NoAuth => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the bearer token as an owned string, or `None` if auth is not header-based.
|
||||
pub fn bearer_token(&self) -> Option<String> {
|
||||
match self {
|
||||
AuthToken::Firebase(token) => Some(token.clone()),
|
||||
AuthToken::ApiKey(key) => Some(key.clone()),
|
||||
AuthToken::Bearer(token) => Some(token.clone()),
|
||||
AuthToken::NoAuth => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Long-lived credentials exchanged for user information.
|
||||
#[derive(Debug)]
|
||||
pub enum LoginToken {
|
||||
/// A Firebase token to be exchanged for auth tokens.
|
||||
Firebase(FirebaseToken),
|
||||
/// An API key for direct server authentication.
|
||||
ApiKey(String),
|
||||
/// Authentication derived from an ambient browser session cookie.
|
||||
SessionCookie,
|
||||
}
|
||||
|
||||
/// The type of firebase token that can be used to authenticate a user.
|
||||
/// For logged in users and anonymous users, we use a refresh token.
|
||||
/// We use a short-lived custom token when we first create and fetch a new anonymous user.
|
||||
/// In both cases the token can be exchanged for a short lived access token.
|
||||
#[derive(Debug)]
|
||||
pub enum FirebaseToken {
|
||||
/// The token type for a logged in user.
|
||||
Refresh(RefreshToken),
|
||||
|
||||
/// The token type for an anonymous user.
|
||||
Custom(String),
|
||||
}
|
||||
|
||||
impl FirebaseToken {
|
||||
/// Returns the url for trading this long lived token into an access token.
|
||||
pub fn access_token_url(&self, api_key: &str) -> String {
|
||||
// See https://firebase.google.com/docs/reference/rest/auth for info on these
|
||||
// authentication endpoints.
|
||||
match self {
|
||||
FirebaseToken::Refresh(_) => {
|
||||
format!("https://securetoken.googleapis.com/v1/token?key={api_key}")
|
||||
}
|
||||
FirebaseToken::Custom(_) => {
|
||||
format!(
|
||||
"https://identitytoolkit.googleapis.com/v1/accounts:signInWithCustomToken?key={api_key}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the POST body for to include when trading this long lived token into an access token.
|
||||
pub fn access_token_request_body(&self) -> Vec<(&str, &str)> {
|
||||
match self {
|
||||
FirebaseToken::Refresh(refresh_token) => vec![
|
||||
("grant_type", "refresh_token"),
|
||||
("refresh_token", refresh_token.get()),
|
||||
],
|
||||
FirebaseToken::Custom(custom_token) => {
|
||||
vec![("returnSecureToken", "true"), ("token", custom_token)]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the proxy URL for trading this long lived token into an access token.
|
||||
/// Used when the initial request to Firebase fails and we want to try and proxy the request
|
||||
/// through our server.
|
||||
pub fn proxy_url(&self, server_root: &str, api_key: &str) -> String {
|
||||
match self {
|
||||
FirebaseToken::Refresh(_) => format!("{server_root}/proxy/token?key={api_key}"),
|
||||
FirebaseToken::Custom(_) => {
|
||||
format!("{server_root}/proxy/customToken?key={api_key}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RefreshToken(String);
|
||||
|
||||
impl RefreshToken {
|
||||
pub fn new(token: impl Into<String>) -> Self {
|
||||
Self(token.into())
|
||||
}
|
||||
|
||||
pub fn get(&self) -> &str {
|
||||
self.0.as_str()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
pub mod anonymous_id;
|
||||
pub mod auth_state;
|
||||
pub mod credentials;
|
||||
pub mod user;
|
||||
pub mod user_uid;
|
||||
|
||||
pub use auth_state::AuthStateProvider;
|
||||
pub use user_uid::UserUid;
|
||||
|
||||
/// Prefix for API keys used in authentication.
|
||||
#[cfg_attr(target_family = "wasm", allow(dead_code))]
|
||||
pub const API_KEY_PREFIX: &str = "wk-";
|
||||
@@ -0,0 +1,221 @@
|
||||
use anyhow::{Result, anyhow};
|
||||
use chrono::{DateTime, FixedOffset, Local};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use galaxy_graphql::queries::get_user::FirebaseProfile;
|
||||
use galaxy_graphql::scalars::time::ServerTimestamp;
|
||||
|
||||
use super::UserUid;
|
||||
pub use super::user_uid::{TEST_USER_EMAIL, TEST_USER_UID};
|
||||
|
||||
pub mod persistence;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum AnonymousUserType {
|
||||
/// An anonymous user created from the native client.
|
||||
NativeClientAnonymousUser,
|
||||
/// An anonymous user created from the native client with feature (rather than time-based) gating.
|
||||
NativeClientAnonymousUserFeatureGated,
|
||||
/// An anonymous user created from the web client.
|
||||
WebClientAnonymousUser,
|
||||
}
|
||||
|
||||
/// Type of principal making the authenticated request.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub enum PrincipalType {
|
||||
#[default]
|
||||
User,
|
||||
ServiceAccount,
|
||||
}
|
||||
|
||||
impl From<galaxy_graphql::queries::get_user::PrincipalType> for PrincipalType {
|
||||
fn from(value: galaxy_graphql::queries::get_user::PrincipalType) -> Self {
|
||||
use galaxy_graphql::queries::get_user::PrincipalType as GqlPrincipalType;
|
||||
match value {
|
||||
GqlPrincipalType::User => PrincipalType::User,
|
||||
GqlPrincipalType::ServiceAccount => PrincipalType::ServiceAccount,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<galaxy_graphql::mutations::create_anonymous_user::AnonymousUserType>
|
||||
for AnonymousUserType
|
||||
{
|
||||
type Error = anyhow::Error;
|
||||
fn try_from(
|
||||
value: galaxy_graphql::mutations::create_anonymous_user::AnonymousUserType,
|
||||
) -> Result<Self, Self::Error> {
|
||||
match value {
|
||||
galaxy_graphql::mutations::create_anonymous_user::AnonymousUserType::NativeClientAnonymousUser => Ok(AnonymousUserType::NativeClientAnonymousUser),
|
||||
galaxy_graphql::mutations::create_anonymous_user::AnonymousUserType::NativeClientAnonymousUserFeatureGated => Ok(AnonymousUserType::NativeClientAnonymousUserFeatureGated),
|
||||
galaxy_graphql::mutations::create_anonymous_user::AnonymousUserType::WebClientAnonymousUser => Ok(AnonymousUserType::WebClientAnonymousUser),
|
||||
galaxy_graphql::mutations::create_anonymous_user::AnonymousUserType::Other(_) => {
|
||||
Err(anyhow!("could not convert unknown anonymous user type"))
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Copy, Clone, Serialize, Deserialize)]
|
||||
pub struct PersonalObjectLimits {
|
||||
pub env_var_limit: usize,
|
||||
pub notebook_limit: usize,
|
||||
pub workflow_limit: usize,
|
||||
}
|
||||
|
||||
impl TryFrom<galaxy_graphql::queries::get_user::AnonymousUserPersonalObjectLimits>
|
||||
for PersonalObjectLimits
|
||||
{
|
||||
type Error = anyhow::Error;
|
||||
fn try_from(
|
||||
value: galaxy_graphql::queries::get_user::AnonymousUserPersonalObjectLimits,
|
||||
) -> Result<Self, Self::Error> {
|
||||
Ok(Self {
|
||||
env_var_limit: value.env_var_limit as usize,
|
||||
notebook_limit: value.notebook_limit as usize,
|
||||
workflow_limit: value.workflow_limit as usize,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The in-memory representation of a logged-in User.
|
||||
/// This does not include authentication credentials, which are stored separately
|
||||
/// in the `Credentials` enum.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct User {
|
||||
/// The Firebase UID of this user.
|
||||
pub local_id: UserUid,
|
||||
/// Metadata about the user.
|
||||
pub metadata: UserMetadata,
|
||||
/// Whether or not the user is onboarded.
|
||||
pub is_onboarded: bool,
|
||||
/// Whether or not the user needs to link their account via SSO due to an organization setting.
|
||||
pub needs_sso_link: bool,
|
||||
/// What type of anonymous user this user is. May be `None` if they are not anonymous.
|
||||
pub anonymous_user_type: Option<AnonymousUserType>,
|
||||
/// Whether or not this user is on what we consider a "work" domain, meaning the domain isn't
|
||||
/// from a general email provider (e.g. gmail.com, hotmail.com, proton.me, etc.).
|
||||
/// Calculated on warp-server.
|
||||
pub is_on_work_domain: bool,
|
||||
pub linked_at: Option<ServerTimestamp>,
|
||||
pub personal_object_limits: Option<PersonalObjectLimits>,
|
||||
/// Type of principal (user or service account). Fetched fresh from the server
|
||||
/// on each login/refresh.
|
||||
pub principal_type: PrincipalType,
|
||||
/// Skill specs that should be available to this principal in every agent run.
|
||||
pub global_skills: Vec<String>,
|
||||
}
|
||||
|
||||
/// This struct holds extra information about the user. Most of this information comes directly
|
||||
/// from Firebase.
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
pub struct UserMetadata {
|
||||
/// The user's email. NOTE: unlike other fields which use `Option`s to denote null values,
|
||||
/// an anonymous user will have an empty string as their email here.
|
||||
pub email: String,
|
||||
/// The user's display name from Firebase. We should prefer showing this over their email, if
|
||||
/// we can. Typically this is only populated when using a non-email provider like GitHub.
|
||||
pub display_name: Option<String>,
|
||||
/// A URL for their profile picture.
|
||||
pub photo_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct FirebaseAuthTokens {
|
||||
/// ID tokens are Firebase tokens, which are short-lived tokens that are used to authenticate
|
||||
/// requests to the server. These are obtained by exchanging long-lived refresh tokens.
|
||||
pub id_token: String,
|
||||
/// Refresh tokens are long-lived tokens that can be exchanged for short-lived access tokens
|
||||
/// (stored in the id_token field). We use the refresh token to get a new ID token when the
|
||||
/// current one expires.
|
||||
/// Note that there are two types of refresh tokens we store in this field:
|
||||
/// "Refresh tokens": these are used for logged-in users.
|
||||
/// "Custom tokens": these are used for anonymous firebase users.
|
||||
pub refresh_token: String,
|
||||
/// When the ID token expires. If the token has expired, or will expire soon, we should
|
||||
/// fetch a new ID token using the user's refresh token.
|
||||
pub expiration_time: DateTime<FixedOffset>,
|
||||
}
|
||||
|
||||
impl FirebaseAuthTokens {
|
||||
pub fn from_response(
|
||||
id_token: String,
|
||||
refresh_token: String,
|
||||
expires_in: String,
|
||||
) -> Result<Self, anyhow::Error> {
|
||||
let local_time = Local::now();
|
||||
Ok(Self {
|
||||
id_token,
|
||||
expiration_time: local_time.with_timezone(local_time.offset())
|
||||
+ chrono::Duration::seconds(
|
||||
expires_in.parse::<i64>().map_err(anyhow::Error::from)?,
|
||||
),
|
||||
refresh_token,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl User {
|
||||
/// The name for the user that we display. This is the user's display name, if set. If not set,
|
||||
/// we then fallback to email (which is always set).
|
||||
pub fn username_for_display(&self) -> &str {
|
||||
let user_metadata = &self.metadata;
|
||||
user_metadata
|
||||
.display_name
|
||||
.as_deref()
|
||||
.unwrap_or(user_metadata.email.as_str())
|
||||
}
|
||||
|
||||
/// The display name of the user. Does not fall back to email.
|
||||
pub fn display_name(&self) -> Option<String> {
|
||||
self.metadata.display_name.clone()
|
||||
}
|
||||
|
||||
pub fn test() -> Self {
|
||||
Self {
|
||||
local_id: UserUid::new(TEST_USER_UID),
|
||||
metadata: UserMetadata {
|
||||
email: TEST_USER_EMAIL.to_string(),
|
||||
display_name: None,
|
||||
photo_url: None,
|
||||
},
|
||||
is_onboarded: true,
|
||||
needs_sso_link: false,
|
||||
anonymous_user_type: None,
|
||||
is_on_work_domain: false,
|
||||
linked_at: None,
|
||||
personal_object_limits: None,
|
||||
principal_type: PrincipalType::User,
|
||||
global_skills: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_user_anonymous(&self) -> bool {
|
||||
self.anonymous_user_type().is_some() && self.linked_at().is_none()
|
||||
}
|
||||
|
||||
pub fn anonymous_user_type(&self) -> Option<AnonymousUserType> {
|
||||
self.anonymous_user_type
|
||||
}
|
||||
|
||||
pub fn personal_object_limits(&self) -> Option<PersonalObjectLimits> {
|
||||
self.personal_object_limits
|
||||
}
|
||||
|
||||
pub fn linked_at(&self) -> Option<ServerTimestamp> {
|
||||
self.linked_at
|
||||
}
|
||||
}
|
||||
|
||||
impl From<FirebaseProfile> for UserMetadata {
|
||||
fn from(value: FirebaseProfile) -> Self {
|
||||
Self {
|
||||
email: value.email.unwrap_or_default(),
|
||||
display_name: value.display_name,
|
||||
photo_url: value.photo_url,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "user_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,82 @@
|
||||
use galaxy_graphql::scalars::time::ServerTimestamp;
|
||||
use galaxyui::AppContext;
|
||||
use galaxyui_extras::secure_storage;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use galaxyui_core::AppContext;
|
||||
use galaxyui_extras::secure_storage::{self, AppContextExt};
|
||||
|
||||
use super::{AnonymousUserType, FirebaseAuthTokens, PersonalObjectLimits, UserMetadata};
|
||||
use crate::UserUid;
|
||||
|
||||
const USER_STORAGE_KEY: &str = "User";
|
||||
|
||||
/// Helper function to set `true` as the default for a serde field on PersistedUser.
|
||||
fn default_as_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// The persisted representation of a user, serialized to/from the user's keychain.
|
||||
/// This struct must remain backwards compatible with the existing keychain JSON format.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PersistedUser {
|
||||
/// Information about the user's authentication through Firebase.
|
||||
#[serde(rename = "id_token")]
|
||||
pub auth_tokens: FirebaseAuthTokens,
|
||||
/// DO NOT USE! This used to be one of the two places we stored the user's Firebase refresh
|
||||
/// token. Now, all callers should go through `auth_tokens` to access it.
|
||||
#[serde(default)]
|
||||
#[deprecated = "use auth_tokens.refresh_token instead"]
|
||||
pub refresh_token: String,
|
||||
/// The Firebase UID of this user.
|
||||
pub local_id: UserUid,
|
||||
/// Metadata about the user.
|
||||
#[serde(flatten)]
|
||||
pub metadata: UserMetadata,
|
||||
/// Whether or not the user is onboarded.
|
||||
#[serde(default = "default_as_true")]
|
||||
pub is_onboarded: bool,
|
||||
/// Whether or not the user needs to link their account via SSO due to an organization setting.
|
||||
#[serde(default)]
|
||||
pub needs_sso_link: bool,
|
||||
/// What type of anonymous user this user is. May be `None` if they are not anonymous.
|
||||
#[serde(default)]
|
||||
pub anonymous_user_type: Option<AnonymousUserType>,
|
||||
#[serde(default)]
|
||||
pub linked_at: Option<ServerTimestamp>,
|
||||
#[serde(default)]
|
||||
pub personal_object_limits: Option<PersonalObjectLimits>,
|
||||
/// Whether or not this user is on what we consider a "work" domain.
|
||||
#[serde(default)]
|
||||
pub is_on_work_domain: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum UserPersistenceError {
|
||||
/// The persisted user was not successfully read from or written to disk.
|
||||
#[error("secure storage error")]
|
||||
SecureStorageError(#[from] secure_storage::Error),
|
||||
|
||||
/// The persisted user on disk could not be decoded into a valid struct.
|
||||
#[error("failed to serialize or deserialize a PersistedUser struct")]
|
||||
SerializationError(#[from] serde_json::Error),
|
||||
}
|
||||
|
||||
impl PersistedUser {
|
||||
pub fn from_secure_storage(_ctx: &AppContext) -> Result<PersistedUser, UserPersistenceError> {
|
||||
Err(UserPersistenceError::SecureStorageError(
|
||||
secure_storage::Error::NotFound,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn write_to_secure_storage(&self, _ctx: &AppContext) -> Result<(), UserPersistenceError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn remove_from_secure_storage(_ctx: &AppContext) -> Result<(), UserPersistenceError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "persistence_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,156 @@
|
||||
use chrono::DateTime;
|
||||
|
||||
use super::PersistedUser;
|
||||
use crate::UserUid;
|
||||
use crate::user::{FirebaseAuthTokens, PersonalObjectLimits, UserMetadata};
|
||||
|
||||
/// Verifies that the JSON blob format as of March 6, 2026 can be deserialized correctly.
|
||||
///
|
||||
/// We must ALWAYS be backwards-compatible with the format here. The inlined JSON string can never change - it represents
|
||||
/// data serialized on user devices.
|
||||
#[test]
|
||||
#[allow(deprecated)]
|
||||
fn test_deserialize_2026_03_06_persisted_user() {
|
||||
const BLOB: &str = r#"{"id_token":{"id_token":"test-id-token","refresh_token":"test-refresh-token","expiration_time":"2099-01-01T00:00:00Z"},"refresh_token":"","local_id":"test-uid","email":"test@example.com","display_name":"Test User","photo_url":"https://example.com/photo.jpg","is_onboarded":true,"needs_sso_link":false,"anonymous_user_type":null,"linked_at":null,"personal_object_limits":null,"is_on_work_domain":false}"#;
|
||||
|
||||
let user: PersistedUser =
|
||||
serde_json::from_str(BLOB).expect("2026-03-06 JSON should deserialize");
|
||||
|
||||
assert_eq!(user.auth_tokens.id_token, "test-id-token");
|
||||
assert_eq!(user.auth_tokens.refresh_token, "test-refresh-token");
|
||||
assert_eq!(user.refresh_token, "");
|
||||
assert_eq!(user.local_id.as_str(), "test-uid");
|
||||
assert_eq!(user.metadata.email, "test@example.com");
|
||||
assert_eq!(user.metadata.display_name.as_deref(), Some("Test User"));
|
||||
assert_eq!(
|
||||
user.metadata.photo_url.as_deref(),
|
||||
Some("https://example.com/photo.jpg")
|
||||
);
|
||||
assert!(user.is_onboarded);
|
||||
assert!(!user.needs_sso_link);
|
||||
assert_eq!(user.anonymous_user_type, None);
|
||||
assert_eq!(user.linked_at, None);
|
||||
assert!(user.personal_object_limits.is_none());
|
||||
assert!(!user.is_on_work_domain);
|
||||
}
|
||||
|
||||
/// Verifies that serializing a PersistedUser produces the expected JSON string.
|
||||
///
|
||||
/// If this test fails, it means the serialization format has changed.
|
||||
/// You should:
|
||||
/// 1. Add a new dated deserialization test (see [`test_deserialize_2026_03_06_persisted_user`])
|
||||
/// 2. Update the serialization test to match the new format
|
||||
#[test]
|
||||
#[allow(deprecated)]
|
||||
fn test_serialize_persisted_user() {
|
||||
const EXPECTED_BLOB: &str = r#"{"id_token":{"id_token":"test-id-token","refresh_token":"test-refresh-token","expiration_time":"2099-01-01T00:00:00Z"},"refresh_token":"","local_id":"test-uid","email":"test@example.com","display_name":"Test User","photo_url":"https://example.com/photo.jpg","is_onboarded":true,"needs_sso_link":false,"anonymous_user_type":null,"linked_at":null,"personal_object_limits":{"env_var_limit":10,"notebook_limit":20,"workflow_limit":30},"is_on_work_domain":false}"#;
|
||||
|
||||
let expiration_time = DateTime::parse_from_rfc3339("2099-01-01T00:00:00+00:00")
|
||||
.expect("should parse expiration datetime");
|
||||
|
||||
let user = PersistedUser {
|
||||
auth_tokens: FirebaseAuthTokens {
|
||||
id_token: "test-id-token".to_string(),
|
||||
refresh_token: "test-refresh-token".to_string(),
|
||||
expiration_time,
|
||||
},
|
||||
refresh_token: String::new(),
|
||||
local_id: UserUid::new("test-uid"),
|
||||
metadata: UserMetadata {
|
||||
email: "test@example.com".to_string(),
|
||||
display_name: Some("Test User".to_string()),
|
||||
photo_url: Some("https://example.com/photo.jpg".to_string()),
|
||||
},
|
||||
is_onboarded: true,
|
||||
needs_sso_link: false,
|
||||
anonymous_user_type: None,
|
||||
linked_at: None,
|
||||
personal_object_limits: Some(PersonalObjectLimits {
|
||||
env_var_limit: 10,
|
||||
notebook_limit: 20,
|
||||
workflow_limit: 30,
|
||||
}),
|
||||
is_on_work_domain: false,
|
||||
};
|
||||
|
||||
let serialized = serde_json::to_string(&user).expect("serialization should succeed");
|
||||
assert_eq!(serialized, EXPECTED_BLOB);
|
||||
}
|
||||
|
||||
/// Test serializing and deserializing persisted user data.
|
||||
/// See galaxyui_extras::secure_storage::linux_test.rs for Linux-specific tests.
|
||||
#[cfg(target_os = "windows")]
|
||||
#[cfg_attr(windows, ignore = "passes locally but not in CI on Windows")]
|
||||
#[test]
|
||||
#[allow(deprecated)]
|
||||
fn test_windows_user_persistence() {
|
||||
use chrono::Local;
|
||||
use galaxy_core::channel::ChannelState;
|
||||
use galaxyui_core::App;
|
||||
use galaxyui_extras::secure_storage;
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
app.update(|ctx| {
|
||||
secure_storage::register_with_dir(
|
||||
ChannelState::data_domain().as_str(),
|
||||
galaxy_core::paths::state_dir(),
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
let local_time = Local::now();
|
||||
|
||||
let tokens = FirebaseAuthTokens {
|
||||
id_token: String::from("This is an ID token."),
|
||||
refresh_token: String::from("This is a refresh token."),
|
||||
expiration_time: local_time.with_timezone(local_time.offset())
|
||||
+ chrono::Duration::days(365),
|
||||
};
|
||||
let persisted_user = PersistedUser {
|
||||
auth_tokens: tokens.clone(),
|
||||
refresh_token: String::new(),
|
||||
local_id: UserUid::new("test_uid"),
|
||||
metadata: UserMetadata {
|
||||
email: "test@test.com".to_string(),
|
||||
display_name: Some(String::from("abcdef")),
|
||||
photo_url: Some(String::from("some-photo-url")),
|
||||
},
|
||||
is_onboarded: true,
|
||||
needs_sso_link: false,
|
||||
anonymous_user_type: None,
|
||||
linked_at: None,
|
||||
personal_object_limits: None,
|
||||
is_on_work_domain: false,
|
||||
};
|
||||
|
||||
app.update(|ctx| {
|
||||
// Write the test user to secure storage.
|
||||
let write = persisted_user.write_to_secure_storage(ctx);
|
||||
match &write {
|
||||
Ok(()) => {}
|
||||
Err(err) => {
|
||||
println!("{err:?}");
|
||||
}
|
||||
}
|
||||
assert!(write.is_ok());
|
||||
|
||||
// Read the persisted user back and ensure the fields match.
|
||||
let stored = PersistedUser::from_secure_storage(ctx).unwrap();
|
||||
assert_eq!(stored.auth_tokens.id_token, tokens.id_token);
|
||||
assert_eq!(stored.auth_tokens.refresh_token, tokens.refresh_token);
|
||||
assert_eq!(stored.auth_tokens.expiration_time, tokens.expiration_time);
|
||||
assert_eq!(
|
||||
stored.metadata.display_name,
|
||||
persisted_user.metadata.display_name
|
||||
);
|
||||
assert_eq!(stored.metadata.email, persisted_user.metadata.email);
|
||||
assert_eq!(stored.metadata.photo_url, persisted_user.metadata.photo_url);
|
||||
|
||||
// Remove the user from secure storage.
|
||||
assert!(PersistedUser::remove_from_secure_storage(ctx).is_ok());
|
||||
|
||||
// Attempt to read a user back, which should fail.
|
||||
let empty_user = PersistedUser::from_secure_storage(ctx);
|
||||
assert!(empty_user.is_err());
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
use anyhow::Result;
|
||||
use galaxy_graphql::queries::get_user::FirebaseProfile;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_user_profile() -> Result<()> {
|
||||
let response: FirebaseProfile = serde_json::from_str(
|
||||
r#"{
|
||||
"uid": "test_local_id",
|
||||
"email": "test_user@example.com",
|
||||
"displayName": "Test User",
|
||||
"photoUrl": "https://photourl.example.com/1234",
|
||||
"needsSsoLink": true
|
||||
}"#,
|
||||
)?;
|
||||
let user = User {
|
||||
is_onboarded: true,
|
||||
local_id: UserUid::new("test_local_id"),
|
||||
metadata: response.into(),
|
||||
needs_sso_link: true,
|
||||
anonymous_user_type: None,
|
||||
is_on_work_domain: false,
|
||||
linked_at: None,
|
||||
personal_object_limits: None,
|
||||
principal_type: PrincipalType::User,
|
||||
global_skills: Vec::new(),
|
||||
};
|
||||
assert_eq!(user.metadata.display_name.as_deref(), Some("Test User"));
|
||||
assert_eq!(user.metadata.email, "test_user@example.com");
|
||||
assert_eq!(
|
||||
user.metadata.photo_url.as_deref(),
|
||||
Some("https://photourl.example.com/1234")
|
||||
);
|
||||
assert!(user.needs_sso_link);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_user_global_skills_defaults_to_empty() {
|
||||
assert_eq!(User::test().global_skills, Vec::<String>::new());
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
use std::fmt;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
|
||||
pub const TEST_USER_EMAIL: &str = "test_user@warp.dev";
|
||||
pub const TEST_USER_UID: &str = "test_user_uid";
|
||||
|
||||
/// UserUid represents the unique identifier for a user. Currently, this is a Firebase UID.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct UserUid(lasso::Spur);
|
||||
|
||||
static USER_UID_INTERNER: LazyLock<lasso::ThreadedRodeo<lasso::Spur>> =
|
||||
LazyLock::new(lasso::ThreadedRodeo::new);
|
||||
|
||||
impl Default for UserUid {
|
||||
fn default() -> Self {
|
||||
// Intern an empty string so that `as_str()` on a default UserUid
|
||||
// returns "" instead of panicking with "Key out of bounds".
|
||||
Self::new("")
|
||||
}
|
||||
}
|
||||
|
||||
impl UserUid {
|
||||
pub fn new(uid: &str) -> Self {
|
||||
Self(USER_UID_INTERNER.get_or_intern(uid))
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
USER_UID_INTERNER.resolve(&self.0)
|
||||
}
|
||||
|
||||
pub fn as_string(&self) -> String {
|
||||
self.as_str().to_string()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<UserUid> for cynic::Id {
|
||||
fn from(user_uid: UserUid) -> Self {
|
||||
cynic::Id::new(user_uid.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for UserUid {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for UserUid {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
f.write_str("UserUid(")?;
|
||||
f.write_str(self.as_str())?;
|
||||
f.write_str(")")
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for UserUid {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
serializer.serialize_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for UserUid {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
struct UidVisitor;
|
||||
impl serde::de::Visitor<'_> for UidVisitor {
|
||||
type Value = UserUid;
|
||||
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
|
||||
formatter.write_str("a user UID")
|
||||
}
|
||||
|
||||
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
|
||||
where
|
||||
E: serde::de::Error,
|
||||
{
|
||||
Ok(UserUid::new(v))
|
||||
}
|
||||
}
|
||||
deserializer.deserialize_str(UidVisitor)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user