e2e_stale_catalog.rs 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589
  1. //! `binary(e2e_stale_catalog)` — stale RFC 8707 and scope catalogs at mint.
  2. //!
  3. //! Every test boots the REAL production router over a loopback socket against
  4. //! the shared Postgres + Redis, with `resource_indicators_enabled` pinned on.
  5. //! A resource or scope disabled AFTER issuance must be dead at the next
  6. //! mint-time check: refresh re-validates the grant's stored audience before
  7. //! rotation (`invalid_target`, presented token left live), the device poll
  8. //! validates before consuming, authorization-code redemption validates a
  9. //! presented audience before consuming the code, and refresh narrows a
  10. //! disabled scope into the replacement token without ever widening it back.
  11. //!
  12. //! Re-enabling each catalog row proves the disable was the cause: the same
  13. //! live token/code then succeeds.
  14. #[path = "e2e/harness.rs"]
  15. #[allow(dead_code)]
  16. mod harness;
  17. #[path = "e2e/browser.rs"]
  18. #[allow(dead_code)]
  19. mod browser;
  20. #[path = "e2e/dpop.rs"]
  21. #[allow(dead_code)]
  22. mod dpop;
  23. #[path = "e2e/device.rs"]
  24. #[allow(dead_code)]
  25. mod device;
  26. use d_oauth::{
  27. db::{TokenEndpointAuthMethod, resource_repo, scope_repo},
  28. protocol::token::ClientKind,
  29. };
  30. use dpop::DpopKey;
  31. use harness::{
  32. TestError, World, attach_redirect, ensure, fail, json_body, open_rate_limit, seed_client,
  33. seed_scope,
  34. };
  35. use reqwest::StatusCode;
  36. /// Catalog resource pinned into every grant under test.
  37. const RESOURCE: &str = "https://rs-stale.example.test/api";
  38. /// Registered loopback redirect for the authorization-code flow.
  39. const REDIRECT_URI: &str = "http://127.0.0.1:9/cb";
  40. /// Confidential refresh client.
  41. const REFRESH_CLIENT: &str = "e2e-stale-refresh";
  42. /// Refresh client secret.
  43. const REFRESH_SECRET: &str = "e2e-stale-refresh-secret-1a2b3c4d5e6f";
  44. /// Public browser client (unbound: no DPoP proof needed).
  45. const PUBLIC_CLIENT: &str = "e2e-stale-public";
  46. /// Confidential device client.
  47. const DEVICE_CLIENT: &str = "e2e-stale-device";
  48. /// Device client secret.
  49. const DEVICE_SECRET: &str = "e2e-stale-device-secret-6f5e4d3c2b1a";
  50. /// Provisioned login email.
  51. const EMAIL: &str = "stale-catalog@example.test";
  52. /// Provisioned login password.
  53. const PASSWORD: &str = "e2e-stale-catalog-password-1a2b";
  54. /// Start one world with the RFC 8707 gate on and one enabled catalog row.
  55. ///
  56. /// Returns the world plus the row id needed to re-enable it: there is no
  57. /// public enable helper, and `upsert` deliberately preserves `enabled=false`.
  58. async fn start_world() -> Result<(World, uuid::Uuid), TestError> {
  59. let world = World::start_with(
  60. open_rate_limit(),
  61. serde_json::json!({"resource_indicators_enabled": true}),
  62. )
  63. .await?;
  64. let row = resource_repo::upsert(&world.pool, RESOURCE, "stale catalog e2e")
  65. .await
  66. .map_err(|error| fail(format!("resource upsert failed: {error}")))?;
  67. Ok((world, row.id))
  68. }
  69. /// Re-enable a catalog row through direct SQL (no production enable helper).
  70. async fn enable_resource(pool: &sqlx::PgPool, id: uuid::Uuid) -> Result<(), TestError> {
  71. sqlx::query("UPDATE resource_servers SET enabled = true WHERE id = $1")
  72. .bind(id)
  73. .execute(pool)
  74. .await?;
  75. Ok(())
  76. }
  77. /// Disable the catalog row, proving the update applied.
  78. async fn disable_resource(world: &World) -> Result<(), TestError> {
  79. ensure(
  80. resource_repo::disable(&world.pool, RESOURCE)
  81. .await
  82. .map_err(|error| fail(format!("resource disable failed: {error}")))?,
  83. "disable must update the seeded row",
  84. )
  85. }
  86. /// Assert a token endpoint failure carries the exact OAuth code and mints
  87. /// nothing.
  88. fn ensure_error(status: StatusCode, body: &str, code: &str) -> Result<(), TestError> {
  89. ensure(
  90. status == StatusCode::BAD_REQUEST,
  91. format!("must be 400, got {status}: {body}"),
  92. )?;
  93. let value = json_body(body)?;
  94. ensure(
  95. value["error"] == code,
  96. format!("failure must be {code}, got {value}"),
  97. )?;
  98. ensure(!body.contains("access_token"), "failure must mint nothing")
  99. }
  100. /// Assert a refresh token still resolves live in the store.
  101. async fn ensure_refresh_live(world: &World, token: &str) -> Result<(), TestError> {
  102. let mut conn = world.state.redis.clone();
  103. ensure(
  104. world
  105. .service
  106. .find_refresh(&mut conn, &world.store, token)
  107. .await
  108. .map_err(|error| fail(format!("refresh lookup failed: {error}")))?
  109. .is_some(),
  110. "the rejected refresh token must stay live",
  111. )
  112. }
  113. /// One authorization-code browser flow that pins `resource`.
  114. struct ResourceFlow<'a> {
  115. /// Public client driving the flow.
  116. client_id: &'a str,
  117. /// Registered callback.
  118. redirect_uri: &'a str,
  119. /// Requested scope.
  120. scope: &'a str,
  121. /// Audience the authorize request pins.
  122. resource: &'a str,
  123. /// Provisioned account email.
  124. email: &'a str,
  125. /// Provisioned account password.
  126. password: &'a str,
  127. /// Provisioned account TOTP secret.
  128. totp_secret: &'a str,
  129. }
  130. /// Drive `GET /authorize` (with `resource`) through login and consent and
  131. /// return the issued code plus the PKCE verifier.
  132. async fn authorize_with_resource(
  133. world: &World,
  134. flow: &ResourceFlow<'_>,
  135. ) -> Result<(String, String), TestError> {
  136. let (challenge, verifier) = browser::oauth2_pkce();
  137. let (status, headers, body) = world
  138. .get(
  139. &format!(
  140. "/authorize?client_id={}&redirect_uri={}&response_type=code&scope={}&state=e2e-stale\
  141. &resource={}&code_challenge={challenge}&code_challenge_method=S256",
  142. browser::encoded(flow.client_id),
  143. browser::encoded(flow.redirect_uri),
  144. browser::encoded(flow.scope),
  145. browser::encoded(flow.resource)
  146. ),
  147. &[],
  148. )
  149. .await?;
  150. ensure(
  151. status == StatusCode::SEE_OTHER,
  152. format!("authorize must 303 to login: {status} {body}"),
  153. )?;
  154. let request_id = browser::query_value(&browser::location_of(&headers)?, "request_id")?;
  155. let (status, headers, body) = world
  156. .get(&format!("/login?request_id={request_id}"), &[])
  157. .await?;
  158. ensure(
  159. status == StatusCode::OK,
  160. format!("login page must render: {status} {body}"),
  161. )?;
  162. let presession = browser::cookie_value(&headers, browser::SESSION_COOKIE)?;
  163. let login_csrf = browser::hidden_input(&body, "csrf")?;
  164. let code_2fa = browser::totp_now(flow.totp_secret).await?;
  165. let cookie = format!("{}={presession}", browser::SESSION_COOKIE);
  166. let (status, headers, body) = world
  167. .post_form(
  168. "/login",
  169. &[
  170. ("email", flow.email),
  171. ("password", flow.password),
  172. ("code", code_2fa.as_str()),
  173. ("csrf", login_csrf.as_str()),
  174. ],
  175. None,
  176. &[("cookie", cookie.as_str())],
  177. )
  178. .await?;
  179. ensure(
  180. status == StatusCode::SEE_OTHER,
  181. format!("login must 303 to consent: {status} {body}"),
  182. )?;
  183. ensure(
  184. browser::location_of(&headers)? == "/consent",
  185. "login must land on consent",
  186. )?;
  187. let session = browser::cookie_value(&headers, browser::SESSION_COOKIE)?;
  188. let cookie = format!("{}={session}", browser::SESSION_COOKIE);
  189. let (status, _, body) = world
  190. .get("/consent", &[("cookie", cookie.as_str())])
  191. .await?;
  192. ensure(
  193. status == StatusCode::OK,
  194. format!("consent must render: {status} {body}"),
  195. )?;
  196. ensure(
  197. browser::hidden_input(&body, "request_id")? == request_id,
  198. "consent must serve the bound authorization request",
  199. )?;
  200. let consent_csrf = browser::hidden_input(&body, "csrf")?;
  201. let (status, headers, body) = world
  202. .post_form(
  203. "/consent",
  204. &[
  205. ("request_id", request_id.as_str()),
  206. ("csrf", consent_csrf.as_str()),
  207. ("decision", "approve"),
  208. ],
  209. None,
  210. &[("cookie", cookie.as_str())],
  211. )
  212. .await?;
  213. ensure(
  214. status == StatusCode::SEE_OTHER,
  215. format!("consent approval must 303 to the redirect: {status} {body}"),
  216. )?;
  217. let location = browser::location_of(&headers)?;
  218. ensure(
  219. location.starts_with(flow.redirect_uri),
  220. format!("code must return to the registered redirect, got {location}"),
  221. )?;
  222. Ok((browser::query_value(&location, "code")?, verifier))
  223. }
  224. /// A resource disabled after issuance rejects refresh with `invalid_target`
  225. /// before rotation, leaves the presented token live, and the same token
  226. /// rotates once the catalog row is re-enabled.
  227. #[tokio::test]
  228. async fn e2e_refresh_rejects_disabled_resource_as_invalid_target() -> Result<(), TestError> {
  229. let (world, resource_id) = start_world().await?;
  230. seed_client(
  231. &world.pool,
  232. &world.state.keys,
  233. REFRESH_CLIENT,
  234. Some(REFRESH_SECRET),
  235. &["refresh_token"],
  236. "read",
  237. TokenEndpointAuthMethod::ClientSecretBasic,
  238. )
  239. .await?;
  240. seed_scope(&world.pool, "read").await?;
  241. let minted = world
  242. .mint_with_aud(
  243. REFRESH_CLIENT,
  244. "read",
  245. ClientKind::Confidential,
  246. None,
  247. RESOURCE,
  248. )
  249. .await?;
  250. disable_resource(&world).await?;
  251. let refresh = [
  252. ("grant_type", "refresh_token"),
  253. ("refresh_token", minted.refresh_token.as_str()),
  254. ];
  255. let basic = Some((REFRESH_CLIENT, REFRESH_SECRET));
  256. let (status, _, body) = world.post_form("/token", &refresh, basic, &[]).await?;
  257. ensure_error(status, &body, "invalid_target")?;
  258. ensure_refresh_live(&world, &minted.refresh_token).await?;
  259. enable_resource(&world.pool, resource_id).await?;
  260. let (status, _, body) = world.post_form("/token", &refresh, basic, &[]).await?;
  261. ensure(
  262. status == StatusCode::OK,
  263. format!("the re-enabled audience must rotate: {status} {body}"),
  264. )?;
  265. ensure(
  266. json_body(&body)?["refresh_token"]
  267. .as_str()
  268. .is_some_and(|token| !token.is_empty()),
  269. "the successful rotation must return a refresh token",
  270. )
  271. }
  272. /// A device code pinned to a resource that is disabled before the poll fails
  273. /// `invalid_target`; the approval was already consumed, so a retry is
  274. /// terminal `expired_token` and no token was ever minted.
  275. #[tokio::test]
  276. async fn e2e_device_poll_rejects_disabled_resource() -> Result<(), TestError> {
  277. let (world, _resource_id) = start_world().await?;
  278. seed_client(
  279. &world.pool,
  280. &world.state.keys,
  281. DEVICE_CLIENT,
  282. Some(DEVICE_SECRET),
  283. &[device::DEVICE_GRANT],
  284. "read",
  285. TokenEndpointAuthMethod::ClientSecretBasic,
  286. )
  287. .await?;
  288. seed_scope(&world.pool, "read").await?;
  289. let (status, _, body) = world
  290. .post_form(
  291. "/device_authorization",
  292. &[
  293. ("client_id", DEVICE_CLIENT),
  294. ("scope", "read"),
  295. ("resource", RESOURCE),
  296. ],
  297. Some((DEVICE_CLIENT, DEVICE_SECRET)),
  298. &[],
  299. )
  300. .await?;
  301. ensure(
  302. status == StatusCode::OK,
  303. format!("device authorization must 200: {status} {body}"),
  304. )?;
  305. let payload = json_body(&body)?;
  306. let device_code = payload["device_code"]
  307. .as_str()
  308. .ok_or_else(|| fail("device authorization must return device_code"))?
  309. .to_owned();
  310. let user_code = payload["user_code"]
  311. .as_str()
  312. .ok_or_else(|| fail("device authorization must return user_code"))?
  313. .to_owned();
  314. disable_resource(&world).await?;
  315. let browser = browser::login_session(world, EMAIL, PASSWORD).await?;
  316. let session = browser
  317. .session
  318. .as_deref()
  319. .ok_or_else(|| fail("login_session must return an authenticated session"))?;
  320. let page = device::review(&browser.world, session, &user_code).await?;
  321. let csrf = browser::hidden_input(&page, "csrf")?;
  322. device::decide(
  323. &browser.world,
  324. device::Decision {
  325. session,
  326. user_code: &user_code,
  327. csrf: &csrf,
  328. action: "approve",
  329. },
  330. )
  331. .await?;
  332. let client = device::Client {
  333. id: DEVICE_CLIENT,
  334. secret: Some(DEVICE_SECRET),
  335. scope: "read",
  336. };
  337. let key = DpopKey::generate()?;
  338. let request = device::Poll {
  339. client,
  340. device_code: &device_code,
  341. };
  342. let (status, body) = device::poll(&browser.world, request, &key).await?;
  343. ensure_error(status, &body, "invalid_target")?;
  344. let (status, body) = device::poll(&browser.world, request, &key).await?;
  345. ensure(
  346. status == StatusCode::BAD_REQUEST,
  347. format!("the consumed device code must be 400: {status} {body}"),
  348. )?;
  349. ensure(
  350. json_body(&body)?["error"] == "expired_token",
  351. format!("the consumed device code must be expired_token: {body}"),
  352. )?;
  353. ensure(
  354. !body.contains("access_token"),
  355. "the retry must mint nothing",
  356. )
  357. }
  358. /// A disabled `resource` on `/authorize` redirects with `invalid_target` and
  359. /// issues no code.
  360. #[tokio::test]
  361. async fn e2e_authorize_rejects_disabled_resource_before_code() -> Result<(), TestError> {
  362. let (world, _resource_id) = start_world().await?;
  363. seed_client(
  364. &world.pool,
  365. &world.state.keys,
  366. PUBLIC_CLIENT,
  367. None,
  368. &["authorization_code", "refresh_token"],
  369. "read",
  370. TokenEndpointAuthMethod::None,
  371. )
  372. .await?;
  373. seed_scope(&world.pool, "read").await?;
  374. attach_redirect(&world.pool, &world.state.keys, PUBLIC_CLIENT, REDIRECT_URI).await?;
  375. disable_resource(&world).await?;
  376. let (challenge, _verifier) = browser::oauth2_pkce();
  377. let (status, headers, body) = world
  378. .get(
  379. &format!(
  380. "/authorize?client_id={PUBLIC_CLIENT}&redirect_uri={}&response_type=code&scope=read\
  381. &resource={}&code_challenge={challenge}&code_challenge_method=S256",
  382. browser::encoded(REDIRECT_URI),
  383. browser::encoded(RESOURCE)
  384. ),
  385. &[],
  386. )
  387. .await?;
  388. ensure(
  389. status == StatusCode::FOUND,
  390. format!("a disabled resource must redirect, got {status}: {body}"),
  391. )?;
  392. let location = browser::location_of(&headers)?;
  393. ensure(
  394. location.starts_with(REDIRECT_URI),
  395. format!("the error must redirect to the client, got {location}"),
  396. )?;
  397. ensure(
  398. location.contains("error=invalid_target"),
  399. format!("the redirect must carry invalid_target, got {location}"),
  400. )?;
  401. ensure(
  402. !browser::has_query_key(&location, "code"),
  403. "a rejected authorize must never issue a code",
  404. )
  405. }
  406. /// A code issued with an audience stays live when that resource is disabled
  407. /// before redemption: the presented audience is invalid_target, never a
  408. /// burned code, and the same code redeems after re-enable.
  409. #[tokio::test]
  410. async fn e2e_auth_code_rejects_resource_disabled_after_authorize() -> Result<(), TestError> {
  411. let (world, resource_id) = start_world().await?;
  412. seed_client(
  413. &world.pool,
  414. &world.state.keys,
  415. PUBLIC_CLIENT,
  416. None,
  417. &["authorization_code", "refresh_token"],
  418. "read",
  419. TokenEndpointAuthMethod::None,
  420. )
  421. .await?;
  422. attach_redirect(&world.pool, &world.state.keys, PUBLIC_CLIENT, REDIRECT_URI).await?;
  423. seed_scope(&world.pool, "read").await?;
  424. let totp_secret = browser::seed_user(&world, EMAIL, PASSWORD, "read").await?;
  425. let (code, verifier) = authorize_with_resource(
  426. &world,
  427. &ResourceFlow {
  428. client_id: PUBLIC_CLIENT,
  429. redirect_uri: REDIRECT_URI,
  430. scope: "read",
  431. resource: RESOURCE,
  432. email: EMAIL,
  433. password: PASSWORD,
  434. totp_secret: &totp_secret,
  435. },
  436. )
  437. .await?;
  438. disable_resource(&world).await?;
  439. let redemption = [
  440. ("grant_type", "authorization_code"),
  441. ("code", code.as_str()),
  442. ("redirect_uri", REDIRECT_URI),
  443. ("client_id", PUBLIC_CLIENT),
  444. ("code_verifier", verifier.as_str()),
  445. ("resource", RESOURCE),
  446. ];
  447. let (status, _, body) = world.post_form("/token", &redemption, None, &[]).await?;
  448. ensure_error(status, &body, "invalid_target")?;
  449. enable_resource(&world.pool, resource_id).await?;
  450. let (status, _, body) = world.post_form("/token", &redemption, None, &[]).await?;
  451. ensure(
  452. status == StatusCode::OK,
  453. format!("the live code must redeem after re-enable: {status} {body}"),
  454. )?;
  455. let tokens = json_body(&body)?;
  456. ensure(
  457. tokens["access_token"]
  458. .as_str()
  459. .is_some_and(|token| !token.is_empty()),
  460. "the re-enabled redeem must return an access token",
  461. )?;
  462. ensure(
  463. tokens["refresh_token"]
  464. .as_str()
  465. .is_some_and(|token| !token.is_empty()),
  466. "the re-enabled redeem must return a refresh token",
  467. )
  468. }
  469. /// A scope disabled after issuance drops from the rotated refresh token while
  470. /// the grant stays live; re-enabling it never widens the grant back.
  471. #[tokio::test]
  472. async fn e2e_refresh_drops_disabled_scope_but_stays_live() -> Result<(), TestError> {
  473. let (world, _resource_id) = start_world().await?;
  474. seed_client(
  475. &world.pool,
  476. &world.state.keys,
  477. REFRESH_CLIENT,
  478. Some(REFRESH_SECRET),
  479. &["refresh_token"],
  480. "read write",
  481. TokenEndpointAuthMethod::ClientSecretBasic,
  482. )
  483. .await?;
  484. seed_scope(&world.pool, "read").await?;
  485. seed_scope(&world.pool, "write").await?;
  486. let minted = world
  487. .mint(REFRESH_CLIENT, "read write", ClientKind::Confidential, None)
  488. .await?;
  489. ensure(
  490. scope_repo::set_enabled(&world.pool, "write", false)
  491. .await
  492. .map_err(|error| fail(format!("scope disable failed: {error}")))?,
  493. "write must be a seeded catalog row",
  494. )?;
  495. let basic = Some((REFRESH_CLIENT, REFRESH_SECRET));
  496. let (status, _, body) = world
  497. .post_form(
  498. "/token",
  499. &[
  500. ("grant_type", "refresh_token"),
  501. ("refresh_token", minted.refresh_token.as_str()),
  502. ],
  503. basic,
  504. &[],
  505. )
  506. .await?;
  507. ensure(
  508. status == StatusCode::OK,
  509. format!("a narrowed refresh must still rotate: {status} {body}"),
  510. )?;
  511. let rotated = json_body(&body)?;
  512. ensure(
  513. rotated["scope"] == "read",
  514. format!("the disabled scope must drop, got {rotated}"),
  515. )?;
  516. let rotated_refresh = rotated["refresh_token"]
  517. .as_str()
  518. .filter(|token| !token.is_empty())
  519. .ok_or_else(|| fail("the narrowed rotation must return a refresh token"))?
  520. .to_owned();
  521. ensure(
  522. scope_repo::set_enabled(&world.pool, "write", true)
  523. .await
  524. .map_err(|error| fail(format!("scope enable failed: {error}")))?,
  525. "write must still be a catalog row",
  526. )?;
  527. let (status, _, body) = world
  528. .post_form(
  529. "/token",
  530. &[
  531. ("grant_type", "refresh_token"),
  532. ("refresh_token", rotated_refresh.as_str()),
  533. ("scope", "write"),
  534. ],
  535. basic,
  536. &[],
  537. )
  538. .await?;
  539. ensure_error(status, &body, "invalid_scope")?;
  540. let (status, _, body) = world
  541. .post_form(
  542. "/token",
  543. &[
  544. ("grant_type", "refresh_token"),
  545. ("refresh_token", rotated_refresh.as_str()),
  546. ],
  547. basic,
  548. &[],
  549. )
  550. .await?;
  551. ensure(
  552. status == StatusCode::OK,
  553. format!("the still-live refresh must rotate: {status} {body}"),
  554. )?;
  555. ensure(
  556. json_body(&body)?["scope"] == "read",
  557. "the re-enabled scope must never be widened back onto the grant",
  558. )
  559. }