diff --git a/crates/turborepo-api-client/src/lib.rs b/crates/turborepo-api-client/src/lib.rs index 85e8b372247e8..9ca495a65250b 100644 --- a/crates/turborepo-api-client/src/lib.rs +++ b/crates/turborepo-api-client/src/lib.rs @@ -44,18 +44,20 @@ pub trait Client { ) -> Result; async fn get_spaces(&self, token: &str, team_id: Option<&str>) -> Result; async fn verify_sso_token(&self, token: &str, token_name: &str) -> Result; - #[allow(clippy::too_many_arguments)] - async fn put_artifact( + async fn handle_403(response: Response) -> Error; + fn make_url(&self, endpoint: &str) -> Result; +} + +#[async_trait] +pub trait ArtifactClient { + async fn get_artifact( &self, hash: &str, - artifact_body: &[u8], - duration: u64, - tag: Option<&str>, token: &str, team_id: Option<&str>, team_slug: Option<&str>, - ) -> Result<()>; - async fn handle_403(response: Response) -> Error; + method: Method, + ) -> Result>; async fn fetch_artifact( &self, hash: &str, @@ -63,22 +65,24 @@ pub trait Client { team_id: Option<&str>, team_slug: Option<&str>, ) -> Result>; - async fn artifact_exists( + #[allow(clippy::too_many_arguments)] + async fn put_artifact( &self, hash: &str, + artifact_body: &[u8], + duration: u64, + tag: Option<&str>, token: &str, team_id: Option<&str>, team_slug: Option<&str>, - ) -> Result>; - async fn get_artifact( + ) -> Result<()>; + async fn artifact_exists( &self, hash: &str, token: &str, team_id: Option<&str>, team_slug: Option<&str>, - method: Method, ) -> Result>; - fn make_url(&self, endpoint: &str) -> Result; } #[derive(Clone)] @@ -220,64 +224,6 @@ impl Client for APIClient { }) } - #[tracing::instrument(skip_all)] - async fn put_artifact( - &self, - hash: &str, - artifact_body: &[u8], - duration: u64, - tag: Option<&str>, - token: &str, - team_id: Option<&str>, - team_slug: Option<&str>, - ) -> Result<()> { - let mut request_url = self.make_url(&format!("/v8/artifacts/{}", hash))?; - let mut allow_auth = true; - - if self.use_preflight { - let preflight_response = self - .do_preflight( - token, - request_url.clone(), - "PUT", - "Authorization, Content-Type, User-Agent, x-artifact-duration, x-artifact-tag", - ) - .await?; - - allow_auth = preflight_response.allow_authorization_header; - request_url = preflight_response.location.clone(); - } - - let mut request_builder = self - .client - .put(request_url) - .header("Content-Type", "application/octet-stream") - .header("x-artifact-duration", duration.to_string()) - .header("User-Agent", self.user_agent.clone()) - .body(artifact_body.to_vec()); - - if allow_auth { - request_builder = request_builder.header("Authorization", format!("Bearer {}", token)); - } - - request_builder = Self::add_team_params(request_builder, team_id, team_slug); - - request_builder = Self::add_ci_header(request_builder); - - if let Some(tag) = tag { - request_builder = request_builder.header("x-artifact-tag", tag); - } - - let response = retry::make_retryable_request(request_builder).await?; - - if response.status() == StatusCode::FORBIDDEN { - return Err(Self::handle_403(response).await); - } - - response.error_for_status()?; - Ok(()) - } - async fn handle_403(response: Response) -> Error { #[derive(Deserialize)] struct WrappedAPIError { @@ -325,16 +271,57 @@ impl Client for APIClient { } } - #[tracing::instrument(skip_all)] - async fn fetch_artifact( + fn make_url(&self, endpoint: &str) -> Result { + let url = format!("{}{}", self.base_url, endpoint); + Url::parse(&url).map_err(|err| Error::InvalidUrl { url, err }) + } +} + +#[async_trait] +impl ArtifactClient for APIClient { + async fn get_artifact( &self, hash: &str, token: &str, team_id: Option<&str>, team_slug: Option<&str>, + method: Method, ) -> Result> { - self.get_artifact(hash, token, team_id, team_slug, Method::GET) - .await + let mut request_url = self.make_url(&format!("/v8/artifacts/{}", hash))?; + let mut allow_auth = true; + + if self.use_preflight { + let preflight_response = self + .do_preflight( + token, + request_url.clone(), + "GET", + "Authorization, User-Agent", + ) + .await?; + + allow_auth = preflight_response.allow_authorization_header; + request_url = preflight_response.location; + }; + + let mut request_builder = self + .client + .request(method, request_url) + .header("User-Agent", self.user_agent.clone()); + + if allow_auth { + request_builder = request_builder.header("Authorization", format!("Bearer {}", token)); + } + + request_builder = Self::add_team_params(request_builder, team_id, team_slug); + + let response = retry::make_retryable_request(request_builder).await?; + + match response.status() { + StatusCode::FORBIDDEN => Err(Self::handle_403(response).await), + StatusCode::NOT_FOUND => Ok(None), + _ => Ok(Some(response.error_for_status()?)), + } } #[tracing::instrument(skip_all)] @@ -349,14 +336,29 @@ impl Client for APIClient { .await } - async fn get_artifact( + #[tracing::instrument(skip_all)] + async fn fetch_artifact( &self, hash: &str, token: &str, team_id: Option<&str>, team_slug: Option<&str>, - method: Method, ) -> Result> { + self.get_artifact(hash, token, team_id, team_slug, Method::GET) + .await + } + + #[tracing::instrument(skip_all)] + async fn put_artifact( + &self, + hash: &str, + artifact_body: &[u8], + duration: u64, + tag: Option<&str>, + token: &str, + team_id: Option<&str>, + team_slug: Option<&str>, + ) -> Result<()> { let mut request_url = self.make_url(&format!("/v8/artifacts/{}", hash))?; let mut allow_auth = true; @@ -365,19 +367,22 @@ impl Client for APIClient { .do_preflight( token, request_url.clone(), - "GET", - "Authorization, User-Agent", + "PUT", + "Authorization, Content-Type, User-Agent, x-artifact-duration, x-artifact-tag", ) .await?; allow_auth = preflight_response.allow_authorization_header; - request_url = preflight_response.location; - }; + request_url = preflight_response.location.clone(); + } let mut request_builder = self .client - .request(method, request_url) - .header("User-Agent", self.user_agent.clone()); + .put(request_url) + .header("Content-Type", "application/octet-stream") + .header("x-artifact-duration", duration.to_string()) + .header("User-Agent", self.user_agent.clone()) + .body(artifact_body.to_vec()); if allow_auth { request_builder = request_builder.header("Authorization", format!("Bearer {}", token)); @@ -385,18 +390,20 @@ impl Client for APIClient { request_builder = Self::add_team_params(request_builder, team_id, team_slug); + request_builder = Self::add_ci_header(request_builder); + + if let Some(tag) = tag { + request_builder = request_builder.header("x-artifact-tag", tag); + } + let response = retry::make_retryable_request(request_builder).await?; - match response.status() { - StatusCode::FORBIDDEN => Err(Self::handle_403(response).await), - StatusCode::NOT_FOUND => Ok(None), - _ => Ok(Some(response.error_for_status()?)), + if response.status() == StatusCode::FORBIDDEN { + return Err(Self::handle_403(response).await); } - } - fn make_url(&self, endpoint: &str) -> Result { - let url = format!("{}{}", self.base_url, endpoint); - Url::parse(&url).map_err(|err| Error::InvalidUrl { url, err }) + response.error_for_status()?; + Ok(()) } } diff --git a/crates/turborepo-auth/src/auth/login.rs b/crates/turborepo-auth/src/auth/login.rs index 7bc69b8e7205a..b18b275ec8fa5 100644 --- a/crates/turborepo-auth/src/auth/login.rs +++ b/crates/turborepo-auth/src/auth/login.rs @@ -99,7 +99,7 @@ mod tests { use std::{assert_matches::assert_matches, sync::atomic::AtomicUsize}; use async_trait::async_trait; - use reqwest::{Method, RequestBuilder, Response}; + use reqwest::{RequestBuilder, Response}; use turborepo_api_client::Client; use turborepo_ui::UI; use turborepo_vercel_api::{ @@ -229,49 +229,9 @@ mod tests { team_id: Some("team_id".to_string()), }) } - async fn put_artifact( - &self, - _hash: &str, - _artifact_body: &[u8], - _duration: u64, - _tag: Option<&str>, - _token: &str, - _team_id: Option<&str>, - _team_slug: Option<&str>, - ) -> turborepo_api_client::Result<()> { - unimplemented!("put_artifact") - } async fn handle_403(_response: Response) -> turborepo_api_client::Error { unimplemented!("handle_403") } - async fn fetch_artifact( - &self, - _hash: &str, - _token: &str, - _team_id: Option<&str>, - _team_slug: Option<&str>, - ) -> turborepo_api_client::Result> { - unimplemented!("fetch_artifact") - } - async fn artifact_exists( - &self, - _hash: &str, - _token: &str, - _team_id: Option<&str>, - _team_slug: Option<&str>, - ) -> turborepo_api_client::Result> { - unimplemented!("artifact_exists") - } - async fn get_artifact( - &self, - _hash: &str, - _token: &str, - _team_id: Option<&str>, - _team_slug: Option<&str>, - _method: Method, - ) -> turborepo_api_client::Result> { - unimplemented!("get_artifact") - } fn make_url(&self, endpoint: &str) -> turborepo_api_client::Result { let url = format!("{}{}", self.base_url, endpoint); Url::parse(&url).map_err(|err| turborepo_api_client::Error::InvalidUrl { url, err }) diff --git a/crates/turborepo-auth/src/auth/sso.rs b/crates/turborepo-auth/src/auth/sso.rs index c4fc0849f3e9c..d58311573a9c3 100644 --- a/crates/turborepo-auth/src/auth/sso.rs +++ b/crates/turborepo-auth/src/auth/sso.rs @@ -126,7 +126,7 @@ mod tests { use std::sync::atomic::AtomicUsize; use async_trait::async_trait; - use reqwest::{Method, RequestBuilder, Response}; + use reqwest::{RequestBuilder, Response}; use turborepo_api_client::Client; use turborepo_ui::UI; use turborepo_vercel_api::{ @@ -245,49 +245,9 @@ mod tests { team_id: Some("team_id".to_string()), }) } - async fn put_artifact( - &self, - _hash: &str, - _artifact_body: &[u8], - _duration: u64, - _tag: Option<&str>, - _token: &str, - _team_id: Option<&str>, - _team_slug: Option<&str>, - ) -> turborepo_api_client::Result<()> { - unimplemented!("put_artifact") - } async fn handle_403(_response: Response) -> turborepo_api_client::Error { unimplemented!("handle_403") } - async fn fetch_artifact( - &self, - _hash: &str, - _token: &str, - _team_id: Option<&str>, - _team_slug: Option<&str>, - ) -> turborepo_api_client::Result> { - unimplemented!("fetch_artifact") - } - async fn artifact_exists( - &self, - _hash: &str, - _token: &str, - _team_id: Option<&str>, - _team_slug: Option<&str>, - ) -> turborepo_api_client::Result> { - unimplemented!("artifact_exists") - } - async fn get_artifact( - &self, - _hash: &str, - _token: &str, - _team_id: Option<&str>, - _team_slug: Option<&str>, - _method: Method, - ) -> turborepo_api_client::Result> { - unimplemented!("get_artifact") - } fn make_url(&self, endpoint: &str) -> turborepo_api_client::Result { let url = format!("{}{}", self.base_url, endpoint); Url::parse(&url).map_err(|err| turborepo_api_client::Error::InvalidUrl { url, err }) diff --git a/crates/turborepo-auth/src/lib.rs b/crates/turborepo-auth/src/lib.rs index 481ced92e78eb..7b6ede5362e2b 100644 --- a/crates/turborepo-auth/src/lib.rs +++ b/crates/turborepo-auth/src/lib.rs @@ -23,8 +23,117 @@ pub enum Token { impl Token { pub fn into_inner(self) -> String { match self { - Self::Existing(s) => s, - Self::New(s) => s, + Self::Existing(token) | Self::New(token) => token.as_str(), + } + } +} + +fn current_unix_time() -> u128 { + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("Time went backwards") + .as_millis() +} + +// As of the time of writing, this should always be true, since a token that +// isn't active returns an error when fetching metadata for the token. +fn is_token_active(metadata: &ResponseTokenMetadata, current_time: u128) -> bool { + let active_at = metadata.active_at; + + let earliest_expiration = metadata + .scopes + .iter() + .filter_map(|scope| scope.expires_at) + .min(); + + // Not all scopes have an expiration date, so we need to check if all of them + // are expired. If there isn't an expiration date, we assume they are infinite + // and therefore cannot be expired. + let all_scopes_active = + earliest_expiration.map_or(true, |expiration| current_time < expiration); + + let token_active = active_at <= current_time; + + token_active && all_scopes_active +} + +fn token_has_permission(metadata: &ResponseTokenMetadata) -> bool { + metadata + .scopes + .iter() + .any(|scope| scope.scope_type == "team" && scope.origin == "vercel") +} + +#[cfg(test)] +mod tests { + use turborepo_vercel_api::token::Scope; + + use super::*; + + #[test] + fn test_is_token_active() { + let current_time = current_unix_time(); + let quick_scope = |expiry| Scope { + expires_at: expiry, + scope_type: "".to_string(), + origin: "".to_string(), + created_at: 0, + team_id: None, + }; + let mock_response = |active_at, scopes| ResponseTokenMetadata { + active_at, + scopes, + // These fields don't matter in the test + id: "".to_string(), + name: "".to_string(), + token_type: "".to_string(), + origin: "".to_string(), + created_at: 0, + }; + + let cases = vec![ + // Case: Token active, no scopes (implicitly infinite) + (current_time - 100, vec![], true), + // Case: Token active, one scope without expiration + (current_time - 100, vec![quick_scope(None)], true), + // Case: Token active, one scope expired + ( + current_time - 100, + vec![quick_scope(Some(current_time - 1))], + false, + ), + // Case: Token active, one scope not expired + ( + current_time - 100, + vec![quick_scope(Some(current_time + 11))], + true, + ), + // Case: Token active, all scopes not expired + ( + current_time - 100, + vec![ + quick_scope(Some(current_time + 11)), + quick_scope(Some(current_time + 10)), + ], + true, + ), + // Case: Token inactive (future `active_at`) + ( + current_time + 1000, + vec![quick_scope(Some(current_time + 20))], + false, + ), + ]; + + for (active_at, scopes, expected) in cases { + let metadata = mock_response(active_at, scopes); + assert_eq!( + is_token_active(&metadata, current_time), + expected, + "Test failed for active_at: {}", + active_at + ); } } } diff --git a/crates/turborepo-cache/src/http.rs b/crates/turborepo-cache/src/http.rs index 6803beb9b9a0f..fa11eab0ee1c4 100644 --- a/crates/turborepo-cache/src/http.rs +++ b/crates/turborepo-cache/src/http.rs @@ -4,7 +4,8 @@ use tracing::debug; use turbopath::{AbsoluteSystemPath, AbsoluteSystemPathBuf, AnchoredSystemPathBuf}; use turborepo_analytics::AnalyticsSender; use turborepo_api_client::{ - analytics, analytics::AnalyticsEvent, APIAuth, APIClient, Client, Response, + analytics::{self, AnalyticsEvent}, + APIAuth, APIClient, ArtifactClient, Response, }; use crate::{