From f1ea261e5b2fedeb3c0c2f4f7b1cdd08ca2e3e2f Mon Sep 17 00:00:00 2001 From: "Mitch (a.k.a Voz)" Date: Tue, 30 Jan 2024 15:03:54 -0600 Subject: [PATCH] use vc token if logging into Vercel --- .github/workflows/test.yml | 3 +- Cargo.lock | 3 + crates/turborepo-auth/Cargo.toml | 5 +- crates/turborepo-auth/src/auth/login.rs | 102 +++++++++++------- crates/turborepo-auth/src/auth/mod.rs | 74 +++++++++++++ crates/turborepo-auth/src/error.rs | 9 ++ crates/turborepo-lib/src/commands/login.rs | 21 ++-- packages/turbo-exe-stub/build.sh | 2 + .../helpers/mock_existing_login.sh | 35 ++++++ .../integration/tests/command-login.t | 5 + 10 files changed, 212 insertions(+), 47 deletions(-) create mode 100644 turborepo-tests/helpers/mock_existing_login.sh diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fd8a8cb968a30..4d175501c8e12 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -245,6 +245,7 @@ jobs: needs: [determine_jobs, build_turborepo] if: needs.determine_jobs.outputs.turborepo_integration == 'true' runs-on: ${{ matrix.os.runner }} + timeout-minutes: 30 strategy: fail-fast: false matrix: @@ -284,7 +285,7 @@ jobs: key: prysk-venv-${{ matrix.os.runner }} - name: Integration Tests - run: turbo run test --filter=turborepo-tests-integration --color --env-mode=strict --token=${{ secrets.TURBO_TOKEN }} --team=${{ vars.TURBO_TEAM }} + run: turbo run test --filter=turborepo-tests-integration --color --env-mode=strict --token=${{ secrets.TURBO_TOKEN }} --team=${{ vars.TURBO_TEAM }} -v --log-order=stream env: EXPERIMENTAL_RUST_CODEPATH: true diff --git a/Cargo.lock b/Cargo.lock index 773b5bef5edff..9a279182a9c46 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11547,10 +11547,13 @@ dependencies = [ "port_scanner", "reqwest", "serde", + "serde_json", + "tempfile", "thiserror", "tokio", "tracing", "turborepo-api-client", + "turborepo-dirs", "turborepo-ui", "turborepo-vercel-api", "turborepo-vercel-api-mock", diff --git a/crates/turborepo-auth/Cargo.toml b/crates/turborepo-auth/Cargo.toml index 4c5caf62c798a..7f468bea47aee 100644 --- a/crates/turborepo-auth/Cargo.toml +++ b/crates/turborepo-auth/Cargo.toml @@ -16,11 +16,14 @@ chrono.workspace = true hostname = "0.3.1" lazy_static.workspace = true reqwest.workspace = true -serde.workspace = true +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +tempfile.workspace = true thiserror = "1.0.38" tokio.workspace = true tracing.workspace = true turborepo-api-client = { workspace = true } +turborepo-dirs = { version = "0.1.0", path = "../turborepo-dirs" } turborepo-ui.workspace = true turborepo-vercel-api = { workspace = true } turborepo-vercel-api-mock = { workspace = true } diff --git a/crates/turborepo-auth/src/auth/login.rs b/crates/turborepo-auth/src/auth/login.rs index 8bc08a14e6979..390a909b40e88 100644 --- a/crates/turborepo-auth/src/auth/login.rs +++ b/crates/turborepo-auth/src/auth/login.rs @@ -1,32 +1,66 @@ -use std::{borrow::Cow, sync::Arc}; +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, BOLD, UI}; +use turborepo_ui::{start_spinner, BOLD}; -use crate::{error, server::LoginServer, ui}; +use crate::{ + auth::{check_token, extract_vercel_token}, + error, ui, LoginOptions, +}; 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()); +/// Token is the result of a successful login. It contains the token string and +/// a boolean indicating whether the token already existed on the filesystem. +#[derive(Debug)] +pub struct Token { + /// The actual token string. + pub token: String, + /// If this is `true`, it means this token already exists on the filesystem. + /// If `false`, this is a new token. + pub exists: bool, +} + +/// Login returns a `Token` struct. If a token is already present, +/// we do not overwrite it and instead log that we found an existing token, +/// setting the `exists` field to `true`. +/// +/// 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 { + let (api_client, ui, login_url_configuration, login_server) = ( + options.api_client, + options.ui, + options.login_url, + options.login_server, + ); + // Check if passed in token exists first. + if let Some(token) = options.existing_token { + return check_token(token, ui, api_client, "Existing token found!").await; + } + + // If the user is logging into Vercel, check for an existing `vc` token. + if login_url_configuration.contains("vercel.com") { + match extract_vercel_token() { + Ok(token) => { + println!( + "{}", + ui.apply(BOLD.apply_to("Existing Vercel token found!")) + ); + return Ok(Token { + token, + exists: true, + }); + } + Err(error) => { + // Only send the warning if we're debugging. + dbg!("Failed to extract Vercel token: ", error); + } } } @@ -74,7 +108,10 @@ pub async fn login<'a>( ui::print_cli_authorized(&user_response.user.email, ui); - Ok(token.to_string().into()) + Ok(Token { + token: token.into(), + exists: false, + }) } #[cfg(test)] @@ -84,6 +121,7 @@ mod tests { use async_trait::async_trait; use reqwest::{Method, RequestBuilder, Response}; use turborepo_api_client::Client; + use turborepo_ui::UI; use turborepo_vercel_api::{ CachingStatusResponse, Membership, Role, SpacesResponse, Team, TeamsResponse, User, UserResponse, VerifiedSsoUser, @@ -91,6 +129,7 @@ mod tests { use turborepo_vercel_api_mock::start_test_server; use super::*; + use crate::LoginServer; struct MockLoginServer { hits: Arc, @@ -271,28 +310,19 @@ mod tests { let login_server = MockLoginServer { hits: Arc::new(0.into()), }; + let mut options = LoginOptions::new(&ui, &url, &api_client, &login_server); - let token = login(&api_client, &ui, None, &url, &login_server) - .await - .unwrap(); + let token = login(&options).await.unwrap(); + assert!(!token.exists); - 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) - ); + let got_token = token.token.to_string(); + assert_eq!(&got_token, 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()); + options.existing_token = Some(&got_token); + let second_token = login(&options).await.unwrap(); + assert!(second_token.exists); api_server.abort(); assert_eq!( diff --git a/crates/turborepo-auth/src/auth/mod.rs b/crates/turborepo-auth/src/auth/mod.rs index 66f5c583b305e..24d8d440ca8cf 100644 --- a/crates/turborepo-auth/src/auth/mod.rs +++ b/crates/turborepo-auth/src/auth/mod.rs @@ -5,3 +5,77 @@ mod sso; pub use login::*; pub use logout::*; pub use sso::*; +use turborepo_api_client::Client; +use turborepo_ui::{BOLD, UI}; + +use crate::{ui, LoginServer}; + +const VERCEL_TOKEN_DIR: &str = "com.vercel.cli"; +const VERCEL_TOKEN_FILE: &str = "auth.json"; + +pub struct LoginOptions<'a, T> +where + T: Client, +{ + pub ui: &'a UI, + pub login_url: &'a str, + pub api_client: &'a T, + pub login_server: &'a dyn LoginServer, + + pub sso_team: Option<&'a str>, + pub existing_token: Option<&'a str>, +} +impl<'a, T> LoginOptions<'a, T> +where + T: Client, +{ + pub fn new( + ui: &'a UI, + login_url: &'a str, + api_client: &'a T, + login_server: &'a dyn LoginServer, + ) -> Self { + Self { + ui, + login_url, + api_client, + login_server, + sso_team: None, + existing_token: None, + } + } +} + +async fn check_token( + token: &str, + ui: &UI, + api_client: &impl Client, + message: &str, +) -> Result { + let response = api_client.get_user(token).await?; + println!("{}", ui.apply(BOLD.apply_to(message))); + ui::print_cli_authorized(&response.user.email, ui); + Ok(Token { + token: token.to_string(), + exists: true, + }) +} + +fn extract_vercel_token() -> Result { + let vercel_config_dir = + turborepo_dirs::vercel_config_dir().ok_or_else(|| Error::ConfigDirNotFound)?; + + let vercel_token_path = vercel_config_dir + .join(VERCEL_TOKEN_DIR) + .join(VERCEL_TOKEN_FILE); + let contents = std::fs::read_to_string(vercel_token_path)?; + + #[derive(serde::Deserialize)] + struct VercelToken { + // This isn't actually dead code, it's used by serde to deserialize the JSON. + #[allow(dead_code)] + token: String, + } + + Ok(serde_json::from_str::(&contents)?.token) +} diff --git a/crates/turborepo-auth/src/error.rs b/crates/turborepo-auth/src/error.rs index c18d4908025c8..6196a8d2536bc 100644 --- a/crates/turborepo-auth/src/error.rs +++ b/crates/turborepo-auth/src/error.rs @@ -4,6 +4,13 @@ use thiserror::Error; #[derive(Debug, Error)] pub enum Error { + #[error(transparent)] + Io(#[from] io::Error), + #[error(transparent)] + SerdeError(#[from] serde_json::Error), + #[error(transparent)] + APIError(#[from] 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." @@ -19,4 +26,6 @@ pub enum Error { FailedToValidateSSOToken(#[source] turborepo_api_client::Error), #[error("failed to make sso token name")] FailedToMakeSSOTokenName(#[source] io::Error), + #[error("config directory not found")] + ConfigDirNotFound, } diff --git a/crates/turborepo-lib/src/commands/login.rs b/crates/turborepo-lib/src/commands/login.rs index a54746042dc54..608c44d6e8dd8 100644 --- a/crates/turborepo-lib/src/commands/login.rs +++ b/crates/turborepo-lib/src/commands/login.rs @@ -1,6 +1,7 @@ use turborepo_api_client::APIClient; use turborepo_auth::{ login as auth_login, sso_login as auth_sso_login, DefaultLoginServer, DefaultSSOLoginServer, + LoginOptions, }; use turborepo_telemetry::events::command::{CommandEventBuilder, LoginMethod}; @@ -58,15 +59,17 @@ pub async fn login(base: &mut CommandBase, telemetry: CommandEventBuilder) -> Re let api_client: APIClient = base.api_client()?; let ui = base.ui; let login_url_config = base.config()?.login_url().to_string(); + let options = LoginOptions { + existing_token: base.config()?.token(), + ..LoginOptions::new(&ui, &login_url_config, &api_client, &DefaultLoginServer) + }; - let token = auth_login( - &api_client, - &ui, - base.config()?.token(), - &login_url_config, - &DefaultLoginServer, - ) - .await?; + let token = auth_login(&options).await?; + + // Don't write to disk if the token is already there + if token.exists { + return Ok(()); + } let global_config_path = base.global_config_path()?; let before = global_config_path @@ -75,7 +78,7 @@ pub async fn login(base: &mut CommandBase, telemetry: CommandEventBuilder) -> Re config_path: global_config_path.clone(), error: e, })?; - let after = set_path(&before, &["token"], &format!("\"{}\"", token))?; + let after = set_path(&before, &["token"], &format!("\"{}\"", token.token))?; global_config_path .ensure_dir() diff --git a/packages/turbo-exe-stub/build.sh b/packages/turbo-exe-stub/build.sh index 988c12097fd34..8cd7a7441cf20 100644 --- a/packages/turbo-exe-stub/build.sh +++ b/packages/turbo-exe-stub/build.sh @@ -40,3 +40,5 @@ cp turbo.exe "${FIND_TURBO_FIXTURES_DIR}/unplugged_env_moved/.moved/unplugged/tu cp turbo.exe "${FIND_TURBO_FIXTURES_DIR}/unplugged_moved/.moved/unplugged/turbo-windows-64-npm-1.0.0-520925a700/node_modules/turbo-windows-64/bin/" cp turbo.exe "${FIND_TURBO_FIXTURES_DIR}/unplugged_moved/.moved/unplugged/turbo-windows-arm64-npm-1.0.0-520925a700/node_modules/turbo-windows-arm64/bin/" + +echo "Done copying files" diff --git a/turborepo-tests/helpers/mock_existing_login.sh b/turborepo-tests/helpers/mock_existing_login.sh new file mode 100644 index 0000000000000..097c002e5f1f5 --- /dev/null +++ b/turborepo-tests/helpers/mock_existing_login.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash + +# Create a mocked Vercel auth file +read -r -d '' AUTH <<- EOF +{ + "// Note": "This is your Vercel credentials file. DO NOT SHARE!", + "// Docs": "https://vercel.com/docs/project-configuration#global-configuration/auth-json", + "token": "mock-token" +} +EOF + +TMP_DIR=$(mktemp -d -t turbo-XXXXXXXXXX) + +# duplicate over to XDG var so that turbo picks it up +export XDG_CONFIG_HOME=$TMP_DIR +export HOME=$TMP_DIR +export TURBO_CONFIG_DIR_PATH=$TMP_DIR +export TURBO_TELEMETRY_MESSAGE_DISABLED=1 + +# For Linux +mkdir -p "$TMP_DIR/com.vercel.cli" +export VERCEL_CONFIG_DIR_PATH="$TMP_DIR" +echo $AUTH > "$TMP_DIR/com.vercel.cli/auth.json" + +# For macOS +MACOS_DIR="$TMP_DIR/Library/Application Support" +mkdir -p "$MACOS_DIR/com.vercel.cli" +export VERCEL_CONFIG_DIR_PATH="$MACOS_DIR" +echo "$AUTH" > "$MACOS_DIR/com.vercel.cli/auth.json" + +# For Windows +WINDOWS_DIR="$TMP_DIR\\AppData\\Roaming" +mkdir -p "$WINDOWS_DIR\\com.vercel.cli" +export VERCEL_CONFIG_DIR_PATH="$WINDOWS_DIR" +echo "$AUTH" > "$WINDOWS_DIR\\com.vercel.cli\\auth.json" diff --git a/turborepo-tests/integration/tests/command-login.t b/turborepo-tests/integration/tests/command-login.t index 1ddfcf0eb794c..309c8427dcf63 100644 --- a/turborepo-tests/integration/tests/command-login.t +++ b/turborepo-tests/integration/tests/command-login.t @@ -4,3 +4,8 @@ Setup Login Test Run $ ${TURBO} login --__test-run Login test run successful + +Login reuses Vercel CLI token + $ . ${TESTDIR}/../../helpers/mock_existing_login.sh + $ ${TURBO} login + Existing Vercel token found!