diff --git a/crates/turborepo-auth/src/auth/login.rs b/crates/turborepo-auth/src/auth/login.rs index b38effd29bbf2..91f24cce594bc 100644 --- a/crates/turborepo-auth/src/auth/login.rs +++ b/crates/turborepo-auth/src/auth/login.rs @@ -4,13 +4,10 @@ pub use error::Error; use reqwest::Url; use tokio::sync::OnceCell; use tracing::warn; -use turborepo_api_client::{Client, TokenClient}; -use turborepo_ui::start_spinner; +use turborepo_api_client::{CacheClient, Client, TokenClient}; +use turborepo_ui::{start_spinner, BOLD, UI}; -use crate::{ - auth::{check_user_token, extract_vercel_token}, - error, ui, LoginOptions, Token, -}; +use crate::{auth::extract_vercel_token, error, ui, LoginOptions, Token}; const DEFAULT_HOST_NAME: &str = "127.0.0.1"; const DEFAULT_PORT: u16 = 9789; @@ -21,7 +18,9 @@ const DEFAULT_PORT: u16 = 9789; /// /// First checks if an existing option has been passed in, then if the login is /// to Vercel, checks if the user has a Vercel CLI token on disk. -pub async fn login(options: &LoginOptions<'_, T>) -> Result { +pub async fn login( + options: &LoginOptions<'_, T>, +) -> Result { let LoginOptions { api_client, ui, @@ -31,13 +30,31 @@ pub async fn login(options: &LoginOptions<'_, T>) -> Re existing_token, } = *options; // Deref or we get double references for each of these + // I created a closure that gives back a closure since the `is_valid` checks do + // a call to get the user, so instead of doing that multiple times we have + // `is_valid` give back the user email. + // + // In the future I want to make the Token have some non-skewable information and + // be able to get rid of this, but it works for now. + let valid_token_callback = |message: &str, ui: &UI| { + let message = message.to_string(); + let ui = *ui; + move |user_email: &str| { + println!("{}", ui.apply(BOLD.apply_to(message))); + ui::print_cli_authorized(user_email, &ui); + } + }; // Check if passed in token exists first. if let Some(token) = existing_token { - if Token::existing(token.to_string()) - .is_valid(api_client) + let token = Token::existing(token.into()); + if token + .is_valid( + api_client, + Some(valid_token_callback("Existing token found!", ui)), + ) .await? { - return check_user_token(token, ui, api_client, "Existing token found!").await; + return Ok(token); } } @@ -46,7 +63,16 @@ pub async fn login(options: &LoginOptions<'_, T>) -> Re // The extraction can return an error, but we don't want to fail the login if // the token is not found. if let Ok(token) = extract_vercel_token() { - return check_user_token(&token, ui, api_client, "Existing Vercel token found!").await; + let token = Token::existing(token); + if token + .is_valid( + api_client, + Some(valid_token_callback("Existing Vercel token found!", ui)), + ) + .await? + { + return Ok(token); + } } } @@ -104,11 +130,12 @@ mod tests { use std::{assert_matches::assert_matches, sync::atomic::AtomicUsize}; use async_trait::async_trait; - use reqwest::{RequestBuilder, Response}; + use reqwest::{Method, RequestBuilder, Response}; use turborepo_api_client::Client; use turborepo_ui::UI; use turborepo_vercel_api::{ - Membership, Role, SpacesResponse, Team, TeamsResponse, User, UserResponse, VerifiedSsoUser, + CachingStatus, CachingStatusResponse, Membership, Role, SpacesResponse, Team, + TeamsResponse, User, UserResponse, VerifiedSsoUser, }; use turborepo_vercel_api_mock::start_test_server; @@ -263,6 +290,60 @@ mod tests { } } + #[async_trait] + impl CacheClient for MockApiClient { + async fn get_artifact( + &self, + _hash: &str, + _token: &str, + _team_id: Option<&str>, + _team_slug: Option<&str>, + _method: Method, + ) -> Result, turborepo_api_client::Error> { + unimplemented!("get_artifact") + } + 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<(), turborepo_api_client::Error> { + unimplemented!("set_artifact") + } + async fn fetch_artifact( + &self, + _hash: &str, + _token: &str, + _team_id: Option<&str>, + _team_slug: Option<&str>, + ) -> Result, turborepo_api_client::Error> { + unimplemented!("fetch_artifact") + } + async fn artifact_exists( + &self, + _hash: &str, + _token: &str, + _team_id: Option<&str>, + _team_slug: Option<&str>, + ) -> Result, turborepo_api_client::Error> { + unimplemented!("artifact_exists") + } + async fn get_caching_status( + &self, + _token: &str, + _team_id: Option<&str>, + _team_slug: Option<&str>, + ) -> Result { + Ok(CachingStatusResponse { + status: CachingStatus::Enabled, + }) + } + } + #[tokio::test] async fn test_login() { let port = port_scanner::request_open_port().unwrap(); diff --git a/crates/turborepo-auth/src/auth/mod.rs b/crates/turborepo-auth/src/auth/mod.rs index c1acf49731309..071ee287e94ba 100644 --- a/crates/turborepo-auth/src/auth/mod.rs +++ b/crates/turborepo-auth/src/auth/mod.rs @@ -5,17 +5,17 @@ mod sso; pub use login::*; pub use logout::*; pub use sso::*; -use turborepo_api_client::{Client, TokenClient}; -use turborepo_ui::{BOLD, UI}; +use turborepo_api_client::{CacheClient, Client, TokenClient}; +use turborepo_ui::UI; -use crate::{ui, LoginServer, Token}; +use crate::LoginServer; const VERCEL_TOKEN_DIR: &str = "com.vercel.cli"; const VERCEL_TOKEN_FILE: &str = "auth.json"; pub struct LoginOptions<'a, T> where - T: Client + TokenClient, + T: Client + TokenClient + CacheClient, { pub ui: &'a UI, pub login_url: &'a str, @@ -27,7 +27,7 @@ where } impl<'a, T> LoginOptions<'a, T> where - T: Client + TokenClient, + T: Client + TokenClient + CacheClient, { pub fn new( ui: &'a UI, @@ -46,52 +46,6 @@ where } } -async fn check_user_token( - token: &str, - ui: &UI, - api_client: &(impl Client + TokenClient), - message: &str, -) -> Result { - let response_user = api_client.get_user(token).await?; - println!("{}", ui.apply(BOLD.apply_to(message))); - ui::print_cli_authorized(&response_user.user.email, ui); - Ok(Token::Existing(token.to_string())) -} - -async fn check_sso_token( - token: &str, - sso_team: &str, - ui: &UI, - api_client: &(impl Client + TokenClient), - message: &str, -) -> Result { - let (result_user, result_teams) = - tokio::join!(api_client.get_user(token), api_client.get_teams(token),); - - let token = Token::existing(token.into()); - - match (result_user, result_teams) { - (Ok(response_user), Ok(response_teams)) => { - if response_teams - .teams - .iter() - .any(|team| team.slug == sso_team) - { - if token.is_valid(api_client).await? { - println!("{}", ui.apply(BOLD.apply_to(message))); - ui::print_cli_authorized(&response_user.user.email, ui); - Ok(token) - } else { - Err(Error::SSOTokenExpired(sso_team.to_string())) - } - } else { - Err(Error::SSOTeamNotFound(sso_team.to_string())) - } - } - (Err(e), _) | (_, Err(e)) => Err(Error::APIError(e)), - } -} - fn extract_vercel_token() -> Result { let vercel_config_dir = turborepo_dirs::vercel_config_dir().ok_or_else(|| Error::ConfigDirNotFound)?; diff --git a/crates/turborepo-auth/src/auth/sso.rs b/crates/turborepo-auth/src/auth/sso.rs index 39bad6b5ac192..7333a4e95ec68 100644 --- a/crates/turborepo-auth/src/auth/sso.rs +++ b/crates/turborepo-auth/src/auth/sso.rs @@ -3,13 +3,10 @@ use std::sync::Arc; use reqwest::Url; use tokio::sync::OnceCell; use tracing::warn; -use turborepo_api_client::{Client, TokenClient}; -use turborepo_ui::start_spinner; +use turborepo_api_client::{CacheClient, Client, TokenClient}; +use turborepo_ui::{start_spinner, BOLD, UI}; -use crate::{ - auth::{check_sso_token, extract_vercel_token}, - error, ui, Error, LoginOptions, Token, -}; +use crate::{auth::extract_vercel_token, error, ui, Error, LoginOptions, Token}; const DEFAULT_HOST_NAME: &str = "127.0.0.1"; const DEFAULT_PORT: u16 = 9789; @@ -27,7 +24,7 @@ fn make_token_name() -> Result { /// Perform an SSO login flow. If an existing token is present, and the token /// has access to the provided `sso_team`, we do not overwrite it and instead /// log that we found an existing token. -pub async fn sso_login<'a, T: Client + TokenClient>( +pub async fn sso_login<'a, T: Client + TokenClient + CacheClient>( options: &LoginOptions<'_, T>, ) -> Result { let LoginOptions { @@ -40,14 +37,34 @@ pub async fn sso_login<'a, T: Client + TokenClient>( } = *options; let sso_team = sso_team.ok_or(Error::EmptySSOTeam)?; + // I created a closure that gives back a closure since the `is_valid` checks do + // a call to get the user, so instead of doing that multiple times we have + // `is_valid` give back the user email. + // + // In the future I want to make the Token have some non-skewable information and + // be able to get rid of this, but it works for now. + let valid_token_callback = |message: &str, ui: &UI| { + let message = message.to_string(); + let ui = *ui; + move |user_email: &str| { + println!("{}", ui.apply(BOLD.apply_to(message))); + ui::print_cli_authorized(user_email, &ui); + } + }; + // Check if token exists first. Must be there for the user and contain the // sso_team passed into this function. if let Some(token) = existing_token { - if Token::existing(token.to_string()) - .is_valid(api_client) + let token = Token::existing(token.into()); + if token + .is_valid_sso( + api_client, + sso_team, + Some(valid_token_callback("Existing token found!", ui)), + ) .await? { - return check_sso_token(token, sso_team, ui, api_client, "Existing token found!").await; + return Ok(token); } } @@ -55,14 +72,17 @@ pub async fn sso_login<'a, T: Client + TokenClient>( // an existing `vc` token with correct scope. if login_url_configuration.contains("vercel.com") { if let Ok(token) = extract_vercel_token() { - return check_sso_token( - &token, - sso_team, - ui, - api_client, - "Existing Vercel token found!", - ) - .await; + let token = Token::existing(token); + if token + .is_valid_sso( + api_client, + sso_team, + Some(valid_token_callback("Existing Vercel token found!", ui)), + ) + .await? + { + return Ok(token); + } } } @@ -121,11 +141,12 @@ mod tests { use std::sync::atomic::AtomicUsize; use async_trait::async_trait; - use reqwest::{RequestBuilder, Response}; + use reqwest::{Method, RequestBuilder, Response}; use turborepo_api_client::Client; use turborepo_ui::UI; use turborepo_vercel_api::{ - Membership, Role, SpacesResponse, Team, TeamsResponse, User, UserResponse, VerifiedSsoUser, + CachingStatus, CachingStatusResponse, Membership, Role, SpacesResponse, Team, + TeamsResponse, User, UserResponse, VerifiedSsoUser, }; use turborepo_vercel_api_mock::start_test_server; @@ -209,7 +230,14 @@ mod tests { _token: &str, _team_id: &str, ) -> turborepo_api_client::Result> { - unimplemented!("get_team") + Ok(Some(Team { + id: "id".to_string(), + slug: "something".to_string(), + name: "name".to_string(), + created_at: 0, + created: chrono::Utc::now(), + membership: Membership::new(Role::Member), + })) } fn add_ci_header(_request_builder: RequestBuilder) -> RequestBuilder { unimplemented!("add_ci_header") @@ -267,6 +295,61 @@ mod tests { }) } } + + #[async_trait] + impl CacheClient for MockApiClient { + async fn get_artifact( + &self, + _hash: &str, + _token: &str, + _team_id: Option<&str>, + _team_slug: Option<&str>, + _method: Method, + ) -> Result, turborepo_api_client::Error> { + unimplemented!("get_artifact") + } + 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<(), turborepo_api_client::Error> { + unimplemented!("set_artifact") + } + async fn fetch_artifact( + &self, + _hash: &str, + _token: &str, + _team_id: Option<&str>, + _team_slug: Option<&str>, + ) -> Result, turborepo_api_client::Error> { + unimplemented!("fetch_artifact") + } + async fn artifact_exists( + &self, + _hash: &str, + _token: &str, + _team_id: Option<&str>, + _team_slug: Option<&str>, + ) -> Result, turborepo_api_client::Error> { + unimplemented!("artifact_exists") + } + async fn get_caching_status( + &self, + _token: &str, + _team_id: Option<&str>, + _team_slug: Option<&str>, + ) -> Result { + Ok(CachingStatusResponse { + status: CachingStatus::Enabled, + }) + } + } + #[derive(Clone)] struct MockSSOLoginServer { hits: Arc, diff --git a/crates/turborepo-auth/src/lib.rs b/crates/turborepo-auth/src/lib.rs index a03987b30d401..fdc6933842093 100644 --- a/crates/turborepo-auth/src/lib.rs +++ b/crates/turborepo-auth/src/lib.rs @@ -12,11 +12,18 @@ mod ui; pub use auth::*; pub use error::Error; pub use login_server::*; -use turborepo_api_client::TokenClient; -use turborepo_vercel_api::token::ResponseTokenMetadata; +use turborepo_api_client::{CacheClient, Client, TokenClient}; +use turborepo_vercel_api::{token::ResponseTokenMetadata, User}; -/// Token is the result of a successful login. It contains the token string and -/// potentially metadata about the token. +pub struct TeamInfo<'a> { + pub id: &'a str, + pub slug: &'a str, +} + +/// Token is the result of a successful login or an existing token. This acts as +/// a wrapper for a bunch of token operations, like validation. We explicitly do +/// not store any information about the underlying token for a few reasons, like +/// having a token invalidated on the web but not locally. #[derive(Debug, Clone)] pub enum Token { /// An existing token on the filesystem @@ -31,17 +38,143 @@ impl Token { pub fn existing(token: String) -> Self { Self::Existing(token) } - /// Checks if the token is valid. We do a few checks: + /// Checks if the token is still valid. The checks ran are: + /// 1. If the token is active. + /// 2. If the token has access to the cache. + /// - If the token is forbidden from accessing the cache, we consider it + /// invalid. + /// 3. We are able to fetch the user associated with the token. + /// + /// ## Arguments + /// * `client` - The client to use for API calls. + /// * `valid_message_fn` - An optional callback that gets called if the + /// token is valid. It will be passed the user's email. + // TODO(voz): This should do a `get_user` or `get_teams` instead of the caller + // doing it. The reason we don't do it here is becuase the caller + // needs to do printing and requires the user struct, which we don't want to + // return here. + pub async fn is_valid( + &self, + client: &T, + // Making this optional since there are cases where we don't want to do anything after + // validation. + // A callback that gets called if the token is valid. This will be + // passed in a user's email if the token is valid. + valid_message_fn: Option, + ) -> Result { + let is_active = self.is_active(client).await?; + let has_cache_access = self.has_cache_access(client, None).await?; + if !is_active || !has_cache_access { + return Ok(false); + } + + if let Some(message_callback) = valid_message_fn { + let user = self.user(client).await?; + message_callback(&user.email); + } + Ok(true) + } + /// This is the same as `is_valid`, but also checks if the token is valid + /// for SSO. + /// + /// ## Arguments + /// * `client` - The client to use for API calls. + /// * `sso_team` - The team to validate the token against. + /// * `valid_message_fn` - An optional callback that gets called if the + /// token is valid. It will be passed the user's email. + pub async fn is_valid_sso( + &self, + client: &T, + sso_team: &str, + // Making this optional since there are cases where we don't want to do anything after + // validation. + // A callback that gets called if the token is valid. This will be + // passed in a user's email if the token is valid. + valid_message_fn: Option, + ) -> Result { + let is_active = self.is_active(client).await?; + let (result_user, result_team) = tokio::join!( + client.get_user(self.into_inner()), + client.get_team(self.into_inner(), sso_team) + ); + + match (result_user, result_team) { + (Ok(response_user), Ok(response_team)) => { + let team = + response_team.ok_or_else(|| Error::SSOTeamNotFound(sso_team.to_owned()))?; + let info = TeamInfo { + id: &team.id, + slug: &team.slug, + }; + if info.slug != sso_team { + return Err(Error::SSOTeamNotFound(sso_team.to_owned())); + } + + let has_cache_access = self.has_cache_access(client, Some(info)).await?; + if !is_active || !has_cache_access { + return Ok(false); + } + + if let Some(message_callback) = valid_message_fn { + message_callback(&response_user.user.email); + }; + + Ok(true) + } + (Err(e), _) | (_, Err(e)) => Err(Error::APIError(e)), + } + } + /// Checks if the token is active. We do a few checks: /// 1. Fetch the token metadata. /// 2. From the metadata, check if the token is active. /// 3. If the token is a SAML SSO token, check if it's expired. - pub async fn is_valid(&self, client: &impl TokenClient) -> Result { + pub async fn is_active(&self, client: &T) -> Result { let metadata = self.fetch_metadata(client).await?; let current_time = current_unix_time(); let active = is_token_active(&metadata, current_time); Ok(active) } + /// Checks if the token has access to the cache. This is a separate check + /// from `is_active` because it's possible for a token to be active but not + /// have access to the cache. + pub async fn has_cache_access<'a, T: CacheClient>( + &self, + client: &T, + team_info: Option>, + ) -> Result { + let (team_id, team_slug) = match team_info { + Some(TeamInfo { id, slug }) => (Some(id), Some(slug)), + None => (None, None), + }; + + match client + .get_caching_status(self.into_inner(), team_id, team_slug) + .await + { + // If we get a successful response, we have cache access and therefore consider it good. + // TODO: In the future this response should include something that tells us what actions + // this token can perform. + Ok(_) => Ok(true), + // An error can mean that we were unable to fetch the cache status, or that the token is + // forbidden from accessing the cache. A forbidden means we should return a `false`, + // otherwise we return an actual error. + Err(e) => match e { + // Check to make sure the code is "forbidden" before returning a `false`. + turborepo_api_client::Error::UnknownStatus { code, .. } if code == "forbidden" => { + Ok(false) + } + _ => Err(e.into()), + }, + } + } + + /// Fetches the user associated with the token. + pub async fn user(&self, client: &impl Client) -> Result { + let user_response = client.get_user(self.into_inner()).await?; + Ok(user_response.user) + } + async fn fetch_metadata( &self, client: &impl TokenClient,