entropy-auth 2026.7.31

Authentication and authorization for Entropy Softworks server and API projects
//! Authorization URL construction for OAuth 2.0 Authorization Code flow.
//!
//! Builds a complete authorization URL with all required parameters:
//! `response_type`, `client_id`, `redirect_uri`, `scope`, `state`,
//! `code_challenge`, and `code_challenge_method`.
//!
//! # Security
//!
//! SECURITY: The `client_secret` is **never** included in the authorization
//! URL — it belongs in the token exchange POST body only. PKCE and state
//! parameters are generated from the platform CSPRNG.

use std::fmt;

use crate::crypto::RandomError;
use crate::encoding::url_encode_component;
use crate::util::log::debug;

use super::config::OAuthConfig;
use super::pkce::PkceChallenge;
use super::state::OAuthState;

// ---------------------------------------------------------------------------
// AuthorizationRequest
// ---------------------------------------------------------------------------

/// A complete authorization request with URL, CSRF state, and PKCE challenge.
///
/// Constructed via [`AuthorizationRequest::build`], which generates fresh
/// state and PKCE values from the platform CSPRNG.
#[doc(alias = "auth_request")]
pub struct AuthorizationRequest {
    url: String,
    state: OAuthState,
    pkce: PkceChallenge,
}

impl AuthorizationRequest {
    /// Builds an authorization URL from the given OAuth configuration.
    ///
    /// Generates fresh PKCE and state parameters from the platform CSPRNG
    /// and assembles the complete authorization URL.
    ///
    /// # URL format
    ///
    /// ```text
    /// {auth_endpoint}?response_type=code
    ///     &client_id={id}
    ///     &redirect_uri={encoded_uri}
    ///     &scope={encoded_scopes}
    ///     &state={state}
    ///     &code_challenge={challenge}
    ///     &code_challenge_method=S256
    /// ```
    ///
    /// # Errors
    ///
    /// Returns [`RandomError`] if the platform CSPRNG is unavailable.
    #[must_use = "building may fail; handle the Result"]
    pub fn build(config: &OAuthConfig) -> Result<Self, RandomError> {
        let state = OAuthState::generate()?;
        let pkce = PkceChallenge::generate()?;

        // Build the scope string (space-delimited).
        let scope_str = config.scopes().join(" ");

        // SECURITY: client_secret is NOT included — it belongs in the POST body.
        let mut url = String::with_capacity(512);
        url.push_str(config.authorization_endpoint());
        // NOTE: Use '&' if the endpoint already contains query parameters
        // (some providers append tracking or tenant params to the base URL).
        if config.authorization_endpoint().contains('?') {
            url.push_str("&response_type=code");
        } else {
            url.push_str("?response_type=code");
        }
        url.push_str("&client_id=");
        url.push_str(&url_encode_component(config.client_id()));
        url.push_str("&redirect_uri=");
        url.push_str(&url_encode_component(config.redirect_uri()));

        if !scope_str.is_empty() {
            url.push_str("&scope=");
            url.push_str(&url_encode_component(&scope_str));
        }

        url.push_str("&state=");
        url.push_str(&url_encode_component(state.value()));
        url.push_str("&code_challenge=");
        url.push_str(&url_encode_component(pkce.challenge()));
        url.push_str("&code_challenge_method=S256");

        // SECURITY: Never log the state value or PKCE verifier.
        debug!(
            endpoint = %config.authorization_endpoint(),
            "oauth: authorization URL built"
        );

        Ok(Self { url, state, pkce })
    }

    /// Returns the complete authorization URL.
    #[must_use]
    #[inline]
    pub fn url(&self) -> &str {
        &self.url
    }

    /// Returns a reference to the CSRF state parameter.
    ///
    /// Store this value (e.g. in a session cookie) to verify the callback.
    #[must_use]
    #[inline]
    pub fn state(&self) -> &OAuthState {
        &self.state
    }

    /// Returns a reference to the PKCE challenge.
    ///
    /// The verifier from this challenge is needed for the token exchange.
    #[must_use]
    #[inline]
    pub fn pkce(&self) -> &PkceChallenge {
        &self.pkce
    }
}

impl fmt::Debug for AuthorizationRequest {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        // SECURITY: Only expose the URL, state, and PKCE challenge — not the
        // raw PKCE verifier or other secret material that the inner types
        // already redact via their own Debug impls.
        f.debug_struct("AuthorizationRequest")
            .field("url", &self.url)
            .field("state", &self.state)
            .field("pkce", &self.pkce)
            .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")
            .scope("profile")
            .build()
            .unwrap()
    }

    // --- URL contains required params ---

    #[test]
    fn url_contains_response_type() {
        let config = test_config();
        let req = AuthorizationRequest::build(&config).unwrap();
        assert!(
            req.url().contains("response_type=code"),
            "URL should contain response_type=code: {}",
            req.url(),
        );
    }

    #[test]
    fn url_contains_client_id() {
        let config = test_config();
        let req = AuthorizationRequest::build(&config).unwrap();
        assert!(
            req.url().contains("client_id=my-client-id"),
            "URL should contain client_id: {}",
            req.url(),
        );
    }

    #[test]
    fn url_contains_redirect_uri() {
        let config = test_config();
        let req = AuthorizationRequest::build(&config).unwrap();
        // The redirect URI should be URL-encoded.
        assert!(
            req.url().contains("redirect_uri="),
            "URL should contain redirect_uri: {}",
            req.url(),
        );
        assert!(
            req.url().contains("myapp.example.com"),
            "URL should contain the redirect host: {}",
            req.url(),
        );
    }

    #[test]
    fn url_contains_scope() {
        let config = test_config();
        let req = AuthorizationRequest::build(&config).unwrap();
        // Scopes are space-delimited, URL-encoded (space → %20).
        assert!(
            req.url().contains("scope=openid%20profile"),
            "URL should contain encoded scope: {}",
            req.url(),
        );
    }

    #[test]
    fn url_contains_state() {
        let config = test_config();
        let req = AuthorizationRequest::build(&config).unwrap();
        assert!(
            req.url().contains("state="),
            "URL should contain state parameter: {}",
            req.url(),
        );
        assert!(
            req.url().contains(req.state().value()),
            "URL should contain the actual state value: {}",
            req.url(),
        );
    }

    #[test]
    fn url_contains_code_challenge() {
        let config = test_config();
        let req = AuthorizationRequest::build(&config).unwrap();
        assert!(
            req.url().contains("code_challenge="),
            "URL should contain code_challenge: {}",
            req.url(),
        );
        assert!(
            req.url().contains(req.pkce().challenge()),
            "URL should contain the actual challenge value: {}",
            req.url(),
        );
    }

    #[test]
    fn url_contains_code_challenge_method() {
        let config = test_config();
        let req = AuthorizationRequest::build(&config).unwrap();
        assert!(
            req.url().contains("code_challenge_method=S256"),
            "URL should contain code_challenge_method=S256: {}",
            req.url(),
        );
    }

    #[test]
    fn url_starts_with_authorization_endpoint() {
        let config = test_config();
        let req = AuthorizationRequest::build(&config).unwrap();
        assert!(
            req.url().starts_with("https://auth.example.com/authorize?"),
            "URL should start with the authorization endpoint: {}",
            req.url(),
        );
    }

    // --- Security: no client_secret in URL ---

    #[test]
    fn url_does_not_contain_client_secret() {
        let config = test_config();
        let req = AuthorizationRequest::build(&config).unwrap();
        // SECURITY: client_secret must NEVER appear in the authorization URL.
        assert!(
            !req.url().contains("my-secret"),
            "URL must not contain client_secret: {}",
            req.url(),
        );
        assert!(
            !req.url().contains("client_secret"),
            "URL must not contain client_secret parameter: {}",
            req.url(),
        );
    }

    // --- URL-encoding correctness ---

    #[test]
    fn redirect_uri_is_url_encoded() {
        let config = OAuthConfig::builder("client", "secret")
            .authorization_endpoint("https://auth.example.com/authorize")
            .token_endpoint("https://auth.example.com/token")
            .redirect_uri("https://myapp.example.com/callback?foo=bar")
            .scope("openid")
            .build()
            .unwrap();
        let req = AuthorizationRequest::build(&config).unwrap();
        // The '?' and '=' in the redirect URI should be percent-encoded.
        assert!(
            req.url()
                .contains("redirect_uri=https%3A%2F%2Fmyapp.example.com%2Fcallback%3Ffoo%3Dbar"),
            "redirect_uri should be fully URL-encoded: {}",
            req.url(),
        );
    }

    // --- No scope parameter when scopes are empty ---

    #[test]
    fn url_omits_scope_when_empty() {
        let config = OAuthConfig::builder("client", "secret")
            .authorization_endpoint("https://auth.example.com/authorize")
            .token_endpoint("https://auth.example.com/token")
            .redirect_uri("https://myapp.example.com/callback")
            .build()
            .unwrap();
        let req = AuthorizationRequest::build(&config).unwrap();
        assert!(
            !req.url().contains("scope="),
            "URL should not contain scope when no scopes are configured: {}",
            req.url(),
        );
    }

    // --- Accessors ---

    #[test]
    fn state_accessor_returns_valid_state() {
        let config = test_config();
        let req = AuthorizationRequest::build(&config).unwrap();
        assert!(!req.state().value().is_empty());
    }

    #[test]
    fn pkce_accessor_returns_valid_pkce() {
        let config = test_config();
        let req = AuthorizationRequest::build(&config).unwrap();
        assert!(!req.pkce().verifier().is_empty());
        assert!(!req.pkce().challenge().is_empty());
        assert_eq!(req.pkce().method(), "S256");
    }

    // --- Each build produces unique values ---

    #[test]
    fn two_builds_produce_different_state_and_pkce() {
        let config = test_config();
        let a = AuthorizationRequest::build(&config).unwrap();
        let b = AuthorizationRequest::build(&config).unwrap();
        assert_ne!(
            a.state().value(),
            b.state().value(),
            "state values should differ",
        );
        assert_ne!(
            a.pkce().verifier(),
            b.pkce().verifier(),
            "PKCE verifiers should differ",
        );
    }

    // --- Endpoint with existing query parameters ---

    #[test]
    fn url_appends_with_ampersand_when_endpoint_has_query() {
        let config = OAuthConfig::builder("client", "secret")
            .authorization_endpoint("https://auth.example.com/authorize?tenant=abc")
            .token_endpoint("https://auth.example.com/token")
            .redirect_uri("https://myapp.example.com/callback")
            .scope("openid")
            .build()
            .unwrap();
        let req = AuthorizationRequest::build(&config).unwrap();
        // Should use '&' instead of '?' since the endpoint already has query params.
        assert!(
            req.url().contains("?tenant=abc&response_type=code"),
            "URL should append with '&' when endpoint has existing query params: {}",
            req.url(),
        );
    }
}