first pass of merging in warp (doesn't build)
This commit is contained in:
@@ -1,3 +1,550 @@
|
||||
pub mod user_uid;
|
||||
mod session;
|
||||
|
||||
use std::result::Result as StdResult;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context as _, Result, anyhow};
|
||||
use async_trait::async_trait;
|
||||
use cynic::{MutationBuilder, QueryBuilder};
|
||||
use firebase::FirebaseError;
|
||||
use instant::Duration;
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
use mockall::automock;
|
||||
pub use session::*;
|
||||
use thiserror::Error;
|
||||
pub use user_uid::{TEST_USER_EMAIL, TEST_USER_UID, UserUid};
|
||||
use galaxy_core::errors::{AnyhowErrorExt, ErrorExt, register_error};
|
||||
use warp_graphql::client::Operation;
|
||||
use warp_graphql::mutations::create_anonymous_user::{
|
||||
AnonymousUserType, CreateAnonymousUser, CreateAnonymousUserResult, CreateAnonymousUserVariables,
|
||||
};
|
||||
use warp_graphql::mutations::expire_api_key::{
|
||||
ExpireApiKey, ExpireApiKeyResult, ExpireApiKeyVariables,
|
||||
};
|
||||
use warp_graphql::mutations::generate_api_key::{
|
||||
GenerateApiKey, GenerateApiKeyInput, GenerateApiKeyResult, GenerateApiKeyVariables,
|
||||
};
|
||||
use warp_graphql::mutations::mint_custom_token::{MintCustomTokenResult, MintCustomTokenVariables};
|
||||
use warp_graphql::mutations::set_user_is_onboarded::{
|
||||
SetUserIsOnboarded, SetUserIsOnboardedResult, SetUserIsOnboardedVariables,
|
||||
};
|
||||
use warp_graphql::mutations::update_user_settings::{
|
||||
UpdateUserSettings, UpdateUserSettingsInput, UpdateUserSettingsResult,
|
||||
UpdateUserSettingsVariables,
|
||||
};
|
||||
use warp_graphql::queries::api_keys::{
|
||||
ApiKeyProperties, ApiKeyPropertiesResult, ApiKeys, ApiKeysVariables,
|
||||
};
|
||||
use warp_graphql::queries::get_user::{GetUser, GetUserVariables, UserOutput as GqlUserOutput};
|
||||
use warp_graphql::queries::get_user_settings::{GetUserSettings, GetUserSettingsVariables};
|
||||
use warp_server_auth::credentials::{AuthToken, Credentials, FirebaseToken, LoginToken};
|
||||
pub use warp_server_auth::user_uid;
|
||||
|
||||
use crate::base_client::BaseClient;
|
||||
use crate::graphql_helpers::send_graphql_request;
|
||||
use crate::ids::ApiKeyUid;
|
||||
|
||||
/// Header key used to associate unauthenticated requests with an experiment identity.
|
||||
pub const EXPERIMENT_ID_HEADER: &str = "X-Warp-Experiment-Id";
|
||||
|
||||
/// A named agent identity from the public API.
|
||||
#[derive(Clone, Debug, serde::Deserialize)]
|
||||
pub struct AgentIdentity {
|
||||
pub uid: String,
|
||||
pub name: String,
|
||||
pub available: bool,
|
||||
}
|
||||
|
||||
/// Wrapper for the `GET /api/v1/agent/identities` response.
|
||||
#[derive(serde::Deserialize)]
|
||||
struct AgentIdentitiesResponse {
|
||||
agents: Vec<AgentIdentity>,
|
||||
}
|
||||
|
||||
/// User settings that are stored server-side on a per-user basis.
|
||||
#[derive(Copy, Clone, Debug, Default)]
|
||||
pub struct SyncedUserSettings {
|
||||
pub is_cloud_conversation_storage_enabled: bool,
|
||||
pub is_crash_reporting_enabled: bool,
|
||||
pub is_telemetry_enabled: bool,
|
||||
}
|
||||
|
||||
/// Protocol-level results of fetching the current user.
|
||||
pub struct FetchUserResult {
|
||||
pub user_output: GqlUserOutput,
|
||||
/// The credentials used to authenticate this user.
|
||||
pub credentials: Credentials,
|
||||
/// Whether this attempt to fetch the user was for refreshing an existing logged-in user.
|
||||
pub from_refresh: bool,
|
||||
}
|
||||
|
||||
#[cfg_attr(any(test, feature = "test-util"), automock)]
|
||||
#[cfg_attr(not(target_family = "wasm"), async_trait)]
|
||||
#[cfg_attr(target_family = "wasm", async_trait(?Send))]
|
||||
pub trait AuthClient: Send + Sync {
|
||||
/// Creates an anonymous user who is allowed to use Warp but may lack the ability
|
||||
/// to interact with particular features.
|
||||
async fn create_anonymous_user(
|
||||
&self,
|
||||
referral_code: Option<String>,
|
||||
anonymous_user_type: AnonymousUserType,
|
||||
) -> Result<CreateAnonymousUserResult>;
|
||||
|
||||
/// Returns the cached access token if it is still valid.
|
||||
///
|
||||
/// If it has expired, this fetches a new access token using the user's refresh
|
||||
/// token, caches it, and then returns it. It may return an auth mode that does
|
||||
/// not require an Authorization header, such as session cookies or test credentials.
|
||||
async fn get_or_refresh_access_token(&self) -> Result<AuthToken>;
|
||||
|
||||
/// Fetches the user's metadata and authentication tokens.
|
||||
async fn fetch_user(
|
||||
&self,
|
||||
token: LoginToken,
|
||||
for_refresh: bool,
|
||||
) -> StdResult<FetchUserResult, UserAuthenticationError>;
|
||||
|
||||
/// Creates and fetches a new custom token for the current user from Firebase.
|
||||
///
|
||||
/// This only works for anonymous users and surfaces an error if the user is not anonymous.
|
||||
async fn fetch_new_custom_token(&self) -> Result<MintCustomTokenResult>;
|
||||
|
||||
/// Handles the response from [`Self::fetch_new_custom_token`] by returning the newly minted custom token.
|
||||
fn on_custom_token_fetched(
|
||||
&self,
|
||||
response: Result<MintCustomTokenResult>,
|
||||
) -> Result<String, MintCustomTokenError>;
|
||||
|
||||
/// Queries warp-server for a set of the currently logged-in user's fields.
|
||||
async fn fetch_user_properties<'a>(&self, auth_token: Option<&'a str>)
|
||||
-> Result<GqlUserOutput>;
|
||||
|
||||
/// Returns the user's settings retrieved from the server, if any.
|
||||
///
|
||||
/// The user may not have server-side settings if they onboarded before telemetry
|
||||
/// opt-out launched, have not logged in since the launch, and have never changed
|
||||
/// defaults for any setting in [`SyncedUserSettings`]. If the fetched settings
|
||||
/// object exists but is missing required fields, or if the request itself fails,
|
||||
/// this returns an error.
|
||||
async fn get_user_settings(&self) -> Result<Option<SyncedUserSettings>>;
|
||||
|
||||
async fn set_is_telemetry_enabled(&self, value: bool) -> Result<()>;
|
||||
|
||||
async fn set_is_crash_reporting_enabled(&self, value: bool) -> Result<()>;
|
||||
|
||||
async fn set_is_cloud_conversation_storage_enabled(&self, value: bool) -> Result<()>;
|
||||
|
||||
/// Sends a request to update the user's settings on the server with values in the given input.
|
||||
async fn update_user_settings(&self, input: UpdateUserSettingsInput) -> Result<()>;
|
||||
|
||||
async fn set_user_is_onboarded(&self) -> Result<bool>;
|
||||
|
||||
/// Requests a device authorization code from the server for headless CLI or SDK authentication.
|
||||
async fn request_device_code(
|
||||
&self,
|
||||
) -> StdResult<oauth2::StandardDeviceAuthorizationResponse, UserAuthenticationError>;
|
||||
|
||||
/// Waits for the request to be approved or rejected and exchanges it for a short-lived custom access token.
|
||||
async fn exchange_device_access_token(
|
||||
&self,
|
||||
details: &oauth2::StandardDeviceAuthorizationResponse,
|
||||
timeout: Duration,
|
||||
) -> StdResult<FirebaseToken, UserAuthenticationError>;
|
||||
|
||||
async fn list_api_keys(&self) -> Result<Vec<ApiKeyProperties>>;
|
||||
|
||||
async fn create_api_key(
|
||||
&self,
|
||||
name: String,
|
||||
team_id: Option<cynic::Id>,
|
||||
agent_uid: Option<cynic::Id>,
|
||||
expires_at: Option<warp_graphql::scalars::Time>,
|
||||
) -> Result<GenerateApiKeyResult>;
|
||||
|
||||
async fn expire_api_key(&self, key_uid: &ApiKeyUid) -> Result<ExpireApiKeyResult>;
|
||||
|
||||
/// Fetches the list of named agent identities for the user's team.
|
||||
async fn list_agent_identities(&self) -> Result<Vec<AgentIdentity>>;
|
||||
}
|
||||
|
||||
/// Implements the [`AuthClient`] trait on top of a base client and auth session.
|
||||
pub struct AuthClientImpl {
|
||||
base_client: Arc<BaseClient>,
|
||||
auth_session: Arc<AuthSession>,
|
||||
}
|
||||
|
||||
impl AuthClientImpl {
|
||||
pub fn new(base_client: Arc<BaseClient>) -> Self {
|
||||
let auth_session = base_client.auth_session();
|
||||
Self {
|
||||
base_client,
|
||||
auth_session,
|
||||
}
|
||||
}
|
||||
|
||||
async fn update_settings(
|
||||
&self,
|
||||
input: UpdateUserSettingsInput,
|
||||
unknown_error_message: &'static str,
|
||||
) -> Result<()> {
|
||||
let operation = UpdateUserSettings::build(UpdateUserSettingsVariables {
|
||||
input,
|
||||
request_context: warp_graphql::client::get_request_context(),
|
||||
});
|
||||
let result = send_graphql_request(&self.base_client, operation, None)
|
||||
.await?
|
||||
.update_user_settings;
|
||||
Self::on_settings_updated(result, unknown_error_message)
|
||||
}
|
||||
|
||||
fn on_settings_updated(
|
||||
result: UpdateUserSettingsResult,
|
||||
unknown_error_message: &'static str,
|
||||
) -> Result<()> {
|
||||
match result {
|
||||
UpdateUserSettingsResult::UpdateUserSettingsOutput(_) => Ok(()),
|
||||
UpdateUserSettingsResult::UserFacingError(error) => Err(anyhow!(
|
||||
warp_graphql::client::get_user_facing_error_message(error)
|
||||
)),
|
||||
UpdateUserSettingsResult::Unknown => Err(anyhow!(unknown_error_message)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(not(target_family = "wasm"), async_trait)]
|
||||
#[cfg_attr(target_family = "wasm", async_trait(?Send))]
|
||||
impl AuthClient for AuthClientImpl {
|
||||
async fn create_anonymous_user(
|
||||
&self,
|
||||
referral_code: Option<String>,
|
||||
anonymous_user_type: AnonymousUserType,
|
||||
) -> Result<CreateAnonymousUserResult> {
|
||||
let operation = CreateAnonymousUser::build(CreateAnonymousUserVariables {
|
||||
input: warp_graphql::mutations::create_anonymous_user::CreateAnonymousUserInput {
|
||||
anonymous_user_type,
|
||||
expiration_type: warp_graphql::mutations::create_anonymous_user::AnonymousUserExpirationType::NoExpiration,
|
||||
referral_code,
|
||||
},
|
||||
request_context: warp_graphql::client::get_request_context(),
|
||||
});
|
||||
let response = operation
|
||||
.send_request(
|
||||
self.base_client.owned_http_client(),
|
||||
self.base_client.graphql_request_options_with_token(None),
|
||||
)
|
||||
.await?;
|
||||
Ok(response
|
||||
.data
|
||||
.ok_or_else(|| anyhow!("missing data in response"))?
|
||||
.create_anonymous_user)
|
||||
}
|
||||
|
||||
async fn get_or_refresh_access_token(&self) -> Result<AuthToken> {
|
||||
self.auth_session.get_or_refresh_access_token().await
|
||||
}
|
||||
|
||||
async fn fetch_user(
|
||||
&self,
|
||||
token: LoginToken,
|
||||
for_refresh: bool,
|
||||
) -> StdResult<FetchUserResult, UserAuthenticationError> {
|
||||
let new_credentials = self.auth_session.exchange_credentials(token).await?;
|
||||
let auth_token = new_credentials.bearer_token();
|
||||
let user_output = self
|
||||
.fetch_user_properties(auth_token.as_bearer_token())
|
||||
.await
|
||||
.context("Failed to fetch user response data")
|
||||
.map_err(UserAuthenticationError::Unexpected)?;
|
||||
// Store the owner type if using an API key.
|
||||
let new_credentials = match new_credentials {
|
||||
Credentials::ApiKey { key, .. } => Credentials::ApiKey {
|
||||
key,
|
||||
owner_type: user_output.api_key_owner_type,
|
||||
},
|
||||
other => other,
|
||||
};
|
||||
Ok(FetchUserResult {
|
||||
user_output,
|
||||
credentials: new_credentials,
|
||||
from_refresh: for_refresh,
|
||||
})
|
||||
}
|
||||
|
||||
async fn fetch_new_custom_token(&self) -> Result<MintCustomTokenResult> {
|
||||
let operation = warp_graphql::mutations::mint_custom_token::MintCustomToken::build(
|
||||
MintCustomTokenVariables {
|
||||
request_context: warp_graphql::client::get_request_context(),
|
||||
},
|
||||
);
|
||||
let response = send_graphql_request(&self.base_client, operation, None).await?;
|
||||
Ok(response.mint_custom_token)
|
||||
}
|
||||
|
||||
fn on_custom_token_fetched(
|
||||
&self,
|
||||
response: Result<MintCustomTokenResult>,
|
||||
) -> Result<String, MintCustomTokenError> {
|
||||
match response {
|
||||
Ok(MintCustomTokenResult::MintCustomTokenOutput(output)) => Ok(output.custom_token),
|
||||
Ok(MintCustomTokenResult::UserFacingError(error)) => {
|
||||
Err(MintCustomTokenError::UserFacingError(
|
||||
warp_graphql::client::get_user_facing_error_message(error),
|
||||
))
|
||||
}
|
||||
Ok(MintCustomTokenResult::Unknown) | Err(_) => Err(MintCustomTokenError::Unknown),
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_user_properties<'a>(
|
||||
&self,
|
||||
auth_token: Option<&'a str>,
|
||||
) -> Result<GqlUserOutput> {
|
||||
let operation = GetUser::build(GetUserVariables {
|
||||
request_context: warp_graphql::client::get_request_context(),
|
||||
});
|
||||
let mut options = self
|
||||
.base_client
|
||||
.graphql_request_options_with_token(auth_token.map(ToOwned::to_owned));
|
||||
options.headers.insert(
|
||||
EXPERIMENT_ID_HEADER.to_string(),
|
||||
self.base_client.anonymous_id(),
|
||||
);
|
||||
let response = operation
|
||||
.send_request(self.base_client.owned_http_client(), options)
|
||||
.await?
|
||||
.data
|
||||
.ok_or_else(|| anyhow!("Expected valid response.data"))?;
|
||||
match response.user {
|
||||
warp_graphql::queries::get_user::UserResult::UserOutput(user_output) => Ok(user_output),
|
||||
warp_graphql::queries::get_user::UserResult::Unknown => {
|
||||
Err(anyhow!("Unable to fetch user"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_user_settings(&self) -> Result<Option<SyncedUserSettings>> {
|
||||
let operation = GetUserSettings::build(GetUserSettingsVariables {
|
||||
request_context: warp_graphql::client::get_request_context(),
|
||||
});
|
||||
let response = send_graphql_request(self.base_client.as_ref(), operation, None).await?;
|
||||
match response.user {
|
||||
warp_graphql::queries::get_user_settings::UserResult::UserOutput(user_output) => {
|
||||
Ok(user_output
|
||||
.user
|
||||
.settings
|
||||
.map(|settings| SyncedUserSettings {
|
||||
is_cloud_conversation_storage_enabled: settings
|
||||
.is_cloud_conversation_storage_enabled,
|
||||
is_crash_reporting_enabled: settings.is_crash_reporting_enabled,
|
||||
is_telemetry_enabled: settings.is_telemetry_enabled,
|
||||
}))
|
||||
}
|
||||
warp_graphql::queries::get_user_settings::UserResult::Unknown => {
|
||||
Err(anyhow!("Unable to fetch user settings"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn set_is_telemetry_enabled(&self, value: bool) -> Result<()> {
|
||||
self.update_settings(
|
||||
UpdateUserSettingsInput {
|
||||
telemetry_enabled: Some(value),
|
||||
..Default::default()
|
||||
},
|
||||
"failed to set telemetry enabled",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn set_is_crash_reporting_enabled(&self, value: bool) -> Result<()> {
|
||||
self.update_settings(
|
||||
UpdateUserSettingsInput {
|
||||
crash_reporting_enabled: Some(value),
|
||||
..Default::default()
|
||||
},
|
||||
"failed to set crash reporting enabled",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn set_is_cloud_conversation_storage_enabled(&self, value: bool) -> Result<()> {
|
||||
self.update_settings(
|
||||
UpdateUserSettingsInput {
|
||||
cloud_conversation_storage_enabled: Some(value),
|
||||
..Default::default()
|
||||
},
|
||||
"failed to set cloud conversation storage enabled",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn update_user_settings(&self, input: UpdateUserSettingsInput) -> Result<()> {
|
||||
self.update_settings(input, "failed to update user settings")
|
||||
.await
|
||||
}
|
||||
|
||||
async fn set_user_is_onboarded(&self) -> Result<bool> {
|
||||
let operation = SetUserIsOnboarded::build(SetUserIsOnboardedVariables {
|
||||
request_context: warp_graphql::client::get_request_context(),
|
||||
});
|
||||
let result = send_graphql_request(self.base_client.as_ref(), operation, None)
|
||||
.await?
|
||||
.set_user_is_onboarded;
|
||||
match result {
|
||||
SetUserIsOnboardedResult::SetUserIsOnboardedOutput(_) => Ok(true),
|
||||
SetUserIsOnboardedResult::UserFacingError(error) => Err(anyhow!(
|
||||
warp_graphql::client::get_user_facing_error_message(error)
|
||||
)),
|
||||
SetUserIsOnboardedResult::Unknown => Err(anyhow!("failed to set user is onboarded")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn request_device_code(
|
||||
&self,
|
||||
) -> StdResult<oauth2::StandardDeviceAuthorizationResponse, UserAuthenticationError> {
|
||||
self.auth_session.request_device_code().await
|
||||
}
|
||||
|
||||
async fn exchange_device_access_token(
|
||||
&self,
|
||||
details: &oauth2::StandardDeviceAuthorizationResponse,
|
||||
timeout: Duration,
|
||||
) -> StdResult<FirebaseToken, UserAuthenticationError> {
|
||||
self.auth_session
|
||||
.exchange_device_access_token(details, timeout)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn list_api_keys(&self) -> Result<Vec<ApiKeyProperties>> {
|
||||
let operation = ApiKeys::build(ApiKeysVariables {
|
||||
request_context: warp_graphql::client::get_request_context(),
|
||||
});
|
||||
let response = send_graphql_request(self.base_client.as_ref(), operation, None).await?;
|
||||
match response.api_keys {
|
||||
ApiKeyPropertiesResult::ApiKeyPropertiesOutput(output) => Ok(output.api_keys),
|
||||
ApiKeyPropertiesResult::UserFacingError(error) => Err(anyhow!(
|
||||
warp_graphql::client::get_user_facing_error_message(error)
|
||||
)),
|
||||
ApiKeyPropertiesResult::Unknown => Err(anyhow!("failed to fetch API keys")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn create_api_key(
|
||||
&self,
|
||||
name: String,
|
||||
team_id: Option<cynic::Id>,
|
||||
agent_uid: Option<cynic::Id>,
|
||||
expires_at: Option<warp_graphql::scalars::Time>,
|
||||
) -> Result<GenerateApiKeyResult> {
|
||||
let operation = GenerateApiKey::build(GenerateApiKeyVariables {
|
||||
input: GenerateApiKeyInput {
|
||||
name,
|
||||
team_id,
|
||||
agent_uid,
|
||||
expires_at,
|
||||
},
|
||||
request_context: warp_graphql::client::get_request_context(),
|
||||
});
|
||||
let response = send_graphql_request(self.base_client.as_ref(), operation, None).await?;
|
||||
Ok(response.generate_api_key)
|
||||
}
|
||||
|
||||
async fn expire_api_key(&self, key_uid: &ApiKeyUid) -> Result<ExpireApiKeyResult> {
|
||||
let operation = ExpireApiKey::build(ExpireApiKeyVariables {
|
||||
key_uid: key_uid.into(),
|
||||
request_context: warp_graphql::client::get_request_context(),
|
||||
});
|
||||
let response = send_graphql_request(self.base_client.as_ref(), operation, None).await?;
|
||||
Ok(response.expire_api_key)
|
||||
}
|
||||
|
||||
async fn list_agent_identities(&self) -> Result<Vec<AgentIdentity>> {
|
||||
let response: AgentIdentitiesResponse =
|
||||
self.base_client.get_public_api("agent/identities").await?;
|
||||
Ok(response.agents)
|
||||
}
|
||||
}
|
||||
|
||||
/// Error type when retrieving a user and validating it against Firebase.
|
||||
#[derive(Error, Debug)]
|
||||
pub enum UserAuthenticationError {
|
||||
/// The user's refresh token is invalid, which can occur after the user changes
|
||||
/// a password for Google or GitHub authentication.
|
||||
#[error("Firebase returned a token error when fetching an ID token")]
|
||||
DeniedAccessToken(FirebaseError),
|
||||
/// The user's account is invalid, which can occur after the user requests
|
||||
/// account deletion under GDPR or CCPA.
|
||||
#[error("Firebase returned a user error when fetching an ID token")]
|
||||
UserAccountDisabled(FirebaseError),
|
||||
#[error("Invalid state parameter in auth redirect")]
|
||||
InvalidStateParameter,
|
||||
#[error("Missing state parameter in auth redirect")]
|
||||
MissingStateParameter,
|
||||
#[error("unexpected error occurred when fetching an ID token: {0:#}")]
|
||||
Unexpected(#[from] anyhow::Error),
|
||||
}
|
||||
|
||||
impl ErrorExt for UserAuthenticationError {
|
||||
fn is_actionable(&self) -> bool {
|
||||
match self {
|
||||
UserAuthenticationError::DeniedAccessToken(error) => {
|
||||
// If a request to our server failed because the user's refresh token
|
||||
// has expired, they should reauthenticate, but there is no value in
|
||||
// reporting this back to us.
|
||||
log::info!("ignoring denied access token error: {error:#}");
|
||||
false
|
||||
}
|
||||
UserAuthenticationError::UserAccountDisabled(error) => {
|
||||
// If the user's account is disabled, they cannot make requests.
|
||||
log::info!("ignoring user account disabled error: {error:#}");
|
||||
false
|
||||
}
|
||||
UserAuthenticationError::Unexpected(error) => error.is_actionable(),
|
||||
UserAuthenticationError::InvalidStateParameter
|
||||
| UserAuthenticationError::MissingStateParameter => {
|
||||
// These errors remain actionable because a surplus could indicate a problem in
|
||||
// the login flow, although an attempt to spoof the `state` variable is not actionable.
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
register_error!(UserAuthenticationError);
|
||||
|
||||
impl From<FirebaseError> for UserAuthenticationError {
|
||||
fn from(error: FirebaseError) -> Self {
|
||||
// These Firebase errors indicate that the user's token is in an errored state
|
||||
// and that the user likely just needs to log in again.
|
||||
const SOFT_ERRORS: &[&str] = &[
|
||||
"TOKEN_EXPIRED",
|
||||
"INVALID_REFRESH_TOKEN",
|
||||
"MISSING_REFRESH_TOKEN",
|
||||
];
|
||||
// These Firebase errors indicate that the user's account is in an errored state
|
||||
// and that the user likely can no longer sign in with it.
|
||||
const HARD_ERRORS: &[&str] = &["USER_DISABLED", "USER_NOT_FOUND"];
|
||||
if SOFT_ERRORS.contains(&error.message.as_str()) {
|
||||
UserAuthenticationError::DeniedAccessToken(error)
|
||||
} else if HARD_ERRORS.contains(&error.message.as_str()) {
|
||||
UserAuthenticationError::UserAccountDisabled(error)
|
||||
} else {
|
||||
UserAuthenticationError::Unexpected(
|
||||
anyhow::Error::from(error)
|
||||
.context("Failed to exchange refresh token with access token."),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Error type when minting a new custom token for an anonymous user.
|
||||
#[derive(Error, Debug)]
|
||||
pub enum MintCustomTokenError {
|
||||
#[error("Received a user facing error: {0}")]
|
||||
UserFacingError(String),
|
||||
#[error("Failed to create new custom token with unknown error")]
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "mod_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
use warp_graphql::mutations::update_user_settings::UpdateUserSettingsResult;
|
||||
|
||||
use super::AuthClientImpl;
|
||||
|
||||
#[test]
|
||||
fn unknown_settings_results_preserve_operation_context() {
|
||||
for expected_message in [
|
||||
"failed to set telemetry enabled",
|
||||
"failed to set crash reporting enabled",
|
||||
"failed to set cloud conversation storage enabled",
|
||||
"failed to update user settings",
|
||||
] {
|
||||
let error = AuthClientImpl::on_settings_updated(
|
||||
UpdateUserSettingsResult::Unknown,
|
||||
expected_message,
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(error.to_string(), expected_message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
use std::fmt;
|
||||
use std::result::Result as StdResult;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context as _, Result, bail};
|
||||
use firebase::FetchAccessTokenResponse;
|
||||
use instant::Duration;
|
||||
use oauth2::TokenResponse as _;
|
||||
use url::Url;
|
||||
use galaxy_core::channel::ChannelState;
|
||||
use warp_server_auth::auth_state::AuthState;
|
||||
use warp_server_auth::credentials::{
|
||||
AuthToken, Credentials, FirebaseToken, LoginToken, RefreshToken,
|
||||
};
|
||||
use warp_server_auth::user::FirebaseAuthTokens;
|
||||
use galaxyui_core::r#async::{BoxFuture, Timer};
|
||||
|
||||
use super::UserAuthenticationError;
|
||||
|
||||
const FETCH_ACCESS_TOKEN_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
/// Authentication and authenticated-transport conditions observed by shared client code.
|
||||
#[derive(Clone)]
|
||||
pub enum AuthEvent {
|
||||
/// A staging API call was blocked, which may indicate a firewall misconfiguration.
|
||||
StagingAccessBlocked,
|
||||
/// The user's access token was invalid, so they need to reauthenticate.
|
||||
NeedsReauth,
|
||||
/// The user's account has been disabled.
|
||||
UserAccountDisabled,
|
||||
/// The current bearer token was refreshed.
|
||||
AccessTokenRefreshed {
|
||||
#[cfg_attr(target_family = "wasm", allow(dead_code))]
|
||||
token: String,
|
||||
},
|
||||
/// An Identity-Aware Proxy challenge was received.
|
||||
IapChallengeReceived,
|
||||
}
|
||||
|
||||
impl fmt::Debug for AuthEvent {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::StagingAccessBlocked => f.write_str("StagingAccessBlocked"),
|
||||
Self::NeedsReauth => f.write_str("NeedsReauth"),
|
||||
Self::UserAccountDisabled => f.write_str("UserAccountDisabled"),
|
||||
Self::AccessTokenRefreshed { .. } => f
|
||||
.debug_struct("AccessTokenRefreshed")
|
||||
.field("token", &"<redacted>")
|
||||
.finish(),
|
||||
Self::IapChallengeReceived => f.write_str("IapChallengeReceived"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The OAuth client type configured for Warp's device authorization endpoints.
|
||||
type OAuth2Client = oauth2::basic::BasicClient<
|
||||
oauth2::EndpointNotSet,
|
||||
oauth2::EndpointSet,
|
||||
oauth2::EndpointNotSet,
|
||||
oauth2::EndpointNotSet,
|
||||
oauth2::EndpointSet,
|
||||
>;
|
||||
|
||||
/// Reusable authentication-session mechanics for server clients.
|
||||
///
|
||||
/// An `AuthSession` combines authentication state with the HTTP transport required to
|
||||
/// exchange credentials, refresh access tokens, and complete OAuth device authorization.
|
||||
/// Changes in authentication state that may require reactions from application logic are
|
||||
/// emitted through an [`AuthEvent`] channel.
|
||||
pub struct AuthSession {
|
||||
client: Arc<http_client::Client>,
|
||||
auth_state: Arc<AuthState>,
|
||||
event_sender: async_channel::Sender<AuthEvent>,
|
||||
oauth_client: OAuth2Client,
|
||||
}
|
||||
|
||||
impl AuthSession {
|
||||
pub fn new(
|
||||
client: Arc<http_client::Client>,
|
||||
auth_state: Arc<AuthState>,
|
||||
event_sender: async_channel::Sender<AuthEvent>,
|
||||
) -> Self {
|
||||
Self {
|
||||
client,
|
||||
auth_state,
|
||||
event_sender,
|
||||
oauth_client: Self::create_oauth_client(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn allowed_to_refresh_token(&self) -> bool {
|
||||
self.auth_state
|
||||
.credentials()
|
||||
.is_none_or(|credentials| !credentials.is_externally_managed())
|
||||
}
|
||||
|
||||
pub async fn get_or_refresh_access_token(&self) -> Result<AuthToken> {
|
||||
if cfg!(feature = "skip_login") {
|
||||
bail!("skip_login enabled; failing all authenticated requests");
|
||||
}
|
||||
|
||||
let Some(credentials) = self.auth_state.credentials() else {
|
||||
bail!("missing authentication credentials");
|
||||
};
|
||||
|
||||
match credentials {
|
||||
Credentials::ApiKey { key, .. } => Ok(AuthToken::ApiKey(key)),
|
||||
Credentials::Bearer(token) => Ok(AuthToken::Bearer(token)),
|
||||
Credentials::Firebase(auth_tokens) => {
|
||||
let expiration_time = auth_tokens.expiration_time;
|
||||
|
||||
// Generate a new ID token if the token has expired or will expire in the
|
||||
// next five minutes. This matches the behavior of the Firebase Auth SDK.
|
||||
if chrono::Local::now().fixed_offset() + chrono::Duration::minutes(5)
|
||||
>= expiration_time
|
||||
{
|
||||
let refresh_token = auth_tokens.refresh_token.clone();
|
||||
let firebase_token = FirebaseToken::Refresh(RefreshToken::new(refresh_token));
|
||||
let result = self.fetch_auth_tokens(firebase_token).await;
|
||||
|
||||
if let Err(UserAuthenticationError::DeniedAccessToken(_)) = result {
|
||||
let _ = self.event_sender.send(AuthEvent::NeedsReauth).await;
|
||||
}
|
||||
let new_firebase_token_info = result?;
|
||||
self.auth_state
|
||||
.update_firebase_tokens(new_firebase_token_info.clone());
|
||||
let _ = self
|
||||
.event_sender
|
||||
.send(AuthEvent::AccessTokenRefreshed {
|
||||
token: new_firebase_token_info.id_token.clone(),
|
||||
})
|
||||
.await;
|
||||
Ok(AuthToken::Firebase(new_firebase_token_info.id_token))
|
||||
} else {
|
||||
Ok(AuthToken::Firebase(auth_tokens.id_token))
|
||||
}
|
||||
}
|
||||
Credentials::SessionCookie => Ok(AuthToken::NoAuth),
|
||||
#[cfg(any(feature = "integration_tests", feature = "skip_login"))]
|
||||
Credentials::Test => Ok(AuthToken::NoAuth),
|
||||
}
|
||||
}
|
||||
|
||||
/// Exchanges a long-lived token for fresh [`Credentials`].
|
||||
pub async fn exchange_credentials(
|
||||
&self,
|
||||
token: LoginToken,
|
||||
) -> StdResult<Credentials, UserAuthenticationError> {
|
||||
match token {
|
||||
LoginToken::Firebase(firebase_token) => {
|
||||
let tokens = self.fetch_auth_tokens(firebase_token).await?;
|
||||
Ok(Credentials::Firebase(tokens))
|
||||
}
|
||||
LoginToken::ApiKey(key) => Ok(Credentials::ApiKey {
|
||||
key,
|
||||
owner_type: None,
|
||||
}),
|
||||
LoginToken::SessionCookie => Ok(Credentials::SessionCookie),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn request_device_code(
|
||||
&self,
|
||||
) -> StdResult<oauth2::StandardDeviceAuthorizationResponse, UserAuthenticationError> {
|
||||
self.oauth_client
|
||||
.exchange_device_code()
|
||||
.request_async(self.client.as_ref())
|
||||
.await
|
||||
.context("Failed to generate device code")
|
||||
.map_err(UserAuthenticationError::Unexpected)
|
||||
}
|
||||
|
||||
pub async fn exchange_device_access_token(
|
||||
&self,
|
||||
details: &oauth2::StandardDeviceAuthorizationResponse,
|
||||
timeout: Duration,
|
||||
) -> StdResult<FirebaseToken, UserAuthenticationError> {
|
||||
let result = self
|
||||
.oauth_client
|
||||
.exchange_device_access_token(details)
|
||||
.request_async(
|
||||
self.client.as_ref(),
|
||||
|delay| async move {
|
||||
let _ = Timer::after(delay).await;
|
||||
},
|
||||
Some(timeout),
|
||||
)
|
||||
.await
|
||||
.context("Unable to obtain access token")
|
||||
.map_err(UserAuthenticationError::Unexpected)?;
|
||||
// Firebase does not directly support the device flow, so the server mints a
|
||||
// short-lived custom access token that can be exchanged for a refresh token.
|
||||
Ok(FirebaseToken::Custom(
|
||||
result.access_token().secret().to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
fn create_oauth_client() -> OAuth2Client {
|
||||
let server_root =
|
||||
Url::parse(&ChannelState::server_root_url()).expect("Server root URL must be valid");
|
||||
let token_url = server_root
|
||||
.join("/api/v1/oauth/token")
|
||||
.expect("Invalid token URL");
|
||||
let device_url = server_root
|
||||
.join("/api/v1/oauth/device/auth")
|
||||
.expect("Invalid device URL");
|
||||
|
||||
oauth2::basic::BasicClient::new(oauth2::ClientId::new("warp-cli".to_string()))
|
||||
.set_token_uri(oauth2::TokenUrl::from_url(token_url))
|
||||
.set_device_authorization_url(oauth2::DeviceAuthorizationUrl::from_url(device_url))
|
||||
}
|
||||
|
||||
fn fetch_auth_tokens(
|
||||
&self,
|
||||
token: FirebaseToken,
|
||||
) -> BoxFuture<'static, StdResult<FirebaseAuthTokens, UserAuthenticationError>> {
|
||||
let client = self.client.clone();
|
||||
Box::pin(async move {
|
||||
let firebase_api_key = ChannelState::firebase_api_key();
|
||||
let url = token.access_token_url(&firebase_api_key);
|
||||
let request_body = token.access_token_request_body();
|
||||
let proxy_url = token.proxy_url(&ChannelState::server_root_url(), &firebase_api_key);
|
||||
let response = match client
|
||||
.post(&url)
|
||||
.form(&request_body)
|
||||
.timeout(FETCH_ACCESS_TOKEN_TIMEOUT)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(response) => match response.error_for_status_ref() {
|
||||
Ok(_) => Ok(response),
|
||||
Err(error) => {
|
||||
log::warn!(
|
||||
"Request to firebase to fetch access token completed, but was unsuccessful: {error:?}"
|
||||
);
|
||||
|
||||
Self::fetch_access_token_via_proxy(client, &request_body, proxy_url).await
|
||||
}
|
||||
},
|
||||
Err(error) => {
|
||||
log::warn!(
|
||||
"Failed to make response to firebase to fetch access token: {error:?}"
|
||||
);
|
||||
|
||||
Self::fetch_access_token_via_proxy(client, &request_body, proxy_url).await
|
||||
}
|
||||
}?;
|
||||
|
||||
let response = response
|
||||
.json::<FetchAccessTokenResponse>()
|
||||
.await
|
||||
.map_err(anyhow::Error::from)?;
|
||||
match response {
|
||||
FetchAccessTokenResponse::Success {
|
||||
id_token,
|
||||
expires_in,
|
||||
refresh_token,
|
||||
} => Ok(FirebaseAuthTokens::from_response(
|
||||
id_token,
|
||||
refresh_token,
|
||||
expires_in,
|
||||
)?),
|
||||
FetchAccessTokenResponse::Error { error } => Err(error.into()),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn fetch_access_token_via_proxy<'a>(
|
||||
client: Arc<http_client::Client>,
|
||||
request_body: &'a [(&'a str, &'a str)],
|
||||
proxy_url: String,
|
||||
) -> BoxFuture<'a, Result<http_client::Response>> {
|
||||
Box::pin(async move {
|
||||
client
|
||||
.post(&proxy_url)
|
||||
.form(request_body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(anyhow::Error::from)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "session_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,67 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::Utc;
|
||||
use futures::executor::block_on;
|
||||
use warp_server_auth::auth_state::AuthState;
|
||||
use warp_server_auth::credentials::{AuthToken, Credentials, LoginToken};
|
||||
use warp_server_auth::user::FirebaseAuthTokens;
|
||||
|
||||
use super::AuthSession;
|
||||
|
||||
fn session_with_state(
|
||||
auth_state: Arc<AuthState>,
|
||||
) -> (AuthSession, async_channel::Receiver<super::AuthEvent>) {
|
||||
let (event_sender, event_receiver) = async_channel::unbounded();
|
||||
let session = AuthSession::new(
|
||||
Arc::new(http_client::Client::new()),
|
||||
auth_state,
|
||||
event_sender,
|
||||
);
|
||||
(session, event_receiver)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bearer_credentials_are_returned_without_session_refresh_events() {
|
||||
let auth_state = Arc::new(AuthState::new_logged_out_for_test());
|
||||
auth_state.set_credentials(Some(Credentials::Bearer("daemon-token".to_string())));
|
||||
let (session, event_receiver) = session_with_state(auth_state);
|
||||
|
||||
assert!(!session.allowed_to_refresh_token());
|
||||
let token = block_on(session.get_or_refresh_access_token()).unwrap();
|
||||
|
||||
assert!(matches!(token, AuthToken::Bearer(token) if token == "daemon-token"));
|
||||
assert!(event_receiver.try_recv().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unexpired_firebase_credentials_return_cached_token_without_refresh_events() {
|
||||
let auth_state = Arc::new(AuthState::new_logged_out_for_test());
|
||||
auth_state.set_credentials(Some(Credentials::Firebase(FirebaseAuthTokens {
|
||||
id_token: "cached-token".to_string(),
|
||||
refresh_token: "refresh-token".to_string(),
|
||||
expiration_time: Utc::now().fixed_offset() + chrono::Duration::hours(1),
|
||||
})));
|
||||
let (session, event_receiver) = session_with_state(auth_state);
|
||||
|
||||
let token = block_on(session.get_or_refresh_access_token()).unwrap();
|
||||
|
||||
assert!(matches!(token, AuthToken::Firebase(token) if token == "cached-token"));
|
||||
assert!(event_receiver.try_recv().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_key_exchange_defers_owner_type_until_user_properties_are_fetched() {
|
||||
let auth_state = Arc::new(AuthState::new_logged_out_for_test());
|
||||
let (session, _) = session_with_state(auth_state);
|
||||
|
||||
let credentials =
|
||||
block_on(session.exchange_credentials(LoginToken::ApiKey("api-key".to_string()))).unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
credentials,
|
||||
Credentials::ApiKey {
|
||||
key,
|
||||
owner_type: None
|
||||
} if key == "api-key"
|
||||
));
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
use std::{fmt, sync::LazyLock};
|
||||
use std::fmt;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
|
||||
|
||||
@@ -0,0 +1,430 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context as _, Result};
|
||||
use futures::StreamExt as _;
|
||||
use instant::Duration;
|
||||
use parking_lot::{Mutex, RwLock};
|
||||
use warp_graphql::client::RequestOptions;
|
||||
use warp_server_auth::auth_state::AuthState;
|
||||
use warp_server_auth::credentials::AuthToken;
|
||||
|
||||
use crate::auth::{AuthEvent, AuthSession, UserUid};
|
||||
|
||||
/// Header key for the ambient agent workload token attached to authenticated requests.
|
||||
pub const AMBIENT_WORKLOAD_TOKEN_HEADER: &str = "X-Warp-Ambient-Workload-Token";
|
||||
|
||||
/// Header key for the cloud agent task ID attached to ambient-agent requests.
|
||||
pub const CLOUD_AGENT_ID_HEADER: &str = "X-Warp-Cloud-Agent-ID";
|
||||
|
||||
/// Header used to communicate the source of an agent run.
|
||||
pub const AGENT_SOURCE_HEADER: &str = "X-Oz-Api-Source";
|
||||
/// Header used to route agent-mode eval requests to a selected eval user.
|
||||
pub const EVAL_USER_ID_HEADER: &str = "X-Eval-User-ID";
|
||||
|
||||
/// IDs in the staging database that were created specifically for evals.
|
||||
///
|
||||
/// Keep this list in sync with `script/populate_agent_mode_eval_user.sql` in warp-server.
|
||||
#[cfg(feature = "agent_mode_evals")]
|
||||
const EVAL_USER_IDS: [i32; 11] = [
|
||||
2162, 2164, 2165, 2166, 2167, 2168, 2169, 2172, 2173, 2174, 2175,
|
||||
];
|
||||
|
||||
/// Duration for which an ambient agent workload token is valid.
|
||||
const AMBIENT_WORKLOAD_TOKEN_DURATION: Duration = Duration::from_secs(3 * 60 * 60);
|
||||
|
||||
/// Selects whether a contextual header is inherited, set, or omitted for one request.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum HeaderOverride<T> {
|
||||
Inherit,
|
||||
Set(T),
|
||||
Omit,
|
||||
}
|
||||
|
||||
/// Describes the request-local ambient agent headers that are safe to vary by endpoint.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct AmbientHeaderPolicy {
|
||||
pub workload_token: HeaderOverride<String>,
|
||||
pub cloud_agent_id: HeaderOverride<String>,
|
||||
pub agent_source: HeaderOverride<String>,
|
||||
}
|
||||
|
||||
impl AmbientHeaderPolicy {
|
||||
/// Inherits every ambient agent contextual header configured on the client.
|
||||
pub fn inherit_all() -> Self {
|
||||
Self {
|
||||
workload_token: HeaderOverride::Inherit,
|
||||
cloud_agent_id: HeaderOverride::Inherit,
|
||||
agent_source: HeaderOverride::Inherit,
|
||||
}
|
||||
}
|
||||
|
||||
/// Replaces only the cloud-agent task identifier for one task-scoped request.
|
||||
pub fn for_task(task_id: impl Into<String>) -> Self {
|
||||
Self {
|
||||
cloud_agent_id: HeaderOverride::Set(task_id.into()),
|
||||
..Self::inherit_all()
|
||||
}
|
||||
}
|
||||
|
||||
/// Includes workload-token context without cloud-agent or source context.
|
||||
pub fn workload_only() -> Self {
|
||||
Self {
|
||||
workload_token: HeaderOverride::Inherit,
|
||||
cloud_agent_id: HeaderOverride::Omit,
|
||||
agent_source: HeaderOverride::Omit,
|
||||
}
|
||||
}
|
||||
|
||||
/// Omits all ambient agent contextual headers for a request.
|
||||
pub fn omit_all() -> Self {
|
||||
Self {
|
||||
workload_token: HeaderOverride::Omit,
|
||||
cloud_agent_id: HeaderOverride::Omit,
|
||||
agent_source: HeaderOverride::Omit,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AmbientHeaderPolicy {
|
||||
fn default() -> Self {
|
||||
Self::inherit_all()
|
||||
}
|
||||
}
|
||||
|
||||
/// Provides GraphQL path routing that applies independently of authentication.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct GraphqlRoutingConfig {
|
||||
pub path_prefix: Option<String>,
|
||||
}
|
||||
|
||||
/// Provides headers added only to session-authenticated GraphQL operations.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct AuthenticatedGraphqlConfig {
|
||||
pub headers: HashMap<String, String>,
|
||||
}
|
||||
|
||||
/// Owns shared transport, authentication, and authenticated request decoration.
|
||||
pub struct BaseClient {
|
||||
client: Arc<http_client::Client>,
|
||||
auth_state: Arc<AuthState>,
|
||||
event_sender: async_channel::Sender<AuthEvent>,
|
||||
auth_session: Arc<AuthSession>,
|
||||
ambient_workload_token: Arc<Mutex<Option<warp_isolation_platform::WorkloadToken>>>,
|
||||
ambient_agent_task_id: Arc<RwLock<Option<String>>>,
|
||||
agent_source: Option<String>,
|
||||
graphql_routing: GraphqlRoutingConfig,
|
||||
authenticated_graphql: AuthenticatedGraphqlConfig,
|
||||
iap_token_provider: Option<Arc<dyn http_client::iap::IapTokenProvider>>,
|
||||
#[cfg(feature = "agent_mode_evals")]
|
||||
eval_user_id: Option<i32>,
|
||||
}
|
||||
|
||||
impl BaseClient {
|
||||
pub fn new(
|
||||
client: Arc<http_client::Client>,
|
||||
auth_state: Arc<AuthState>,
|
||||
event_sender: async_channel::Sender<AuthEvent>,
|
||||
agent_source: Option<String>,
|
||||
graphql_routing: GraphqlRoutingConfig,
|
||||
mut authenticated_graphql: AuthenticatedGraphqlConfig,
|
||||
iap_token_provider: Option<Arc<dyn http_client::iap::IapTokenProvider>>,
|
||||
) -> Self {
|
||||
authenticated_graphql.headers.retain(|name, _| {
|
||||
if Self::is_reserved_authenticated_graphql_header(name) {
|
||||
log::warn!("Ignoring reserved authenticated GraphQL header configuration: {name}");
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
});
|
||||
// We generate one random user ID per client so evals can run in parallel.
|
||||
#[cfg(feature = "agent_mode_evals")]
|
||||
let eval_user_id = {
|
||||
use rand::Rng as _;
|
||||
|
||||
Some(EVAL_USER_IDS[rand::thread_rng().gen_range(0..EVAL_USER_IDS.len())])
|
||||
};
|
||||
#[cfg(feature = "agent_mode_evals")]
|
||||
if let Some(eval_user_id) = eval_user_id {
|
||||
authenticated_graphql
|
||||
.headers
|
||||
.insert(EVAL_USER_ID_HEADER.to_string(), eval_user_id.to_string());
|
||||
}
|
||||
let auth_session = Arc::new(AuthSession::new(
|
||||
client.clone(),
|
||||
auth_state.clone(),
|
||||
event_sender.clone(),
|
||||
));
|
||||
Self {
|
||||
client,
|
||||
auth_state,
|
||||
event_sender,
|
||||
auth_session,
|
||||
ambient_workload_token: Arc::new(Mutex::new(None)),
|
||||
ambient_agent_task_id: Arc::new(RwLock::new(None)),
|
||||
agent_source,
|
||||
graphql_routing,
|
||||
authenticated_graphql,
|
||||
iap_token_provider,
|
||||
#[cfg(feature = "agent_mode_evals")]
|
||||
eval_user_id,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns whether authenticated GraphQL decoration would override BaseClient-owned headers.
|
||||
fn is_reserved_authenticated_graphql_header(name: &str) -> bool {
|
||||
#[cfg(feature = "agent_mode_evals")]
|
||||
if name.eq_ignore_ascii_case(EVAL_USER_ID_HEADER) {
|
||||
return true;
|
||||
}
|
||||
[
|
||||
http::header::AUTHORIZATION.as_str(),
|
||||
http::header::CONTENT_TYPE.as_str(),
|
||||
http::header::CONTENT_LENGTH.as_str(),
|
||||
http_client::iap::IAP_PROXY_AUTH_HEADER,
|
||||
AMBIENT_WORKLOAD_TOKEN_HEADER,
|
||||
CLOUD_AGENT_ID_HEADER,
|
||||
AGENT_SOURCE_HEADER,
|
||||
]
|
||||
.iter()
|
||||
.any(|reserved| name.eq_ignore_ascii_case(reserved))
|
||||
}
|
||||
|
||||
/// Returns the shared HTTP client for request construction.
|
||||
pub fn http_client(&self) -> &http_client::Client {
|
||||
self.client.as_ref()
|
||||
}
|
||||
|
||||
/// Returns an owned handle to the shared HTTP client for GraphQL operations.
|
||||
pub fn owned_http_client(&self) -> Arc<http_client::Client> {
|
||||
self.client.clone()
|
||||
}
|
||||
|
||||
pub fn auth_session(&self) -> Arc<AuthSession> {
|
||||
self.auth_session.clone()
|
||||
}
|
||||
|
||||
pub fn anonymous_id(&self) -> String {
|
||||
self.auth_state.anonymous_id()
|
||||
}
|
||||
|
||||
pub fn user_id(&self) -> Option<UserUid> {
|
||||
self.auth_state.user_id()
|
||||
}
|
||||
|
||||
/// Returns the eval user selected for this client, if eval routing is enabled.
|
||||
pub fn eval_user_id(&self) -> Option<i32> {
|
||||
#[cfg(feature = "agent_mode_evals")]
|
||||
{
|
||||
self.eval_user_id
|
||||
}
|
||||
#[cfg(not(feature = "agent_mode_evals"))]
|
||||
{
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn access_token_ignoring_validity(&self) -> Option<String> {
|
||||
self.auth_state.get_access_token_ignoring_validity()
|
||||
}
|
||||
|
||||
pub fn allowed_to_refresh_token(&self) -> bool {
|
||||
self.auth_session.allowed_to_refresh_token()
|
||||
}
|
||||
|
||||
pub async fn get_or_refresh_access_token(&self) -> Result<AuthToken> {
|
||||
self.auth_session.get_or_refresh_access_token().await
|
||||
}
|
||||
|
||||
/// Returns a sender for asynchronous work that emits auth events without borrowing this client.
|
||||
pub fn event_sender(&self) -> async_channel::Sender<AuthEvent> {
|
||||
self.event_sender.clone()
|
||||
}
|
||||
/// Sends an auth event from synchronous client-owned response handling.
|
||||
pub fn send_auth_event(
|
||||
&self,
|
||||
event: AuthEvent,
|
||||
) -> Result<(), async_channel::TrySendError<AuthEvent>> {
|
||||
self.event_sender.try_send(event)
|
||||
}
|
||||
|
||||
pub fn is_auth_refresh_allowed(&self) -> bool {
|
||||
self.allowed_to_refresh_token()
|
||||
}
|
||||
|
||||
/// Sets the default cloud-agent identifier inherited by subsequent requests.
|
||||
pub fn set_ambient_agent_task_id(&self, task_id: Option<String>) {
|
||||
*self.ambient_agent_task_id.write() = task_id;
|
||||
}
|
||||
|
||||
/// Returns an ambient agent workload token when the current runtime can issue one.
|
||||
pub async fn get_or_create_ambient_workload_token(&self) -> Result<Option<String>> {
|
||||
if cfg!(target_family = "wasm") {
|
||||
return Ok(None);
|
||||
}
|
||||
{
|
||||
let cached = self.ambient_workload_token.lock();
|
||||
if let Some(token) = cached.as_ref() {
|
||||
let is_valid = token.expires_at.is_none_or(|expires_at| {
|
||||
chrono::Utc::now() + chrono::Duration::minutes(5) < expires_at
|
||||
});
|
||||
if is_valid {
|
||||
return Ok(Some(token.token.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
let workload_token = match warp_isolation_platform::issue_workload_token(Some(
|
||||
AMBIENT_WORKLOAD_TOKEN_DURATION,
|
||||
))
|
||||
.await
|
||||
{
|
||||
Ok(token) => token,
|
||||
Err(warp_isolation_platform::IsolationPlatformError::NoIsolationPlatformDetected) => {
|
||||
return Ok(None);
|
||||
}
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
let token = workload_token.token.clone();
|
||||
*self.ambient_workload_token.lock() = Some(workload_token);
|
||||
Ok(Some(token))
|
||||
}
|
||||
|
||||
/// Resolves request-local ambient agent policy into wire headers.
|
||||
pub async fn ambient_headers(
|
||||
&self,
|
||||
policy: AmbientHeaderPolicy,
|
||||
) -> Result<Vec<(String, String)>> {
|
||||
let workload_token = match policy.workload_token {
|
||||
HeaderOverride::Inherit => self
|
||||
.get_or_create_ambient_workload_token()
|
||||
.await
|
||||
.context("Failed to get ambient agent workload token")?,
|
||||
HeaderOverride::Set(token) => Some(token),
|
||||
HeaderOverride::Omit => None,
|
||||
};
|
||||
let cloud_agent_id = match policy.cloud_agent_id {
|
||||
HeaderOverride::Inherit => self.ambient_agent_task_id.read().clone(),
|
||||
HeaderOverride::Set(task_id) => Some(task_id),
|
||||
HeaderOverride::Omit => None,
|
||||
};
|
||||
let agent_source = match policy.agent_source {
|
||||
HeaderOverride::Inherit => self.agent_source.clone(),
|
||||
HeaderOverride::Set(source) => Some(source),
|
||||
HeaderOverride::Omit => None,
|
||||
};
|
||||
|
||||
Ok(workload_token
|
||||
.map(|token| (AMBIENT_WORKLOAD_TOKEN_HEADER.to_string(), token))
|
||||
.into_iter()
|
||||
.chain(cloud_agent_id.map(|id| (CLOUD_AGENT_ID_HEADER.to_string(), id)))
|
||||
.chain(agent_source.map(|source| (AGENT_SOURCE_HEADER.to_string(), source)))
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Returns GraphQL options for bootstrap or explicit-token operations.
|
||||
pub fn graphql_request_options_with_token(&self, auth_token: Option<String>) -> RequestOptions {
|
||||
RequestOptions {
|
||||
auth_token,
|
||||
path_prefix: self.graphql_routing.path_prefix.clone(),
|
||||
..RequestOptions::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns GraphQL options for a session-authenticated operation.
|
||||
pub async fn graphql_request_options(
|
||||
&self,
|
||||
timeout: Option<Duration>,
|
||||
) -> Result<RequestOptions> {
|
||||
let auth_token = self
|
||||
.get_or_refresh_access_token()
|
||||
.await
|
||||
.context("Failed to get access token for GraphQL request")?;
|
||||
let mut options = self.graphql_request_options_with_token(auth_token.bearer_token());
|
||||
options.timeout = timeout;
|
||||
options.headers = self.authenticated_graphql.headers.clone();
|
||||
options.headers.extend(
|
||||
self.ambient_headers(AmbientHeaderPolicy::inherit_all())
|
||||
.await?,
|
||||
);
|
||||
Ok(options)
|
||||
}
|
||||
|
||||
/// Notifies the application when an enabled IAP-backed request receives an IAP challenge.
|
||||
pub fn observe_iap_challenge(&self, response: &http_client::Response) -> bool {
|
||||
if self.iap_token_provider.is_none()
|
||||
|| !http_client::iap::is_iap_challenge(response.status(), response.headers())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
log::warn!(
|
||||
"Received IAP challenge (status {}); notifying IapManager",
|
||||
response.status()
|
||||
);
|
||||
if let Err(error) = self.send_auth_event(AuthEvent::IapChallengeReceived) {
|
||||
log::warn!("Failed to enqueue IapChallengeReceived event: {error}");
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Wraps an eventsource stream so IAP challenges notify the application without changing the
|
||||
/// original stream result or reconnecting it.
|
||||
pub fn wrap_eventsource_with_iap_detection(
|
||||
&self,
|
||||
stream: http_client::EventSourceStream,
|
||||
) -> http_client::EventSourceStream {
|
||||
if self.iap_token_provider.is_none() {
|
||||
return stream;
|
||||
}
|
||||
let event_sender = self.event_sender();
|
||||
let wrapped = stream.map(move |event| {
|
||||
if let Err(reqwest_eventsource::Error::InvalidStatusCode(status, ref response)) = event
|
||||
&& http_client::iap::is_iap_challenge(status, response.headers())
|
||||
{
|
||||
log::warn!(
|
||||
"Received IAP challenge on eventsource (status {status}); notifying IapManager"
|
||||
);
|
||||
if let Err(error) = event_sender.try_send(AuthEvent::IapChallengeReceived) {
|
||||
log::warn!(
|
||||
"Failed to enqueue IapChallengeReceived event from eventsource: {error}"
|
||||
);
|
||||
}
|
||||
}
|
||||
event
|
||||
});
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(target_family = "wasm")] {
|
||||
wrapped.boxed_local()
|
||||
} else {
|
||||
wrapped.boxed()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Inspects a WebSocket handshake error for an IAP challenge and notifies the application.
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub fn report_ws_iap_challenge(&self, error: &anyhow::Error) {
|
||||
if self.iap_token_provider.is_none() || !crate::iap::ws_connect_is_iap_challenge(error) {
|
||||
return;
|
||||
}
|
||||
log::warn!("Received IAP challenge on websocket handshake; notifying IapManager");
|
||||
if let Err(error) = self.send_auth_event(AuthEvent::IapChallengeReceived) {
|
||||
log::warn!("Failed to enqueue IapChallengeReceived: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_family = "wasm")]
|
||||
pub fn report_ws_iap_challenge(&self, _error: &anyhow::Error) {}
|
||||
|
||||
/// Returns the current IAP proxy authorization header for transports outside the HTTP client.
|
||||
pub fn iap_proxy_auth_header(&self) -> Option<(&'static str, String)> {
|
||||
self.iap_token_provider
|
||||
.as_ref()?
|
||||
.cached_token()
|
||||
.map(|token| http_client::iap::proxy_auth_header(&token))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "base_client_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,218 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use futures::executor::block_on;
|
||||
use warp_server_auth::auth_state::AuthState;
|
||||
|
||||
use super::{
|
||||
AGENT_SOURCE_HEADER, AMBIENT_WORKLOAD_TOKEN_HEADER, AmbientHeaderPolicy,
|
||||
AuthenticatedGraphqlConfig, BaseClient, CLOUD_AGENT_ID_HEADER, GraphqlRoutingConfig,
|
||||
HeaderOverride,
|
||||
};
|
||||
#[cfg(feature = "agent_mode_evals")]
|
||||
use super::{EVAL_USER_ID_HEADER, EVAL_USER_IDS};
|
||||
|
||||
struct StaticIapTokenProvider;
|
||||
|
||||
impl http_client::iap::IapTokenProvider for StaticIapTokenProvider {
|
||||
fn cached_token(&self) -> Option<String> {
|
||||
Some("iap-token".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn client() -> BaseClient {
|
||||
let (event_sender, _) = async_channel::unbounded();
|
||||
let mut authenticated_headers = HashMap::new();
|
||||
authenticated_headers.insert("X-Test-Authenticated".to_string(), "true".to_string());
|
||||
BaseClient::new(
|
||||
Arc::new(http_client::Client::new()),
|
||||
Arc::new(AuthState::new_for_test()),
|
||||
event_sender,
|
||||
Some("cloud_mode".to_string()),
|
||||
GraphqlRoutingConfig {
|
||||
path_prefix: Some("/routing-only".to_string()),
|
||||
},
|
||||
AuthenticatedGraphqlConfig {
|
||||
headers: authenticated_headers,
|
||||
},
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iap_proxy_auth_header_uses_configured_provider() {
|
||||
let (event_sender, _) = async_channel::unbounded();
|
||||
let client = BaseClient::new(
|
||||
Arc::new(http_client::Client::new()),
|
||||
Arc::new(AuthState::new_for_test()),
|
||||
event_sender,
|
||||
None,
|
||||
GraphqlRoutingConfig::default(),
|
||||
AuthenticatedGraphqlConfig::default(),
|
||||
Some(Arc::new(StaticIapTokenProvider)),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
client.iap_proxy_auth_header(),
|
||||
Some((
|
||||
http_client::iap::IAP_PROXY_AUTH_HEADER,
|
||||
"Bearer iap-token".to_string()
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(feature = "agent_mode_evals")]
|
||||
#[test]
|
||||
fn eval_user_id_is_selected_once_and_used_for_authenticated_graphql() {
|
||||
let client = client();
|
||||
let eval_user_id = client.eval_user_id().unwrap();
|
||||
|
||||
assert!(EVAL_USER_IDS.contains(&eval_user_id));
|
||||
|
||||
let options = block_on(client.graphql_request_options(None)).unwrap();
|
||||
let eval_user_id = eval_user_id.to_string();
|
||||
assert_eq!(
|
||||
options.headers.get(EVAL_USER_ID_HEADER).map(String::as_str),
|
||||
Some(eval_user_id.as_str())
|
||||
);
|
||||
assert_eq!(
|
||||
client.eval_user_id().map(|id| id.to_string()),
|
||||
Some(eval_user_id)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_token_graphql_options_route_without_authenticated_headers() {
|
||||
let client = client();
|
||||
client.set_ambient_agent_task_id(Some("ambient-task".to_string()));
|
||||
|
||||
let options = client.graphql_request_options_with_token(Some("token".to_string()));
|
||||
|
||||
assert_eq!(options.path_prefix.as_deref(), Some("/routing-only"));
|
||||
assert_eq!(options.auth_token.as_deref(), Some("token"));
|
||||
assert!(options.headers.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ambient_policy_supports_inherit_override_and_omit() {
|
||||
let client = client();
|
||||
client.set_ambient_agent_task_id(Some("ambient-task".to_string()));
|
||||
|
||||
let inherited = block_on(client.ambient_headers(AmbientHeaderPolicy {
|
||||
workload_token: HeaderOverride::Set("workload".to_string()),
|
||||
cloud_agent_id: HeaderOverride::Inherit,
|
||||
agent_source: HeaderOverride::Inherit,
|
||||
}))
|
||||
.unwrap();
|
||||
assert!(inherited.contains(&(
|
||||
AMBIENT_WORKLOAD_TOKEN_HEADER.to_string(),
|
||||
"workload".to_string(),
|
||||
)));
|
||||
assert!(inherited.contains(&(
|
||||
CLOUD_AGENT_ID_HEADER.to_string(),
|
||||
"ambient-task".to_string()
|
||||
)));
|
||||
assert!(inherited.contains(&(AGENT_SOURCE_HEADER.to_string(), "cloud_mode".to_string())));
|
||||
|
||||
let task_scoped = block_on(client.ambient_headers(AmbientHeaderPolicy {
|
||||
workload_token: HeaderOverride::Set("workload".to_string()),
|
||||
..AmbientHeaderPolicy::for_task("specific-task")
|
||||
}))
|
||||
.unwrap();
|
||||
assert!(task_scoped.contains(&(
|
||||
CLOUD_AGENT_ID_HEADER.to_string(),
|
||||
"specific-task".to_string(),
|
||||
)));
|
||||
assert!(!task_scoped.contains(&(
|
||||
CLOUD_AGENT_ID_HEADER.to_string(),
|
||||
"ambient-task".to_string()
|
||||
)));
|
||||
|
||||
let omitted = block_on(client.ambient_headers(AmbientHeaderPolicy::omit_all())).unwrap();
|
||||
assert!(omitted.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authenticated_graphql_options_include_configured_and_ambient_headers() {
|
||||
let client = client();
|
||||
client.set_ambient_agent_task_id(Some("ambient-task".to_string()));
|
||||
|
||||
let options = block_on(client.graphql_request_options(None)).unwrap();
|
||||
|
||||
assert_eq!(options.path_prefix.as_deref(), Some("/routing-only"));
|
||||
assert_eq!(
|
||||
options
|
||||
.headers
|
||||
.get("X-Test-Authenticated")
|
||||
.map(String::as_str),
|
||||
Some("true")
|
||||
);
|
||||
assert_eq!(
|
||||
options
|
||||
.headers
|
||||
.get(CLOUD_AGENT_ID_HEADER)
|
||||
.map(String::as_str),
|
||||
Some("ambient-task")
|
||||
);
|
||||
assert_eq!(
|
||||
options.headers.get(AGENT_SOURCE_HEADER).map(String::as_str),
|
||||
Some("cloud_mode")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authenticated_graphql_configuration_cannot_override_base_client_owned_headers() {
|
||||
let (event_sender, _) = async_channel::unbounded();
|
||||
let mut headers = HashMap::new();
|
||||
headers.insert("authorization".to_string(), "malicious".to_string());
|
||||
headers.insert("content-type".to_string(), "text/plain".to_string());
|
||||
headers.insert("CONTENT-LENGTH".to_string(), "9999".to_string());
|
||||
headers.insert(
|
||||
http_client::iap::IAP_PROXY_AUTH_HEADER.to_string(),
|
||||
"malicious".to_string(),
|
||||
);
|
||||
headers.insert(
|
||||
CLOUD_AGENT_ID_HEADER.to_ascii_lowercase(),
|
||||
"malicious".to_string(),
|
||||
);
|
||||
headers.insert("x-eval-user-id".to_string(), "1234".to_string());
|
||||
let client = BaseClient::new(
|
||||
Arc::new(http_client::Client::new()),
|
||||
Arc::new(AuthState::new_for_test()),
|
||||
event_sender,
|
||||
None,
|
||||
GraphqlRoutingConfig::default(),
|
||||
AuthenticatedGraphqlConfig { headers },
|
||||
None,
|
||||
);
|
||||
|
||||
let options = block_on(client.graphql_request_options(None)).unwrap();
|
||||
|
||||
assert!(!options.headers.contains_key("authorization"));
|
||||
assert!(!options.headers.contains_key("content-type"));
|
||||
assert!(!options.headers.contains_key("CONTENT-LENGTH"));
|
||||
assert!(
|
||||
!options
|
||||
.headers
|
||||
.contains_key(http_client::iap::IAP_PROXY_AUTH_HEADER)
|
||||
);
|
||||
assert!(
|
||||
!options
|
||||
.headers
|
||||
.contains_key(&CLOUD_AGENT_ID_HEADER.to_ascii_lowercase())
|
||||
);
|
||||
#[cfg(feature = "agent_mode_evals")]
|
||||
{
|
||||
let eval_user_id = client.eval_user_id().unwrap().to_string();
|
||||
assert!(!options.headers.contains_key("x-eval-user-id"));
|
||||
assert_eq!(
|
||||
options.headers.get(EVAL_USER_ID_HEADER).map(String::as_str),
|
||||
Some(eval_user_id.as_str())
|
||||
);
|
||||
}
|
||||
#[cfg(not(feature = "agent_mode_evals"))]
|
||||
assert_eq!(
|
||||
options.headers.get("x-eval-user-id").map(String::as_str),
|
||||
Some("1234")
|
||||
);
|
||||
}
|
||||
@@ -1,30 +1,41 @@
|
||||
use std::{borrow::Cow, fmt, str::FromStr};
|
||||
use std::borrow::Cow;
|
||||
use std::fmt;
|
||||
use std::str::FromStr;
|
||||
|
||||
use anyhow::{Result, anyhow};
|
||||
use chrono::{DateTime, Utc};
|
||||
use derivative::Derivative;
|
||||
use galaxy_core::{
|
||||
features::FeatureFlag,
|
||||
ui::{Icon, appearance::Appearance, theme::Fill},
|
||||
};
|
||||
use galaxy_graphql::{object_permissions::AccessLevel, scalars::time::ServerTimestamp};
|
||||
use galaxyui_core::{
|
||||
Element,
|
||||
elements::{
|
||||
Align, ChildAnchor, ConstrainedBox, Hoverable, MouseStateHandle, OffsetPositioning,
|
||||
ParentAnchor, ParentElement, ParentOffsetBounds, Stack,
|
||||
},
|
||||
ui_components::components::UiComponent,
|
||||
};
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
auth::UserUid,
|
||||
drive::sharing::{SharingAccessLevel, Subject, TeamKind, UserKind},
|
||||
ids::{FolderId, ServerId, SyncId},
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use galaxy_core::ui::Icon;
|
||||
use galaxy_core::ui::appearance::Appearance;
|
||||
use galaxy_core::ui::theme::Fill;
|
||||
use galaxy_graphql::object_permissions::AccessLevel;
|
||||
use galaxy_graphql::scalars::time::ServerTimestamp;
|
||||
use galaxyui_core::Element;
|
||||
use galaxyui_core::elements::{
|
||||
Align, ChildAnchor, ConstrainedBox, Hoverable, MouseStateHandle, OffsetPositioning,
|
||||
ParentAnchor, ParentElement, ParentOffsetBounds, Stack,
|
||||
};
|
||||
use galaxyui_core::ui_components::components::UiComponent;
|
||||
|
||||
use crate::auth::UserUid;
|
||||
use crate::drive::sharing::{SharingAccessLevel, Subject, TeamKind, UserKind};
|
||||
use crate::ids::{FolderId, ServerId, SyncId};
|
||||
|
||||
mod creation;
|
||||
mod generic_cloud_object;
|
||||
mod generic_string_model;
|
||||
pub mod models;
|
||||
mod server_object;
|
||||
mod update;
|
||||
|
||||
pub use creation::*;
|
||||
pub use generic_cloud_object::*;
|
||||
pub use generic_string_model::*;
|
||||
pub use server_object::*;
|
||||
pub use update::*;
|
||||
/// The type of object id each ObjectType corresponds to.
|
||||
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
|
||||
pub enum ObjectIdType {
|
||||
@@ -134,6 +145,22 @@ pub enum GenericStringObjectFormat {
|
||||
Json(JsonObjectType),
|
||||
}
|
||||
|
||||
/// Represents a unique key for a generic string object. The server enforces that
|
||||
/// no two generic string objects have the same key.
|
||||
#[derive(PartialEq, Eq, Debug, Clone)]
|
||||
pub struct GenericStringObjectUniqueKey {
|
||||
/// The unique key. E.g. for cloud prefs this is the storage key of the pref.
|
||||
pub key: String,
|
||||
|
||||
/// Whether this key is unique for all generic string objects, or unique per user.
|
||||
pub unique_per: UniquePer,
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Eq, Debug, Clone)]
|
||||
pub enum UniquePer {
|
||||
User,
|
||||
}
|
||||
|
||||
// Temporarily suppress clippy warnings about the `ToString` impl until we
|
||||
// move `ObjectType` away from using `std::fmt::Display` for serialization.
|
||||
#[allow(clippy::to_string_trait_impl)]
|
||||
@@ -544,7 +571,7 @@ pub struct CloudObjectMetadata {
|
||||
pub pending_changes_statuses: CloudObjectStatuses,
|
||||
pub trashed_ts: Option<ServerTimestamp>,
|
||||
pub folder_id: Option<SyncId>,
|
||||
/// Welcome objects are created on the server when a user first recieves
|
||||
/// Welcome objects are created on the server when a user first receives
|
||||
/// access to Warp Drive as part of onboarding.
|
||||
pub is_welcome_object: bool,
|
||||
pub last_editor_uid: Option<String>,
|
||||
@@ -784,6 +811,36 @@ pub enum CloudObjectEventEntrypoint {
|
||||
Unknown,
|
||||
}
|
||||
|
||||
// A newtype for a serialized model that wraps a plain string.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SerializedModel(String);
|
||||
|
||||
impl SerializedModel {
|
||||
pub fn new(s: String) -> Self {
|
||||
Self(s)
|
||||
}
|
||||
|
||||
pub fn model_as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
|
||||
pub fn take(self) -> String {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for SerializedModel {
|
||||
fn from(s: String) -> Self {
|
||||
Self(s)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct RevisionAndLastEditor {
|
||||
pub revision: Revision,
|
||||
pub last_editor_uid: Option<String>,
|
||||
}
|
||||
|
||||
// GraphQL conversion impls.
|
||||
|
||||
impl From<GenericStringObjectFormat>
|
||||
@@ -840,6 +897,27 @@ impl From<CloudObjectEventEntrypoint> for galaxy_graphql::object::CloudObjectEve
|
||||
}
|
||||
}
|
||||
|
||||
impl From<GenericStringObjectUniqueKey>
|
||||
for galaxy_graphql::generic_string_object::GenericStringObjectUniqueKey
|
||||
{
|
||||
fn from(key: GenericStringObjectUniqueKey) -> Self {
|
||||
use galaxy_graphql::generic_string_object::GenericStringObjectUniqueKey as GraphQLKey;
|
||||
GraphQLKey {
|
||||
key: key.key,
|
||||
unique_per: key.unique_per.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<UniquePer> for galaxy_graphql::generic_string_object::UniquePer {
|
||||
fn from(unique_per: UniquePer) -> Self {
|
||||
use galaxy_graphql::generic_string_object::UniquePer as GraphQLUniquePer;
|
||||
match unique_per {
|
||||
UniquePer::User => GraphQLUniquePer::User,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<galaxy_graphql::object::ObjectMetadata> for ServerMetadata {
|
||||
type Error = anyhow::Error;
|
||||
|
||||
@@ -986,8 +1064,7 @@ impl TryFrom<galaxy_graphql::object::Space> for Owner {
|
||||
|
||||
impl From<Owner> for galaxy_graphql::object_permissions::Owner {
|
||||
fn from(owner: Owner) -> Self {
|
||||
use galaxy_graphql::object_permissions::Owner as GraphQLOwner;
|
||||
use galaxy_graphql::object_permissions::OwnerType;
|
||||
use galaxy_graphql::object_permissions::{Owner as GraphQLOwner, OwnerType};
|
||||
match owner {
|
||||
Owner::User { user_uid } => GraphQLOwner {
|
||||
type_: OwnerType::User,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
pub use cloud_objects::drive::*;
|
||||
@@ -4,7 +4,9 @@ use galaxy_graphql::object_permissions::AccessLevel;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use session_sharing_protocol::common::{ProfileData as SessionSharingProfileData, Role};
|
||||
|
||||
use crate::{auth::UserUid, cloud_object::Owner, ids::ServerId};
|
||||
use crate::auth::UserUid;
|
||||
use crate::cloud_object::Owner;
|
||||
use crate::ids::ServerId;
|
||||
|
||||
#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
pub enum SharingAccessLevel {
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
use std::borrow::Cow;
|
||||
|
||||
use anyhow::{Result, anyhow};
|
||||
use http::StatusCode;
|
||||
use instant::Duration;
|
||||
use warp_graphql::client::{GraphQLError, Operation};
|
||||
use galaxyui_core::r#async::BoxFuture;
|
||||
|
||||
use crate::auth::AuthEvent;
|
||||
use crate::base_client::BaseClient;
|
||||
|
||||
/// Sends a GraphQL operation through a base client supplied by the application.
|
||||
///
|
||||
/// This function is deliberately generic so concrete endpoint operation
|
||||
/// instantiations occur in server client crates rather than in the app crate.
|
||||
pub fn send_graphql_request<'a, QF: 'a, O>(
|
||||
base_client: &'a BaseClient,
|
||||
operation: O,
|
||||
timeout: Option<Duration>,
|
||||
) -> BoxFuture<'a, Result<QF>>
|
||||
where
|
||||
O: Operation<QF> + Send + 'a,
|
||||
{
|
||||
Box::pin(async move {
|
||||
let operation_name = operation.operation_name().map(Cow::into_owned);
|
||||
let options = base_client.graphql_request_options(timeout).await?;
|
||||
let response = match operation
|
||||
.send_request(base_client.owned_http_client(), options)
|
||||
.await
|
||||
{
|
||||
Ok(response) => response,
|
||||
Err(GraphQLError::StagingAccessBlocked) => {
|
||||
let _ = base_client.send_auth_event(AuthEvent::StagingAccessBlocked);
|
||||
anyhow::bail!(GraphQLError::StagingAccessBlocked);
|
||||
}
|
||||
Err(GraphQLError::IapChallengeBlocked) => {
|
||||
let _ = base_client.send_auth_event(AuthEvent::IapChallengeReceived);
|
||||
anyhow::bail!(GraphQLError::IapChallengeBlocked);
|
||||
}
|
||||
Err(err) => {
|
||||
let is_auth_rejection = match &err {
|
||||
GraphQLError::HttpError { status, .. } => {
|
||||
*status == StatusCode::UNAUTHORIZED || *status == StatusCode::FORBIDDEN
|
||||
}
|
||||
GraphQLError::RequestError(_)
|
||||
| GraphQLError::StagingAccessBlocked
|
||||
| GraphQLError::IapChallengeBlocked
|
||||
| GraphQLError::ResponseError(_) => false,
|
||||
};
|
||||
if !base_client.is_auth_refresh_allowed() && is_auth_rejection {
|
||||
anyhow::bail!("server rejected authentication credentials");
|
||||
}
|
||||
anyhow::bail!(err);
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(errors) = response.errors.as_ref() {
|
||||
galaxy_core::safe_error!(
|
||||
safe: ("graphql response for {:?} had errors", operation_name),
|
||||
full: ("graphql response for {:?} had errors {:?}", operation_name, errors)
|
||||
);
|
||||
// The "User not in context: Not found" response indicates that warp-server
|
||||
// could not resolve the required user because the user's account was disabled
|
||||
// or deleted.
|
||||
if errors
|
||||
.iter()
|
||||
.any(|error| error.message.contains("User not in context: Not found"))
|
||||
{
|
||||
if base_client.is_auth_refresh_allowed() {
|
||||
log::error!("GraphQL request failed due to unauthenticated user");
|
||||
let _ = base_client.send_auth_event(AuthEvent::UserAccountDisabled);
|
||||
} else {
|
||||
anyhow::bail!("server rejected authentication credentials");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
response.data.ok_or_else(|| {
|
||||
let operation_label = operation_name
|
||||
.as_deref()
|
||||
.unwrap_or("unknown GraphQL operation");
|
||||
let error_messages = response
|
||||
.errors
|
||||
.as_ref()
|
||||
.map(|errors| {
|
||||
errors
|
||||
.iter()
|
||||
.filter_map(|error| {
|
||||
let message = error.message.trim();
|
||||
(!message.is_empty()).then(|| message.to_string())
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ")
|
||||
})
|
||||
.filter(|messages| !messages.is_empty());
|
||||
match error_messages {
|
||||
Some(messages) => {
|
||||
anyhow!("missing response data for {operation_label}: {messages}")
|
||||
}
|
||||
None => anyhow!("missing response data for {operation_label}"),
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "graphql_helpers_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,276 @@
|
||||
use std::borrow::Cow;
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use cynic::{GraphQlError, GraphQlResponse};
|
||||
use futures::executor::block_on;
|
||||
use http::StatusCode;
|
||||
use warp_graphql::client::{GraphQLError, RequestOptions};
|
||||
use warp_server_auth::auth_state::AuthState;
|
||||
|
||||
use super::send_graphql_request;
|
||||
use crate::auth::AuthEvent;
|
||||
use crate::base_client::{AuthenticatedGraphqlConfig, BaseClient, GraphqlRoutingConfig};
|
||||
|
||||
fn base_client(auth_state: AuthState) -> (BaseClient, async_channel::Receiver<AuthEvent>) {
|
||||
let (event_sender, event_receiver) = async_channel::unbounded();
|
||||
(
|
||||
BaseClient::new(
|
||||
Arc::new(http_client::Client::new()),
|
||||
Arc::new(auth_state),
|
||||
event_sender,
|
||||
None,
|
||||
GraphqlRoutingConfig::default(),
|
||||
AuthenticatedGraphqlConfig::default(),
|
||||
None,
|
||||
),
|
||||
event_receiver,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refreshable_user_not_in_context_emits_account_disabled_event() {
|
||||
let (base_client, event_receiver) = refreshable_base_client();
|
||||
let send_count = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
let error = block_on(send_graphql_request(
|
||||
&base_client,
|
||||
FakeGraphqlOperation::response_errors(
|
||||
None,
|
||||
send_count.clone(),
|
||||
vec!["User not in context: Not found".to_string()],
|
||||
),
|
||||
None,
|
||||
))
|
||||
.unwrap_err();
|
||||
|
||||
assert!(error.to_string().contains("missing response data"));
|
||||
assert_eq!(send_count.load(Ordering::SeqCst), 1);
|
||||
assert_user_disabled_event(&event_receiver);
|
||||
}
|
||||
|
||||
fn refreshable_base_client() -> (BaseClient, async_channel::Receiver<AuthEvent>) {
|
||||
base_client(AuthState::new_for_test())
|
||||
}
|
||||
|
||||
fn externally_authenticated_base_client(
|
||||
bearer_token: &str,
|
||||
) -> (BaseClient, async_channel::Receiver<AuthEvent>) {
|
||||
let auth_state = AuthState::new_logged_out_for_test();
|
||||
auth_state.set_remote_server_bearer_token(bearer_token.to_string());
|
||||
base_client(auth_state)
|
||||
}
|
||||
|
||||
fn missing_credentials_base_client() -> (BaseClient, async_channel::Receiver<AuthEvent>) {
|
||||
base_client(AuthState::new_logged_out_for_test())
|
||||
}
|
||||
|
||||
fn assert_no_events(event_receiver: &async_channel::Receiver<AuthEvent>) {
|
||||
assert!(event_receiver.try_recv().is_err());
|
||||
}
|
||||
|
||||
fn assert_user_disabled_event(event_receiver: &async_channel::Receiver<AuthEvent>) {
|
||||
match event_receiver.try_recv().unwrap() {
|
||||
AuthEvent::UserAccountDisabled => {}
|
||||
event => panic!("Expected UserAccountDisabled event, got {event:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
struct FakeGraphqlOperation {
|
||||
expected_auth_token: Option<String>,
|
||||
send_count: Arc<AtomicUsize>,
|
||||
result: FakeGraphqlResult,
|
||||
}
|
||||
|
||||
enum FakeGraphqlResult {
|
||||
Success,
|
||||
Rejected(StatusCode),
|
||||
ResponseErrors(Vec<String>),
|
||||
}
|
||||
|
||||
impl FakeGraphqlOperation {
|
||||
fn successful(expected_auth_token: Option<&str>, send_count: Arc<AtomicUsize>) -> Self {
|
||||
Self {
|
||||
expected_auth_token: expected_auth_token.map(ToOwned::to_owned),
|
||||
send_count,
|
||||
result: FakeGraphqlResult::Success,
|
||||
}
|
||||
}
|
||||
|
||||
fn rejected(
|
||||
expected_auth_token: Option<&str>,
|
||||
send_count: Arc<AtomicUsize>,
|
||||
status: StatusCode,
|
||||
) -> Self {
|
||||
Self {
|
||||
expected_auth_token: expected_auth_token.map(ToOwned::to_owned),
|
||||
send_count,
|
||||
result: FakeGraphqlResult::Rejected(status),
|
||||
}
|
||||
}
|
||||
|
||||
fn response_errors(
|
||||
expected_auth_token: Option<&str>,
|
||||
send_count: Arc<AtomicUsize>,
|
||||
messages: Vec<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
expected_auth_token: expected_auth_token.map(ToOwned::to_owned),
|
||||
send_count,
|
||||
result: FakeGraphqlResult::ResponseErrors(messages),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl warp_graphql::client::Operation<()> for FakeGraphqlOperation {
|
||||
fn operation_name(&self) -> Option<Cow<'_, str>> {
|
||||
Some(Cow::Borrowed("FakeGraphqlOperation"))
|
||||
}
|
||||
|
||||
fn send_request(
|
||||
self,
|
||||
_client: Arc<http_client::Client>,
|
||||
options: RequestOptions,
|
||||
) -> Pin<
|
||||
Box<
|
||||
dyn Future<Output = std::result::Result<GraphQlResponse<()>, GraphQLError>>
|
||||
+ Send
|
||||
+ 'static,
|
||||
>,
|
||||
>
|
||||
where
|
||||
Self: Sized,
|
||||
{
|
||||
Box::pin(async move {
|
||||
assert_eq!(options.auth_token, self.expected_auth_token);
|
||||
self.send_count.fetch_add(1, Ordering::SeqCst);
|
||||
match self.result {
|
||||
FakeGraphqlResult::Success => Ok(GraphQlResponse {
|
||||
data: Some(()),
|
||||
errors: None,
|
||||
}),
|
||||
FakeGraphqlResult::Rejected(status) => Err(GraphQLError::HttpError {
|
||||
status,
|
||||
body: "redacted auth rejection".to_string(),
|
||||
}),
|
||||
FakeGraphqlResult::ResponseErrors(messages) => Ok(GraphQlResponse {
|
||||
data: None,
|
||||
errors: Some(
|
||||
messages
|
||||
.into_iter()
|
||||
.map(|message| GraphQlError::new(message, None, None, None))
|
||||
.collect(),
|
||||
),
|
||||
}),
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn has_error_message(error: &anyhow::Error, expected: &str) -> bool {
|
||||
error.chain().any(|cause| cause.to_string() == expected)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refresh_enabled_sends_configured_request_options() {
|
||||
let (base_client, event_receiver) = refreshable_base_client();
|
||||
let send_count = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
block_on(send_graphql_request(
|
||||
&base_client,
|
||||
FakeGraphqlOperation::successful(None, send_count.clone()),
|
||||
None,
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
assert!(base_client.is_auth_refresh_allowed());
|
||||
assert_eq!(send_count.load(Ordering::SeqCst), 1);
|
||||
assert_no_events(&event_receiver);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refresh_disabled_sends_provided_bearer_token() {
|
||||
let (base_client, event_receiver) = externally_authenticated_base_client("daemon-token");
|
||||
let send_count = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
block_on(send_graphql_request(
|
||||
&base_client,
|
||||
FakeGraphqlOperation::successful(Some("daemon-token"), send_count.clone()),
|
||||
None,
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
assert!(!base_client.is_auth_refresh_allowed());
|
||||
assert_eq!(send_count.load(Ordering::SeqCst), 1);
|
||||
assert_no_events(&event_receiver);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_request_credentials_returns_before_sending() {
|
||||
let (base_client, event_receiver) = missing_credentials_base_client();
|
||||
let send_count = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
let error = block_on(send_graphql_request(
|
||||
&base_client,
|
||||
FakeGraphqlOperation::successful(Some("unused-token"), send_count.clone()),
|
||||
None,
|
||||
))
|
||||
.unwrap_err();
|
||||
|
||||
assert!(has_error_message(
|
||||
&error,
|
||||
"missing authentication credentials"
|
||||
));
|
||||
assert_eq!(send_count.load(Ordering::SeqCst), 0);
|
||||
assert_no_events(&event_receiver);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_auth_rejection_returns_credentials_rejected_without_account_event() {
|
||||
let (base_client, event_receiver) = externally_authenticated_base_client("daemon-token");
|
||||
let send_count = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
let error = block_on(send_graphql_request(
|
||||
&base_client,
|
||||
FakeGraphqlOperation::rejected(
|
||||
Some("daemon-token"),
|
||||
send_count.clone(),
|
||||
StatusCode::UNAUTHORIZED,
|
||||
),
|
||||
None,
|
||||
))
|
||||
.unwrap_err();
|
||||
|
||||
assert!(has_error_message(
|
||||
&error,
|
||||
"server rejected authentication credentials"
|
||||
));
|
||||
assert_eq!(send_count.load(Ordering::SeqCst), 1);
|
||||
assert_no_events(&event_receiver);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_user_not_in_context_returns_credentials_rejected_without_account_event() {
|
||||
let (base_client, event_receiver) = externally_authenticated_base_client("daemon-token");
|
||||
let send_count = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
let error = block_on(send_graphql_request(
|
||||
&base_client,
|
||||
FakeGraphqlOperation::response_errors(
|
||||
Some("daemon-token"),
|
||||
send_count.clone(),
|
||||
vec!["User not in context: Not found".to_string()],
|
||||
),
|
||||
None,
|
||||
))
|
||||
.unwrap_err();
|
||||
|
||||
assert!(has_error_message(
|
||||
&error,
|
||||
"server rejected authentication credentials"
|
||||
));
|
||||
assert_eq!(send_count.load(Ordering::SeqCst), 1);
|
||||
assert_no_events(&event_receiver);
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use anyhow::Result;
|
||||
use base64::Engine;
|
||||
use blocking::unblock;
|
||||
use instant::Instant;
|
||||
use galaxy_core::channel::IapConfig;
|
||||
use galaxyui_core::r#async::{BoxFuture, FutureExt as _, Timer};
|
||||
use galaxyui_core::{AppContext, Entity, ModelContext, SingletonEntity};
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use websocket::connect_error_http_response;
|
||||
|
||||
const PROACTIVE_REFRESH_BUFFER: Duration = Duration::from_secs(5 * 60);
|
||||
const INJECTED_TOKEN_ENV_VAR: &str = "WARP_IAP_TOKEN";
|
||||
|
||||
const BASE_FAILURE_RETRY_DELAY: Duration = Duration::from_secs(30);
|
||||
const MAX_FAILURE_RETRY_DELAY: Duration = Duration::from_secs(5 * 60);
|
||||
/// Maximum number of consecutive failed fetches to automatically retry
|
||||
/// before giving up and waiting for a manual Refresh or an inbound
|
||||
/// IAP challenge. i.e. so a persistently broken setup (no gcloud,
|
||||
/// bad credentials) doesn't loop forever.
|
||||
const MAX_FAILURE_RETRIES: u32 = 5;
|
||||
|
||||
pub type PathResolver = Box<dyn Fn(&mut AppContext) -> BoxFuture<'static, Option<String>>>;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CachedToken {
|
||||
pub token: String,
|
||||
pub expires_at: Instant,
|
||||
}
|
||||
|
||||
impl CachedToken {
|
||||
fn valid_token(&self) -> Option<String> {
|
||||
(self.expires_at > Instant::now()).then(|| self.token.clone())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum IapCredentialsState {
|
||||
Missing,
|
||||
/// A credential fetch is in progress. `previous` carries the last
|
||||
/// successfully-loaded token (if any). Allows us to attach it to
|
||||
/// outbound requests while we're refreshing so that proactive refreshes
|
||||
/// (i.e. refresh the token 5min before exp) don't prevent active requests.
|
||||
Refreshing {
|
||||
previous: Option<CachedToken>,
|
||||
},
|
||||
Loaded(CachedToken),
|
||||
Failed {
|
||||
message: String,
|
||||
// in case the last token still works... we can try to use that for a couple more mins
|
||||
previous: Option<CachedToken>,
|
||||
},
|
||||
/// Represents a terminal state in the iap creds state machine.
|
||||
/// The gcloud refresh loop will never run, and an IAP challenge is logged
|
||||
/// rather than triggering a refresh (we have no way to refresh a new token
|
||||
/// from ambient agent context yet).
|
||||
/// TODO(Isaiah/Jason): implement token refreshing scheme.
|
||||
/// see: https://linear.app/warpdotdev/issue/REMOTE-1370/refresh-github-token
|
||||
EnvInjected {
|
||||
token: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl IapCredentialsState {
|
||||
fn previous_token(&self) -> Option<CachedToken> {
|
||||
match self {
|
||||
IapCredentialsState::Loaded(cached) => Some(cached.clone()),
|
||||
IapCredentialsState::Refreshing { previous }
|
||||
| IapCredentialsState::Failed { previous, .. } => previous.clone(),
|
||||
IapCredentialsState::EnvInjected { .. } | IapCredentialsState::Missing => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct IapState {
|
||||
audiences: String,
|
||||
service_account_email: String,
|
||||
inner: RwLock<IapCredentialsState>,
|
||||
}
|
||||
|
||||
impl IapState {
|
||||
pub fn new(config: &IapConfig) -> Self {
|
||||
let initial = std::env::var(INJECTED_TOKEN_ENV_VAR)
|
||||
.ok()
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|token| IapCredentialsState::EnvInjected { token })
|
||||
.unwrap_or(IapCredentialsState::Missing);
|
||||
Self {
|
||||
audiences: config.audiences.to_string(),
|
||||
service_account_email: config.service_account_email.to_string(),
|
||||
inner: RwLock::new(initial),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_cached(&self) -> Option<String> {
|
||||
match &*self.inner.read().expect("IAP state lock poisoned") {
|
||||
// Gate on expiry even while `Loaded`: if a proactive refresh is
|
||||
// delayed (e.g. the machine slept across the refresh window), the
|
||||
// token may already be expired, and attaching it would guarantee an
|
||||
// IAP challenge. Returning `None` lets the caller proceed without a
|
||||
// doomed token while the reactive refresh recovers.
|
||||
IapCredentialsState::Loaded(cached) => cached.valid_token(),
|
||||
IapCredentialsState::EnvInjected { token } => Some(token.clone()),
|
||||
IapCredentialsState::Refreshing { previous }
|
||||
| IapCredentialsState::Failed { previous, .. } => {
|
||||
previous.as_ref().and_then(CachedToken::valid_token)
|
||||
}
|
||||
IapCredentialsState::Missing => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn proxy_auth_header(&self) -> Option<(&'static str, String)> {
|
||||
self.get_cached()
|
||||
.map(|token| http_client::iap::proxy_auth_header(&token))
|
||||
}
|
||||
|
||||
pub fn state(&self) -> IapCredentialsState {
|
||||
self.inner.read().expect("IAP state lock poisoned").clone()
|
||||
}
|
||||
|
||||
pub fn audiences(&self) -> &str {
|
||||
&self.audiences
|
||||
}
|
||||
|
||||
pub fn service_account_email(&self) -> &str {
|
||||
&self.service_account_email
|
||||
}
|
||||
|
||||
fn set_refreshing(&self) {
|
||||
let mut state = self.inner.write().expect("IAP state lock poisoned");
|
||||
*state = IapCredentialsState::Refreshing {
|
||||
previous: state.previous_token(),
|
||||
};
|
||||
}
|
||||
|
||||
fn set_loaded(&self, cached: CachedToken) {
|
||||
*self.inner.write().expect("IAP state lock poisoned") = IapCredentialsState::Loaded(cached);
|
||||
}
|
||||
|
||||
fn set_failed(&self, message: String) {
|
||||
let mut state = self.inner.write().expect("IAP state lock poisoned");
|
||||
*state = IapCredentialsState::Failed {
|
||||
message,
|
||||
previous: state.previous_token(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
impl http_client::iap::IapTokenProvider for IapState {
|
||||
fn cached_token(&self) -> Option<String> {
|
||||
self.get_cached()
|
||||
}
|
||||
}
|
||||
|
||||
/// Owns the IAP refresh lifecycle: initial fetch, proactive time-based
|
||||
/// refresh, and reactive refresh on challenge events.
|
||||
pub struct IapManager {
|
||||
state: Option<Arc<IapState>>,
|
||||
path_resolver: PathResolver,
|
||||
/// Number of consecutive failed fetches since the last success.
|
||||
consecutive_failures: u32,
|
||||
}
|
||||
|
||||
pub enum IapManagerEvent {
|
||||
StateChanged,
|
||||
RefreshFailed {
|
||||
/// A human-readable error message describing why the refresh failed.
|
||||
message: String,
|
||||
/// Whether this is the first failure in a streak of failures.
|
||||
is_first_failure_of_streak: bool,
|
||||
},
|
||||
}
|
||||
|
||||
impl IapManager {
|
||||
pub fn new(
|
||||
state: Option<Arc<IapState>>,
|
||||
path_resolver: PathResolver,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> Self {
|
||||
let mut manager = Self {
|
||||
state,
|
||||
path_resolver,
|
||||
consecutive_failures: 0,
|
||||
};
|
||||
manager.start_refresh(ctx);
|
||||
manager
|
||||
}
|
||||
|
||||
/// Returns `true` if IAP is active for this build. When `false`, all
|
||||
/// other methods on this type are no-ops.
|
||||
pub fn is_enabled(&self) -> bool {
|
||||
self.state.is_some()
|
||||
}
|
||||
|
||||
pub fn state(&self) -> Option<IapCredentialsState> {
|
||||
self.state.as_ref().map(|s| s.state())
|
||||
}
|
||||
|
||||
/// Returns a handle to the shared IAP credential state, if IAP is active.
|
||||
/// Mirrors how `AuthStateProvider` hands out the `Arc<AuthState>`, letting
|
||||
/// callers read cached credentials (e.g. to build a proxy-auth header) off
|
||||
/// a `ModelContext` without reaching through `ServerApi`.
|
||||
pub fn iap_state(&self) -> Option<Arc<IapState>> {
|
||||
self.state.clone()
|
||||
}
|
||||
|
||||
pub fn handle_challenge(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
let Some(state) = self.state.as_ref() else {
|
||||
return;
|
||||
};
|
||||
if matches!(state.state(), IapCredentialsState::EnvInjected { .. }) {
|
||||
log::warn!(
|
||||
"Env-injected IAP token ({INJECTED_TOKEN_ENV_VAR}) was rejected by IAP; \
|
||||
token is likely stale — re-inject to recover"
|
||||
);
|
||||
return;
|
||||
}
|
||||
self.consecutive_failures = 0;
|
||||
self.start_refresh(ctx);
|
||||
}
|
||||
|
||||
pub fn start_refresh(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
let Some(state) = self.state.clone() else {
|
||||
return;
|
||||
};
|
||||
// Don't touch state if a refresh is already running, or if we're
|
||||
// in the terminal env-injected state (no refresh path exists).
|
||||
if matches!(
|
||||
state.state(),
|
||||
IapCredentialsState::Refreshing { .. } | IapCredentialsState::EnvInjected { .. }
|
||||
) {
|
||||
return;
|
||||
}
|
||||
state.set_refreshing();
|
||||
ctx.emit(IapManagerEvent::StateChanged);
|
||||
ctx.notify();
|
||||
|
||||
let audiences = state.audiences().to_string();
|
||||
let service_account_email = state.service_account_email().to_string();
|
||||
|
||||
// Make `gcloud` findable even when Warp is launched from the macOS GUI
|
||||
// (i.e. in environments without something like `~/.zshrc && WarpDev` happening to init cli path)
|
||||
let path_future = (self.path_resolver)(ctx);
|
||||
|
||||
ctx.spawn(
|
||||
async move {
|
||||
// Bound the interactive PATH capture. It spawns an interactive
|
||||
// login shell (sourcing rc files), which can hang indefinitely
|
||||
// on a misbehaving startup script. Without this bound the
|
||||
// spawned task would never reach the `GCLOUD_TIMEOUT`-guarded
|
||||
// fetch, stranding the state machine in `Refreshing` and
|
||||
// silently disabling every future refresh and IAP challenge
|
||||
// (both early-return while `Refreshing`). On timeout, fall back
|
||||
// to the ambient PATH so the fetch still runs and the state
|
||||
// machine can make progress (succeed or fail).
|
||||
const PATH_CAPTURE_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
let path_env = match path_future.with_timeout(PATH_CAPTURE_TIMEOUT).await {
|
||||
Ok(path_env) => path_env,
|
||||
Err(_) => {
|
||||
log::warn!(
|
||||
"Interactive PATH capture timed out after {}s; \
|
||||
falling back to ambient PATH for IAP token fetch",
|
||||
PATH_CAPTURE_TIMEOUT.as_secs()
|
||||
);
|
||||
None
|
||||
}
|
||||
};
|
||||
unblock(move || {
|
||||
fetch_iap_token(&audiences, &service_account_email, path_env.as_deref())
|
||||
})
|
||||
.await
|
||||
},
|
||||
move |manager, result, ctx| {
|
||||
let Some(state) = manager.state.as_ref() else {
|
||||
return;
|
||||
};
|
||||
match result {
|
||||
Ok(cached) => {
|
||||
let expires_at = cached.expires_at;
|
||||
state.set_loaded(cached);
|
||||
manager.consecutive_failures = 0;
|
||||
log::info!("Warp Staging IAP token refreshed");
|
||||
ctx.emit(IapManagerEvent::StateChanged);
|
||||
ctx.notify();
|
||||
manager.schedule_next_refresh(expires_at, ctx);
|
||||
}
|
||||
Err(err) => {
|
||||
let message = format!("{err:#}");
|
||||
log::warn!("Warp Staging IAP token fetch failed: {message}");
|
||||
let is_first_failure_of_streak = manager.consecutive_failures == 0;
|
||||
state.set_failed(message.clone());
|
||||
ctx.emit(IapManagerEvent::RefreshFailed {
|
||||
message,
|
||||
is_first_failure_of_streak,
|
||||
});
|
||||
ctx.emit(IapManagerEvent::StateChanged);
|
||||
ctx.notify();
|
||||
manager.schedule_failure_retry(ctx);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn schedule_next_refresh(&mut self, expires_at: Instant, ctx: &mut ModelContext<Self>) {
|
||||
let sleep_duration = expires_at
|
||||
.saturating_duration_since(Instant::now())
|
||||
.saturating_sub(PROACTIVE_REFRESH_BUFFER);
|
||||
self.schedule_retry(sleep_duration, ctx);
|
||||
}
|
||||
|
||||
fn schedule_failure_retry(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
if self.consecutive_failures >= MAX_FAILURE_RETRIES {
|
||||
log::warn!(
|
||||
"IAP token fetch failed {MAX_FAILURE_RETRIES} times in a row; giving up until \
|
||||
manual refresh or server challenge"
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Delay = BASE * 2^failures, capped at MAX. Using u32 shift is
|
||||
// safe because we cap failures at MAX_FAILURE_RETRIES (< 32).
|
||||
let delay = BASE_FAILURE_RETRY_DELAY
|
||||
.saturating_mul(1u32 << self.consecutive_failures)
|
||||
.min(MAX_FAILURE_RETRY_DELAY);
|
||||
self.consecutive_failures += 1;
|
||||
log::info!(
|
||||
"Scheduling IAP refresh retry #{} in {}s",
|
||||
self.consecutive_failures,
|
||||
delay.as_secs()
|
||||
);
|
||||
self.schedule_retry(delay, ctx);
|
||||
}
|
||||
|
||||
fn schedule_retry(&mut self, delay: Duration, ctx: &mut ModelContext<Self>) {
|
||||
ctx.spawn(
|
||||
async move {
|
||||
Timer::after(delay).await;
|
||||
},
|
||||
|manager, _, ctx| {
|
||||
manager.start_refresh(ctx);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Inspects a websocket *handshake* connect error for an IAP challenge.
|
||||
/// If detected, triggers a refresh so the caller's retry loop can pick up
|
||||
/// a fresh token on the next attempt.
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub fn check_ws_connect_error(&mut self, err: &anyhow::Error, ctx: &mut ModelContext<Self>) {
|
||||
if ws_connect_is_iap_challenge(err) {
|
||||
log::warn!("Received IAP challenge on websocket handshake; triggering refresh");
|
||||
self.handle_challenge(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_family = "wasm")]
|
||||
pub fn check_ws_connect_error(&mut self, _err: &anyhow::Error, _ctx: &mut ModelContext<Self>) {}
|
||||
}
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub fn ws_connect_is_iap_challenge(err: &anyhow::Error) -> bool {
|
||||
connect_error_http_response(err).is_some_and(|response| {
|
||||
http_client::iap::is_iap_challenge(response.status(), response.headers())
|
||||
})
|
||||
}
|
||||
|
||||
impl Entity for IapManager {
|
||||
type Event = IapManagerEvent;
|
||||
}
|
||||
|
||||
impl SingletonEntity for IapManager {}
|
||||
|
||||
/// How long to wait for `auth print-identity-token` command to respond before killing it.
|
||||
const GCLOUD_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
// gcloud ships as `gcloud.cmd` on Windows
|
||||
#[cfg(windows)]
|
||||
const GCLOUD_PROGRAM: &str = "gcloud.cmd";
|
||||
#[cfg(not(windows))]
|
||||
const GCLOUD_PROGRAM: &str = "gcloud";
|
||||
|
||||
fn fetch_iap_token(
|
||||
audiences: &str,
|
||||
service_account_email: &str,
|
||||
path_env: Option<&str>,
|
||||
) -> Result<CachedToken> {
|
||||
let args = [
|
||||
"auth",
|
||||
"print-identity-token",
|
||||
"--audiences",
|
||||
audiences,
|
||||
"--impersonate-service-account",
|
||||
service_account_email,
|
||||
"--include-email",
|
||||
];
|
||||
let cmd_display = format!("{GCLOUD_PROGRAM} {}", args.join(" "));
|
||||
|
||||
let mut cmd = command::blocking::Command::new(GCLOUD_PROGRAM);
|
||||
cmd
|
||||
// Prevent gcloud from waiting for interactive input (fail fast instead of hanging)
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.args(args);
|
||||
// allows warp to resolve `gcloud` cli path
|
||||
if let Some(path_env) = path_env {
|
||||
cmd.env("PATH", path_env);
|
||||
}
|
||||
let mut child = cmd
|
||||
.spawn()
|
||||
.map_err(|err| anyhow::anyhow!("Failed to spawn `{cmd_display}`: {err}"))?;
|
||||
|
||||
// Poll for completion, killing the child if it exceeds the timeout.
|
||||
let start = Instant::now();
|
||||
loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(_)) => break,
|
||||
Ok(None) => {
|
||||
if start.elapsed() > GCLOUD_TIMEOUT {
|
||||
let _ = child.kill();
|
||||
anyhow::bail!(
|
||||
"`{cmd_display}` timed out after {}s",
|
||||
GCLOUD_TIMEOUT.as_secs()
|
||||
);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
Err(err) => anyhow::bail!("Failed to wait for `{cmd_display}`: {err}"),
|
||||
}
|
||||
}
|
||||
|
||||
let output = child
|
||||
.wait_with_output()
|
||||
.map_err(|err| anyhow::anyhow!("Failed to collect output from `{cmd_display}`: {err}"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
anyhow::bail!("`{cmd_display}` failed: {stderr}");
|
||||
}
|
||||
|
||||
let token = String::from_utf8(output.stdout)
|
||||
.map_err(|err| anyhow::anyhow!("gcloud output is not valid UTF-8: {err}"))?
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
anyhow::ensure!(!token.is_empty(), "gcloud returned an empty token");
|
||||
|
||||
let expires_at = get_expires_at(&token)?;
|
||||
Ok(CachedToken { token, expires_at })
|
||||
}
|
||||
|
||||
fn get_expires_at(token: &str) -> Result<Instant> {
|
||||
let exp = parse_exp_from_jwt(token).ok_or_else(|| {
|
||||
anyhow::anyhow!("IAP token missing or unparseable `exp` claim; refusing to cache")
|
||||
})?;
|
||||
// `exp` is Unix wall-clock seconds; `Instant` is monotonic and
|
||||
// has no Unix-time API, so bridge via `SystemTime::now()` to
|
||||
// compute a delta, then add that to `Instant::now()`.
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_err(|err| anyhow::anyhow!("system clock is before unix epoch: {err}"))?
|
||||
.as_secs();
|
||||
let secs_remaining = exp
|
||||
.checked_sub(now)
|
||||
.ok_or_else(|| anyhow::anyhow!("IAP token is already expired (exp={exp}, now={now})"))?;
|
||||
Ok(Instant::now() + Duration::from_secs(secs_remaining))
|
||||
}
|
||||
|
||||
fn parse_exp_from_jwt(token: &str) -> Option<u64> {
|
||||
let payload_b64 = token.split('.').nth(1)?;
|
||||
let payload_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||
.decode(payload_b64)
|
||||
.ok()?;
|
||||
let payload: serde_json::Value = serde_json::from_slice(&payload_bytes).ok()?;
|
||||
payload.get("exp")?.as_u64()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "iap_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,120 @@
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use base64::Engine;
|
||||
use instant::Instant;
|
||||
use galaxy_core::channel::IapConfig;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Builds a syntactically-valid JWT (`header.payload.sig`) whose payload is the
|
||||
/// provided JSON. The signature is a placeholder \u2014 `parse_exp_from_jwt` only
|
||||
/// decodes the payload segment.
|
||||
fn jwt_with_payload(payload_json: &str) -> String {
|
||||
let b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
let header = b64.encode(br#"{"alg":"none"}"#);
|
||||
let payload = b64.encode(payload_json.as_bytes());
|
||||
format!("{header}.{payload}.signature")
|
||||
}
|
||||
|
||||
fn now_unix() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs()
|
||||
}
|
||||
|
||||
fn test_state() -> IapState {
|
||||
IapState::new(&IapConfig {
|
||||
audiences: "iap-client-id".into(),
|
||||
service_account_email: "iap-access@example.iam.gserviceaccount.com".into(),
|
||||
})
|
||||
}
|
||||
|
||||
fn cached(token: &str, ttl: Option<Duration>) -> CachedToken {
|
||||
// `None` produces an already-at-boundary instant, which `valid_token` treats
|
||||
// as expired once the comparison reads a slightly later `Instant::now()`.
|
||||
let expires_at = ttl.map_or_else(Instant::now, |d| Instant::now() + d);
|
||||
CachedToken {
|
||||
token: token.to_string(),
|
||||
expires_at,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_exp_from_jwt_reads_exp_claim() {
|
||||
let token = jwt_with_payload(r#"{"exp": 1893456000, "sub": "x"}"#);
|
||||
assert_eq!(parse_exp_from_jwt(&token), Some(1893456000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_exp_from_jwt_missing_exp_is_none() {
|
||||
let token = jwt_with_payload(r#"{"sub": "x"}"#);
|
||||
assert_eq!(parse_exp_from_jwt(&token), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_exp_from_jwt_not_a_jwt_is_none() {
|
||||
assert_eq!(parse_exp_from_jwt("not-a-jwt"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_exp_from_jwt_invalid_base64_is_none() {
|
||||
assert_eq!(parse_exp_from_jwt("aaa.!!!not-base64!!!.ccc"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_expires_at_future_exp_is_ok() {
|
||||
let token = jwt_with_payload(&format!(r#"{{"exp": {}}}"#, now_unix() + 3600));
|
||||
let expires_at = get_expires_at(&token).expect("future exp should parse");
|
||||
assert!(expires_at > Instant::now());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_expires_at_past_exp_errs() {
|
||||
let token = jwt_with_payload(r#"{"exp": 1}"#);
|
||||
assert!(get_expires_at(&token).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_expires_at_missing_exp_errs() {
|
||||
let token = jwt_with_payload(r#"{"sub": "x"}"#);
|
||||
assert!(get_expires_at(&token).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_cached_loaded_valid_returns_token() {
|
||||
let state = test_state();
|
||||
state.set_loaded(cached("fresh-token", Some(Duration::from_secs(60))));
|
||||
assert_eq!(state.get_cached().as_deref(), Some("fresh-token"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_cached_loaded_expired_is_none() {
|
||||
let state = test_state();
|
||||
state.set_loaded(cached("stale-token", None));
|
||||
assert_eq!(state.get_cached(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_cached_refreshing_uses_valid_previous_token() {
|
||||
let state = test_state();
|
||||
state.set_loaded(cached("prev-token", Some(Duration::from_secs(60))));
|
||||
state.set_refreshing();
|
||||
assert_eq!(state.get_cached().as_deref(), Some("prev-token"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_cached_refreshing_drops_expired_previous_token() {
|
||||
let state = test_state();
|
||||
state.set_loaded(cached("prev-token", None));
|
||||
state.set_refreshing();
|
||||
assert_eq!(state.get_cached(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_cached_failed_uses_valid_previous_token() {
|
||||
let state = test_state();
|
||||
state.set_loaded(cached("prev-token", Some(Duration::from_secs(60))));
|
||||
state.set_failed("gcloud blew up".to_string());
|
||||
assert_eq!(state.get_cached().as_deref(), Some("prev-token"));
|
||||
}
|
||||
@@ -1,415 +1 @@
|
||||
use std::fmt;
|
||||
|
||||
use itertools::Itertools;
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::cloud_object::ObjectIdType;
|
||||
|
||||
/// Convert ID enums into and from a hashed UUID.
|
||||
pub trait HashableId: Sized + Send + Sync {
|
||||
fn to_hash(&self) -> String;
|
||||
fn from_hash(hash: &str) -> Option<Self>;
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, Serialize, Deserialize, schemars::JsonSchema)]
|
||||
#[schemars(description = "A client-generated unique identifier.")]
|
||||
pub struct ClientId(Uuid);
|
||||
|
||||
impl HashableId for ClientId {
|
||||
fn to_hash(&self) -> String {
|
||||
self.to_string()
|
||||
}
|
||||
|
||||
fn from_hash(hash: &str) -> Option<ClientId> {
|
||||
hash.strip_prefix("Client-")
|
||||
.and_then(|s| Uuid::parse_str(s).ok())
|
||||
.map(ClientId)
|
||||
}
|
||||
}
|
||||
|
||||
impl ClientId {
|
||||
pub fn new() -> ClientId {
|
||||
Self(Uuid::new_v4())
|
||||
}
|
||||
|
||||
pub fn sqlite_hash(&self) -> String {
|
||||
self.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ClientId {
|
||||
fn default() -> Self {
|
||||
ClientId::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ClientId {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
write!(f, "Client-{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for ClientId {
|
||||
fn from(s: String) -> Self {
|
||||
ClientId::from_hash(&s).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
/// ID of an object in the sync queue.
|
||||
#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, schemars::JsonSchema)]
|
||||
#[schemars(description = "Identifier for a synced object, either local or server-assigned.")]
|
||||
pub enum SyncId {
|
||||
/// Item has not been sync-ed yet. Using a client-created UUID.
|
||||
#[schemars(
|
||||
description = "A locally-generated identifier for an object not yet synced to the server."
|
||||
)]
|
||||
ClientId(ClientId),
|
||||
/// Item has been sync-ed to the cloud. Using the server ID.
|
||||
#[schemars(description = "A server-assigned identifier for a synced object.")]
|
||||
ServerId(ServerId),
|
||||
}
|
||||
|
||||
impl SyncId {
|
||||
pub fn from_object_id<K>(id: K) -> Self
|
||||
where
|
||||
K: ToServerId,
|
||||
{
|
||||
Self::ServerId(id.to_server_id())
|
||||
}
|
||||
|
||||
pub fn uid(&self) -> ObjectUid {
|
||||
match self {
|
||||
Self::ClientId(id) => id.to_string(),
|
||||
Self::ServerId(id) => id.uid(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sqlite_uid_hash(&self, object_id_type: ObjectIdType) -> String {
|
||||
match self {
|
||||
SyncId::ClientId(id) => id.sqlite_hash(),
|
||||
SyncId::ServerId(id) => id.sqlite_type_and_uid_hash(object_id_type),
|
||||
}
|
||||
}
|
||||
|
||||
/// If this item has been synced to the cloud, extract its server ID.
|
||||
pub fn into_server(self) -> Option<ServerId> {
|
||||
match self {
|
||||
Self::ServerId(id) => Some(id),
|
||||
Self::ClientId(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn into_client(self) -> Option<ClientId> {
|
||||
match self {
|
||||
Self::ServerId(_) => None,
|
||||
Self::ClientId(id) => Some(id),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl settings_value::SettingsValue for SyncId {}
|
||||
|
||||
impl fmt::Display for SyncId {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
match self {
|
||||
Self::ServerId(id) => id.fmt(f),
|
||||
Self::ClientId(id) => id.fmt(f),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ServerId> for SyncId {
|
||||
fn from(id: ServerId) -> SyncId {
|
||||
SyncId::ServerId(id)
|
||||
}
|
||||
}
|
||||
|
||||
/// Custom serialize function for SyncIds.
|
||||
impl Serialize for SyncId {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
match self {
|
||||
SyncId::ServerId(server_id) => server_id.serialize(serializer),
|
||||
SyncId::ClientId(client_id) => client_id.to_hash().serialize(serializer),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Custom deserialize function for SyncIds.
|
||||
impl<'de> Deserialize<'de> for SyncId {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let s: String = Deserialize::deserialize(deserializer)?;
|
||||
|
||||
// We try to deserialize as a ClientID, which only succeeds if the ID is prefixed with `Client-`.
|
||||
// If that fails, we assume this is a server id and create a server ID.
|
||||
if let Some(hashed) = ClientId::from_hash(s.as_str()) {
|
||||
Ok(SyncId::ClientId(hashed))
|
||||
} else {
|
||||
Ok(SyncId::ServerId(ServerId::from_string_lossy(s)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Length of the ServerId, should be in sync with the length picked for the server.
|
||||
const SERVER_ID_LENGTH: usize = 22;
|
||||
|
||||
/// ServerId is a representation of a string-based unique ID we generate on the server,
|
||||
/// of length SERVER_ID_LENGTH.
|
||||
/// Because it's of fixed length, it can implement the Copy trait
|
||||
/// (in contrast to simply using a String type).
|
||||
#[derive(Clone, Copy, Default, Hash, PartialEq, Eq, schemars::JsonSchema)]
|
||||
#[schemars(description = "A server-assigned unique identifier.")]
|
||||
pub struct ServerId([char; SERVER_ID_LENGTH]);
|
||||
|
||||
/// For server IDs, this is the value that is stored
|
||||
/// in the database. For client IDs, it is of the form "Client-{id}".
|
||||
/// Used to index into cloud model and in most object read, write, and metadata
|
||||
/// mutation server APIs.
|
||||
pub type ObjectUid = String;
|
||||
|
||||
/// Corresponds to what is stored for a given object id within the local sqlite
|
||||
/// database. Needed for backwards compatibility of the sqlite db following a refactor
|
||||
/// that stripped the object type away from SyncID.
|
||||
///
|
||||
/// Of the format {sqlite_prefix}-{uid}.
|
||||
///
|
||||
/// Other than sqlite model events, this id is used for embedded objects within notebooks.
|
||||
pub type HashedSqliteId = String;
|
||||
|
||||
/// UID for API keys.
|
||||
pub type ApiKeyUid = String;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ParseServerIdError {
|
||||
#[error("ServerId must be exactly {SERVER_ID_LENGTH} characters, got {len}")]
|
||||
InvalidLength { len: usize },
|
||||
}
|
||||
|
||||
/// Removes the prefix from sqlite IDs to extract the UIDs. Should not be used unless there
|
||||
/// is not other way to cleanly do the conversion, i.e., when we don't know the ID type.
|
||||
#[allow(clippy::result_unit_err)]
|
||||
pub fn parse_sqlite_id_to_uid(hashed_sqlite_id: HashedSqliteId) -> Result<ObjectUid, ()> {
|
||||
let Some(uid) = hashed_sqlite_id.split("-").last() else {
|
||||
return Err(());
|
||||
};
|
||||
|
||||
Ok(uid.to_owned())
|
||||
}
|
||||
|
||||
impl ServerId {
|
||||
/// Convert a string input to a server ID. If the string is not exactly
|
||||
/// [`SERVER_ID_LENGTH`] characters long, it will be truncated or padded as
|
||||
/// necessary.
|
||||
pub fn from_string_lossy(id: impl AsRef<str>) -> Self {
|
||||
let id = id.as_ref();
|
||||
Self::try_from(id).unwrap_or_else(|err| {
|
||||
if cfg!(debug_assertions) {
|
||||
panic!("{err}");
|
||||
}
|
||||
// ServerIds need to be exactly 22 characters, so to prevent a crash, we'll normalize
|
||||
// the string. Nothing that uses it will work, but it's better than crashing.
|
||||
let normalized = Self::normalize_id_str(id, 0);
|
||||
Self::try_from(normalized).expect("id should convert")
|
||||
})
|
||||
}
|
||||
|
||||
/// Normalizes a string to be exactly 22 characters long.
|
||||
fn normalize_id_str(input: &str, prefix_length: usize) -> String {
|
||||
let available_len = SERVER_ID_LENGTH - prefix_length;
|
||||
let truncated = if input.len() > available_len {
|
||||
&input[input.len() - available_len..]
|
||||
} else {
|
||||
input
|
||||
};
|
||||
format!("{truncated:0>available_len$}")
|
||||
}
|
||||
|
||||
pub fn uid(&self) -> ObjectUid {
|
||||
(*self).into()
|
||||
}
|
||||
|
||||
/// We need this API for backwards compatibility with local sqlite data.
|
||||
/// In sqlite, objects are stored in object typy, uid pairs of the format
|
||||
/// {sqlite-prefix}-{uid}. For example, for a workflow this would be
|
||||
/// "Workflow-{uid}".
|
||||
pub fn sqlite_type_and_uid_hash(&self, object_id_type: ObjectIdType) -> HashedSqliteId {
|
||||
format!("{}-{}", object_id_type.sqlite_prefix(), self)
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<&str> for ServerId {
|
||||
type Error = ParseServerIdError;
|
||||
|
||||
fn try_from(s: &str) -> Result<Self, Self::Error> {
|
||||
match s.chars().collect_array() {
|
||||
Some(chars) => Ok(Self(chars)),
|
||||
None => Err(ParseServerIdError::InvalidLength {
|
||||
len: s.chars().count(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<String> for ServerId {
|
||||
type Error = ParseServerIdError;
|
||||
|
||||
fn try_from(id: String) -> Result<Self, Self::Error> {
|
||||
Self::try_from(id.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a conversion between an i64 and a corresponding deterministic ServerId for use in tests.
|
||||
/// An i64 like 123 will be converted to "test_uid00000000000123".
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
impl From<i64> for ServerId {
|
||||
fn from(id: i64) -> Self {
|
||||
let prefix = "test_uid";
|
||||
let id_str = id.abs().to_string();
|
||||
let normalized = format!(
|
||||
"{}{}",
|
||||
prefix,
|
||||
Self::normalize_id_str(&id_str, prefix.len())
|
||||
);
|
||||
Self::try_from(normalized).expect("normalized string should always be valid")
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ServerId> for String {
|
||||
fn from(id: ServerId) -> String {
|
||||
String::from_iter(id.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// We need our own implementation of serialize, due to ServerId being essentially a char array.
|
||||
/// The default serializer in this case would spit a string that looks like an array, instead of a
|
||||
/// nicely formatted string that we want. This implementation would serialize ServerId('a', 'b') to
|
||||
/// "ab" instead.
|
||||
impl Serialize for ServerId {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
let s: String = (*self).into();
|
||||
serializer.serialize_str(&s)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for ServerId {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let s: String = Deserialize::deserialize(deserializer)?;
|
||||
ServerId::try_from(s.as_str()).map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ServerId {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
|
||||
use std::fmt::Write;
|
||||
for ch in self.0.iter() {
|
||||
f.write_char(*ch)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ServerId {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
|
||||
write!(f, "ServerId({self})")
|
||||
}
|
||||
}
|
||||
|
||||
pub trait ToServerId {
|
||||
fn to_server_id(&self) -> ServerId;
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ServerIdAndType {
|
||||
pub id: ServerId,
|
||||
pub id_type: ObjectIdType,
|
||||
}
|
||||
|
||||
impl ServerIdAndType {
|
||||
pub fn sqlite_type_and_uid_hash(&self) -> HashedSqliteId {
|
||||
self.id.sqlite_type_and_uid_hash(self.id_type)
|
||||
}
|
||||
}
|
||||
|
||||
/// string_id_traits is a macro used for generating implementations for the type aliases on
|
||||
/// ServerId, implements different To/From and Display, and HashableId traits.
|
||||
/// Takes type and desired prefix for HashableId.
|
||||
#[macro_export]
|
||||
macro_rules! server_id_traits {
|
||||
($t:ty, $prefix:literal) => {
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
impl From<i64> for $t {
|
||||
fn from(id: i64) -> Self {
|
||||
Self(id.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for $t {
|
||||
fn from(id: String) -> Self {
|
||||
Self($crate::ids::ServerId::from_string_lossy(id))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<$t> for String {
|
||||
fn from(id: $t) -> String {
|
||||
id.0.into()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for $t {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<$t> for $crate::ids::ServerId {
|
||||
fn from(id: $t) -> Self {
|
||||
id.0
|
||||
}
|
||||
}
|
||||
|
||||
impl $crate::ids::HashableId for $t {
|
||||
fn to_hash(&self) -> String {
|
||||
format!("{}-{}", $prefix, self)
|
||||
}
|
||||
|
||||
fn from_hash(hash: &str) -> Option<$t> {
|
||||
hash.strip_prefix(&format!("{}-", $prefix))
|
||||
.map(|s| s.to_string().into())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<$crate::ids::ServerId> for $t {
|
||||
fn from(id: $crate::ids::ServerId) -> Self {
|
||||
Self(id)
|
||||
}
|
||||
}
|
||||
|
||||
impl $crate::ids::ToServerId for $t {
|
||||
fn to_server_id(&self) -> $crate::ids::ServerId {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Copy, PartialEq, Eq, Hash, Default)]
|
||||
pub struct FolderId(ServerId);
|
||||
server_id_traits! { FolderId, "Folder" }
|
||||
|
||||
impl From<FolderId> for SyncId {
|
||||
fn from(id: FolderId) -> Self {
|
||||
Self::ServerId(id.into())
|
||||
}
|
||||
}
|
||||
pub use cloud_objects::ids::*;
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
pub mod auth;
|
||||
pub mod cloud_object;
|
||||
pub mod base_client;
|
||||
pub mod drive;
|
||||
pub mod graphql_helpers;
|
||||
pub mod iap;
|
||||
pub mod ids;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub mod persistence;
|
||||
pub mod network_logging;
|
||||
mod public_api;
|
||||
|
||||
pub use auth::UserUid;
|
||||
pub use cloud_objects::server_id_traits;
|
||||
pub use public_api::HttpStatusError;
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
use std::fmt;
|
||||
|
||||
use bounded_vec_deque::BoundedVecDeque;
|
||||
use chrono::{DateTime, FixedOffset};
|
||||
use galaxyui_core::{Entity, ModelContext, SingletonEntity};
|
||||
|
||||
/// Maximum number of network log items retained in memory. Matches the
|
||||
/// previous file-rotation threshold so the pane surface behaves consistently
|
||||
/// with historical expectations.
|
||||
const NETWORK_LOGGING_MAX_ITEMS: usize = 50;
|
||||
|
||||
/// Upper bound on the bounded async channel between the HTTP client hooks and
|
||||
/// the in-memory model. Keeps a small backlog to tolerate bursts without
|
||||
/// blocking the request thread.
|
||||
const NETWORK_LOGGING_MAX_QUEUE_SIZE: usize = 100;
|
||||
|
||||
/// In-memory store of the most recent network log items. Populated by
|
||||
/// [`Self::install_on_clients`] and read by the network log pane. Holds at most
|
||||
/// [`NETWORK_LOGGING_MAX_ITEMS`] entries; older entries are dropped when new
|
||||
/// ones arrive.
|
||||
pub struct NetworkLogModel {
|
||||
items: BoundedVecDeque<NetworkLogItem>,
|
||||
}
|
||||
|
||||
impl Default for NetworkLogModel {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
items: BoundedVecDeque::new(NETWORK_LOGGING_MAX_ITEMS),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl NetworkLogModel {
|
||||
/// Appends a new log item, evicting the oldest if at capacity.
|
||||
pub fn push(&mut self, item: NetworkLogItem, ctx: &mut ModelContext<Self>) {
|
||||
// `BoundedVecDeque::push_back` returns the evicted item when the
|
||||
// store is at capacity; we discard it since the pane only needs the
|
||||
// most recent entries.
|
||||
let _evicted = self.items.push_back(item);
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
/// Returns the current snapshot as a single string with one item per line,
|
||||
/// in chronological order. Returns an empty string when no items have been
|
||||
/// captured.
|
||||
pub fn snapshot_text(&self) -> String {
|
||||
let mut out = String::new();
|
||||
for (i, item) in self.items.iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push('\n');
|
||||
}
|
||||
out.push_str(&item.0);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Installs network logging hooks that listen for requests that pass
|
||||
/// through the provided HTTP clients and forward them to this model.
|
||||
///
|
||||
/// The logging happens via an async channel so that request hooks never
|
||||
/// block on the main thread. Items are delivered to the model on the main
|
||||
/// thread through [`ModelContext::spawn_stream_local`].
|
||||
pub fn install_on_clients<'a>(
|
||||
&mut self,
|
||||
http_clients: impl IntoIterator<Item = &'a mut http_client::Client>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let (tx, rx) = async_channel::bounded::<NetworkLogItem>(NETWORK_LOGGING_MAX_QUEUE_SIZE);
|
||||
ctx.spawn_stream_local(rx, |model, item, ctx| model.push(item, ctx), |_, _| {});
|
||||
|
||||
for client in http_clients {
|
||||
let request_tx = tx.clone();
|
||||
client.set_before_request_fn(Box::new(move |request, serialized_payload| {
|
||||
if !request_tx.is_closed()
|
||||
&& let Err(error) = request_tx.try_send(NetworkLogItem::request(
|
||||
request,
|
||||
serialized_payload.clone(),
|
||||
chrono::Local::now().fixed_offset(),
|
||||
))
|
||||
{
|
||||
log::error!("Error sending request from HTTP client to logging task: {error}");
|
||||
}
|
||||
}));
|
||||
|
||||
let response_tx = tx.clone();
|
||||
client.set_after_response_fn(Box::new(move |response| {
|
||||
if !response_tx.is_closed()
|
||||
&& let Err(error) = response_tx.try_send(NetworkLogItem::response(
|
||||
response,
|
||||
chrono::Local::now().fixed_offset(),
|
||||
))
|
||||
{
|
||||
log::error!("Error sending response from HTTP client to logging task: {error}");
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of items currently retained. Exposed for tests.
|
||||
#[cfg(test)]
|
||||
fn len(&self) -> usize {
|
||||
self.items.len()
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for NetworkLogModel {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl SingletonEntity for NetworkLogModel {}
|
||||
|
||||
/// Represents an item (either a request or response) captured for the network
|
||||
/// activity log. The inner string contains a timestamp and the
|
||||
/// [`Debug`]-formatted representation of the request or response, matching the
|
||||
/// format previously written to `warp_network.log`.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct NetworkLogItem(String);
|
||||
|
||||
impl NetworkLogItem {
|
||||
pub fn request(
|
||||
request: &reqwest::Request,
|
||||
serialized_payload: Option<String>,
|
||||
timestamp: DateTime<FixedOffset>,
|
||||
) -> Self {
|
||||
Self(format!(
|
||||
"[{}]: {:?}{}",
|
||||
timestamp.format("%Y-%m-%d %H:%M:%S,%3f"),
|
||||
request,
|
||||
serialized_payload.map_or("".to_owned(), |payload| format!("\nBody {payload}"))
|
||||
))
|
||||
}
|
||||
|
||||
pub fn response(response: &reqwest::Response, timestamp: DateTime<FixedOffset>) -> Self {
|
||||
Self(format!(
|
||||
"[{}]: {:?}",
|
||||
timestamp.format("%Y-%m-%d %H:%M:%S,%3f"),
|
||||
response
|
||||
))
|
||||
}
|
||||
|
||||
/// Constructs a log item directly from a pre-formatted string. Used in
|
||||
/// tests where we don't have a real `reqwest` request/response handy.
|
||||
#[cfg(test)]
|
||||
pub fn from_string(s: impl Into<String>) -> Self {
|
||||
Self(s.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for NetworkLogItem {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "network_logging_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,71 @@
|
||||
use galaxyui_core::App;
|
||||
|
||||
use super::{NETWORK_LOGGING_MAX_ITEMS, NetworkLogItem, NetworkLogModel};
|
||||
|
||||
#[test]
|
||||
fn empty_snapshot_is_empty_string() {
|
||||
App::test((), |app| async move {
|
||||
let model = app.add_singleton_model(|_| NetworkLogModel::default());
|
||||
model.read(&app, |model, _| {
|
||||
assert_eq!(model.snapshot_text(), "");
|
||||
assert_eq!(model.len(), 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_joins_items_with_newlines() {
|
||||
App::test((), |mut app| async move {
|
||||
let model = app.add_singleton_model(|_| NetworkLogModel::default());
|
||||
model.update(&mut app, |model, ctx| {
|
||||
model.push(NetworkLogItem::from_string("first"), ctx);
|
||||
model.push(NetworkLogItem::from_string("second"), ctx);
|
||||
model.push(NetworkLogItem::from_string("third"), ctx);
|
||||
});
|
||||
model.read(&app, |model, _| {
|
||||
assert_eq!(model.snapshot_text(), "first\nsecond\nthird");
|
||||
assert_eq!(model.len(), 3);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_beyond_capacity_drops_oldest() {
|
||||
App::test((), |mut app| async move {
|
||||
let model = app.add_singleton_model(|_| NetworkLogModel::default());
|
||||
// Push exactly the capacity; the snapshot should contain all items
|
||||
// and the count should equal the capacity.
|
||||
model.update(&mut app, |model, ctx| {
|
||||
for i in 0..NETWORK_LOGGING_MAX_ITEMS {
|
||||
model.push(NetworkLogItem::from_string(format!("item-{i}")), ctx);
|
||||
}
|
||||
});
|
||||
model.read(&app, |model, _| {
|
||||
assert_eq!(model.len(), NETWORK_LOGGING_MAX_ITEMS);
|
||||
// The oldest item is still present when we are exactly at capacity.
|
||||
assert!(model.snapshot_text().starts_with("item-0\n"));
|
||||
});
|
||||
|
||||
// Push one more: the oldest item should be evicted so the store stays
|
||||
// at capacity, and the snapshot should start at item-1 now.
|
||||
model.update(&mut app, |model, ctx| {
|
||||
model.push(NetworkLogItem::from_string("overflow"), ctx);
|
||||
});
|
||||
model.read(&app, |model, _| {
|
||||
assert_eq!(model.len(), NETWORK_LOGGING_MAX_ITEMS);
|
||||
assert!(!model.snapshot_text().contains("item-0\n"));
|
||||
assert!(model.snapshot_text().starts_with("item-1\n"));
|
||||
assert!(model.snapshot_text().ends_with("\noverflow"));
|
||||
});
|
||||
|
||||
// Pushing many additional items keeps the store at capacity.
|
||||
model.update(&mut app, |model, ctx| {
|
||||
for i in 0..10 {
|
||||
model.push(NetworkLogItem::from_string(format!("extra-{i}")), ctx);
|
||||
}
|
||||
});
|
||||
model.read(&app, |model, _| {
|
||||
assert_eq!(model.len(), NETWORK_LOGGING_MAX_ITEMS);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,15 +1,12 @@
|
||||
//! Supporting types for persisting cloud objects to SQLite.
|
||||
//! Supporting helpers for persisting cloud-object permissions to SQLite.
|
||||
|
||||
use anyhow::anyhow;
|
||||
use cloud_objects::auth::UserUid;
|
||||
use cloud_objects::cloud_object::{CloudLinkSharing, CloudObjectGuest, ServerObjectContainer};
|
||||
use cloud_objects::drive::sharing::{SharingAccessLevel, Subject, TeamKind, UserKind};
|
||||
use cloud_objects::ids::ServerId;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
auth::UserUid,
|
||||
cloud_object::{CloudLinkSharing, CloudObjectGuest, ServerObjectContainer},
|
||||
drive::sharing::{SharingAccessLevel, Subject, TeamKind, UserKind},
|
||||
ids::ServerId,
|
||||
};
|
||||
|
||||
/// Decode a link-sharing setting.
|
||||
pub fn decode_link_sharing(
|
||||
encoded_access_level: &str,
|
||||
@@ -125,8 +122,14 @@ impl PersistedSubject {
|
||||
Err(anyhow!("Session-sharing teams not supported"))
|
||||
}
|
||||
},
|
||||
// Link sharing is persisted separately in the schema.
|
||||
Subject::AnyoneWithLink(_) => Err(anyhow!("Anyone with the link not supported")),
|
||||
Subject::AnyoneWithLink(_) => {
|
||||
// Link sharing is persisted separately in the schema.
|
||||
Err(anyhow!("Anyone with the link not supported"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "encoded_permissions_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
use anyhow::{Context as _, Result};
|
||||
use serde::de::DeserializeOwned;
|
||||
use galaxy_core::channel::ChannelState;
|
||||
use galaxy_core::errors::{ErrorExt, register_error};
|
||||
|
||||
use crate::base_client::{AmbientHeaderPolicy, BaseClient};
|
||||
|
||||
/// Typed error for HTTP operations so retry classifiers can inspect status failures.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[error("HTTP request failed with status {status}: {body}")]
|
||||
pub struct HttpStatusError {
|
||||
pub status: u16,
|
||||
pub body: String,
|
||||
}
|
||||
|
||||
impl ErrorExt for HttpStatusError {
|
||||
fn is_actionable(&self) -> bool {
|
||||
!matches!(self.status, 408 | 429)
|
||||
}
|
||||
}
|
||||
|
||||
register_error!(HttpStatusError);
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct PublicApiError {
|
||||
error: String,
|
||||
}
|
||||
|
||||
impl BaseClient {
|
||||
/// Sends a GET request to a public API endpoint and returns the raw response on success.
|
||||
///
|
||||
/// Unlike [`get_public_api`], this does not attempt JSON deserialization on the
|
||||
/// response body, allowing the caller to decode it however they need.
|
||||
pub async fn get_public_api_response(&self, path: &str) -> Result<http_client::Response> {
|
||||
let auth_token = self
|
||||
.get_or_refresh_access_token()
|
||||
.await
|
||||
.context("Failed to get access token for API request")?;
|
||||
let url = format!("{}/api/v1/{path}", ChannelState::server_root_url());
|
||||
let mut request = self.http_client().get(&url);
|
||||
if let Some(token) = auth_token.as_bearer_token() {
|
||||
request = request.bearer_auth(token);
|
||||
}
|
||||
for (name, value) in self
|
||||
.ambient_headers(AmbientHeaderPolicy::inherit_all())
|
||||
.await?
|
||||
{
|
||||
request = request.header(name, value);
|
||||
}
|
||||
|
||||
let response = request
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("Failed to send API request to {url}"))?;
|
||||
|
||||
if response.status().is_success() {
|
||||
Ok(response)
|
||||
} else {
|
||||
self.observe_iap_challenge(&response);
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
let status_error = HttpStatusError {
|
||||
status: status.as_u16(),
|
||||
body: body.clone(),
|
||||
};
|
||||
match serde_json::from_str::<PublicApiError>(&body) {
|
||||
Ok(error_response) => {
|
||||
Err(anyhow::Error::new(status_error).context(error_response.error))
|
||||
}
|
||||
Err(_) => Err(anyhow::Error::new(status_error)
|
||||
.context(format!("API request failed with status {status}"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends a GET request to a public API endpoint.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `path` - Endpoint path relative to `/api/v1` (e.g., "agent/tasks/{task_id}")
|
||||
pub async fn get_public_api<R>(&self, path: &str) -> Result<R>
|
||||
where
|
||||
R: DeserializeOwned,
|
||||
{
|
||||
let response = self.get_public_api_response(path).await?;
|
||||
let response_url = response.url().clone();
|
||||
response
|
||||
.json::<R>()
|
||||
.await
|
||||
.with_context(|| format!("Failed to deserialize response from {response_url}"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "public_api_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,184 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use futures::executor::block_on;
|
||||
use galaxy_core::channel::ChannelState;
|
||||
use galaxy_core::errors::AnyhowErrorExt as _;
|
||||
use warp_server_auth::auth_state::AuthState;
|
||||
|
||||
use super::HttpStatusError;
|
||||
use crate::auth::AuthEvent;
|
||||
use crate::base_client::{
|
||||
AGENT_SOURCE_HEADER, AuthenticatedGraphqlConfig, BaseClient, CLOUD_AGENT_ID_HEADER,
|
||||
GraphqlRoutingConfig,
|
||||
};
|
||||
struct EmptyIapTokenProvider;
|
||||
|
||||
impl http_client::iap::IapTokenProvider for EmptyIapTokenProvider {
|
||||
fn cached_token(&self) -> Option<String> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn base_client(observe_iap_challenges: bool) -> (BaseClient, async_channel::Receiver<AuthEvent>) {
|
||||
base_client_with_auth(AuthState::new_for_test(), None, observe_iap_challenges)
|
||||
}
|
||||
|
||||
fn base_client_with_auth(
|
||||
auth_state: AuthState,
|
||||
agent_source: Option<String>,
|
||||
observe_iap_challenges: bool,
|
||||
) -> (BaseClient, async_channel::Receiver<AuthEvent>) {
|
||||
let (event_sender, event_receiver) = async_channel::unbounded();
|
||||
(
|
||||
BaseClient::new(
|
||||
Arc::new(http_client::Client::new()),
|
||||
Arc::new(auth_state),
|
||||
event_sender,
|
||||
agent_source,
|
||||
GraphqlRoutingConfig::default(),
|
||||
AuthenticatedGraphqlConfig::default(),
|
||||
observe_iap_challenges.then(|| {
|
||||
Arc::new(EmptyIapTokenProvider) as Arc<dyn http_client::iap::IapTokenProvider>
|
||||
}),
|
||||
),
|
||||
event_receiver,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_api_get_deserializes_successful_response() {
|
||||
let _request = {
|
||||
let mut server = ChannelState::mock_server();
|
||||
server
|
||||
.mock("GET", "/api/v1/test/success")
|
||||
.with_status(200)
|
||||
.with_body(r#"{"value":"success"}"#)
|
||||
.create()
|
||||
};
|
||||
let (base_client, _) = base_client(false);
|
||||
|
||||
let response =
|
||||
block_on(base_client.get_public_api::<serde_json::Value>("test/success")).unwrap();
|
||||
|
||||
assert_eq!(response, serde_json::json!({ "value": "success" }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_api_get_sends_bearer_auth() {
|
||||
let _request = {
|
||||
let mut server = ChannelState::mock_server();
|
||||
server
|
||||
.mock("GET", "/api/v1/test/bearer-auth")
|
||||
.match_header("authorization", "Bearer bearer-token")
|
||||
.with_status(200)
|
||||
.with_body(r#"{"value":"success"}"#)
|
||||
.create()
|
||||
};
|
||||
let auth_state = AuthState::new_logged_out_for_test();
|
||||
auth_state.set_remote_server_bearer_token("bearer-token".to_string());
|
||||
let (base_client, _) = base_client_with_auth(auth_state, None, false);
|
||||
|
||||
block_on(base_client.get_public_api::<serde_json::Value>("test/bearer-auth")).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_api_get_inherits_ambient_headers() {
|
||||
let _request = {
|
||||
let mut server = ChannelState::mock_server();
|
||||
server
|
||||
.mock("GET", "/api/v1/test/ambient-headers")
|
||||
.match_header(CLOUD_AGENT_ID_HEADER, "ambient-task")
|
||||
.match_header(AGENT_SOURCE_HEADER, "cloud-mode")
|
||||
.with_status(200)
|
||||
.with_body(r#"{"value":"success"}"#)
|
||||
.create()
|
||||
};
|
||||
let (base_client, _) = base_client_with_auth(
|
||||
AuthState::new_for_test(),
|
||||
Some("cloud-mode".to_string()),
|
||||
false,
|
||||
);
|
||||
base_client.set_ambient_agent_task_id(Some("ambient-task".to_string()));
|
||||
|
||||
block_on(base_client.get_public_api::<serde_json::Value>("test/ambient-headers")).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_public_api_failure_preserves_shared_status_error() {
|
||||
let _request = {
|
||||
let mut server = ChannelState::mock_server();
|
||||
server
|
||||
.mock("GET", "/api/v1/test/failure")
|
||||
.with_status(500)
|
||||
.with_body(r#"{"error":"request failed"}"#)
|
||||
.create()
|
||||
};
|
||||
let (base_client, event_receiver) = base_client(false);
|
||||
|
||||
let error =
|
||||
block_on(base_client.get_public_api::<serde_json::Value>("test/failure")).unwrap_err();
|
||||
|
||||
assert!(error.to_string().contains("request failed"));
|
||||
assert!(
|
||||
error
|
||||
.chain()
|
||||
.any(|cause| cause.downcast_ref::<HttpStatusError>().is_some())
|
||||
);
|
||||
assert!(event_receiver.try_recv().is_err());
|
||||
}
|
||||
#[test]
|
||||
fn iap_challenge_failure_emits_event_when_observation_is_enabled() {
|
||||
let _request = {
|
||||
let mut server = ChannelState::mock_server();
|
||||
server
|
||||
.mock("GET", "/api/v1/agent/identities")
|
||||
.with_status(401)
|
||||
.with_header(http_client::iap::IAP_GENERATED_RESPONSE_HEADER, "true")
|
||||
.with_body(r#"{"error":"IAP challenge"}"#)
|
||||
.create()
|
||||
};
|
||||
let (base_client, event_receiver) = base_client(true);
|
||||
|
||||
let error =
|
||||
block_on(base_client.get_public_api::<serde_json::Value>("agent/identities")).unwrap_err();
|
||||
|
||||
assert!(error.to_string().contains("IAP challenge"));
|
||||
assert!(
|
||||
error
|
||||
.chain()
|
||||
.any(|cause| cause.downcast_ref::<HttpStatusError>().is_some())
|
||||
);
|
||||
assert!(matches!(
|
||||
event_receiver.try_recv().unwrap(),
|
||||
AuthEvent::IapChallengeReceived
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iap_challenge_failure_emits_no_event_when_observation_is_disabled() {
|
||||
let _request = {
|
||||
let mut server = ChannelState::mock_server();
|
||||
server
|
||||
.mock("GET", "/api/v1/agent/identities")
|
||||
.with_status(401)
|
||||
.with_header(http_client::iap::IAP_GENERATED_RESPONSE_HEADER, "true")
|
||||
.with_body(r#"{"error":"IAP challenge"}"#)
|
||||
.create()
|
||||
};
|
||||
let (base_client, event_receiver) = base_client(false);
|
||||
|
||||
block_on(base_client.get_public_api::<serde_json::Value>("agent/identities")).unwrap_err();
|
||||
|
||||
assert!(event_receiver.try_recv().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_status_error_actionability_ignores_retryable_client_failures() {
|
||||
let error = anyhow::Error::new(HttpStatusError {
|
||||
status: 429,
|
||||
body: "retry later".to_string(),
|
||||
})
|
||||
.context("Public API request failed");
|
||||
|
||||
assert!(!error.is_actionable());
|
||||
}
|
||||
Reference in New Issue
Block a user