e2e_stale_catalog.rs 18 KB

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