no_secret_output.rs 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153
  1. //! No-secret-in-output gate (todo 39).
  2. //!
  3. //! Formats every loggable/`Debug` representation the BFF can emit and asserts
  4. //! that none of the four secret classes appears: the client secret, the DPoP
  5. //! private scalar, access/refresh tokens, and the PKCE verifier. Dummy
  6. //! sentinel strings stand in for real secrets — they are fabricated for this
  7. //! test, never provisioned anywhere, and this file only ever asserts their
  8. //! *absence* from formatted output (it never prints a secret to prove that).
  9. use std::time::Duration;
  10. use secrecy::SecretString;
  11. /// Fabricated client-secret stand-in (never a real credential).
  12. const CLIENT_SECRET_SENTINEL: &str = "xtask-39-client-secret-sentinel";
  13. /// Fabricated access-token stand-in (never a real token).
  14. const ACCESS_TOKEN_SENTINEL: &str = "xtask-39-access-token-sentinel";
  15. /// Fabricated refresh-token stand-in (never a real token).
  16. const REFRESH_TOKEN_SENTINEL: &str = "xtask-39-refresh-token-sentinel";
  17. /// Fabricated PKCE-verifier stand-in (never a real verifier).
  18. const VERIFIER_SENTINEL: &str = "xtask-39-pkce-verifier-sentinel";
  19. /// Fabricated authorization-code stand-in (never a real code).
  20. const CODE_SENTINEL: &str = "xtask-39-auth-code-sentinel";
  21. /// Assert that `rendered` carries none of the fabricated secrets.
  22. fn assert_no_secret(rendered: &str, what: &str) {
  23. for sentinel in [
  24. CLIENT_SECRET_SENTINEL,
  25. ACCESS_TOKEN_SENTINEL,
  26. REFRESH_TOKEN_SENTINEL,
  27. VERIFIER_SENTINEL,
  28. CODE_SENTINEL,
  29. ] {
  30. assert!(
  31. !rendered.contains(sentinel),
  32. "{what} leaked a secret class into output: {rendered:?}"
  33. );
  34. }
  35. }
  36. #[test]
  37. fn client_secret_never_appears_in_debug_output() {
  38. let creds = oauth_core::ClientCredentials {
  39. client_id: "xtask-39-demo-client".to_owned(),
  40. client_secret: SecretString::from(CLIENT_SECRET_SENTINEL),
  41. };
  42. let rendered = format!("{creds:?}");
  43. assert_no_secret(&rendered, "ClientCredentials Debug");
  44. assert!(
  45. rendered.contains(oauth_core::REDACTED),
  46. "redaction marker must be present: {rendered:?}"
  47. );
  48. let profile = oauth_core::OAuthProfile {
  49. client: creds,
  50. ..oauth_core::OAuthProfile::default()
  51. };
  52. let rendered = format!("{profile:?}");
  53. assert_no_secret(&rendered, "OAuthProfile Debug");
  54. }
  55. #[test]
  56. fn dpop_private_scalar_never_appears_in_debug_output() {
  57. let key = oauth_core::dpop::key::DpopKey::generate().expect("key generates");
  58. let rendered = format!("{key:?}");
  59. assert!(
  60. rendered.contains(oauth_core::REDACTED),
  61. "DPoP key Debug must redact: {rendered:?}"
  62. );
  63. // The persisted file holds the real scalar; the Debug form must not repeat
  64. // any of it. Extract the `d` member and prove absence.
  65. let dir = tempfile::tempdir().expect("tempdir creates");
  66. let path = dir.path().join("dpop-key.json");
  67. key.save_to_file(&path).expect("key persists");
  68. let raw = std::fs::read_to_string(&path).expect("key file reads");
  69. let scalar = raw.parse::<serde_json::Value>().expect("key file is JSON")["d"]
  70. .as_str()
  71. .expect("key file carries a `d` scalar")
  72. .to_owned();
  73. assert!(
  74. !scalar.is_empty(),
  75. "scalar must be non-empty to prove absence"
  76. );
  77. assert!(
  78. !rendered.contains(&scalar),
  79. "DPoP private scalar leaked into Debug output"
  80. );
  81. }
  82. #[test]
  83. fn tokens_never_appear_in_debug_or_log_output() {
  84. let response = oauth_core::token::TokenResponse {
  85. access_token: SecretString::from(ACCESS_TOKEN_SENTINEL),
  86. token_type: "DPoP".to_owned(),
  87. expires_in: 300,
  88. refresh_token: Some(SecretString::from(REFRESH_TOKEN_SENTINEL)),
  89. scope: Some("demo.read".to_owned()),
  90. };
  91. let rendered = format!("{response:?}");
  92. assert_no_secret(&rendered, "TokenResponse Debug");
  93. // The BFF log line for an outbound token call: query/fragment stripped,
  94. // header presence recorded only as the marker.
  95. let line = server::http_client::loggable_request(
  96. "POST",
  97. &format!("https://as.example/token?code={CODE_SENTINEL}"),
  98. true,
  99. true,
  100. );
  101. let decorated = format!(
  102. "{line} token={} secret={}",
  103. server::http_client::redact_secret(ACCESS_TOKEN_SENTINEL),
  104. server::http_client::redact_secret(CLIENT_SECRET_SENTINEL),
  105. );
  106. assert_no_secret(&decorated, "BFF log line");
  107. // Server-side session Debug + cookie carry no token material.
  108. let store = server::session::SessionStore::new(Duration::from_secs(3600));
  109. let created = store.create().expect("session creates");
  110. assert!(store.store_tokens(
  111. &created.id,
  112. Some(SecretString::from(ACCESS_TOKEN_SENTINEL)),
  113. Some(SecretString::from(REFRESH_TOKEN_SENTINEL)),
  114. Some("DPoP".to_owned()),
  115. ));
  116. let rendered = format!("{:?}", store.get(&created.id).expect("session lives"));
  117. assert_no_secret(&rendered, "SessionData Debug");
  118. let cookie = server::session::set_cookie_value(&created.id, false, Duration::from_secs(60));
  119. assert_no_secret(&cookie, "session cookie");
  120. }
  121. #[test]
  122. fn pkce_verifier_never_appears_in_debug_output() {
  123. let pending = server::oauth::PendingStore::new();
  124. pending.insert(
  125. "xtask-39-state".to_owned(),
  126. SecretString::from(VERIFIER_SENTINEL),
  127. "xtask-39-session".to_owned(),
  128. "https://as.example".to_owned(),
  129. );
  130. let record = pending
  131. .take("xtask-39-state")
  132. .expect("pending record lives");
  133. let rendered = format!("{record:?}");
  134. assert_no_secret(&rendered, "PendingRecord Debug");
  135. assert!(
  136. rendered.contains(oauth_core::REDACTED),
  137. "redaction marker must be present: {rendered:?}"
  138. );
  139. // Single-use: the state never resolves twice (replay defense).
  140. assert!(pending.take("xtask-39-state").is_none());
  141. }