camel-auth 0.12.0

Provider-neutral authentication and claim mapping for rust-camel
Documentation
use camel_api::security_policy::Principal;
use serde::{Deserialize, Serialize};

use crate::types::AuthError;

pub trait ClaimsMapper: Send + Sync {
    fn to_principal(&self, claims: &serde_json::Value) -> Result<Principal, AuthError>;
}

#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
pub struct ClaimPaths {
    pub subject: String,
    pub roles: Vec<String>,
    pub scopes: Option<String>,
}

pub struct JsonPointerClaimsMapper {
    subject_path: String,
    role_paths: Vec<String>,
    scope_path: Option<String>,
}

impl JsonPointerClaimsMapper {
    pub fn new(paths: ClaimPaths) -> Self {
        Self {
            subject_path: paths.subject,
            role_paths: paths.roles,
            scope_path: paths.scopes,
        }
    }
}

impl ClaimsMapper for JsonPointerClaimsMapper {
    fn to_principal(&self, claims: &serde_json::Value) -> Result<Principal, AuthError> {
        let subject = claims
            .pointer(&self.subject_path)
            .and_then(|v| v.as_str())
            .filter(|s| !s.is_empty())
            .ok_or_else(|| {
                AuthError::TokenInvalid(format!(
                    "missing or empty subject at JSON pointer {}",
                    self.subject_path
                ))
            })?
            .to_string();

        let mut roles: Vec<String> = Vec::new();
        for path in &self.role_paths {
            if let Some(arr) = claims.pointer(path).and_then(|v| v.as_array()) {
                roles.extend(arr.iter().filter_map(|v| v.as_str()).map(String::from));
            }
        }
        roles.sort();
        roles.dedup();

        let scopes = self
            .scope_path
            .as_ref()
            .and_then(|p| claims.pointer(p))
            .map(|v| match v {
                serde_json::Value::String(s) => s.split_whitespace().map(String::from).collect(),
                serde_json::Value::Array(arr) => arr
                    .iter()
                    .filter_map(|v| v.as_str().map(String::from))
                    .collect(),
                _ => Vec::new(),
            })
            .unwrap_or_default();

        Ok(Principal {
            subject,
            issuer: claims
                .pointer("/iss")
                .and_then(|v| v.as_str())
                .unwrap_or("")
                .to_string(),
            audience: claims
                .pointer("/aud")
                .and_then(|v| match v {
                    serde_json::Value::String(s) => Some(vec![s.clone()]),
                    serde_json::Value::Array(arr) => Some(
                        arr.iter()
                            .filter_map(|v| v.as_str())
                            .map(String::from)
                            .collect(),
                    ),
                    _ => None,
                })
                .unwrap_or_default(),
            roles,
            scopes,
            claims: claims.clone(),
        })
    }
}

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

    fn mapper(paths: ClaimPaths) -> JsonPointerClaimsMapper {
        JsonPointerClaimsMapper::new(paths)
    }

    fn default_paths() -> ClaimPaths {
        ClaimPaths {
            subject: "/sub".into(),
            roles: vec!["/groups".into()],
            scopes: None,
        }
    }

    #[test]
    fn extracts_subject() {
        let claims = json!({"sub": "user-1"});
        let principal = mapper(default_paths()).to_principal(&claims).unwrap();
        assert_eq!(principal.subject, "user-1");
    }

    #[test]
    fn missing_subject_returns_error() {
        let claims = json!({"no_sub": "x"});
        let result = mapper(default_paths()).to_principal(&claims);
        assert!(result.is_err());
    }

    #[test]
    fn empty_subject_returns_error() {
        let claims = json!({"sub": ""});
        let result = mapper(default_paths()).to_principal(&claims);
        assert!(result.is_err());
    }

    #[test]
    fn extracts_roles_from_single_path() {
        let claims = json!({
            "sub": "u",
            "groups": ["admin", "user"]
        });
        let principal = mapper(default_paths()).to_principal(&claims).unwrap();
        assert!(principal.has_role("admin"));
        assert!(principal.has_role("user"));
    }

    #[test]
    fn extracts_roles_from_multiple_paths_and_deduplicates() {
        let paths = ClaimPaths {
            subject: "/sub".into(),
            roles: vec!["/groups".into(), "/app_roles".into()],
            scopes: None,
        };
        let claims = json!({
            "sub": "u",
            "groups": ["admin"],
            "app_roles": ["admin", "editor"]
        });
        let principal = mapper(paths).to_principal(&claims).unwrap();
        assert_eq!(principal.roles, vec!["admin", "editor"]);
    }

    #[test]
    fn no_role_paths_produces_empty_roles() {
        let paths = ClaimPaths {
            subject: "/sub".into(),
            roles: vec![],
            scopes: None,
        };
        let claims = json!({"sub": "u"});
        let principal = mapper(paths).to_principal(&claims).unwrap();
        assert!(principal.roles.is_empty());
    }

    #[test]
    fn extracts_scopes_from_space_separated_string() {
        let paths = ClaimPaths {
            subject: "/sub".into(),
            roles: vec![],
            scopes: Some("/scope".into()),
        };
        let claims = json!({"sub": "u", "scope": "read write"});
        let principal = mapper(paths).to_principal(&claims).unwrap();
        assert_eq!(principal.scopes, vec!["read", "write"]);
    }

    #[test]
    fn extracts_scopes_from_array() {
        let paths = ClaimPaths {
            subject: "/sub".into(),
            roles: vec![],
            scopes: Some("/scope".into()),
        };
        let claims = json!({"sub": "u", "scope": ["read", "write", "admin"]});
        let principal = mapper(paths).to_principal(&claims).unwrap();
        assert_eq!(principal.scopes, vec!["read", "write", "admin"]);
    }

    #[test]
    fn claims_stored_in_principal() {
        let claims = json!({"sub": "u", "custom": "value"});
        let principal = mapper(default_paths()).to_principal(&claims).unwrap();
        assert_eq!(principal.claims["custom"], "value");
    }

    #[test]
    fn custom_subject_path() {
        let paths = ClaimPaths {
            subject: "/preferred_username".into(),
            roles: vec![],
            scopes: None,
        };
        let claims = json!({"preferred_username": "alice"});
        let principal = mapper(paths).to_principal(&claims).unwrap();
        assert_eq!(principal.subject, "alice");
    }
}