tests.rs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413
  1. //! `test(introspect)` suite: active shape, inactive misses, 401, ownership.
  2. //!
  3. //! Each test runs against a real Redis container plus a migrated Postgres
  4. //! database on the shared test server, through the public [`super`] API and a
  5. //! one-route router for the HTTP status assertions.
  6. use std::{
  7. sync::{
  8. Arc,
  9. atomic::{AtomicU64, Ordering},
  10. },
  11. time::Duration,
  12. };
  13. use axum::{
  14. Router,
  15. body::Body,
  16. http::{Request, StatusCode, header},
  17. routing::post,
  18. };
  19. use base64::Engine as _;
  20. use http_body_util::BodyExt as _;
  21. use secrecy::SecretString;
  22. use serde_json::{Value, json};
  23. use sqlx::PgPool;
  24. use testcontainers::{
  25. GenericImage, ImageExt,
  26. core::{IntoContainerPort, WaitFor},
  27. runners::AsyncRunner,
  28. };
  29. use time::OffsetDateTime;
  30. use tower::ServiceExt as _;
  31. use uuid::Uuid;
  32. use super::{
  33. IntrospectContext, IntrospectHttpInputs, IntrospectStores, IntrospectionResponse, handler,
  34. introspect, record_act,
  35. };
  36. use crate::{
  37. config::Config,
  38. crypto::secret::{Argon2Params, hash_secret},
  39. db::{NewClient, TokenEndpointAuthMethod, client_repo, test_support::migrated_pool},
  40. protocol::token::{ClientKind, MintRequest, SenderProof, TokenService},
  41. state::{AppState, Clock, RedisConn},
  42. store::Store,
  43. test_guard::{ContainerGuard, Lease},
  44. };
  45. /// Boxed test error (no `unwrap`/`expect` under the crate deny lints).
  46. type TestResult<T = ()> = Result<T, Box<dyn std::error::Error + Send + Sync>>;
  47. static NONCE: AtomicU64 = AtomicU64::new(1);
  48. fn unique(label: &str) -> String {
  49. let n = NONCE.fetch_add(1, Ordering::Relaxed);
  50. format!("introspect-test-{n}-{label}")
  51. }
  52. fn now_unix() -> i64 {
  53. OffsetDateTime::now_utc().unix_timestamp()
  54. }
  55. /// Live handles for one test: migrated Postgres, Redis, store, and config.
  56. struct Harness {
  57. _db: Lease<crate::db::test_support::Db>,
  58. pool: PgPool,
  59. conn: RedisConn,
  60. store: Store,
  61. config: Arc<Config>,
  62. }
  63. async fn harness() -> TestResult<(ContainerGuard, Harness)> {
  64. let (db, pool) = migrated_pool().await?;
  65. let container = GenericImage::new("redis", "7.2-alpine")
  66. .with_exposed_port(6379.tcp())
  67. .with_wait_for(WaitFor::message_on_stdout("Ready to accept connections"))
  68. .with_cmd(["redis-server", "--appendonly", "yes"])
  69. .start()
  70. .await?;
  71. let guarded = ContainerGuard::new(container);
  72. let port = guarded.get_host_port_ipv4(6379).await?;
  73. let redis_url = format!("redis://127.0.0.1:{port}");
  74. let client = redis::Client::open(redis_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. crate::config::DurabilityMode::WaitAof,
  83. );
  84. let fixture = json!({
  85. "issuer": "https://auth.example.test",
  86. "environment": "development",
  87. "tls": {"cert_path": "/tmp/introspect-test-cert.pem", "key_path": "/tmp/introspect-test-key.pem"},
  88. "database": {"url": "postgres://127.0.0.1:1/introspect"},
  89. "redis": {"url": redis_url, "durability": "waitaof"},
  90. "token_pepper": "introspect-test-pepper-0123456789abcdef",
  91. "totp_encryption_key": "introspect-test-totp-key-0123456789abcdef",
  92. "pii_encryption_key": "introspect-test-pii-key-0123456789abcdef",
  93. });
  94. let source = ::config::Config::try_from(&fixture)?;
  95. let config = Config::from_source(source)?;
  96. Ok((
  97. guarded,
  98. Harness {
  99. _db: db,
  100. pool,
  101. conn: RedisConn::Single(manager),
  102. store,
  103. config: Arc::new(config),
  104. },
  105. ))
  106. }
  107. async fn insert_client(
  108. pool: &PgPool,
  109. keys: &crate::keys::KeyRing,
  110. client_id: &str,
  111. method: TokenEndpointAuthMethod,
  112. secret: Option<&str>,
  113. ) -> TestResult {
  114. let params = Argon2Params::new(8, 1, 1, 32).map_err(|error| format!("{error:?}"))?;
  115. let secret_hash = secret
  116. .map(|value| hash_secret(value, &params))
  117. .transpose()?;
  118. let new = NewClient {
  119. id: Uuid::new_v4(),
  120. client_id: client_id.to_owned(),
  121. client_secret_hash: secret_hash,
  122. client_secret_expires_at: None,
  123. client_name: format!("{client_id} name"),
  124. grant_types: vec!["client_credentials".to_owned()],
  125. response_types: Vec::new(),
  126. scope: "orders:read".to_owned(),
  127. token_endpoint_auth_method: method.as_str().to_owned(),
  128. jwks: None,
  129. jwks_uri: None,
  130. tls_client_auth_subject_dn: None,
  131. tls_client_auth_cert_thumbprint: None,
  132. request_uris: None,
  133. contacts: None,
  134. software_id: None,
  135. dpop_bound_access_tokens: false,
  136. registration_access_token_hash: None,
  137. };
  138. client_repo::insert(pool, keys, false, &new).await?;
  139. Ok(())
  140. }
  141. fn token_service(h: &Harness) -> TestResult<TokenService> {
  142. Ok(TokenService::new(&h.config.ttl, &h.config.keys)?)
  143. }
  144. /// Mint one sender-constrained access token owned by `client_id`.
  145. async fn mint_constrained(
  146. h: &mut Harness,
  147. client_id: &str,
  148. ) -> TestResult<crate::protocol::token::MintedTokens> {
  149. let service = token_service(h)?;
  150. Ok(
  151. service
  152. .mint(
  153. &mut h.conn,
  154. &h.store,
  155. &MintRequest {
  156. client_id: client_id.to_owned(),
  157. client_kind: ClientKind::Confidential,
  158. subject: "user-1".to_owned(),
  159. scope: "orders:read".to_owned(),
  160. grant_id: unique("grant"),
  161. proof: SenderProof {
  162. dpop_jkt: Some("jkt-thumbnail".to_owned()),
  163. mtls_x5t_s256: Some("x5t-thumbnail".to_owned()),
  164. },
  165. },
  166. now_unix(),
  167. )
  168. .await?,
  169. )
  170. }
  171. /// Drive the core with `client_secret_post` credentials.
  172. async fn run_core(
  173. h: &mut Harness,
  174. token: &str,
  175. hint: Option<&str>,
  176. client_id: &str,
  177. secret: &str,
  178. ) -> TestResult<IntrospectionResponse> {
  179. let service = token_service(h)?;
  180. let mut stores = IntrospectStores {
  181. pool: &h.pool,
  182. keys: &h.config.keys,
  183. conn: &mut h.conn,
  184. store: &h.store,
  185. };
  186. let ctx = IntrospectContext {
  187. issuer: &h.config.issuer,
  188. now_unix: now_unix(),
  189. service: &service,
  190. };
  191. let inputs = IntrospectHttpInputs {
  192. token,
  193. token_type_hint: hint,
  194. client_id: Some(client_id),
  195. basic_header: None,
  196. body_secret: Some(SecretString::from(secret)),
  197. assertion_type: None,
  198. assertion: None,
  199. peer_certs: None,
  200. };
  201. Ok(introspect(&mut stores, &ctx, &inputs).await?)
  202. }
  203. fn app_state(h: &Harness) -> AppState {
  204. AppState {
  205. pool: h.pool.clone(),
  206. redis: h.conn.clone(),
  207. config: Arc::clone(&h.config),
  208. keys: Arc::new(h.config.keys.clone()),
  209. transition_unsafe: Arc::new(std::sync::atomic::AtomicBool::new(false)),
  210. clock: Clock::system(),
  211. }
  212. }
  213. fn basic_header(client_id: &str, secret: &str) -> String {
  214. let encoded = base64::engine::general_purpose::STANDARD.encode(format!("{client_id}:{secret}"));
  215. format!("Basic {encoded}")
  216. }
  217. async fn post_form(app: Router, auth: Option<&str>, body: &str) -> TestResult<(StatusCode, Value)> {
  218. let mut builder = Request::builder()
  219. .method("POST")
  220. .uri("/introspect")
  221. .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded");
  222. if let Some(value) = auth {
  223. builder = builder.header(header::AUTHORIZATION, value);
  224. }
  225. let request = builder.body(Body::from(body.to_owned()))?;
  226. let response = app.oneshot(request).await?;
  227. let status = response.status();
  228. let bytes = response.into_body().collect().await?.to_bytes();
  229. Ok((status, serde_json::from_slice(&bytes)?))
  230. }
  231. fn router(h: &Harness) -> Router {
  232. Router::new()
  233. .route("/introspect", post(handler))
  234. .with_state(app_state(h))
  235. }
  236. #[tokio::test]
  237. async fn test_introspect_active_access_token_reports_shape() -> TestResult {
  238. let (_container, mut h) = harness().await?;
  239. let client_id = unique("owner");
  240. insert_client(
  241. &h.pool,
  242. &h.config.keys,
  243. &client_id,
  244. TokenEndpointAuthMethod::ClientSecretPost,
  245. Some("secret-a"),
  246. )
  247. .await?;
  248. let minted = mint_constrained(&mut h, &client_id).await?;
  249. record_act(
  250. &mut h.conn,
  251. &h.store,
  252. &minted.access_hash,
  253. &json!({"sub": "actor-1"}),
  254. h.config.ttl.access,
  255. )
  256. .await?;
  257. let before = now_unix();
  258. let response = run_core(
  259. &mut h,
  260. &minted.access_token,
  261. Some("access_token"),
  262. &client_id,
  263. "secret-a",
  264. )
  265. .await?;
  266. assert!(response.active, "minted token must be active");
  267. assert_eq!(response.scope.as_deref(), Some("orders:read"));
  268. assert_eq!(response.client_id.as_deref(), Some(client_id.as_str()));
  269. assert_eq!(response.token_type.as_deref(), Some("Bearer"));
  270. assert_eq!(response.sub.as_deref(), Some("user-1"));
  271. let cnf = response.cnf.as_ref().ok_or("cnf missing")?;
  272. assert_eq!(cnf.jkt.as_deref(), Some("jkt-thumbnail"));
  273. assert_eq!(cnf.x5t_s256.as_deref(), Some("x5t-thumbnail"));
  274. assert_eq!(response.act, Some(json!({"sub": "actor-1"})));
  275. let iat = response.iat.ok_or("iat missing")?;
  276. let exp = response.exp.ok_or("exp missing")?;
  277. assert!(iat < exp, "iat must precede exp");
  278. assert!(exp > before, "exp must be in the future");
  279. let ttl = i64::try_from(h.config.ttl.access.as_secs())?;
  280. assert_eq!(exp - iat, ttl, "window must equal the access TTL");
  281. Ok(())
  282. }
  283. #[tokio::test]
  284. async fn test_introspect_unknown_token_is_inactive_200() -> TestResult {
  285. let (_container, h) = harness().await?;
  286. let client_id = unique("unknown");
  287. insert_client(
  288. &h.pool,
  289. &h.config.keys,
  290. &client_id,
  291. TokenEndpointAuthMethod::ClientSecretBasic,
  292. Some("secret-u"),
  293. )
  294. .await?;
  295. let auth = basic_header(&client_id, "secret-u");
  296. let (status, body) = post_form(
  297. router(&h),
  298. Some(&auth),
  299. "token=not-a-real-token&token_type_hint=access_token",
  300. )
  301. .await?;
  302. assert_eq!(status, StatusCode::OK, "unknown token must not 4xx");
  303. assert_eq!(body, json!({"active": false}), "no claims may leak");
  304. Ok(())
  305. }
  306. #[tokio::test]
  307. async fn test_introspect_unauthenticated_is_401() -> TestResult {
  308. let (_container, h) = harness().await?;
  309. let (status, body) = post_form(router(&h), None, "token=whatever").await?;
  310. assert_eq!(status, StatusCode::UNAUTHORIZED);
  311. assert_eq!(
  312. body.get("error").and_then(Value::as_str),
  313. Some("invalid_client")
  314. );
  315. Ok(())
  316. }
  317. #[tokio::test]
  318. async fn test_introspect_foreign_client_token_is_inactive_200() -> TestResult {
  319. let (_container, mut h) = harness().await?;
  320. let owner = unique("owner");
  321. insert_client(
  322. &h.pool,
  323. &h.config.keys,
  324. &owner,
  325. TokenEndpointAuthMethod::ClientSecretBasic,
  326. Some("owner-secret"),
  327. )
  328. .await?;
  329. let foreign = unique("foreign");
  330. insert_client(
  331. &h.pool,
  332. &h.config.keys,
  333. &foreign,
  334. TokenEndpointAuthMethod::ClientSecretBasic,
  335. Some("foreign-secret"),
  336. )
  337. .await?;
  338. let minted = mint_constrained(&mut h, &owner).await?;
  339. let auth = basic_header(&foreign, "foreign-secret");
  340. let (status, body) = post_form(
  341. router(&h),
  342. Some(&auth),
  343. &format!("token={}", minted.access_token),
  344. )
  345. .await?;
  346. assert_eq!(status, StatusCode::OK);
  347. assert_ne!(status, StatusCode::FORBIDDEN, "existence must not leak");
  348. assert_eq!(body, json!({"active": false}));
  349. Ok(())
  350. }
  351. #[tokio::test]
  352. async fn test_introspect_revoked_token_is_inactive() -> TestResult {
  353. let (_container, mut h) = harness().await?;
  354. let client_id = unique("revoked");
  355. insert_client(
  356. &h.pool,
  357. &h.config.keys,
  358. &client_id,
  359. TokenEndpointAuthMethod::ClientSecretPost,
  360. Some("secret-r"),
  361. )
  362. .await?;
  363. let minted = mint_constrained(&mut h, &client_id).await?;
  364. let service = token_service(&h)?;
  365. let revoked = service
  366. .revoke_token(&mut h.conn, &h.store, &minted.access_token)
  367. .await?;
  368. assert!(revoked, "revoke must delete a live token");
  369. let response = run_core(
  370. &mut h,
  371. &minted.access_token,
  372. Some("access_token"),
  373. &client_id,
  374. "secret-r",
  375. )
  376. .await?;
  377. assert!(!response.active, "revoked token must be inactive");
  378. assert_eq!(response, IntrospectionResponse::inactive());
  379. Ok(())
  380. }