e2e_device.rs 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293
  1. //! `binary(e2e_device)` device-grant flow over the live router (RFC 8628).
  2. //!
  3. //! Every request goes through the production router over a loopback socket
  4. //! against the real shared Postgres + Redis. The tests drive the real user
  5. //! half (`POST /device` entry form, review form, CSRF) through the shared
  6. //! browser helper, then poll `/token` with a fresh DPoP proof, exactly as a
  7. //! device would.
  8. //!
  9. //! The minted token is introspected with the owner client: introspection is
  10. //! client-scoped (`src/protocol/introspect.rs`, a foreign token collapses to
  11. //! `active:false`) and RFC 7662 authentication rejects the public `none`
  12. //! method, so the DPoP-bound device client here holds a secret for the
  13. //! introspection leg. Public-client device issuance and denial are covered by
  14. //! the other two cases.
  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. mod dpop;
  23. #[path = "e2e/device.rs"]
  24. mod device;
  25. use d_oauth::db::TokenEndpointAuthMethod;
  26. use device::{
  27. Client, DEVICE_CLIENT, DEVICE_GRANT, DEVICE_SECRET, Decision, PUBLIC_CLIENT, Poll, SCOPE, decide,
  28. issue, poll, review, seed_public_client,
  29. };
  30. use dpop::DpopKey;
  31. use harness::{
  32. SeedClientOpts, TestError, World, ensure, fail, json_body, open_rate_limit, seed_client_bound,
  33. seed_scope,
  34. };
  35. use reqwest::StatusCode;
  36. /// Provisioned user in the approve/poll case.
  37. const OWNER_EMAIL: &str = "device-owner@example.test";
  38. /// Provisioned password in the approve/poll case.
  39. const OWNER_PASSWORD: &str = "e2e-device-owner-password-1a2b";
  40. /// Provisioned user in the deny case.
  41. const DENY_EMAIL: &str = "device-deny@example.test";
  42. /// Provisioned password in the deny case.
  43. const DENY_PASSWORD: &str = "e2e-device-deny-password-1a2b";
  44. /// Provisioned user in the normalization case.
  45. const NORMALIZE_EMAIL: &str = "device-normalize@example.test";
  46. /// Provisioned password in the normalization case.
  47. const NORMALIZE_PASSWORD: &str = "e2e-device-normalize-password-1a2b";
  48. /// Provisioned user in the guess-budget case.
  49. const BUDGET_EMAIL: &str = "device-budget@example.test";
  50. /// Provisioned password in the guess-budget case.
  51. const BUDGET_PASSWORD: &str = "e2e-device-budget-password-1a2b";
  52. /// Approve through the UI, then redeem exactly once and introspect the token.
  53. #[tokio::test]
  54. async fn e2e_device_grant_approve_poll_mint() -> Result<(), TestError> {
  55. let world = World::start(open_rate_limit()).await?;
  56. let client = Client {
  57. id: DEVICE_CLIENT,
  58. secret: Some(DEVICE_SECRET),
  59. scope: SCOPE,
  60. };
  61. seed_client_bound(
  62. &world.pool,
  63. &world.state.keys,
  64. client.id,
  65. client.secret,
  66. &SeedClientOpts {
  67. grants: &[DEVICE_GRANT],
  68. scope: SCOPE,
  69. method: TokenEndpointAuthMethod::ClientSecretBasic,
  70. dpop_bound: true,
  71. },
  72. )
  73. .await?;
  74. seed_scope(&world.pool, SCOPE).await?;
  75. let key = DpopKey::generate()?;
  76. let (device_code, user_code) = issue(&world, client, &key).await?;
  77. // The anonymous GET /device only renders the entry form; the review page
  78. // comes from POST /device without an `action`, on an authenticated session.
  79. let browser = browser::login_session(world, OWNER_EMAIL, OWNER_PASSWORD).await?;
  80. let session = browser
  81. .session
  82. .as_deref()
  83. .ok_or_else(|| fail("login_session must return an authenticated session"))?;
  84. let page = review(&browser.world, session, &user_code).await?;
  85. let csrf = browser::hidden_input(&page, "csrf")?;
  86. decide(
  87. &browser.world,
  88. Decision {
  89. session,
  90. user_code: &user_code,
  91. csrf: &csrf,
  92. action: "approve",
  93. },
  94. )
  95. .await?;
  96. let request = Poll {
  97. client,
  98. device_code: &device_code,
  99. };
  100. let (status, body) = poll(&browser.world, request, &key).await?;
  101. ensure(
  102. status == StatusCode::OK,
  103. format!("approved device poll must mint: {status} {body}"),
  104. )?;
  105. let tokens = json_body(&body)?;
  106. ensure(tokens["token_type"] == "DPoP", "token must be DPoP-bound")?;
  107. ensure(
  108. tokens["scope"] == SCOPE,
  109. "token must carry the granted scope",
  110. )?;
  111. let access_token = tokens["access_token"]
  112. .as_str()
  113. .filter(|token| !token.is_empty())
  114. .ok_or_else(|| fail("redemption must return a non-empty access token"))?;
  115. ensure(
  116. tokens["refresh_token"]
  117. .as_str()
  118. .is_some_and(|token| !token.is_empty()),
  119. "redemption must return a non-empty refresh token",
  120. )?;
  121. let basic = Some((DEVICE_CLIENT, DEVICE_SECRET));
  122. let (status, _, body) = browser
  123. .world
  124. .post_form("/introspect", &[("token", access_token)], basic, &[])
  125. .await?;
  126. ensure(status == StatusCode::OK, "introspection must answer 200")?;
  127. let introspection = json_body(&body)?;
  128. ensure(
  129. introspection["active"] == true,
  130. "minted token must be active",
  131. )?;
  132. ensure(
  133. introspection["client_id"] == DEVICE_CLIENT,
  134. "introspection must report the owning client",
  135. )?;
  136. // The code is single-use: the next poll is terminal `expired_token`, never a
  137. // second pair and never `invalid_grant`.
  138. let (status, body) = poll(&browser.world, request, &key).await?;
  139. ensure(
  140. status == StatusCode::BAD_REQUEST,
  141. format!("second poll must be 400: {status} {body}"),
  142. )?;
  143. let error = json_body(&body)?;
  144. ensure(
  145. error["error"] == "expired_token",
  146. format!("consumed code must be expired_token: {error}"),
  147. )?;
  148. ensure(!body.contains("access_token"), "replay must mint nothing")?;
  149. Ok(())
  150. }
  151. /// Denying the request makes every later poll `access_denied` with no token.
  152. #[tokio::test]
  153. async fn e2e_device_grant_deny_is_access_denied() -> Result<(), TestError> {
  154. let world = World::start(open_rate_limit()).await?;
  155. let client = Client {
  156. id: PUBLIC_CLIENT,
  157. secret: None,
  158. scope: SCOPE,
  159. };
  160. seed_public_client(&world).await?;
  161. let key = DpopKey::generate()?;
  162. let (device_code, user_code) = issue(&world, client, &key).await?;
  163. let browser = browser::login_session(world, DENY_EMAIL, DENY_PASSWORD).await?;
  164. let session = browser
  165. .session
  166. .as_deref()
  167. .ok_or_else(|| fail("login_session must return an authenticated session"))?;
  168. let page = review(&browser.world, session, &user_code).await?;
  169. let csrf = browser::hidden_input(&page, "csrf")?;
  170. decide(
  171. &browser.world,
  172. Decision {
  173. session,
  174. user_code: &user_code,
  175. csrf: &csrf,
  176. action: "deny",
  177. },
  178. )
  179. .await?;
  180. let request = Poll {
  181. client,
  182. device_code: &device_code,
  183. };
  184. let (status, body) = poll(&browser.world, request, &key).await?;
  185. ensure(
  186. status == StatusCode::BAD_REQUEST,
  187. format!("denied poll must be 400: {status} {body}"),
  188. )?;
  189. let error = json_body(&body)?;
  190. ensure(
  191. error["error"] == "access_denied",
  192. format!("denied poll must be access_denied: {error}"),
  193. )?;
  194. ensure(!body.contains("access_token"), "denial must mint nothing")?;
  195. Ok(())
  196. }
  197. /// A lowercase, hyphenated code resolves, and the 11th lookup from one IP is
  198. /// rate-limited with a positive `Retry-After`.
  199. #[tokio::test]
  200. async fn e2e_device_user_code_normalization_and_guess_limit() -> Result<(), TestError> {
  201. // Normalization: a real code typed as `abcd-efgh` must reach the review
  202. // page. Separate worlds give the guess-budget half a fresh per-IP counter
  203. // (each world has its own Redis key prefix).
  204. let world = World::start(open_rate_limit()).await?;
  205. let client = Client {
  206. id: PUBLIC_CLIENT,
  207. secret: None,
  208. scope: SCOPE,
  209. };
  210. seed_public_client(&world).await?;
  211. let key = DpopKey::generate()?;
  212. let (_device_code, user_code) = issue(&world, client, &key).await?;
  213. let browser = browser::login_session(world, NORMALIZE_EMAIL, NORMALIZE_PASSWORD).await?;
  214. let session = browser
  215. .session
  216. .as_deref()
  217. .ok_or_else(|| fail("login_session must return an authenticated session"))?;
  218. let normalized = user_code.to_ascii_lowercase();
  219. ensure(
  220. normalized.contains('-'),
  221. "the issued user code must have the display hyphen",
  222. )?;
  223. let page = review(&browser.world, session, &normalized).await?;
  224. ensure(
  225. page.contains(PUBLIC_CLIENT),
  226. "the normalized code must resolve to the pending client",
  227. )?;
  228. // The review form is the observable proof the code was accepted.
  229. let _csrf = browser::hidden_input(&page, "csrf")?;
  230. // Guess budget: 10 lookups pass, the 11th from the same IP is `429` with a
  231. // positive `Retry-After` (RFC 8628 §3.3 brute-force protection).
  232. let world = World::start(open_rate_limit()).await?;
  233. let browser = browser::login_session(world, BUDGET_EMAIL, BUDGET_PASSWORD).await?;
  234. let session = browser
  235. .session
  236. .as_deref()
  237. .ok_or_else(|| fail("login_session must return an authenticated session"))?;
  238. let cookie = device::session_cookie(session);
  239. for attempt in 1..=10 {
  240. let code = format!("ZZZZ-{attempt:04}");
  241. let (status, _, body) = browser
  242. .world
  243. .post_form(
  244. "/device",
  245. &[("user_code", code.as_str())],
  246. None,
  247. &[("cookie", cookie.as_str())],
  248. )
  249. .await?;
  250. ensure(
  251. status == StatusCode::OK,
  252. format!("bad guess {attempt} must be allowed: {status} {body}"),
  253. )?;
  254. }
  255. let (status, headers, body) = browser
  256. .world
  257. .post_form(
  258. "/device",
  259. &[("user_code", "ZZZZ-9999")],
  260. None,
  261. &[("cookie", cookie.as_str())],
  262. )
  263. .await?;
  264. ensure(
  265. status == StatusCode::TOO_MANY_REQUESTS,
  266. format!("the 11th bad guess must be 429: {status} {body}"),
  267. )?;
  268. let retry_after = headers
  269. .get(reqwest::header::RETRY_AFTER)
  270. .and_then(|value| value.to_str().ok())
  271. .ok_or_else(|| fail("429 must carry Retry-After"))?;
  272. let seconds: u64 = retry_after
  273. .parse()
  274. .map_err(|_| fail("Retry-After must be whole seconds"))?;
  275. ensure(seconds >= 1, "Retry-After must be positive")?;
  276. Ok(())
  277. }