Skip to content

Commit c582978

Browse files
committed
Merge branch 'claude/gracious-einstein-ywrwim' into claude/gracious-einstein-ywrwim-guest-errors
2 parents e3b2111 + 5249770 commit c582978

8 files changed

Lines changed: 102 additions & 29 deletions

File tree

‎.changeset/auth-error-codes-and-help.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ Auth failures carry more help, and their messages never quote a credential or an
77
- `REQUEST_ERROR`'s message no longer repeats the transport's own error, which can carry a URL with its query string. It gains `help` saying what to check.
88
- `INVALID_TOKEN` for a token whose claims do not decode no longer quotes the decoder's message, which could carry a byte or a claim of the token.
99
- A failed device binding reports ZeroKMS's status, not its response body.
10+
- `SERVER_ERROR` for a refused token exchange names the HTTP status and the auth server's `error_description`, not the response body, which from the edge in front of it is an HTML page and can echo the access key. A body that is not JSON is reported by where it broke, not by the parser's message.
1011
- A profile file that is not valid JSON is reported by error kind, line and column, not by the parser's message, which could quote the file.
1112
- `INVALID_GRANT`, `INVALID_WORKSPACE_ID` and `ALREADY_CONSUMED` gain `help`, and `NOT_AUTHENTICATED`'s help names `stash auth login`.
1213
- A `STORE_ERROR` carries the help of the profile failure underneath it, such as logging in again when the profile file is missing.

‎packages/stack-auth/src/access_key_refresher.rs‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -70,9 +70,9 @@ impl Refresher for AccessKeyRefresher {
7070
if let Some(err) = crate::error::classify_issuance_failure(status, &body) {
7171
return Err(err);
7272
}
73-
return Err(AuthError::Server(crate::error::ServerError(format!(
74-
"{status}: {body}"
75-
))));
73+
return Err(AuthError::Server(crate::error::ServerError::refused(
74+
status, &body,
75+
)));
7676
}
7777

7878
let auth_resp: AuthoriseResponse = resp.json()?;

‎packages/stack-auth/src/device_code/mod.rs‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -394,9 +394,7 @@ impl PendingDeviceCode {
394394
}
395395

396396
let err: ErrorResponse = serde_json::from_str(&body).map_err(|e| {
397-
AuthError::Server(crate::error::ServerError(format!(
398-
"{status}: unparseable error body: {e}"
399-
)))
397+
AuthError::Server(crate::error::ServerError::unparseable(status, &e))
400398
})?;
401399
match err.error.as_str() {
402400
"authorization_pending" => {

‎packages/stack-auth/src/device_session_refresher.rs‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -126,10 +126,12 @@ impl DeviceSessionRefresher {
126126
};
127127
let lock = tokio::task::spawn_blocking(move || store.lock_exclusive(Token::FILENAME))
128128
.await
129-
.map_err(|e| {
130-
AuthError::Server(crate::error::ServerError(format!(
131-
"refresh lock task join failed: {e}"
132-
)))
129+
// A join error is tokio's: it says only that the task panicked
130+
// or was cancelled, and is not ours to repeat.
131+
.map_err(|_| {
132+
AuthError::Server(crate::error::ServerError(
133+
"refresh lock task join failed".to_owned(),
134+
))
133135
})?
134136
.map_err(|e| {
135137
AuthError::Server(crate::error::ServerError(format!(

‎packages/stack-auth/src/error.rs‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,12 @@ impl AuthErrorKind for UsageLimitExceeded {
372372
}
373373

374374
/// An unexpected error was returned by the auth server.
375+
///
376+
/// The message the crate builds names the HTTP status and, where the auth
377+
/// server gave one, its `error_description`: never the response body. From
378+
/// the edge in front of the auth server a body is an HTML page, and any
379+
/// body may echo the credential the request carried (see
380+
/// [`ErrorPayload`] for the rule).
375381
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
376382
#[error("Server error: {0}")]
377383
#[diagnostic(code(stack_auth::server_error))]
@@ -382,6 +388,38 @@ impl AuthErrorKind for ServerError {
382388
}
383389
}
384390

391+
impl ServerError {
392+
/// A refused exchange no classifier had anything more specific for: the
393+
/// status, and the auth server's `error_description` when the body is
394+
/// its JSON error.
395+
pub(crate) fn refused(status: u16, body: &str) -> Self {
396+
let description = serde_json::from_str::<serde_json::Value>(body)
397+
.ok()
398+
.and_then(|value| {
399+
value
400+
.get("error_description")?
401+
.as_str()
402+
.map(str::trim)
403+
.filter(|text| !text.is_empty())
404+
.map(str::to_owned)
405+
});
406+
match description {
407+
Some(description) => Self(format!("{status}: {description}")),
408+
None => Self(status.to_string()),
409+
}
410+
}
411+
412+
/// An error body that is not the JSON the endpoint answers with: the
413+
/// status, and where the JSON broke. serde_json's own message can quote
414+
/// the body, so it is not used.
415+
pub(crate) fn unparseable(status: u16, error: &serde_json::Error) -> Self {
416+
Self(format!(
417+
"{status}: unparseable error body ({})",
418+
stack_profile::diagnostic::describe_json_error(error)
419+
))
420+
}
421+
}
422+
385423
/// A consumable handle (e.g. a device-code poll) was used after it had already
386424
/// been consumed. A caller bug rather than an auth outcome, but surfaced as an
387425
/// `AuthError` so it flows through the `Result` contract rather than throwing

‎packages/stack-auth/src/oidc_refresher.rs‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -189,9 +189,9 @@ impl OidcFederation {
189189
if let Some(err) = crate::error::classify_issuance_failure(status, &body) {
190190
return Err(err);
191191
}
192-
return Err(AuthError::Server(crate::error::ServerError(format!(
193-
"{status}: {body}"
194-
))));
192+
return Err(AuthError::Server(crate::error::ServerError::refused(
193+
status, &body,
194+
)));
195195
}
196196

197197
let auth_resp: AuthoriseResponse = resp.json()?;

‎packages/stack-auth/src/token.rs‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -313,9 +313,7 @@ impl Token {
313313
}
314314

315315
let err: RefreshErrorResponse = serde_json::from_str(&body).map_err(|e| {
316-
AuthError::Server(crate::error::ServerError(format!(
317-
"{status}: unparseable error body: {e}"
318-
)))
316+
AuthError::Server(crate::error::ServerError::unparseable(status, &e))
319317
})?;
320318

321319
return Err(match err.error.as_str() {

‎packages/stack-auth/src/transport.rs‎

Lines changed: 49 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -882,22 +882,58 @@ mod tests {
882882
assert!(matches!(err, AuthError::UsageLimitExceeded(_)), "{err:?}");
883883
}
884884

885+
/// An unclassified failure names its status and never carries the
886+
/// body: from the edge it is an HTML page, and it may echo the
887+
/// credential. The auth server's own error description is kept.
885888
#[tokio::test]
886-
async fn an_unclassified_failure_is_a_server_error_with_the_body() {
887-
let transport: SharedTransport = Arc::new(Stub::replying(500, "boom"));
888-
let refresher = AccessKeyRefresher::new(
889-
SecretToken::new("CSAKid.secret"),
890-
base_url(),
889+
async fn an_unclassified_failure_is_a_server_error_without_the_body() {
890+
const PAGE: &str = "<html><h1>403 Forbidden</h1>nginx CSAKmarker.secret</html>";
891+
let refused = |body: &'static str| {
892+
let transport: SharedTransport = Arc::new(Stub::replying(403, body));
893+
AccessKeyRefresher::new(
894+
SecretToken::new("CSAKmarker.secret"),
895+
base_url(),
896+
None,
897+
transport,
898+
)
899+
};
900+
let err = refused(PAGE).refresh(&()).await.unwrap_err();
901+
assert!(matches!(err, AuthError::Server(_)), "{err:?}");
902+
assert_eq!(err.to_string(), "Server error: 403");
903+
904+
let transport: SharedTransport = Arc::new(Stub::replying(403, PAGE));
905+
let err = OidcFederation::new(workspace_id(), base_url(), transport)
906+
.federate(&SecretToken::new("h.p.s"))
907+
.await
908+
.unwrap_err();
909+
assert_eq!(err.to_string(), "Server error: 403");
910+
911+
let described = r#"{"error":"forbidden","error_description":"client is disabled"}"#;
912+
let err = refused(described).refresh(&()).await.unwrap_err();
913+
assert_eq!(err.to_string(), "Server error: 403: client is disabled");
914+
}
915+
916+
/// An error body that is not JSON says where it broke, not what it held.
917+
#[tokio::test]
918+
async fn an_unparseable_error_body_is_not_quoted() {
919+
let transport: SharedTransport =
920+
Arc::new(Stub::replying(400, r#"{"error": "marker-rt-echo"#));
921+
let err = Token::refresh_with(
922+
&transport,
923+
&SecretToken::new("rt"),
924+
&base_url(),
925+
"cli",
891926
None,
892-
transport,
927+
)
928+
.await
929+
.unwrap_err();
930+
let shown = err.to_string();
931+
assert!(matches!(err, AuthError::Server(_)), "{err:?}");
932+
assert!(
933+
shown.starts_with("Server error: 400: unparseable error body"),
934+
"{shown}"
893935
);
894-
let err = refresher.refresh(&()).await.unwrap_err();
895-
match err {
896-
AuthError::Server(e) => {
897-
assert!(e.to_string().contains("500") && e.to_string().contains("boom"))
898-
}
899-
other => panic!("{other:?}"),
900-
}
936+
assert!(!shown.contains("marker"), "{shown}");
901937
}
902938

903939
#[tokio::test]

0 commit comments

Comments
 (0)