sunbeam-g2v 0.3.3

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 = "axum")]
/// Hydra token introspection.
pub mod introspection;

#[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};
#[cfg(feature = "axum")]
/// Re-export introspection types.
pub use introspection::{IntrospectionClient, IntrospectionLayer, IntrospectionResponse};

/// 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>,
    /// User email address (if provided by the identity provider).
    pub email: Option<String>,
    /// User display name (if provided by the identity provider).
    pub name: Option<String>,
    /// User roles (if provided by the identity provider).
    pub roles: Vec<String>,
    /// OAuth2 scope(s) associated with the token (space-separated per RFC 7662).
    pub scope: Option<String>,
    /// OAuth2 client ID that requested the token.
    pub client_id: Option<String>,
    /// Token expiration timestamp (if provided by the identity provider).
    pub exp: Option<i64>,
    /// 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,
            email: None,
            name: None,
            roles: Vec::new(),
            scope: None,
            client_id: None,
            exp: 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()),
            email: None,
            name: None,
            roles: Vec::new(),
            scope: None,
            client_id: None,
            exp: None,
            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()
    }

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

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

    /// Get the roles.
    pub fn roles(&self) -> &[String] {
        &self.roles
    }

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

    /// Get the OAuth2 client ID.
    pub fn client_id(&self) -> Option<&String> {
        self.client_id.as_ref()
    }

    /// 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_name(mut self, name: impl Into<String>) -> Self {
        self.name = Some(name.into());
        self
    }

    /// Set the roles.
    pub fn with_roles(mut self, roles: Vec<String>) -> Self {
        self.roles = roles;
        self
    }

    /// Set the OAuth2 scope(s).
    pub fn with_scope(mut self, scope: impl Into<String>) -> Self {
        self.scope = Some(scope.into());
        self
    }

    /// Set the OAuth2 client ID.
    pub fn with_client_id(mut self, client_id: impl Into<String>) -> Self {
        self.client_id = Some(client_id.into());
        self
    }

    /// Set the token expiration timestamp.
    pub fn with_exp(mut self, exp: i64) -> Self {
        self.exp = Some(exp);
        self
    }
}

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);
    }
}