camel-auth 0.21.0

Provider-neutral authentication and claim mapping for rust-camel
Documentation
use crate::jwks::Jwk;
use crate::native_issuer::NativeSigningKey;
use crate::types::AuthError;
use async_trait::async_trait;

pub struct NativeJwksProvider {
    jwk: Jwk,
}

impl std::fmt::Debug for NativeJwksProvider {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("NativeJwksProvider")
            .field("kid", &self.jwk.kid)
            .finish()
    }
}

impl NativeJwksProvider {
    pub fn new(signing_key: NativeSigningKey) -> Result<Self, AuthError> {
        let public_pem = signing_key.public_pem();
        let (n, e) = Self::pem_to_jwk_components(public_pem)?;
        Ok(Self {
            jwk: Jwk {
                kid: signing_key.kid().to_string(),
                kty: "RSA".to_string(),
                alg: Some("RS256".to_string()),
                r#use: Some("sig".to_string()),
                n,
                e,
            },
        })
    }

    fn pem_to_jwk_components(pem: &str) -> Result<(String, String), AuthError> {
        use base64::Engine as _;
        use base64::engine::general_purpose::URL_SAFE_NO_PAD;
        use pkcs1::RsaPublicKey;
        use pkcs1::der::Decode;

        let (label, doc) = pkcs1::der::Document::from_pem(pem)
            .map_err(|e| AuthError::ConfigError(format!("failed to parse public key PEM: {e}")))?;
        if label != "RSA PUBLIC KEY" {
            return Err(AuthError::ConfigError(format!(
                "invalid RSA public key PEM label: {label}"
            )));
        }
        let pub_key = <RsaPublicKey as Decode>::from_der(doc.as_bytes())
            .map_err(|e| AuthError::ConfigError(format!("failed to parse public key PEM: {e}")))?;
        let n = URL_SAFE_NO_PAD.encode(minimal_bigint_bytes(pub_key.modulus.as_bytes()));
        let e = URL_SAFE_NO_PAD.encode(minimal_bigint_bytes(pub_key.public_exponent.as_bytes()));
        Ok((n, e))
    }
}

fn minimal_bigint_bytes(b: &[u8]) -> &[u8] {
    if b.len() > 1 && b[0] == 0x00 {
        &b[1..]
    } else {
        b
    }
}

#[async_trait]
impl crate::jwks::JwksProvider for NativeJwksProvider {
    async fn get_signing_keys(&self) -> Result<Vec<Jwk>, AuthError> {
        Ok(vec![self.jwk.clone()])
    }

    async fn refresh(&self) -> Result<(), AuthError> {
        Ok(())
    }
}

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

    #[tokio::test]
    async fn native_jwks_returns_active_key() {
        let pem = include_str!("../tests/fixtures/test_rsa_private.pem");
        let signing_key = NativeSigningKey::from_pem(pem, "test-kid-1".to_string()).unwrap();
        let provider = NativeJwksProvider::new(signing_key).unwrap();
        let keys = provider.get_signing_keys().await.unwrap();
        assert_eq!(keys.len(), 1);
        assert_eq!(keys[0].kid, "test-kid-1");
        assert_eq!(keys[0].kty, "RSA");
        assert_eq!(keys[0].alg.as_deref(), Some("RS256"));
        assert_eq!(keys[0].r#use.as_deref(), Some("sig"));
    }

    #[tokio::test]
    async fn native_jwks_refresh_is_noop() {
        let pem = include_str!("../tests/fixtures/test_rsa_private.pem");
        let signing_key = NativeSigningKey::from_pem(pem, "test-kid-2".to_string()).unwrap();
        let provider = NativeJwksProvider::new(signing_key).unwrap();
        provider.refresh().await.unwrap();
    }

    #[test]
    fn pem_to_jwk_components_extracts_base64url_n_and_e() {
        use base64::Engine as _;
        use base64::engine::general_purpose::URL_SAFE_NO_PAD;

        let pem = include_str!("../tests/fixtures/test_rsa_private.pem");
        let signing_key = NativeSigningKey::from_pem(pem, "base64url-kid".to_string()).unwrap();
        let public_pem = signing_key.public_pem();
        let (n, e) = NativeJwksProvider::pem_to_jwk_components(public_pem).unwrap();
        assert!(!n.is_empty(), "modulus (n) must be non-empty");
        assert!(!e.is_empty(), "exponent (e) must be non-empty");
        let is_base64url = |s: &str| {
            s.bytes()
                .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
        };
        assert!(is_base64url(&n), "modulus must be base64url: {n}");
        assert!(is_base64url(&e), "exponent must be base64url: {e}");

        let n_bytes = URL_SAFE_NO_PAD.decode(&n).unwrap();
        assert_eq!(n_bytes.len(), 256, "2048-bit modulus must be 256 octets");
        let e_bytes = URL_SAFE_NO_PAD.decode(&e).unwrap();
        assert_eq!(e_bytes.len(), 3, "exponent 65537 must be 3 octets");
    }

    #[tokio::test]
    async fn native_jwks_validates_issued_token() {
        use crate::claims::{ClaimPaths, JsonPointerClaimsMapper};
        use crate::jwt::JwtValidator;
        use crate::native_client_store::{M2mClient, M2mClientSecret, M2mClientStore};
        use crate::native_issuer::NativeTokenIssuer;
        use std::sync::Arc;

        let pem = include_str!("../tests/fixtures/test_rsa_private.pem");
        let signing_key = NativeSigningKey::from_pem(pem, "kid-validate".to_string()).unwrap();
        let provider = Arc::new(NativeJwksProvider::new(signing_key).unwrap());

        let store = M2mClientStore::try_new(vec![M2mClient {
            client_id: "test-client".into(),
            secret: M2mClientSecret::Plaintext { value: "s".into() },
            roles: vec!["test".into()],
            scopes: vec!["read".into()],
        }])
        .unwrap();

        let signing_key_ref = NativeSigningKey::from_pem(pem, "kid-validate".to_string()).unwrap();
        let issuer = NativeTokenIssuer::try_new(
            "https://test.local".to_string(),
            vec!["api".to_string()],
            std::time::Duration::from_secs(300),
            signing_key_ref,
            store,
        )
        .unwrap();

        let token_resp = issuer
            .issue_token("test-client", "s", None, None)
            .await
            .unwrap();

        let claim_paths = ClaimPaths {
            subject: "/sub".to_string(),
            roles: vec!["/roles".to_string()],
            scopes: Some("/scope".to_string()),
        };
        let mapper = Arc::new(JsonPointerClaimsMapper::new(claim_paths));
        let validator = crate::jwt::LocalJwtValidator::new(
            vec!["api".to_string()],
            "https://test.local".to_string(),
            provider.clone(),
            mapper,
        );

        let principal = validator.validate(&token_resp.access_token).await.unwrap();
        assert_eq!(principal.subject, "test-client");
        assert_eq!(principal.roles, vec!["test"]);
    }
}