Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: do better validation for existing tokens #7358

Merged
merged 1 commit into from
Feb 14, 2024
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
32 changes: 30 additions & 2 deletions crates/turborepo-api-client/src/lib.rs
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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),
}
Loading
Loading