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;
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/// ADR-0051 credential boundary: manual-redaction
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
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        // SAFETY: test-only, single-threaded, unique env var names
645        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        // SAFETY: test-only cleanup
680        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}