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