Skip to content

Commit 7e8e00f

Browse files
claudecoderdan
authored andcommitted
fix(stack-auth): a refused exchange's ServerError names the status, never the body
The Go binding's test for a 403 from the edge in front of the auth server showed the access-key and OIDC refreshers putting the whole response body into ServerError's message: "Server error: 403: <html>...nginx CSAK...</html>". A body from the edge is an HTML page, and any body may echo the credential the request carried, so under the rule on ErrorPayload it never belongs in a message. With se_last_error that message now crosses into every binding. ServerError::refused builds the message from the status and, when the body is the auth server's JSON error, its error_description, which the rule allows. ServerError::unparseable replaces serde_json's message, which can quote the body, with where the JSON broke. The refresh-lock join failure no longer repeats tokio's error text. The body is still logged at debug level where it was before. The test that asserted the body was in the message now asserts it is not. Refs #1099 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01URtfKsTToFUCRwq3g7gCUf
1 parent 40df657 commit 7e8e00f

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)