tests.rs 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186
  1. //! `test(grant_client_credentials)` suite: confidential-only, scope, bindings.
  2. //!
  3. //! Shared Redis 8 container plus per-test connections; unique client
  4. //! labels keep the shared keyspace collision-free.
  5. mod binding;
  6. mod issue;
  7. use std::{
  8. sync::atomic::{AtomicU64, Ordering},
  9. time::Duration,
  10. };
  11. use secrecy::SecretString;
  12. use testcontainers::{
  13. GenericImage, ImageExt as _,
  14. core::{IntoContainerPort as _, WaitFor},
  15. runners::AsyncRunner as _,
  16. };
  17. use super::{ClientCredentialsInput, ClientCredentialsTokens};
  18. use crate::{
  19. config::{DurabilityMode, TtlConfig},
  20. error::{AppError, OAuthErrorCode},
  21. keys::{KeyId, KeyRing, KeyRingError},
  22. protocol::{
  23. scope::ScopeSet,
  24. token::{ClientKind, SenderProof, TokenService, TokenServiceError},
  25. },
  26. state::RedisConn,
  27. store::{Store, StoreError},
  28. test_guard::{ContainerGuard, Lease, Shared},
  29. };
  30. /// Test body result (no `unwrap`/`expect` under the deny lints).
  31. type TestResult = Result<(), TestError>;
  32. /// Errors raised by the client-credentials test scaffolding itself.
  33. #[derive(Debug, thiserror::Error)]
  34. enum TestError {
  35. /// A testcontainers operation failed.
  36. #[error("container error")]
  37. Containers(#[from] testcontainers::TestcontainersError),
  38. /// A Redis command failed.
  39. #[error("redis error")]
  40. Redis(#[from] redis::RedisError),
  41. /// A store operation failed.
  42. #[error("store error")]
  43. Store(#[from] StoreError),
  44. /// The token service failed.
  45. #[error("service error")]
  46. Service(#[from] TokenServiceError),
  47. /// The key ring was rejected.
  48. #[error("key ring error")]
  49. Keys(#[from] KeyRingError),
  50. /// The issuance (or scope parsing) failed.
  51. #[error("app error")]
  52. App(#[from] AppError),
  53. /// A test assertion observed an unexpected value.
  54. #[error("unexpected test value: {0}")]
  55. Unexpected(String),
  56. }
  57. /// One shared Redis 8 container for the whole suite.
  58. static REDIS: Shared<ContainerGuard> = Shared::new();
  59. /// Fresh connection plus store handle against the shared container.
  60. async fn test_redis() -> Result<(Lease<ContainerGuard>, RedisConn, Store), TestError> {
  61. let container = REDIS
  62. .lease::<TestError>(async {
  63. let started = GenericImage::new("redis", "8-alpine")
  64. .with_exposed_port(6379.tcp())
  65. .with_wait_for(WaitFor::message_on_stdout("Ready to accept connections"))
  66. .with_cmd(["redis-server", "--appendonly", "yes"])
  67. .start()
  68. .await?;
  69. Ok(ContainerGuard::new(started))
  70. })
  71. .await?;
  72. let port = container.get_host_port_ipv4(6379).await?;
  73. let url = format!("redis://127.0.0.1:{port}");
  74. let client = redis::Client::open(url.as_str())?;
  75. let manager_config = redis::aio::ConnectionManagerConfig::new()
  76. .set_response_timeout(Some(Duration::from_secs(10)))
  77. .set_connection_timeout(Some(Duration::from_secs(5)));
  78. let manager = redis::aio::ConnectionManager::new_with_config(client, manager_config).await?;
  79. let store = Store::from_parts(
  80. "oauth:{state}:".to_owned(),
  81. "oauth:{rl}:".to_owned(),
  82. DurabilityMode::WaitAof,
  83. );
  84. Ok((container, RedisConn::Single(manager), store))
  85. }
  86. static TEST_NONCE: AtomicU64 = AtomicU64::new(400_000);
  87. /// A label no other test uses, so grants/clients never collide.
  88. fn unique(label: &str) -> String {
  89. let n = TEST_NONCE.fetch_add(1, Ordering::Relaxed);
  90. format!("client-credentials-test-{n}-{label}")
  91. }
  92. /// A key ring with one pepper plus a dummy TOTP key.
  93. fn ring() -> Result<KeyRing, TestError> {
  94. KeyRing::from_parts(
  95. vec![(KeyId::new("v1"), SecretString::from("cc-test-pepper"))],
  96. vec![(KeyId::new("v1"), SecretString::from("totp-test-secret"))],
  97. vec![(KeyId::new("v1"), SecretString::from("pii-test-secret"))],
  98. )
  99. .map_err(TestError::from)
  100. }
  101. /// A service over the default TTLs (access 300 s, refresh 30 d).
  102. fn service() -> Result<TokenService, TestError> {
  103. TokenService::new(&TtlConfig::default(), &ring()?).map_err(TestError::from)
  104. }
  105. /// Current time as seconds since the Unix epoch.
  106. fn now_unix() -> i64 {
  107. time::OffsetDateTime::now_utc().unix_timestamp()
  108. }
  109. /// An empty proof: no DPoP, no mTLS.
  110. fn no_proof() -> SenderProof {
  111. SenderProof::none()
  112. }
  113. /// A DPoP-only proof for `jkt`.
  114. fn dpop_proof(jkt: &str) -> SenderProof {
  115. SenderProof {
  116. dpop_jkt: Some(jkt.to_owned()),
  117. mtls_x5t_s256: None,
  118. }
  119. }
  120. /// An mTLS-only proof for `x5t`.
  121. fn mtls_proof(x5t: &str) -> SenderProof {
  122. SenderProof {
  123. dpop_jkt: None,
  124. mtls_x5t_s256: Some(x5t.to_owned()),
  125. }
  126. }
  127. /// Shared issuance context for one client-credentials test.
  128. struct Ctx<'a> {
  129. kind: ClientKind,
  130. registered: &'a ScopeSet,
  131. enabled: &'a ScopeSet,
  132. dpop_bound: Option<&'a str>,
  133. mtls_bound: Option<&'a str>,
  134. proof: &'a SenderProof,
  135. }
  136. /// Baseline issuance inputs; tests override what they probe.
  137. fn input<'a>(
  138. client: &'a str,
  139. requested_scope: Option<&'a str>,
  140. ctx: &Ctx<'a>,
  141. ) -> ClientCredentialsInput<'a> {
  142. ClientCredentialsInput {
  143. client_id: client,
  144. client_kind: ctx.kind,
  145. client_registered: ctx.registered,
  146. enabled: ctx.enabled,
  147. requested_scope,
  148. dpop_bound_jkt: ctx.dpop_bound,
  149. mtls_bound_x5t_s256: ctx.mtls_bound,
  150. proof: ctx.proof,
  151. now_unix: now_unix(),
  152. }
  153. }
  154. /// Whether the error renders as `invalid_client`.
  155. fn is_invalid_client(error: &AppError) -> bool {
  156. error.code() == OAuthErrorCode::InvalidClient
  157. }
  158. /// Whether the error renders as `invalid_grant`.
  159. fn is_invalid_grant(error: &AppError) -> bool {
  160. error.code() == OAuthErrorCode::InvalidGrant
  161. }
  162. /// Whether the error renders as `invalid_scope`.
  163. fn is_invalid_scope(error: &AppError) -> bool {
  164. error.code() == OAuthErrorCode::InvalidScope
  165. }