nylas-types 0.1.1

Type definitions for Nylas API v3
Documentation
//! # Authentication Types
//!
//! OAuth 2.0 authentication, PKCE, and token management types for Nylas API v3.

use crate::common::{EmailAddress, GrantId, Provider};
use serde::{Deserialize, Serialize};

/// OAuth authorization code exchange request
#[derive(Debug, Clone, Serialize)]
pub struct TokenExchangeRequest {
    /// The authorization code from the OAuth callback
    pub code: String,

    /// Must be "authorization_code"
    pub grant_type: String,

    /// Your Nylas client ID
    pub client_id: String,

    /// Optional client secret (for confidential clients)
    #[serde(skip_serializing_if = "Option::is_none")]
    pub client_secret: Option<String>,

    /// PKCE code verifier (if using PKCE)
    #[serde(skip_serializing_if = "Option::is_none")]
    pub code_verifier: Option<String>,

    /// Redirect URI used in the authorization request
    pub redirect_uri: String,
}

/// OAuth token exchange response
#[derive(Debug, Clone, Deserialize)]
pub struct TokenExchangeResponse {
    /// Access token for API requests
    pub access_token: String,

    /// Token type (always "Bearer")
    pub token_type: String,

    /// Grant ID associated with this token
    pub grant_id: GrantId,

    /// Scope granted (comma-separated)
    pub scope: String,

    /// Email address of the authenticated user
    pub email: EmailAddress,

    /// Provider (google, microsoft, etc.)
    pub provider: Provider,
}

/// PKCE code challenge method
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum CodeChallengeMethod {
    /// Plain text (not recommended)
    Plain,

    /// SHA-256 hash (recommended)
    S256,
}

/// PKCE code pair for secure OAuth flows
///
/// Proof Key for Code Exchange (PKCE) prevents authorization code interception attacks.
/// Always use S256 method for production applications.
///
/// # Example
///
/// ```
/// # use nylas_types::auth::PkceCodePair;
/// # fn example() -> Result<(), Box<dyn std::error::Error>> {
/// // Generate a new PKCE code pair
/// let pkce = PkceCodePair::generate()?;
///
/// // Use the challenge in the authorization URL
/// println!("Challenge: {}", pkce.challenge);
///
/// // Store the verifier securely for token exchange
/// println!("Verifier: {}", pkce.verifier);
/// # Ok(())
/// # }
/// ```
#[derive(Debug, Clone)]
pub struct PkceCodePair {
    /// Code verifier (random string, 43-128 characters)
    pub verifier: String,

    /// Code challenge (hash of verifier)
    pub challenge: String,

    /// Challenge method used
    pub method: CodeChallengeMethod,
}

impl PkceCodePair {
    /// Generate a new PKCE code pair using S256 method
    ///
    /// Creates a cryptographically secure random verifier and computes
    /// the SHA-256 challenge.
    ///
    /// # Errors
    ///
    /// Returns an error if random number generation fails.
    ///
    /// # Example
    ///
    /// ```
    /// # use nylas_types::auth::PkceCodePair;
    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
    /// let pkce = PkceCodePair::generate()?;
    /// assert!(!pkce.verifier.is_empty());
    /// assert!(!pkce.challenge.is_empty());
    /// # Ok(())
    /// # }
    /// ```
    pub fn generate() -> Result<Self, PkceError> {
        use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
        use sha2::{Digest, Sha256};

        // Generate 32 random bytes (256 bits)
        let mut verifier_bytes = [0u8; 32];
        getrandom::getrandom(&mut verifier_bytes)
            .map_err(|e| PkceError::RandomGeneration(e.to_string()))?;

        // Base64url encode (no padding)
        let verifier = URL_SAFE_NO_PAD.encode(verifier_bytes);

        // SHA256 hash
        let mut hasher = Sha256::new();
        hasher.update(verifier.as_bytes());
        let challenge_bytes = hasher.finalize();

        // Base64url encode challenge
        let challenge = URL_SAFE_NO_PAD.encode(challenge_bytes);

        Ok(Self {
            verifier,
            challenge,
            method: CodeChallengeMethod::S256,
        })
    }
}

/// PKCE generation errors
#[derive(Debug, thiserror::Error)]
pub enum PkceError {
    /// Failed to generate random bytes
    #[error("Failed to generate random bytes: {0}")]
    RandomGeneration(String),
}

/// Provider detection request
#[derive(Debug, Clone, Serialize)]
pub struct ProviderDetectRequest {
    /// Email address to detect provider for
    pub email: EmailAddress,
}

/// Provider detection response
#[derive(Debug, Clone, Deserialize)]
pub struct ProviderDetectResponse {
    /// Detected provider
    pub provider: Provider,

    /// Whether the provider was detected
    pub detected: bool,

    /// Provider type (oauth or imap)
    #[serde(rename = "type")]
    pub provider_type: String,
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_pkce_generation() {
        let pkce = PkceCodePair::generate().unwrap();
        assert!(!pkce.verifier.is_empty());
        assert!(!pkce.challenge.is_empty());
        assert_eq!(pkce.method, CodeChallengeMethod::S256);
    }

    #[test]
    fn test_pkce_verifier_length() {
        let pkce = PkceCodePair::generate().unwrap();
        // Base64url encoded 32 bytes should be 43 characters (no padding)
        assert_eq!(pkce.verifier.len(), 43);
    }

    #[test]
    fn test_pkce_challenge_deterministic() {
        use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
        use sha2::{Digest, Sha256};

        let pkce = PkceCodePair::generate().unwrap();

        // Manually compute challenge from verifier
        let mut hasher = Sha256::new();
        hasher.update(pkce.verifier.as_bytes());
        let expected_challenge = URL_SAFE_NO_PAD.encode(hasher.finalize());

        assert_eq!(pkce.challenge, expected_challenge);
    }

    #[test]
    fn test_token_exchange_request_serialization() {
        let request = TokenExchangeRequest {
            code: "auth_code_123".to_string(),
            grant_type: "authorization_code".to_string(),
            client_id: "client_123".to_string(),
            client_secret: Some("secret_123".to_string()),
            code_verifier: None,
            redirect_uri: "http://localhost:3000/callback".to_string(),
        };

        let json = serde_json::to_string(&request).unwrap();
        assert!(json.contains("auth_code_123"));
        assert!(json.contains("authorization_code"));
        assert!(json.contains("client_123"));
    }

    #[test]
    fn test_code_challenge_method_serialization() {
        let s256 = CodeChallengeMethod::S256;
        let json = serde_json::to_string(&s256).unwrap();
        assert_eq!(json, "\"s256\"");

        let plain = CodeChallengeMethod::Plain;
        let json = serde_json::to_string(&plain).unwrap();
        assert_eq!(json, "\"plain\"");
    }

    #[test]
    fn test_provider_detect_request_serialization() {
        let request = ProviderDetectRequest {
            email: EmailAddress::new("user@gmail.com").unwrap(),
        };

        let json = serde_json::to_string(&request).unwrap();
        assert!(json.contains("user@gmail.com"));
    }
}