diff --git a/Cargo.lock b/Cargo.lock index 5a91cf5493b43..f52d37b8c3cfb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10982,14 +10982,18 @@ dependencies = [ "axum", "axum-server", "chrono", + "dirs-next", "hostname", "lazy_static", "port_scanner", "reqwest", "serde", + "serde_json", + "tempfile", "thiserror", "tokio", "tracing", + "turbopath", "turborepo-api-client", "turborepo-ui", "turborepo-vercel-api", @@ -11219,6 +11223,7 @@ dependencies = [ "turborepo-auth", "turborepo-cache", "turborepo-ci", + "turborepo-dirs", "turborepo-env", "turborepo-filewatch", "turborepo-fs", @@ -11356,6 +11361,7 @@ dependencies = [ "anyhow", "atty", "console", + "dialoguer", "indicatif", "lazy_static", "tempfile", diff --git a/crates/turborepo-api-client/src/lib.rs b/crates/turborepo-api-client/src/lib.rs index 5284622685cc7..33be324921581 100644 --- a/crates/turborepo-api-client/src/lib.rs +++ b/crates/turborepo-api-client/src/lib.rs @@ -13,7 +13,8 @@ use serde::Deserialize; use turborepo_ci::{is_ci, Vendor}; use turborepo_vercel_api::{ APIError, CachingStatus, CachingStatusResponse, PreflightResponse, SpacesResponse, Team, - TeamsResponse, UserResponse, VerificationResponse, VerifiedSsoUser, + TeamsResponse, TokenMetadata, TokenMetadataResponse, UserResponse, VerificationResponse, + VerifiedSsoUser, }; use url::Url; @@ -32,6 +33,7 @@ lazy_static! { #[async_trait] pub trait Client { + fn base_url(&self) -> &str; async fn get_user(&self, token: &str) -> Result; async fn get_teams(&self, token: &str) -> Result; async fn get_team(&self, token: &str, team_id: &str) -> Result>; @@ -44,6 +46,7 @@ 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; + async fn get_token_metadata(&self, token: &str) -> Result; #[allow(clippy::too_many_arguments)] async fn put_artifact( &self, @@ -105,6 +108,24 @@ pub struct APIAuth { #[async_trait] impl Client for APIClient { + fn base_url(&self) -> &str { + &self.base_url + } + async fn get_token_metadata(&self, token: &str) -> Result { + let url = self.make_url("/v5/user/tokens/current"); + let request_builder = self + .client + .get(url) + .header("User-Agent", self.user_agent.clone()) + .header("Authorization", format!("Bearer {}", token)) + .header("Content-Type", "application/json"); + let response = retry::make_retryable_request(request_builder) + .await? + .error_for_status()?; + let json: TokenMetadataResponse = response.json().await?; + + Ok(json.token) + } async fn get_user(&self, token: &str) -> Result { let url = self.make_url("/v2/user"); let request_builder = self diff --git a/crates/turborepo-auth/Cargo.toml b/crates/turborepo-auth/Cargo.toml index 4c5caf62c798a..c58fc37015ecd 100644 --- a/crates/turborepo-auth/Cargo.toml +++ b/crates/turborepo-auth/Cargo.toml @@ -13,13 +13,17 @@ async-trait.workspace = true axum-server = { workspace = true } axum.workspace = true chrono.workspace = true +dirs-next = "2.0.0" hostname = "0.3.1" lazy_static.workspace = true reqwest.workspace = true serde.workspace = true +serde_json.workspace = true +tempfile.workspace = true thiserror = "1.0.38" tokio.workspace = true tracing.workspace = true +turbopath = { workspace = true } turborepo-api-client = { workspace = true } turborepo-ui.workspace = true turborepo-vercel-api = { workspace = true } diff --git a/crates/turborepo-auth/src/auth/login.rs b/crates/turborepo-auth/src/auth/login.rs deleted file mode 100644 index 2ff4204b6b9c0..0000000000000 --- a/crates/turborepo-auth/src/auth/login.rs +++ /dev/null @@ -1,311 +0,0 @@ -use std::{borrow::Cow, sync::Arc}; - -pub use error::Error; -use reqwest::Url; -use tokio::sync::OnceCell; -use tracing::warn; -use turborepo_api_client::Client; -use turborepo_ui::{start_spinner, BOLD, UI}; - -use crate::{error, server::LoginServer, ui}; - -const DEFAULT_HOST_NAME: &str = "127.0.0.1"; -const DEFAULT_PORT: u16 = 9789; - -/// Login writes a token to disk at token_path. If a token is already present, -/// we do not overwrite it and instead log that we found an existing token. -pub async fn login<'a>( - api_client: &impl Client, - ui: &UI, - existing_token: Option<&'a str>, - login_url_configuration: &str, - login_server: &impl LoginServer, -) -> Result, Error> { - // Check if token exists first. - if let Some(token) = existing_token { - if let Ok(response) = api_client.get_user(token).await { - println!("{}", ui.apply(BOLD.apply_to("Existing token found!"))); - ui::print_cli_authorized(&response.user.email, ui); - return Ok(token.into()); - } - } - - let redirect_url = format!("http://{DEFAULT_HOST_NAME}:{DEFAULT_PORT}"); - let mut login_url = Url::parse(login_url_configuration)?; - - login_url - .path_segments_mut() - .map_err(|_: ()| Error::LoginUrlCannotBeABase { - value: login_url_configuration.to_string(), - })? - .extend(["turborepo", "token"]); - - login_url - .query_pairs_mut() - .append_pair("redirect_uri", &redirect_url); - - println!(">>> Opening browser to {login_url}"); - let spinner = start_spinner("Waiting for your authorization..."); - - // Try to open browser for auth confirmation. - let url = login_url.as_str(); - if !cfg!(test) && webbrowser::open(url).is_err() { - warn!("Failed to open browser. Please visit {url} in your browser."); - } - - let token_cell = Arc::new(OnceCell::new()); - login_server - .run( - DEFAULT_PORT, - login_url_configuration.to_string(), - token_cell.clone(), - ) - .await?; - - spinner.finish_and_clear(); - - let token = token_cell.get().ok_or(Error::FailedToGetToken)?; - - // TODO: make this a request to /teams endpoint instead? - let user_response = api_client - .get_user(token.as_str()) - .await - .map_err(Error::FailedToFetchUser)?; - - ui::print_cli_authorized(&user_response.user.email, ui); - - Ok(token.to_string().into()) -} - -#[cfg(test)] -mod tests { - use std::sync::atomic::AtomicUsize; - - use async_trait::async_trait; - use reqwest::{Method, RequestBuilder, Response}; - use turborepo_api_client::Client; - use turborepo_vercel_api::{ - CachingStatusResponse, Membership, PreflightResponse, Role, SpacesResponse, Team, - TeamsResponse, User, UserResponse, VerifiedSsoUser, - }; - use turborepo_vercel_api_mock::start_test_server; - - use super::*; - - struct MockLoginServer { - hits: Arc, - } - - #[async_trait] - impl LoginServer for MockLoginServer { - async fn run( - &self, - _: u16, - _: String, - login_token: Arc>, - ) -> Result<(), Error> { - self.hits.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - login_token - .set(turborepo_vercel_api_mock::EXPECTED_TOKEN.to_string()) - .unwrap(); - Ok(()) - } - } - - #[derive(Debug, thiserror::Error)] - enum MockApiError { - #[error("Empty token")] - EmptyToken, - } - - impl From for turborepo_api_client::Error { - fn from(error: MockApiError) -> Self { - match error { - MockApiError::EmptyToken => turborepo_api_client::Error::UnknownStatus { - code: "empty token".to_string(), - message: "token is empty".to_string(), - backtrace: std::backtrace::Backtrace::capture(), - }, - } - } - } - - struct MockApiClient { - pub base_url: String, - } - - impl MockApiClient { - fn new() -> Self { - Self { - base_url: String::new(), - } - } - } - - #[async_trait] - impl Client for MockApiClient { - async fn get_user(&self, token: &str) -> turborepo_api_client::Result { - if token.is_empty() { - return Err(MockApiError::EmptyToken.into()); - } - - Ok(UserResponse { - user: User { - id: "id".to_string(), - username: "username".to_string(), - email: "email".to_string(), - name: None, - created_at: None, - }, - }) - } - async fn get_teams(&self, token: &str) -> turborepo_api_client::Result { - if token.is_empty() { - return Err(MockApiError::EmptyToken.into()); - } - - Ok(TeamsResponse { - teams: vec![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), - }], - }) - } - async fn get_team( - &self, - _token: &str, - _team_id: &str, - ) -> turborepo_api_client::Result> { - unimplemented!("get_team") - } - fn add_ci_header(_request_builder: RequestBuilder) -> RequestBuilder { - unimplemented!("add_ci_header") - } - async fn get_caching_status( - &self, - _token: &str, - _team_id: Option<&str>, - _team_slug: Option<&str>, - ) -> turborepo_api_client::Result { - unimplemented!("get_caching_status") - } - async fn get_spaces( - &self, - _token: &str, - _team_id: Option<&str>, - ) -> turborepo_api_client::Result { - unimplemented!("get_spaces") - } - async fn verify_sso_token( - &self, - token: &str, - _: &str, - ) -> turborepo_api_client::Result { - Ok(VerifiedSsoUser { - token: token.to_string(), - 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") - } - async fn do_preflight( - &self, - _token: &str, - _request_url: &str, - _request_method: &str, - _request_headers: &str, - ) -> turborepo_api_client::Result { - unimplemented!("do_preflight") - } - fn make_url(&self, endpoint: &str) -> String { - format!("{}{}", self.base_url, endpoint) - } - } - - #[tokio::test] - async fn test_login() { - let port = port_scanner::request_open_port().unwrap(); - let api_server = tokio::spawn(start_test_server(port)); - let ui = UI::new(false); - let url = format!("http://localhost:{port}"); - - let api_client = MockApiClient::new(); - - let login_server = MockLoginServer { - hits: Arc::new(0.into()), - }; - - let token = login(&api_client, &ui, None, &url, &login_server) - .await - .unwrap(); - - let got_token = Some(token.to_string()); - - // Token should be set now - assert_eq!( - got_token.as_deref(), - Some(turborepo_vercel_api_mock::EXPECTED_TOKEN) - ); - - // Call the login function a second time to test that we check for existing - // tokens. Total server hits should be 1. - let second_token = login(&api_client, &ui, got_token.as_deref(), &url, &login_server) - .await - .unwrap(); - - // We can confirm that we didn't fetch a new token because we're borrowing the - // existing token and not getting a new allocation. - assert!(second_token.is_borrowed()); - - api_server.abort(); - assert_eq!( - login_server.hits.load(std::sync::atomic::Ordering::SeqCst), - 1 - ); - } -} diff --git a/crates/turborepo-auth/src/auth/mod.rs b/crates/turborepo-auth/src/auth/mod.rs deleted file mode 100644 index 66f5c583b305e..0000000000000 --- a/crates/turborepo-auth/src/auth/mod.rs +++ /dev/null @@ -1,7 +0,0 @@ -mod login; -mod logout; -mod sso; - -pub use login::*; -pub use logout::*; -pub use sso::*; diff --git a/crates/turborepo-auth/src/auth/sso.rs b/crates/turborepo-auth/src/auth/sso.rs deleted file mode 100644 index 30ab84bd230e5..0000000000000 --- a/crates/turborepo-auth/src/auth/sso.rs +++ /dev/null @@ -1,349 +0,0 @@ -use std::{borrow::Cow, sync::Arc}; - -use reqwest::Url; -use tokio::sync::OnceCell; -use tracing::warn; -use turborepo_api_client::Client; -use turborepo_ui::{start_spinner, BOLD, UI}; - -use crate::{error, server, ui, Error}; - -const DEFAULT_HOST_NAME: &str = "127.0.0.1"; -const DEFAULT_PORT: u16 = 9789; -const DEFAULT_SSO_PROVIDER: &str = "SAML/OIDC Single Sign-On"; - -fn make_token_name() -> Result { - let host = hostname::get().map_err(Error::FailedToMakeSSOTokenName)?; - - Ok(format!( - "Turbo CLI on {} via {DEFAULT_SSO_PROVIDER}", - host.to_string_lossy() - )) -} - -/// 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>( - api_client: &impl Client, - ui: &UI, - existing_token: Option<&'a str>, - login_url_configuration: &str, - sso_team: &str, - login_server: &impl server::SSOLoginServer, -) -> Result, Error> { - // 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 { - let (result_user, result_teams) = - tokio::join!(api_client.get_user(token), api_client.get_teams(token)); - - if let (Ok(response_user), Ok(response_teams)) = (result_user, result_teams) { - if response_teams - .teams - .iter() - .any(|team| team.slug == sso_team) - { - println!("{}", ui.apply(BOLD.apply_to("Existing token found!"))); - ui::print_cli_authorized(&response_user.user.email, ui); - return Ok(token.into()); - } - } - } - - let redirect_url = format!("http://{DEFAULT_HOST_NAME}:{DEFAULT_PORT}"); - let mut login_url = Url::parse(login_url_configuration)?; - - login_url - .path_segments_mut() - .map_err(|_: ()| error::Error::LoginUrlCannotBeABase { - value: login_url_configuration.to_string(), - })? - .extend(["api", "auth", "sso"]); - - login_url - .query_pairs_mut() - .append_pair("teamId", sso_team) - .append_pair("mode", "login") - .append_pair("next", &redirect_url); - - println!(">>> Opening browser to {login_url}"); - let spinner = start_spinner("Waiting for your authorization..."); - let url = login_url.as_str(); - - // Don't open the browser in tests. - if !cfg!(test) && webbrowser::open(url).is_err() { - warn!("Failed to open browser. Please visit {url} in your browser."); - } - - let token_cell = Arc::new(OnceCell::new()); - login_server.run(DEFAULT_PORT, token_cell.clone()).await?; - spinner.finish_and_clear(); - - let token = token_cell.get().ok_or(Error::FailedToGetToken)?; - - let token_name = make_token_name()?; - - let verified_user = api_client - .verify_sso_token(token, &token_name) - .await - .map_err(Error::FailedToValidateSSOToken)?; - - let user_response = api_client - .get_user(&verified_user.token) - .await - .map_err(Error::FailedToFetchUser)?; - - ui::print_cli_authorized(&user_response.user.email, ui); - - Ok(verified_user.token.into()) -} - -#[cfg(test)] -mod tests { - use std::sync::atomic::AtomicUsize; - - use async_trait::async_trait; - use reqwest::{Method, RequestBuilder, Response}; - use turborepo_api_client::Client; - use turborepo_vercel_api::{ - CachingStatusResponse, Membership, PreflightResponse, Role, SpacesResponse, Team, - TeamsResponse, User, UserResponse, VerifiedSsoUser, - }; - use turborepo_vercel_api_mock::start_test_server; - - use super::*; - use crate::SSOLoginServer; - const EXPECTED_VERIFICATION_TOKEN: &str = "expected_verification_token"; - - lazy_static::lazy_static! { - static ref SSO_HITS: Arc = Arc::new(AtomicUsize::new(0)); - } - - #[derive(Debug, thiserror::Error)] - enum MockApiError { - #[error("Empty token")] - EmptyToken, - } - - impl From for turborepo_api_client::Error { - fn from(error: MockApiError) -> Self { - match error { - MockApiError::EmptyToken => turborepo_api_client::Error::UnknownStatus { - code: "empty token".to_string(), - message: "token is empty".to_string(), - backtrace: std::backtrace::Backtrace::capture(), - }, - } - } - } - - struct MockApiClient { - pub base_url: String, - } - - impl MockApiClient { - fn new() -> Self { - Self { - base_url: String::new(), - } - } - - fn set_base_url(&mut self, base_url: &str) { - self.base_url = base_url.to_string(); - } - } - - #[async_trait] - impl Client for MockApiClient { - async fn get_user(&self, token: &str) -> turborepo_api_client::Result { - if token.is_empty() { - return Err(MockApiError::EmptyToken.into()); - } - - Ok(UserResponse { - user: User { - id: "id".to_string(), - username: "username".to_string(), - email: "email".to_string(), - name: None, - created_at: None, - }, - }) - } - async fn get_teams(&self, token: &str) -> turborepo_api_client::Result { - if token.is_empty() { - return Err(MockApiError::EmptyToken.into()); - } - - Ok(TeamsResponse { - teams: vec![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), - }], - }) - } - async fn get_team( - &self, - _token: &str, - _team_id: &str, - ) -> turborepo_api_client::Result> { - unimplemented!("get_team") - } - fn add_ci_header(_request_builder: RequestBuilder) -> RequestBuilder { - unimplemented!("add_ci_header") - } - async fn get_caching_status( - &self, - _token: &str, - _team_id: Option<&str>, - _team_slug: Option<&str>, - ) -> turborepo_api_client::Result { - unimplemented!("get_caching_status") - } - async fn get_spaces( - &self, - _token: &str, - _team_id: Option<&str>, - ) -> turborepo_api_client::Result { - unimplemented!("get_spaces") - } - async fn verify_sso_token( - &self, - token: &str, - _: &str, - ) -> turborepo_api_client::Result { - Ok(VerifiedSsoUser { - token: token.to_string(), - 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") - } - async fn do_preflight( - &self, - _token: &str, - _request_url: &str, - _request_method: &str, - _request_headers: &str, - ) -> turborepo_api_client::Result { - unimplemented!("do_preflight") - } - fn make_url(&self, endpoint: &str) -> String { - format!("{}{}", self.base_url, endpoint) - } - } - - #[derive(Clone)] - struct MockSSOLoginServer { - hits: Arc, - } - - #[async_trait] - impl SSOLoginServer for MockSSOLoginServer { - async fn run( - &self, - _port: u16, - verification_token: Arc>, - ) -> Result<(), Error> { - self.hits.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - verification_token - .set(EXPECTED_VERIFICATION_TOKEN.to_string()) - .unwrap(); - Ok(()) - } - } - - #[tokio::test] - async fn test_sso_login() { - let port = port_scanner::request_open_port().unwrap(); - let handle = tokio::spawn(start_test_server(port)); - let url = format!("http://localhost:{port}"); - let ui = UI::new(false); - let team = "something"; - - let mut api_client = MockApiClient::new(); - api_client.set_base_url(&url); - - let login_server = MockSSOLoginServer { - hits: Arc::new(0.into()), - }; - - let token = sso_login(&api_client, &ui, None, &url, team, &login_server) - .await - .unwrap(); - - let got_token = Some(token.to_string()); - - assert_eq!(got_token, Some(EXPECTED_VERIFICATION_TOKEN.to_owned())); - - // Call the login function twice to test that we check for existing tokens. - // Total server hits should be 1. - let second_token = sso_login( - &api_client, - &ui, - got_token.as_deref(), - &url, - team, - &login_server, - ) - .await - .unwrap(); - - // We can confirm that we didn't fetch a new token because we're borrowing the - // existing token and not getting a new allocation. - assert!(second_token.is_borrowed()); - - handle.abort(); - - // This makes sure we never make it to the login server. - assert_eq!( - login_server.hits.load(std::sync::atomic::Ordering::SeqCst), - 1 - ); - } -} diff --git a/crates/turborepo-auth/src/auth_file.rs b/crates/turborepo-auth/src/auth_file.rs new file mode 100644 index 0000000000000..681e50bd99978 --- /dev/null +++ b/crates/turborepo-auth/src/auth_file.rs @@ -0,0 +1,189 @@ +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; +use turbopath::AbsoluteSystemPath; + +use crate::Error; + +#[derive(Serialize, Deserialize, Debug, Default, PartialEq)] +/// AuthFile contains a list of domains, each with a token. +pub struct AuthFile { + tokens: HashMap, +} + +impl AuthFile { + /// Create an empty auth file. Caller must invoke `write_to_disk` to + /// actually write it to disk. + pub fn new() -> Self { + AuthFile { + tokens: HashMap::new(), + } + } + /// Writes the contents of the auth file to disk. Will override whatever is + /// there with what's in the struct. + pub fn write_to_disk(&self, path: &AbsoluteSystemPath) -> Result<(), Error> { + path.ensure_dir()?; + + let mut pretty_content = serde_json::to_string_pretty(self) + .map_err(|e| Error::FailedToSerializeAuthFile { source: e })?; + // to_string_pretty doesn't add terminating line endings, so do that. + pretty_content.push('\n'); + + path.create_with_contents(pretty_content) + .map_err(|e| crate::Error::FailedToWriteAuth { + auth_path: path.to_owned(), + error: e, + })?; + + Ok(()) + } + pub fn get_token(&self, api: &str) -> Option { + self.tokens.get(api).map(|raw_token| AuthToken { + token: raw_token.to_owned(), + api: api.to_owned(), + }) + } + /// Adds a token to the auth file. Attempts to match exclusively on `api`. + /// If the api matches a token already in the file, it will be updated with + /// the new token. + pub fn insert(&mut self, api: String, token: String) -> Option { + self.tokens.insert(api, token) + } + + /// Removes a token from the auth file via the api as a key. + pub fn remove(&mut self, api: &str) { + self.tokens.remove(api); + } + + /// Returns a reference to the tokens in the auth file. + pub fn tokens(&self) -> &HashMap { + &self.tokens + } + + /// Returns a mutable reference to the tokens in the auth file. + pub fn tokens_mut(&mut self) -> &mut HashMap { + &mut self.tokens + } +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +/// Contains the token itself and a list of teams the token is valid for. +pub struct AuthToken { + /// The token itself. + pub token: String, + /// The API URL the token was issued from / for. + pub api: String, +} + +impl AuthToken { + pub fn friendly_api_display(&self) -> &str { + if self.api.contains("vercel.com") { + // We're Vercel, let's make it look nice ;) + "▲ Vercel Remote Cache" + } else { + &self.api + } + } +} + +/// Converts our old style of token held in `config.json` into the new schema. +pub fn convert_to_auth_token(token: &str, api: &str) -> AuthToken { + AuthToken { + token: token.to_string(), + api: api.to_string(), + } +} + +#[cfg(test)] +mod tests { + use std::{fs, ops::Deref}; + + use tempfile::tempdir; + use turborepo_api_client::Client; + + use super::*; + use crate::{mocks::*, TURBOREPO_AUTH_FILE_NAME, TURBOREPO_CONFIG_DIR}; + + #[tokio::test] + async fn test_convert_to_auth_token() { + // Setup: Create a mock client and a fake token + let mock_client = MockApiClient::new(); + let token = "test-token"; + + // Test: Call the convert_to_auth_file function and check the result + let auth_token = convert_to_auth_token(token, mock_client.base_url()); + + // Check that the AuthFile contains the correct data + assert_eq!(auth_token.token, "test-token".to_string()); + assert_eq!(auth_token.api, "custom-domain".to_string()); + } + + #[tokio::test] + async fn test_write_to_disk_and_read_back() { + // Setup: Use temp dirs to avoid polluting the user's config dir + let temp_dir = tempdir().unwrap(); + let auth_file_path = temp_dir + .path() + .join(TURBOREPO_CONFIG_DIR) + .join(TURBOREPO_AUTH_FILE_NAME); + + let absolute_auth_path = AbsoluteSystemPath::new(auth_file_path.to_str().unwrap()).unwrap(); + + // Make sure the temp dir exists before writing to it. + fs::create_dir_all(temp_dir.path().join(TURBOREPO_CONFIG_DIR)).unwrap(); + + // Add a token to auth file + let mut auth_file = AuthFile::default(); + auth_file.insert("test-api".to_string(), "test-token".to_string()); + + // Test: Write the auth file to disk and then read it back. + auth_file.write_to_disk(absolute_auth_path).unwrap(); + + let read_back: AuthFile = + serde_json::from_str(&absolute_auth_path.read_to_string().unwrap()).unwrap(); + assert_eq!(read_back.tokens.len(), 1); + assert!(read_back.tokens.contains_key("test-api")); + assert_eq!( + read_back.tokens.get("test-api").unwrap().deref(), + "test-token".to_owned() + ); + } + + #[tokio::test] + async fn test_get_token() { + let mut auth_file = AuthFile::default(); + auth_file.insert("test-api".to_string(), "test-token".to_string()); + + let token = auth_file.get_token("test-api"); + assert!(token.is_some()); + assert_eq!(token.unwrap().token, "test-token"); + } + + #[tokio::test] + async fn test_add_token() { + // Setup: Create an empty auth file. + let mut auth_file = AuthFile::default(); + assert_eq!(auth_file.tokens.len(), 0); + + // Test: Add a token to the auth file, then add same key with a different value + // to ensure update happens. + auth_file.insert("test-api".to_string(), "test-token".to_string()); + auth_file.insert("test-api".to_string(), "some new token".to_string()); + + assert_eq!(auth_file.tokens.len(), 1); + let mut token = auth_file.get_token("test-api"); + assert!(token.is_some()); + + let mut t = token.unwrap(); + assert!(t.token == *"some new token"); + + auth_file.insert("some vercel api".to_string(), "a second token".to_string()); + assert_eq!(auth_file.tokens.len(), 2); + + token = auth_file.get_token("some vercel api"); + assert!(token.is_some()); + + t = token.unwrap(); + assert!(t.token == *"a second token"); + } +} diff --git a/crates/turborepo-auth/src/config_token.rs b/crates/turborepo-auth/src/config_token.rs new file mode 100644 index 0000000000000..ff2651f5b3005 --- /dev/null +++ b/crates/turborepo-auth/src/config_token.rs @@ -0,0 +1,12 @@ +/** + * This whole file will hopefully go away in the future when we stop writing + * tokens to `config.json`. + */ + +#[derive(serde::Deserialize, serde::Serialize)] +/// ConfigToken describes the legacy token format. It should only be used as a +/// way to store the underlying token as a String, and then converted to an +/// AuthToken. +pub struct ConfigToken { + pub token: String, +} diff --git a/crates/turborepo-auth/src/error.rs b/crates/turborepo-auth/src/error.rs index c18d4908025c8..959453fb17da1 100644 --- a/crates/turborepo-auth/src/error.rs +++ b/crates/turborepo-auth/src/error.rs @@ -1,22 +1,91 @@ use std::io; -use thiserror::Error; +use turbopath::AbsoluteSystemPathBuf; +use turborepo_api_client::Error as APIError; -#[derive(Debug, Error)] +#[derive(Debug, thiserror::Error)] pub enum Error { + #[error(transparent)] + UrlParseError(#[from] url::ParseError), + #[error(transparent)] + APIError(#[from] APIError), + #[error(transparent)] + IOError(#[from] io::Error), + + #[error("failed to get token")] + FailedToGetToken, + + #[error("failed to fetch user: {0}")] + FailedToFetchUser(#[source] turborepo_api_client::Error), + #[error("failed to fetch token metadata: {source}")] + FailedToFetchTokenMetadata { source: turborepo_api_client::Error }, #[error( "loginUrl is configured to \"{value}\", but cannot be a base URL. This happens in \ situations like using a `data:` URL." )] LoginUrlCannotBeABase { value: String }, - #[error("failed to get token")] - FailedToGetToken, - #[error("failed to fetch user: {0}")] - FailedToFetchUser(#[source] turborepo_api_client::Error), - #[error("url is invalid: {0}")] - InvalidUrl(#[from] url::ParseError), - #[error("failed to validate sso token")] - FailedToValidateSSOToken(#[source] turborepo_api_client::Error), + + // SSO errors #[error("failed to make sso token name")] - FailedToMakeSSOTokenName(#[source] io::Error), + FailedToMakeSSOTokenName(io::Error), + #[error("failed to validate sso token")] + FailedToValidateSSOToken(turborepo_api_client::Error), + + // File write errors + #[error("failed to write to auth file at {auth_path}: {error}")] + FailedToWriteAuth { + auth_path: turbopath::AbsoluteSystemPathBuf, + error: io::Error, + }, + #[error("failed to create auth file at {auth_path}: {error}")] + FailedToCreateAuthFile { + auth_path: turbopath::AbsoluteSystemPathBuf, + error: io::Error, + }, + + // File read errors. + #[error(transparent)] + PathError(#[from] turbopath::PathError), + #[error("failed to read auth file at path: {path}")] + FailedToReadAuthFile { + #[source] + source: std::io::Error, + path: AbsoluteSystemPathBuf, + }, + #[error("failed to read config file at path: {path}")] + FailedToReadConfigFile { + #[source] + source: std::io::Error, + path: AbsoluteSystemPathBuf, + }, + + // File write errors. + #[error("failed to write AuthFile to disk: {source}")] + FailedToWriteAuthFile { + #[source] + source: std::io::Error, + }, + + // Conversion errors. + #[error("failed to deserialize auth file: {source}")] + FailedToDeserializeAuthFile { + #[source] + source: serde_json::Error, + }, + #[error("failed to deserialize config token: {source}")] + FailedToDeserializeConfigToken { + #[source] + source: serde_json::Error, + }, + #[error("failed to serialize auth file: {source}")] + FailedToSerializeAuthFile { + #[source] + source: serde_json::Error, + }, + + #[error("failed to convert config to auth file: {source}")] + ConvertConfigToAuth { + #[source] + source: serde_json::Error, + }, } diff --git a/crates/turborepo-auth/src/lib.rs b/crates/turborepo-auth/src/lib.rs index 1434aa2678e66..4d58aa4749cd8 100644 --- a/crates/turborepo-auth/src/lib.rs +++ b/crates/turborepo-auth/src/lib.rs @@ -1,13 +1,179 @@ #![feature(cow_is_borrowed)] +#![feature(fs_try_exists)] // Used in tests #![deny(clippy::all)] //! Turborepo's library for authenticating with the Vercel API. //! Handles logging into Vercel, verifying SSO, and storing the token. -mod auth; +mod auth_file; +mod config_token; mod error; -mod server; +mod login; +mod login_server; +mod logout; +pub mod mocks; +mod sso; +mod sso_server; mod ui; -pub use auth::*; -pub use error::Error; -pub use server::*; +use turbopath::AbsoluteSystemPath; +use turborepo_api_client::Client; + +pub use self::{ + auth_file::*, config_token::ConfigToken, error::Error, login::*, login_server::*, logout::*, + sso::*, sso_server::*, +}; + +pub const TURBOREPO_AUTH_FILE_NAME: &str = "auth.json"; +pub const TURBOREPO_LEGACY_AUTH_FILE_NAME: &str = "config.json"; +pub const TURBOREPO_CONFIG_DIR: &str = "turborepo"; + +pub const DEFAULT_LOGIN_URL: &str = "https://vercel.com"; +pub const DEFAULT_API_URL: &str = "https://vercel.com/api"; + +/// Checks the auth file path first, then the config file path, and does the +/// following: +/// 1) If the auth file exists, read it and return the contents from it, if +/// possible. Otherwise return a FailedToReadAuthFile error. +/// 2) If the auth file does not exist, but the config file does, read it and +/// convert it to an auth file, then return the contents from it, if +/// possible. Otherwise return a FailedToReadConfigFile error. +/// 3) If neither file exists, return an empty auth file and write a blank one +/// to disk. +/// +/// Note that we have a potential TOCTOU race condition in this function. If +/// this is invoked and the file we're trying to read is deleted after a +/// condition is met, we should simply error out on reading a file that no +/// longer exists. +pub fn read_or_create_auth_file( + auth_file_path: &AbsoluteSystemPath, + config_file_path: &AbsoluteSystemPath, + client: &impl Client, +) -> Result { + if auth_file_path.try_exists()? { + let content = auth_file_path + .read_to_string() + .map_err(|e| Error::FailedToReadAuthFile { + source: e, + path: auth_file_path.to_owned(), + })?; + let tokens: AuthFile = serde_json::from_str(&content) + .map_err(|e| Error::FailedToDeserializeAuthFile { source: e })?; + let mut auth_file = AuthFile::new(); + for (api, token) in tokens.tokens() { + auth_file.insert(api.to_owned(), token.to_owned()); + } + return Ok(auth_file); + } else if config_file_path.try_exists()? { + let content = + config_file_path + .read_to_string() + .map_err(|e| Error::FailedToReadConfigFile { + source: e, + path: config_file_path.to_owned(), + })?; + let config_token: ConfigToken = serde_json::from_str(&content) + .map_err(|e| Error::FailedToDeserializeConfigToken { source: e })?; + + let auth_token = convert_to_auth_token(&config_token.token, client.base_url()); + + let mut auth_file = AuthFile::new(); + auth_file.insert(client.base_url().to_owned(), auth_token.token); + auth_file.write_to_disk(auth_file_path)?; + return Ok(auth_file); + } + + // If neither file exists, return an empty auth file and write a blank one to + // disk. + let auth_file = AuthFile::default(); + auth_file.write_to_disk(auth_file_path)?; + Ok(auth_file) +} + +#[cfg(test)] +mod tests { + use std::{fs::File, io::Write}; + + use super::*; + use crate::mocks::MockApiClient; + + #[tokio::test] + async fn test_read_or_create_auth_file_existing_auth_file() { + let tempdir = tempfile::tempdir().unwrap(); + let tempdir_path = tempdir.path().join(TURBOREPO_AUTH_FILE_NAME); + let auth_file_path = AbsoluteSystemPath::new(tempdir_path.to_str().unwrap()) + .expect("Failed to create auth file path"); + let config_file_path = AbsoluteSystemPath::new(tempdir_path.to_str().unwrap()) + .expect("Failed to create config file path"); + + // Create auth file + let mut mock_auth_file = AuthFile::new(); + mock_auth_file.insert("mock-api".to_owned(), "mock-token".to_owned()); + mock_auth_file.write_to_disk(auth_file_path).unwrap(); + + let client = MockApiClient::new(); + + let result = read_or_create_auth_file(auth_file_path, config_file_path, &client); + + assert!(result.is_ok()); + let auth_file = result.unwrap(); + assert_eq!(auth_file.tokens().len(), 1); + } + + #[tokio::test] + async fn test_read_or_create_auth_file_no_file_exists() { + let tempdir = tempfile::tempdir().unwrap(); + let tempdir_path = tempdir.path().join(TURBOREPO_AUTH_FILE_NAME); + let auth_file_path = AbsoluteSystemPath::new(tempdir_path.to_str().unwrap()) + .expect("Failed to create auth file path"); + let config_file_path = AbsoluteSystemPath::new(tempdir_path.to_str().unwrap()) + .expect("Failed to create config file path"); + + let client = MockApiClient::new(); + let result = read_or_create_auth_file(auth_file_path, config_file_path, &client); + + assert!(result.is_ok()); + assert!(std::fs::try_exists(auth_file_path).unwrap_or(false)); + assert!(result.unwrap().tokens().is_empty()); + } + + #[tokio::test] + async fn test_read_or_create_auth_file_existing_config_file() { + let tempdir = tempfile::tempdir().unwrap(); + let tempdir_path = tempdir.path(); + let auth_file_path = tempdir_path.join(TURBOREPO_AUTH_FILE_NAME); + let config_file_path = tempdir_path.join(TURBOREPO_LEGACY_AUTH_FILE_NAME); + let full_auth_file_path = AbsoluteSystemPath::new(auth_file_path.to_str().unwrap()) + .expect("Failed to create auth file path"); + let full_config_file_path = AbsoluteSystemPath::new(config_file_path.to_str().unwrap()) + .expect("Failed to create config file path"); + + // Create config file data + let mock_config_file_data = serde_json::to_string(&ConfigToken { + token: "mock-token".to_string(), + }) + .unwrap(); + + // Write config file data to system. + let mut file = File::create(full_config_file_path).unwrap(); + file.write_all(mock_config_file_data.as_bytes()).unwrap(); + + let client = MockApiClient::new(); + + // Test: Get the result of reading the auth file + let result = read_or_create_auth_file(full_auth_file_path, full_config_file_path, &client); + + // Make sure no errors come back + assert!(result.is_ok()); + // And then make sure the file was actually created on the fs + assert!(std::fs::try_exists(full_auth_file_path).unwrap_or(false)); + + // Then make sure the auth file contains the correct data + let auth_file_check: AuthFile = + serde_json::from_str(&full_auth_file_path.read_to_string().unwrap()).unwrap(); + + let auth_file = result.unwrap(); + + assert_eq!(auth_file_check, auth_file); + assert_eq!(auth_file.tokens().len(), 1); + } +} diff --git a/crates/turborepo-auth/src/login.rs b/crates/turborepo-auth/src/login.rs new file mode 100644 index 0000000000000..8bda5d29bf2d0 --- /dev/null +++ b/crates/turborepo-auth/src/login.rs @@ -0,0 +1,110 @@ +use std::sync::Arc; + +pub use error::Error; +use reqwest::Url; +use tokio::sync::OnceCell; +use tracing::warn; +use turborepo_api_client::Client; +use turborepo_ui::{start_spinner, UI}; + +use crate::{convert_to_auth_token, error, ui, AuthToken, LoginServer}; + +const DEFAULT_HOST_NAME: &str = "127.0.0.1"; +const DEFAULT_PORT: u16 = 9789; + +/// Fetches a raw token from the login server and converts it to an +/// AuthToken. +pub async fn login( + api_client: &impl Client, + ui: &UI, + login_url_configuration: &str, + login_server: &impl LoginServer, +) -> Result { + let login_url = build_login_url(login_url_configuration)?; + + println!(">>> Opening browser to {login_url}"); + let spinner = start_spinner("Waiting for your authorization..."); + + // Try to open browser for auth confirmation. + let url = login_url.as_str(); + if login_server.open_web_browser(url).is_err() { + warn!("Failed to open browser. Please visit {url} in your browser."); + } + + let token_cell = Arc::new(OnceCell::new()); + login_server + .run( + DEFAULT_PORT, + login_url_configuration.to_string(), + token_cell.clone(), + ) + .await?; + + spinner.finish_and_clear(); + + let token = token_cell.get().ok_or(Error::FailedToGetToken)?; + let auth_token = convert_to_auth_token(token, api_client.base_url()); + let response_user = api_client.get_user(&auth_token.token).await?; + + ui::print_cli_authorized(&response_user.user.email, ui); + + Ok(auth_token) +} + +fn build_login_url(config: &str) -> Result { + let redirect_url = format!("http://{DEFAULT_HOST_NAME}:{DEFAULT_PORT}"); + let mut login_url = Url::parse(config).map_err(Error::UrlParseError)?; + + login_url + .path_segments_mut() + .map_err(|_: ()| Error::LoginUrlCannotBeABase { + value: config.to_string(), + })? + .extend(["turborepo", "token"]); + + login_url + .query_pairs_mut() + .append_pair("redirect_uri", &redirect_url); + + Ok(login_url) +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::Ordering; + + use turborepo_vercel_api_mock::start_test_server; + + use super::*; + use crate::mocks::*; + + #[tokio::test] + async fn test_login() { + // Setup: Start login server on separate thread + let port = port_scanner::request_open_port().unwrap(); + let api_server = tokio::spawn(start_test_server(port)); + let ui = UI::new(false); + let url = format!("http://localhost:{port}"); + + let api_client = MockApiClient::new(); + + let login_server = MockLoginServer { + hits: Arc::new(0.into()), + }; + + // Test: Call the login function and check the result + let auth_token = login(&api_client, &ui, &url, &login_server).await.unwrap(); + + let got_token = Some(auth_token.token); + + // Token should be set now + assert_eq!( + got_token.as_deref(), + Some(turborepo_vercel_api_mock::EXPECTED_TOKEN) + ); + // Make sure we hit the login server only once + assert_eq!(login_server.hits.load(Ordering::Relaxed), 1); + + api_server.abort(); + } +} diff --git a/crates/turborepo-auth/src/server/login_server.rs b/crates/turborepo-auth/src/login_server.rs similarity index 84% rename from crates/turborepo-auth/src/server/login_server.rs rename to crates/turborepo-auth/src/login_server.rs index 094add9f2758e..24b60c9ed3e5c 100644 --- a/crates/turborepo-auth/src/server/login_server.rs +++ b/crates/turborepo-auth/src/login_server.rs @@ -21,10 +21,11 @@ pub trait LoginServer { login_url_base: String, login_token: Arc>, ) -> Result<(), Error>; + fn open_web_browser(&self, url: &str) -> std::io::Result<()>; } -/// TODO: Document this. -pub struct DefaultLoginServer; +/// Default login server. Does not handle SSO, and requires no configuration. +pub struct DefaultLoginServer {} #[async_trait] impl LoginServer for DefaultLoginServer { @@ -56,4 +57,7 @@ impl LoginServer for DefaultLoginServer { Ok(()) } + fn open_web_browser(&self, url: &str) -> std::io::Result<()> { + webbrowser::open(url) + } } diff --git a/crates/turborepo-auth/src/auth/logout.rs b/crates/turborepo-auth/src/logout.rs similarity index 100% rename from crates/turborepo-auth/src/auth/logout.rs rename to crates/turborepo-auth/src/logout.rs diff --git a/crates/turborepo-auth/src/mocks/mock_api_client.rs b/crates/turborepo-auth/src/mocks/mock_api_client.rs new file mode 100644 index 0000000000000..fc0675b89de70 --- /dev/null +++ b/crates/turborepo-auth/src/mocks/mock_api_client.rs @@ -0,0 +1,188 @@ +use std::time::SystemTime; + +use async_trait::async_trait; +use reqwest::{Method, RequestBuilder, Response}; +use turborepo_api_client::Client; +use turborepo_vercel_api::{ + CachingStatusResponse, Membership, PreflightResponse, Role, Space, SpacesResponse, Team, + TeamsResponse, TokenMetadata, User, UserResponse, VerifiedSsoUser, +}; + +#[derive(Debug, thiserror::Error)] +pub enum MockApiError { + #[error("Empty token")] + EmptyToken, +} + +impl From for turborepo_api_client::Error { + fn from(error: MockApiError) -> Self { + match error { + MockApiError::EmptyToken => turborepo_api_client::Error::UnknownStatus { + code: "empty token".to_string(), + message: "token is empty".to_string(), + backtrace: std::backtrace::Backtrace::capture(), + }, + } + } +} + +#[derive(Default)] +pub struct MockApiClient { + pub base_url: String, +} + +impl MockApiClient { + #[allow(dead_code)] + pub fn new() -> Self { + Self { + base_url: "custom-domain".to_string(), + } + } +} + +#[async_trait] +impl Client for MockApiClient { + fn base_url(&self) -> &str { + &self.base_url + } + async fn get_user(&self, token: &str) -> turborepo_api_client::Result { + if token.is_empty() { + return Err(MockApiError::EmptyToken.into()); + } + + Ok(UserResponse { + user: User { + id: "user id".to_string(), + username: "username".to_string(), + email: "email".to_string(), + name: Some("Voz".to_string()), + created_at: Some( + SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap() + .as_secs(), + ), + }, + }) + } + async fn get_teams(&self, token: &str) -> turborepo_api_client::Result { + if token.is_empty() { + return Err(MockApiError::EmptyToken.into()); + } + + Ok(TeamsResponse { + teams: vec![Team { + id: "team id".to_string(), + slug: "voz-slug".to_string(), + name: "team-voz".to_string(), + created_at: 0, + created: chrono::Utc::now(), + membership: Membership::new(Role::Member), + }], + }) + } + async fn get_team( + &self, + _token: &str, + _team_id: &str, + ) -> turborepo_api_client::Result> { + unimplemented!("get_team") + } + fn add_ci_header(_request_builder: RequestBuilder) -> RequestBuilder { + unimplemented!("add_ci_header") + } + async fn get_caching_status( + &self, + _token: &str, + _team_id: Option<&str>, + _team_slug: Option<&str>, + ) -> turborepo_api_client::Result { + unimplemented!("get_caching_status") + } + async fn get_spaces( + &self, + token: &str, + _team_id: Option<&str>, + ) -> turborepo_api_client::Result { + if token.is_empty() { + return Err(MockApiError::EmptyToken.into()); + } + Ok(SpacesResponse { + spaces: vec![Space { + id: "space id".to_string(), + name: "space jam".to_string(), + }], + }) + } + async fn verify_sso_token( + &self, + token: &str, + _: &str, + ) -> turborepo_api_client::Result { + Ok(VerifiedSsoUser { + token: token.to_string(), + team_id: Some("team_id".to_string()), + }) + } + async fn get_token_metadata( + &self, + _token: &str, + ) -> turborepo_api_client::Result { + unimplemented!("get_token_metadata") + } + 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") + } + async fn do_preflight( + &self, + _token: &str, + _request_url: &str, + _request_method: &str, + _request_headers: &str, + ) -> turborepo_api_client::Result { + unimplemented!("do_preflight") + } + fn make_url(&self, endpoint: &str) -> String { + format!("{}{}", self.base_url, endpoint) + } +} diff --git a/crates/turborepo-auth/src/mocks/mock_login_server.rs b/crates/turborepo-auth/src/mocks/mock_login_server.rs new file mode 100644 index 0000000000000..fc4c46d39f286 --- /dev/null +++ b/crates/turborepo-auth/src/mocks/mock_login_server.rs @@ -0,0 +1,54 @@ +use std::sync::{atomic::AtomicUsize, Arc}; + +use async_trait::async_trait; +use tokio::sync::OnceCell; + +use crate::{Error, LoginServer, SSOLoginServer}; + +pub const EXPECTED_VERIFICATION_TOKEN: &str = "expected_verification_token"; + +pub struct MockLoginServer { + pub hits: Arc, +} + +#[async_trait] +impl LoginServer for MockLoginServer { + async fn run( + &self, + _: u16, + _: String, + login_token: Arc>, + ) -> Result<(), Error> { + self.hits.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + login_token + .set(turborepo_vercel_api_mock::EXPECTED_TOKEN.to_string()) + .unwrap(); + Ok(()) + } + fn open_web_browser(&self, _: &str) -> std::io::Result<()> { + Ok(()) + } +} + +#[derive(Clone)] +pub struct MockSSOLoginServer { + pub hits: Arc, +} + +#[async_trait] +impl SSOLoginServer for MockSSOLoginServer { + async fn run( + &self, + _port: u16, + verification_token: Arc>, + ) -> Result<(), Error> { + self.hits.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + verification_token + .set(EXPECTED_VERIFICATION_TOKEN.to_string()) + .unwrap(); + Ok(()) + } + fn open_web_browser(&self, _: &str) -> std::io::Result<()> { + Ok(()) + } +} diff --git a/crates/turborepo-auth/src/mocks/mod.rs b/crates/turborepo-auth/src/mocks/mod.rs new file mode 100644 index 0000000000000..57670e680314f --- /dev/null +++ b/crates/turborepo-auth/src/mocks/mod.rs @@ -0,0 +1,8 @@ +pub mod mock_api_client; +pub mod mock_login_server; + +// These are fine to allow because they are only used in tests. +#[allow(unused_imports)] +pub use mock_api_client::*; +#[allow(unused_imports)] +pub use mock_login_server::*; diff --git a/crates/turborepo-auth/src/server/mod.rs b/crates/turborepo-auth/src/server/mod.rs deleted file mode 100644 index 3aad0b921f447..0000000000000 --- a/crates/turborepo-auth/src/server/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -mod login_server; -mod sso_server; - -pub use login_server::*; -pub use sso_server::*; diff --git a/crates/turborepo-auth/src/sso.rs b/crates/turborepo-auth/src/sso.rs new file mode 100644 index 0000000000000..5e5d7ed5c77db --- /dev/null +++ b/crates/turborepo-auth/src/sso.rs @@ -0,0 +1,113 @@ +use std::sync::Arc; + +use reqwest::Url; +use tokio::sync::OnceCell; +use tracing::warn; +use turborepo_api_client::Client; +use turborepo_ui::{start_spinner, UI}; + +use crate::{convert_to_auth_token, error, ui, AuthToken, Error, SSOLoginServer}; + +const DEFAULT_HOST_NAME: &str = "127.0.0.1"; +const DEFAULT_PORT: u16 = 9789; +const DEFAULT_SSO_PROVIDER: &str = "SAML/OIDC Single Sign-On"; + +fn make_token_name() -> Result { + let host = hostname::get().map_err(Error::FailedToMakeSSOTokenName)?; + + Ok(format!( + "Turbo CLI on {} via {DEFAULT_SSO_PROVIDER}", + host.to_string_lossy() + )) +} + +/// Given an API client and a login URL, fetches a raw token from the login +/// server. Will use the `sso_team` to fetch a correct token and verify it +/// against the user. +pub async fn sso_login<'a>( + api_client: &impl Client, + ui: &UI, + login_url_configuration: &str, + sso_team: &str, + login_server: &impl SSOLoginServer, +) -> Result { + let redirect_url = format!("http://{DEFAULT_HOST_NAME}:{DEFAULT_PORT}"); + let mut login_url = Url::parse(login_url_configuration)?; + + login_url + .path_segments_mut() + .map_err(|_: ()| error::Error::LoginUrlCannotBeABase { + value: login_url_configuration.to_string(), + })? + .extend(["api", "auth", "sso"]); + + login_url + .query_pairs_mut() + .append_pair("teamId", sso_team) + .append_pair("mode", "login") + .append_pair("next", &redirect_url); + + println!(">>> Opening browser to {login_url}"); + let spinner = start_spinner("Waiting for your authorization..."); + + // Try to open browser for auth confirmation. + let url = login_url.as_str(); + if login_server.open_web_browser(url).is_err() { + warn!("Failed to open browser. Please visit {url} in your browser."); + } + + let token_cell = Arc::new(OnceCell::new()); + login_server.run(DEFAULT_PORT, token_cell.clone()).await?; + spinner.finish_and_clear(); + + let token = token_cell.get().ok_or(Error::FailedToGetToken)?; + + let token_name = make_token_name()?; + + let verified_user = api_client + .verify_sso_token(token, &token_name) + .await + .map_err(Error::FailedToValidateSSOToken)?; + + let user_response = api_client + .get_user(&verified_user.token) + .await + .map_err(Error::FailedToFetchUser)?; + + let auth_token = convert_to_auth_token(&verified_user.token, api_client.base_url()); + + ui::print_cli_authorized(&user_response.user.email, ui); + + Ok(auth_token) +} + +#[cfg(test)] +mod tests { + use turborepo_vercel_api_mock::start_test_server; + + use super::*; + use crate::mocks::*; + + #[tokio::test] + async fn test_sso_login() { + let port = port_scanner::request_open_port().unwrap(); + let handle = tokio::spawn(start_test_server(port)); + let url = format!("http://localhost:{port}"); + let ui = UI::new(false); + let team = "something"; + + let api_client = MockApiClient::new(); + + let login_server = MockSSOLoginServer { + hits: Arc::new(0.into()), + }; + + let token = sso_login(&api_client, &ui, &url, team, &login_server) + .await + .unwrap(); + + assert_eq!(token.token, EXPECTED_VERIFICATION_TOKEN.to_owned()); + + handle.abort(); + } +} diff --git a/crates/turborepo-auth/src/server/sso_server.rs b/crates/turborepo-auth/src/sso_server.rs similarity index 95% rename from crates/turborepo-auth/src/server/sso_server.rs rename to crates/turborepo-auth/src/sso_server.rs index 79bf66ec7ed61..7c769374bdee3 100644 --- a/crates/turborepo-auth/src/server/sso_server.rs +++ b/crates/turborepo-auth/src/sso_server.rs @@ -22,9 +22,10 @@ pub struct SsoPayload { #[async_trait] pub trait SSOLoginServer { async fn run(&self, port: u16, verification_token: Arc>) -> Result<(), Error>; + fn open_web_browser(&self, url: &str) -> std::io::Result<()>; } -/// TODO: Document this. +/// Basic SSO login server. No configuration required. pub struct DefaultSSOLoginServer; #[async_trait] @@ -56,6 +57,9 @@ impl SSOLoginServer for DefaultSSOLoginServer { Ok(()) } + fn open_web_browser(&self, url: &str) -> std::io::Result<()> { + webbrowser::open(url) + } } fn get_token_and_redirect(payload: SsoPayload) -> Result<(Option, Url), Error> { diff --git a/crates/turborepo-auth/src/ui/mod.rs b/crates/turborepo-auth/src/ui/mod.rs index 299ee6fcabf8e..919d259c77110 100644 --- a/crates/turborepo-auth/src/ui/mod.rs +++ b/crates/turborepo-auth/src/ui/mod.rs @@ -1,3 +1,3 @@ mod messages; -pub use messages::*; +pub(crate) use messages::*; diff --git a/crates/turborepo-lib/Cargo.toml b/crates/turborepo-lib/Cargo.toml index fb6eaac94ddd9..27ca9e7aa0cfe 100644 --- a/crates/turborepo-lib/Cargo.toml +++ b/crates/turborepo-lib/Cargo.toml @@ -85,6 +85,7 @@ tonic-reflection = { version = "0.6.0", optional = true } tower = "0.4.13" turborepo-analytics = { path = "../turborepo-analytics" } turborepo-auth = { path = "../turborepo-auth" } +turborepo-dirs = { path = "../turborepo-dirs" } turborepo-fs = { path = "../turborepo-fs" } turborepo-graph-utils = { path = "../turborepo-graph-utils" } diff --git a/crates/turborepo-lib/src/cli/error.rs b/crates/turborepo-lib/src/cli/error.rs index ec9b52ea1622f..9f6b840fecf36 100644 --- a/crates/turborepo-lib/src/cli/error.rs +++ b/crates/turborepo-lib/src/cli/error.rs @@ -1,7 +1,8 @@ -use std::backtrace; +use std::{backtrace, io}; use miette::Diagnostic; use thiserror::Error; +use turbopath::AbsoluteSystemPathBuf; use turborepo_repository::package_graph; use crate::{ @@ -27,11 +28,33 @@ pub enum Error { ChromeTracing(#[from] crate::tracing::Error), #[error(transparent)] BuildPackageGraph(#[from] package_graph::builder::Error), + #[error("Encountered an IO error while attempting to read {config_path}: {error}")] + FailedToReadConfig { + config_path: AbsoluteSystemPathBuf, + error: io::Error, + }, + #[error("Encountered an IO error while attempting to read {auth_path}: {error}")] + FailedToReadAuth { + auth_path: AbsoluteSystemPathBuf, + error: io::Error, + }, + #[error("Encountered an IO error while attempting to set {config_path}: {error}")] + FailedToSetConfig { + config_path: AbsoluteSystemPathBuf, + error: io::Error, + }, + #[error("Encountered an IO error while attempting to set {auth_path}: {error}")] + FailedToSetAuth { + auth_path: AbsoluteSystemPathBuf, + error: io::Error, + }, #[error(transparent)] Rewrite(#[from] RewriteError), #[error(transparent)] Auth(#[from] turborepo_auth::Error), #[error(transparent)] + Api(#[from] turborepo_api_client::Error), + #[error(transparent)] Daemon(#[from] DaemonError), #[error(transparent)] Generate(#[from] generate::Error), diff --git a/crates/turborepo-lib/src/cli/mod.rs b/crates/turborepo-lib/src/cli/mod.rs index f2cb3eb80f946..9dc04909511ee 100644 --- a/crates/turborepo-lib/src/cli/mod.rs +++ b/crates/turborepo-lib/src/cli/mod.rs @@ -909,8 +909,9 @@ pub async fn run( let event = CommandEventBuilder::new("logout").with_parent(&root_telemetry); event.track_call(); let mut base = CommandBase::new(cli_args, repo_root, version, ui); + let event_child = event.child(); - logout::logout(&mut base, event_child)?; + logout::logout(&mut base, event_child).await?; Ok(Payload::Rust(Ok(0))) } diff --git a/crates/turborepo-lib/src/commands/link.rs b/crates/turborepo-lib/src/commands/link.rs index f7e214a0200bc..3078ab652c141 100644 --- a/crates/turborepo-lib/src/commands/link.rs +++ b/crates/turborepo-lib/src/commands/link.rs @@ -18,6 +18,7 @@ use dirs_next::home_dir; use rand::Rng; use thiserror::Error; use turborepo_api_client::Client; +use turborepo_auth::read_or_create_auth_file; #[cfg(not(test))] use turborepo_ui::CYAN; use turborepo_ui::{BOLD, GREY, UNDERLINE}; @@ -171,9 +172,19 @@ pub async fn link( let homedir = homedir_path.to_string_lossy(); let repo_root_with_tilde = base.repo_root.to_string().replacen(&*homedir, "~", 1); let api_client = base.api_client()?; - let token = base.config()?.token().ok_or_else(|| Error::TokenNotFound { - command: base.ui.apply(BOLD.apply_to("`npx turbo login`")), - })?; + let auth_file_path = base.global_auth_path()?; + let config_file_path = base.global_config_path()?; + let auth = read_or_create_auth_file(&auth_file_path, &config_file_path, &api_client).map_err( + |_| Error::TokenNotFound { + command: base.ui.apply(BOLD.apply_to("npx turbo login")), + }, + )?; + let token = &auth + .get_token(api_client.base_url()) + .ok_or_else(|| Error::TokenNotFound { + command: base.ui.apply(BOLD.apply_to("npx turbo login")), + })? + .token; match target { LinkTarget::RemoteCache => { @@ -585,6 +596,7 @@ mod test { use std::{cell::OnceCell, fs}; use anyhow::Result; + use serde_json::json; use tempfile::{NamedTempFile, TempDir}; use turbopath::AbsoluteSystemPathBuf; use turborepo_ui::UI; @@ -599,9 +611,22 @@ mod test { #[tokio::test] async fn test_link_remote_cache() -> Result<()> { + let port = port_scanner::request_open_port().unwrap(); + // user config let user_config_file = NamedTempFile::new().unwrap(); - fs::write(user_config_file.path(), r#"{ "token": "hello" }"#).unwrap(); + + // auth file + let auth_file = NamedTempFile::new().unwrap(); + let host_with_port = format!("http://localhost:{}", port); + let raw_token_json = json!({ + "tokens": { + host_with_port.clone(): "token" + } + }); + let raw_json = serde_json::to_string_pretty(&raw_token_json).unwrap(); + + fs::write(auth_file.path(), raw_json).unwrap(); // repo let repo_root_tmp_dir = TempDir::new().unwrap(); @@ -622,12 +647,14 @@ mod test { .create_with_contents(r#"{ "apiurl": "http://localhost:3000" }"#) .unwrap(); - let port = port_scanner::request_open_port().unwrap(); let handle = tokio::spawn(start_test_server(port)); let mut base = CommandBase { global_config_path: Some( AbsoluteSystemPathBuf::try_from(user_config_file.path().to_path_buf()).unwrap(), ), + global_auth_path: Some( + AbsoluteSystemPathBuf::try_from(auth_file.path().to_path_buf()).unwrap(), + ), repo_root: repo_root.clone(), ui: UI::new(false), config: OnceCell::new(), @@ -637,8 +664,8 @@ mod test { base.config .set( TurborepoConfigBuilder::new(&base) - .with_api_url(Some(format!("http://localhost:{}", port))) - .with_login_url(Some(format!("http://localhost:{}", port))) + .with_api_url(Some(host_with_port.clone())) + .with_login_url(Some(host_with_port.clone())) .with_token(Some("token".to_string())) .build() .unwrap(), @@ -665,9 +692,22 @@ mod test { #[tokio::test] async fn test_link_spaces() { + let port = port_scanner::request_open_port().unwrap(); + // user config let user_config_file = NamedTempFile::new().unwrap(); - fs::write(user_config_file.path(), r#"{ "token": "hello" }"#).unwrap(); + + // auth file + let auth_file = NamedTempFile::new().unwrap(); + let host_with_port = format!("http://localhost:{}", port); + let raw_token_json = json!({ + "tokens": { + host_with_port: "hello" + } + }); + let raw_json = serde_json::to_string_pretty(&raw_token_json).unwrap(); + + fs::write(auth_file.path(), raw_json).unwrap(); // repo let repo_root_tmp_dir = TempDir::new().unwrap(); @@ -688,12 +728,14 @@ mod test { .create_with_contents(r#"{ "apiurl": "http://localhost:3000" }"#) .unwrap(); - let port = port_scanner::request_open_port().unwrap(); let handle = tokio::spawn(start_test_server(port)); let mut base = CommandBase { global_config_path: Some( AbsoluteSystemPathBuf::try_from(user_config_file.path().to_path_buf()).unwrap(), ), + global_auth_path: Some( + AbsoluteSystemPathBuf::try_from(auth_file.path().to_path_buf()).unwrap(), + ), repo_root: repo_root.clone(), ui: UI::new(false), config: OnceCell::new(), diff --git a/crates/turborepo-lib/src/commands/login.rs b/crates/turborepo-lib/src/commands/login.rs index a54746042dc54..1a420b8153987 100644 --- a/crates/turborepo-lib/src/commands/login.rs +++ b/crates/turborepo-lib/src/commands/login.rs @@ -1,95 +1,282 @@ -use turborepo_api_client::APIClient; +use turborepo_api_client::{APIClient, Client}; use turborepo_auth::{ - login as auth_login, sso_login as auth_sso_login, DefaultLoginServer, DefaultSSOLoginServer, + login as auth_login, read_or_create_auth_file, sso_login as auth_sso_login, AuthFile, + DefaultLoginServer, DefaultSSOLoginServer, LoginServer, SSOLoginServer, }; use turborepo_telemetry::events::command::{CommandEventBuilder, LoginMethod}; +use turborepo_ui::{BOLD, CYAN, UI}; +use turborepo_vercel_api::TokenMetadata; -use crate::{cli::Error, commands::CommandBase, config, rewrite_json::set_path}; +use crate::{cli::Error, commands::CommandBase}; +pub struct Login { + pub(crate) server: T, +} + +impl Login { + pub(crate) async fn login( + &self, + base: &mut CommandBase, + telemetry: CommandEventBuilder, + ) -> Result<(), Error> { + telemetry.track_login_method(LoginMethod::Standard); + let api_client: APIClient = base.api_client()?; + let ui = base.ui; + let login_url_config = base.config()?.login_url().to_string(); + + // Get both possible token paths for existing token(s) checks. + let global_auth_path = base.global_auth_path()?; + let global_config_path = base.global_config_path()?; + + let mut auth_file = + read_or_create_auth_file(&global_auth_path, &global_config_path, &api_client)?; + + if self + .has_existing_token(&api_client, &mut auth_file, &ui) + .await? + { + return Ok(()); + } + + let login_server = &self.server; + + let auth_token = auth_login(&api_client, &ui, &login_url_config, login_server).await?; + + auth_file.insert(api_client.base_url().to_owned(), auth_token.token); + auth_file.write_to_disk(&global_auth_path)?; + Ok(()) + } + + async fn has_existing_token( + &self, + api_client: &APIClient, + auth_file: &mut AuthFile, + ui: &UI, + ) -> Result { + if let Some(token) = auth_file.get_token(api_client.base_url()) { + let metadata: TokenMetadata = api_client.get_token_metadata(&token.token).await?; + let user_response = api_client.get_user(&token.token).await?; + if metadata.origin == "manual" { + println!("{}", ui.apply(BOLD.apply_to("Existing token found!"))); + print_cli_authorized(&user_response.user.username, ui); + return Ok(true); + } + } + Ok(false) + } +} +impl Login { + pub(crate) async fn sso_login( + &self, + base: &mut CommandBase, + sso_team: &str, + telemetry: CommandEventBuilder, + ) -> Result<(), Error> { + telemetry.track_login_method(LoginMethod::SSO); + let api_client: APIClient = base.api_client()?; + let ui = base.ui; + let login_url_config = base.config()?.login_url().to_string(); + + // Get both possible token paths for existing token(s) checks. + let global_auth_path = base.global_auth_path()?; + let global_config_path = base.global_config_path()?; + + let mut auth_file = + read_or_create_auth_file(&global_auth_path, &global_config_path, &api_client)?; + + if self + .has_existing_sso_token(&api_client, &mut auth_file, &base.ui, sso_team) + .await? + { + return Ok(()); + } + + let login_server = &self.server; + + let auth_token = + auth_sso_login(&api_client, &ui, &login_url_config, sso_team, login_server).await?; + + auth_file.insert(api_client.base_url().to_owned(), auth_token.token); + auth_file.write_to_disk(&global_auth_path)?; + + Ok(()) + } + + async fn has_existing_sso_token( + &self, + api_client: &APIClient, + auth_file: &mut AuthFile, + ui: &UI, + sso_team: &str, + ) -> Result { + if let Some(token) = auth_file.get_token(api_client.base_url()) { + let metadata: TokenMetadata = api_client.get_token_metadata(&token.token).await?; + let user_response = api_client.get_user(&token.token).await?; + let teams = api_client.get_teams(&token.token).await?; + if metadata.origin == "saml" && teams.teams.iter().any(|team| team.slug == sso_team) { + println!("{}", ui.apply(BOLD.apply_to("Existing token found!"))); + print_cli_authorized(&user_response.user.username, ui); + return Ok(true); + } + } + Ok(false) + } +} + +/// Entry point for `turbo login --sso-team`. pub async fn sso_login( base: &mut CommandBase, sso_team: &str, telemetry: CommandEventBuilder, ) -> Result<(), Error> { - telemetry.track_login_method(LoginMethod::SSO); - let api_client: APIClient = base.api_client()?; - let ui = base.ui; - let login_url_config = base.config()?.login_url().to_string(); - - let token = auth_sso_login( - &api_client, - &ui, - base.config()?.token(), - &login_url_config, - sso_team, - &DefaultSSOLoginServer, - ) - .await?; - - let global_config_path = base.global_config_path()?; - let before = global_config_path - .read_existing_to_string_or(Ok("{}")) - .map_err(|e| config::Error::FailedToReadConfig { - config_path: global_config_path.clone(), - error: e, - })?; - - let after = set_path(&before, &["token"], &format!("\"{}\"", token))?; - - global_config_path - .ensure_dir() - .map_err(|e| config::Error::FailedToSetConfig { - config_path: global_config_path.clone(), - error: e, - })?; - - global_config_path - .create_with_contents(after) - .map_err(|e| config::Error::FailedToSetConfig { - config_path: global_config_path.clone(), - error: e, - })?; - - Ok(()) + Login { + server: DefaultSSOLoginServer {}, + } + .sso_login(base, sso_team, telemetry) + .await } +/// Entry point for `turbo login`. Checks for the existence of an auth file +/// token that matches the API base URL, and if we already have a token for it, +/// returns that one instead of fetching a new one. Otherwise, fetches a new +/// token and writes it to `auth.json` in the Turbo config directory. pub async fn login(base: &mut CommandBase, telemetry: CommandEventBuilder) -> Result<(), Error> { - telemetry.track_login_method(LoginMethod::Standard); - let api_client: APIClient = base.api_client()?; - let ui = base.ui; - let login_url_config = base.config()?.login_url().to_string(); - - let token = auth_login( - &api_client, - &ui, - base.config()?.token(), - &login_url_config, - &DefaultLoginServer, - ) - .await?; - - let global_config_path = base.global_config_path()?; - let before = global_config_path - .read_existing_to_string_or(Ok("{}")) - .map_err(|e| config::Error::FailedToReadConfig { - config_path: global_config_path.clone(), - error: e, - })?; - let after = set_path(&before, &["token"], &format!("\"{}\"", token))?; - - global_config_path - .ensure_dir() - .map_err(|e| config::Error::FailedToSetConfig { - config_path: global_config_path.clone(), - error: e, - })?; - - global_config_path - .create_with_contents(after) - .map_err(|e| config::Error::FailedToSetConfig { - config_path: global_config_path.clone(), - error: e, - })?; - - Ok(()) + Login { + server: DefaultLoginServer {}, + } + .login(base, telemetry) + .await +} + +fn print_cli_authorized(user: &str, ui: &UI) { + println!( + " +{} Turborepo CLI authorized for {} +{} +{} +", + ui.rainbow(">>> Success!"), + user, + ui.apply( + CYAN.apply_to("To connect to your Remote Cache, run the following in any turborepo:") + ), + ui.apply(BOLD.apply_to(" npx turbo link")) + ); +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use camino::Utf8PathBuf; + use turbopath::AbsoluteSystemPathBuf; + use turborepo_auth::{mocks::*, AuthFile, TURBOREPO_AUTH_FILE_NAME}; + use turborepo_vercel_api_mock::start_test_server; + + use super::*; + use crate::{commands::CommandBase, Args}; + + fn setup_base(auth_path: &AbsoluteSystemPathBuf, port: u16) -> CommandBase { + let temp_dir = tempfile::tempdir().unwrap(); + let auth_file_path = + AbsoluteSystemPathBuf::try_from(temp_dir.path().join(TURBOREPO_AUTH_FILE_NAME)) + .unwrap(); + + let cwd = Utf8PathBuf::from_path_buf(temp_dir.path().to_path_buf()) + .expect("Failed to create cwd"); + + let args = Args { + api: Some(format!("http://localhost:{}", port)), + cwd: Some(cwd), + login: Some(format!("http://localhost:{}", port)), + no_color: true, + ..Default::default() + }; + let repo_root = AbsoluteSystemPathBuf::try_from(temp_dir.path().to_path_buf()).unwrap(); + let ui = turborepo_ui::UI::new(false); + + let mut base = CommandBase::new(args, repo_root, "0.0.0", ui) + .with_global_auth_path(auth_file_path.clone()) + .with_global_config_path(auth_file_path.clone()); + + base.config_init().unwrap(); + base.global_auth_path = Some(auth_path.clone()); + base + } + + #[tokio::test] + async fn test_login_with_existing_token() { + // Setup: Test dirs and mocks. + let port = port_scanner::request_open_port().unwrap(); + let api_server = tokio::spawn(start_test_server(port)); + let temp_dir = tempfile::tempdir().unwrap(); + let auth_file_path = + AbsoluteSystemPathBuf::try_from(temp_dir.path().join(TURBOREPO_AUTH_FILE_NAME)) + .unwrap(); + // Mock out the existing file. + let mut mock_auth_file = AuthFile::default(); + mock_auth_file.insert("mock-api".to_string(), "mock-token".to_string()); + mock_auth_file.write_to_disk(&auth_file_path).unwrap(); + + let mock_api_client = MockApiClient::new(); + + let mut base = setup_base(&auth_file_path, port); + + // Test: Call login function and see if we got the existing token on + // the FS back. + let login_with_mock_server = Login { + server: MockLoginServer { + hits: Arc::new(0.into()), + }, + }; + let telemetry = CommandEventBuilder::new("login"); + let result = login_with_mock_server.login(&mut base, telemetry).await; + assert!(result.is_ok()); + + // Since we don't return anything if the login found an existing + // token, we should read the FS for the auth token. Whatever we + // get back should be the same as the mock auth file. + // Pass in the auth file path for both possible paths becuase we + // should never read the config from here. + let found_auth_file = + read_or_create_auth_file(&auth_file_path, &auth_file_path, &mock_api_client).unwrap(); + + api_server.abort(); + assert_eq!( + mock_auth_file.get_token("mock-api"), + found_auth_file.get_token("mock-api") + ) + } + + #[tokio::test] + async fn test_login_no_existing_token() { + // Setup: Test dirs and mocks. + let port = port_scanner::request_open_port().unwrap(); + let api_server = tokio::spawn(start_test_server(port)); + let temp_dir = tempfile::tempdir().unwrap(); + let auth_file_path = + AbsoluteSystemPathBuf::try_from(temp_dir.path().join(TURBOREPO_AUTH_FILE_NAME)) + .unwrap(); + + let mock_api_client = MockApiClient::new(); + + let mut base = setup_base(&auth_file_path, port); + + // Test: Call login function and see if we got the expected token. + let login_with_mock_server = Login { + server: MockLoginServer { + hits: Arc::new(0.into()), + }, + }; + let telemetry = CommandEventBuilder::new("login"); + let result = login_with_mock_server.login(&mut base, telemetry).await; + assert!(result.is_ok()); + + let found_auth_file = + read_or_create_auth_file(&auth_file_path, &auth_file_path, &mock_api_client).unwrap(); + + api_server.abort(); + + assert_eq!(found_auth_file.tokens().len(), 1); + } } diff --git a/crates/turborepo-lib/src/commands/logout.rs b/crates/turborepo-lib/src/commands/logout.rs index 496168cb9c343..98cf66f681e25 100644 --- a/crates/turborepo-lib/src/commands/logout.rs +++ b/crates/turborepo-lib/src/commands/logout.rs @@ -1,39 +1,57 @@ use tracing::error; -use turborepo_auth::logout as auth_logout; +use turborepo_auth::{logout as auth_logout, read_or_create_auth_file, AuthToken}; use turborepo_telemetry::events::command::CommandEventBuilder; -use crate::{cli::Error, commands::CommandBase, config, rewrite_json::unset_path}; +use crate::{cli::Error, commands::CommandBase}; -pub fn logout(base: &mut CommandBase, _telemetry: CommandEventBuilder) -> Result<(), Error> { - if let Err(err) = remove_token(base) { +pub async fn logout(base: &mut CommandBase, _telemetry: CommandEventBuilder) -> Result<(), Error> { + let client = base.api_client()?; + let auth_path = base.global_auth_path()?; + let config_path = base.global_config_path()?; + let mut auth_file = read_or_create_auth_file(&auth_path, &config_path, &client)?; + + match auth_file.tokens().len() { + 0 => { + println!("No tokens to remove"); + return Ok(()); + } + 1 => { + auth_file.tokens_mut().clear(); + } + _ => { + let items = &auth_file + .tokens() + .iter() + .map(|t| { + let token = AuthToken { + api: t.0.to_string(), + token: t.1.to_string(), + }; + token.friendly_api_display().to_string() + }) + .collect::>(); + + let index = base + .ui + .display_selectable_items("Select api to log out of:", items) + .unwrap(); + + let api = items[index].split_whitespace().next().unwrap(); + + let token = auth_file + .get_token(api) + .ok_or(Error::Auth(turborepo_auth::Error::FailedToGetToken))?; + println!("Removing token for {}", token.friendly_api_display()); + auth_file.remove(api); + } + } + + if let Err(err) = auth_file.write_to_disk(&auth_path) { error!("could not logout. Something went wrong: {}", err); - return Err(err); + return Err(Error::Auth(err)); } auth_logout(&base.ui); Ok(()) } - -fn remove_token(base: &mut CommandBase) -> Result<(), Error> { - let global_config_path = base.global_config_path()?; - let before = global_config_path - .read_existing_to_string_or(Ok("{}")) - .map_err(|e| { - Error::Config(config::Error::FailedToReadConfig { - config_path: global_config_path.clone(), - error: e, - }) - })?; - - if let Some(after) = unset_path(&before, &["token"], true)? { - global_config_path.create_with_contents(after).map_err(|e| { - Error::Config(config::Error::FailedToSetConfig { - config_path: global_config_path.clone(), - error: e, - }) - }) - } else { - Ok(()) - } -} diff --git a/crates/turborepo-lib/src/commands/mod.rs b/crates/turborepo-lib/src/commands/mod.rs index 95034290a1d94..f9f43b64ea5bf 100644 --- a/crates/turborepo-lib/src/commands/mod.rs +++ b/crates/turborepo-lib/src/commands/mod.rs @@ -1,9 +1,12 @@ use std::cell::OnceCell; -use dirs_next::config_dir; use sha2::{Digest, Sha256}; use turbopath::{AbsoluteSystemPath, AbsoluteSystemPathBuf}; -use turborepo_api_client::{APIAuth, APIClient}; +use turborepo_api_client::{APIAuth, APIClient, Client}; +use turborepo_auth::{ + TURBOREPO_AUTH_FILE_NAME, TURBOREPO_CONFIG_DIR, TURBOREPO_LEGACY_AUTH_FILE_NAME, +}; +use turborepo_dirs::config_dir; use turborepo_ui::UI; use crate::{ @@ -29,6 +32,8 @@ pub struct CommandBase { pub ui: UI, #[cfg(test)] pub global_config_path: Option, + #[cfg(test)] + pub global_auth_path: Option, config: OnceCell, args: Args, version: &'static str, @@ -47,6 +52,8 @@ impl CommandBase { args, #[cfg(test)] global_config_path: None, + #[cfg(test)] + global_auth_path: None, config: OnceCell::new(), version, } @@ -57,10 +64,14 @@ impl CommandBase { self.global_config_path = Some(path); self } + #[cfg(test)] + pub fn with_global_auth_path(mut self, path: AbsoluteSystemPathBuf) -> Self { + self.global_auth_path = Some(path); + self + } fn config_init(&self) -> Result { TurborepoConfigBuilder::new(self) - // The below should be deprecated and removed. .with_api_url(self.args.api.clone()) .with_login_url(self.args.login.clone()) .with_team_slug(self.args.team.clone()) @@ -81,9 +92,24 @@ impl CommandBase { } let config_dir = config_dir().ok_or(ConfigError::NoGlobalConfigPath)?; - let global_config_path = config_dir.join("turborepo").join("config.json"); + let global_config_path = config_dir + .join(TURBOREPO_CONFIG_DIR) + .join(TURBOREPO_LEGACY_AUTH_FILE_NAME); AbsoluteSystemPathBuf::try_from(global_config_path).map_err(ConfigError::PathError) } + /// Returns the path to the global auth file (auth.json). + fn global_auth_path(&self) -> Result { + #[cfg(test)] + if let Some(global_auth_path) = &self.global_auth_path { + return Ok(global_auth_path.clone()); + } + + let config_dir = config_dir().ok_or(ConfigError::NoGlobalAuthFilePath)?; + let global_auth_path = config_dir + .join(TURBOREPO_CONFIG_DIR) + .join(TURBOREPO_AUTH_FILE_NAME); + AbsoluteSystemPathBuf::try_from(global_auth_path).map_err(ConfigError::PathError) + } fn local_config_path(&self) -> AbsoluteSystemPathBuf { self.repo_root.join_components(&[".turbo", "config.json"]) } @@ -99,15 +125,31 @@ impl CommandBase { let team_id = config.team_id(); let team_slug = config.team_slug(); - let Some(token) = config.token() else { - return Ok(None); - }; + // Check to see if token was passed in. If so, use that. + if let Some(token) = self.args.token.clone() { + return Ok(Some(APIAuth { + team_id: team_id.map(|s| s.to_string()), + token: token.to_string(), + team_slug: team_slug.map(|s| s.to_string()), + })); + } - Ok(Some(APIAuth { - team_id: team_id.map(|s| s.to_string()), - token: token.to_string(), - team_slug: team_slug.map(|s| s.to_string()), - })) + let auth_file_path = self.global_auth_path()?; + let config_file_path = self.global_config_path()?; + let client = self.api_client()?; + let auth = + turborepo_auth::read_or_create_auth_file(&auth_file_path, &config_file_path, &client)?; + + let auth_token = auth.get_token(client.base_url()); + if let Some(auth_token) = auth_token { + Ok(Some(APIAuth { + team_id: team_id.map(|s| s.to_string()), + token: auth_token.token, + team_slug: team_slug.map(|s| s.to_string()), + })) + } else { + Ok(None) + } } pub fn args(&self) -> &Args { diff --git a/crates/turborepo-lib/src/config/mod.rs b/crates/turborepo-lib/src/config/mod.rs index 552a5a5cc1c84..c2eec22b26a9e 100644 --- a/crates/turborepo-lib/src/config/mod.rs +++ b/crates/turborepo-lib/src/config/mod.rs @@ -13,8 +13,12 @@ use turbopath::AbsoluteSystemPathBuf; #[allow(clippy::enum_variant_names)] #[derive(Debug, Error)] pub enum Error { + #[error("Authentication error: {0}")] + Auth(#[from] turborepo_auth::Error), #[error("Global config path not found")] NoGlobalConfigPath, + #[error("Global auth file path not found")] + NoGlobalAuthFilePath, #[error(transparent)] PackageJson(#[from] turborepo_repository::package_json::Error), #[error( @@ -33,6 +37,11 @@ pub enum Error { config_path: AbsoluteSystemPathBuf, error: io::Error, }, + #[error("Encountered an IO error while attempting to read {auth_path}: {error}")] + FailedToReadAuth { + auth_path: AbsoluteSystemPathBuf, + error: io::Error, + }, #[error("Encountered an IO error while attempting to set {config_path}: {error}")] FailedToSetConfig { config_path: AbsoluteSystemPathBuf, diff --git a/crates/turborepo-lib/src/config/turbo_config.rs b/crates/turborepo-lib/src/config/turbo_config.rs index 3582cf563d3be..c94caef44c2fa 100644 --- a/crates/turborepo-lib/src/config/turbo_config.rs +++ b/crates/turborepo-lib/src/config/turbo_config.rs @@ -57,6 +57,8 @@ pub struct TurborepoConfigBuilder { #[cfg(test)] global_config_path: Option, #[cfg(test)] + global_auth_path: Option, + #[cfg(test)] environment: HashMap, } @@ -296,6 +298,8 @@ impl TurborepoConfigBuilder { #[cfg(test)] global_config_path: base.global_config_path.clone(), #[cfg(test)] + global_auth_path: base.global_auth_path.clone(), + #[cfg(test)] environment: Default::default(), } } @@ -311,6 +315,17 @@ impl TurborepoConfigBuilder { let global_config_path = config_dir.join("turborepo").join("config.json"); AbsoluteSystemPathBuf::try_from(global_config_path).map_err(ConfigError::PathError) } + // Location of the auth file for Turborepo. + fn global_auth_path(&self) -> Result { + #[cfg(test)] + if let Some(global_auth_path) = self.global_auth_path.clone() { + return Ok(global_auth_path); + } + + let config_dir = config_dir().ok_or(ConfigError::NoGlobalConfigPath)?; + let global_auth_path = config_dir.join("turborepo").join("auth.json"); + AbsoluteSystemPathBuf::try_from(global_auth_path).map_err(ConfigError::PathError) + } fn local_config_path(&self) -> AbsoluteSystemPathBuf { self.repo_root.join_components(&[".turbo", "config.json"]) } @@ -537,6 +552,11 @@ mod test { ) .unwrap(); + let global_auth_path = AbsoluteSystemPathBuf::try_from( + TempDir::new().unwrap().path().join("nonexistent-auth.json"), + ) + .unwrap(); + let turbo_teamid = "team_nLlpyC6REAqxydlFKbrMDlud"; let turbo_token = "abcdef1234567890abcdef"; let vercel_artifacts_owner = "team_SOMEHASH"; @@ -564,6 +584,7 @@ mod test { repo_root, override_config, global_config_path: Some(global_config_path), + global_auth_path: Some(global_auth_path), environment: env, }; diff --git a/crates/turborepo-paths/src/absolute_system_path.rs b/crates/turborepo-paths/src/absolute_system_path.rs index 4f6a1e2ecb67d..e99031a9de244 100644 --- a/crates/turborepo-paths/src/absolute_system_path.rs +++ b/crates/turborepo-paths/src/absolute_system_path.rs @@ -454,6 +454,11 @@ impl AbsoluteSystemPath { Ok(()) } + + pub fn try_exists(&self) -> Result { + // try_exists is an experimental API and not yet in fs_err + Ok(std::fs::try_exists(&self.0)?) + } } impl<'a> From<&'a AbsoluteSystemPath> for CandidatePath<'a> { diff --git a/crates/turborepo-paths/src/absolute_system_path_buf.rs b/crates/turborepo-paths/src/absolute_system_path_buf.rs index 51eae84cc2e7c..f7b48e2a0c933 100644 --- a/crates/turborepo-paths/src/absolute_system_path_buf.rs +++ b/crates/turborepo-paths/src/absolute_system_path_buf.rs @@ -196,11 +196,6 @@ impl AbsoluteSystemPathBuf { self.0.file_name() } - pub fn try_exists(&self) -> Result { - // try_exists is an experimental API and not yet in fs_err - Ok(std::fs::try_exists(&self.0)?) - } - pub fn extension(&self) -> Option<&str> { self.0.extension() } diff --git a/crates/turborepo-ui/Cargo.toml b/crates/turborepo-ui/Cargo.toml index d2501593f8513..5edb94cd6d507 100644 --- a/crates/turborepo-ui/Cargo.toml +++ b/crates/turborepo-ui/Cargo.toml @@ -16,6 +16,7 @@ workspace = true [dependencies] atty = { workspace = true } console = { workspace = true } +dialoguer = { workspace = true, features = ["fuzzy-select"] } indicatif = { workspace = true } lazy_static = { workspace = true } thiserror = { workspace = true } diff --git a/crates/turborepo-ui/src/lib.rs b/crates/turborepo-ui/src/lib.rs index 182e8b605082d..ef193895a986a 100644 --- a/crates/turborepo-ui/src/lib.rs +++ b/crates/turborepo-ui/src/lib.rs @@ -7,9 +7,13 @@ mod logs; mod output; mod prefixed; -use std::{borrow::Cow, env, f64::consts::PI, time::Duration}; +use std::{borrow::Cow, env, f64::consts::PI, io, time::Duration}; use console::{Style, StyledObject}; +use dialoguer::{ + theme::{ColorfulTheme, SimpleTheme, Theme}, + FuzzySelect, +}; use indicatif::{ProgressBar, ProgressStyle}; use lazy_static::lazy_static; use thiserror::Error; @@ -178,6 +182,26 @@ impl UI { Cow::Owned(out.join("")) } + + /// Display a list of selectable items to the user and returns the index of + /// the selected item from the provided items list. + pub fn display_selectable_items( + &self, + prompt: &str, + items: &[T], + ) -> io::Result { + let theme: Box = if self.should_strip_ansi { + Box::::default() + } else { + Box::new(SimpleTheme {}) + }; + + FuzzySelect::with_theme(&*theme) + .with_prompt(prompt) + .default(0) + .items(items) + .interact() + } } lazy_static! { diff --git a/crates/turborepo-vercel-api/Cargo.toml b/crates/turborepo-vercel-api/Cargo.toml index e9931d614e03a..a3c8a4763a820 100644 --- a/crates/turborepo-vercel-api/Cargo.toml +++ b/crates/turborepo-vercel-api/Cargo.toml @@ -17,4 +17,5 @@ workspace = true [dependencies] chrono = { workspace = true, features = ["serde"] } serde = { workspace = true } +serde_json.workspace = true url = { workspace = true } diff --git a/crates/turborepo-vercel-api/src/lib.rs b/crates/turborepo-vercel-api/src/lib.rs index 9b321c87793f9..873729d1d438e 100644 --- a/crates/turborepo-vercel-api/src/lib.rs +++ b/crates/turborepo-vercel-api/src/lib.rs @@ -1,9 +1,41 @@ //! Types for interacting with the Vercel API. Used for both //! the client (`turborepo-api-client`) and for the //! mock server (`turborepo-vercel-api-mock`) +use std::collections::HashMap; + use serde::{Deserialize, Serialize}; use url::Url; +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TokenMetadataResponse { + pub token: TokenMetadata, +} +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +pub struct TokenMetadata { + pub id: String, + pub name: String, + #[serde(rename = "type")] + pub token_type: String, + pub origin: String, + pub scopes: Vec, + #[serde(rename = "activeAt")] + pub active_at: Option, + #[serde(rename = "createdAt")] + pub created_at: Option, + #[serde(rename = "expiresAt")] + pub expires_at: Option, + #[serde(rename = "teamId")] + pub team_id: Option, +} +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)] +pub struct TokenScope { + #[serde(rename = "type")] + pub kind: String, + pub origin: String, + #[serde(flatten)] + pub extra: HashMap, +} + #[derive(Debug, Clone, Deserialize)] pub struct VerifiedSsoUser { pub token: String, @@ -40,9 +72,9 @@ pub struct ArtifactResponse { /// Membership is the relationship between the logged-in user and a particular /// team -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct Membership { - role: Role, + pub role: Role, } impl Membership { @@ -52,7 +84,7 @@ impl Membership { } } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "UPPERCASE")] pub enum Role { Member, @@ -62,7 +94,7 @@ pub enum Role { Billing, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct Team { pub id: String, pub slug: String, @@ -79,7 +111,7 @@ impl Team { } } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct Space { pub id: String, pub name: String, @@ -101,7 +133,7 @@ pub struct SpaceRun { pub url: String, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct User { pub id: String, pub username: String, @@ -212,9 +244,10 @@ pub struct TelemetryGenericEvent { #[cfg(test)] mod tests { + use serde_json::json; use test_case::test_case; - use crate::{AnalyticsEvent, CacheEvent, CacheSource}; + use crate::{AnalyticsEvent, CacheEvent, CacheSource, TokenMetadata, TokenScope}; #[test_case( AnalyticsEvent { @@ -224,7 +257,7 @@ mod tests { hash: "this-is-my-hash".to_string(), duration: 58, }, - "with-id-local-hit" + "with-id-local-hit"; "id local hit" )] #[test_case( AnalyticsEvent { @@ -234,7 +267,7 @@ mod tests { hash: "this-is-my-hash-2".to_string(), duration: 21, }, - "with-id-remote-miss" + "with-id-remote-miss"; "id remote miss" )] #[test_case( AnalyticsEvent { @@ -244,10 +277,101 @@ mod tests { hash: "this-is-my-hash-2".to_string(), duration: 21, }, - "without-id-remote-miss" + "without-id-remote-miss"; "without id remote miss" )] fn test_serialize_analytics_event(event: AnalyticsEvent, name: &str) { let json = serde_json::to_string(&event).unwrap(); insta::assert_json_snapshot!(name, json); } + + #[test_case( + TokenMetadata{ + id: "id".to_owned(), + name: "name".to_owned(), + token_type: "token type".to_owned(), + origin: "origin".to_owned(), + scopes: vec![TokenScope{ + ..Default::default() + }], + ..Default::default() + }, + json!({ + "id": "id", + "name": "name", + "type": "token type", + "origin": "origin", + "scopes": [{ + "type": "", + "origin": "", + }], + "activeAt": null, + "createdAt": null, + "expiresAt": null, + "teamId": null, + }); "renaming fields" + )] + #[test_case( + TokenMetadata::default(), + json!({ + "id": "", + "name": "", + "type": "", + "origin": "", + "scopes": [], + "activeAt": null, + "createdAt": null, + "expiresAt": null, + "teamId": null, + }); "pure defaults" + )] + fn test_serialize_token_metadata(raw_json: impl serde::Serialize, want: serde_json::Value) { + assert_eq!(serde_json::to_value(raw_json).unwrap(), want) + } + + #[test_case( + json!({ + "id": "id", + "name": "name", + "type": "token type", + "origin": "origin", + "scopes": [{ + "type": "", + "origin": "", + }], + "activeAt": null, + "createdAt": null, + "expiresAt": null, + "teamId": null, + }), + TokenMetadata{ + id: "id".to_owned(), + name: "name".to_owned(), + token_type: "token type".to_owned(), + origin: "origin".to_owned(), + scopes: vec![TokenScope{ + ..Default::default() + }], + ..Default::default() + }; "renaming fields" + )] + #[test_case( + json!({ + "id": "", + "name": "", + "type": "", + "origin": "", + "scopes": [], + "activeAt": null, + "createdAt": null, + "expiresAt": null, + "teamId": null, + }), + TokenMetadata::default(); "pure defaults" + )] + fn test_deserialize_token_metadata(raw_json: serde_json::Value, want: TokenMetadata) { + assert_eq!( + serde_json::from_value::(raw_json).unwrap(), + want + ) + } } diff --git a/crates/turborepo/src/panic_handler.rs b/crates/turborepo/src/panic_handler.rs index b09341353f722..e404fe12efa0b 100644 --- a/crates/turborepo/src/panic_handler.rs +++ b/crates/turborepo/src/panic_handler.rs @@ -36,7 +36,7 @@ Please open an issue at \ eprintln!( "Oops! Turbo has crashed. - + {}", report_message ); diff --git a/packages/turbo-repository/rust/build.rs b/packages/turbo-repository/rust/build.rs index 624aa845789a7..0f1b01002b079 100644 --- a/packages/turbo-repository/rust/build.rs +++ b/packages/turbo-repository/rust/build.rs @@ -1,5 +1,3 @@ -use napi_build; - fn main() { napi_build::setup(); } diff --git a/turborepo-tests/integration/tests/_helpers/logged_in.sh b/turborepo-tests/integration/tests/_helpers/logged_in.sh index 845cf97bf8abb..2eb33eddaf909 100644 --- a/turborepo-tests/integration/tests/_helpers/logged_in.sh +++ b/turborepo-tests/integration/tests/_helpers/logged_in.sh @@ -1,12 +1,23 @@ #!/usr/bin/env bash +# Previous auth token format read -r -d '' CONFIG <<- EOF { "token": "normal-user-token" } EOF +# New auth token format +read -r -d '' AUTH <<- EOF +{ + "tokens": { + "vercel.com/api": "normal vercel token" + } +} +EOF + TMP_DIR=$(mktemp -d -t turbo-XXXXXXXXXX) +export TURBO_CONFIG_DIR_PATH=$TMP_DIR # duplicate over to XDG var so that turbo picks it up export XDG_CONFIG_HOME=$TMP_DIR @@ -15,8 +26,16 @@ export HOME=$TMP_DIR # For Linux mkdir -p "$TMP_DIR/turborepo" echo $CONFIG > "$TMP_DIR/turborepo/config.json" +echo $AUTH > "$TMP_DIR/turborepo/auth.json" # For macOS MACOS_DIR="$TMP_DIR/Library/Application Support" mkdir -p "$MACOS_DIR/turborepo" echo "$CONFIG" > "$MACOS_DIR/turborepo/config.json" +echo "$AUTH" > "$MACOS_DIR/turborepo/auth.json" + +# XDG_CONFIG_HOME equivalent for Windows is {FOLDERID_RoamingAppData} which is roughly C:\Users\{username}\AppData\Roaming +WINDOWS_DIR="$TMP_DIR/AppData/Roaming" +mkdir -p "$WINDOWS_DIR/turborepo" +echo "$CONFIG" > "$WINDOWS_DIR/turborepo/config.json" +echo "$AUTH" > "$WINDOWS_DIR/turborepo/auth.json" diff --git a/turborepo-tests/integration/tests/command-logout.t b/turborepo-tests/integration/tests/command-logout.t index 919657f9f368b..34b49d40cb6dc 100644 --- a/turborepo-tests/integration/tests/command-logout.t +++ b/turborepo-tests/integration/tests/command-logout.t @@ -8,5 +8,4 @@ Logout while logged in Logout while logged out $ ${TURBO} logout - >>> Logged out - + No tokens to remove