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