Skip to content

Commit

Permalink
implement vercel auth token for SSO
Browse files Browse the repository at this point in the history
  • Loading branch information
Zertsov committed Feb 7, 2024
1 parent 5c6073c commit 517524b
Show file tree
Hide file tree
Showing 9 changed files with 185 additions and 186 deletions.
54 changes: 21 additions & 33 deletions crates/turborepo-auth/src/auth/login.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,36 +9,28 @@ use turborepo_ui::{start_spinner, BOLD};

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

const DEFAULT_HOST_NAME: &str = "127.0.0.1";
const DEFAULT_PORT: u16 = 9789;

/// 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<T: Client>(options: &LoginOptions<'_, T>) -> Result<Token, Error> {
let (api_client, ui, login_url_configuration, login_server) = (
options.api_client,
options.ui,
options.login_url,
options.login_server,
);
let LoginOptions {
api_client,
ui,
login_url: login_url_configuration,
login_server,
sso_team: _,
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;
Expand All @@ -52,10 +44,7 @@ pub async fn login<T: Client>(options: &LoginOptions<'_, T>) -> Result<Token, Er
"{}",
ui.apply(BOLD.apply_to("Existing Vercel token found!"))
);
return Ok(Token {
token,
exists: true,
});
return Ok(Token::Existing(token));
}
Err(error) => {
// Only send the warning if we're debugging.
Expand Down Expand Up @@ -91,7 +80,9 @@ pub async fn login<T: Client>(options: &LoginOptions<'_, T>) -> Result<Token, Er
login_server
.run(
DEFAULT_PORT,
login_url_configuration.to_string(),
crate::LoginType::Basic {
login_url_configuration: login_url.to_string(),
},
token_cell.clone(),
)
.await?;
Expand All @@ -108,15 +99,12 @@ pub async fn login<T: Client>(options: &LoginOptions<'_, T>) -> Result<Token, Er

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

Ok(Token {
token: token.into(),
exists: false,
})
Ok(Token::New(token.into()))
}

#[cfg(test)]
mod tests {
use std::sync::atomic::AtomicUsize;
use std::{assert_matches::assert_matches, sync::atomic::AtomicUsize};

use async_trait::async_trait;
use reqwest::{Method, RequestBuilder, Response};
Expand All @@ -129,7 +117,7 @@ mod tests {
use turborepo_vercel_api_mock::start_test_server;

use super::*;
use crate::LoginServer;
use crate::{login_server, LoginServer};

struct MockLoginServer {
hits: Arc<AtomicUsize>,
Expand All @@ -140,7 +128,7 @@ mod tests {
async fn run(
&self,
_: u16,
_: String,
_: login_server::LoginType,
login_token: Arc<OnceCell<String>>,
) -> Result<(), Error> {
self.hits.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Expand Down Expand Up @@ -313,16 +301,16 @@ mod tests {
let mut options = LoginOptions::new(&ui, &url, &api_client, &login_server);

let token = login(&options).await.unwrap();
assert!(!token.exists);
assert_matches!(token, Token::New(..));

let got_token = token.token.to_string();
let got_token = token.into_inner().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.
options.existing_token = Some(&got_token);
let second_token = login(&options).await.unwrap();
assert!(second_token.exists);
assert!(matches!(second_token, Token::Existing(..)));

api_server.abort();
assert_eq!(
Expand Down
7 changes: 2 additions & 5 deletions crates/turborepo-auth/src/auth/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ pub use sso::*;
use turborepo_api_client::Client;
use turborepo_ui::{BOLD, UI};

use crate::{ui, LoginServer};
use crate::{ui, LoginServer, Token};

const VERCEL_TOKEN_DIR: &str = "com.vercel.cli";
const VERCEL_TOKEN_FILE: &str = "auth.json";
Expand Down Expand Up @@ -55,10 +55,7 @@ async fn check_token(
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,
})
Ok(Token::Existing(token.to_string()))
}

fn extract_vercel_token() -> Result<String, Error> {
Expand Down
97 changes: 56 additions & 41 deletions crates/turborepo-auth/src/auth/sso.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
use std::{borrow::Cow, sync::Arc};
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, UI};
use turborepo_ui::{start_spinner, BOLD};

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

const DEFAULT_HOST_NAME: &str = "127.0.0.1";
const DEFAULT_PORT: u16 = 9789;
Expand All @@ -21,19 +21,22 @@ fn make_token_name() -> Result<String, Error> {
))
}

/// 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>(
api_client: &impl Client,
ui: &UI,
existing_token: Option<&'a str>,
login_url_configuration: &str,
sso_team: &str,
login_server: &impl server::SSOLoginServer,
) -> Result<Cow<'a, str>, 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> {
let (api_client, ui, login_url_configuration, login_server, sso_team) = (
options.api_client,
options.ui,
options.login_url,
options.login_server,
options.sso_team,
);

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) = existing_token {
if let Some(token) = options.existing_token {
let (result_user, result_teams) =
tokio::join!(api_client.get_user(token), api_client.get_teams(token));

Expand All @@ -45,7 +48,24 @@ pub async fn sso_login<'a>(
{
println!("{}", ui.apply(BOLD.apply_to("Existing token found!")));
ui::print_cli_authorized(&response_user.user.email, ui);
return Ok(token.into());
return Ok(Token::Existing(token.into()));
}
}
}

// No existing 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);
}
}
}
Expand Down Expand Up @@ -76,7 +96,9 @@ pub async fn sso_login<'a>(
}

let token_cell = Arc::new(OnceCell::new());
login_server.run(DEFAULT_PORT, token_cell.clone()).await?;
login_server
.run(DEFAULT_PORT, crate::LoginType::SSO, token_cell.clone())
.await?;
spinner.finish_and_clear();

let token = token_cell.get().ok_or(Error::FailedToGetToken)?;
Expand All @@ -95,7 +117,7 @@ pub async fn sso_login<'a>(

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

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

#[cfg(test)]
Expand All @@ -105,14 +127,15 @@ 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,
};
use turborepo_vercel_api_mock::start_test_server;

use super::*;
use crate::SSOLoginServer;
use crate::{LoginServer, LoginType};
const EXPECTED_VERIFICATION_TOKEN: &str = "expected_verification_token";

lazy_static::lazy_static! {
Expand Down Expand Up @@ -276,14 +299,15 @@ mod tests {
}

#[async_trait]
impl SSOLoginServer for MockSSOLoginServer {
impl LoginServer for MockSSOLoginServer {
async fn run(
&self,
_port: u16,
verification_token: Arc<OnceCell<String>>,
_login_type: LoginType,
login_token: Arc<OnceCell<String>>,
) -> Result<(), Error> {
self.hits.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
verification_token
login_token
.set(EXPECTED_VERIFICATION_TOKEN.to_string())
.unwrap();
Ok(())
Expand All @@ -304,31 +328,22 @@ mod tests {
let login_server = MockSSOLoginServer {
hits: Arc::new(0.into()),
};
let mut options = LoginOptions {
sso_team: Some(team),
..LoginOptions::new(&ui, &url, &api_client, &login_server)
};

let token = sso_login(&api_client, &ui, None, &url, team, &login_server)
.await
.unwrap();

let got_token = Some(token.to_string());
let token = sso_login(&options).await.unwrap();
assert!(!matches!(token, Token::Existing(..)));

assert_eq!(got_token, Some(EXPECTED_VERIFICATION_TOKEN.to_owned()));
let got_token = token.into_inner().to_string();
assert_eq!(got_token, EXPECTED_VERIFICATION_TOKEN.to_owned());

// Call the login function twice to test that we check for existing tokens.
// Total server hits should be 1.
let second_token = sso_login(
&api_client,
&ui,
got_token.as_deref(),
&url,
team,
&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 = sso_login(&options).await.unwrap();
assert!(matches!(second_token, Token::Existing(..)));

handle.abort();

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 @@ -28,4 +28,6 @@ pub enum Error {
FailedToMakeSSOTokenName(#[source] io::Error),
#[error("config directory not found")]
ConfigDirNotFound,
#[error("sso team cannot be empty for login")]
EmptySSOTeam,
}
21 changes: 19 additions & 2 deletions crates/turborepo-auth/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,30 @@
#![feature(cow_is_borrowed)]
#![feature(assert_matches)]
#![deny(clippy::all)]
//! Turborepo's library for authenticating with the Vercel API.
//! Handles logging into Vercel, verifying SSO, and storing the token.

mod auth;
mod error;
mod server;
mod login_server;
mod ui;

pub use auth::*;
pub use error::Error;
pub use server::*;
pub use login_server::*;

/// 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 enum Token {
Existing(String),
New(String),
}
impl Token {
pub fn into_inner(self) -> String {
match self {
Self::Existing(s) => s,
Self::New(s) => s,
}
}
}
Loading

0 comments on commit 517524b

Please sign in to comment.