e2e_admin.rs 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451
  1. //! `binary(e2e_admin)` disabled-user cascade over the live router.
  2. //!
  3. //! `user disable` purges the subject's Redis state BEFORE flipping the
  4. //! Postgres flag (`src/cli/user.rs:105-137`), so this suite drives both halves
  5. //! in that order. The purge removes the session, the unredeemed code, and the
  6. //! approved device record; the flag kills introspection and refresh at their
  7. //! use-time re-checks. A flag-only disable would still fail the token checks
  8. //! but leave the code, the device record, and the session alive — exactly the
  9. //! three records whose assertions here fail without the purge.
  10. //!
  11. //! The browser flow and device wire mechanics come from the shared
  12. //! [`browser`]/[`device`] helpers. User-token subjects are the canonical
  13. //! EMAIL, which is what `purge_subject` and the use-time re-checks resolve.
  14. //! The approved device record must carry that subject too: a merely pending
  15. //! record stores `subject: ""` and the purge never sees it
  16. //! (`src/store/device.rs:49-50`, `src/protocol/purge.rs:131-145`).
  17. // allow: SIZE_OK — the plan fixes both cascade scenarios in this one suite
  18. // file, and the browser/device/harness modules are owned by sibling todos.
  19. // The extra length is assertion messages, not duplicated driver logic.
  20. #[path = "e2e/harness.rs"]
  21. #[allow(dead_code)]
  22. mod harness;
  23. #[path = "e2e/browser.rs"]
  24. #[allow(dead_code)]
  25. mod browser;
  26. #[path = "e2e/dpop.rs"]
  27. #[allow(dead_code)]
  28. mod dpop;
  29. #[path = "e2e/device.rs"]
  30. #[allow(dead_code)]
  31. mod device;
  32. use browser::{
  33. CLIENT_ID, INVALID_CREDENTIALS, REDIRECT_URI, SESSION_COOKIE, begin_browser, cookie_value,
  34. encoded, get_consent, hidden_input, location_of, oauth2_pkce, post_consent, post_login,
  35. query_value, totp_now,
  36. };
  37. use d_oauth::{
  38. db::{TokenEndpointAuthMethod, user_repo},
  39. protocol::{
  40. purge::{PurgedSubject, purge_subject},
  41. token::ClientKind,
  42. },
  43. };
  44. use device::{
  45. Client as DeviceClient, Decision, Poll, decide, issue, poll, review, seed_public_client,
  46. };
  47. use dpop::DpopKey;
  48. use harness::{TestError, World, ensure, fail, json_body, open_rate_limit, seed_client};
  49. use reqwest::StatusCode;
  50. /// Provisioned user in the cascade case.
  51. const CASCADE_EMAIL: &str = "e2e-admin-cascade@example.test";
  52. /// Provisioned password in the cascade case.
  53. const CASCADE_PASSWORD: &str = "e2e-admin-cascade-password-1a2b";
  54. /// Confidential client used to introspect the minted tokens.
  55. const INTROSPECT_CLIENT: &str = "e2e-admin-introspect";
  56. /// Secret for [`INTROSPECT_CLIENT`].
  57. const INTROSPECT_SECRET: &str = "e2e-admin-introspect-secret-1a2b3c4d5e6f";
  58. /// Provisioned user in the re-login case.
  59. const RELOGIN_EMAIL: &str = "e2e-admin-relogin@example.test";
  60. /// Provisioned password in the re-login case.
  61. const RELOGIN_PASSWORD: &str = "e2e-admin-relogin-password-1a2b";
  62. /// Flip the flag exactly like the CLI: purge the subject's Redis state first,
  63. /// then set `users.disabled`. Returns the purge summary.
  64. async fn disable_like_cli(world: &World, email: &str) -> Result<PurgedSubject, TestError> {
  65. let record = user_repo::find_by_email(&world.pool, &world.state.keys, email)
  66. .await?
  67. .ok_or_else(|| fail("seeded user must resolve by email"))?;
  68. let mut conn = world.state.redis.clone();
  69. let summary = purge_subject(
  70. &mut conn,
  71. &world.store,
  72. &record.email,
  73. &record.id.to_string(),
  74. )
  75. .await?;
  76. ensure(
  77. user_repo::disable_user(&world.pool, record.id).await?,
  78. "disable_user must update the user row",
  79. )?;
  80. Ok(summary)
  81. }
  82. /// Introspect `token` as the confidential fixture client.
  83. async fn introspect(world: &World, token: &str) -> Result<serde_json::Value, TestError> {
  84. let (status, _, body) = world
  85. .post_form(
  86. "/introspect",
  87. &[("token", token)],
  88. Some((INTROSPECT_CLIENT, INTROSPECT_SECRET)),
  89. &[],
  90. )
  91. .await?;
  92. ensure(
  93. status == StatusCode::OK,
  94. format!("introspection must answer 200: {status} {body}"),
  95. )?;
  96. json_body(&body)
  97. }
  98. /// Drive one more `/authorize` → `/consent` leg on an authenticated session
  99. /// and return the fresh (unredeemed) code with its PKCE verifier.
  100. async fn consent_for_new_code(
  101. browser: &browser::Browser,
  102. session: &str,
  103. ) -> Result<(String, String), TestError> {
  104. let (challenge, verifier) = oauth2_pkce();
  105. let issuer = &browser.world.state.config.issuer;
  106. let proof = browser.dpop.proof("GET", &format!("{issuer}/authorize"))?;
  107. let cookie = format!("{SESSION_COOKIE}={session}");
  108. let path = format!(
  109. "/authorize?client_id={}&redirect_uri={}&response_type=code&scope={}&state={}\
  110. &code_challenge={challenge}&code_challenge_method=S256",
  111. encoded(CLIENT_ID),
  112. encoded(REDIRECT_URI),
  113. encoded(browser::SCOPE),
  114. encoded(browser::STATE),
  115. );
  116. let (status, headers, body) = browser
  117. .world
  118. .get(
  119. &path,
  120. &[("dpop", proof.as_str()), ("cookie", cookie.as_str())],
  121. )
  122. .await?;
  123. ensure(
  124. status == StatusCode::SEE_OTHER,
  125. format!("second authorize must 303 to login: {status} {body}"),
  126. )?;
  127. let request_id = query_value(&location_of(&headers)?, "request_id")?;
  128. let (status, headers, body) = browser
  129. .world
  130. .get(
  131. &format!("/login?request_id={request_id}"),
  132. &[("cookie", cookie.as_str())],
  133. )
  134. .await?;
  135. ensure(
  136. status == StatusCode::SEE_OTHER,
  137. format!("an authenticated login must 303 onward: {status} {body}"),
  138. )?;
  139. ensure(
  140. location_of(&headers)? == "/consent",
  141. "an authenticated session must land on consent",
  142. )?;
  143. let (status, _, body) = get_consent(browser, session).await?;
  144. ensure(
  145. status == StatusCode::OK,
  146. format!("second consent must render: {status} {body}"),
  147. )?;
  148. let bound = hidden_input(&body, "request_id")?;
  149. ensure(
  150. bound == request_id,
  151. format!("consent must serve the new request {request_id}, got {bound}"),
  152. )?;
  153. let csrf = hidden_input(&body, "csrf")?;
  154. let (status, headers, body) = post_consent(browser, session, &bound, &csrf, "approve").await?;
  155. ensure(
  156. status == StatusCode::SEE_OTHER,
  157. format!("second consent must 303 to the redirect: {status} {body}"),
  158. )?;
  159. let location = location_of(&headers)?;
  160. ensure(
  161. location.starts_with(REDIRECT_URI),
  162. format!("the new code must return to the registered redirect: {location}"),
  163. )?;
  164. Ok((query_value(&location, "code")?, verifier))
  165. }
  166. /// Disabling a real user purges the session, the unredeemed code, and the
  167. /// approved device record, and every use of the old material fails closed.
  168. #[tokio::test]
  169. async fn e2e_user_disable_kills_tokens_refresh_and_code() -> Result<(), TestError> {
  170. let world = World::start(open_rate_limit()).await?;
  171. let browser = begin_browser(world, CASCADE_EMAIL, CASCADE_PASSWORD).await?;
  172. let two_factor = totp_now(&browser.totp_secret).await?;
  173. let (status, headers, body) =
  174. post_login(&browser, &browser.password.clone(), &two_factor).await?;
  175. ensure(
  176. status == StatusCode::SEE_OTHER,
  177. format!("login must 303 to consent: {status} {body}"),
  178. )?;
  179. let session = cookie_value(&headers, SESSION_COOKIE)?;
  180. let (status, _, body) = get_consent(&browser, &session).await?;
  181. ensure(
  182. status == StatusCode::OK,
  183. format!("consent must render: {status} {body}"),
  184. )?;
  185. let request_id = hidden_input(&body, "request_id")?;
  186. let csrf = hidden_input(&body, "csrf")?;
  187. let (status, headers, body) =
  188. post_consent(&browser, &session, &request_id, &csrf, "approve").await?;
  189. ensure(
  190. status == StatusCode::SEE_OTHER,
  191. format!("consent approval must 303: {status} {body}"),
  192. )?;
  193. let code = query_value(&location_of(&headers)?, "code")?;
  194. // Redeem the first code for the real access/refresh pair.
  195. let proof = browser.dpop.proof(
  196. "POST",
  197. &format!("{}/token", browser.world.state.config.issuer),
  198. )?;
  199. let (status, _, body) = browser
  200. .world
  201. .post_form(
  202. "/token",
  203. &[
  204. ("grant_type", "authorization_code"),
  205. ("code", code.as_str()),
  206. ("redirect_uri", REDIRECT_URI),
  207. ("client_id", CLIENT_ID),
  208. ("code_verifier", browser.verifier.as_str()),
  209. ],
  210. None,
  211. &[("dpop", proof.as_str())],
  212. )
  213. .await?;
  214. ensure(
  215. status == StatusCode::OK,
  216. format!("code redemption must mint: {status} {body}"),
  217. )?;
  218. let tokens = json_body(&body)?;
  219. let access_token = tokens["access_token"]
  220. .as_str()
  221. .filter(|value| !value.is_empty())
  222. .ok_or_else(|| fail("redemption must return an access token"))?
  223. .to_owned();
  224. let refresh_token = tokens["refresh_token"]
  225. .as_str()
  226. .filter(|value| !value.is_empty())
  227. .ok_or_else(|| fail("redemption must return a refresh token"))?
  228. .to_owned();
  229. // A second, unredeemed code for the post-purge redemption check.
  230. let (fresh_code, fresh_verifier) = consent_for_new_code(&browser, &session).await?;
  231. // Approve a device code through the real UI so its record carries the
  232. // subject the purge will match. A pending record's empty subject is
  233. // invisible to `purge_subject`.
  234. seed_public_client(&browser.world).await?;
  235. let device_key = DpopKey::generate()?;
  236. let device_client = DeviceClient {
  237. id: device::PUBLIC_CLIENT,
  238. secret: None,
  239. scope: device::SCOPE,
  240. };
  241. let (device_code, user_code) = issue(&browser.world, device_client, &device_key).await?;
  242. let page = review(&browser.world, &session, &user_code).await?;
  243. let device_csrf = hidden_input(&page, "csrf")?;
  244. decide(
  245. &browser.world,
  246. Decision {
  247. session: session.as_str(),
  248. user_code: user_code.as_str(),
  249. csrf: device_csrf.as_str(),
  250. action: "approve",
  251. },
  252. )
  253. .await?;
  254. // A confidential fixture token proves introspection reads the live subject
  255. // before the cascade and the dead one after it.
  256. seed_client(
  257. &browser.world.pool,
  258. &browser.world.state.keys,
  259. INTROSPECT_CLIENT,
  260. Some(INTROSPECT_SECRET),
  261. &["authorization_code"],
  262. browser::SCOPE,
  263. TokenEndpointAuthMethod::ClientSecretBasic,
  264. )
  265. .await?;
  266. let fixture = browser
  267. .world
  268. .mint_for_subject(
  269. INTROSPECT_CLIENT,
  270. CASCADE_EMAIL,
  271. browser::SCOPE,
  272. ClientKind::Confidential,
  273. None,
  274. )
  275. .await?;
  276. ensure(
  277. introspect(&browser.world, &fixture.access_token).await?["active"] == true,
  278. "the fixture access token must start active",
  279. )?;
  280. // CLI order: purge the Redis state, then flip the flag.
  281. let summary = disable_like_cli(&browser.world, CASCADE_EMAIL).await?;
  282. ensure(
  283. summary.sessions >= 1,
  284. format!("the purge must delete the login session, got {summary:?}"),
  285. )?;
  286. ensure(
  287. summary.codes >= 1,
  288. format!("the purge must delete the unredeemed code, got {summary:?}"),
  289. )?;
  290. ensure(
  291. summary.devices == 1,
  292. format!("the purge must delete the approved device record, got {summary:?}"),
  293. )?;
  294. ensure(
  295. summary.grants >= 2,
  296. format!("the purge must revoke both token pairs' grant families, got {summary:?}"),
  297. )?;
  298. // 1. Introspection: the fixture token is owned by the introspecting client,
  299. // so its inactive answer proves the purge. The browser token is foreign-owned,
  300. // where the ownership check alone would also answer inactive.
  301. ensure(
  302. introspect(&browser.world, &fixture.access_token).await?["active"] == false,
  303. "the fixture access token must introspect inactive after disable",
  304. )?;
  305. ensure(
  306. introspect(&browser.world, &access_token).await?["active"] == false,
  307. "the browser access token must introspect inactive after disable",
  308. )?;
  309. // 2. Refresh: the record is gone, so the grant is invalid.
  310. let proof = browser.dpop.proof(
  311. "POST",
  312. &format!("{}/token", browser.world.state.config.issuer),
  313. )?;
  314. let (status, _, body) = browser
  315. .world
  316. .post_form(
  317. "/token",
  318. &[
  319. ("grant_type", "refresh_token"),
  320. ("refresh_token", refresh_token.as_str()),
  321. ("client_id", CLIENT_ID),
  322. ],
  323. None,
  324. &[("dpop", proof.as_str())],
  325. )
  326. .await?;
  327. ensure(
  328. status == StatusCode::BAD_REQUEST,
  329. format!("refresh must be 400: {status} {body}"),
  330. )?;
  331. ensure(
  332. json_body(&body)?["error"] == "invalid_grant",
  333. "refresh must be invalid_grant",
  334. )?;
  335. // 3. Authorization code: the unredeemed record is gone.
  336. let proof = browser.dpop.proof(
  337. "POST",
  338. &format!("{}/token", browser.world.state.config.issuer),
  339. )?;
  340. let (status, _, body) = browser
  341. .world
  342. .post_form(
  343. "/token",
  344. &[
  345. ("grant_type", "authorization_code"),
  346. ("code", fresh_code.as_str()),
  347. ("redirect_uri", REDIRECT_URI),
  348. ("client_id", CLIENT_ID),
  349. ("code_verifier", fresh_verifier.as_str()),
  350. ],
  351. None,
  352. &[("dpop", proof.as_str())],
  353. )
  354. .await?;
  355. ensure(
  356. status == StatusCode::BAD_REQUEST,
  357. format!("code redemption must be 400: {status} {body}"),
  358. )?;
  359. ensure(
  360. json_body(&body)?["error"] == "invalid_grant",
  361. "code redemption must be invalid_grant",
  362. )?;
  363. // 4. Device: the approved record is gone, so polling is terminal
  364. // `expired_token` (never a mint, never `invalid_grant`).
  365. let (status, body) = poll(
  366. &browser.world,
  367. Poll {
  368. client: device_client,
  369. device_code: device_code.as_str(),
  370. },
  371. &device_key,
  372. )
  373. .await?;
  374. ensure(
  375. status == StatusCode::BAD_REQUEST,
  376. format!("the purged device poll must be 400: {status} {body}"),
  377. )?;
  378. ensure(
  379. json_body(&body)?["error"] == "expired_token",
  380. "the purged device code must be expired_token",
  381. )?;
  382. // 5. Session: the old cookie no longer authenticates a consent page.
  383. let (status, headers, _) = get_consent(&browser, &session).await?;
  384. ensure(
  385. status == StatusCode::SEE_OTHER,
  386. format!("a purged session must bounce from consent: {status}"),
  387. )?;
  388. ensure(
  389. location_of(&headers)? == "/login",
  390. "the purged session must bounce to /login",
  391. )?;
  392. Ok(())
  393. }
  394. /// A disabled account's correct password + TOTP is still the uniform 401 and
  395. /// never upgrades the pre-session.
  396. #[tokio::test]
  397. async fn e2e_disabled_user_cannot_relogin() -> Result<(), TestError> {
  398. let world = World::start(open_rate_limit()).await?;
  399. let browser = begin_browser(world, RELOGIN_EMAIL, RELOGIN_PASSWORD).await?;
  400. disable_like_cli(&browser.world, RELOGIN_EMAIL).await?;
  401. let two_factor = totp_now(&browser.totp_secret).await?;
  402. let (status, headers, body) =
  403. post_login(&browser, &browser.password.clone(), &two_factor).await?;
  404. ensure(
  405. status == StatusCode::UNAUTHORIZED,
  406. format!("a disabled account must be 401: {status} {body}"),
  407. )?;
  408. ensure(
  409. body.contains(INVALID_CREDENTIALS),
  410. "the rejection must render the uniform credential message",
  411. )?;
  412. ensure(
  413. cookie_value(&headers, SESSION_COOKIE).is_err(),
  414. "a disabled account must not upgrade the pre-session",
  415. )?;
  416. let (status, headers, _) = get_consent(&browser, &browser.presession).await?;
  417. ensure(
  418. status == StatusCode::SEE_OTHER,
  419. format!("without a session consent must bounce to login: {status}"),
  420. )?;
  421. ensure(
  422. location_of(&headers)? == "/login",
  423. "the bounce target must be /login",
  424. )?;
  425. Ok(())
  426. }