From 3d6a97c61d136910ee0af5f9607461cd97a3b0a0 Mon Sep 17 00:00:00 2001 From: atlas Date: Mon, 17 Aug 2026 00:31:17 +0200 Subject: [PATCH] fix(#3363): authenticate the token request with HTTP Basic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every access-token request the swarm queue client has ever made was refused. It sent the client id and secret as form fields (`client_secret_post`); authelia's client registration allows only `client_secret_basic`, so the identity provider rejected the request before looking at the credentials at all. What made it survive so long is the shape of the failure. The refusals tripped authelia's rate limiter, whose penalty grows faster than this client's retry interval โ€” 56s, then 296s, then 535s, against a retry every 60s โ€” so the limiter never drained and a 429 came back before the credentials were evaluated. The line naming the real cause appeared roughly once an hour, inside a continuous storm of a different error, and the storm read as the problem. Both other callers in this workspace that present client credentials already use Basic. RFC 6749 says clients SHOULD, authelia's registration default says so, and a secret in a header is one fewer place for a proxy to log it. The test asserts the shape of the request rather than a server's reply: Authorization is Basic, the body carries the grant type, and neither the secret nor the client id appears in the body. It fails on the previous code with no identity provider, no deployment and no network โ€” which is what this needed and did not have. --- swarm-queue-client/src/lib.rs | 98 ++++++++++++++++++++++++++++++++--- 1 file changed, 91 insertions(+), 7 deletions(-) diff --git a/swarm-queue-client/src/lib.rs b/swarm-queue-client/src/lib.rs index 862d72a6..e998648d 100644 --- a/swarm-queue-client/src/lib.rs +++ b/swarm-queue-client/src/lib.rs @@ -296,13 +296,7 @@ async fn mint_token(http: &reqwest::Client, cfg: &QueueConfig) -> Result Result reqwest::RequestBuilder { + http.post(&cfg.token_endpoint) + .basic_auth(&cfg.client_id, Some(secret)) + .form(&[("grant_type", "client_credentials")]) +} + /// Build the HTTP client used to reach `cfg.token_endpoint`, trusting /// `cfg.ca_file` when set. Shared by [`connect`]'s auth callback and by /// [`mint_token_for`] โ€” anything presenting this identity's credentials to @@ -533,4 +556,65 @@ mod tests { std::env::remove_var("SWARM_QUEUE_HALF_NATS_URL"); } } + + fn token_cfg() -> QueueConfig { + QueueConfig { + url: "nats://127.0.0.1:4222".to_owned(), + token_endpoint: "https://auth.example.com/api/oidc/token".to_owned(), + client_id: "hive-alpha".to_owned(), + client_secret_file: PathBuf::from("/nonexistent"), + ca_file: None, + } + } + + /// ๐Ÿฉธ THE REGRESSION TEST FOR AN OUTAGE THAT RAN FOR WEEKS. + /// + /// The credentials used to go in the form body (`client_secret_post`). + /// Authelia's client registration allows only `client_secret_basic`, so + /// every token request was refused โ€” and the refusals tripped a rate + /// limiter whose 429 then arrived *before* the credentials were evaluated, + /// so the error naming the cause appeared roughly once an hour inside a + /// continuous storm of a different error. + /// + /// This asserts the *shape of the request* rather than a server's reply, + /// which is the whole point: it fails on the old code with no identity + /// provider, no deployment and no network. + #[test] + fn the_token_request_authenticates_with_http_basic() { + let req = token_request(&reqwest::Client::new(), &token_cfg(), "s3cret") + .build() + .expect("the token request must build"); + + let auth = req + .headers() + .get(reqwest::header::AUTHORIZATION) + .expect("credentials must travel in the Authorization header") + .to_str() + .expect("the header is ascii"); + assert!( + auth.starts_with("Basic "), + "must be client_secret_basic, got: {auth}" + ); + + let body = std::str::from_utf8( + req.body() + .and_then(reqwest::Body::as_bytes) + .expect("the request has an in-memory body"), + ) + .expect("the body is utf-8"); + assert!( + body.contains("grant_type=client_credentials"), + "the grant type still belongs in the body, got: {body}" + ); + // The half that was actually broken: a secret in the body is both the + // wrong auth method for our registration and a value proxies log. + assert!( + !body.contains("client_secret"), + "the secret must not be in the request body, got: {body}" + ); + assert!( + !body.contains("client_id"), + "the client id belongs in the Basic credentials, got: {body}" + ); + } }