e2e_dcr.rs 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428
  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. let other_id = "e2e-dcr-introspector";
  273. let other_secret = "e2e-dcr-introspector-secret-aaaabbbbccccdddd";
  274. seed_client(
  275. &world.pool,
  276. &world.state.keys,
  277. other_id,
  278. Some(other_secret),
  279. &["authorization_code"],
  280. "",
  281. TokenEndpointAuthMethod::ClientSecretBasic,
  282. )
  283. .await?;
  284. let (status, _, body) = world
  285. .post_form(
  286. "/introspect",
  287. &[("token", minted.access_token.as_str())],
  288. Some((other_id, other_secret)),
  289. &[],
  290. )
  291. .await?;
  292. ensure(
  293. status == StatusCode::OK,
  294. format!("foreign introspection must be 200, got {status}: {body}"),
  295. )?;
  296. ensure(
  297. json_body(&body)?["active"].as_bool() == Some(false),
  298. "the deleted client's token must introspect inactive",
  299. )
  300. }
  301. /// Every bad-metadata row renders its documented RFC 7591 error and creates
  302. /// no client row.
  303. #[tokio::test]
  304. async fn e2e_dcr_rejects_bad_metadata_at_http() -> Result<(), TestError> {
  305. let world = World::start_with(open_rate_limit(), json!({})).await?;
  306. let iat = seed_iat(&world, 10).await?;
  307. let wildcard = json!({
  308. "redirect_uris": ["https://*.example.test/cb"],
  309. "token_endpoint_auth_method": "client_secret_basic",
  310. "dpop_bound_access_tokens": true,
  311. });
  312. let plain_http = json!({
  313. "redirect_uris": ["http://app.example.test/cb"],
  314. "token_endpoint_auth_method": "client_secret_basic",
  315. "dpop_bound_access_tokens": true,
  316. });
  317. let bad_method = json!({
  318. "redirect_uris": ["https://app.example.test/cb"],
  319. "token_endpoint_auth_method": "bogus",
  320. "dpop_bound_access_tokens": true,
  321. });
  322. let both_jwks = json!({
  323. "redirect_uris": ["https://app.example.test/cb"],
  324. "token_endpoint_auth_method": "client_secret_basic",
  325. "dpop_bound_access_tokens": true,
  326. "jwks": {"keys": []},
  327. "jwks_uri": "https://app.example.test/jwks.json",
  328. });
  329. let rows = [
  330. (&wildcard, "invalid_redirect_uri"),
  331. (&plain_http, "invalid_redirect_uri"),
  332. (&bad_method, "invalid_client_metadata"),
  333. (&both_jwks, "invalid_client_metadata"),
  334. ];
  335. for (body, expected) in rows {
  336. let (status, response_body) = post_json(&world, "/register", &iat, body).await?;
  337. ensure(
  338. status == StatusCode::BAD_REQUEST,
  339. format!("{expected} row must be 400, got {status}: {response_body}"),
  340. )?;
  341. let parsed = json_body(&response_body)?;
  342. ensure(
  343. parsed["error"].as_str() == Some(expected),
  344. format!("expected {expected}, got {response_body}"),
  345. )?;
  346. }
  347. let clients: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM oauth_clients")
  348. .fetch_one(&world.pool)
  349. .await?;
  350. ensure(
  351. clients == 0,
  352. format!("no rejected registration may persist a client row, found {clients}"),
  353. )
  354. }
  355. /// A management token is scoped to the client it was minted for.
  356. #[tokio::test]
  357. async fn e2e_dcr_management_token_is_client_scoped() -> Result<(), TestError> {
  358. let world = World::start_with(open_rate_limit(), json!({"dcr_redirect_policy": "open"})).await?;
  359. let iat = seed_iat(&world, 5).await?;
  360. let first = register(&world, &iat, "first client", "https://app.example.test/a").await?;
  361. let second = register(&world, &iat, "second client", "https://app.example.test/b").await?;
  362. let auth = format!("Bearer {}", second.registration_token);
  363. let (status, _, body) = world
  364. .get(
  365. &format!("/register/{}", first.client_id),
  366. &[("Authorization", auth.as_str())],
  367. )
  368. .await?;
  369. ensure_forbidden(status, &body)?;
  370. let rename = registration_body("attacker rename", "https://app.example.test/b");
  371. let (status, body) = put_json(
  372. &world,
  373. &format!("/register/{}", first.client_id),
  374. &second.registration_token,
  375. &rename,
  376. )
  377. .await?;
  378. ensure_forbidden(status, &body)?;
  379. let (status, body) = delete(
  380. &world,
  381. &format!("/register/{}", first.client_id),
  382. &second.registration_token,
  383. )
  384. .await?;
  385. ensure_forbidden(status, &body)?;
  386. let auth = format!("Bearer {}", first.registration_token);
  387. let (status, _, body) = world
  388. .get(
  389. &format!("/register/{}", first.client_id),
  390. &[("Authorization", auth.as_str())],
  391. )
  392. .await?;
  393. ensure(
  394. status == StatusCode::OK,
  395. format!("the victim client must stay manageable, got {status}: {body}"),
  396. )?;
  397. ensure(
  398. json_body(&body)?["client_name"].as_str() == Some("first client"),
  399. "cross-client calls must not mutate the victim client",
  400. )
  401. }