entropy-auth 2026.7.31

Authentication and authorization for Entropy Softworks server and API projects
//! Token exchange request building (POST body).
//!
//! Builds the `application/x-www-form-urlencoded` POST body for exchanging
//! an authorization code for tokens at the token endpoint.
//!
//! # Security
//!
//! SECURITY: The `client_secret` and `code_verifier` 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;

// ---------------------------------------------------------------------------
// TokenExchangeRequest
// ---------------------------------------------------------------------------

/// A token exchange request (authorization code → tokens).
///
/// 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.
#[doc(alias = "token_exchange")]
pub struct TokenExchangeRequest {
    // SECURITY: The body contains the client_secret and code_verifier
    // in plaintext. Wrapping in `Zeroizing` ensures it is cleared from
    // memory on drop.
    body: Zeroizing<String>,
    content_type: &'static str,
}

impl TokenExchangeRequest {
    /// Builds a token exchange request body.
    ///
    /// # Parameters
    ///
    /// * `config` — The OAuth provider configuration.
    /// * `code` — The authorization code received from the callback.
    /// * `pkce_verifier` — The PKCE code verifier from the authorization request.
    ///
    /// # 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, code: &str, pkce_verifier: &str) -> Self {
        let mut body = String::with_capacity(512);

        body.push_str("grant_type=authorization_code");
        body.push_str("&code=");
        body.push_str(&url_encode_component(code));
        body.push_str("&redirect_uri=");
        body.push_str(&url_encode_component(config.redirect_uri()));
        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()));
        body.push_str("&code_verifier=");
        body.push_str(&url_encode_component(pkce_verifier));

        // SECURITY: Never log the code, client_secret, or code_verifier.
        debug!(
            endpoint = %config.token_endpoint(),
            "oauth: token exchange 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 TokenExchangeRequest {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        // SECURITY: Redact body which contains client_secret and code_verifier.
        f.debug_struct("TokenExchangeRequest")
            .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 = TokenExchangeRequest::new(&config, "auth-code-123", "verifier-456");
        assert!(
            req.body().contains("grant_type=authorization_code"),
            "body should contain grant_type: {}",
            req.body(),
        );
    }

    #[test]
    fn body_contains_code() {
        let config = test_config();
        let req = TokenExchangeRequest::new(&config, "auth-code-123", "verifier-456");
        assert!(
            req.body().contains("code=auth-code-123"),
            "body should contain code: {}",
            req.body(),
        );
    }

    #[test]
    fn body_contains_redirect_uri() {
        let config = test_config();
        let req = TokenExchangeRequest::new(&config, "auth-code-123", "verifier-456");
        assert!(
            req.body().contains("redirect_uri="),
            "body should contain redirect_uri: {}",
            req.body(),
        );
    }

    #[test]
    fn body_contains_client_id() {
        let config = test_config();
        let req = TokenExchangeRequest::new(&config, "auth-code-123", "verifier-456");
        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 = TokenExchangeRequest::new(&config, "auth-code-123", "verifier-456");
        assert!(
            req.body().contains("client_secret=my-secret"),
            "body should contain client_secret: {}",
            req.body(),
        );
    }

    #[test]
    fn body_contains_code_verifier() {
        let config = test_config();
        let req = TokenExchangeRequest::new(&config, "auth-code-123", "verifier-456");
        assert!(
            req.body().contains("code_verifier=verifier-456"),
            "body should contain code_verifier: {}",
            req.body(),
        );
    }

    // --- Content type ---

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

    // --- URL encoding ---

    #[test]
    fn code_with_special_chars_is_encoded() {
        let config = test_config();
        let req = TokenExchangeRequest::new(&config, "code=with&special", "verifier");
        assert!(
            req.body().contains("code=code%3Dwith%26special"),
            "code with special characters should be URL-encoded: {}",
            req.body(),
        );
    }

    #[test]
    fn verifier_with_special_chars_is_encoded() {
        let config = test_config();
        let req = TokenExchangeRequest::new(&config, "code", "verifier=special&chars");
        assert!(
            req.body()
                .contains("code_verifier=verifier%3Dspecial%26chars"),
            "verifier with special characters should be URL-encoded: {}",
            req.body(),
        );
    }
}