Skip to content

Commit 80a6f65

Browse files
feat: better error when not enough scopes for SSO login (#9948)
### Description When you already have a token but don't have SSO scopes, we were throwing an error that didn't have much information. The error should be more clear now when you are in this state, informing you to use `--force`. Specifically you could get into this state by doing: ``` turbo login turbo login --sso-team=my-team ``` #### Note I happy-pathed (error-pathed?) this for the specific case I wanted to solve for. I'm not sure if this is accidentally changing the error for other problematic states you can be in. ### Testing Instructions I'm struggling to write a unit test. Help would be appreciate if you think one would be good for this (I do). Additionally, here's a before and after: Before: ``` ▲ 👟 turbo on shew/6b0e1 turbo login turbo 2.4.0 >>> Opening browser to https://vercel.com/turborepo/token?redirect_uri=redacted >>> Success! Turborepo CLI authorized for [email protected] To connect to your Remote Cache, run the following in any turborepo: npx turbo link ▲ 👟 turbo on shew/6b0e1 took 6s turbo login --sso-team=my-team turbo 2.4.0 × Error making HTTP request: HTTP status client error (403 Forbidden) for url (https://vercel.com/api/v2/teams/my-team) ╰─▶ HTTP status client error (403 Forbidden) for url (https://vercel.com/api/v2/teams/my-team) ``` After: ``` ▲ 👟 turbo on shew/6b0e1 dt login turbo 2.4.2-canary.0 >>> Opening browser to https://vercel.com/turborepo/token?redirect_uri=redacted >>> Success! Turborepo CLI authorized for [email protected] To connect to your Remote Cache, run the following in any turborepo: npx turbo link ▲ 👟 turbo on shew/6b0e1 took 2s dt login --sso-team=my-team turbo 2.4.2-canary.0 × [HTTP 403] request to https://vercel.com/api/v2/teams/my-team returned "HTTP status client error (403 Forbidden) for url (https://vercel.com/api/v2/teams/my-team)" │ Try logging in again, or force a refresh of your token (turbo login --sso-team=your-team --force). ``` --------- Co-authored-by: Chris Olszewski <[email protected]>
1 parent 8e8c6b7 commit 80a6f65

File tree

3 files changed

+157
-1
lines changed

3 files changed

+157
-1
lines changed

Cargo.lock

+2
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/turborepo-auth/Cargo.toml

+2
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,6 @@ url = { workspace = true }
3232
webbrowser = { workspace = true }
3333

3434
[dev-dependencies]
35+
http = "1.1.0"
36+
httpmock = { workspace = true }
3537
port_scanner = { workspace = true }

crates/turborepo-auth/src/lib.rs

+153-1
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,33 @@ impl Token {
111111
Ok(true)
112112
}
113113

114+
async fn handle_sso_token_error<T: TokenClient>(
115+
&self,
116+
client: &T,
117+
error: reqwest::Error,
118+
) -> Result<bool, Error> {
119+
if error.status() == Some(reqwest::StatusCode::FORBIDDEN) {
120+
let metadata = self.fetch_metadata(client).await?;
121+
if !metadata.token_type.is_empty() {
122+
return Err(Error::APIError(turborepo_api_client::Error::InvalidToken {
123+
status: error
124+
.status()
125+
.unwrap_or(reqwest::StatusCode::FORBIDDEN)
126+
.as_u16(),
127+
url: error
128+
.url()
129+
.map(|u| u.to_string())
130+
.unwrap_or("Unknown url".to_string()),
131+
message: error.to_string(),
132+
}));
133+
}
134+
}
135+
136+
Err(Error::APIError(turborepo_api_client::Error::ReqwestError(
137+
error,
138+
)))
139+
}
140+
114141
/// This is the same as `is_valid`, but also checks if the token is valid
115142
/// for SSO.
116143
///
@@ -158,7 +185,12 @@ impl Token {
158185

159186
Ok(true)
160187
}
161-
(Err(e), _) | (_, Err(e)) => Err(Error::APIError(e)),
188+
(Err(e), _) | (_, Err(e)) => match e {
189+
turborepo_api_client::Error::ReqwestError(e) => {
190+
self.handle_sso_token_error(client, e).await
191+
}
192+
e => Err(Error::APIError(e)),
193+
},
162194
}
163195
}
164196

@@ -675,4 +707,124 @@ mod tests {
675707
let result = Token::from_file(&file_path);
676708
assert!(matches!(result, Err(Error::TokenNotFound)));
677709
}
710+
711+
struct MockSSOTokenClient {
712+
metadata_response: Option<ResponseTokenMetadata>,
713+
}
714+
715+
impl TokenClient for MockSSOTokenClient {
716+
async fn get_metadata(
717+
&self,
718+
_token: &str,
719+
) -> turborepo_api_client::Result<ResponseTokenMetadata> {
720+
if let Some(metadata) = &self.metadata_response {
721+
Ok(metadata.clone())
722+
} else {
723+
Ok(ResponseTokenMetadata {
724+
id: "test".to_string(),
725+
name: "test".to_string(),
726+
token_type: "".to_string(),
727+
origin: "test".to_string(),
728+
scopes: vec![],
729+
active_at: current_unix_time() - 100,
730+
created_at: 0,
731+
})
732+
}
733+
}
734+
735+
async fn delete_token(&self, _token: &str) -> turborepo_api_client::Result<()> {
736+
Ok(())
737+
}
738+
}
739+
740+
#[tokio::test]
741+
async fn test_handle_sso_token_error_forbidden_with_invalid_token_error() {
742+
let token = Token::new("test-token".to_string());
743+
let client = MockSSOTokenClient {
744+
metadata_response: Some(ResponseTokenMetadata {
745+
id: "test".to_string(),
746+
name: "test".to_string(),
747+
token_type: "sso".to_string(),
748+
origin: "test".to_string(),
749+
scopes: vec![],
750+
active_at: current_unix_time() - 100,
751+
created_at: 0,
752+
}),
753+
};
754+
755+
let errorful_response = reqwest::Response::from(
756+
http::Response::builder()
757+
.status(reqwest::StatusCode::FORBIDDEN)
758+
.body("")
759+
.unwrap(),
760+
);
761+
762+
let result = token
763+
.handle_sso_token_error(&client, errorful_response.error_for_status().unwrap_err())
764+
.await;
765+
assert!(matches!(
766+
result,
767+
Err(Error::APIError(
768+
turborepo_api_client::Error::InvalidToken { .. }
769+
))
770+
));
771+
}
772+
773+
#[tokio::test]
774+
async fn test_handle_sso_token_error_forbidden_without_token_type() {
775+
let token = Token::new("test-token".to_string());
776+
let client = MockSSOTokenClient {
777+
metadata_response: Some(ResponseTokenMetadata {
778+
id: "test".to_string(),
779+
name: "test".to_string(),
780+
token_type: "".to_string(),
781+
origin: "test".to_string(),
782+
scopes: vec![],
783+
active_at: current_unix_time() - 100,
784+
created_at: 0,
785+
}),
786+
};
787+
788+
let errorful_response = reqwest::Response::from(
789+
http::Response::builder()
790+
.status(reqwest::StatusCode::FORBIDDEN)
791+
.body("")
792+
.unwrap(),
793+
);
794+
795+
let result = token
796+
.handle_sso_token_error(&client, errorful_response.error_for_status().unwrap_err())
797+
.await;
798+
assert!(matches!(
799+
result,
800+
Err(Error::APIError(turborepo_api_client::Error::ReqwestError(
801+
_
802+
)))
803+
));
804+
}
805+
806+
#[tokio::test]
807+
async fn test_handle_sso_token_error_non_forbidden() {
808+
let token = Token::new("test-token".to_string());
809+
let client = MockSSOTokenClient {
810+
metadata_response: None,
811+
};
812+
813+
let errorful_response = reqwest::Response::from(
814+
http::Response::builder()
815+
.status(reqwest::StatusCode::INTERNAL_SERVER_ERROR)
816+
.body("")
817+
.unwrap(),
818+
);
819+
820+
let result = token
821+
.handle_sso_token_error(&client, errorful_response.error_for_status().unwrap_err())
822+
.await;
823+
assert!(matches!(
824+
result,
825+
Err(Error::APIError(turborepo_api_client::Error::ReqwestError(
826+
_
827+
)))
828+
));
829+
}
678830
}

0 commit comments

Comments
 (0)