e2e_grants.rs 24 KB

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