Skip to main content

camel_auth/
native_issuer.rs

1use crate::native_client_store::M2mClientStore;
2use crate::types::AuthError;
3use jsonwebtoken::{Algorithm, EncodingKey, Header, encode};
4use serde::Serialize;
5use std::fmt;
6use std::sync::atomic::{AtomicU64, Ordering};
7use std::time::Duration;
8
9#[derive(Debug, thiserror::Error)]
10pub enum IssuerError {
11    #[error("invalid_client")]
12    InvalidClient,
13    #[error("invalid_scope")]
14    InvalidScope,
15    #[error("invalid_audience")]
16    InvalidAudience,
17    #[error("unsupported_grant_type")]
18    UnsupportedGrantType,
19    #[error("{0}")]
20    Other(String),
21}
22
23impl From<AuthError> for IssuerError {
24    fn from(e: AuthError) -> Self {
25        IssuerError::Other(e.to_string())
26    }
27}
28
29pub struct NativeSigningKey {
30    encoding_key: EncodingKey,
31    kid: String,
32    public_pem: String,
33}
34
35impl fmt::Debug for NativeSigningKey {
36    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
37        f.debug_struct("NativeSigningKey")
38            .field("kid", &self.kid)
39            .finish()
40    }
41}
42
43impl NativeSigningKey {
44    pub fn from_pem(private_pem: &str, kid: String) -> Result<Self, AuthError> {
45        if private_pem.is_empty() {
46            return Err(AuthError::ConfigError("signing key PEM is empty".into()));
47        }
48        let encoding_key = EncodingKey::from_rsa_pem(private_pem.as_bytes())
49            .map_err(|e| AuthError::ConfigError(format!("invalid signing key PEM: {e}")))?;
50        let public_pem = Self::extract_public_pem(private_pem)?;
51        Ok(Self {
52            encoding_key,
53            kid,
54            public_pem,
55        })
56    }
57
58    fn extract_public_pem(private_pem: &str) -> Result<String, AuthError> {
59        use pkcs1::der::Decode;
60        use pkcs1::{LineEnding, RsaPrivateKey, RsaPublicKey};
61
62        let (label, doc) = pkcs1::der::SecretDocument::from_pem(private_pem)
63            .map_err(|e| AuthError::ConfigError(format!("failed to parse RSA private key: {e}")))?;
64        let der_bytes = doc.as_bytes();
65
66        let rsa_der: &[u8] = match label {
67            "RSA PRIVATE KEY" => der_bytes,
68            "PRIVATE KEY" => {
69                let pki = <pkcs8::PrivateKeyInfo as Decode>::from_der(der_bytes).map_err(|e| {
70                    AuthError::ConfigError(format!("failed to parse RSA private key: {e}"))
71                })?;
72                pki.private_key
73            }
74            other => {
75                return Err(AuthError::ConfigError(format!(
76                    "unsupported RSA key PEM label: {other}"
77                )));
78            }
79        };
80
81        let private_key = <RsaPrivateKey as Decode>::from_der(rsa_der)
82            .map_err(|e| AuthError::ConfigError(format!("failed to parse RSA private key: {e}")))?;
83
84        let public_key = RsaPublicKey {
85            modulus: private_key.modulus,
86            public_exponent: private_key.public_exponent,
87        };
88        let public_doc = <pkcs1::der::Document as TryFrom<&RsaPublicKey>>::try_from(&public_key)
89            .map_err(|e| AuthError::ConfigError(format!("failed to encode public key: {e}")))?;
90        public_doc
91            .to_pem("RSA PUBLIC KEY", LineEnding::LF)
92            .map_err(|e| AuthError::ConfigError(format!("failed to encode public key: {e}")))
93    }
94
95    pub fn kid(&self) -> &str {
96        &self.kid
97    }
98
99    pub fn public_pem(&self) -> &str {
100        &self.public_pem
101    }
102
103    pub(crate) fn encoding_key(&self) -> &EncodingKey {
104        &self.encoding_key
105    }
106}
107
108#[derive(Debug, Clone, Serialize)]
109struct NativeTokenClaims {
110    iss: String,
111    sub: String,
112    aud: serde_json::Value,
113    iat: u64,
114    exp: u64,
115    jti: String,
116    scope: String,
117    roles: Vec<String>,
118}
119
120#[derive(Debug)]
121#[non_exhaustive]
122pub struct TokenResponse {
123    pub access_token: String,
124    pub token_type: String,
125    pub expires_in: u64,
126    pub scope: String,
127}
128
129pub struct NativeTokenIssuer {
130    issuer: String,
131    audience: Vec<String>,
132    ttl: Duration,
133    signing_key: NativeSigningKey,
134    client_store: M2mClientStore,
135    jti_counter: AtomicU64,
136}
137
138impl fmt::Debug for NativeTokenIssuer {
139    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
140        f.debug_struct("NativeTokenIssuer")
141            .field("issuer", &self.issuer)
142            .field("audience", &self.audience)
143            .field("ttl_secs", &self.ttl.as_secs())
144            .field("signing_key", &self.signing_key)
145            .finish()
146    }
147}
148
149impl NativeTokenIssuer {
150    pub fn try_new(
151        issuer: String,
152        audience: Vec<String>,
153        ttl: Duration,
154        signing_key: NativeSigningKey,
155        client_store: M2mClientStore,
156    ) -> Result<Self, AuthError> {
157        if audience.is_empty() {
158            return Err(AuthError::ConfigError(
159                "native issuer requires at least one audience".into(),
160            ));
161        }
162        if ttl.is_zero() {
163            return Err(AuthError::ConfigError(
164                "native issuer token_ttl_secs must be greater than 0".into(),
165            ));
166        }
167        if ttl > Duration::from_secs(3600) {
168            return Err(AuthError::ConfigError(format!(
169                "native issuer token_ttl_secs {} exceeds maximum 3600",
170                ttl.as_secs()
171            )));
172        }
173        Ok(Self {
174            issuer,
175            audience,
176            ttl,
177            signing_key,
178            client_store,
179            jti_counter: AtomicU64::new(1),
180        })
181    }
182
183    pub async fn issue_token(
184        &self,
185        client_id: &str,
186        client_secret: &str,
187        requested_scope: Option<&str>,
188        requested_audience: Option<&str>,
189    ) -> Result<TokenResponse, IssuerError> {
190        let client = self
191            .client_store
192            .lookup(client_id, client_secret)
193            .ok_or(IssuerError::InvalidClient)?;
194
195        let granted_scopes = match requested_scope {
196            Some(req) => {
197                let requested: Vec<&str> = req.split_whitespace().collect();
198                for s in &requested {
199                    if !client.scopes.iter().any(|cs| cs == *s) {
200                        return Err(IssuerError::InvalidScope);
201                    }
202                }
203                requested.iter().map(|s| s.to_string()).collect::<Vec<_>>()
204            }
205            None => client.scopes.to_vec(),
206        };
207
208        let aud = match requested_audience {
209            Some(req) => {
210                if !self.audience.iter().any(|a| a == req) {
211                    return Err(IssuerError::InvalidAudience);
212                }
213                serde_json::Value::String(req.to_string())
214            }
215            None => {
216                if self.audience.len() == 1 {
217                    serde_json::Value::String(self.audience[0].clone())
218                } else {
219                    serde_json::Value::Array(
220                        self.audience
221                            .iter()
222                            .map(|a| serde_json::Value::String(a.clone()))
223                            .collect(),
224                    )
225                }
226            }
227        };
228
229        let now = std::time::SystemTime::now()
230            .duration_since(std::time::UNIX_EPOCH)
231            .map_err(|e| IssuerError::Other(format!("system clock error: {e}")))?
232            .as_secs();
233
234        let jti = format!("{:016x}", self.jti_counter.fetch_add(1, Ordering::Relaxed));
235
236        let claims = NativeTokenClaims {
237            iss: self.issuer.clone(),
238            sub: client.client_id.to_string(),
239            aud,
240            iat: now,
241            exp: now + self.ttl.as_secs(),
242            jti,
243            scope: granted_scopes.join(" "),
244            roles: client.roles.to_vec(),
245        };
246
247        let mut header = Header::new(Algorithm::RS256);
248        header.kid = Some(self.signing_key.kid().to_string());
249
250        let token = encode(&header, &claims, self.signing_key.encoding_key())
251            .map_err(|e| IssuerError::Other(format!("JWT encoding failed: {e}")))?;
252
253        Ok(TokenResponse {
254            access_token: token,
255            token_type: "Bearer".to_string(),
256            expires_in: self.ttl.as_secs(),
257            scope: claims.scope.clone(),
258        })
259    }
260
261    pub async fn handle_token_request(&self, body: &str) -> Result<TokenResponse, IssuerError> {
262        let params: std::collections::HashMap<String, String> = serde_urlencoded::from_str(body)
263            .map_err(|e| IssuerError::Other(format!("invalid request body: {e}")))?;
264
265        let grant_type = params.get("grant_type").map(|s| s.as_str()).unwrap_or("");
266        if grant_type != "client_credentials" {
267            return Err(IssuerError::UnsupportedGrantType);
268        }
269
270        let client_id = params.get("client_id").ok_or(IssuerError::InvalidClient)?;
271        let client_secret = params
272            .get("client_secret")
273            .ok_or(IssuerError::InvalidClient)?;
274        let scope = params.get("scope").map(|s| s.as_str());
275        let audience = params
276            .get("audience")
277            .or_else(|| params.get("resource"))
278            .map(|s| s.as_str());
279
280        self.issue_token(client_id, client_secret, scope, audience)
281            .await
282    }
283
284    pub fn signing_key(&self) -> &NativeSigningKey {
285        &self.signing_key
286    }
287
288    pub fn issuer(&self) -> &str {
289        &self.issuer
290    }
291
292    pub fn audience(&self) -> &[String] {
293        &self.audience
294    }
295
296    pub fn ttl(&self) -> Duration {
297        self.ttl
298    }
299
300    pub fn client_store(&self) -> &M2mClientStore {
301        &self.client_store
302    }
303}
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308    use crate::native_client_store::{M2mClient, M2mClientSecret, M2mClientStore};
309    use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode};
310    use serde_json::json;
311
312    fn test_store() -> M2mClientStore {
313        M2mClientStore::try_new(vec![M2mClient {
314            client_id: "billing".into(),
315            secret: M2mClientSecret::Plaintext {
316                value: "secret".into(),
317            },
318            roles: vec!["billing".into()],
319            scopes: vec!["orders:read".into(), "orders:write".into()],
320        }])
321        .unwrap()
322    }
323
324    fn test_issuer() -> NativeTokenIssuer {
325        let pem = include_str!("../tests/fixtures/test_rsa_private.pem");
326        let signing_key = NativeSigningKey::from_pem(pem, "test-kid".to_string()).unwrap();
327        let store = test_store();
328        NativeTokenIssuer::try_new(
329            "https://orders.local".to_string(),
330            vec!["orders-api".to_string()],
331            std::time::Duration::from_secs(900),
332            signing_key,
333            store,
334        )
335        .unwrap()
336    }
337
338    #[test]
339    fn signing_key_loads_pem() {
340        let pem = include_str!("../tests/fixtures/test_rsa_private.pem");
341        let key = NativeSigningKey::from_pem(pem, "test-key-1".to_string()).unwrap();
342        assert_eq!(key.kid(), "test-key-1");
343    }
344
345    #[test]
346    fn signing_key_loads_pkcs1_pem() {
347        let pem = include_str!("../tests/fixtures/test_rsa_private_pkcs1.pem");
348        let key = NativeSigningKey::from_pem(pem, "pkcs1-kid".to_string()).unwrap();
349        assert_eq!(key.kid(), "pkcs1-kid");
350        assert!(
351            key.public_pem()
352                .starts_with("-----BEGIN RSA PUBLIC KEY-----"),
353            "expected PKCS#1 public key PEM, got: {}",
354            key.public_pem()
355        );
356    }
357
358    #[test]
359    fn public_pem_is_pkcs1_rsa_public_key() {
360        let pem = include_str!("../tests/fixtures/test_rsa_private.pem");
361        let key = NativeSigningKey::from_pem(pem, "pkcs1-kid".to_string()).unwrap();
362        let public_pem = key.public_pem();
363        assert!(!public_pem.trim().is_empty());
364        assert!(
365            public_pem.starts_with("-----BEGIN RSA PUBLIC KEY-----"),
366            "expected PKCS#1 public key PEM, got: {public_pem}"
367        );
368        assert!(public_pem.contains("-----END RSA PUBLIC KEY-----"));
369    }
370
371    #[test]
372    fn signing_key_rejects_empty_pem() {
373        let result = NativeSigningKey::from_pem("", "key-1".to_string());
374        assert!(result.is_err());
375    }
376
377    #[test]
378    fn issuer_try_new_rejects_ttl_above_3600() {
379        let pem = include_str!("../tests/fixtures/test_rsa_private.pem");
380        let signing_key = NativeSigningKey::from_pem(pem, "k".to_string()).unwrap();
381        let store = M2mClientStore::try_new(vec![]).unwrap();
382        let result = NativeTokenIssuer::try_new(
383            "https://test.local".into(),
384            vec!["orders-api".into()],
385            std::time::Duration::from_secs(4000),
386            signing_key,
387            store,
388        );
389        let msg = format!("{}", result.unwrap_err());
390        assert!(msg.contains("3600"));
391    }
392
393    #[test]
394    fn issuer_try_new_accepts_ttl_at_3600() {
395        let pem = include_str!("../tests/fixtures/test_rsa_private.pem");
396        let signing_key = NativeSigningKey::from_pem(pem, "k".to_string()).unwrap();
397        let store = M2mClientStore::try_new(vec![]).unwrap();
398        let result = NativeTokenIssuer::try_new(
399            "https://test.local".into(),
400            vec!["orders-api".into()],
401            std::time::Duration::from_secs(3600),
402            signing_key,
403            store,
404        );
405        assert!(result.is_ok());
406    }
407
408    #[test]
409    fn issuer_try_new_rejects_empty_audience() {
410        let pem = include_str!("../tests/fixtures/test_rsa_private.pem");
411        let signing_key = NativeSigningKey::from_pem(pem, "k".to_string()).unwrap();
412        let store = M2mClientStore::try_new(vec![]).unwrap();
413        let result = NativeTokenIssuer::try_new(
414            "https://test.local".into(),
415            vec![],
416            std::time::Duration::from_secs(900),
417            signing_key,
418            store,
419        );
420        let msg = format!("{}", result.unwrap_err());
421        assert!(msg.contains("audience"));
422    }
423
424    #[test]
425    fn issuer_try_new_rejects_zero_ttl() {
426        let pem = include_str!("../tests/fixtures/test_rsa_private.pem");
427        let signing_key = NativeSigningKey::from_pem(pem, "k".to_string()).unwrap();
428        let store = M2mClientStore::try_new(vec![]).unwrap();
429        let result = NativeTokenIssuer::try_new(
430            "https://test.local".into(),
431            vec!["api".into()],
432            Duration::ZERO,
433            signing_key,
434            store,
435        );
436        let msg = format!("{}", result.unwrap_err());
437        assert!(msg.contains("greater than 0"));
438    }
439
440    #[tokio::test]
441    async fn issuer_issues_valid_jwt() {
442        let issuer = test_issuer();
443        let response = issuer
444            .issue_token("billing", "secret", None, None)
445            .await
446            .unwrap();
447        assert_eq!(response.token_type, "Bearer");
448        assert_eq!(response.expires_in, 900);
449        assert!(!response.access_token.is_empty());
450
451        let pub_pem = include_str!("../tests/fixtures/test_rsa_public.pem");
452        let mut validation = Validation::new(Algorithm::RS256);
453        validation.set_issuer(&["https://orders.local"]);
454        validation.set_audience(&["orders-api"]);
455        let decoded = decode::<serde_json::Value>(
456            &response.access_token,
457            &DecodingKey::from_rsa_pem(pub_pem.as_bytes()).unwrap(),
458            &validation,
459        )
460        .unwrap();
461        let claims = decoded.claims;
462        assert_eq!(claims["sub"], "billing");
463        assert_eq!(claims["scope"], "orders:read orders:write");
464        assert_eq!(claims["roles"], json!(["billing"]));
465        assert!(claims["jti"].is_string());
466    }
467
468    #[tokio::test]
469    async fn issuer_narrows_scopes() {
470        let issuer = test_issuer();
471        let response = issuer
472            .issue_token("billing", "secret", Some("orders:read"), None)
473            .await
474            .unwrap();
475        let pub_pem = include_str!("../tests/fixtures/test_rsa_public.pem");
476        let mut validation = Validation::new(Algorithm::RS256);
477        validation.set_issuer(&["https://orders.local"]);
478        validation.set_audience(&["orders-api"]);
479        let decoded = decode::<serde_json::Value>(
480            &response.access_token,
481            &DecodingKey::from_rsa_pem(pub_pem.as_bytes()).unwrap(),
482            &validation,
483        )
484        .unwrap();
485        assert_eq!(decoded.claims["scope"], "orders:read");
486    }
487
488    #[tokio::test]
489    async fn issuer_rejects_scope_escalation() {
490        let issuer = test_issuer();
491        let result = issuer
492            .issue_token("billing", "secret", Some("admin:super"), None)
493            .await;
494        assert!(result.is_err());
495        let msg = format!("{}", result.unwrap_err());
496        assert!(msg.contains("invalid_scope") || msg.contains("scope"));
497    }
498
499    #[tokio::test]
500    async fn issuer_rejects_bad_credentials() {
501        let issuer = test_issuer();
502        let result = issuer
503            .issue_token("billing", "wrong-secret", None, None)
504            .await;
505        assert!(result.is_err());
506    }
507
508    #[tokio::test]
509    async fn issuer_rejects_unknown_client() {
510        let issuer = test_issuer();
511        let result = issuer.issue_token("unknown", "secret", None, None).await;
512        assert!(result.is_err());
513    }
514
515    #[tokio::test]
516    async fn issuer_constrains_requested_audience() {
517        let pem = include_str!("../tests/fixtures/test_rsa_private.pem");
518        let signing_key = NativeSigningKey::from_pem(pem, "test-kid".to_string()).unwrap();
519        let store = test_store();
520        let issuer = NativeTokenIssuer::try_new(
521            "https://orders.local".to_string(),
522            vec!["orders-api".to_string(), "internal-api".to_string()],
523            std::time::Duration::from_secs(900),
524            signing_key,
525            store,
526        )
527        .unwrap();
528
529        let response = issuer
530            .issue_token("billing", "secret", None, Some("orders-api"))
531            .await
532            .unwrap();
533        let pub_pem = include_str!("../tests/fixtures/test_rsa_public.pem");
534        let mut validation = Validation::new(Algorithm::RS256);
535        validation.set_issuer(&["https://orders.local"]);
536        validation.set_audience(&["orders-api"]);
537        let decoded = decode::<serde_json::Value>(
538            &response.access_token,
539            &DecodingKey::from_rsa_pem(pub_pem.as_bytes()).unwrap(),
540            &validation,
541        )
542        .unwrap();
543        assert_eq!(decoded.claims["aud"], json!("orders-api"));
544    }
545
546    #[tokio::test]
547    async fn issuer_rejects_invalid_audience() {
548        let issuer = test_issuer();
549        let result = issuer
550            .issue_token("billing", "secret", None, Some("evil-api"))
551            .await;
552        assert!(result.is_err());
553    }
554
555    #[tokio::test]
556    async fn handle_token_request_valid_client_credentials() {
557        let issuer = test_issuer();
558        let body = "grant_type=client_credentials&client_id=billing&client_secret=secret";
559        let response = issuer.handle_token_request(body).await.unwrap();
560        assert_eq!(response.token_type, "Bearer");
561        assert_eq!(response.expires_in, 900);
562    }
563
564    #[tokio::test]
565    async fn handle_token_request_with_scope() {
566        let issuer = test_issuer();
567        let body = "grant_type=client_credentials&client_id=billing&client_secret=secret&scope=orders%3Aread";
568        let response = issuer.handle_token_request(body).await.unwrap();
569        assert_eq!(response.scope, "orders:read");
570    }
571
572    #[tokio::test]
573    async fn handle_token_request_unsupported_grant_type() {
574        let issuer = test_issuer();
575        let body = "grant_type=authorization_code&client_id=billing&client_secret=secret";
576        let result = issuer.handle_token_request(body).await;
577        assert!(result.is_err());
578        assert!(matches!(
579            result.unwrap_err(),
580            IssuerError::UnsupportedGrantType
581        ));
582    }
583
584    #[tokio::test]
585    async fn handle_token_request_invalid_client() {
586        let issuer = test_issuer();
587        let body = "grant_type=client_credentials&client_id=evil&client_secret=guess";
588        let result = issuer.handle_token_request(body).await;
589        assert!(result.is_err());
590        assert!(matches!(result.unwrap_err(), IssuerError::InvalidClient));
591    }
592
593    #[tokio::test]
594    async fn handle_token_request_invalid_scope() {
595        let issuer = test_issuer();
596        let body = "grant_type=client_credentials&client_id=billing&client_secret=secret&scope=admin%3Asuper";
597        let result = issuer.handle_token_request(body).await;
598        assert!(result.is_err());
599        assert!(matches!(result.unwrap_err(), IssuerError::InvalidScope));
600    }
601
602    #[tokio::test]
603    async fn handle_token_request_error_does_not_leak_secret() {
604        let issuer = test_issuer();
605        let body =
606            "grant_type=client_credentials&client_id=billing&client_secret=super-secret-value";
607        let result = issuer.handle_token_request(body).await;
608        assert!(result.is_err());
609        let err_msg = format!("{}", result.unwrap_err());
610        assert!(!err_msg.contains("super-secret-value"));
611    }
612
613    #[tokio::test]
614    async fn handle_token_request_resource_alias_for_audience() {
615        let issuer = test_issuer();
616        let body = "grant_type=client_credentials&client_id=billing&client_secret=secret&resource=orders-api";
617        let response = issuer.handle_token_request(body).await.unwrap();
618        assert_eq!(response.token_type, "Bearer");
619    }
620
621    #[tokio::test]
622    async fn handle_token_request_resource_alias_rejects_invalid() {
623        let issuer = test_issuer();
624        let body = "grant_type=client_credentials&client_id=billing&client_secret=secret&resource=evil-api";
625        let result = issuer.handle_token_request(body).await;
626        assert!(result.is_err());
627        assert!(matches!(result.unwrap_err(), IssuerError::InvalidAudience));
628    }
629
630    #[test]
631    fn issuer_from_config_builds_valid_issuer() {
632        // SAFETY: test-only, single-threaded, unique env var names
633        unsafe {
634            std::env::set_var(
635                "TEST_ISSUER_KEY_PEM_WIRING",
636                include_str!("../tests/fixtures/test_rsa_private.pem"),
637            );
638            std::env::set_var("TEST_M2M_CLIENT_SECRET_WIRING", "test-secret");
639        }
640
641        let signing_key_pem = std::env::var("TEST_ISSUER_KEY_PEM_WIRING").unwrap();
642        let signing_key =
643            NativeSigningKey::from_pem(&signing_key_pem, "config-kid".to_string()).unwrap();
644
645        let store = M2mClientStore::try_new(vec![M2mClient {
646            client_id: "worker".into(),
647            secret: M2mClientSecret::Env {
648                name: "TEST_M2M_CLIENT_SECRET_WIRING".into(),
649            },
650            roles: vec!["worker".into()],
651            scopes: vec!["api:read".into()],
652        }])
653        .unwrap();
654
655        let issuer = NativeTokenIssuer::try_new(
656            "https://config.local".into(),
657            vec!["api".into()],
658            Duration::from_secs(600),
659            signing_key,
660            store,
661        )
662        .unwrap();
663
664        assert_eq!(issuer.issuer(), "https://config.local");
665        assert_eq!(issuer.ttl(), Duration::from_secs(600));
666
667        // SAFETY: test-only cleanup
668        unsafe {
669            std::env::remove_var("TEST_ISSUER_KEY_PEM_WIRING");
670            std::env::remove_var("TEST_M2M_CLIENT_SECRET_WIRING");
671        }
672    }
673}