e2e_dcr.rs 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441
  1. //! `binary(e2e_dcr)` dynamic-client-registration lifecycle over the live
  2. //! router (RFC 7591 registration, RFC 7592 management).
  3. //!
  4. //! Every request goes through the production router over a loopback socket
  5. //! against the real shared Postgres + Redis. Initial access tokens are seeded
  6. //! through the same keyed-hash path the CLI uses, so the registration
  7. //! authorization the tests observe is the production one (not `sha256_hex`).
  8. //!
  9. //! DCR forbids `client_credentials` (`SUPPORTED_GRANTS`), so no case attempts
  10. //! it. Confidential `client_secret_basic` registration requires a sender
  11. //! constraint, hence every positive body sets `dpop_bound_access_tokens`.
  12. #[path = "e2e/harness.rs"]
  13. #[allow(dead_code)]
  14. mod harness;
  15. use std::time::Duration;
  16. use d_oauth::{
  17. crypto::tokens::{KeyId as TokenKeyId, mint_token, token_hash},
  18. db::TokenEndpointAuthMethod,
  19. protocol::token::ClientKind,
  20. store::dcr::{self, InitialAccessRecord},
  21. };
  22. use harness::{TestError, World, ensure, fail, json_body, open_rate_limit, seed_client};
  23. use reqwest::StatusCode;
  24. use secrecy::ExposeSecret as _;
  25. use serde_json::{Value, json};
  26. /// Persisted portion of one RFC 7591 registration response used by a test.
  27. struct Registered {
  28. client_id: String,
  29. client_secret: String,
  30. registration_token: String,
  31. }
  32. /// RFC 7591 body for a confidential, DPoP-bound client in its one grant.
  33. fn registration_body(name: &str, redirect: &str) -> Value {
  34. json!({
  35. "redirect_uris": [redirect],
  36. "client_name": name,
  37. "token_endpoint_auth_method": "client_secret_basic",
  38. "grant_types": ["authorization_code"],
  39. "response_types": ["code"],
  40. "dpop_bound_access_tokens": true,
  41. })
  42. }
  43. /// Read a required string member of a JSON response.
  44. fn string_field<'a>(body: &'a Value, key: &str) -> Result<&'a str, TestError> {
  45. body
  46. .get(key)
  47. .and_then(Value::as_str)
  48. .ok_or_else(|| fail(format!("response is missing string field {key}")))
  49. }
  50. /// Seed an IAT exactly as `protocol::dcr::tests::issue_iat` does: the storage
  51. /// key is the active pepper's keyed hash, not a plain `sha256_hex`.
  52. async fn seed_iat(world: &World, uses: u64) -> Result<String, TestError> {
  53. let raw = mint_token().map_err(|error| fail(format!("mint IAT failed: {error}")))?;
  54. let (key_id, pepper) = world
  55. .state
  56. .config
  57. .keys
  58. .active_pepper()
  59. .ok_or_else(|| fail("config has no active token pepper"))?;
  60. let hash = token_hash(
  61. &TokenKeyId::new(key_id.as_str()),
  62. pepper.expose_secret().as_bytes(),
  63. &raw,
  64. )
  65. .to_storage_string();
  66. let mut conn = world.state.redis.clone();
  67. dcr::issue(
  68. &mut conn,
  69. &world.store,
  70. &hash,
  71. &InitialAccessRecord {
  72. uses_remaining: uses,
  73. label: "e2e".to_owned(),
  74. },
  75. Duration::from_secs(600),
  76. )
  77. .await
  78. .map_err(|error| fail(format!("seed IAT failed: {error}")))?;
  79. Ok(raw)
  80. }
  81. /// `POST {path}` as JSON with a bearer credential.
  82. async fn post_json(
  83. world: &World,
  84. path: &str,
  85. bearer: &str,
  86. body: &Value,
  87. ) -> Result<(StatusCode, String), TestError> {
  88. let response = world
  89. .client
  90. .post(format!("{}{path}", world.base))
  91. .bearer_auth(bearer)
  92. .json(body)
  93. .send()
  94. .await?;
  95. Ok((response.status(), response.text().await?))
  96. }
  97. /// `PUT {path}` as JSON with a bearer credential.
  98. async fn put_json(
  99. world: &World,
  100. path: &str,
  101. bearer: &str,
  102. body: &Value,
  103. ) -> Result<(StatusCode, String), TestError> {
  104. let response = world
  105. .client
  106. .put(format!("{}{path}", world.base))
  107. .bearer_auth(bearer)
  108. .json(body)
  109. .send()
  110. .await?;
  111. Ok((response.status(), response.text().await?))
  112. }
  113. /// `DELETE {path}` with a bearer credential.
  114. async fn delete(
  115. world: &World,
  116. path: &str,
  117. bearer: &str,
  118. ) -> Result<(StatusCode, String), TestError> {
  119. let response = world
  120. .client
  121. .delete(format!("{}{path}", world.base))
  122. .bearer_auth(bearer)
  123. .send()
  124. .await?;
  125. Ok((response.status(), response.text().await?))
  126. }
  127. /// Register one client and return the credentials the response exposes once.
  128. async fn register(
  129. world: &World,
  130. iat: &str,
  131. name: &str,
  132. redirect: &str,
  133. ) -> Result<Registered, TestError> {
  134. let (status, body) =
  135. post_json(world, "/register", iat, &registration_body(name, redirect)).await?;
  136. ensure(
  137. status == StatusCode::CREATED,
  138. format!("POST /register must be 201, got {status}: {body}"),
  139. )?;
  140. let parsed = json_body(&body)?;
  141. Ok(Registered {
  142. client_id: string_field(&parsed, "client_id")?.to_owned(),
  143. client_secret: string_field(&parsed, "client_secret")?.to_owned(),
  144. registration_token: string_field(&parsed, "registration_access_token")?.to_owned(),
  145. })
  146. }
  147. /// Assert an exact `403 access_denied` management denial.
  148. fn ensure_forbidden(status: StatusCode, body: &str) -> Result<(), TestError> {
  149. ensure(
  150. status == StatusCode::FORBIDDEN,
  151. format!("cross-client management must be 403, got {status}: {body}"),
  152. )?;
  153. let parsed = json_body(body)?;
  154. ensure(
  155. parsed["error"].as_str() == Some("access_denied"),
  156. format!("cross-client denial must be access_denied, got {body}"),
  157. )
  158. }
  159. /// RFC 7591 register → RFC 7592 read/update/delete, with the deleted client's
  160. /// tokens no longer introspectable.
  161. #[tokio::test]
  162. async fn e2e_dcr_register_read_update_delete_lifecycle() -> Result<(), TestError> {
  163. let world = World::start_with(open_rate_limit(), json!({"dcr_redirect_policy": "open"})).await?;
  164. let iat = seed_iat(&world, 3).await?;
  165. let redirect = "https://app.example.test/dcr/lifecycle";
  166. let registered = register(&world, &iat, "lifecycle client", redirect).await?;
  167. ensure(
  168. !registered.client_id.is_empty(),
  169. "registration must mint a client_id",
  170. )?;
  171. ensure(
  172. !registered.registration_token.is_empty(),
  173. "registration must mint a management token",
  174. )?;
  175. ensure(
  176. !registered.client_secret.is_empty(),
  177. "registration must mint a client secret",
  178. )?;
  179. let auth = format!("Bearer {}", registered.registration_token);
  180. let (status, _, body) = world
  181. .get(
  182. &format!("/register/{}", registered.client_id),
  183. &[("Authorization", auth.as_str())],
  184. )
  185. .await?;
  186. ensure(
  187. status == StatusCode::OK,
  188. format!("GET /register/{{id}} must be 200, got {status}: {body}"),
  189. )?;
  190. let metadata = json_body(&body)?;
  191. ensure(
  192. metadata["client_id"].as_str() == Some(registered.client_id.as_str()),
  193. "read must echo the registered client_id",
  194. )?;
  195. ensure(
  196. metadata["client_name"].as_str() == Some("lifecycle client"),
  197. "read must echo the registered client_name",
  198. )?;
  199. ensure(
  200. metadata["dpop_bound_access_tokens"].as_bool() == Some(true),
  201. "read must echo the DPoP binding",
  202. )?;
  203. let renamed = registration_body("lifecycle client renamed", redirect);
  204. let (status, body) = put_json(
  205. &world,
  206. &format!("/register/{}", registered.client_id),
  207. &registered.registration_token,
  208. &renamed,
  209. )
  210. .await?;
  211. ensure(
  212. status == StatusCode::OK,
  213. format!("PUT /register/{{id}} must be 200, got {status}: {body}"),
  214. )?;
  215. let updated = json_body(&body)?;
  216. ensure(
  217. updated["client_name"].as_str() == Some("lifecycle client renamed"),
  218. "PUT must apply the new client_name",
  219. )?;
  220. let rotated = string_field(&updated, "registration_access_token")?.to_owned();
  221. ensure(
  222. rotated != registered.registration_token,
  223. "PUT must rotate the management token",
  224. )?;
  225. // A successful PUT also rotates a secret-method client's secret, so the
  226. // secret from the 201 body is stale from here on.
  227. let rotated_secret = string_field(&updated, "client_secret")?.to_owned();
  228. let minted = world
  229. .mint(&registered.client_id, "", ClientKind::Confidential, None)
  230. .await?;
  231. let (status, _, body) = world
  232. .post_form(
  233. "/introspect",
  234. &[("token", minted.access_token.as_str())],
  235. Some((registered.client_id.as_str(), rotated_secret.as_str())),
  236. &[],
  237. )
  238. .await?;
  239. ensure(
  240. status == StatusCode::OK,
  241. format!("introspection must be 200, got {status}: {body}"),
  242. )?;
  243. ensure(
  244. json_body(&body)?["active"].as_bool() == Some(true),
  245. "the minted token must introspect active before delete",
  246. )?;
  247. let (status, _) = delete(
  248. &world,
  249. &format!("/register/{}", registered.client_id),
  250. &rotated,
  251. )
  252. .await?;
  253. ensure(
  254. status == StatusCode::NO_CONTENT,
  255. format!("DELETE /register/{{id}} must be 204, got {status}"),
  256. )?;
  257. let auth = format!("Bearer {rotated}");
  258. let (status, _, body) = world
  259. .get(
  260. &format!("/register/{}", registered.client_id),
  261. &[("Authorization", auth.as_str())],
  262. )
  263. .await?;
  264. ensure(
  265. status == StatusCode::UNAUTHORIZED,
  266. format!("management after delete must be 401, got {status}: {body}"),
  267. )?;
  268. ensure(
  269. json_body(&body)?["error"].as_str() == Some("invalid_client"),
  270. format!("management after delete must be invalid_client, got {body}"),
  271. )?;
  272. // `DELETE` revokes the records themselves. A foreign client's introspection
  273. // would answer inactive for any owner mismatch, so assert the store directly
  274. // and keep the cross-client check as a separate observable contract.
  275. let mut conn = world.state.redis.clone();
  276. let access = d_oauth::store::tokens::get_access(&mut conn, &world.store, &minted.access_hash)
  277. .await
  278. .map_err(|error| fail(format!("access lookup failed: {error}")))?;
  279. ensure(access.is_none(), "DELETE must revoke the access token")?;
  280. let refresh = d_oauth::store::tokens::get_refresh(&mut conn, &world.store, &minted.refresh_hash)
  281. .await
  282. .map_err(|error| fail(format!("refresh lookup failed: {error}")))?;
  283. ensure(refresh.is_none(), "DELETE must revoke the refresh token")?;
  284. let other_id = "e2e-dcr-introspector";
  285. let other_secret = "e2e-dcr-introspector-secret-aaaabbbbccccdddd";
  286. seed_client(
  287. &world.pool,
  288. &world.state.keys,
  289. other_id,
  290. Some(other_secret),
  291. &["authorization_code"],
  292. "",
  293. TokenEndpointAuthMethod::ClientSecretBasic,
  294. )
  295. .await?;
  296. let (status, _, body) = world
  297. .post_form(
  298. "/introspect",
  299. &[("token", minted.access_token.as_str())],
  300. Some((other_id, other_secret)),
  301. &[],
  302. )
  303. .await?;
  304. ensure(
  305. status == StatusCode::OK,
  306. format!("foreign introspection must be 200, got {status}: {body}"),
  307. )?;
  308. ensure(
  309. json_body(&body)?["active"].as_bool() == Some(false),
  310. "the deleted client's token must introspect inactive",
  311. )
  312. }
  313. /// Every bad-metadata row renders its documented RFC 7591 error and creates
  314. /// no client row.
  315. #[tokio::test]
  316. async fn e2e_dcr_rejects_bad_metadata_at_http() -> Result<(), TestError> {
  317. let world = World::start_with(open_rate_limit(), json!({})).await?;
  318. let iat = seed_iat(&world, 10).await?;
  319. let wildcard = json!({
  320. "redirect_uris": ["https://*.example.test/cb"],
  321. "token_endpoint_auth_method": "client_secret_basic",
  322. "dpop_bound_access_tokens": true,
  323. });
  324. let plain_http = json!({
  325. "redirect_uris": ["http://app.example.test/cb"],
  326. "token_endpoint_auth_method": "client_secret_basic",
  327. "dpop_bound_access_tokens": true,
  328. });
  329. let bad_method = json!({
  330. "redirect_uris": ["https://app.example.test/cb"],
  331. "token_endpoint_auth_method": "bogus",
  332. "dpop_bound_access_tokens": true,
  333. });
  334. let both_jwks = json!({
  335. "redirect_uris": ["https://app.example.test/cb"],
  336. "token_endpoint_auth_method": "client_secret_basic",
  337. "dpop_bound_access_tokens": true,
  338. "jwks": {"keys": []},
  339. "jwks_uri": "https://app.example.test/jwks.json",
  340. });
  341. let rows = [
  342. (&wildcard, "invalid_redirect_uri"),
  343. (&plain_http, "invalid_redirect_uri"),
  344. (&bad_method, "invalid_client_metadata"),
  345. (&both_jwks, "invalid_client_metadata"),
  346. ];
  347. for (body, expected) in rows {
  348. let (status, response_body) = post_json(&world, "/register", &iat, body).await?;
  349. ensure(
  350. status == StatusCode::BAD_REQUEST,
  351. format!("{expected} row must be 400, got {status}: {response_body}"),
  352. )?;
  353. let parsed = json_body(&response_body)?;
  354. ensure(
  355. parsed["error"].as_str() == Some(expected),
  356. format!("expected {expected}, got {response_body}"),
  357. )?;
  358. }
  359. let clients: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM oauth_clients")
  360. .fetch_one(&world.pool)
  361. .await?;
  362. ensure(
  363. clients == 0,
  364. format!("no rejected registration may persist a client row, found {clients}"),
  365. )
  366. }
  367. /// A management token is scoped to the client it was minted for.
  368. #[tokio::test]
  369. async fn e2e_dcr_management_token_is_client_scoped() -> Result<(), TestError> {
  370. let world = World::start_with(open_rate_limit(), json!({"dcr_redirect_policy": "open"})).await?;
  371. let iat = seed_iat(&world, 5).await?;
  372. let first = register(&world, &iat, "first client", "https://app.example.test/a").await?;
  373. let second = register(&world, &iat, "second client", "https://app.example.test/b").await?;
  374. let auth = format!("Bearer {}", second.registration_token);
  375. let (status, _, body) = world
  376. .get(
  377. &format!("/register/{}", first.client_id),
  378. &[("Authorization", auth.as_str())],
  379. )
  380. .await?;
  381. ensure_forbidden(status, &body)?;
  382. let rename = registration_body("attacker rename", "https://app.example.test/b");
  383. let (status, body) = put_json(
  384. &world,
  385. &format!("/register/{}", first.client_id),
  386. &second.registration_token,
  387. &rename,
  388. )
  389. .await?;
  390. ensure_forbidden(status, &body)?;
  391. let (status, body) = delete(
  392. &world,
  393. &format!("/register/{}", first.client_id),
  394. &second.registration_token,
  395. )
  396. .await?;
  397. ensure_forbidden(status, &body)?;
  398. let auth = format!("Bearer {}", first.registration_token);
  399. let (status, _, body) = world
  400. .get(
  401. &format!("/register/{}", first.client_id),
  402. &[("Authorization", auth.as_str())],
  403. )
  404. .await?;
  405. ensure(
  406. status == StatusCode::OK,
  407. format!("the victim client must stay manageable, got {status}: {body}"),
  408. )?;
  409. ensure(
  410. json_body(&body)?["client_name"].as_str() == Some("first client"),
  411. "cross-client calls must not mutate the victim client",
  412. )
  413. }