-
Notifications
You must be signed in to change notification settings - Fork 1.8k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(turborepo-auth): create new crate to handle authentication to ba…
…ckend (#6027) This creates a `turborepo-auth` crate that contains the logic for logging into a backend (Vercel by default). It only covers logging into non-SAML enforced teams to get a proof of concept merged, sso_login and logout will be moved in the future.
- Loading branch information
Showing
6 changed files
with
242 additions
and
159 deletions.
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,22 @@ | ||
[package] | ||
name = "turborepo-auth" | ||
version = "0.1.0" | ||
edition = "2021" | ||
license = "MPL-2.0" | ||
|
||
[dependencies] | ||
anyhow.workspace = true | ||
axum-server = { workspace = true } | ||
axum.workspace = true | ||
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 | ||
turborepo-vercel-api-mock = { workspace = true } | ||
webbrowser = { workspace = true } | ||
|
||
[dev-dependencies] | ||
port_scanner = { workspace = true } |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,184 @@ | ||
#[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 }, | ||
} | ||
|
||
pub async fn login<F>( | ||
api_client: APIClient, | ||
ui: &UI, | ||
mut set_token: F, | ||
login_url_configuration: &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(["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_configuration.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. | ||
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?) | ||
} | ||
|
||
#[cfg(test)] | ||
mod test { | ||
use port_scanner; | ||
use tokio; | ||
use turborepo_ui::UI; | ||
use turborepo_vercel_api_mock::start_test_server; | ||
|
||
use crate::login; | ||
|
||
#[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 = | ||
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(()) | ||
}; | ||
|
||
login(api_client, &ui, set_token, &url).await.unwrap(); | ||
|
||
api_server.abort(); | ||
assert_eq!(got_token, turborepo_vercel_api_mock::EXPECTED_TOKEN); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.