sunbeam-g2v 0.3.1

Sunbeam Service Framework - A ConnectRPC-based framework for building microservices
Documentation
//! Authentication middleware.

#[cfg(feature = "jwt")]
pub mod jwt;
#[cfg(all(feature = "keto", feature = "axum"))]
/// Keto.
pub mod keto;
#[cfg(feature = "keto")]
/// Keto proto.
pub mod keto_proto;

#[cfg(feature = "jwt")]
/// Re-export JWT types.
pub use jwt::{JwtLayer, JwtValidator, extract_jwt_claims};
#[cfg(all(feature = "keto", feature = "axum"))]
/// Re-export Keto types.
pub use keto::{KetoClient, KetoConfig, KetoLayer, Permission, RelationTuple, Subject};

/// JWT claims.
///
/// Kept in the core auth module so `AuthContext` can optionally carry claims
/// even when the JWT middleware itself is not enabled.
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
pub struct JwtClaims {
    /// Subject (usually user ID).
    pub sub: String,
    /// Issued at timestamp.
    pub iat: i64,
    /// Expiration timestamp.
    pub exp: i64,
    /// Issuer.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub iss: Option<String>,
    /// Audience.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub aud: Option<String>,
    /// Custom claims.
    #[serde(flatten)]
    pub extra: std::collections::HashMap<String, serde_json::Value>,
}

impl JwtClaims {
    /// Get a claim by key.
    pub fn get(&self, key: &str) -> Option<&serde_json::Value> {
        self.extra.get(key)
    }

    /// Check if the token is expired.
    pub fn is_expired(&self) -> bool {
        let now = chrono::Utc::now().timestamp();
        self.exp < now
    }
}

/// Authentication strategy.
#[derive(Debug, Clone, Default)]
pub enum AuthStrategy {
    /// JWT authentication.
    #[cfg(feature = "jwt")]
    Jwt(JwtValidator),
    /// Keto authentication.
    #[cfg(all(feature = "keto", feature = "axum"))]
    Keto(KetoClient),
    /// No authentication.
    #[default]
    None,
}

/// Authentication context.
#[derive(Debug, Clone)]
pub struct AuthContext {
    /// The authenticated subject (user ID).
    pub subject: Option<String>,
    /// JWT claims (if using JWT auth).
    pub claims: Option<JwtClaims>,
    /// Whether the request is authenticated.
    pub is_authenticated: bool,
}

impl AuthContext {
    /// Create a new unauthenticated context.
    pub fn unauthenticated() -> Self {
        Self {
            subject: None,
            claims: None,
            is_authenticated: false,
        }
    }

    /// Create a new authenticated context.
    pub fn authenticated(subject: impl Into<String>, claims: Option<JwtClaims>) -> Self {
        Self {
            subject: Some(subject.into()),
            claims,
            is_authenticated: true,
        }
    }

    /// Check if authenticated.
    pub fn is_authenticated(&self) -> bool {
        self.is_authenticated
    }

    /// Get the subject.
    pub fn subject(&self) -> Option<&String> {
        self.subject.as_ref()
    }
}

impl Default for AuthContext {
    fn default() -> Self {
        Self::unauthenticated()
    }
}

/// Authentication middleware layer (stub: no Tower `Layer` impl yet).
#[allow(dead_code)]
#[derive(Debug, Clone)]
pub struct AuthLayer {
    strategy: AuthStrategy,
}

impl AuthLayer {
    /// Create a new authentication layer with the given strategy.
    pub fn new(strategy: AuthStrategy) -> Self {
        Self { strategy }
    }

    /// Create a new JWT authentication layer.
    #[cfg(feature = "jwt")]
    pub fn jwt(validator: jwt::JwtValidator) -> Self {
        Self::new(AuthStrategy::Jwt(validator))
    }

    /// Create a new Keto authentication layer.
    #[cfg(all(feature = "keto", feature = "axum"))]
    pub fn keto(client: keto::KetoClient) -> Self {
        Self::new(AuthStrategy::Keto(client))
    }

    /// Create a new authentication layer with no authentication.
    pub fn none() -> Self {
        Self::new(AuthStrategy::None)
    }
}

/// Extract the raw Bearer token string from request headers.
///
/// This is a lower-level helper; prefer reading [`AuthContext`] from request
/// extensions (set by [`super::jwt::JwtLayer`]) in middleware.
pub fn extract_subject(headers: &http::HeaderMap) -> Option<String> {
    headers
        .get("Authorization")
        .and_then(|v| v.to_str().ok())
        .and_then(|v| v.strip_prefix("Bearer ").map(str::to_string))
}

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

    #[test]
    fn test_auth_context_unauthenticated() {
        let ctx = AuthContext::unauthenticated();
        assert!(!ctx.is_authenticated());
        assert!(ctx.subject().is_none());
    }

    #[test]
    fn test_auth_context_authenticated() {
        let ctx = AuthContext::authenticated("user-1", None);
        assert!(ctx.is_authenticated());
        assert_eq!(ctx.subject(), Some(&"user-1".to_string()));
    }

    #[test]
    fn test_auth_layer_new() {
        let layer = AuthLayer::none();
        match layer.strategy {
            AuthStrategy::None => {}
            #[allow(unreachable_patterns)]
            _ => panic!("Expected None strategy"),
        }
    }

    #[test]
    fn test_extract_subject_bearer() {
        let mut headers = http::HeaderMap::new();
        headers.insert(
            "Authorization",
            http::HeaderValue::from_static("Bearer mytoken"),
        );
        assert_eq!(extract_subject(&headers), Some("mytoken".to_string()));
    }

    #[test]
    fn test_extract_subject_non_bearer() {
        let mut headers = http::HeaderMap::new();
        headers.insert(
            "Authorization",
            http::HeaderValue::from_static("Basic credentials"),
        );
        assert_eq!(extract_subject(&headers), None);
    }

    #[test]
    fn test_extract_subject_missing() {
        let headers = http::HeaderMap::new();
        assert_eq!(extract_subject(&headers), None);
    }
}