diff --git a/crates/turborepo-auth/src/auth/login.rs b/crates/turborepo-auth/src/auth/login.rs index d223e96f0d43a..789aff85d816e 100644 --- a/crates/turborepo-auth/src/auth/login.rs +++ b/crates/turborepo-auth/src/auth/login.rs @@ -3,11 +3,11 @@ use std::sync::Arc; pub use error::Error; use reqwest::Url; use tokio::sync::OnceCell; -use tracing::{debug, warn}; +use tracing::warn; use turborepo_api_client::{CacheClient, Client, TokenClient}; use turborepo_ui::{BOLD, ColorConfig, start_spinner}; -use crate::{LoginOptions, Token, auth::extract_vercel_token, error, ui}; +use crate::{LoginOptions, Token, error, ui}; const DEFAULT_HOST_NAME: &str = "127.0.0.1"; const DEFAULT_PORT: u16 = 9789; @@ -50,7 +50,6 @@ pub async fn login( // Check if passed in token exists first. if !force { if let Some(token) = existing_token { - debug!("found existing turbo token"); let token = Token::existing(token.into()); if token .is_valid( @@ -61,25 +60,27 @@ pub async fn login( { return Ok(token); } - // If the user is logging into Vercel, check for an existing `vc` token. + // If the user is logging into Vercel, check for an existing `vc` token + // with automatic refresh if expired. } else if login_url_configuration.contains("vercel.com") { - // The extraction can return an error, but we don't want to fail the login if - // the token is not found. - if let Ok(Some(token)) = extract_vercel_token() { - debug!("found existing Vercel token"); - let token = Token::existing(token); - if token - .is_valid( - api_client, - Some(valid_token_callback( - "Existing Vercel token found!", - color_config, - )), - ) - .await? - { - return Ok(token); + match crate::auth::get_token_with_refresh().await { + Ok(Some(token_str)) => { + let token = Token::existing(token_str); + if token + .is_valid( + api_client, + Some(valid_token_callback( + "Existing Vercel token found!", + color_config, + )), + ) + .await? + { + return Ok(token); + } } + Ok(None) => {} + Err(_) => {} } } } diff --git a/crates/turborepo-auth/src/auth/mod.rs b/crates/turborepo-auth/src/auth/mod.rs index 4005d9b0e9847..3ed65bd98a428 100644 --- a/crates/turborepo-auth/src/auth/mod.rs +++ b/crates/turborepo-auth/src/auth/mod.rs @@ -57,20 +57,51 @@ pub struct LogoutOptions { pub path: Option, } -fn extract_vercel_token() -> Result, Error> { - let vercel_config_dir = - turborepo_dirs::vercel_config_dir()?.ok_or_else(|| Error::ConfigDirNotFound)?; +/// Attempts to get a valid token with automatic refresh if expired. +/// Falls back to turborepo/config.json if refresh fails. +pub async fn get_token_with_refresh() -> Result, Error> { + use crate::{TURBO_TOKEN_DIR, TURBO_TOKEN_FILE, Token}; - let vercel_token_path = - vercel_config_dir.join_components(&[VERCEL_TOKEN_DIR, VERCEL_TOKEN_FILE]); - let contents = std::fs::read_to_string(vercel_token_path)?; + let vercel_config_dir = match turborepo_dirs::vercel_config_dir()? { + Some(dir) => dir, + None => return Ok(None), + }; - #[derive(serde::Deserialize)] - struct VercelToken { - // This isn't actually dead code, it's used by serde to deserialize the JSON. - #[allow(dead_code)] - token: Option, - } + let auth_path = vercel_config_dir.join_components(&[VERCEL_TOKEN_DIR, VERCEL_TOKEN_FILE]); + + let auth_tokens = Token::from_auth_file(&auth_path)?; + + if let Some(token) = &auth_tokens.token { + if auth_tokens.is_expired() { + // Try to refresh the token + if auth_tokens.refresh_token.is_some() + && let Ok(new_tokens) = auth_tokens.refresh_token().await + { + let _ = new_tokens.write_to_auth_file(&auth_path); + return Ok(new_tokens.token); + } + + if let Ok(Some(config_dir)) = turborepo_dirs::config_dir() { + let turbo_config_path = + config_dir.join_components(&[TURBO_TOKEN_DIR, TURBO_TOKEN_FILE]); + if let Ok(turbo_token) = Token::from_file(&turbo_config_path) { + return Ok(Some(turbo_token.into_inner().to_string())); + } + } - Ok(serde_json::from_str::(&contents)?.token) + Ok(None) + } else { + Ok(Some(token.clone())) + } + } else { + // No token in auth.json, try turborepo/config.json + if let Ok(Some(config_dir)) = turborepo_dirs::config_dir() { + let turbo_config_path = + config_dir.join_components(&[TURBO_TOKEN_DIR, TURBO_TOKEN_FILE]); + if let Ok(turbo_token) = Token::from_file(&turbo_config_path) { + return Ok(Some(turbo_token.into_inner().to_string())); + } + } + Ok(None) + } } diff --git a/crates/turborepo-auth/src/auth/sso.rs b/crates/turborepo-auth/src/auth/sso.rs index 01d51d3865e72..8978b361379f2 100644 --- a/crates/turborepo-auth/src/auth/sso.rs +++ b/crates/turborepo-auth/src/auth/sso.rs @@ -6,7 +6,7 @@ use tracing::warn; use turborepo_api_client::{CacheClient, Client, TokenClient}; use turborepo_ui::{BOLD, ColorConfig, start_spinner}; -use crate::{Error, LoginOptions, Token, auth::extract_vercel_token, error, ui}; +use crate::{Error, LoginOptions, Token, error, ui}; const DEFAULT_HOST_NAME: &str = "127.0.0.1"; const DEFAULT_PORT: u16 = 9789; @@ -70,23 +70,27 @@ pub async fn sso_login( return Ok(token); } // No existing turbo token found. If the user is logging into Vercel, - // check for an existing `vc` token with correct scope. - } else if login_url_configuration.contains("vercel.com") - && let Ok(Some(token)) = extract_vercel_token() - { - let token = Token::existing(token); - if token - .is_valid_sso( - api_client, - sso_team, - Some(valid_token_callback( - &format!("Existing Vercel token for {sso_team} found!"), - color_config, - )), - ) - .await? - { - return Ok(token); + // check for an existing token with automatic refresh if expired. + } else if login_url_configuration.contains("vercel.com") { + match crate::auth::get_token_with_refresh().await { + Ok(Some(token_str)) => { + let token = Token::existing(token_str); + if token + .is_valid_sso( + api_client, + sso_team, + Some(valid_token_callback( + &format!("Existing Vercel token for {sso_team} found!"), + color_config, + )), + ) + .await? + { + return Ok(token); + } + } + Ok(None) => {} + Err(_) => {} } } } diff --git a/crates/turborepo-auth/src/error.rs b/crates/turborepo-auth/src/error.rs index 9ec40bc0df58a..5e4a75b98681b 100644 --- a/crates/turborepo-auth/src/error.rs +++ b/crates/turborepo-auth/src/error.rs @@ -11,6 +11,8 @@ pub enum Error { SerdeError(#[from] serde_json::Error), #[error(transparent)] APIError(#[from] turborepo_api_client::Error), + #[error(transparent)] + ReqwestError(#[from] reqwest::Error), #[error( "`loginUrl` is configured to \"{value}\", but cannot be a base URL. This happens in \ diff --git a/crates/turborepo-auth/src/lib.rs b/crates/turborepo-auth/src/lib.rs index 09befb45c65c7..bb770f2314fec 100644 --- a/crates/turborepo-auth/src/lib.rs +++ b/crates/turborepo-auth/src/lib.rs @@ -27,6 +27,22 @@ pub const VERCEL_TOKEN_FILE: &str = "auth.json"; pub const TURBO_TOKEN_DIR: &str = "turborepo"; pub const TURBO_TOKEN_FILE: &str = "config.json"; +const VERCEL_OAUTH_CLIENT_ID: &str = "cl_HYyOPBNtFMfHhaUn9L4QPfTZz6TP47bp"; +const VERCEL_OAUTH_TOKEN_URL: &str = "https://vercel.com/api/login/oauth/token"; + +#[derive(Debug, Clone)] +pub struct AuthTokens { + pub token: Option, + pub refresh_token: Option, + pub expires_at: Option, +} + +#[derive(Debug, serde::Deserialize)] +struct OAuthTokenResponse { + access_token: String, + refresh_token: String, +} + /// Token. /// /// It's the result of a successful login or an existing token. This acts as @@ -78,6 +94,38 @@ impl Token { } } + /// Reads token, refresh token, and expiration from auth.json + pub fn from_auth_file(path: &AbsoluteSystemPath) -> Result { + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct AuthWrapper { + token: Option, + refresh_token: Option, + expires_at: Option, + } + + match path.read_existing_to_string()? { + Some(content) => { + let wrapper = serde_json::from_str::(&content).map_err(|err| { + Error::InvalidTokenFileFormat { + path: path.to_string(), + source: err, + } + })?; + Ok(AuthTokens { + token: wrapper.token, + refresh_token: wrapper.refresh_token, + expires_at: wrapper.expires_at, + }) + } + None => Ok(AuthTokens { + token: None, + refresh_token: None, + expires_at: None, + }), + } + } + /// Checks if the token is still valid. The checks ran are: /// 1. If the token is active. /// 2. If the token has access to the cache. @@ -286,6 +334,14 @@ fn current_unix_time() -> u128 { .as_millis() } +fn current_unix_time_secs() -> u64 { + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("Time went backwards") + .as_secs() +} + // As of the time of writing, this should always be true, since a token that // isn't active returns an error when fetching metadata for the token. fn is_token_active(metadata: &ResponseTokenMetadata, current_time: u128) -> bool { @@ -305,6 +361,73 @@ fn is_token_active(metadata: &ResponseTokenMetadata, current_time: u128) -> bool all_scopes_active && (active_at <= current_time) } +impl AuthTokens { + /// Checks if the access token has expired based on expiresAt field + pub fn is_expired(&self) -> bool { + if let Some(expires_at) = self.expires_at { + let current_time = current_unix_time_secs(); + current_time >= expires_at + } else { + false + } + } + + /// Attempts to refresh the access token using the refresh token + pub async fn refresh_token(&self) -> Result { + let refresh_token = self + .refresh_token + .as_ref() + .ok_or_else(|| Error::TokenNotFound)?; + + let client = reqwest::Client::new(); + let params = [ + ("refresh_token", refresh_token.as_str()), + ("grant_type", "refresh_token"), + ("client_id", VERCEL_OAUTH_CLIENT_ID), + ]; + + let response = client + .post(VERCEL_OAUTH_TOKEN_URL) + .form(¶ms) + .send() + .await?; + + let status = response.status(); + + if !status.is_success() { + return Err(Error::FailedToGetToken); + } + + let response_text = response.text().await?; + + let oauth_response: OAuthTokenResponse = serde_json::from_str(&response_text)?; + + Ok(AuthTokens { + token: Some(oauth_response.access_token), + refresh_token: Some(oauth_response.refresh_token), + expires_at: Some(current_unix_time_secs() + 8 * 60 * 60), // 8 hours from now + }) + } + + /// Writes the auth tokens to the auth.json file + pub fn write_to_auth_file(&self, path: &AbsoluteSystemPath) -> Result<(), Error> { + use serde_json::json; + + let content = json!({ + "// Note": "This is your Vercel credentials file. DO NOT SHARE!", + "// Docs": "https://vercel.com/docs/projects/project-configuration/global-configuration#auth.json", + "token": self.token, + "refreshToken": self.refresh_token, + "expiresAt": self.expires_at, + }); + + let json_string = serde_json::to_string_pretty(&content)?; + path.ensure_dir()?; + path.create_with_contents(json_string)?; + Ok(()) + } +} + #[cfg(test)] mod tests { use std::backtrace::Backtrace; diff --git a/crates/turborepo-lib/src/commands/link.rs b/crates/turborepo-lib/src/commands/link.rs index c2e8ed2d8a6ff..7cc8cc1bfb88d 100644 --- a/crates/turborepo-lib/src/commands/link.rs +++ b/crates/turborepo-lib/src/commands/link.rs @@ -153,14 +153,24 @@ 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 - .opts() - .api_client_opts - .token - .as_deref() - .ok_or_else(|| Error::TokenNotFound { - command: base.color_config.apply(BOLD.apply_to("`npx turbo login`")), - })?; + + // Always try to get a valid token with automatic refresh if expired + let token = match turborepo_auth::get_token_with_refresh().await { + Ok(Some(refreshed_token)) => { + // Store the refreshed token temporarily for this command + Box::leak(refreshed_token.into_boxed_str()) + } + Ok(None) | Err(_) => { + // Fall back to the token from config/CLI if refresh logic didn't work + base.opts() + .api_client_opts + .token + .as_deref() + .ok_or_else(|| Error::TokenNotFound { + command: base.color_config.apply(BOLD.apply_to("`npx turbo login`")), + })? + } + }; println!( "\n{}\n\n{}\n\nFor more information, visit: {}\n",