first pass of merging in warp (doesn't build)

This commit is contained in:
Ryan Ward
2026-07-01 16:08:58 -05:00
parent 2f64909469
commit 4770ac06b5
3662 changed files with 414574 additions and 89772 deletions
@@ -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
})
}
+596
View File
@@ -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 {}
+246
View File
@@ -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()
}
}
+12
View File
@@ -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-";
+221
View File
@@ -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());
});
});
}
+43
View File
@@ -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());
}
+88
View File
@@ -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)
}
}