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 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 unsafe {
620 std::env::remove_var("TEST_ISSUER_KEY_PEM_WIRING");
621 std::env::remove_var("TEST_M2M_CLIENT_SECRET_WIRING");
622 }
623 }
624}