first pass of merging in warp (doesn't build)
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
/// The response header set by GCP Identity-Aware Proxy on its generated responses.
|
||||
pub const IAP_GENERATED_RESPONSE_HEADER: &str = "x-goog-iap-generated-response";
|
||||
|
||||
/// HTTP header used to attach the IAP bearer token to outbound requests.
|
||||
pub const IAP_PROXY_AUTH_HEADER: &str = "Proxy-Authorization";
|
||||
|
||||
pub fn proxy_auth_header(token: &str) -> (&'static str, String) {
|
||||
(IAP_PROXY_AUTH_HEADER, format!("Bearer {token}"))
|
||||
}
|
||||
|
||||
/// Returns `true` if the given status + headers appear to be an IAP-generated
|
||||
/// challenge (302, 401, or 403 with the IAP response header present). Useful
|
||||
/// for detecting stale credentials and triggering a re-fetch.
|
||||
pub fn is_iap_challenge(status: reqwest::StatusCode, headers: &http::HeaderMap) -> bool {
|
||||
let is_challenge_status = status == reqwest::StatusCode::FOUND
|
||||
|| status == reqwest::StatusCode::UNAUTHORIZED
|
||||
|| status == reqwest::StatusCode::FORBIDDEN;
|
||||
|
||||
is_challenge_status && headers.get(IAP_GENERATED_RESPONSE_HEADER).is_some()
|
||||
}
|
||||
|
||||
/// Source of the current IAP bearer token.
|
||||
pub trait IapTokenProvider: Send + Sync {
|
||||
fn cached_token(&self) -> Option<String>;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "iap_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,42 @@
|
||||
use http::{HeaderMap, HeaderValue};
|
||||
use reqwest::StatusCode;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn headers_with_iap() -> HeaderMap {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
IAP_GENERATED_RESPONSE_HEADER,
|
||||
HeaderValue::from_static("true"),
|
||||
);
|
||||
headers
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn challenge_statuses_with_iap_header_are_challenges() {
|
||||
for status in [
|
||||
StatusCode::FOUND,
|
||||
StatusCode::UNAUTHORIZED,
|
||||
StatusCode::FORBIDDEN,
|
||||
] {
|
||||
assert!(is_iap_challenge(status, &headers_with_iap()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn challenge_status_without_iap_header_is_not_a_challenge() {
|
||||
assert!(!is_iap_challenge(StatusCode::FORBIDDEN, &HeaderMap::new()));
|
||||
assert!(!is_iap_challenge(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
&HeaderMap::new()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_challenge_status_with_iap_header_is_not_a_challenge() {
|
||||
assert!(!is_iap_challenge(StatusCode::OK, &headers_with_iap()));
|
||||
assert!(!is_iap_challenge(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
&headers_with_iap()
|
||||
));
|
||||
}
|
||||
+142
-41
@@ -1,5 +1,8 @@
|
||||
pub mod iap;
|
||||
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use std::{fmt, future};
|
||||
|
||||
@@ -8,20 +11,20 @@ use async_compat::{Compat, CompatExt};
|
||||
use async_stream::stream;
|
||||
use bytes::Bytes;
|
||||
use futures::{Stream, StreamExt};
|
||||
use galaxy_core::{
|
||||
channel::{Channel, ChannelState},
|
||||
execution_mode,
|
||||
operating_system_info::OperatingSystemInfo,
|
||||
report_error,
|
||||
};
|
||||
use galaxy_core::channel::{Channel, ChannelState};
|
||||
use galaxy_core::operating_system_info::OperatingSystemInfo;
|
||||
use galaxy_core::{execution_mode, report_error};
|
||||
use http::HeaderValue;
|
||||
pub use http::header::AUTHORIZATION;
|
||||
use http::header::HeaderName;
|
||||
pub use http::{HeaderMap, StatusCode, header::AUTHORIZATION};
|
||||
pub use http::{HeaderMap, StatusCode};
|
||||
use reqwest::IntoUrl;
|
||||
use reqwest_eventsource::RequestBuilderExt;
|
||||
use serde::Serialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
|
||||
use crate::iap::{IapTokenProvider, proxy_auth_header};
|
||||
|
||||
pub mod headers {
|
||||
/// Custom Warp header indicating the version of the Warp app.
|
||||
pub const CLIENT_RELEASE_VERSION_HEADER_KEY: &str = "X-Warp-Client-Version";
|
||||
@@ -62,6 +65,11 @@ pub struct Client {
|
||||
|
||||
/// A callback that is executed on after each response is received.
|
||||
after_response_received: Option<ResponseHookFn>,
|
||||
|
||||
/// If set, provides IAP bearer tokens to attach as `Proxy-Authorization`
|
||||
/// headers on outbound requests to the Warp staging server. Wired in by
|
||||
/// the app layer on IAP-enabled builds (staging).
|
||||
iap_token_provider: Option<Arc<dyn IapTokenProvider>>,
|
||||
}
|
||||
|
||||
/// Type for 'hook' functions to be executed prior to sending a request. A reference to the
|
||||
@@ -145,9 +153,7 @@ impl Client {
|
||||
let client_builder = reqwest::ClientBuilder::new()
|
||||
// Don't load any SSL/TLS certificates, as doing so can be slow and we should
|
||||
// never be making real requests in tests.
|
||||
.tls_built_in_native_certs(false)
|
||||
.tls_built_in_root_certs(false)
|
||||
.tls_built_in_webpki_certs(false)
|
||||
.tls_certs_only([])
|
||||
// Disable proxy usage in tests, as loading system proxy configuration can be
|
||||
// slow.
|
||||
.no_proxy();
|
||||
@@ -159,6 +165,7 @@ impl Client {
|
||||
wrapped: client,
|
||||
before_request_sent: None,
|
||||
after_response_received: None,
|
||||
iap_token_provider: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -170,10 +177,15 @@ impl Client {
|
||||
self.after_response_received = Some(hook_fn);
|
||||
}
|
||||
|
||||
pub fn set_iap_token_provider(&mut self, provider: Arc<dyn IapTokenProvider>) {
|
||||
self.iap_token_provider = Some(provider);
|
||||
}
|
||||
|
||||
fn builder(
|
||||
&self,
|
||||
wrapped: reqwest::RequestBuilder,
|
||||
include_warp_headers: bool,
|
||||
iap_token: Option<String>,
|
||||
) -> RequestBuilder<'_> {
|
||||
let mut builder = RequestBuilder {
|
||||
wrapped,
|
||||
@@ -186,42 +198,53 @@ impl Client {
|
||||
builder = Self::add_warp_http_headers(builder);
|
||||
}
|
||||
|
||||
if let Some(token) = iap_token {
|
||||
let (name, value) = proxy_auth_header(&token);
|
||||
builder = builder.header(name, value);
|
||||
}
|
||||
|
||||
builder
|
||||
}
|
||||
|
||||
pub fn get<U: IntoUrl + Clone>(&self, url: U) -> RequestBuilder<'_> {
|
||||
self.builder(
|
||||
self.wrapped.get(url.clone()),
|
||||
Self::include_warp_http_headers(url),
|
||||
)
|
||||
let include_warp_headers = Self::include_warp_http_headers(url.clone());
|
||||
let iap_token = self.iap_token_for(url.clone());
|
||||
self.builder(self.wrapped.get(url), include_warp_headers, iap_token)
|
||||
}
|
||||
|
||||
pub fn post<U: IntoUrl + Clone>(&self, url: U) -> RequestBuilder<'_> {
|
||||
self.builder(
|
||||
self.wrapped.post(url.clone()),
|
||||
Self::include_warp_http_headers(url),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn patch<U: IntoUrl + Clone>(&self, url: U) -> RequestBuilder<'_> {
|
||||
self.builder(
|
||||
self.wrapped.patch(url.clone()),
|
||||
Self::include_warp_http_headers(url),
|
||||
)
|
||||
let include_warp_headers = Self::include_warp_http_headers(url.clone());
|
||||
let iap_token = self.iap_token_for(url.clone());
|
||||
self.builder(self.wrapped.post(url), include_warp_headers, iap_token)
|
||||
}
|
||||
|
||||
pub fn put<U: IntoUrl + Clone>(&self, url: U) -> RequestBuilder<'_> {
|
||||
self.builder(
|
||||
self.wrapped.put(url.clone()),
|
||||
Self::include_warp_http_headers(url),
|
||||
)
|
||||
let include_warp_headers = Self::include_warp_http_headers(url.clone());
|
||||
let iap_token = self.iap_token_for(url.clone());
|
||||
self.builder(self.wrapped.put(url), include_warp_headers, iap_token)
|
||||
}
|
||||
|
||||
pub fn patch<U: IntoUrl + Clone>(&self, url: U) -> RequestBuilder<'_> {
|
||||
let include_warp_headers = Self::include_warp_http_headers(url.clone());
|
||||
let iap_token = self.iap_token_for(url.clone());
|
||||
self.builder(self.wrapped.patch(url), include_warp_headers, iap_token)
|
||||
}
|
||||
|
||||
pub fn delete<U: IntoUrl + Clone>(&self, url: U) -> RequestBuilder<'_> {
|
||||
self.builder(
|
||||
self.wrapped.delete(url.clone()),
|
||||
Self::include_warp_http_headers(url),
|
||||
)
|
||||
let include_warp_headers = Self::include_warp_http_headers(url.clone());
|
||||
let iap_token = self.iap_token_for(url.clone());
|
||||
self.builder(self.wrapped.delete(url), include_warp_headers, iap_token)
|
||||
}
|
||||
|
||||
/// Returns the IAP bearer token to attach to a request targeting
|
||||
/// `url`, scoped to the Warp server's origin.
|
||||
fn iap_token_for<U: IntoUrl>(&self, url: U) -> Option<String> {
|
||||
let provider = self.iap_token_provider.as_ref()?;
|
||||
let url = url.into_url().ok()?;
|
||||
if !is_warp_server_origin(&url) {
|
||||
return None;
|
||||
}
|
||||
provider.cached_token()
|
||||
}
|
||||
|
||||
/// Helper method to determine if the request should include warp-specific headers. The only case
|
||||
@@ -326,6 +349,11 @@ impl Client {
|
||||
}
|
||||
|
||||
pub async fn execute(&self, request: Request) -> reqwest::Result<Response> {
|
||||
self.execute_inner(request).await
|
||||
}
|
||||
|
||||
/// Core request execution logic shared by all platforms.
|
||||
async fn execute_inner(&self, request: Request) -> reqwest::Result<Response> {
|
||||
let Request {
|
||||
wrapped: request,
|
||||
serialized_payload,
|
||||
@@ -358,6 +386,16 @@ impl Client {
|
||||
}
|
||||
}
|
||||
|
||||
fn is_warp_server_origin(url: &reqwest::Url) -> bool {
|
||||
[
|
||||
ChannelState::server_root_url(),
|
||||
ChannelState::rtc_http_url(),
|
||||
]
|
||||
.iter()
|
||||
.filter_map(|candidate| reqwest::Url::parse(candidate.as_ref()).ok())
|
||||
.any(|candidate| candidate.origin() == url.origin())
|
||||
}
|
||||
|
||||
impl<'a> RequestBuilder<'a> {
|
||||
pub fn build(self) -> reqwest::Result<Request> {
|
||||
self.build_split().1
|
||||
@@ -555,6 +593,16 @@ impl<'a> RequestBuilder<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Attach a `multipart/form-data` body.
|
||||
/// Not available on wasm because reqwest's multipart builder API is native-only.
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub fn multipart(self, form: reqwest::multipart::Form) -> RequestBuilder<'a> {
|
||||
Self {
|
||||
wrapped: self.wrapped.multipart(form),
|
||||
..self
|
||||
}
|
||||
}
|
||||
|
||||
/// Prevents the system from sleeping due to idle while this request is in progress.
|
||||
///
|
||||
/// The provided reason will be used in user-visible logging, so make sure it is
|
||||
@@ -567,12 +615,14 @@ impl<'a> RequestBuilder<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// An error returned from `Response::error_for_status` that includes response headers.
|
||||
/// This allows callers to inspect headers (like X-Warp-Error-Code) when handling errors.
|
||||
/// An error returned from `Response::error_for_status` that includes response metadata.
|
||||
/// This allows callers to inspect headers (like X-Warp-Error-Code) and the response body when
|
||||
/// handling errors.
|
||||
#[derive(Debug)]
|
||||
pub struct ResponseError {
|
||||
pub source: reqwest::Error,
|
||||
pub headers: HeaderMap,
|
||||
pub headers: Box<HeaderMap>,
|
||||
pub body: Option<String>,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ResponseError {
|
||||
@@ -619,7 +669,28 @@ impl Response {
|
||||
let headers = self.0.headers().clone();
|
||||
match self.0.error_for_status() {
|
||||
Ok(response) => Ok(Self(response)),
|
||||
Err(source) => Err(ResponseError { source, headers }),
|
||||
Err(source) => Err(ResponseError {
|
||||
source,
|
||||
headers: Box::new(headers),
|
||||
body: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks the response status and returns an error if it's not successful.
|
||||
/// Unlike `error_for_status`, this also reads and preserves the response body on errors.
|
||||
pub async fn error_for_status_with_body(self) -> Result<Self, ResponseError> {
|
||||
let headers = self.0.headers().clone();
|
||||
match self.0.error_for_status_ref() {
|
||||
Ok(_) => Ok(self),
|
||||
Err(source) => {
|
||||
let body = self.text().await.ok();
|
||||
Err(ResponseError {
|
||||
source,
|
||||
headers: Box::new(headers),
|
||||
body,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -629,7 +700,11 @@ impl Response {
|
||||
let headers = self.0.headers().clone();
|
||||
match self.0.error_for_status_ref() {
|
||||
Ok(response) => Ok(response),
|
||||
Err(source) => Err(ResponseError { source, headers }),
|
||||
Err(source) => Err(ResponseError {
|
||||
source,
|
||||
headers: Box::new(headers),
|
||||
body: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -659,18 +734,20 @@ impl<'c> oauth2::AsyncHttpClient<'c> for Client {
|
||||
type Future = Pin<Box<dyn Future<Output = Result<oauth2::HttpResponse, Self::Error>> + 'c>>;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
type Future =
|
||||
Pin<Box<dyn Future<Output = Result<oauth2::HttpResponse, Self::Error>> + Send + Sync + 'c>>;
|
||||
Pin<Box<dyn Future<Output = Result<oauth2::HttpResponse, Self::Error>> + Send + 'c>>;
|
||||
|
||||
fn call(&'c self, request: oauth2::HttpRequest) -> Self::Future {
|
||||
Box::pin(async move {
|
||||
let include_warp_headers = Self::include_warp_http_headers(request.uri().to_string());
|
||||
let uri = request.uri().to_string();
|
||||
let include_warp_headers = Self::include_warp_http_headers(uri.clone());
|
||||
let iap_token = self.iap_token_for(uri);
|
||||
let builder = reqwest::RequestBuilder::from_parts(
|
||||
self.wrapped.clone(),
|
||||
request.try_into().map_err(Box::new)?,
|
||||
);
|
||||
|
||||
let response = self
|
||||
.builder(builder, include_warp_headers)
|
||||
.builder(builder, include_warp_headers, iap_token)
|
||||
.send()
|
||||
.await
|
||||
.map_err(Box::new)?;
|
||||
@@ -693,3 +770,27 @@ impl<'c> oauth2::AsyncHttpClient<'c> for Client {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod origin_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn server_and_rtc_origins_match() {
|
||||
// Derive the expected origins from `ChannelState` so the assertion holds
|
||||
// regardless of which channel config the test build resolves to.
|
||||
let server = reqwest::Url::parse(ChannelState::server_root_url().as_ref()).unwrap();
|
||||
assert!(is_warp_server_origin(&server.join("/graphql/v2").unwrap()));
|
||||
|
||||
let rtc = reqwest::Url::parse(ChannelState::rtc_http_url().as_ref()).unwrap();
|
||||
assert!(is_warp_server_origin(
|
||||
&rtc.join("/api/v1/agent/events/stream").unwrap()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn third_party_origin_does_not_match() {
|
||||
let url = reqwest::Url::parse("https://evil.example.com/graphql/v2").unwrap();
|
||||
assert!(!is_warp_server_origin(&url));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user