e2e_grants.rs 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197
  1. //! `test(e2e_)` token-grant rows over the live router: the wired dispatcher.
  2. //!
  3. //! Every test boots the REAL production router ([`d_oauth::routes::build_router`])
  4. //! over a REAL loopback socket against the shared Postgres + Redis from
  5. //! `compose.test.yaml` and asserts only observable HTTP behavior: the five grants dispatch, the
  6. //! sender-constraint matrix holds, and every failure is the specified 4xx.
  7. //!
  8. //! The `oauth2` crate drives the baseline shapes (PKCE material, code
  9. //! redemption, client-credentials exchange); DPoP proofs and the extension
  10. //! grants are hand-rolled `reqwest`.
  11. #[path = "e2e/harness.rs"]
  12. mod harness;
  13. #[path = "e2e/dpop.rs"]
  14. mod dpop;
  15. #[path = "e2e/clients.rs"]
  16. mod clients;
  17. use d_oauth::db::{TokenEndpointAuthMethod, user_repo, user_scope_repo};
  18. use d_oauth::protocol::code::{CodeClientKind, CodeIssueRequest, CodeIssuer, IssuedCode};
  19. use d_oauth::protocol::device::hash_token;
  20. use d_oauth::protocol::scope::ScopeSet;
  21. use d_oauth::protocol::token::ClientKind;
  22. use d_oauth::store::device::{DeviceStatus, decide};
  23. use dpop::DpopKey;
  24. use harness::{
  25. SeedClientOpts, TestError, World, attach_redirect, ensure, fail, json_body, open_rate_limit,
  26. seed_client, seed_client_bound, seed_scope,
  27. };
  28. use oauth2::TokenResponse as _;
  29. /// Fixture confidential client.
  30. const CLIENT_ID: &str = "e2e-grant-client";
  31. /// Fixture secret.
  32. const CLIENT_SECRET: &str = "e2e-grant-secret-1a2b3c4d5e6f708192";
  33. /// Fixture public client (DPoP-bound by policy).
  34. const PUBLIC_ID: &str = "e2e-grant-public";
  35. /// Registered loopback redirect.
  36. const REDIRECT_URI: &str = "http://127.0.0.1:9/cb";
  37. /// One world plus seeded confidential + public clients, scopes, redirect.
  38. async fn setup() -> Result<World, TestError> {
  39. let world = World::start(open_rate_limit()).await?;
  40. seed_client(
  41. &world.pool,
  42. &world.state.keys,
  43. CLIENT_ID,
  44. Some(CLIENT_SECRET),
  45. &[
  46. "authorization_code",
  47. "refresh_token",
  48. "client_credentials",
  49. "urn:ietf:params:oauth:grant-type:device_code",
  50. "urn:ietf:params:oauth:grant-type:token-exchange",
  51. ],
  52. "read write",
  53. TokenEndpointAuthMethod::ClientSecretBasic,
  54. )
  55. .await?;
  56. seed_client(
  57. &world.pool,
  58. &world.state.keys,
  59. PUBLIC_ID,
  60. None,
  61. &["authorization_code", "refresh_token"],
  62. "read",
  63. TokenEndpointAuthMethod::None,
  64. )
  65. .await?;
  66. seed_scope(&world.pool, "read").await?;
  67. seed_scope(&world.pool, "write").await?;
  68. attach_redirect(&world.pool, &world.state.keys, CLIENT_ID, REDIRECT_URI).await?;
  69. attach_redirect(&world.pool, &world.state.keys, PUBLIC_ID, REDIRECT_URI).await?;
  70. Ok(world)
  71. }
  72. /// Assert a token failure is the specified OAuth error, minting nothing.
  73. fn ensure_error(status: reqwest::StatusCode, body: &str, code: &str) -> Result<(), TestError> {
  74. ensure(
  75. status == reqwest::StatusCode::BAD_REQUEST,
  76. format!("must be 400, got {status}: {body}"),
  77. )?;
  78. let value = json_body(body)?;
  79. ensure(
  80. value["error"] == code,
  81. format!("failure must be {code}, got {value}"),
  82. )?;
  83. ensure(!body.contains("access_token"), "failure must mint nothing")?;
  84. ensure(
  85. !body.contains(CLIENT_SECRET),
  86. "failure must not leak the secret",
  87. )?;
  88. Ok(())
  89. }
  90. /// PKCE downgrade is rejected at the authorize layer (`plain` → 302
  91. /// `invalid_request`); redemption of a stale code is `invalid_grant`.
  92. #[tokio::test]
  93. async fn e2e_pkce_downgrade_rejected() -> Result<(), TestError> {
  94. let world = setup().await?;
  95. let (challenge, _verifier) = oauth2_pkce();
  96. let (status, headers, _) = world
  97. .get(
  98. &format!(
  99. "/authorize?client_id={PUBLIC_ID}&redirect_uri={}&response_type=code\
  100. &code_challenge={challenge}&code_challenge_method=plain",
  101. encoded(REDIRECT_URI)
  102. ),
  103. &[],
  104. )
  105. .await?;
  106. ensure(status == reqwest::StatusCode::FOUND, "plain PKCE must 302")?;
  107. let location = location_of(&headers)?;
  108. ensure(
  109. location.contains("error=invalid_request"),
  110. "plain PKCE must be invalid_request",
  111. )?;
  112. let (status, _, body) = world
  113. .post_form(
  114. "/token",
  115. &[
  116. ("grant_type", "authorization_code"),
  117. ("code", "stale-code"),
  118. ("redirect_uri", REDIRECT_URI),
  119. ("client_id", CLIENT_ID),
  120. ],
  121. Some((CLIENT_ID, CLIENT_SECRET)),
  122. &[],
  123. )
  124. .await?;
  125. ensure_error(status, &body, "invalid_grant")?;
  126. Ok(())
  127. }
  128. /// Code replay and cross-client redemption never succeed: an unknown or
  129. /// already-consumed code is always `invalid_grant`.
  130. #[tokio::test]
  131. async fn e2e_code_replay_expiry_cross_client_fail_closed() -> Result<(), TestError> {
  132. let world = setup().await?;
  133. let redemption = [
  134. ("grant_type", "authorization_code"),
  135. ("code", "one-time-code"),
  136. ("redirect_uri", REDIRECT_URI),
  137. ("client_id", CLIENT_ID),
  138. ];
  139. for attempt in 1..=2 {
  140. let (status, _, body) = world
  141. .post_form("/token", &redemption, Some((CLIENT_ID, CLIENT_SECRET)), &[])
  142. .await?;
  143. ensure_error(status, &body, "invalid_grant")
  144. .map_err(|error| fail(format!("replay attempt {attempt}: {error}")))?;
  145. }
  146. let (status, _, body) = world
  147. .post_form(
  148. "/token",
  149. &[
  150. ("grant_type", "authorization_code"),
  151. ("code", "one-time-code"),
  152. ("redirect_uri", REDIRECT_URI),
  153. ("client_id", PUBLIC_ID),
  154. ],
  155. None,
  156. &[],
  157. )
  158. .await?;
  159. ensure_error(status, &body, "invalid_grant")?;
  160. Ok(())
  161. }
  162. /// A replayed DPoP `jti` mints once and is `invalid_dpop_proof` on reuse.
  163. #[tokio::test]
  164. async fn e2e_dpop_jti_replay_fail_closed() -> Result<(), TestError> {
  165. let world = setup().await?;
  166. let key = DpopKey::generate()?;
  167. let htu = format!("{}/token", world.state.config.issuer);
  168. let proof = key.proof("POST", &htu)?;
  169. let attempt = [("grant_type", "client_credentials")];
  170. let (status, _, body) = world
  171. .post_form(
  172. "/token",
  173. &attempt,
  174. Some((CLIENT_ID, CLIENT_SECRET)),
  175. &[("dpop", proof.as_str())],
  176. )
  177. .await?;
  178. ensure(
  179. status == reqwest::StatusCode::OK,
  180. format!("fresh proof must mint: {status} {body}"),
  181. )?;
  182. let value = json_body(&body)?;
  183. ensure(
  184. value["token_type"] == "DPoP",
  185. "proof-bound issuance must be DPoP",
  186. )?;
  187. let (status, _, body) = world
  188. .post_form(
  189. "/token",
  190. &attempt,
  191. Some((CLIENT_ID, CLIENT_SECRET)),
  192. &[("dpop", proof.as_str())],
  193. )
  194. .await?;
  195. ensure_error(status, &body, "invalid_dpop_proof")?;
  196. Ok(())
  197. }
  198. /// A DPoP proof bound to the wrong `htu` is `invalid_dpop_proof`.
  199. #[tokio::test]
  200. async fn e2e_dpop_htu_mismatch_fail_closed() -> Result<(), TestError> {
  201. let world = setup().await?;
  202. let key = DpopKey::generate()?;
  203. let wrong = key.proof("POST", "https://auth.example.test/wrong-token")?;
  204. let (status, _, body) = world
  205. .post_form(
  206. "/token",
  207. &[("grant_type", "client_credentials")],
  208. Some((CLIENT_ID, CLIENT_SECRET)),
  209. &[("dpop", wrong.as_str())],
  210. )
  211. .await?;
  212. ensure_error(status, &body, "invalid_dpop_proof")?;
  213. Ok(())
  214. }
  215. /// Refresh reuse rotates once, then `invalid_grant` and cascade-revokes the
  216. /// grant family (the first access token reads inactive).
  217. #[tokio::test]
  218. async fn e2e_refresh_rotation_reuse_cascade_fail_closed() -> Result<(), TestError> {
  219. let world = setup().await?;
  220. let key = DpopKey::generate()?;
  221. let jkt =
  222. d_oauth::jose::jwk::thumbprint(&key.jwk()).map_err(|error| fail(format!("jwk: {error}")))?;
  223. let minted = world
  224. .mint(CLIENT_ID, "read", ClientKind::Confidential, Some(&jkt))
  225. .await?;
  226. let htu = format!("{}/token", world.state.config.issuer);
  227. let refresh = [
  228. ("grant_type", "refresh_token"),
  229. ("refresh_token", minted.refresh_token.as_str()),
  230. ];
  231. let first_proof = key.proof("POST", &htu)?;
  232. let (status, _, body) = world
  233. .post_form(
  234. "/token",
  235. &refresh,
  236. Some((CLIENT_ID, CLIENT_SECRET)),
  237. &[("dpop", first_proof.as_str())],
  238. )
  239. .await?;
  240. ensure(
  241. status == reqwest::StatusCode::OK,
  242. format!("first rotation must mint: {status} {body}"),
  243. )?;
  244. let rotated = json_body(&body)?;
  245. ensure(
  246. rotated["access_token"]
  247. .as_str()
  248. .is_some_and(|t| !t.is_empty()),
  249. "rotation must return an access token",
  250. )?;
  251. ensure(
  252. rotated["refresh_token"]
  253. .as_str()
  254. .is_some_and(|t| !t.is_empty()),
  255. "rotation must return a refresh token",
  256. )?;
  257. let second_proof = key.proof("POST", &htu)?;
  258. let (status, _, body) = world
  259. .post_form(
  260. "/token",
  261. &refresh,
  262. Some((CLIENT_ID, CLIENT_SECRET)),
  263. &[("dpop", second_proof.as_str())],
  264. )
  265. .await?;
  266. ensure_error(status, &body, "invalid_grant")?;
  267. // The replay mapping masks the tombstone by design (a same-request retry
  268. // is rejected without cascading); expire it to exercise the reuse path.
  269. let mut conn = world.state.redis.clone();
  270. let found = world
  271. .service
  272. .find_refresh(&mut conn, &world.store, &minted.refresh_token)
  273. .await
  274. .map_err(|error| fail(format!("refresh lookup: {error}")))?
  275. .ok_or_else(|| fail("rotated refresh token must resolve to its tombstone"))?;
  276. let request_id = world
  277. .service
  278. .request_id_for(&found.hash)
  279. .map_err(|error| fail(format!("request id: {error}")))?;
  280. let _: () = redis::cmd("DEL")
  281. .arg(world.store.rotation_map_key(&request_id))
  282. .query_async(&mut conn)
  283. .await?;
  284. let third_proof = key.proof("POST", &htu)?;
  285. let (status, _, body) = world
  286. .post_form(
  287. "/token",
  288. &refresh,
  289. Some((CLIENT_ID, CLIENT_SECRET)),
  290. &[("dpop", third_proof.as_str())],
  291. )
  292. .await?;
  293. ensure_error(status, &body, "invalid_grant")?;
  294. let first_access = minted.access_token.as_str();
  295. let (status, _, body) = world
  296. .post_form(
  297. "/introspect",
  298. &[("token", first_access)],
  299. Some((CLIENT_ID, CLIENT_SECRET)),
  300. &[],
  301. )
  302. .await?;
  303. ensure(
  304. status == reqwest::StatusCode::OK,
  305. "introspection must answer",
  306. )?;
  307. ensure(
  308. json_body(&body)?["active"] == false,
  309. "reuse must cascade-revoke the rotated access token",
  310. )?;
  311. Ok(())
  312. }
  313. /// Concurrent rotations of one refresh token: exactly one mints, every other
  314. /// racer is `invalid_grant`.
  315. #[tokio::test]
  316. async fn e2e_concurrent_rotation_fail_closed() -> Result<(), TestError> {
  317. let world = setup().await?;
  318. let key = DpopKey::generate()?;
  319. let jkt =
  320. d_oauth::jose::jwk::thumbprint(&key.jwk()).map_err(|error| fail(format!("jwk: {error}")))?;
  321. let minted = world
  322. .mint(CLIENT_ID, "read", ClientKind::Confidential, Some(&jkt))
  323. .await?;
  324. let htu = format!("{}/token", world.state.config.issuer);
  325. let mut proofs = Vec::with_capacity(8);
  326. for _ in 0..8 {
  327. proofs.push(key.proof("POST", &htu)?);
  328. }
  329. let mut set = tokio::task::JoinSet::new();
  330. let client = reqwest::Client::builder()
  331. .redirect(reqwest::redirect::Policy::none())
  332. .timeout(std::time::Duration::from_secs(60))
  333. .build()?;
  334. for proof in proofs {
  335. let client = client.clone();
  336. let base = world.base.clone();
  337. let refresh = minted.refresh_token.clone();
  338. set.spawn(async move {
  339. client
  340. .post(format!("{base}/token"))
  341. .basic_auth(CLIENT_ID, Some(CLIENT_SECRET))
  342. .header("dpop", proof)
  343. .form(&[
  344. ("grant_type", "refresh_token"),
  345. ("refresh_token", refresh.as_str()),
  346. ])
  347. .send()
  348. .await
  349. });
  350. }
  351. let mut successes = 0;
  352. let mut rejections = 0;
  353. let mut unavailable = 0;
  354. while let Some(outcome) = set.join_next().await {
  355. let response = outcome.map_err(|error| fail(format!("join failed: {error}")))??;
  356. let status = response.status();
  357. let body = response.text().await?;
  358. if status == reqwest::StatusCode::OK {
  359. successes += 1;
  360. } else if status == reqwest::StatusCode::SERVICE_UNAVAILABLE {
  361. ensure(
  362. json_body(&body)?["error"] == "temporarily_unavailable",
  363. format!("store outage must be temporarily_unavailable: {body}"),
  364. )?;
  365. ensure(!body.contains("access_token"), "failure must mint nothing")?;
  366. unavailable += 1;
  367. } else {
  368. ensure_error(status, &body, "invalid_grant")?;
  369. rejections += 1;
  370. }
  371. }
  372. ensure(
  373. successes == 1,
  374. format!(
  375. "exactly one racer must rotate (got {successes}; invalid_grant={rejections}, \
  376. unavailable={unavailable})"
  377. ),
  378. )?;
  379. ensure(
  380. rejections + unavailable == 7,
  381. "every other racer must be rejected",
  382. )?;
  383. Ok(())
  384. }
  385. /// Concurrent redemptions of one authorization code: exactly one exchanges,
  386. /// every other racer is `invalid_grant` — the code is consumed atomically, so
  387. /// only that one winner can create a token family.
  388. #[tokio::test]
  389. async fn e2e_concurrent_code_redemption_fail_closed() -> Result<(), TestError> {
  390. let world = setup().await?;
  391. let (challenge, verifier) = oauth2_pkce();
  392. let issued = issue_conf_code(&world, CLIENT_ID, &challenge).await?;
  393. let verifier = verifier.secret().to_owned();
  394. let client = reqwest::Client::builder()
  395. .redirect(reqwest::redirect::Policy::none())
  396. .timeout(std::time::Duration::from_secs(60))
  397. .build()?;
  398. let mut set = tokio::task::JoinSet::new();
  399. for _ in 0..2 {
  400. let client = client.clone();
  401. let base = world.base.clone();
  402. let code = issued.code.clone();
  403. let verifier = verifier.clone();
  404. set.spawn(async move {
  405. client
  406. .post(format!("{base}/token"))
  407. .basic_auth(CLIENT_ID, Some(CLIENT_SECRET))
  408. .form(&[
  409. ("grant_type", "authorization_code"),
  410. ("code", code.as_str()),
  411. ("redirect_uri", REDIRECT_URI),
  412. ("client_id", CLIENT_ID),
  413. ("code_verifier", verifier.as_str()),
  414. ])
  415. .send()
  416. .await
  417. });
  418. }
  419. let mut successes = 0;
  420. let mut rejections = 0;
  421. let mut unavailable = 0;
  422. while let Some(outcome) = set.join_next().await {
  423. let response = outcome.map_err(|error| fail(format!("join failed: {error}")))??;
  424. let status = response.status();
  425. let body = response.text().await?;
  426. if status == reqwest::StatusCode::OK {
  427. let minted = json_body(&body)?;
  428. ensure(
  429. minted["access_token"]
  430. .as_str()
  431. .is_some_and(|token| !token.is_empty()),
  432. "the winner must mint an access token",
  433. )?;
  434. ensure(
  435. minted["refresh_token"]
  436. .as_str()
  437. .is_some_and(|token| !token.is_empty()),
  438. "the single winner must create the token family",
  439. )?;
  440. successes += 1;
  441. } else if status == reqwest::StatusCode::SERVICE_UNAVAILABLE {
  442. ensure(
  443. json_body(&body)?["error"] == "temporarily_unavailable",
  444. format!("store outage must be temporarily_unavailable: {body}"),
  445. )?;
  446. ensure(!body.contains("access_token"), "failure must mint nothing")?;
  447. unavailable += 1;
  448. } else {
  449. ensure_error(status, &body, "invalid_grant")?;
  450. rejections += 1;
  451. }
  452. }
  453. ensure(
  454. successes == 1,
  455. format!(
  456. "exactly one racer must redeem the code (got {successes}; invalid_grant={rejections}, \
  457. unavailable={unavailable})"
  458. ),
  459. )?;
  460. ensure(
  461. rejections + unavailable == 1,
  462. "every other racer must be rejected",
  463. )?;
  464. Ok(())
  465. }
  466. /// Concurrent `/authorize` GETs on one PAR `request_uri`: exactly one proceeds
  467. /// into login, every other racer is the fail-closed `invalid_request` — the
  468. /// handle is consumed atomically and cannot be replayed.
  469. #[tokio::test]
  470. async fn e2e_concurrent_par_consumption_fail_closed() -> Result<(), TestError> {
  471. let world = setup().await?;
  472. let (challenge, _verifier) = oauth2_pkce();
  473. let (status, _, body) = world
  474. .post_form(
  475. "/par",
  476. &[
  477. ("client_id", CLIENT_ID),
  478. ("redirect_uri", REDIRECT_URI),
  479. ("response_type", "code"),
  480. ("scope", "read"),
  481. ("state", "par-race-state"),
  482. ("code_challenge", challenge.as_str()),
  483. ("code_challenge_method", "S256"),
  484. ],
  485. Some((CLIENT_ID, CLIENT_SECRET)),
  486. &[],
  487. )
  488. .await?;
  489. ensure(
  490. status == reqwest::StatusCode::CREATED,
  491. format!("PAR push must 201: {status} {body}"),
  492. )?;
  493. let request_uri = json_body(&body)?["request_uri"]
  494. .as_str()
  495. .ok_or_else(|| fail("PAR must return request_uri"))?
  496. .to_owned();
  497. let client = reqwest::Client::builder()
  498. .redirect(reqwest::redirect::Policy::none())
  499. .timeout(std::time::Duration::from_secs(60))
  500. .build()?;
  501. let mut set = tokio::task::JoinSet::new();
  502. for _ in 0..2 {
  503. let client = client.clone();
  504. let base = world.base.clone();
  505. let target = format!(
  506. "/authorize?client_id={CLIENT_ID}&request_uri={}",
  507. encoded(&request_uri)
  508. );
  509. set.spawn(async move { client.get(format!("{base}{target}")).send().await });
  510. }
  511. let mut winners = 0;
  512. let mut rejections = 0;
  513. let mut unavailable = 0;
  514. while let Some(outcome) = set.join_next().await {
  515. let response = outcome.map_err(|error| fail(format!("join failed: {error}")))??;
  516. let status = response.status();
  517. let location = response
  518. .headers()
  519. .get("location")
  520. .and_then(|value| value.to_str().ok())
  521. .map(str::to_owned);
  522. let body = response.text().await?;
  523. if status == reqwest::StatusCode::SEE_OTHER {
  524. ensure(
  525. location
  526. .as_deref()
  527. .is_some_and(|target| target.starts_with("/login?request_id=")),
  528. format!("the winner must enter login: {location:?} {body}"),
  529. )?;
  530. winners += 1;
  531. } else if status == reqwest::StatusCode::SERVICE_UNAVAILABLE {
  532. ensure(
  533. json_body(&body)?["error"] == "temporarily_unavailable",
  534. format!("store outage must be temporarily_unavailable: {body}"),
  535. )?;
  536. unavailable += 1;
  537. } else {
  538. ensure(
  539. status == reqwest::StatusCode::BAD_REQUEST || status == reqwest::StatusCode::FOUND,
  540. format!("a losing PAR authorize must be rejected: {status} {body}"),
  541. )?;
  542. let rejected = body.contains("invalid_request")
  543. || location
  544. .as_deref()
  545. .is_some_and(|target| target.contains("error=invalid_request"));
  546. ensure(
  547. rejected,
  548. format!("a losing PAR authorize must be invalid_request: {status} {location:?} {body}"),
  549. )?;
  550. ensure(
  551. location
  552. .as_deref()
  553. .is_none_or(|target| !target.starts_with("/login")),
  554. "a losing racer must never enter login",
  555. )?;
  556. ensure(
  557. !body.contains("request_id="),
  558. "a losing racer must not start a flow",
  559. )?;
  560. rejections += 1;
  561. }
  562. }
  563. ensure(
  564. winners == 1,
  565. format!("exactly one racer must consume the PAR handle (got {winners})"),
  566. )?;
  567. ensure(
  568. rejections + unavailable == 1,
  569. "every other racer must be rejected",
  570. )?;
  571. Ok(())
  572. }
  573. /// Concurrent `/token` calls reusing one DPoP `jti`: exactly one mints, every
  574. /// other racer is `invalid_dpop_proof` — the replay marker is written with
  575. /// `SET NX`.
  576. #[tokio::test]
  577. async fn e2e_concurrent_dpop_jti_reuse_fail_closed() -> Result<(), TestError> {
  578. let world = setup().await?;
  579. let key = DpopKey::generate()?;
  580. let htu = format!("{}/token", world.state.config.issuer);
  581. let jti = uuid::Uuid::new_v4().to_string();
  582. let mut proofs = Vec::with_capacity(2);
  583. for _ in 0..2 {
  584. proofs.push(key.proof_with_jti("POST", &htu, &jti)?);
  585. }
  586. let client = reqwest::Client::builder()
  587. .redirect(reqwest::redirect::Policy::none())
  588. .timeout(std::time::Duration::from_secs(60))
  589. .build()?;
  590. let mut set = tokio::task::JoinSet::new();
  591. for proof in proofs {
  592. let client = client.clone();
  593. let base = world.base.clone();
  594. set.spawn(async move {
  595. client
  596. .post(format!("{base}/token"))
  597. .basic_auth(CLIENT_ID, Some(CLIENT_SECRET))
  598. .header("dpop", proof)
  599. .form(&[("grant_type", "client_credentials")])
  600. .send()
  601. .await
  602. });
  603. }
  604. let mut successes = 0;
  605. let mut rejections = 0;
  606. let mut unavailable = 0;
  607. while let Some(outcome) = set.join_next().await {
  608. let response = outcome.map_err(|error| fail(format!("join failed: {error}")))??;
  609. let status = response.status();
  610. let body = response.text().await?;
  611. if status == reqwest::StatusCode::OK {
  612. ensure(
  613. json_body(&body)?["token_type"] == "DPoP",
  614. "the winner must mint a DPoP-bound token",
  615. )?;
  616. successes += 1;
  617. } else if status == reqwest::StatusCode::SERVICE_UNAVAILABLE {
  618. ensure(
  619. json_body(&body)?["error"] == "temporarily_unavailable",
  620. format!("store outage must be temporarily_unavailable: {body}"),
  621. )?;
  622. ensure(!body.contains("access_token"), "failure must mint nothing")?;
  623. unavailable += 1;
  624. } else {
  625. ensure_error(status, &body, "invalid_dpop_proof")?;
  626. rejections += 1;
  627. }
  628. }
  629. ensure(
  630. successes == 1,
  631. format!(
  632. "exactly one racer must mint (got {successes}; invalid_dpop_proof={rejections}, \
  633. unavailable={unavailable})"
  634. ),
  635. )?;
  636. ensure(
  637. rejections + unavailable == 1,
  638. "every other racer must be rejected",
  639. )?;
  640. Ok(())
  641. }
  642. /// Concurrent `/token` polls of one approved device code: exactly one mints,
  643. /// every other racer fails closed (`expired_token`/`invalid_grant`, or the
  644. /// RFC 8628 back-off when it races the interval stamp) — the approval is
  645. /// consumed atomically and is never redeemed twice.
  646. #[tokio::test]
  647. async fn e2e_concurrent_device_poll_fail_closed() -> Result<(), TestError> {
  648. let world = setup().await?;
  649. let approver = user_repo::create_user(
  650. &world.pool,
  651. &world.state.keys,
  652. world.state.config.pii_dual_write,
  653. &user_repo::NewUser {
  654. email: "e2e-device-approver@example.test".to_owned(),
  655. password_hash: "e2e-not-a-login-credential".to_owned(),
  656. is_admin: false,
  657. must_change_password: false,
  658. },
  659. )
  660. .await?;
  661. user_scope_repo::entitle(&world.pool, approver.id, "read", None)
  662. .await
  663. .map_err(|error| fail(format!("entitle failed: {error}")))?;
  664. let (status, _, body) = world
  665. .post_form(
  666. "/device_authorization",
  667. &[("client_id", CLIENT_ID), ("scope", "read")],
  668. Some((CLIENT_ID, CLIENT_SECRET)),
  669. &[],
  670. )
  671. .await?;
  672. ensure(
  673. status.is_success(),
  674. format!("device authorization must 200: {status} {body}"),
  675. )?;
  676. let device_code = json_body(&body)?["device_code"]
  677. .as_str()
  678. .ok_or_else(|| fail("device authorization must return device_code"))?
  679. .to_owned();
  680. // Approve through the store decision the `/device` UI records.
  681. let device_hash = hash_token(&world.state.keys, &device_code)
  682. .map_err(|error| fail(format!("device hash failed: {error}")))?;
  683. let mut conn = world.state.redis.clone();
  684. let approved = decide(
  685. &mut conn,
  686. &world.store,
  687. &device_hash,
  688. DeviceStatus::Approved,
  689. &approver.id.to_string(),
  690. &uuid::Uuid::new_v4().to_string(),
  691. )
  692. .await
  693. .map_err(|error| fail(format!("device approval failed: {error}")))?;
  694. ensure(approved, "the device record must be live to approve")?;
  695. let client = reqwest::Client::builder()
  696. .redirect(reqwest::redirect::Policy::none())
  697. .timeout(std::time::Duration::from_secs(60))
  698. .build()?;
  699. let mut set = tokio::task::JoinSet::new();
  700. for _ in 0..2 {
  701. let client = client.clone();
  702. let base = world.base.clone();
  703. let device_code = device_code.clone();
  704. set.spawn(async move {
  705. client
  706. .post(format!("{base}/token"))
  707. .basic_auth(CLIENT_ID, Some(CLIENT_SECRET))
  708. .form(&[
  709. ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
  710. ("device_code", device_code.as_str()),
  711. ])
  712. .send()
  713. .await
  714. });
  715. }
  716. let mut successes = 0;
  717. let mut rejections = 0;
  718. let mut unavailable = 0;
  719. while let Some(outcome) = set.join_next().await {
  720. let response = outcome.map_err(|error| fail(format!("join failed: {error}")))??;
  721. let status = response.status();
  722. let body = response.text().await?;
  723. if status == reqwest::StatusCode::OK {
  724. let minted = json_body(&body)?;
  725. ensure(
  726. minted["access_token"]
  727. .as_str()
  728. .is_some_and(|token| !token.is_empty()),
  729. "the winner must mint an access token",
  730. )?;
  731. ensure(
  732. minted["refresh_token"]
  733. .as_str()
  734. .is_some_and(|token| !token.is_empty()),
  735. "the winner must mint a refresh token",
  736. )?;
  737. successes += 1;
  738. } else if status == reqwest::StatusCode::SERVICE_UNAVAILABLE {
  739. ensure(
  740. json_body(&body)?["error"] == "temporarily_unavailable",
  741. format!("store outage must be temporarily_unavailable: {body}"),
  742. )?;
  743. ensure(!body.contains("access_token"), "failure must mint nothing")?;
  744. unavailable += 1;
  745. } else {
  746. let error_code = json_body(&body)?["error"]
  747. .as_str()
  748. .unwrap_or_default()
  749. .to_owned();
  750. // A racer that lands after the winner's interval stamp gets the RFC 8628
  751. // back-off; one that lands after the atomic consume gets `expired_token`.
  752. ensure(
  753. matches!(
  754. error_code.as_str(),
  755. "expired_token" | "invalid_grant" | "slow_down"
  756. ),
  757. format!("a losing device poll must fail closed, got {status}: {body}"),
  758. )?;
  759. ensure(!body.contains("access_token"), "failure must mint nothing")?;
  760. rejections += 1;
  761. }
  762. }
  763. ensure(
  764. successes == 1,
  765. format!(
  766. "exactly one poll must mint (got {successes}; expired_or_invalid={rejections}, \
  767. unavailable={unavailable})"
  768. ),
  769. )?;
  770. ensure(
  771. rejections + unavailable == 1,
  772. "every other poll must fail closed",
  773. )?;
  774. Ok(())
  775. }
  776. /// A public client's refresh grant without a fresh DPoP proof is
  777. /// `invalid_grant` (the stored `jkt` alone never mints).
  778. #[tokio::test]
  779. async fn e2e_public_client_without_dpop_fail_closed() -> Result<(), TestError> {
  780. let world = setup().await?;
  781. let minted = world
  782. .mint(
  783. PUBLIC_ID,
  784. "read",
  785. ClientKind::Public,
  786. Some("e2e-public-jkt"),
  787. )
  788. .await?;
  789. let (status, _, body) = world
  790. .post_form(
  791. "/token",
  792. &[
  793. ("grant_type", "refresh_token"),
  794. ("refresh_token", minted.refresh_token.as_str()),
  795. ("client_id", PUBLIC_ID),
  796. ],
  797. None,
  798. &[],
  799. )
  800. .await?;
  801. ensure_error(status, &body, "invalid_grant")?;
  802. Ok(())
  803. }
  804. /// A DPoP-bound confidential client must present a fresh proof at token time.
  805. #[tokio::test]
  806. async fn e2e_dpop_bound_client_requires_fresh_proof() -> Result<(), TestError> {
  807. const BOUND_ID: &str = "e2e-grant-dpop-bound";
  808. const BOUND_SECRET: &str = "e2e-grant-bound-secret-0f1e2d3c4b5a6978";
  809. let world = World::start(open_rate_limit()).await?;
  810. seed_client_bound(
  811. &world.pool,
  812. &world.state.keys,
  813. BOUND_ID,
  814. Some(BOUND_SECRET),
  815. &SeedClientOpts {
  816. grants: &["client_credentials"],
  817. scope: "read",
  818. method: TokenEndpointAuthMethod::ClientSecretBasic,
  819. dpop_bound: true,
  820. },
  821. )
  822. .await?;
  823. seed_scope(&world.pool, "read").await?;
  824. let (status, _, body) = world
  825. .post_form(
  826. "/token",
  827. &[("grant_type", "client_credentials")],
  828. Some((BOUND_ID, BOUND_SECRET)),
  829. &[],
  830. )
  831. .await?;
  832. ensure_error(status, &body, "invalid_grant")?;
  833. let key = DpopKey::generate()?;
  834. let proof = key.proof("POST", &format!("{}/token", world.state.config.issuer))?;
  835. let (status, _, body) = world
  836. .post_form(
  837. "/token",
  838. &[("grant_type", "client_credentials"), ("scope", "read")],
  839. Some((BOUND_ID, BOUND_SECRET)),
  840. &[("dpop", proof.as_str())],
  841. )
  842. .await?;
  843. ensure(
  844. status == reqwest::StatusCode::OK,
  845. format!("fresh proof must mint: {status} {body}"),
  846. )?;
  847. ensure(
  848. json_body(&body)?["token_type"] == "DPoP",
  849. "the bound issuance must be a DPoP token",
  850. )?;
  851. Ok(())
  852. }
  853. /// A DPoP-bound confidential client must present a fresh proof on the
  854. /// authorization-code grant too (not only the grants the dispatcher
  855. /// authenticates itself); a proof-less redemption is `invalid_grant`.
  856. #[tokio::test]
  857. async fn e2e_dpop_bound_auth_code_requires_fresh_proof() -> Result<(), TestError> {
  858. const BOUND_ID: &str = "e2e-grant-bound-code";
  859. const BOUND_SECRET: &str = "e2e-grant-bound-code-secret-4d3c2b1a";
  860. let world = World::start(open_rate_limit()).await?;
  861. seed_client_bound(
  862. &world.pool,
  863. &world.state.keys,
  864. BOUND_ID,
  865. Some(BOUND_SECRET),
  866. &SeedClientOpts {
  867. grants: &["authorization_code"],
  868. scope: "read",
  869. method: TokenEndpointAuthMethod::ClientSecretBasic,
  870. dpop_bound: true,
  871. },
  872. )
  873. .await?;
  874. seed_scope(&world.pool, "read").await?;
  875. attach_redirect(&world.pool, &world.state.keys, BOUND_ID, REDIRECT_URI).await?;
  876. let (challenge, verifier) = oauth2_pkce();
  877. let code = issue_conf_code(&world, BOUND_ID, &challenge).await?;
  878. let (status, _, body) = world
  879. .post_form(
  880. "/token",
  881. &[
  882. ("grant_type", "authorization_code"),
  883. ("code", code.code.as_str()),
  884. ("redirect_uri", REDIRECT_URI),
  885. ("client_id", BOUND_ID),
  886. ("code_verifier", verifier.secret()),
  887. ],
  888. Some((BOUND_ID, BOUND_SECRET)),
  889. &[],
  890. )
  891. .await?;
  892. ensure_error(status, &body, "invalid_grant")?;
  893. let code = issue_conf_code(&world, BOUND_ID, &challenge).await?;
  894. let key = DpopKey::generate()?;
  895. let proof = key.proof("POST", &format!("{}/token", world.state.config.issuer))?;
  896. let (status, _, body) = world
  897. .post_form(
  898. "/token",
  899. &[
  900. ("grant_type", "authorization_code"),
  901. ("code", code.code.as_str()),
  902. ("redirect_uri", REDIRECT_URI),
  903. ("client_id", BOUND_ID),
  904. ("code_verifier", verifier.secret()),
  905. ],
  906. Some((BOUND_ID, BOUND_SECRET)),
  907. &[("dpop", proof.as_str())],
  908. )
  909. .await?;
  910. ensure(
  911. status == reqwest::StatusCode::OK,
  912. format!("fresh proof must mint: {status} {body}"),
  913. )?;
  914. ensure(
  915. json_body(&body)?["token_type"] == "DPoP",
  916. "the bound redemption must be a DPoP token",
  917. )?;
  918. Ok(())
  919. }
  920. /// Baseline client-credentials flow via the `oauth2` crate issues a token.
  921. #[tokio::test]
  922. async fn e2e_grant_client_credentials_oauth2_issues_token() -> Result<(), TestError> {
  923. let world = setup().await?;
  924. let client = clients::oauth2_client(&world.base, CLIENT_ID, Some(CLIENT_SECRET))?;
  925. let token = client
  926. .exchange_client_credentials()
  927. .request_async(&world.client)
  928. .await
  929. .map_err(|error| fail(format!("client-credentials failed: {error}")))?;
  930. ensure(
  931. !token.access_token().secret().is_empty(),
  932. "client-credentials must return an access token",
  933. )?;
  934. Ok(())
  935. }
  936. /// Baseline authorization-code + PKCE exchange via the `oauth2` crate
  937. /// redeems a real code issued through [`CodeIssuer`].
  938. #[tokio::test]
  939. async fn e2e_grant_authorization_code_oauth2_issues_token() -> Result<(), TestError> {
  940. let world = setup().await?;
  941. let (challenge, verifier) = oauth2_pkce();
  942. let issued = {
  943. let issuer = CodeIssuer::new(&world.state.config.ttl, &world.state.keys)
  944. .map_err(|error| fail(format!("issuer: {error}")))?;
  945. let approved = ScopeSet::parse("read").map_err(|error| fail(format!("scope: {error}")))?;
  946. let entitled =
  947. ScopeSet::parse("read write").map_err(|error| fail(format!("scope: {error}")))?;
  948. let mut conn = world.state.redis.clone();
  949. let request = CodeIssueRequest {
  950. client_id: CLIENT_ID,
  951. client_kind: CodeClientKind::Confidential,
  952. user_id: "e2e-subject",
  953. redirect_uri: REDIRECT_URI,
  954. approved_scope: &approved,
  955. user_entitled: &entitled,
  956. code_challenge: &challenge,
  957. dpop_jkt: None,
  958. state: None,
  959. issuer: &world.state.config.issuer,
  960. };
  961. issuer
  962. .issue(&mut conn, &world.store, &request)
  963. .await
  964. .map_err(|error| fail(format!("code issue failed: {error}")))?
  965. };
  966. let client = clients::oauth2_client(&world.base, CLIENT_ID, Some(CLIENT_SECRET))?;
  967. let token = client
  968. .exchange_code(oauth2::AuthorizationCode::new(issued.code))
  969. .set_pkce_verifier(verifier)
  970. .request_async(&world.client)
  971. .await
  972. .map_err(|error| fail(format!("code redemption failed: {error}")))?;
  973. ensure(
  974. !token.access_token().secret().is_empty(),
  975. "code redemption must return an access token",
  976. )?;
  977. ensure(
  978. token.refresh_token().is_some(),
  979. "code redemption must return a refresh token",
  980. )?;
  981. Ok(())
  982. }
  983. /// A stale authorization code via the `oauth2` crate is `invalid_grant`.
  984. #[tokio::test]
  985. async fn e2e_grant_authorization_code_stale_is_invalid_grant() -> Result<(), TestError> {
  986. let world = setup().await?;
  987. let client = clients::oauth2_client(&world.base, CLIENT_ID, Some(CLIENT_SECRET))?;
  988. let (_challenge, verifier) = oauth2_pkce();
  989. let outcome = client
  990. .exchange_code(oauth2::AuthorizationCode::new("stale-code".to_owned()))
  991. .set_pkce_verifier(verifier)
  992. .request_async(&world.client)
  993. .await;
  994. match outcome {
  995. Err(oauth2::RequestTokenError::ServerResponse(response)) => ensure(
  996. response.error() == &oauth2::basic::BasicErrorResponseType::InvalidGrant,
  997. "stale code must be invalid_grant",
  998. ),
  999. _ => Err(fail("stale code must be an invalid_grant server response")),
  1000. }
  1001. }
  1002. /// An unknown `grant_type` — including the OAuth 2.0-only `password` — is
  1003. /// rejected as `unsupported_grant_type` without minting.
  1004. #[tokio::test]
  1005. async fn e2e_unknown_grant_type_rejected() -> Result<(), TestError> {
  1006. let world = setup().await?;
  1007. let (status, _, body) = world
  1008. .post_form(
  1009. "/token",
  1010. &[
  1011. ("grant_type", "password"),
  1012. ("username", "u"),
  1013. ("password", "p"),
  1014. ],
  1015. Some((CLIENT_ID, CLIENT_SECRET)),
  1016. &[],
  1017. )
  1018. .await?;
  1019. ensure_error(status, &body, "unsupported_grant_type")?;
  1020. Ok(())
  1021. }
  1022. /// Device polling of an unknown code is `expired_token`; a disabled token
  1023. /// exchange is `invalid_grant`.
  1024. #[tokio::test]
  1025. async fn e2e_grant_device_code_and_token_exchange_fail_closed() -> Result<(), TestError> {
  1026. let world = setup().await?;
  1027. let (status, _, body) = world
  1028. .post_form(
  1029. "/token",
  1030. &[
  1031. ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
  1032. ("device_code", "pending-device-code"),
  1033. ],
  1034. Some((CLIENT_ID, CLIENT_SECRET)),
  1035. &[],
  1036. )
  1037. .await?;
  1038. ensure_error(status, &body, "expired_token")?;
  1039. let (status, _, body) = world
  1040. .post_form(
  1041. "/token",
  1042. &[
  1043. (
  1044. "grant_type",
  1045. "urn:ietf:params:oauth:grant-type:token-exchange",
  1046. ),
  1047. ("subject_token", "opaque-subject"),
  1048. ],
  1049. Some((CLIENT_ID, CLIENT_SECRET)),
  1050. &[],
  1051. )
  1052. .await?;
  1053. ensure_error(status, &body, "invalid_grant")?;
  1054. Ok(())
  1055. }
  1056. /// The authorize endpoint verifies a presented DPoP proof and stashes its
  1057. /// `jkt` in the pending-auth record the code issuance binds.
  1058. #[tokio::test]
  1059. async fn e2e_authorize_captures_dpop_jkt() -> Result<(), TestError> {
  1060. let world = setup().await?;
  1061. let (challenge, _verifier) = oauth2_pkce();
  1062. let key = DpopKey::generate()?;
  1063. let htu = format!("{}/authorize", world.state.config.issuer);
  1064. let proof = key.proof("GET", &htu)?;
  1065. let (status, headers, body) = world
  1066. .get(
  1067. &format!(
  1068. "/authorize?client_id={PUBLIC_ID}&redirect_uri={}&response_type=code&scope=read\
  1069. &code_challenge={challenge}&code_challenge_method=S256",
  1070. encoded(REDIRECT_URI)
  1071. ),
  1072. &[("dpop", proof.as_str())],
  1073. )
  1074. .await?;
  1075. ensure(
  1076. status == reqwest::StatusCode::SEE_OTHER,
  1077. format!("authorize with DPoP must 303: {status} {body}"),
  1078. )?;
  1079. let location = location_of(&headers)?;
  1080. let request_id = location
  1081. .split("request_id=")
  1082. .nth(1)
  1083. .ok_or_else(|| fail("authorize redirect must carry request_id"))?;
  1084. let mut conn = world.state.redis.clone();
  1085. let record = d_oauth::store::pending::get(&mut conn, &world.store, request_id)
  1086. .await
  1087. .map_err(|error| fail(format!("pending read failed: {error}")))?
  1088. .ok_or_else(|| fail("pending record must exist"))?;
  1089. let expected =
  1090. d_oauth::jose::jwk::thumbprint(&key.jwk()).map_err(|error| fail(format!("jwk: {error}")))?;
  1091. ensure(
  1092. record.client_id == PUBLIC_ID,
  1093. "pending record must bind the client",
  1094. )?;
  1095. ensure(
  1096. record.dpop_jkt.as_deref() == Some(expected.as_str()),
  1097. "pending record must carry the verified DPoP thumbprint",
  1098. )?;
  1099. Ok(())
  1100. }
  1101. /// PKCE pair from the `oauth2` crate (baseline-flow material only).
  1102. fn oauth2_pkce() -> (String, oauth2::PkceCodeVerifier) {
  1103. let (challenge, verifier) = oauth2::PkceCodeChallenge::new_random_sha256();
  1104. (challenge.as_str().to_owned(), verifier)
  1105. }
  1106. /// Issue a fresh confidential authorization code with no DPoP binding.
  1107. async fn issue_conf_code(
  1108. world: &World,
  1109. client_id: &str,
  1110. challenge: &str,
  1111. ) -> Result<IssuedCode, TestError> {
  1112. let issuer = CodeIssuer::new(&world.state.config.ttl, &world.state.keys)
  1113. .map_err(|error| fail(format!("issuer: {error}")))?;
  1114. let approved = ScopeSet::parse("read").map_err(|error| fail(format!("scope: {error}")))?;
  1115. let mut conn = world.state.redis.clone();
  1116. let request = CodeIssueRequest {
  1117. client_id,
  1118. client_kind: CodeClientKind::Confidential,
  1119. user_id: "e2e-subject",
  1120. redirect_uri: REDIRECT_URI,
  1121. approved_scope: &approved,
  1122. user_entitled: &approved,
  1123. code_challenge: challenge,
  1124. dpop_jkt: None,
  1125. state: None,
  1126. issuer: &world.state.config.issuer,
  1127. };
  1128. issuer
  1129. .issue(&mut conn, &world.store, &request)
  1130. .await
  1131. .map_err(|error| fail(format!("code issue failed: {error}")))
  1132. }
  1133. /// Percent-encode a value for query embedding.
  1134. fn encoded(value: &str) -> String {
  1135. let mut out = String::with_capacity(value.len());
  1136. for byte in value.bytes() {
  1137. if byte.is_ascii_alphanumeric() || b"-_.~".contains(&byte) {
  1138. out.push(byte as char);
  1139. } else {
  1140. out.push_str(&format!("%{byte:02X}"));
  1141. }
  1142. }
  1143. out
  1144. }
  1145. /// Read the `location` header or fail the test.
  1146. fn location_of(headers: &reqwest::header::HeaderMap) -> Result<String, TestError> {
  1147. headers
  1148. .get("location")
  1149. .and_then(|value| value.to_str().ok())
  1150. .map(str::to_owned)
  1151. .ok_or_else(|| fail("response lacks location header"))
  1152. }