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

feat(turborepo-auth): create new crate to handle authentication to backend #6027

Merged
merged 23 commits into from
Sep 27, 2023
Merged
Show file tree
Hide file tree
Changes from 8 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
6 changes: 5 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,9 @@
},
"search.exclude": {
"crates/turbopack-tests/tests/snapshot/**": true
}
},
"rust-analyzer.linkedProjects": [
"./crates/turborepo-auth-manual/Cargo.toml"
],
"cSpell.enabled": false
}
20 changes: 20 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

20 changes: 20 additions & 0 deletions crates/turborepo-auth/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
[package]
name = "turborepo-auth"
version = "0.1.0"
edition = "2021"


[dependencies]
anyhow.workspace = true
axum-server = { workspace = true }
axum.workspace = true
config = "0.13"
hostname = "0.3.1"
reqwest.workspace = true
serde.workspace = true
thiserror = "1.0.38"
tokio.workspace = true
tracing.workspace = true
turborepo-api-client = { workspace = true }
turborepo-ui.workspace = true
webbrowser = { workspace = true }
142 changes: 142 additions & 0 deletions crates/turborepo-auth/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
#[cfg(not(test))]
use std::net::SocketAddr;
use std::sync::Arc;

use anyhow::{anyhow, Result};
#[cfg(not(test))]
use axum::{extract::Query, response::Redirect, routing::get, Router};
use reqwest::Url;
use serde::Deserialize;
use tokio::sync::OnceCell;
#[cfg(not(test))]
use tracing::warn;
use turborepo_api_client::APIClient;
use turborepo_ui::{start_spinner, BOLD, CYAN, UI};

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

use thiserror::Error;
#[derive(Debug, Error)]
pub enum Error {
#[error(
"loginUrl is configured to \"{value}\", but cannot be a base URL. This happens in \
situations like using a `data:` URL."
)]
LoginUrlCannotBeABase { value: String },
}

// TODO: make this configurable
const LOGIN_URL: &str = "https://vercel.com/api";

pub async fn login<F>(api_client: APIClient, ui: &UI, mut set_token: F) -> Result<()>
where
F: FnMut(&str) -> Result<()>,
{
let redirect_url = format!("http://{DEFAULT_HOST_NAME}:{DEFAULT_PORT}");
let mut login_url = Url::parse(LOGIN_URL)?;

login_url
.path_segments_mut()
.map_err(|_: ()| Error::LoginUrlCannotBeABase {
value: LOGIN_URL.to_string(),
})?
.extend(["turborepo", "token"]);

login_url
.query_pairs_mut()
.append_pair("redirect_uri", &redirect_url);

println!(">>> Opening browser to {login_url}");
let spinner = start_spinner("Waiting for your authorization...");
direct_user_to_url(login_url.as_str());

let token_cell = Arc::new(OnceCell::new());
run_login_one_shot_server(DEFAULT_PORT, LOGIN_URL.to_string(), token_cell.clone()).await?;

spinner.finish_and_clear();

let token = token_cell
.get()
.ok_or_else(|| anyhow!("Failed to get token"))?;

// This function is passed in from turborepo-lib
// TODO: inline this here and only pass in the location to write the token as an
// optional arg.
let _ = set_token(token);

// TODO: make this a request to /teams endpoint instead?
let user_response = api_client.get_user(token.as_str()).await?;

println!(
"
{} Turborepo CLI authorized for {}

{}

{}

",
ui.rainbow(">>> Success!"),
user_response.user.email,
ui.apply(
CYAN.apply_to("To connect to your Remote Cache, run the following in any turborepo:")
),
ui.apply(BOLD.apply_to(" npx turbo link"))
);
Ok(())
}

// TODO: Duplicated
#[cfg(test)]
fn direct_user_to_url(_: &str) {}
#[cfg(not(test))]
fn direct_user_to_url(url: &str) {
if webbrowser::open(url).is_err() {
warn!("Failed to open browser. Please visit {url} in your browser.");
}
}

#[derive(Debug, Clone, Deserialize)]
struct LoginPayload {
#[cfg(not(test))]
token: String,
}

#[cfg(test)]
async fn run_login_one_shot_server(
_: u16,
_: String,
login_token: Arc<OnceCell<String>>,
) -> Result<()> {
login_token
.set(turborepo_vercel_api_mock::EXPECTED_TOKEN.to_string())
.unwrap();
Ok(())
}

#[cfg(not(test))]
async fn run_login_one_shot_server(
port: u16,
login_url_base: String,
login_token: Arc<OnceCell<String>>,
) -> Result<()> {
let handle = axum_server::Handle::new();
let route_handle = handle.clone();
let app = Router::new()
// `GET /` goes to `root`
.route(
"/",
get(|login_payload: Query<LoginPayload>| async move {
let _ = login_token.set(login_payload.0.token);
route_handle.shutdown();
Redirect::to(&format!("{login_url_base}/turborepo/success"))
}),
);
let addr = SocketAddr::from(([127, 0, 0, 1], port));

Ok(axum_server::bind(addr)
.handle(handle)
.serve(app.into_make_service())
.await?)
}
1 change: 1 addition & 0 deletions crates/turborepo-lib/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ tokio-util = { version = "0.7.7", features = ["compat"] }
tonic = { version = "0.8.3", features = ["transport"] }
tonic-reflection = { version = "0.6.0", optional = true }
tower = "0.4.13"
turborepo-auth = { path = "../turborepo-auth" }
turborepo-fs = { path = "../turborepo-fs" }
turborepo-vercel-api = { path = "../turborepo-vercel-api" }
uds_windows = "1.0.2"
Expand Down
112 changes: 11 additions & 101 deletions crates/turborepo-lib/src/commands/login.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,17 @@ use serde::Deserialize;
use tokio::sync::OnceCell;
#[cfg(not(test))]
use tracing::warn;
use turborepo_ui::{start_spinner, BOLD, CYAN};
use turborepo_api_client::APIClient;
use turborepo_ui::{start_spinner, BOLD, CYAN, UI};

use crate::{commands::CommandBase, config::Error};

const DEFAULT_HOST_NAME: &str = "127.0.0.1";
const DEFAULT_PORT: u16 = 9789;
const DEFAULT_SSO_PROVIDER: &str = "SAML/OIDC Single Sign-On";

use turborepo_auth::login as auth_login;

pub async fn sso_login(base: &mut CommandBase, sso_team: &str) -> Result<()> {
let repo_config = base.repo_config()?;
let redirect_url = format!("http://{DEFAULT_HOST_NAME}:{DEFAULT_PORT}");
Expand Down Expand Up @@ -92,64 +95,15 @@ fn make_token_name() -> Result<String> {
}

pub async fn login(base: &mut CommandBase) -> Result<()> {
let repo_config = base.repo_config()?;
let redirect_url = format!("http://{DEFAULT_HOST_NAME}:{DEFAULT_PORT}");
let login_url_configuration = repo_config.login_url();
let mut login_url = Url::parse(login_url_configuration)?;

login_url
.path_segments_mut()
.map_err(|_: ()| Error::LoginUrlCannotBeABase {
value: login_url_configuration.to_string(),
})?
.extend(["turborepo", "token"]);

login_url
.query_pairs_mut()
.append_pair("redirect_uri", &redirect_url);

println!(">>> Opening browser to {login_url}");
let spinner = start_spinner("Waiting for your authorization...");
direct_user_to_url(login_url.as_str());

let token_cell = Arc::new(OnceCell::new());
run_login_one_shot_server(
DEFAULT_PORT,
repo_config.login_url().to_string(),
token_cell.clone(),
)
.await?;

spinner.finish_and_clear();

let token = token_cell
.get()
.ok_or_else(|| anyhow!("Failed to get token"))?;

base.user_config_mut()?.set_token(Some(token.to_string()))?;
let api_client: APIClient = base.api_client()?;
let ui: &UI = &base.ui;

let client = base.api_client()?;
let user_response = client.get_user(token.as_str()).await?;

let ui = &base.ui;

println!(
"
{} Turborepo CLI authorized for {}

{}

{}
let set_token = |token: &str| -> Result<(), _> {
base.user_config_mut()?.set_token(Some(token.to_string()));
Ok(())
};

",
ui.rainbow(">>> Success!"),
user_response.user.email,
ui.apply(
CYAN.apply_to("To connect to your Remote Cache, run the following in any turborepo:")
),
ui.apply(BOLD.apply_to(" npx turbo link"))
);
Ok(())
return auth_login(api_client, ui, set_token).await;
}

#[cfg(test)]
Expand All @@ -161,53 +115,9 @@ fn direct_user_to_url(url: &str) {
}
}

#[derive(Debug, Clone, Deserialize)]
struct LoginPayload {
#[cfg(not(test))]
token: String,
}

#[cfg(test)]
const EXPECTED_VERIFICATION_TOKEN: &str = "expected_verification_token";

#[cfg(test)]
async fn run_login_one_shot_server(
_: u16,
_: String,
login_token: Arc<OnceCell<String>>,
) -> Result<()> {
login_token
.set(turborepo_vercel_api_mock::EXPECTED_TOKEN.to_string())
.unwrap();
Ok(())
}

#[cfg(not(test))]
async fn run_login_one_shot_server(
port: u16,
login_url_base: String,
login_token: Arc<OnceCell<String>>,
) -> Result<()> {
let handle = axum_server::Handle::new();
let route_handle = handle.clone();
let app = Router::new()
// `GET /` goes to `root`
.route(
"/",
get(|login_payload: Query<LoginPayload>| async move {
let _ = login_token.set(login_payload.0.token);
route_handle.shutdown();
Redirect::to(&format!("{login_url_base}/turborepo/success"))
}),
);
let addr = SocketAddr::from(([127, 0, 0, 1], port));

Ok(axum_server::bind(addr)
.handle(handle)
.serve(app.into_make_service())
.await?)
}

#[derive(Debug, Default, Clone, Deserialize)]
#[allow(dead_code)]
struct SsoPayload {
Expand Down
1 change: 1 addition & 0 deletions crates/turborepo-lib/src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ pub enum Error {
one"
)]
NoTurboJSON,
// TODO: remove LoginUrlCannotBeABase once sso_login is moved out to turborepo-auth crate
#[error(
"loginUrl is configured to \"{value}\", but cannot be a base URL. This happens in \
situations like using a `data:` URL."
Expand Down
Loading