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};