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