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

This commit is contained in:
Ryan Ward
2026-07-01 16:08:58 -05:00
parent 2f64909469
commit 4770ac06b5
3662 changed files with 414574 additions and 89772 deletions
+548 -1
View File
@@ -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,
+1
View File
@@ -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);
}
+482
View File
@@ -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
View File
@@ -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::*;
+7 -3
View File
@@ -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());
}