e2e_grants.rs 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796
  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;
  18. use d_oauth::protocol::code::{CodeClientKind, CodeIssueRequest, CodeIssuer, IssuedCode};
  19. use d_oauth::protocol::scope::ScopeSet;
  20. use d_oauth::protocol::token::ClientKind;
  21. use dpop::DpopKey;
  22. use harness::{
  23. SeedClientOpts, TestError, World, attach_redirect, ensure, fail, json_body, open_rate_limit,
  24. seed_client, seed_client_bound, seed_scope,
  25. };
  26. use oauth2::TokenResponse as _;
  27. /// Fixture confidential client.
  28. const CLIENT_ID: &str = "e2e-grant-client";
  29. /// Fixture secret.
  30. const CLIENT_SECRET: &str = "e2e-grant-secret-1a2b3c4d5e6f708192";
  31. /// Fixture public client (DPoP-bound by policy).
  32. const PUBLIC_ID: &str = "e2e-grant-public";
  33. /// Registered loopback redirect.
  34. const REDIRECT_URI: &str = "http://127.0.0.1:9/cb";
  35. /// One world plus seeded confidential + public clients, scopes, redirect.
  36. async fn setup() -> Result<World, TestError> {
  37. let world = World::start(open_rate_limit()).await?;
  38. seed_client(
  39. &world.pool,
  40. &world.state.keys,
  41. CLIENT_ID,
  42. Some(CLIENT_SECRET),
  43. &[
  44. "authorization_code",
  45. "refresh_token",
  46. "client_credentials",
  47. "urn:ietf:params:oauth:grant-type:device_code",
  48. "urn:ietf:params:oauth:grant-type:token-exchange",
  49. ],
  50. "read write",
  51. TokenEndpointAuthMethod::ClientSecretBasic,
  52. )
  53. .await?;
  54. seed_client(
  55. &world.pool,
  56. &world.state.keys,
  57. PUBLIC_ID,
  58. None,
  59. &["authorization_code", "refresh_token"],
  60. "read",
  61. TokenEndpointAuthMethod::None,
  62. )
  63. .await?;
  64. seed_scope(&world.pool, "read").await?;
  65. seed_scope(&world.pool, "write").await?;
  66. attach_redirect(&world.pool, &world.state.keys, CLIENT_ID, REDIRECT_URI).await?;
  67. attach_redirect(&world.pool, &world.state.keys, PUBLIC_ID, REDIRECT_URI).await?;
  68. Ok(world)
  69. }
  70. /// Assert a token failure is the specified OAuth error, minting nothing.
  71. fn ensure_error(status: reqwest::StatusCode, body: &str, code: &str) -> Result<(), TestError> {
  72. ensure(
  73. status == reqwest::StatusCode::BAD_REQUEST,
  74. format!("must be 400, got {status}: {body}"),
  75. )?;
  76. let value = json_body(body)?;
  77. ensure(
  78. value["error"] == code,
  79. format!("failure must be {code}, got {value}"),
  80. )?;
  81. ensure(!body.contains("access_token"), "failure must mint nothing")?;
  82. ensure(
  83. !body.contains(CLIENT_SECRET),
  84. "failure must not leak the secret",
  85. )?;
  86. Ok(())
  87. }
  88. /// PKCE downgrade is rejected at the authorize layer (`plain` → 302
  89. /// `invalid_request`); redemption of a stale code is `invalid_grant`.
  90. #[tokio::test]
  91. async fn e2e_pkce_downgrade_rejected() -> Result<(), TestError> {
  92. let world = setup().await?;
  93. let (challenge, _verifier) = oauth2_pkce();
  94. let (status, headers, _) = world
  95. .get(
  96. &format!(
  97. "/authorize?client_id={PUBLIC_ID}&redirect_uri={}&response_type=code\
  98. &code_challenge={challenge}&code_challenge_method=plain",
  99. encoded(REDIRECT_URI)
  100. ),
  101. &[],
  102. )
  103. .await?;
  104. ensure(status == reqwest::StatusCode::FOUND, "plain PKCE must 302")?;
  105. let location = location_of(&headers)?;
  106. ensure(
  107. location.contains("error=invalid_request"),
  108. "plain PKCE must be invalid_request",
  109. )?;
  110. let (status, _, body) = world
  111. .post_form(
  112. "/token",
  113. &[
  114. ("grant_type", "authorization_code"),
  115. ("code", "stale-code"),
  116. ("redirect_uri", REDIRECT_URI),
  117. ("client_id", CLIENT_ID),
  118. ],
  119. Some((CLIENT_ID, CLIENT_SECRET)),
  120. &[],
  121. )
  122. .await?;
  123. ensure_error(status, &body, "invalid_grant")?;
  124. Ok(())
  125. }
  126. /// Code replay and cross-client redemption never succeed: an unknown or
  127. /// already-consumed code is always `invalid_grant`.
  128. #[tokio::test]
  129. async fn e2e_code_replay_expiry_cross_client_fail_closed() -> Result<(), TestError> {
  130. let world = setup().await?;
  131. let redemption = [
  132. ("grant_type", "authorization_code"),
  133. ("code", "one-time-code"),
  134. ("redirect_uri", REDIRECT_URI),
  135. ("client_id", CLIENT_ID),
  136. ];
  137. for attempt in 1..=2 {
  138. let (status, _, body) = world
  139. .post_form("/token", &redemption, Some((CLIENT_ID, CLIENT_SECRET)), &[])
  140. .await?;
  141. ensure_error(status, &body, "invalid_grant")
  142. .map_err(|error| fail(format!("replay attempt {attempt}: {error}")))?;
  143. }
  144. let (status, _, body) = world
  145. .post_form(
  146. "/token",
  147. &[
  148. ("grant_type", "authorization_code"),
  149. ("code", "one-time-code"),
  150. ("redirect_uri", REDIRECT_URI),
  151. ("client_id", PUBLIC_ID),
  152. ],
  153. None,
  154. &[],
  155. )
  156. .await?;
  157. ensure_error(status, &body, "invalid_grant")?;
  158. Ok(())
  159. }
  160. /// A replayed DPoP `jti` mints once and is `invalid_dpop_proof` on reuse.
  161. #[tokio::test]
  162. async fn e2e_dpop_jti_replay_fail_closed() -> Result<(), TestError> {
  163. let world = setup().await?;
  164. let key = DpopKey::generate()?;
  165. let htu = format!("{}/token", world.state.config.issuer);
  166. let proof = key.proof("POST", &htu)?;
  167. let attempt = [("grant_type", "client_credentials")];
  168. let (status, _, body) = world
  169. .post_form(
  170. "/token",
  171. &attempt,
  172. Some((CLIENT_ID, CLIENT_SECRET)),
  173. &[("dpop", proof.as_str())],
  174. )
  175. .await?;
  176. ensure(
  177. status == reqwest::StatusCode::OK,
  178. format!("fresh proof must mint: {status} {body}"),
  179. )?;
  180. let value = json_body(&body)?;
  181. ensure(
  182. value["token_type"] == "DPoP",
  183. "proof-bound issuance must be DPoP",
  184. )?;
  185. let (status, _, body) = world
  186. .post_form(
  187. "/token",
  188. &attempt,
  189. Some((CLIENT_ID, CLIENT_SECRET)),
  190. &[("dpop", proof.as_str())],
  191. )
  192. .await?;
  193. ensure_error(status, &body, "invalid_dpop_proof")?;
  194. Ok(())
  195. }
  196. /// A DPoP proof bound to the wrong `htu` is `invalid_dpop_proof`.
  197. #[tokio::test]
  198. async fn e2e_dpop_htu_mismatch_fail_closed() -> Result<(), TestError> {
  199. let world = setup().await?;
  200. let key = DpopKey::generate()?;
  201. let wrong = key.proof("POST", "https://auth.example.test/wrong-token")?;
  202. let (status, _, body) = world
  203. .post_form(
  204. "/token",
  205. &[("grant_type", "client_credentials")],
  206. Some((CLIENT_ID, CLIENT_SECRET)),
  207. &[("dpop", wrong.as_str())],
  208. )
  209. .await?;
  210. ensure_error(status, &body, "invalid_dpop_proof")?;
  211. Ok(())
  212. }
  213. /// Refresh reuse rotates once, then `invalid_grant` and cascade-revokes the
  214. /// grant family (the first access token reads inactive).
  215. #[tokio::test]
  216. async fn e2e_refresh_rotation_reuse_cascade_fail_closed() -> Result<(), TestError> {
  217. let world = setup().await?;
  218. let key = DpopKey::generate()?;
  219. let jkt =
  220. d_oauth::jose::jwk::thumbprint(&key.jwk()).map_err(|error| fail(format!("jwk: {error}")))?;
  221. let minted = world
  222. .mint(CLIENT_ID, "read", ClientKind::Confidential, Some(&jkt))
  223. .await?;
  224. let htu = format!("{}/token", world.state.config.issuer);
  225. let refresh = [
  226. ("grant_type", "refresh_token"),
  227. ("refresh_token", minted.refresh_token.as_str()),
  228. ];
  229. let first_proof = key.proof("POST", &htu)?;
  230. let (status, _, body) = world
  231. .post_form(
  232. "/token",
  233. &refresh,
  234. Some((CLIENT_ID, CLIENT_SECRET)),
  235. &[("dpop", first_proof.as_str())],
  236. )
  237. .await?;
  238. ensure(
  239. status == reqwest::StatusCode::OK,
  240. format!("first rotation must mint: {status} {body}"),
  241. )?;
  242. let rotated = json_body(&body)?;
  243. ensure(
  244. rotated["access_token"]
  245. .as_str()
  246. .is_some_and(|t| !t.is_empty()),
  247. "rotation must return an access token",
  248. )?;
  249. ensure(
  250. rotated["refresh_token"]
  251. .as_str()
  252. .is_some_and(|t| !t.is_empty()),
  253. "rotation must return a refresh token",
  254. )?;
  255. let second_proof = key.proof("POST", &htu)?;
  256. let (status, _, body) = world
  257. .post_form(
  258. "/token",
  259. &refresh,
  260. Some((CLIENT_ID, CLIENT_SECRET)),
  261. &[("dpop", second_proof.as_str())],
  262. )
  263. .await?;
  264. ensure_error(status, &body, "invalid_grant")?;
  265. // The replay mapping masks the tombstone by design (a same-request retry
  266. // is rejected without cascading); expire it to exercise the reuse path.
  267. let mut conn = world.state.redis.clone();
  268. let found = world
  269. .service
  270. .find_refresh(&mut conn, &world.store, &minted.refresh_token)
  271. .await
  272. .map_err(|error| fail(format!("refresh lookup: {error}")))?
  273. .ok_or_else(|| fail("rotated refresh token must resolve to its tombstone"))?;
  274. let request_id = world
  275. .service
  276. .request_id_for(&found.hash)
  277. .map_err(|error| fail(format!("request id: {error}")))?;
  278. let _: () = redis::cmd("DEL")
  279. .arg(world.store.rotation_map_key(&request_id))
  280. .query_async(&mut conn)
  281. .await?;
  282. let third_proof = key.proof("POST", &htu)?;
  283. let (status, _, body) = world
  284. .post_form(
  285. "/token",
  286. &refresh,
  287. Some((CLIENT_ID, CLIENT_SECRET)),
  288. &[("dpop", third_proof.as_str())],
  289. )
  290. .await?;
  291. ensure_error(status, &body, "invalid_grant")?;
  292. let first_access = minted.access_token.as_str();
  293. let (status, _, body) = world
  294. .post_form(
  295. "/introspect",
  296. &[("token", first_access)],
  297. Some((CLIENT_ID, CLIENT_SECRET)),
  298. &[],
  299. )
  300. .await?;
  301. ensure(
  302. status == reqwest::StatusCode::OK,
  303. "introspection must answer",
  304. )?;
  305. ensure(
  306. json_body(&body)?["active"] == false,
  307. "reuse must cascade-revoke the rotated access token",
  308. )?;
  309. Ok(())
  310. }
  311. /// Concurrent rotations of one refresh token: exactly one mints, every other
  312. /// racer is `invalid_grant`.
  313. #[tokio::test]
  314. async fn e2e_concurrent_rotation_fail_closed() -> Result<(), TestError> {
  315. let world = setup().await?;
  316. let key = DpopKey::generate()?;
  317. let jkt =
  318. d_oauth::jose::jwk::thumbprint(&key.jwk()).map_err(|error| fail(format!("jwk: {error}")))?;
  319. let minted = world
  320. .mint(CLIENT_ID, "read", ClientKind::Confidential, Some(&jkt))
  321. .await?;
  322. let htu = format!("{}/token", world.state.config.issuer);
  323. let mut proofs = Vec::with_capacity(8);
  324. for _ in 0..8 {
  325. proofs.push(key.proof("POST", &htu)?);
  326. }
  327. let mut set = tokio::task::JoinSet::new();
  328. let client = reqwest::Client::builder()
  329. .redirect(reqwest::redirect::Policy::none())
  330. .timeout(std::time::Duration::from_secs(60))
  331. .build()?;
  332. for proof in proofs {
  333. let client = client.clone();
  334. let base = world.base.clone();
  335. let refresh = minted.refresh_token.clone();
  336. set.spawn(async move {
  337. client
  338. .post(format!("{base}/token"))
  339. .basic_auth(CLIENT_ID, Some(CLIENT_SECRET))
  340. .header("dpop", proof)
  341. .form(&[
  342. ("grant_type", "refresh_token"),
  343. ("refresh_token", refresh.as_str()),
  344. ])
  345. .send()
  346. .await
  347. });
  348. }
  349. let mut successes = 0;
  350. let mut rejections = 0;
  351. let mut unavailable = 0;
  352. while let Some(outcome) = set.join_next().await {
  353. let response = outcome.map_err(|error| fail(format!("join failed: {error}")))??;
  354. let status = response.status();
  355. let body = response.text().await?;
  356. if status == reqwest::StatusCode::OK {
  357. successes += 1;
  358. } else if status == reqwest::StatusCode::SERVICE_UNAVAILABLE {
  359. ensure(
  360. json_body(&body)?["error"] == "temporarily_unavailable",
  361. format!("store outage must be temporarily_unavailable: {body}"),
  362. )?;
  363. ensure(!body.contains("access_token"), "failure must mint nothing")?;
  364. unavailable += 1;
  365. } else {
  366. ensure_error(status, &body, "invalid_grant")?;
  367. rejections += 1;
  368. }
  369. }
  370. ensure(
  371. successes == 1,
  372. format!(
  373. "exactly one racer must rotate (got {successes}; invalid_grant={rejections}, \
  374. unavailable={unavailable})"
  375. ),
  376. )?;
  377. ensure(
  378. rejections + unavailable == 7,
  379. "every other racer must be rejected",
  380. )?;
  381. Ok(())
  382. }
  383. /// A public client's refresh grant without a fresh DPoP proof is
  384. /// `invalid_grant` (the stored `jkt` alone never mints).
  385. #[tokio::test]
  386. async fn e2e_public_client_without_dpop_fail_closed() -> Result<(), TestError> {
  387. let world = setup().await?;
  388. let minted = world
  389. .mint(
  390. PUBLIC_ID,
  391. "read",
  392. ClientKind::Public,
  393. Some("e2e-public-jkt"),
  394. )
  395. .await?;
  396. let (status, _, body) = world
  397. .post_form(
  398. "/token",
  399. &[
  400. ("grant_type", "refresh_token"),
  401. ("refresh_token", minted.refresh_token.as_str()),
  402. ("client_id", PUBLIC_ID),
  403. ],
  404. None,
  405. &[],
  406. )
  407. .await?;
  408. ensure_error(status, &body, "invalid_grant")?;
  409. Ok(())
  410. }
  411. /// A DPoP-bound confidential client must present a fresh proof at token time.
  412. #[tokio::test]
  413. async fn e2e_dpop_bound_client_requires_fresh_proof() -> Result<(), TestError> {
  414. const BOUND_ID: &str = "e2e-grant-dpop-bound";
  415. const BOUND_SECRET: &str = "e2e-grant-bound-secret-0f1e2d3c4b5a6978";
  416. let world = World::start(open_rate_limit()).await?;
  417. seed_client_bound(
  418. &world.pool,
  419. &world.state.keys,
  420. BOUND_ID,
  421. Some(BOUND_SECRET),
  422. &SeedClientOpts {
  423. grants: &["client_credentials"],
  424. scope: "read",
  425. method: TokenEndpointAuthMethod::ClientSecretBasic,
  426. dpop_bound: true,
  427. },
  428. )
  429. .await?;
  430. seed_scope(&world.pool, "read").await?;
  431. let (status, _, body) = world
  432. .post_form(
  433. "/token",
  434. &[("grant_type", "client_credentials")],
  435. Some((BOUND_ID, BOUND_SECRET)),
  436. &[],
  437. )
  438. .await?;
  439. ensure_error(status, &body, "invalid_grant")?;
  440. let key = DpopKey::generate()?;
  441. let proof = key.proof("POST", &format!("{}/token", world.state.config.issuer))?;
  442. let (status, _, body) = world
  443. .post_form(
  444. "/token",
  445. &[("grant_type", "client_credentials"), ("scope", "read")],
  446. Some((BOUND_ID, BOUND_SECRET)),
  447. &[("dpop", proof.as_str())],
  448. )
  449. .await?;
  450. ensure(
  451. status == reqwest::StatusCode::OK,
  452. format!("fresh proof must mint: {status} {body}"),
  453. )?;
  454. ensure(
  455. json_body(&body)?["token_type"] == "DPoP",
  456. "the bound issuance must be a DPoP token",
  457. )?;
  458. Ok(())
  459. }
  460. /// A DPoP-bound confidential client must present a fresh proof on the
  461. /// authorization-code grant too (not only the grants the dispatcher
  462. /// authenticates itself); a proof-less redemption is `invalid_grant`.
  463. #[tokio::test]
  464. async fn e2e_dpop_bound_auth_code_requires_fresh_proof() -> Result<(), TestError> {
  465. const BOUND_ID: &str = "e2e-grant-bound-code";
  466. const BOUND_SECRET: &str = "e2e-grant-bound-code-secret-4d3c2b1a";
  467. let world = World::start(open_rate_limit()).await?;
  468. seed_client_bound(
  469. &world.pool,
  470. &world.state.keys,
  471. BOUND_ID,
  472. Some(BOUND_SECRET),
  473. &SeedClientOpts {
  474. grants: &["authorization_code"],
  475. scope: "read",
  476. method: TokenEndpointAuthMethod::ClientSecretBasic,
  477. dpop_bound: true,
  478. },
  479. )
  480. .await?;
  481. seed_scope(&world.pool, "read").await?;
  482. attach_redirect(&world.pool, &world.state.keys, BOUND_ID, REDIRECT_URI).await?;
  483. let (challenge, verifier) = oauth2_pkce();
  484. let code = issue_conf_code(&world, BOUND_ID, &challenge).await?;
  485. let (status, _, body) = world
  486. .post_form(
  487. "/token",
  488. &[
  489. ("grant_type", "authorization_code"),
  490. ("code", code.code.as_str()),
  491. ("redirect_uri", REDIRECT_URI),
  492. ("client_id", BOUND_ID),
  493. ("code_verifier", verifier.secret()),
  494. ],
  495. Some((BOUND_ID, BOUND_SECRET)),
  496. &[],
  497. )
  498. .await?;
  499. ensure_error(status, &body, "invalid_grant")?;
  500. let code = issue_conf_code(&world, BOUND_ID, &challenge).await?;
  501. let key = DpopKey::generate()?;
  502. let proof = key.proof("POST", &format!("{}/token", world.state.config.issuer))?;
  503. let (status, _, body) = world
  504. .post_form(
  505. "/token",
  506. &[
  507. ("grant_type", "authorization_code"),
  508. ("code", code.code.as_str()),
  509. ("redirect_uri", REDIRECT_URI),
  510. ("client_id", BOUND_ID),
  511. ("code_verifier", verifier.secret()),
  512. ],
  513. Some((BOUND_ID, BOUND_SECRET)),
  514. &[("dpop", proof.as_str())],
  515. )
  516. .await?;
  517. ensure(
  518. status == reqwest::StatusCode::OK,
  519. format!("fresh proof must mint: {status} {body}"),
  520. )?;
  521. ensure(
  522. json_body(&body)?["token_type"] == "DPoP",
  523. "the bound redemption must be a DPoP token",
  524. )?;
  525. Ok(())
  526. }
  527. /// Baseline client-credentials flow via the `oauth2` crate issues a token.
  528. #[tokio::test]
  529. async fn e2e_grant_client_credentials_oauth2_issues_token() -> Result<(), TestError> {
  530. let world = setup().await?;
  531. let client = clients::oauth2_client(&world.base, CLIENT_ID, Some(CLIENT_SECRET))?;
  532. let token = client
  533. .exchange_client_credentials()
  534. .request_async(&world.client)
  535. .await
  536. .map_err(|error| fail(format!("client-credentials failed: {error}")))?;
  537. ensure(
  538. !token.access_token().secret().is_empty(),
  539. "client-credentials must return an access token",
  540. )?;
  541. Ok(())
  542. }
  543. /// Baseline authorization-code + PKCE exchange via the `oauth2` crate
  544. /// redeems a real code issued through [`CodeIssuer`].
  545. #[tokio::test]
  546. async fn e2e_grant_authorization_code_oauth2_issues_token() -> Result<(), TestError> {
  547. let world = setup().await?;
  548. let (challenge, verifier) = oauth2_pkce();
  549. let issued = {
  550. let issuer = CodeIssuer::new(&world.state.config.ttl, &world.state.keys)
  551. .map_err(|error| fail(format!("issuer: {error}")))?;
  552. let approved = ScopeSet::parse("read").map_err(|error| fail(format!("scope: {error}")))?;
  553. let entitled =
  554. ScopeSet::parse("read write").map_err(|error| fail(format!("scope: {error}")))?;
  555. let mut conn = world.state.redis.clone();
  556. let request = CodeIssueRequest {
  557. client_id: CLIENT_ID,
  558. client_kind: CodeClientKind::Confidential,
  559. user_id: "e2e-subject",
  560. redirect_uri: REDIRECT_URI,
  561. approved_scope: &approved,
  562. user_entitled: &entitled,
  563. code_challenge: &challenge,
  564. dpop_jkt: None,
  565. state: None,
  566. issuer: &world.state.config.issuer,
  567. };
  568. issuer
  569. .issue(&mut conn, &world.store, &request)
  570. .await
  571. .map_err(|error| fail(format!("code issue failed: {error}")))?
  572. };
  573. let client = clients::oauth2_client(&world.base, CLIENT_ID, Some(CLIENT_SECRET))?;
  574. let token = client
  575. .exchange_code(oauth2::AuthorizationCode::new(issued.code))
  576. .set_pkce_verifier(verifier)
  577. .request_async(&world.client)
  578. .await
  579. .map_err(|error| fail(format!("code redemption failed: {error}")))?;
  580. ensure(
  581. !token.access_token().secret().is_empty(),
  582. "code redemption must return an access token",
  583. )?;
  584. ensure(
  585. token.refresh_token().is_some(),
  586. "code redemption must return a refresh token",
  587. )?;
  588. Ok(())
  589. }
  590. /// A stale authorization code via the `oauth2` crate is `invalid_grant`.
  591. #[tokio::test]
  592. async fn e2e_grant_authorization_code_stale_is_invalid_grant() -> Result<(), TestError> {
  593. let world = setup().await?;
  594. let client = clients::oauth2_client(&world.base, CLIENT_ID, Some(CLIENT_SECRET))?;
  595. let (_challenge, verifier) = oauth2_pkce();
  596. let outcome = client
  597. .exchange_code(oauth2::AuthorizationCode::new("stale-code".to_owned()))
  598. .set_pkce_verifier(verifier)
  599. .request_async(&world.client)
  600. .await;
  601. match outcome {
  602. Err(oauth2::RequestTokenError::ServerResponse(response)) => ensure(
  603. response.error() == &oauth2::basic::BasicErrorResponseType::InvalidGrant,
  604. "stale code must be invalid_grant",
  605. ),
  606. _ => Err(fail("stale code must be an invalid_grant server response")),
  607. }
  608. }
  609. /// An unknown `grant_type` — including the OAuth 2.0-only `password` — is
  610. /// rejected as `unsupported_grant_type` without minting.
  611. #[tokio::test]
  612. async fn e2e_unknown_grant_type_rejected() -> Result<(), TestError> {
  613. let world = setup().await?;
  614. let (status, _, body) = world
  615. .post_form(
  616. "/token",
  617. &[
  618. ("grant_type", "password"),
  619. ("username", "u"),
  620. ("password", "p"),
  621. ],
  622. Some((CLIENT_ID, CLIENT_SECRET)),
  623. &[],
  624. )
  625. .await?;
  626. ensure_error(status, &body, "unsupported_grant_type")?;
  627. Ok(())
  628. }
  629. /// Device polling of an unknown code is `expired_token`; a disabled token
  630. /// exchange is `invalid_grant`.
  631. #[tokio::test]
  632. async fn e2e_grant_device_code_and_token_exchange_fail_closed() -> Result<(), TestError> {
  633. let world = setup().await?;
  634. let (status, _, body) = world
  635. .post_form(
  636. "/token",
  637. &[
  638. ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
  639. ("device_code", "pending-device-code"),
  640. ],
  641. Some((CLIENT_ID, CLIENT_SECRET)),
  642. &[],
  643. )
  644. .await?;
  645. ensure_error(status, &body, "expired_token")?;
  646. let (status, _, body) = world
  647. .post_form(
  648. "/token",
  649. &[
  650. (
  651. "grant_type",
  652. "urn:ietf:params:oauth:grant-type:token-exchange",
  653. ),
  654. ("subject_token", "opaque-subject"),
  655. ],
  656. Some((CLIENT_ID, CLIENT_SECRET)),
  657. &[],
  658. )
  659. .await?;
  660. ensure_error(status, &body, "invalid_grant")?;
  661. Ok(())
  662. }
  663. /// The authorize endpoint verifies a presented DPoP proof and stashes its
  664. /// `jkt` in the pending-auth record the code issuance binds.
  665. #[tokio::test]
  666. async fn e2e_authorize_captures_dpop_jkt() -> Result<(), TestError> {
  667. let world = setup().await?;
  668. let (challenge, _verifier) = oauth2_pkce();
  669. let key = DpopKey::generate()?;
  670. let htu = format!("{}/authorize", world.state.config.issuer);
  671. let proof = key.proof("GET", &htu)?;
  672. let (status, headers, body) = world
  673. .get(
  674. &format!(
  675. "/authorize?client_id={PUBLIC_ID}&redirect_uri={}&response_type=code&scope=read\
  676. &code_challenge={challenge}&code_challenge_method=S256",
  677. encoded(REDIRECT_URI)
  678. ),
  679. &[("dpop", proof.as_str())],
  680. )
  681. .await?;
  682. ensure(
  683. status == reqwest::StatusCode::SEE_OTHER,
  684. format!("authorize with DPoP must 303: {status} {body}"),
  685. )?;
  686. let location = location_of(&headers)?;
  687. let request_id = location
  688. .split("request_id=")
  689. .nth(1)
  690. .ok_or_else(|| fail("authorize redirect must carry request_id"))?;
  691. let mut conn = world.state.redis.clone();
  692. let record = d_oauth::store::pending::get(&mut conn, &world.store, request_id)
  693. .await
  694. .map_err(|error| fail(format!("pending read failed: {error}")))?
  695. .ok_or_else(|| fail("pending record must exist"))?;
  696. let expected =
  697. d_oauth::jose::jwk::thumbprint(&key.jwk()).map_err(|error| fail(format!("jwk: {error}")))?;
  698. ensure(
  699. record.client_id == PUBLIC_ID,
  700. "pending record must bind the client",
  701. )?;
  702. ensure(
  703. record.dpop_jkt.as_deref() == Some(expected.as_str()),
  704. "pending record must carry the verified DPoP thumbprint",
  705. )?;
  706. Ok(())
  707. }
  708. /// PKCE pair from the `oauth2` crate (baseline-flow material only).
  709. fn oauth2_pkce() -> (String, oauth2::PkceCodeVerifier) {
  710. let (challenge, verifier) = oauth2::PkceCodeChallenge::new_random_sha256();
  711. (challenge.as_str().to_owned(), verifier)
  712. }
  713. /// Issue a fresh confidential authorization code with no DPoP binding.
  714. async fn issue_conf_code(
  715. world: &World,
  716. client_id: &str,
  717. challenge: &str,
  718. ) -> Result<IssuedCode, TestError> {
  719. let issuer = CodeIssuer::new(&world.state.config.ttl, &world.state.keys)
  720. .map_err(|error| fail(format!("issuer: {error}")))?;
  721. let approved = ScopeSet::parse("read").map_err(|error| fail(format!("scope: {error}")))?;
  722. let mut conn = world.state.redis.clone();
  723. let request = CodeIssueRequest {
  724. client_id,
  725. client_kind: CodeClientKind::Confidential,
  726. user_id: "e2e-subject",
  727. redirect_uri: REDIRECT_URI,
  728. approved_scope: &approved,
  729. user_entitled: &approved,
  730. code_challenge: challenge,
  731. dpop_jkt: None,
  732. state: None,
  733. issuer: &world.state.config.issuer,
  734. };
  735. issuer
  736. .issue(&mut conn, &world.store, &request)
  737. .await
  738. .map_err(|error| fail(format!("code issue failed: {error}")))
  739. }
  740. /// Percent-encode a value for query embedding.
  741. fn encoded(value: &str) -> String {
  742. let mut out = String::with_capacity(value.len());
  743. for byte in value.bytes() {
  744. if byte.is_ascii_alphanumeric() || b"-_.~".contains(&byte) {
  745. out.push(byte as char);
  746. } else {
  747. out.push_str(&format!("%{byte:02X}"));
  748. }
  749. }
  750. out
  751. }
  752. /// Read the `location` header or fail the test.
  753. fn location_of(headers: &reqwest::header::HeaderMap) -> Result<String, TestError> {
  754. headers
  755. .get("location")
  756. .and_then(|value| value.to_str().ok())
  757. .map(str::to_owned)
  758. .ok_or_else(|| fail("response lacks location header"))
  759. }