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

feature: Add invalidate flag to logout #7444

Merged
merged 1 commit into from
Mar 7, 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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
11 changes: 11 additions & 0 deletions crates/turborepo-api-client/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,17 @@ pub enum Error {
err: serde_json::Error,
text: String,
},
#[error(
"[HTTP {status}] request to {url} returned \"{message}\" \ntry logging in again, or force \
a new token (turbo login <--sso-team your_team> -f)."
)]
InvalidToken {
status: u16,
url: String,
message: String,
},
#[error("[HTTP 403] token is forbidden from accessing {url}")]
ForbiddenToken { url: String },
}

pub type Result<T> = std::result::Result<T, Error>;
98 changes: 94 additions & 4 deletions crates/turborepo-api-client/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ pub trait CacheClient {
#[async_trait]
pub trait TokenClient {
async fn get_metadata(&self, token: &str) -> Result<ResponseTokenMetadata>;
async fn delete_token(&self, token: &str) -> Result<()>;
}

#[derive(Clone)]
Expand Down Expand Up @@ -416,22 +417,111 @@ impl CacheClient 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 endpoint = "/v5/user/tokens/current";
let url = self.make_url(endpoint)?;
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)
#[derive(Deserialize, Debug)]
struct ErrorResponse {
error: ErrorDetails,
}
#[derive(Deserialize, Debug)]
struct ErrorDetails {
message: String,
#[serde(rename = "invalidToken", default)]
invalid_token: bool,
}

let response = retry::make_retryable_request(request_builder).await?;
let status = response.status();
// Give a better error message for invalid tokens. This endpoint returns the
// following statuses:
// 200: OK
// 400: Bad Request
// 403: Forbidden
// 404: Not Found
match status {
StatusCode::OK => Ok(response.json::<Response>().await?.metadata),
// If we're forbidden, check to see if the token is invalid. If so, give back a nice
// error message.
StatusCode::FORBIDDEN => {
let body = response.json::<ErrorResponse>().await?;
if body.error.invalid_token {
return Err(Error::InvalidToken {
status: status.as_u16(),
// Call make_url again since url is moved.
url: self.make_url(endpoint)?.to_string(),
message: body.error.message,
});
}
return Err(Error::ForbiddenToken {
url: self.make_url(endpoint)?.to_string(),
});
}
_ => Err(response.error_for_status().unwrap_err().into()),
}
}

/// Invalidates the given token on the server.
async fn delete_token(&self, token: &str) -> Result<()> {
let endpoint = "/v3/user/tokens/current";
let url = self.make_url(endpoint)?;
let request_builder = self
.client
.delete(url)
.header("User-Agent", self.user_agent.clone())
.header("Authorization", format!("Bearer {}", token))
.header("Content-Type", "application/json");

#[derive(Deserialize, Debug)]
struct ErrorResponse {
error: ErrorDetails,
}
#[derive(Deserialize, Debug)]
struct ErrorDetails {
message: String,
#[serde(rename = "invalidToken", default)]
invalid_token: bool,
}

let response = retry::make_retryable_request(request_builder).await?;
let status = response.status();
// Give a better error message for invalid tokens. This endpoint returns the
// following statuses:
// 200: OK
// 400: Bad Request
// 403: Forbidden
// 404: Not Found
match status {
StatusCode::OK => Ok(()),
// If we're forbidden, check to see if the token is invalid. If so, give back a nice
// error message.
StatusCode::FORBIDDEN => {
let body = response.json::<ErrorResponse>().await?;
if body.error.invalid_token {
return Err(Error::InvalidToken {
status: status.as_u16(),
// Call make_url again since url is moved.
url: self.make_url(endpoint)?.to_string(),
message: body.error.message,
});
}
return Err(Error::ForbiddenToken {
url: self.make_url(endpoint)?.to_string(),
});
}
_ => Err(response.error_for_status().unwrap_err().into()),
}
}
}

Expand Down
4 changes: 4 additions & 0 deletions crates/turborepo-auth/src/auth/login.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ pub async fn login<T: Client + TokenClient + CacheClient>(
ui::print_cli_authorized(user_email, &ui);
}
};

// Check if passed in token exists first.
if !force {
if let Some(token) = existing_token {
Expand Down Expand Up @@ -297,6 +298,9 @@ mod tests {
created_at: 123456,
})
}
async fn delete_token(&self, _token: &str) -> turborepo_api_client::Result<()> {
Ok(())
}
}

#[async_trait]
Expand Down
93 changes: 71 additions & 22 deletions crates/turborepo-auth/src/auth/logout.rs
Original file line number Diff line number Diff line change
@@ -1,22 +1,33 @@
use tracing::error;
use turborepo_api_client::Client;
use turbopath::AbsoluteSystemPath;
use turborepo_api_client::TokenClient;
use turborepo_ui::{cprintln, GREY};

use crate::{Error, LogoutOptions};
use crate::{Error, LogoutOptions, Token};

pub fn logout<T: Client>(options: &LogoutOptions<T>) -> Result<(), Error> {
if let Err(err) = remove_token(options) {
pub async fn logout<T: TokenClient>(options: &LogoutOptions<'_, T>) -> Result<(), Error> {
let LogoutOptions {
ui,
api_client,
path,
invalidate,
} = *options;

if invalidate {
Token::from_file(path)?.invalidate(api_client).await?;
}

if let Err(err) = remove_token(path) {
error!("could not logout. Something went wrong: {}", err);
return Err(err);
}

cprintln!(options.ui, GREY, ">>> Logged out");
cprintln!(ui, GREY, ">>> Logged out");
Ok(())
}

fn remove_token<T: Client>(options: &LogoutOptions<T>) -> Result<(), Error> {
// Read the existing content from the global configuration path
let content = options.path.read_to_string()?;
fn remove_token(path: &AbsoluteSystemPath) -> Result<(), Error> {
let content = path.read_to_string()?;

// Attempt to deserialize the content into a serde_json::Value
let mut data: serde_json::Value = serde_json::from_str(&content)?;
Expand All @@ -32,33 +43,30 @@ fn remove_token<T: Client>(options: &LogoutOptions<T>) -> Result<(), Error> {

// Serialize the updated data back to a string
let new_content = serde_json::to_string_pretty(&data)?;

// Write the updated content back to the file
options.path.create_with_contents(new_content)?;
path.create_with_contents(new_content)?;

Ok(())
}

#[cfg(test)]
mod tests {
use std::backtrace::Backtrace;

use async_trait::async_trait;
use reqwest::{RequestBuilder, Response};
use tempfile::tempdir;
use turbopath::AbsoluteSystemPathBuf;
use turborepo_ui::UI;
use turborepo_api_client::Client;
use turborepo_vercel_api::{
SpacesResponse, Team, TeamsResponse, UserResponse, VerifiedSsoUser,
token::ResponseTokenMetadata, SpacesResponse, Team, TeamsResponse, UserResponse,
VerifiedSsoUser,
};
use url::Url;

use super::*;

struct MockApiClient {}

impl MockApiClient {
fn new() -> Self {
Self {}
}
struct MockApiClient {
pub succeed_delete_request: bool,
}

#[async_trait]
Expand Down Expand Up @@ -104,6 +112,27 @@ mod tests {
}
}

#[async_trait]
impl TokenClient for MockApiClient {
async fn delete_token(&self, _token: &str) -> turborepo_api_client::Result<()> {
if self.succeed_delete_request {
Ok(())
} else {
Err(turborepo_api_client::Error::UnknownStatus {
code: "code".to_string(),
message: "this failed".to_string(),
backtrace: Backtrace::capture(),
})
}
}
async fn get_metadata(
&self,
_token: &str,
) -> turborepo_api_client::Result<ResponseTokenMetadata> {
unimplemented!("get_metadata")
}
}

#[test]
fn test_remove_token() {
let tmp_dir = tempdir().unwrap();
Expand All @@ -113,13 +142,33 @@ mod tests {
path.create_with_contents(content)
.expect("could not create file");

remove_token(&path).unwrap();

let new_content = path.read_to_string().unwrap();
assert_eq!(new_content, "{}");
}

#[tokio::test]
async fn test_invalidate_token() {
let tmp_dir = tempdir().unwrap();
let path = AbsoluteSystemPathBuf::try_from(tmp_dir.path().join("config.json"))
.expect("could not create path");
let content = r#"{"token":"some-token"}"#;
path.create_with_contents(content)
.expect("could not create file");

let api_client = MockApiClient {
succeed_delete_request: true,
};

let options = LogoutOptions {
ui: &UI::new(false),
api_client: &MockApiClient::new(),
ui: &turborepo_ui::UI::new(false),
api_client: &api_client,
path: &path,
invalidate: true,
};

remove_token(&options).unwrap();
logout(&options).await.unwrap();

let new_content = path.read_to_string().unwrap();
assert_eq!(new_content, "{}");
Expand Down
2 changes: 2 additions & 0 deletions crates/turborepo-auth/src/auth/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ pub struct LogoutOptions<'a, T> {

/// The path where we should look for the token to logout.
pub path: &'a AbsoluteSystemPath,
/// If we should invalidate the token on the server.
pub invalidate: bool,
}

fn extract_vercel_token() -> Result<Option<String>, Error> {
Expand Down
3 changes: 3 additions & 0 deletions crates/turborepo-auth/src/auth/sso.rs
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,9 @@ mod tests {
created_at: 123456,
})
}
async fn delete_token(&self, _token: &str) -> turborepo_api_client::Result<()> {
Ok(())
}
}

#[async_trait]
Expand Down
10 changes: 8 additions & 2 deletions crates/turborepo-auth/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,8 @@ impl Token {
// passed in a user's email if the token is valid.
valid_message_fn: Option<impl FnOnce(&str)>,
) -> Result<bool, Error> {
let is_active = self.is_active(client).await?;
let has_cache_access = self.has_cache_access(client, None).await?;
let (is_active, has_cache_access) =
tokio::try_join!(self.is_active(client), self.has_cache_access(client, None))?;
if !is_active || !has_cache_access {
return Ok(false);
}
Expand Down Expand Up @@ -220,6 +220,12 @@ impl Token {
.await
.map_err(Error::from)
}

/// Invalidates the token on the server.
pub async fn invalidate<T: TokenClient>(&self, client: &T) -> Result<(), Error> {
client.delete_token(self.into_inner()).await?;
Ok(())
}
/// Returns the underlying token string.
pub fn into_inner(&self) -> &str {
match self {
Expand Down