first pass of merging in warp (doesn't build)
This commit is contained in:
@@ -1,3 +1,550 @@
|
||||
pub mod user_uid;
|
||||
mod session;
|
||||
|
||||
use std::result::Result as StdResult;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context as _, Result, anyhow};
|
||||
use async_trait::async_trait;
|
||||
use cynic::{MutationBuilder, QueryBuilder};
|
||||
use firebase::FirebaseError;
|
||||
use instant::Duration;
|
||||
#[cfg(any(test, feature = "test-util"))]
|
||||
use mockall::automock;
|
||||
pub use session::*;
|
||||
use thiserror::Error;
|
||||
pub use user_uid::{TEST_USER_EMAIL, TEST_USER_UID, UserUid};
|
||||
use galaxy_core::errors::{AnyhowErrorExt, ErrorExt, register_error};
|
||||
use warp_graphql::client::Operation;
|
||||
use warp_graphql::mutations::create_anonymous_user::{
|
||||
AnonymousUserType, CreateAnonymousUser, CreateAnonymousUserResult, CreateAnonymousUserVariables,
|
||||
};
|
||||
use warp_graphql::mutations::expire_api_key::{
|
||||
ExpireApiKey, ExpireApiKeyResult, ExpireApiKeyVariables,
|
||||
};
|
||||
use warp_graphql::mutations::generate_api_key::{
|
||||
GenerateApiKey, GenerateApiKeyInput, GenerateApiKeyResult, GenerateApiKeyVariables,
|
||||
};
|
||||
use warp_graphql::mutations::mint_custom_token::{MintCustomTokenResult, MintCustomTokenVariables};
|
||||
use warp_graphql::mutations::set_user_is_onboarded::{
|
||||
SetUserIsOnboarded, SetUserIsOnboardedResult, SetUserIsOnboardedVariables,
|
||||
};
|
||||
use warp_graphql::mutations::update_user_settings::{
|
||||
UpdateUserSettings, UpdateUserSettingsInput, UpdateUserSettingsResult,
|
||||
UpdateUserSettingsVariables,
|
||||
};
|
||||
use warp_graphql::queries::api_keys::{
|
||||
ApiKeyProperties, ApiKeyPropertiesResult, ApiKeys, ApiKeysVariables,
|
||||
};
|
||||
use warp_graphql::queries::get_user::{GetUser, GetUserVariables, UserOutput as GqlUserOutput};
|
||||
use warp_graphql::queries::get_user_settings::{GetUserSettings, GetUserSettingsVariables};
|
||||
use warp_server_auth::credentials::{AuthToken, Credentials, FirebaseToken, LoginToken};
|
||||
pub use warp_server_auth::user_uid;
|
||||
|
||||
use crate::base_client::BaseClient;
|
||||
use crate::graphql_helpers::send_graphql_request;
|
||||
use crate::ids::ApiKeyUid;
|
||||
|
||||
/// Header key used to associate unauthenticated requests with an experiment identity.
|
||||
pub const EXPERIMENT_ID_HEADER: &str = "X-Warp-Experiment-Id";
|
||||
|
||||
/// A named agent identity from the public API.
|
||||
#[derive(Clone, Debug, serde::Deserialize)]
|
||||
pub struct AgentIdentity {
|
||||
pub uid: String,
|
||||
pub name: String,
|
||||
pub available: bool,
|
||||
}
|
||||
|
||||
/// Wrapper for the `GET /api/v1/agent/identities` response.
|
||||
#[derive(serde::Deserialize)]
|
||||
struct AgentIdentitiesResponse {
|
||||
agents: Vec<AgentIdentity>,
|
||||
}
|
||||
|
||||
/// User settings that are stored server-side on a per-user basis.
|
||||
#[derive(Copy, Clone, Debug, Default)]
|
||||
pub struct SyncedUserSettings {
|
||||
pub is_cloud_conversation_storage_enabled: bool,
|
||||
pub is_crash_reporting_enabled: bool,
|
||||
pub is_telemetry_enabled: bool,
|
||||
}
|
||||
|
||||
/// Protocol-level results of fetching the current user.
|
||||
pub struct FetchUserResult {
|
||||
pub user_output: GqlUserOutput,
|
||||
/// The credentials used to authenticate this user.
|
||||
pub credentials: Credentials,
|
||||
/// Whether this attempt to fetch the user was for refreshing an existing logged-in user.
|
||||
pub from_refresh: bool,
|
||||
}
|
||||
|
||||
#[cfg_attr(any(test, feature = "test-util"), automock)]
|
||||
#[cfg_attr(not(target_family = "wasm"), async_trait)]
|
||||
#[cfg_attr(target_family = "wasm", async_trait(?Send))]
|
||||
pub trait AuthClient: Send + Sync {
|
||||
/// Creates an anonymous user who is allowed to use Warp but may lack the ability
|
||||
/// to interact with particular features.
|
||||
async fn create_anonymous_user(
|
||||
&self,
|
||||
referral_code: Option<String>,
|
||||
anonymous_user_type: AnonymousUserType,
|
||||
) -> Result<CreateAnonymousUserResult>;
|
||||
|
||||
/// Returns the cached access token if it is still valid.
|
||||
///
|
||||
/// If it has expired, this fetches a new access token using the user's refresh
|
||||
/// token, caches it, and then returns it. It may return an auth mode that does
|
||||
/// not require an Authorization header, such as session cookies or test credentials.
|
||||
async fn get_or_refresh_access_token(&self) -> Result<AuthToken>;
|
||||
|
||||
/// Fetches the user's metadata and authentication tokens.
|
||||
async fn fetch_user(
|
||||
&self,
|
||||
token: LoginToken,
|
||||
for_refresh: bool,
|
||||
) -> StdResult<FetchUserResult, UserAuthenticationError>;
|
||||
|
||||
/// Creates and fetches a new custom token for the current user from Firebase.
|
||||
///
|
||||
/// This only works for anonymous users and surfaces an error if the user is not anonymous.
|
||||
async fn fetch_new_custom_token(&self) -> Result<MintCustomTokenResult>;
|
||||
|
||||
/// Handles the response from [`Self::fetch_new_custom_token`] by returning the newly minted custom token.
|
||||
fn on_custom_token_fetched(
|
||||
&self,
|
||||
response: Result<MintCustomTokenResult>,
|
||||
) -> Result<String, MintCustomTokenError>;
|
||||
|
||||
/// Queries warp-server for a set of the currently logged-in user's fields.
|
||||
async fn fetch_user_properties<'a>(&self, auth_token: Option<&'a str>)
|
||||
-> Result<GqlUserOutput>;
|
||||
|
||||
/// Returns the user's settings retrieved from the server, if any.
|
||||
///
|
||||
/// The user may not have server-side settings if they onboarded before telemetry
|
||||
/// opt-out launched, have not logged in since the launch, and have never changed
|
||||
/// defaults for any setting in [`SyncedUserSettings`]. If the fetched settings
|
||||
/// object exists but is missing required fields, or if the request itself fails,
|
||||
/// this returns an error.
|
||||
async fn get_user_settings(&self) -> Result<Option<SyncedUserSettings>>;
|
||||
|
||||
async fn set_is_telemetry_enabled(&self, value: bool) -> Result<()>;
|
||||
|
||||
async fn set_is_crash_reporting_enabled(&self, value: bool) -> Result<()>;
|
||||
|
||||
async fn set_is_cloud_conversation_storage_enabled(&self, value: bool) -> Result<()>;
|
||||
|
||||
/// Sends a request to update the user's settings on the server with values in the given input.
|
||||
async fn update_user_settings(&self, input: UpdateUserSettingsInput) -> Result<()>;
|
||||
|
||||
async fn set_user_is_onboarded(&self) -> Result<bool>;
|
||||
|
||||
/// Requests a device authorization code from the server for headless CLI or SDK authentication.
|
||||
async fn request_device_code(
|
||||
&self,
|
||||
) -> StdResult<oauth2::StandardDeviceAuthorizationResponse, UserAuthenticationError>;
|
||||
|
||||
/// Waits for the request to be approved or rejected and exchanges it for a short-lived custom access token.
|
||||
async fn exchange_device_access_token(
|
||||
&self,
|
||||
details: &oauth2::StandardDeviceAuthorizationResponse,
|
||||
timeout: Duration,
|
||||
) -> StdResult<FirebaseToken, UserAuthenticationError>;
|
||||
|
||||
async fn list_api_keys(&self) -> Result<Vec<ApiKeyProperties>>;
|
||||
|
||||
async fn create_api_key(
|
||||
&self,
|
||||
name: String,
|
||||
team_id: Option<cynic::Id>,
|
||||
agent_uid: Option<cynic::Id>,
|
||||
expires_at: Option<warp_graphql::scalars::Time>,
|
||||
) -> Result<GenerateApiKeyResult>;
|
||||
|
||||
async fn expire_api_key(&self, key_uid: &ApiKeyUid) -> Result<ExpireApiKeyResult>;
|
||||
|
||||
/// Fetches the list of named agent identities for the user's team.
|
||||
async fn list_agent_identities(&self) -> Result<Vec<AgentIdentity>>;
|
||||
}
|
||||
|
||||
/// Implements the [`AuthClient`] trait on top of a base client and auth session.
|
||||
pub struct AuthClientImpl {
|
||||
base_client: Arc<BaseClient>,
|
||||
auth_session: Arc<AuthSession>,
|
||||
}
|
||||
|
||||
impl AuthClientImpl {
|
||||
pub fn new(base_client: Arc<BaseClient>) -> Self {
|
||||
let auth_session = base_client.auth_session();
|
||||
Self {
|
||||
base_client,
|
||||
auth_session,
|
||||
}
|
||||
}
|
||||
|
||||
async fn update_settings(
|
||||
&self,
|
||||
input: UpdateUserSettingsInput,
|
||||
unknown_error_message: &'static str,
|
||||
) -> Result<()> {
|
||||
let operation = UpdateUserSettings::build(UpdateUserSettingsVariables {
|
||||
input,
|
||||
request_context: warp_graphql::client::get_request_context(),
|
||||
});
|
||||
let result = send_graphql_request(&self.base_client, operation, None)
|
||||
.await?
|
||||
.update_user_settings;
|
||||
Self::on_settings_updated(result, unknown_error_message)
|
||||
}
|
||||
|
||||
fn on_settings_updated(
|
||||
result: UpdateUserSettingsResult,
|
||||
unknown_error_message: &'static str,
|
||||
) -> Result<()> {
|
||||
match result {
|
||||
UpdateUserSettingsResult::UpdateUserSettingsOutput(_) => Ok(()),
|
||||
UpdateUserSettingsResult::UserFacingError(error) => Err(anyhow!(
|
||||
warp_graphql::client::get_user_facing_error_message(error)
|
||||
)),
|
||||
UpdateUserSettingsResult::Unknown => Err(anyhow!(unknown_error_message)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(not(target_family = "wasm"), async_trait)]
|
||||
#[cfg_attr(target_family = "wasm", async_trait(?Send))]
|
||||
impl AuthClient for AuthClientImpl {
|
||||
async fn create_anonymous_user(
|
||||
&self,
|
||||
referral_code: Option<String>,
|
||||
anonymous_user_type: AnonymousUserType,
|
||||
) -> Result<CreateAnonymousUserResult> {
|
||||
let operation = CreateAnonymousUser::build(CreateAnonymousUserVariables {
|
||||
input: warp_graphql::mutations::create_anonymous_user::CreateAnonymousUserInput {
|
||||
anonymous_user_type,
|
||||
expiration_type: warp_graphql::mutations::create_anonymous_user::AnonymousUserExpirationType::NoExpiration,
|
||||
referral_code,
|
||||
},
|
||||
request_context: warp_graphql::client::get_request_context(),
|
||||
});
|
||||
let response = operation
|
||||
.send_request(
|
||||
self.base_client.owned_http_client(),
|
||||
self.base_client.graphql_request_options_with_token(None),
|
||||
)
|
||||
.await?;
|
||||
Ok(response
|
||||
.data
|
||||
.ok_or_else(|| anyhow!("missing data in response"))?
|
||||
.create_anonymous_user)
|
||||
}
|
||||
|
||||
async fn get_or_refresh_access_token(&self) -> Result<AuthToken> {
|
||||
self.auth_session.get_or_refresh_access_token().await
|
||||
}
|
||||
|
||||
async fn fetch_user(
|
||||
&self,
|
||||
token: LoginToken,
|
||||
for_refresh: bool,
|
||||
) -> StdResult<FetchUserResult, UserAuthenticationError> {
|
||||
let new_credentials = self.auth_session.exchange_credentials(token).await?;
|
||||
let auth_token = new_credentials.bearer_token();
|
||||
let user_output = self
|
||||
.fetch_user_properties(auth_token.as_bearer_token())
|
||||
.await
|
||||
.context("Failed to fetch user response data")
|
||||
.map_err(UserAuthenticationError::Unexpected)?;
|
||||
// Store the owner type if using an API key.
|
||||
let new_credentials = match new_credentials {
|
||||
Credentials::ApiKey { key, .. } => Credentials::ApiKey {
|
||||
key,
|
||||
owner_type: user_output.api_key_owner_type,
|
||||
},
|
||||
other => other,
|
||||
};
|
||||
Ok(FetchUserResult {
|
||||
user_output,
|
||||
credentials: new_credentials,
|
||||
from_refresh: for_refresh,
|
||||
})
|
||||
}
|
||||
|
||||
async fn fetch_new_custom_token(&self) -> Result<MintCustomTokenResult> {
|
||||
let operation = warp_graphql::mutations::mint_custom_token::MintCustomToken::build(
|
||||
MintCustomTokenVariables {
|
||||
request_context: warp_graphql::client::get_request_context(),
|
||||
},
|
||||
);
|
||||
let response = send_graphql_request(&self.base_client, operation, None).await?;
|
||||
Ok(response.mint_custom_token)
|
||||
}
|
||||
|
||||
fn on_custom_token_fetched(
|
||||
&self,
|
||||
response: Result<MintCustomTokenResult>,
|
||||
) -> Result<String, MintCustomTokenError> {
|
||||
match response {
|
||||
Ok(MintCustomTokenResult::MintCustomTokenOutput(output)) => Ok(output.custom_token),
|
||||
Ok(MintCustomTokenResult::UserFacingError(error)) => {
|
||||
Err(MintCustomTokenError::UserFacingError(
|
||||
warp_graphql::client::get_user_facing_error_message(error),
|
||||
))
|
||||
}
|
||||
Ok(MintCustomTokenResult::Unknown) | Err(_) => Err(MintCustomTokenError::Unknown),
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_user_properties<'a>(
|
||||
&self,
|
||||
auth_token: Option<&'a str>,
|
||||
) -> Result<GqlUserOutput> {
|
||||
let operation = GetUser::build(GetUserVariables {
|
||||
request_context: warp_graphql::client::get_request_context(),
|
||||
});
|
||||
let mut options = self
|
||||
.base_client
|
||||
.graphql_request_options_with_token(auth_token.map(ToOwned::to_owned));
|
||||
options.headers.insert(
|
||||
EXPERIMENT_ID_HEADER.to_string(),
|
||||
self.base_client.anonymous_id(),
|
||||
);
|
||||
let response = operation
|
||||
.send_request(self.base_client.owned_http_client(), options)
|
||||
.await?
|
||||
.data
|
||||
.ok_or_else(|| anyhow!("Expected valid response.data"))?;
|
||||
match response.user {
|
||||
warp_graphql::queries::get_user::UserResult::UserOutput(user_output) => Ok(user_output),
|
||||
warp_graphql::queries::get_user::UserResult::Unknown => {
|
||||
Err(anyhow!("Unable to fetch user"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_user_settings(&self) -> Result<Option<SyncedUserSettings>> {
|
||||
let operation = GetUserSettings::build(GetUserSettingsVariables {
|
||||
request_context: warp_graphql::client::get_request_context(),
|
||||
});
|
||||
let response = send_graphql_request(self.base_client.as_ref(), operation, None).await?;
|
||||
match response.user {
|
||||
warp_graphql::queries::get_user_settings::UserResult::UserOutput(user_output) => {
|
||||
Ok(user_output
|
||||
.user
|
||||
.settings
|
||||
.map(|settings| SyncedUserSettings {
|
||||
is_cloud_conversation_storage_enabled: settings
|
||||
.is_cloud_conversation_storage_enabled,
|
||||
is_crash_reporting_enabled: settings.is_crash_reporting_enabled,
|
||||
is_telemetry_enabled: settings.is_telemetry_enabled,
|
||||
}))
|
||||
}
|
||||
warp_graphql::queries::get_user_settings::UserResult::Unknown => {
|
||||
Err(anyhow!("Unable to fetch user settings"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn set_is_telemetry_enabled(&self, value: bool) -> Result<()> {
|
||||
self.update_settings(
|
||||
UpdateUserSettingsInput {
|
||||
telemetry_enabled: Some(value),
|
||||
..Default::default()
|
||||
},
|
||||
"failed to set telemetry enabled",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn set_is_crash_reporting_enabled(&self, value: bool) -> Result<()> {
|
||||
self.update_settings(
|
||||
UpdateUserSettingsInput {
|
||||
crash_reporting_enabled: Some(value),
|
||||
..Default::default()
|
||||
},
|
||||
"failed to set crash reporting enabled",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn set_is_cloud_conversation_storage_enabled(&self, value: bool) -> Result<()> {
|
||||
self.update_settings(
|
||||
UpdateUserSettingsInput {
|
||||
cloud_conversation_storage_enabled: Some(value),
|
||||
..Default::default()
|
||||
},
|
||||
"failed to set cloud conversation storage enabled",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn update_user_settings(&self, input: UpdateUserSettingsInput) -> Result<()> {
|
||||
self.update_settings(input, "failed to update user settings")
|
||||
.await
|
||||
}
|
||||
|
||||
async fn set_user_is_onboarded(&self) -> Result<bool> {
|
||||
let operation = SetUserIsOnboarded::build(SetUserIsOnboardedVariables {
|
||||
request_context: warp_graphql::client::get_request_context(),
|
||||
});
|
||||
let result = send_graphql_request(self.base_client.as_ref(), operation, None)
|
||||
.await?
|
||||
.set_user_is_onboarded;
|
||||
match result {
|
||||
SetUserIsOnboardedResult::SetUserIsOnboardedOutput(_) => Ok(true),
|
||||
SetUserIsOnboardedResult::UserFacingError(error) => Err(anyhow!(
|
||||
warp_graphql::client::get_user_facing_error_message(error)
|
||||
)),
|
||||
SetUserIsOnboardedResult::Unknown => Err(anyhow!("failed to set user is onboarded")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn request_device_code(
|
||||
&self,
|
||||
) -> StdResult<oauth2::StandardDeviceAuthorizationResponse, UserAuthenticationError> {
|
||||
self.auth_session.request_device_code().await
|
||||
}
|
||||
|
||||
async fn exchange_device_access_token(
|
||||
&self,
|
||||
details: &oauth2::StandardDeviceAuthorizationResponse,
|
||||
timeout: Duration,
|
||||
) -> StdResult<FirebaseToken, UserAuthenticationError> {
|
||||
self.auth_session
|
||||
.exchange_device_access_token(details, timeout)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn list_api_keys(&self) -> Result<Vec<ApiKeyProperties>> {
|
||||
let operation = ApiKeys::build(ApiKeysVariables {
|
||||
request_context: warp_graphql::client::get_request_context(),
|
||||
});
|
||||
let response = send_graphql_request(self.base_client.as_ref(), operation, None).await?;
|
||||
match response.api_keys {
|
||||
ApiKeyPropertiesResult::ApiKeyPropertiesOutput(output) => Ok(output.api_keys),
|
||||
ApiKeyPropertiesResult::UserFacingError(error) => Err(anyhow!(
|
||||
warp_graphql::client::get_user_facing_error_message(error)
|
||||
)),
|
||||
ApiKeyPropertiesResult::Unknown => Err(anyhow!("failed to fetch API keys")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn create_api_key(
|
||||
&self,
|
||||
name: String,
|
||||
team_id: Option<cynic::Id>,
|
||||
agent_uid: Option<cynic::Id>,
|
||||
expires_at: Option<warp_graphql::scalars::Time>,
|
||||
) -> Result<GenerateApiKeyResult> {
|
||||
let operation = GenerateApiKey::build(GenerateApiKeyVariables {
|
||||
input: GenerateApiKeyInput {
|
||||
name,
|
||||
team_id,
|
||||
agent_uid,
|
||||
expires_at,
|
||||
},
|
||||
request_context: warp_graphql::client::get_request_context(),
|
||||
});
|
||||
let response = send_graphql_request(self.base_client.as_ref(), operation, None).await?;
|
||||
Ok(response.generate_api_key)
|
||||
}
|
||||
|
||||
async fn expire_api_key(&self, key_uid: &ApiKeyUid) -> Result<ExpireApiKeyResult> {
|
||||
let operation = ExpireApiKey::build(ExpireApiKeyVariables {
|
||||
key_uid: key_uid.into(),
|
||||
request_context: warp_graphql::client::get_request_context(),
|
||||
});
|
||||
let response = send_graphql_request(self.base_client.as_ref(), operation, None).await?;
|
||||
Ok(response.expire_api_key)
|
||||
}
|
||||
|
||||
async fn list_agent_identities(&self) -> Result<Vec<AgentIdentity>> {
|
||||
let response: AgentIdentitiesResponse =
|
||||
self.base_client.get_public_api("agent/identities").await?;
|
||||
Ok(response.agents)
|
||||
}
|
||||
}
|
||||
|
||||
/// Error type when retrieving a user and validating it against Firebase.
|
||||
#[derive(Error, Debug)]
|
||||
pub enum UserAuthenticationError {
|
||||
/// The user's refresh token is invalid, which can occur after the user changes
|
||||
/// a password for Google or GitHub authentication.
|
||||
#[error("Firebase returned a token error when fetching an ID token")]
|
||||
DeniedAccessToken(FirebaseError),
|
||||
/// The user's account is invalid, which can occur after the user requests
|
||||
/// account deletion under GDPR or CCPA.
|
||||
#[error("Firebase returned a user error when fetching an ID token")]
|
||||
UserAccountDisabled(FirebaseError),
|
||||
#[error("Invalid state parameter in auth redirect")]
|
||||
InvalidStateParameter,
|
||||
#[error("Missing state parameter in auth redirect")]
|
||||
MissingStateParameter,
|
||||
#[error("unexpected error occurred when fetching an ID token: {0:#}")]
|
||||
Unexpected(#[from] anyhow::Error),
|
||||
}
|
||||
|
||||
impl ErrorExt for UserAuthenticationError {
|
||||
fn is_actionable(&self) -> bool {
|
||||
match self {
|
||||
UserAuthenticationError::DeniedAccessToken(error) => {
|
||||
// If a request to our server failed because the user's refresh token
|
||||
// has expired, they should reauthenticate, but there is no value in
|
||||
// reporting this back to us.
|
||||
log::info!("ignoring denied access token error: {error:#}");
|
||||
false
|
||||
}
|
||||
UserAuthenticationError::UserAccountDisabled(error) => {
|
||||
// If the user's account is disabled, they cannot make requests.
|
||||
log::info!("ignoring user account disabled error: {error:#}");
|
||||
false
|
||||
}
|
||||
UserAuthenticationError::Unexpected(error) => error.is_actionable(),
|
||||
UserAuthenticationError::InvalidStateParameter
|
||||
| UserAuthenticationError::MissingStateParameter => {
|
||||
// These errors remain actionable because a surplus could indicate a problem in
|
||||
// the login flow, although an attempt to spoof the `state` variable is not actionable.
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
register_error!(UserAuthenticationError);
|
||||
|
||||
impl From<FirebaseError> for UserAuthenticationError {
|
||||
fn from(error: FirebaseError) -> Self {
|
||||
// These Firebase errors indicate that the user's token is in an errored state
|
||||
// and that the user likely just needs to log in again.
|
||||
const SOFT_ERRORS: &[&str] = &[
|
||||
"TOKEN_EXPIRED",
|
||||
"INVALID_REFRESH_TOKEN",
|
||||
"MISSING_REFRESH_TOKEN",
|
||||
];
|
||||
// These Firebase errors indicate that the user's account is in an errored state
|
||||
// and that the user likely can no longer sign in with it.
|
||||
const HARD_ERRORS: &[&str] = &["USER_DISABLED", "USER_NOT_FOUND"];
|
||||
if SOFT_ERRORS.contains(&error.message.as_str()) {
|
||||
UserAuthenticationError::DeniedAccessToken(error)
|
||||
} else if HARD_ERRORS.contains(&error.message.as_str()) {
|
||||
UserAuthenticationError::UserAccountDisabled(error)
|
||||
} else {
|
||||
UserAuthenticationError::Unexpected(
|
||||
anyhow::Error::from(error)
|
||||
.context("Failed to exchange refresh token with access token."),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Error type when minting a new custom token for an anonymous user.
|
||||
#[derive(Error, Debug)]
|
||||
pub enum MintCustomTokenError {
|
||||
#[error("Received a user facing error: {0}")]
|
||||
UserFacingError(String),
|
||||
#[error("Failed to create new custom token with unknown error")]
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "mod_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
use warp_graphql::mutations::update_user_settings::UpdateUserSettingsResult;
|
||||
|
||||
use super::AuthClientImpl;
|
||||
|
||||
#[test]
|
||||
fn unknown_settings_results_preserve_operation_context() {
|
||||
for expected_message in [
|
||||
"failed to set telemetry enabled",
|
||||
"failed to set crash reporting enabled",
|
||||
"failed to set cloud conversation storage enabled",
|
||||
"failed to update user settings",
|
||||
] {
|
||||
let error = AuthClientImpl::on_settings_updated(
|
||||
UpdateUserSettingsResult::Unknown,
|
||||
expected_message,
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(error.to_string(), expected_message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
use std::fmt;
|
||||
use std::result::Result as StdResult;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context as _, Result, bail};
|
||||
use firebase::FetchAccessTokenResponse;
|
||||
use instant::Duration;
|
||||
use oauth2::TokenResponse as _;
|
||||
use url::Url;
|
||||
use galaxy_core::channel::ChannelState;
|
||||
use warp_server_auth::auth_state::AuthState;
|
||||
use warp_server_auth::credentials::{
|
||||
AuthToken, Credentials, FirebaseToken, LoginToken, RefreshToken,
|
||||
};
|
||||
use warp_server_auth::user::FirebaseAuthTokens;
|
||||
use galaxyui_core::r#async::{BoxFuture, Timer};
|
||||
|
||||
use super::UserAuthenticationError;
|
||||
|
||||
const FETCH_ACCESS_TOKEN_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
/// Authentication and authenticated-transport conditions observed by shared client code.
|
||||
#[derive(Clone)]
|
||||
pub enum AuthEvent {
|
||||
/// A staging API call was blocked, which may indicate a firewall misconfiguration.
|
||||
StagingAccessBlocked,
|
||||
/// The user's access token was invalid, so they need to reauthenticate.
|
||||
NeedsReauth,
|
||||
/// The user's account has been disabled.
|
||||
UserAccountDisabled,
|
||||
/// The current bearer token was refreshed.
|
||||
AccessTokenRefreshed {
|
||||
#[cfg_attr(target_family = "wasm", allow(dead_code))]
|
||||
token: String,
|
||||
},
|
||||
/// An Identity-Aware Proxy challenge was received.
|
||||
IapChallengeReceived,
|
||||
}
|
||||
|
||||
impl fmt::Debug for AuthEvent {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::StagingAccessBlocked => f.write_str("StagingAccessBlocked"),
|
||||
Self::NeedsReauth => f.write_str("NeedsReauth"),
|
||||
Self::UserAccountDisabled => f.write_str("UserAccountDisabled"),
|
||||
Self::AccessTokenRefreshed { .. } => f
|
||||
.debug_struct("AccessTokenRefreshed")
|
||||
.field("token", &"<redacted>")
|
||||
.finish(),
|
||||
Self::IapChallengeReceived => f.write_str("IapChallengeReceived"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The OAuth client type configured for Warp's device authorization endpoints.
|
||||
type OAuth2Client = oauth2::basic::BasicClient<
|
||||
oauth2::EndpointNotSet,
|
||||
oauth2::EndpointSet,
|
||||
oauth2::EndpointNotSet,
|
||||
oauth2::EndpointNotSet,
|
||||
oauth2::EndpointSet,
|
||||
>;
|
||||
|
||||
/// Reusable authentication-session mechanics for server clients.
|
||||
///
|
||||
/// An `AuthSession` combines authentication state with the HTTP transport required to
|
||||
/// exchange credentials, refresh access tokens, and complete OAuth device authorization.
|
||||
/// Changes in authentication state that may require reactions from application logic are
|
||||
/// emitted through an [`AuthEvent`] channel.
|
||||
pub struct AuthSession {
|
||||
client: Arc<http_client::Client>,
|
||||
auth_state: Arc<AuthState>,
|
||||
event_sender: async_channel::Sender<AuthEvent>,
|
||||
oauth_client: OAuth2Client,
|
||||
}
|
||||
|
||||
impl AuthSession {
|
||||
pub fn new(
|
||||
client: Arc<http_client::Client>,
|
||||
auth_state: Arc<AuthState>,
|
||||
event_sender: async_channel::Sender<AuthEvent>,
|
||||
) -> Self {
|
||||
Self {
|
||||
client,
|
||||
auth_state,
|
||||
event_sender,
|
||||
oauth_client: Self::create_oauth_client(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn allowed_to_refresh_token(&self) -> bool {
|
||||
self.auth_state
|
||||
.credentials()
|
||||
.is_none_or(|credentials| !credentials.is_externally_managed())
|
||||
}
|
||||
|
||||
pub async fn get_or_refresh_access_token(&self) -> Result<AuthToken> {
|
||||
if cfg!(feature = "skip_login") {
|
||||
bail!("skip_login enabled; failing all authenticated requests");
|
||||
}
|
||||
|
||||
let Some(credentials) = self.auth_state.credentials() else {
|
||||
bail!("missing authentication credentials");
|
||||
};
|
||||
|
||||
match credentials {
|
||||
Credentials::ApiKey { key, .. } => Ok(AuthToken::ApiKey(key)),
|
||||
Credentials::Bearer(token) => Ok(AuthToken::Bearer(token)),
|
||||
Credentials::Firebase(auth_tokens) => {
|
||||
let expiration_time = auth_tokens.expiration_time;
|
||||
|
||||
// Generate a new ID token if the token has expired or will expire in the
|
||||
// next five minutes. This matches the behavior of the Firebase Auth SDK.
|
||||
if chrono::Local::now().fixed_offset() + chrono::Duration::minutes(5)
|
||||
>= expiration_time
|
||||
{
|
||||
let refresh_token = auth_tokens.refresh_token.clone();
|
||||
let firebase_token = FirebaseToken::Refresh(RefreshToken::new(refresh_token));
|
||||
let result = self.fetch_auth_tokens(firebase_token).await;
|
||||
|
||||
if let Err(UserAuthenticationError::DeniedAccessToken(_)) = result {
|
||||
let _ = self.event_sender.send(AuthEvent::NeedsReauth).await;
|
||||
}
|
||||
let new_firebase_token_info = result?;
|
||||
self.auth_state
|
||||
.update_firebase_tokens(new_firebase_token_info.clone());
|
||||
let _ = self
|
||||
.event_sender
|
||||
.send(AuthEvent::AccessTokenRefreshed {
|
||||
token: new_firebase_token_info.id_token.clone(),
|
||||
})
|
||||
.await;
|
||||
Ok(AuthToken::Firebase(new_firebase_token_info.id_token))
|
||||
} else {
|
||||
Ok(AuthToken::Firebase(auth_tokens.id_token))
|
||||
}
|
||||
}
|
||||
Credentials::SessionCookie => Ok(AuthToken::NoAuth),
|
||||
#[cfg(any(feature = "integration_tests", feature = "skip_login"))]
|
||||
Credentials::Test => Ok(AuthToken::NoAuth),
|
||||
}
|
||||
}
|
||||
|
||||
/// Exchanges a long-lived token for fresh [`Credentials`].
|
||||
pub async fn exchange_credentials(
|
||||
&self,
|
||||
token: LoginToken,
|
||||
) -> StdResult<Credentials, UserAuthenticationError> {
|
||||
match token {
|
||||
LoginToken::Firebase(firebase_token) => {
|
||||
let tokens = self.fetch_auth_tokens(firebase_token).await?;
|
||||
Ok(Credentials::Firebase(tokens))
|
||||
}
|
||||
LoginToken::ApiKey(key) => Ok(Credentials::ApiKey {
|
||||
key,
|
||||
owner_type: None,
|
||||
}),
|
||||
LoginToken::SessionCookie => Ok(Credentials::SessionCookie),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn request_device_code(
|
||||
&self,
|
||||
) -> StdResult<oauth2::StandardDeviceAuthorizationResponse, UserAuthenticationError> {
|
||||
self.oauth_client
|
||||
.exchange_device_code()
|
||||
.request_async(self.client.as_ref())
|
||||
.await
|
||||
.context("Failed to generate device code")
|
||||
.map_err(UserAuthenticationError::Unexpected)
|
||||
}
|
||||
|
||||
pub async fn exchange_device_access_token(
|
||||
&self,
|
||||
details: &oauth2::StandardDeviceAuthorizationResponse,
|
||||
timeout: Duration,
|
||||
) -> StdResult<FirebaseToken, UserAuthenticationError> {
|
||||
let result = self
|
||||
.oauth_client
|
||||
.exchange_device_access_token(details)
|
||||
.request_async(
|
||||
self.client.as_ref(),
|
||||
|delay| async move {
|
||||
let _ = Timer::after(delay).await;
|
||||
},
|
||||
Some(timeout),
|
||||
)
|
||||
.await
|
||||
.context("Unable to obtain access token")
|
||||
.map_err(UserAuthenticationError::Unexpected)?;
|
||||
// Firebase does not directly support the device flow, so the server mints a
|
||||
// short-lived custom access token that can be exchanged for a refresh token.
|
||||
Ok(FirebaseToken::Custom(
|
||||
result.access_token().secret().to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
fn create_oauth_client() -> OAuth2Client {
|
||||
let server_root =
|
||||
Url::parse(&ChannelState::server_root_url()).expect("Server root URL must be valid");
|
||||
let token_url = server_root
|
||||
.join("/api/v1/oauth/token")
|
||||
.expect("Invalid token URL");
|
||||
let device_url = server_root
|
||||
.join("/api/v1/oauth/device/auth")
|
||||
.expect("Invalid device URL");
|
||||
|
||||
oauth2::basic::BasicClient::new(oauth2::ClientId::new("warp-cli".to_string()))
|
||||
.set_token_uri(oauth2::TokenUrl::from_url(token_url))
|
||||
.set_device_authorization_url(oauth2::DeviceAuthorizationUrl::from_url(device_url))
|
||||
}
|
||||
|
||||
fn fetch_auth_tokens(
|
||||
&self,
|
||||
token: FirebaseToken,
|
||||
) -> BoxFuture<'static, StdResult<FirebaseAuthTokens, UserAuthenticationError>> {
|
||||
let client = self.client.clone();
|
||||
Box::pin(async move {
|
||||
let firebase_api_key = ChannelState::firebase_api_key();
|
||||
let url = token.access_token_url(&firebase_api_key);
|
||||
let request_body = token.access_token_request_body();
|
||||
let proxy_url = token.proxy_url(&ChannelState::server_root_url(), &firebase_api_key);
|
||||
let response = match client
|
||||
.post(&url)
|
||||
.form(&request_body)
|
||||
.timeout(FETCH_ACCESS_TOKEN_TIMEOUT)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(response) => match response.error_for_status_ref() {
|
||||
Ok(_) => Ok(response),
|
||||
Err(error) => {
|
||||
log::warn!(
|
||||
"Request to firebase to fetch access token completed, but was unsuccessful: {error:?}"
|
||||
);
|
||||
|
||||
Self::fetch_access_token_via_proxy(client, &request_body, proxy_url).await
|
||||
}
|
||||
},
|
||||
Err(error) => {
|
||||
log::warn!(
|
||||
"Failed to make response to firebase to fetch access token: {error:?}"
|
||||
);
|
||||
|
||||
Self::fetch_access_token_via_proxy(client, &request_body, proxy_url).await
|
||||
}
|
||||
}?;
|
||||
|
||||
let response = response
|
||||
.json::<FetchAccessTokenResponse>()
|
||||
.await
|
||||
.map_err(anyhow::Error::from)?;
|
||||
match response {
|
||||
FetchAccessTokenResponse::Success {
|
||||
id_token,
|
||||
expires_in,
|
||||
refresh_token,
|
||||
} => Ok(FirebaseAuthTokens::from_response(
|
||||
id_token,
|
||||
refresh_token,
|
||||
expires_in,
|
||||
)?),
|
||||
FetchAccessTokenResponse::Error { error } => Err(error.into()),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn fetch_access_token_via_proxy<'a>(
|
||||
client: Arc<http_client::Client>,
|
||||
request_body: &'a [(&'a str, &'a str)],
|
||||
proxy_url: String,
|
||||
) -> BoxFuture<'a, Result<http_client::Response>> {
|
||||
Box::pin(async move {
|
||||
client
|
||||
.post(&proxy_url)
|
||||
.form(request_body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(anyhow::Error::from)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "session_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,67 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::Utc;
|
||||
use futures::executor::block_on;
|
||||
use warp_server_auth::auth_state::AuthState;
|
||||
use warp_server_auth::credentials::{AuthToken, Credentials, LoginToken};
|
||||
use warp_server_auth::user::FirebaseAuthTokens;
|
||||
|
||||
use super::AuthSession;
|
||||
|
||||
fn session_with_state(
|
||||
auth_state: Arc<AuthState>,
|
||||
) -> (AuthSession, async_channel::Receiver<super::AuthEvent>) {
|
||||
let (event_sender, event_receiver) = async_channel::unbounded();
|
||||
let session = AuthSession::new(
|
||||
Arc::new(http_client::Client::new()),
|
||||
auth_state,
|
||||
event_sender,
|
||||
);
|
||||
(session, event_receiver)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bearer_credentials_are_returned_without_session_refresh_events() {
|
||||
let auth_state = Arc::new(AuthState::new_logged_out_for_test());
|
||||
auth_state.set_credentials(Some(Credentials::Bearer("daemon-token".to_string())));
|
||||
let (session, event_receiver) = session_with_state(auth_state);
|
||||
|
||||
assert!(!session.allowed_to_refresh_token());
|
||||
let token = block_on(session.get_or_refresh_access_token()).unwrap();
|
||||
|
||||
assert!(matches!(token, AuthToken::Bearer(token) if token == "daemon-token"));
|
||||
assert!(event_receiver.try_recv().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unexpired_firebase_credentials_return_cached_token_without_refresh_events() {
|
||||
let auth_state = Arc::new(AuthState::new_logged_out_for_test());
|
||||
auth_state.set_credentials(Some(Credentials::Firebase(FirebaseAuthTokens {
|
||||
id_token: "cached-token".to_string(),
|
||||
refresh_token: "refresh-token".to_string(),
|
||||
expiration_time: Utc::now().fixed_offset() + chrono::Duration::hours(1),
|
||||
})));
|
||||
let (session, event_receiver) = session_with_state(auth_state);
|
||||
|
||||
let token = block_on(session.get_or_refresh_access_token()).unwrap();
|
||||
|
||||
assert!(matches!(token, AuthToken::Firebase(token) if token == "cached-token"));
|
||||
assert!(event_receiver.try_recv().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_key_exchange_defers_owner_type_until_user_properties_are_fetched() {
|
||||
let auth_state = Arc::new(AuthState::new_logged_out_for_test());
|
||||
let (session, _) = session_with_state(auth_state);
|
||||
|
||||
let credentials =
|
||||
block_on(session.exchange_credentials(LoginToken::ApiKey("api-key".to_string()))).unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
credentials,
|
||||
Credentials::ApiKey {
|
||||
key,
|
||||
owner_type: None
|
||||
} if key == "api-key"
|
||||
));
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
use std::{fmt, sync::LazyLock};
|
||||
use std::fmt;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user