这是indexloc提供的服务,不要输入任何密码
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 21 additions & 20 deletions crates/turborepo-auth/src/auth/login.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -50,7 +50,6 @@ pub async fn login<T: Client + TokenClient + CacheClient>(
// 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(
Expand All @@ -61,25 +60,27 @@ pub async fn login<T: Client + TokenClient + CacheClient>(
{
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(_) => {}
}
}
}
Expand Down
57 changes: 44 additions & 13 deletions crates/turborepo-auth/src/auth/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,20 +57,51 @@ pub struct LogoutOptions<T> {
pub path: Option<AbsoluteSystemPathBuf>,
}

fn extract_vercel_token() -> Result<Option<String>, 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<Option<String>, 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<String>,
}
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::<VercelToken>(&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)
}
}
40 changes: 22 additions & 18 deletions crates/turborepo-auth/src/auth/sso.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -70,23 +70,27 @@ pub async fn sso_login<T: Client + TokenClient + CacheClient>(
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(_) => {}
}
}
}
Expand Down
2 changes: 2 additions & 0 deletions crates/turborepo-auth/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down
123 changes: 123 additions & 0 deletions crates/turborepo-auth/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
pub refresh_token: Option<String>,
pub expires_at: Option<u64>,
}

#[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
Expand Down Expand Up @@ -78,6 +94,38 @@ impl Token {
}
}

/// Reads token, refresh token, and expiration from auth.json
pub fn from_auth_file(path: &AbsoluteSystemPath) -> Result<AuthTokens, Error> {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct AuthWrapper {
token: Option<String>,
refresh_token: Option<String>,
expires_at: Option<u64>,
}

match path.read_existing_to_string()? {
Some(content) => {
let wrapper = serde_json::from_str::<AuthWrapper>(&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.
Expand Down Expand Up @@ -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 {
Expand All @@ -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<AuthTokens, Error> {
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(&params)
.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;
Expand Down
26 changes: 18 additions & 8 deletions crates/turborepo-lib/src/commands/link.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading