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 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 unsafe {
669 std::env::remove_var("TEST_ISSUER_KEY_PEM_WIRING");
670 std::env::remove_var("TEST_M2M_CLIENT_SECRET_WIRING");
671 }
672 }
673}