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): move sso_login implementation to new crate #6037

Merged
merged 1 commit into from
Sep 27, 2023
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
1 change: 1 addition & 0 deletions Cargo.lock

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

3 changes: 2 additions & 1 deletion crates/turborepo-auth/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@ edition = "2021"
license = "MPL-2.0"

[dependencies]
anyhow.workspace = true
anyhow = { workspace = true, features = ["backtrace"] }
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Made this match how it's specified in turborepo-lib

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not that it is blocking in anyway, but Cargo resolves features in an additive way so when turborepo-lib is built any of it's dependencies will get a version of anyhow with the backtrace feature. Feature unification

axum-server = { workspace = true }
axum.workspace = true
hostname = "0.3.1"
reqwest.workspace = true
serde.workspace = true
thiserror = "1.0.38"
Expand Down
264 changes: 259 additions & 5 deletions crates/turborepo-auth/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@
use std::net::SocketAddr;
use std::sync::Arc;

use anyhow::{anyhow, Result};
use anyhow::{anyhow, Context, Result};
#[cfg(not(test))]
use axum::{extract::Query, response::Redirect, routing::get, Router};
use reqwest::Url;
use serde::Deserialize;
use thiserror::Error;
use tokio::sync::OnceCell;
#[cfg(not(test))]
use tracing::warn;
Expand All @@ -15,8 +16,11 @@ use turborepo_ui::{start_spinner, BOLD, CYAN, UI};

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

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

use thiserror::Error;
#[derive(Debug, Error)]
pub enum Error {
#[error(
Expand Down Expand Up @@ -148,22 +152,179 @@ async fn run_login_one_shot_server(
.await?)
}

#[derive(Debug, Default, Clone, Deserialize)]
#[allow(dead_code)]
struct SsoPayload {
login_error: Option<String>,
sso_email: Option<String>,
team_name: Option<String>,
sso_type: Option<String>,
token: Option<String>,
email: Option<String>,
}

pub async fn sso_login<F>(
api_client: APIClient,
ui: &UI,
mut set_token: F,
login_url_configuration: &str,
sso_team: &str,
) -> Result<()>
where
F: FnMut(&str) -> Result<()>,
{
let redirect_url = format!("http://{DEFAULT_HOST_NAME}:{DEFAULT_PORT}");
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(["api", "auth", "sso"]);

login_url
.query_pairs_mut()
.append_pair("teamId", sso_team)
.append_pair("mode", "login")
.append_pair("next", &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_sso_one_shot_server(DEFAULT_PORT, token_cell.clone()).await?;
spinner.finish_and_clear();

let token = token_cell
.get()
.ok_or_else(|| anyhow!("no token auth token found"))?;

let token_name = make_token_name().context("failed to make sso token name")?;

let verified_user = api_client.verify_sso_token(token, &token_name).await?;
let user_response = api_client.get_user(&verified_user.token).await?;

set_token(&verified_user.token)?;

println!(
"
{} {}
",
ui.rainbow(">>> Success!"),
ui.apply(BOLD.apply_to(format!(
"Turborepo CLI authorized for {}",
user_response.user.email
)))
);

println!(
"{}
{}
",
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(())
}

fn make_token_name() -> Result<String> {
let host = hostname::get()?;

Ok(format!(
"Turbo CLI on {} via {DEFAULT_SSO_PROVIDER}",
host.to_string_lossy()
))
}

fn get_token_and_redirect(payload: SsoPayload) -> Result<(Option<String>, Url)> {
let location_stub = "https://vercel.com/notifications/cli-login/turbo/";
if let Some(login_error) = payload.login_error {
let mut url = Url::parse(&format!("{}failed", location_stub))?;
url.query_pairs_mut()
.append_pair("loginError", login_error.as_str());
return Ok((None, url));
}

if let Some(sso_email) = payload.sso_email {
let mut url = Url::parse(&format!("{}incomplete", location_stub))?;
url.query_pairs_mut()
.append_pair("ssoEmail", sso_email.as_str());
if let Some(team_name) = payload.team_name {
url.query_pairs_mut()
.append_pair("teamName", team_name.as_str());
}
if let Some(sso_type) = payload.sso_type {
url.query_pairs_mut()
.append_pair("ssoType", sso_type.as_str());
}

return Ok((None, url));
}
let mut url = Url::parse(&format!("{}success", location_stub))?;
if let Some(email) = payload.email {
url.query_pairs_mut().append_pair("email", email.as_str());
}

Ok((payload.token, url))
}

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

#[cfg(not(test))]
async fn run_sso_one_shot_server(
port: u16,
verification_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(|sso_payload: Query<SsoPayload>| async move {
let (token, location) = get_token_and_redirect(sso_payload.0).unwrap();
if let Some(token) = token {
// If token is already set, it's not a big deal, so we ignore the error.
let _ = verification_token.set(token);
}
route_handle.shutdown();
Redirect::to(location.as_str())
}),
);
let addr = SocketAddr::from(([127, 0, 0, 1], port));

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

#[cfg(test)]
mod test {
use port_scanner;
use reqwest::Url;
use tokio;
use turborepo_ui::UI;
use turborepo_vercel_api_mock::start_test_server;

use crate::login;
use crate::{get_token_and_redirect, login, sso_login, SsoPayload};

#[tokio::test]
async fn test_login() {
let port = port_scanner::request_open_port().unwrap();
let api_server = tokio::spawn(start_test_server(port));

let ui = UI::new(false);

let url = format!("http://localhost:{port}");

let api_client =
Expand All @@ -181,4 +342,97 @@ mod test {
api_server.abort();
assert_eq!(got_token, turborepo_vercel_api_mock::EXPECTED_TOKEN);
}

#[tokio::test]
async fn test_sso_login() {
let port = port_scanner::request_open_port().unwrap();
let handle = tokio::spawn(start_test_server(port));
let url = format!("http://localhost:{port}");
let ui = UI::new(false);
let team = "something";

let api_client =
turborepo_api_client::APIClient::new(url.clone(), 1000, "1", false).unwrap();

// closure that will check that the token is sent correctly
let mut got_token = String::new();
let set_token = |t: &str| -> Result<(), anyhow::Error> {
got_token = t.to_string();
Ok(())
};

sso_login(api_client, &ui, set_token, &url, team)
.await
.unwrap();

handle.abort();

assert_eq!(got_token, turborepo_vercel_api_mock::EXPECTED_TOKEN);
}

#[test]
fn test_get_token_and_redirect() {
assert_eq!(
get_token_and_redirect(SsoPayload::default()).unwrap(),
(
None,
Url::parse("https://vercel.com/notifications/cli-login/turbo/success").unwrap()
)
);

assert_eq!(
get_token_and_redirect(SsoPayload {
login_error: Some("error".to_string()),
..SsoPayload::default()
})
.unwrap(),
(
None,
Url::parse(
"https://vercel.com/notifications/cli-login/turbo/failed?loginError=error"
)
.unwrap()
)
);

assert_eq!(
get_token_and_redirect(SsoPayload {
sso_email: Some("email".to_string()),
..SsoPayload::default()
})
.unwrap(),
(
None,
Url::parse(
"https://vercel.com/notifications/cli-login/turbo/incomplete?ssoEmail=email"
)
.unwrap()
)
);

assert_eq!(
get_token_and_redirect(SsoPayload {
sso_email: Some("email".to_string()),
team_name: Some("team".to_string()),
..SsoPayload::default()
}).unwrap(),
(
None,
Url::parse("https://vercel.com/notifications/cli-login/turbo/incomplete?ssoEmail=email&teamName=team")
.unwrap()
)
);

assert_eq!(
get_token_and_redirect(SsoPayload {
token: Some("token".to_string()),
..SsoPayload::default()
})
.unwrap(),
(
Some("token".to_string()),
Url::parse("https://vercel.com/notifications/cli-login/turbo/success").unwrap()
)
);
}
}
Loading
Loading