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