Skip to content

Commit

Permalink
fix: do better validation for existing tokens
Browse files Browse the repository at this point in the history
  • Loading branch information
Zertsov committed Feb 13, 2024
1 parent 4a7d9e9 commit 827a123
Show file tree
Hide file tree
Showing 8 changed files with 310 additions and 68 deletions.
32 changes: 30 additions & 2 deletions crates/turborepo-api-client/src/lib.rs
Expand Up @@ -12,8 +12,9 @@ use reqwest::{Method, RequestBuilder, StatusCode};
use serde::Deserialize;
use turborepo_ci::{is_ci, Vendor};
use turborepo_vercel_api::{
APIError, CachingStatus, CachingStatusResponse, PreflightResponse, SpacesResponse, Team,
TeamsResponse, UserResponse, VerificationResponse, VerifiedSsoUser,
token::ResponseTokenMetadata, APIError, CachingStatus, CachingStatusResponse,
PreflightResponse, SpacesResponse, Team, TeamsResponse, UserResponse, VerificationResponse,
VerifiedSsoUser,
};
use url::Url;

Expand Down Expand Up @@ -81,6 +82,11 @@ pub trait Client {
fn make_url(&self, endpoint: &str) -> Result<Url>;
}

#[async_trait]
pub trait TokenClient {
async fn get_metadata(&self, token: &str) -> Result<ResponseTokenMetadata>;
}

#[derive(Clone)]
pub struct APIClient {
client: reqwest::Client,
Expand Down Expand Up @@ -400,6 +406,28 @@ impl Client for APIClient {
}
}

#[async_trait]
impl TokenClient for APIClient {
async fn get_metadata(&self, token: &str) -> Result<ResponseTokenMetadata> {
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?;

#[derive(Deserialize, Debug)]
struct Response {
#[serde(rename = "token")]
metadata: ResponseTokenMetadata,
}
let body = response.json::<Response>().await?;
Ok(body.metadata)
}
}

impl APIClient {
pub fn new(
base_url: impl AsRef<str>,
Expand Down
52 changes: 43 additions & 9 deletions crates/turborepo-auth/src/auth/login.rs
Expand Up @@ -4,11 +4,11 @@ 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};
use turborepo_api_client::{Client, TokenClient};
use turborepo_ui::start_spinner;

use crate::{
auth::{check_token, extract_vercel_token},
auth::{check_user_token, extract_vercel_token},
error, ui, LoginOptions, Token,
};

Expand All @@ -21,27 +21,32 @@ const DEFAULT_PORT: u16 = 9789;
///
/// First checks if an existing option has been passed in, then if the login is
/// to Vercel, checks if the user has a Vercel CLI token on disk.
pub async fn login<T: Client>(options: &LoginOptions<'_, T>) -> Result<Token, Error> {
pub async fn login<T: Client + TokenClient>(options: &LoginOptions<'_, T>) -> Result<Token, Error> {
let LoginOptions {
api_client,
ui,
login_url: login_url_configuration,
login_server,
sso_team: _,
existing_token: _,
existing_token,
} = *options; // Deref or we get double references for each of these

// 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 let Some(token) = existing_token {
if Token::existing(token.to_string())
.is_valid(api_client)
.await?
{
return check_user_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") {
// The extraction can return an error, but we don't want to fail the login if
// the token is not found.
if let Ok(token) = extract_vercel_token() {
return check_token(&token, ui, api_client, "Existing Vercel token found!").await;
return check_user_token(&token, ui, api_client, "Existing Vercel token found!").await;
}
}

Expand Down Expand Up @@ -91,7 +96,7 @@ pub async fn login<T: Client>(options: &LoginOptions<'_, T>) -> Result<Token, Er

ui::print_cli_authorized(&user_response.user.email, ui);

Ok(Token::New(token.into()))
Ok(Token::new(token.into()))
}

#[cfg(test)]
Expand Down Expand Up @@ -278,6 +283,35 @@ mod tests {
}
}

#[async_trait]
impl TokenClient for MockApiClient {
async fn get_metadata(
&self,
token: &str,
) -> turborepo_api_client::Result<turborepo_vercel_api::token::ResponseTokenMetadata>
{
if token.is_empty() {
return Err(MockApiError::EmptyToken.into());
}

Ok(turborepo_vercel_api::token::ResponseTokenMetadata {
id: "id".to_string(),
name: "name".to_string(),
token_type: "token".to_string(),
origin: "github".to_string(),
scopes: vec![turborepo_vercel_api::token::Scope {
scope_type: "user".to_string(),
origin: "github".to_string(),
team_id: None,
expires_at: None,
created_at: 1111111111111,
}],
active_at: 0,
created_at: 123456,
})
}
}

#[tokio::test]
async fn test_login() {
let port = port_scanner::request_open_port().unwrap();
Expand Down
48 changes: 41 additions & 7 deletions crates/turborepo-auth/src/auth/mod.rs
Expand Up @@ -5,7 +5,7 @@ mod sso;
pub use login::*;
pub use logout::*;
pub use sso::*;
use turborepo_api_client::Client;
use turborepo_api_client::{Client, TokenClient};
use turborepo_ui::{BOLD, UI};

use crate::{ui, LoginServer, Token};
Expand All @@ -15,7 +15,7 @@ const VERCEL_TOKEN_FILE: &str = "auth.json";

pub struct LoginOptions<'a, T>
where
T: Client,
T: Client + TokenClient,
{
pub ui: &'a UI,
pub login_url: &'a str,
Expand All @@ -27,7 +27,7 @@ where
}
impl<'a, T> LoginOptions<'a, T>
where
T: Client,
T: Client + TokenClient,
{
pub fn new(
ui: &'a UI,
Expand All @@ -46,18 +46,52 @@ where
}
}

async fn check_token(
async fn check_user_token(
token: &str,
ui: &UI,
api_client: &impl Client,
api_client: &(impl Client + TokenClient),
message: &str,
) -> Result<Token, Error> {
let response = api_client.get_user(token).await?;
let response_user = api_client.get_user(token).await?;
println!("{}", ui.apply(BOLD.apply_to(message)));
ui::print_cli_authorized(&response.user.email, ui);
ui::print_cli_authorized(&response_user.user.email, ui);
Ok(Token::Existing(token.to_string()))
}

async fn check_sso_token(
token: &str,
sso_team: &str,
ui: &UI,
api_client: &(impl Client + TokenClient),
message: &str,
) -> Result<Token, Error> {
let (result_user, result_teams) =
tokio::join!(api_client.get_user(token), api_client.get_teams(token),);

let token = Token::existing(token.into());

match (result_user, result_teams) {
(Ok(response_user), Ok(response_teams)) => {
if response_teams
.teams
.iter()
.any(|team| team.slug == sso_team)
{
if token.is_valid(api_client).await? {
println!("{}", ui.apply(BOLD.apply_to(message)));
ui::print_cli_authorized(&response_user.user.email, ui);
Ok(token)
} else {
Err(Error::SSOTokenExpired(sso_team.to_string()))
}
} else {
Err(Error::SSOTeamNotFound(sso_team.to_string()))
}
}
(Err(e), _) | (_, Err(e)) => Err(Error::APIError(e)),
}
}

fn extract_vercel_token() -> Result<String, Error> {
let vercel_config_dir =
turborepo_dirs::vercel_config_dir().ok_or_else(|| Error::ConfigDirNotFound)?;
Expand Down
86 changes: 54 additions & 32 deletions crates/turborepo-auth/src/auth/sso.rs
Expand Up @@ -3,10 +3,13 @@ use std::sync::Arc;
use reqwest::Url;
use tokio::sync::OnceCell;
use tracing::warn;
use turborepo_api_client::Client;
use turborepo_ui::{start_spinner, BOLD};
use turborepo_api_client::{Client, TokenClient};
use turborepo_ui::start_spinner;

use crate::{auth::extract_vercel_token, error, ui, Error, LoginOptions, Token};
use crate::{
auth::{check_sso_token, extract_vercel_token},
error, ui, Error, LoginOptions, Token,
};

const DEFAULT_HOST_NAME: &str = "127.0.0.1";
const DEFAULT_PORT: u16 = 9789;
Expand All @@ -24,50 +27,42 @@ fn make_token_name() -> Result<String, Error> {
/// Perform an SSO login flow. If an existing token is present, and the token
/// has access to the provided `sso_team`, we do not overwrite it and instead
/// log that we found an existing token.
pub async fn sso_login<'a, T: Client>(options: &LoginOptions<'_, T>) -> Result<Token, Error> {
pub async fn sso_login<'a, T: Client + TokenClient>(
options: &LoginOptions<'_, T>,
) -> Result<Token, Error> {
let LoginOptions {
api_client,
ui,
login_url: login_url_configuration,
login_server,
sso_team,
existing_token: _,
existing_token,
} = *options;

let sso_team = sso_team.ok_or(Error::EmptySSOTeam)?;
// Check if token exists first. Must be there for the user and contain the
// sso_team passed into this function.
if let Some(token) = options.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::Existing(token.into()));
}
if let Some(token) = existing_token {
if Token::existing(token.to_string())
.is_valid(api_client)
.await?
{
return check_sso_token(token, sso_team, ui, api_client, "Existing token found!").await;
}
}

// No existing token found. If the user is logging into Vercel, check for an
// existing `vc` token with correct scope.
// No existing turbo token found. If the user is logging into Vercel, check for
// an existing `vc` token with correct scope.
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::Existing(token));
}
Err(e) => {
dbg!("Failed to extract Vercel token: ", e);
}
if let Ok(token) = extract_vercel_token() {
return check_sso_token(
&token,
sso_team,
ui,
api_client,
"Existing Vercel token found!",
)
.await;
}
}

Expand Down Expand Up @@ -294,6 +289,33 @@ mod tests {
}
}

#[async_trait]
impl TokenClient for MockApiClient {
async fn get_metadata(
&self,
token: &str,
) -> turborepo_api_client::Result<turborepo_vercel_api::token::ResponseTokenMetadata>
{
if token.is_empty() {
return Err(MockApiError::EmptyToken.into());
}
Ok(turborepo_vercel_api::token::ResponseTokenMetadata {
id: "id".to_string(),
name: "name".to_string(),
token_type: "token".to_string(),
origin: "github".to_string(),
scopes: vec![turborepo_vercel_api::token::Scope {
scope_type: "team".to_string(),
origin: "saml".to_string(),
team_id: Some("team_vozisthebest".to_string()),
created_at: 1111111111111,
expires_at: Some(9999999990000),
}],
active_at: 0,
created_at: 123456,
})
}
}
#[derive(Clone)]
struct MockSSOLoginServer {
hits: Arc<AtomicUsize>,
Expand Down
4 changes: 4 additions & 0 deletions crates/turborepo-auth/src/error.rs
Expand Up @@ -30,4 +30,8 @@ pub enum Error {
ConfigDirNotFound,
#[error("sso team cannot be empty for login")]
EmptySSOTeam,
#[error("sso team not found: {0}")]
SSOTeamNotFound(String),
#[error("sso token expired for team: {0}")]
SSOTokenExpired(String),
}

0 comments on commit 827a123

Please sign in to comment.