entropy-auth 2026.7.31

Authentication and authorization for Entropy Softworks server and API projects
//! OAuth refresh token request building.
//!
//! Builds the `application/x-www-form-urlencoded` POST body for exchanging
//! a refresh token for a new access token at the token endpoint.
//!
//! # Security
//!
//! SECURITY: The `client_secret` and `refresh_token` are included in the
//! POST body, never in query parameters. This ensures they are protected
//! by TLS and not logged in server access logs.

use std::fmt;

use crate::crypto::zeroize::Zeroizing;
use crate::encoding::url_encode_component;
use crate::util::log::debug;

use super::config::OAuthConfig;

// ---------------------------------------------------------------------------
// RefreshRequest
// ---------------------------------------------------------------------------

/// A refresh token request.
///
/// Contains the pre-built `application/x-www-form-urlencoded` POST body
/// and the `Content-Type` header value. The caller sends this to the
/// token endpoint using their HTTP transport.
///
/// NOTE: The field layout and accessors intentionally mirror
/// [`TokenExchangeRequest`](super::token_request::TokenExchangeRequest)
/// — both represent OAuth POST bodies with the same security
/// properties. The duplication is small (2 fields, 2 accessors) and
/// the constructors differ enough that extracting a shared base would
/// add indirection without meaningful simplification.
#[doc(alias = "refresh")]
pub struct RefreshRequest {
    // SECURITY: The body contains the client_secret and refresh_token
    // in plaintext. Wrapping in `Zeroizing` ensures it is cleared from
    // memory on drop.
    body: Zeroizing<String>,
    content_type: &'static str,
}

impl RefreshRequest {
    /// Builds a refresh token request body.
    ///
    /// # Parameters
    ///
    /// * `config` — The OAuth provider configuration.
    /// * `refresh_token` — The refresh token from a previous token response.
    ///
    /// # Security
    ///
    /// SECURITY: The `client_secret` is included in the POST body, not in
    /// query parameters, so it is protected by TLS and not logged in
    /// server access logs.
    #[must_use]
    pub fn new(config: &OAuthConfig, refresh_token: &str) -> Self {
        let mut body = String::with_capacity(256);

        body.push_str("grant_type=refresh_token");
        body.push_str("&refresh_token=");
        body.push_str(&url_encode_component(refresh_token));
        body.push_str("&client_id=");
        body.push_str(&url_encode_component(config.client_id()));
        // SECURITY: client_secret goes in the POST body, NOT in the URL.
        body.push_str("&client_secret=");
        body.push_str(&url_encode_component(config.client_secret()));

        // SECURITY: Never log the refresh_token or client_secret.
        debug!("oauth: refresh request built");

        Self {
            body: Zeroizing::new(body),
            content_type: "application/x-www-form-urlencoded",
        }
    }

    /// Returns the URL-encoded POST body.
    #[must_use]
    #[inline]
    pub fn body(&self) -> &str {
        &self.body
    }

    /// Returns the `Content-Type` header value for this request.
    #[must_use]
    #[inline]
    pub fn content_type(&self) -> &str {
        self.content_type
    }
}

impl fmt::Debug for RefreshRequest {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        // SECURITY: Redact body which contains client_secret and refresh_token.
        f.debug_struct("RefreshRequest")
            .field("body", &"[REDACTED]")
            .field("content_type", &self.content_type)
            .finish()
    }
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    use super::*;
    use crate::oauth::config::OAuthConfig;

    fn test_config() -> OAuthConfig {
        OAuthConfig::builder("my-client-id", "my-secret")
            .authorization_endpoint("https://auth.example.com/authorize")
            .token_endpoint("https://auth.example.com/token")
            .redirect_uri("https://myapp.example.com/callback")
            .scope("openid")
            .build()
            .unwrap()
    }

    // --- Body contains required fields ---

    #[test]
    fn body_contains_grant_type() {
        let config = test_config();
        let req = RefreshRequest::new(&config, "refresh-token-123");
        assert!(
            req.body().contains("grant_type=refresh_token"),
            "body should contain grant_type=refresh_token: {}",
            req.body(),
        );
    }

    #[test]
    fn body_contains_refresh_token() {
        let config = test_config();
        let req = RefreshRequest::new(&config, "refresh-token-123");
        assert!(
            req.body().contains("refresh_token=refresh-token-123"),
            "body should contain refresh_token: {}",
            req.body(),
        );
    }

    #[test]
    fn body_contains_client_id() {
        let config = test_config();
        let req = RefreshRequest::new(&config, "refresh-token-123");
        assert!(
            req.body().contains("client_id=my-client-id"),
            "body should contain client_id: {}",
            req.body(),
        );
    }

    #[test]
    fn body_contains_client_secret() {
        let config = test_config();
        let req = RefreshRequest::new(&config, "refresh-token-123");
        assert!(
            req.body().contains("client_secret=my-secret"),
            "body should contain client_secret: {}",
            req.body(),
        );
    }

    // --- Content type ---

    #[test]
    fn content_type_is_form_urlencoded() {
        let config = test_config();
        let req = RefreshRequest::new(&config, "token");
        assert_eq!(req.content_type(), "application/x-www-form-urlencoded",);
    }

    // --- URL encoding ---

    #[test]
    fn refresh_token_with_special_chars_is_encoded() {
        let config = test_config();
        let req = RefreshRequest::new(&config, "token=with&special/chars");
        assert!(
            req.body()
                .contains("refresh_token=token%3Dwith%26special%2Fchars"),
            "refresh token with special characters should be URL-encoded: {}",
            req.body(),
        );
    }
}