e2e_admin.rs 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441
  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. let csrf = hidden_input(&body, "csrf")?;
  150. let (status, headers, body) = post_consent(browser, session, &bound, &csrf, "approve").await?;
  151. ensure(
  152. status == StatusCode::SEE_OTHER,
  153. format!("second consent must 303 to the redirect: {status} {body}"),
  154. )?;
  155. let location = location_of(&headers)?;
  156. ensure(
  157. location.starts_with(REDIRECT_URI),
  158. format!("the new code must return to the registered redirect: {location}"),
  159. )?;
  160. Ok((query_value(&location, "code")?, verifier))
  161. }
  162. /// Disabling a real user purges the session, the unredeemed code, and the
  163. /// approved device record, and every use of the old material fails closed.
  164. #[tokio::test]
  165. async fn e2e_user_disable_kills_tokens_refresh_and_code() -> Result<(), TestError> {
  166. let world = World::start(open_rate_limit()).await?;
  167. let browser = begin_browser(world, CASCADE_EMAIL, CASCADE_PASSWORD).await?;
  168. let two_factor = totp_now(&browser.totp_secret).await?;
  169. let (status, headers, body) =
  170. post_login(&browser, &browser.password.clone(), &two_factor).await?;
  171. ensure(
  172. status == StatusCode::SEE_OTHER,
  173. format!("login must 303 to consent: {status} {body}"),
  174. )?;
  175. let session = cookie_value(&headers, SESSION_COOKIE)?;
  176. let (status, _, body) = get_consent(&browser, &session).await?;
  177. ensure(
  178. status == StatusCode::OK,
  179. format!("consent must render: {status} {body}"),
  180. )?;
  181. let request_id = hidden_input(&body, "request_id")?;
  182. let csrf = hidden_input(&body, "csrf")?;
  183. let (status, headers, body) =
  184. post_consent(&browser, &session, &request_id, &csrf, "approve").await?;
  185. ensure(
  186. status == StatusCode::SEE_OTHER,
  187. format!("consent approval must 303: {status} {body}"),
  188. )?;
  189. let code = query_value(&location_of(&headers)?, "code")?;
  190. // Redeem the first code for the real access/refresh pair.
  191. let proof = browser.dpop.proof(
  192. "POST",
  193. &format!("{}/token", browser.world.state.config.issuer),
  194. )?;
  195. let (status, _, body) = browser
  196. .world
  197. .post_form(
  198. "/token",
  199. &[
  200. ("grant_type", "authorization_code"),
  201. ("code", code.as_str()),
  202. ("redirect_uri", REDIRECT_URI),
  203. ("client_id", CLIENT_ID),
  204. ("code_verifier", browser.verifier.as_str()),
  205. ],
  206. None,
  207. &[("dpop", proof.as_str())],
  208. )
  209. .await?;
  210. ensure(
  211. status == StatusCode::OK,
  212. format!("code redemption must mint: {status} {body}"),
  213. )?;
  214. let tokens = json_body(&body)?;
  215. let access_token = tokens["access_token"]
  216. .as_str()
  217. .filter(|value| !value.is_empty())
  218. .ok_or_else(|| fail("redemption must return an access token"))?
  219. .to_owned();
  220. let refresh_token = tokens["refresh_token"]
  221. .as_str()
  222. .filter(|value| !value.is_empty())
  223. .ok_or_else(|| fail("redemption must return a refresh token"))?
  224. .to_owned();
  225. // A second, unredeemed code for the post-purge redemption check.
  226. let (fresh_code, fresh_verifier) = consent_for_new_code(&browser, &session).await?;
  227. // Approve a device code through the real UI so its record carries the
  228. // subject the purge will match. A pending record's empty subject is
  229. // invisible to `purge_subject`.
  230. seed_public_client(&browser.world).await?;
  231. let device_key = DpopKey::generate()?;
  232. let device_client = DeviceClient {
  233. id: device::PUBLIC_CLIENT,
  234. secret: None,
  235. scope: device::SCOPE,
  236. };
  237. let (device_code, user_code) = issue(&browser.world, device_client, &device_key).await?;
  238. let page = review(&browser.world, &session, &user_code).await?;
  239. let device_csrf = hidden_input(&page, "csrf")?;
  240. decide(
  241. &browser.world,
  242. Decision {
  243. session: session.as_str(),
  244. user_code: user_code.as_str(),
  245. csrf: device_csrf.as_str(),
  246. action: "approve",
  247. },
  248. )
  249. .await?;
  250. // A confidential fixture token proves introspection reads the live subject
  251. // before the cascade and the dead one after it.
  252. seed_client(
  253. &browser.world.pool,
  254. &browser.world.state.keys,
  255. INTROSPECT_CLIENT,
  256. Some(INTROSPECT_SECRET),
  257. &["authorization_code"],
  258. browser::SCOPE,
  259. TokenEndpointAuthMethod::ClientSecretBasic,
  260. )
  261. .await?;
  262. let fixture = browser
  263. .world
  264. .mint_for_subject(
  265. INTROSPECT_CLIENT,
  266. CASCADE_EMAIL,
  267. browser::SCOPE,
  268. ClientKind::Confidential,
  269. None,
  270. )
  271. .await?;
  272. ensure(
  273. introspect(&browser.world, &fixture.access_token).await?["active"] == true,
  274. "the fixture access token must start active",
  275. )?;
  276. // CLI order: purge the Redis state, then flip the flag.
  277. let summary = disable_like_cli(&browser.world, CASCADE_EMAIL).await?;
  278. ensure(
  279. summary.sessions >= 1,
  280. format!("the purge must delete the login session, got {summary:?}"),
  281. )?;
  282. ensure(
  283. summary.codes >= 1,
  284. format!("the purge must delete the unredeemed code, got {summary:?}"),
  285. )?;
  286. ensure(
  287. summary.devices == 1,
  288. format!("the purge must delete the approved device record, got {summary:?}"),
  289. )?;
  290. // 1. Introspection: both the fixture token and the browser token are dead.
  291. ensure(
  292. introspect(&browser.world, &fixture.access_token).await?["active"] == false,
  293. "the fixture access token must introspect inactive after disable",
  294. )?;
  295. ensure(
  296. introspect(&browser.world, &access_token).await?["active"] == false,
  297. "the browser access token must introspect inactive after disable",
  298. )?;
  299. // 2. Refresh: the record is gone, so the grant is invalid.
  300. let proof = browser.dpop.proof(
  301. "POST",
  302. &format!("{}/token", browser.world.state.config.issuer),
  303. )?;
  304. let (status, _, body) = browser
  305. .world
  306. .post_form(
  307. "/token",
  308. &[
  309. ("grant_type", "refresh_token"),
  310. ("refresh_token", refresh_token.as_str()),
  311. ("client_id", CLIENT_ID),
  312. ],
  313. None,
  314. &[("dpop", proof.as_str())],
  315. )
  316. .await?;
  317. ensure(
  318. status == StatusCode::BAD_REQUEST,
  319. format!("refresh must be 400: {status} {body}"),
  320. )?;
  321. ensure(
  322. json_body(&body)?["error"] == "invalid_grant",
  323. "refresh must be invalid_grant",
  324. )?;
  325. // 3. Authorization code: the unredeemed record is gone.
  326. let proof = browser.dpop.proof(
  327. "POST",
  328. &format!("{}/token", browser.world.state.config.issuer),
  329. )?;
  330. let (status, _, body) = browser
  331. .world
  332. .post_form(
  333. "/token",
  334. &[
  335. ("grant_type", "authorization_code"),
  336. ("code", fresh_code.as_str()),
  337. ("redirect_uri", REDIRECT_URI),
  338. ("client_id", CLIENT_ID),
  339. ("code_verifier", fresh_verifier.as_str()),
  340. ],
  341. None,
  342. &[("dpop", proof.as_str())],
  343. )
  344. .await?;
  345. ensure(
  346. status == StatusCode::BAD_REQUEST,
  347. format!("code redemption must be 400: {status} {body}"),
  348. )?;
  349. ensure(
  350. json_body(&body)?["error"] == "invalid_grant",
  351. "code redemption must be invalid_grant",
  352. )?;
  353. // 4. Device: the approved record is gone, so polling is terminal
  354. // `expired_token` (never a mint, never `invalid_grant`).
  355. let (status, body) = poll(
  356. &browser.world,
  357. Poll {
  358. client: device_client,
  359. device_code: device_code.as_str(),
  360. },
  361. &device_key,
  362. )
  363. .await?;
  364. ensure(
  365. status == StatusCode::BAD_REQUEST,
  366. format!("the purged device poll must be 400: {status} {body}"),
  367. )?;
  368. ensure(
  369. json_body(&body)?["error"] == "expired_token",
  370. "the purged device code must be expired_token",
  371. )?;
  372. // 5. Session: the old cookie no longer authenticates a consent page.
  373. let (status, headers, _) = get_consent(&browser, &session).await?;
  374. ensure(
  375. status == StatusCode::SEE_OTHER,
  376. format!("a purged session must bounce from consent: {status}"),
  377. )?;
  378. ensure(
  379. location_of(&headers)? == "/login",
  380. "the purged session must bounce to /login",
  381. )?;
  382. Ok(())
  383. }
  384. /// A disabled account's correct password + TOTP is still the uniform 401 and
  385. /// never upgrades the pre-session.
  386. #[tokio::test]
  387. async fn e2e_disabled_user_cannot_relogin() -> Result<(), TestError> {
  388. let world = World::start(open_rate_limit()).await?;
  389. let browser = begin_browser(world, RELOGIN_EMAIL, RELOGIN_PASSWORD).await?;
  390. disable_like_cli(&browser.world, RELOGIN_EMAIL).await?;
  391. let two_factor = totp_now(&browser.totp_secret).await?;
  392. let (status, headers, body) =
  393. post_login(&browser, &browser.password.clone(), &two_factor).await?;
  394. ensure(
  395. status == StatusCode::UNAUTHORIZED,
  396. format!("a disabled account must be 401: {status} {body}"),
  397. )?;
  398. ensure(
  399. body.contains(INVALID_CREDENTIALS),
  400. "the rejection must render the uniform credential message",
  401. )?;
  402. ensure(
  403. cookie_value(&headers, SESSION_COOKIE).is_err(),
  404. "a disabled account must not upgrade the pre-session",
  405. )?;
  406. let (status, headers, _) = get_consent(&browser, &browser.presession).await?;
  407. ensure(
  408. status == StatusCode::SEE_OTHER,
  409. format!("without a session consent must bounce to login: {status}"),
  410. )?;
  411. ensure(
  412. location_of(&headers)? == "/login",
  413. "the bounce target must be /login",
  414. )?;
  415. Ok(())
  416. }