evidentsource-client 1.0.0-rc1

Rust client for the EvidentSource event sourcing platform
Documentation
//! Authentication support for EvidentSource client
//!
//! This module provides credentials and interceptor types for authenticating
//! with EvidentSource servers.
//!
//! # Supported Authentication Methods
//!
//! ## Bearer Token (OAuth2/JWT)
//!
//! For production use with OAuth2/JWT authentication. The token is sent in the
//! standard `Authorization: Bearer <token>` header.
//!
//! **Important**: Bearer tokens require TLS - never send tokens over insecure connections.
//!
//! ```rust,ignore
//! use evidentsource_client::{EvidentSource, Credentials};
//!
//! let es = EvidentSource::connect_with_auth(
//!     "https://api.example.com:50051",
//!     Credentials::BearerToken(my_jwt_token),
//! ).await?;
//! ```
//!
//! ## DevMode
//!
//! For local development and testing, use DevMode credentials:
//!
//! ```rust,ignore
//! use evidentsource_client::{EvidentSource, Credentials, DevModeCredentials};
//!
//! let es = EvidentSource::connect_with_auth(
//!     "http://localhost:50051",
//!     Credentials::DevMode(
//!         DevModeCredentials::new("dev-user@example.com")
//!             .with_email("dev@example.com")
//!             .with_display_name("Developer")
//!     ),
//! ).await?;
//! ```

use tonic::service::Interceptor;
use tonic::Status;

/// Credentials for authenticating with EvidentSource server.
#[derive(Clone, Debug)]
pub enum Credentials {
    /// Bearer token for OAuth2/JWT authentication.
    ///
    /// The token is sent in the standard `Authorization: Bearer <token>` header.
    ///
    /// **Important**: Bearer tokens should only be sent over TLS connections.
    BearerToken(String),

    /// DevMode credentials for local development.
    ///
    /// Headers sent:
    /// - `x-dev-subject` (required)
    /// - `x-dev-email` (optional)
    /// - `x-dev-display-name` (optional)
    /// - `x-dev-grants` (optional, JSON)
    DevMode(DevModeCredentials),

    /// No authentication.
    ///
    /// Only works if the server has `allow_anonymous=true`.
    None,
}

/// DevMode credentials for local development and testing.
#[derive(Clone, Debug, Default)]
pub struct DevModeCredentials {
    /// Unique identifier for the user (required).
    pub subject: String,
    /// Email address (optional).
    pub email: Option<String>,
    /// Display name (optional).
    pub display_name: Option<String>,
    /// Authorization grants as JSON string (optional).
    ///
    /// Format: `{"global": [...], "databases": {...}, "all_databases": [...]}`
    pub grants: Option<String>,
}

impl DevModeCredentials {
    /// Create new DevMode credentials with the given subject.
    pub fn new(subject: impl Into<String>) -> Self {
        Self {
            subject: subject.into(),
            ..Default::default()
        }
    }

    /// Set the email address.
    pub fn with_email(mut self, email: impl Into<String>) -> Self {
        self.email = Some(email.into());
        self
    }

    /// Set the display name.
    pub fn with_display_name(mut self, display_name: impl Into<String>) -> Self {
        self.display_name = Some(display_name.into());
        self
    }

    /// Set the grants as a JSON string.
    ///
    /// Format: `{"global": [...], "databases": {...}, "all_databases": [...]}`
    pub fn with_grants(mut self, grants: impl Into<String>) -> Self {
        self.grants = Some(grants.into());
        self
    }
}

/// Tonic interceptor that injects authentication headers into gRPC requests.
#[derive(Clone, Debug)]
pub struct AuthInterceptor {
    credentials: Credentials,
}

impl AuthInterceptor {
    /// Create a new auth interceptor with the given credentials.
    pub fn new(credentials: Credentials) -> Self {
        Self { credentials }
    }
}

impl Interceptor for AuthInterceptor {
    fn call(&mut self, mut request: tonic::Request<()>) -> Result<tonic::Request<()>, Status> {
        let metadata = request.metadata_mut();

        match &self.credentials {
            Credentials::BearerToken(token) => {
                if let Ok(value) = format!("Bearer {}", token).parse() {
                    metadata.insert("authorization", value);
                }
            }
            Credentials::DevMode(creds) => {
                if let Ok(value) = creds.subject.parse() {
                    metadata.insert("x-dev-subject", value);
                }
                if let Some(email) = &creds.email {
                    if let Ok(value) = email.parse() {
                        metadata.insert("x-dev-email", value);
                    }
                }
                if let Some(name) = &creds.display_name {
                    if let Ok(value) = name.parse() {
                        metadata.insert("x-dev-display-name", value);
                    }
                }
                if let Some(grants) = &creds.grants {
                    if let Ok(value) = grants.parse() {
                        metadata.insert("x-dev-grants", value);
                    }
                }
            }
            Credentials::None => {}
        }

        Ok(request)
    }
}

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

    #[test]
    fn test_dev_mode_credentials_builder() {
        let creds = DevModeCredentials::new("user@example.com")
            .with_email("user@example.com")
            .with_display_name("Test User")
            .with_grants(r#"{"global": ["reader"]}"#);

        assert_eq!(creds.subject, "user@example.com");
        assert_eq!(creds.email, Some("user@example.com".to_string()));
        assert_eq!(creds.display_name, Some("Test User".to_string()));
        assert_eq!(creds.grants, Some(r#"{"global": ["reader"]}"#.to_string()));
    }

    #[test]
    fn test_credentials_clone() {
        let creds = Credentials::BearerToken("test-token".to_string());
        let cloned = creds.clone();
        assert!(matches!(cloned, Credentials::BearerToken(t) if t == "test-token"));
    }
}