diff --git a/src/runtime/auth/tokens.rs b/src/runtime/auth/tokens.rs index 6ce451b..eccc291 100644 --- a/src/runtime/auth/tokens.rs +++ b/src/runtime/auth/tokens.rs @@ -123,8 +123,8 @@ pub async fn fetch_client_credentials_token( /// Fetch a token via refresh token grant. /// -/// For confidential clients, sends Basic auth with client_id:client_secret. -/// For public clients (no secret), sends client_id in the form body. +/// Sends Basic auth with `client_id:client_secret` for confidential clients +/// and `client_id:` for public clients. pub async fn fetch_refresh_token( client: &Client, base_url: &str, @@ -135,17 +135,12 @@ pub async fn fetch_refresh_token( let url = token_url(base_url).map_err(RuntimeError::from)?; let mut request = client.post(url); - if let Some(secret) = client_secret { - request = request.basic_auth(client_id, Some(secret)); - } + request = request.basic_auth(client_id, Some(client_secret.unwrap_or(""))); - let mut form_params = vec![ + let form_params = vec![ ("grant_type", "refresh_token"), ("refresh_token", refresh_token), ]; - if client_secret.is_none() { - form_params.push(("client_id", client_id)); - } let response = request .form(&form_params) diff --git a/tests/integration/auth.rs b/tests/integration/auth.rs index 7135d09..3a30ed7 100644 --- a/tests/integration/auth.rs +++ b/tests/integration/auth.rs @@ -15,6 +15,12 @@ fn expected_basic_auth() -> String { format!("Basic {encoded}") } +fn expected_public_basic_auth() -> String { + use base64::Engine; + let encoded = base64::engine::general_purpose::STANDARD.encode("test-client-id:"); + format!("Basic {encoded}") +} + fn token_response_json(access_token: &str, expires_in: u64) -> String { format!( r#"{{"access_token":"{access_token}","expires_in":{expires_in},"token_type":"Bearer"}}"# @@ -297,6 +303,58 @@ async fn test_refresh_token_sends_correct_request() { assert_eq!(result.refresh_token.as_deref(), Some("new-refresh-token")); } +/// Public-client refresh must send Basic auth with an empty secret. +#[tokio::test] +async fn test_refresh_token_public_client_sends_basic_auth_with_empty_secret() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/iam/v3/oauth/token")) + .and(header( + "Authorization", + expected_public_basic_auth().as_str(), + )) + .and(body_string_contains("grant_type=refresh_token")) + .and(body_string_contains("refresh_token=my-refresh-token")) + .respond_with( + ResponseTemplate::new(200).set_body_string(token_response_with_refresh( + "new-access-token", + 3600, + "new-refresh-token", + 86400, + )), + ) + .expect(1) + .mount(&server) + .await; + + let client = reqwest::Client::new(); + + let result = service::fetch_refresh_token( + &client, + &server.uri(), + TEST_CLIENT_ID, + None, + "my-refresh-token", + ) + .await + .unwrap(); + + assert_eq!(result.access_token, "new-access-token"); + assert_eq!(result.refresh_token.as_deref(), Some("new-refresh-token")); + + let requests = server.received_requests().await.unwrap(); + let request = requests + .iter() + .find(|request| request.url.path() == "/iam/v3/oauth/token") + .expect("refresh request captured"); + let body = std::str::from_utf8(&request.body).expect("form body is UTF-8"); + assert!( + !body.contains("client_id="), + "public-client refresh must not duplicate client_id in the form body" + ); +} + /// Expired refresh tokens must produce a clear error so the caller can fall back to a full re-login #[tokio::test] async fn test_refresh_token_handles_401() {