Skip to content

Commit

Permalink
feature: Add invalidate flag to logout (#7444)
Browse files Browse the repository at this point in the history
### Description
Allows for `turbo logout --invalidate` which should call to the API to actually remove the token from the account.



Closes TURBO-2406
  • Loading branch information
Zertsov committed Mar 7, 2024
1 parent 0e0be25 commit 7f6a3ae
Show file tree
Hide file tree
Showing 10 changed files with 213 additions and 34 deletions.
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

0 comments on commit 7f6a3ae

Please sign in to comment.