e2e_grants.rs 50 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595
  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::{
  18. TokenEndpointAuthMethod, client_repo, resource_repo, user_repo, user_scope_repo,
  19. };
  20. use d_oauth::protocol::code::{CodeClientKind, CodeIssueRequest, CodeIssuer, IssuedCode};
  21. use d_oauth::protocol::device::hash_token;
  22. use d_oauth::protocol::scope::ScopeSet;
  23. use d_oauth::protocol::token::ClientKind;
  24. use d_oauth::store::device::{DeviceStatus, decide};
  25. use dpop::DpopKey;
  26. use harness::{
  27. SeedClientOpts, TestError, World, attach_redirect, ensure, fail, json_body, open_rate_limit,
  28. seed_client, seed_client_bound, seed_scope,
  29. };
  30. use oauth2::TokenResponse as _;
  31. /// Fixture confidential client.
  32. const CLIENT_ID: &str = "e2e-grant-client";
  33. /// Fixture secret.
  34. const CLIENT_SECRET: &str = "e2e-grant-secret-1a2b3c4d5e6f708192";
  35. /// Fixture public client (DPoP-bound by policy).
  36. const PUBLIC_ID: &str = "e2e-grant-public";
  37. /// Registered loopback redirect.
  38. const REDIRECT_URI: &str = "http://127.0.0.1:9/cb";
  39. /// First catalog-registered resource indicator (RFC 8707).
  40. const RESOURCE_A: &str = "https://rs-a.example.test/api";
  41. /// Second catalog-registered resource indicator (RFC 8707).
  42. const RESOURCE_B: &str = "https://rs-b.example.test/api";
  43. /// One world plus seeded confidential + public clients, scopes, redirect.
  44. async fn setup() -> Result<World, TestError> {
  45. setup_with_resource_indicators(false).await
  46. }
  47. /// [`setup`] with the `resource_indicators_enabled` gate pinned.
  48. ///
  49. /// The RFC 8707 catalog always holds [`RESOURCE_A`] and [`RESOURCE_B`]; the
  50. /// flag decides whether a token request may pin them.
  51. async fn setup_with_resource_indicators(enabled: bool) -> Result<World, TestError> {
  52. let world = World::start_with(
  53. open_rate_limit(),
  54. serde_json::json!({"resource_indicators_enabled": enabled}),
  55. )
  56. .await?;
  57. for resource in [RESOURCE_A, RESOURCE_B] {
  58. resource_repo::upsert(&world.pool, resource, "e2e resource server")
  59. .await
  60. .map_err(|error| fail(format!("resource upsert failed: {error}")))?;
  61. }
  62. seed_client(
  63. &world.pool,
  64. &world.state.keys,
  65. CLIENT_ID,
  66. Some(CLIENT_SECRET),
  67. &[
  68. "authorization_code",
  69. "refresh_token",
  70. "client_credentials",
  71. "urn:ietf:params:oauth:grant-type:device_code",
  72. "urn:ietf:params:oauth:grant-type:token-exchange",
  73. ],
  74. "read write",
  75. TokenEndpointAuthMethod::ClientSecretBasic,
  76. )
  77. .await?;
  78. seed_client(
  79. &world.pool,
  80. &world.state.keys,
  81. PUBLIC_ID,
  82. None,
  83. &["authorization_code", "refresh_token"],
  84. "read",
  85. TokenEndpointAuthMethod::None,
  86. )
  87. .await?;
  88. seed_scope(&world.pool, "read").await?;
  89. seed_scope(&world.pool, "write").await?;
  90. attach_redirect(&world.pool, &world.state.keys, CLIENT_ID, REDIRECT_URI).await?;
  91. attach_redirect(&world.pool, &world.state.keys, PUBLIC_ID, REDIRECT_URI).await?;
  92. Ok(world)
  93. }
  94. /// Assert a token failure is the specified OAuth error, minting nothing.
  95. fn ensure_error(status: reqwest::StatusCode, body: &str, code: &str) -> Result<(), TestError> {
  96. ensure(
  97. status == reqwest::StatusCode::BAD_REQUEST,
  98. format!("must be 400, got {status}: {body}"),
  99. )?;
  100. let value = json_body(body)?;
  101. ensure(
  102. value["error"] == code,
  103. format!("failure must be {code}, got {value}"),
  104. )?;
  105. ensure(!body.contains("access_token"), "failure must mint nothing")?;
  106. ensure(
  107. !body.contains(CLIENT_SECRET),
  108. "failure must not leak the secret",
  109. )?;
  110. Ok(())
  111. }
  112. /// PKCE downgrade is rejected at the authorize layer (`plain` → 302
  113. /// `invalid_request`); redemption of a stale code is `invalid_grant`.
  114. #[tokio::test]
  115. async fn e2e_pkce_downgrade_rejected() -> Result<(), TestError> {
  116. let world = setup().await?;
  117. let (challenge, _verifier) = oauth2_pkce();
  118. let (status, headers, _) = world
  119. .get(
  120. &format!(
  121. "/authorize?client_id={PUBLIC_ID}&redirect_uri={}&response_type=code\
  122. &code_challenge={challenge}&code_challenge_method=plain",
  123. encoded(REDIRECT_URI)
  124. ),
  125. &[],
  126. )
  127. .await?;
  128. ensure(status == reqwest::StatusCode::FOUND, "plain PKCE must 302")?;
  129. let location = location_of(&headers)?;
  130. ensure(
  131. location.contains("error=invalid_request"),
  132. "plain PKCE must be invalid_request",
  133. )?;
  134. let (status, _, body) = world
  135. .post_form(
  136. "/token",
  137. &[
  138. ("grant_type", "authorization_code"),
  139. ("code", "stale-code"),
  140. ("redirect_uri", REDIRECT_URI),
  141. ("client_id", CLIENT_ID),
  142. ],
  143. Some((CLIENT_ID, CLIENT_SECRET)),
  144. &[],
  145. )
  146. .await?;
  147. ensure_error(status, &body, "invalid_grant")?;
  148. Ok(())
  149. }
  150. /// Code replay and cross-client redemption never succeed: an unknown or
  151. /// already-consumed code is always `invalid_grant`.
  152. #[tokio::test]
  153. async fn e2e_code_replay_expiry_cross_client_fail_closed() -> Result<(), TestError> {
  154. let world = setup().await?;
  155. let redemption = [
  156. ("grant_type", "authorization_code"),
  157. ("code", "one-time-code"),
  158. ("redirect_uri", REDIRECT_URI),
  159. ("client_id", CLIENT_ID),
  160. ];
  161. for attempt in 1..=2 {
  162. let (status, _, body) = world
  163. .post_form("/token", &redemption, Some((CLIENT_ID, CLIENT_SECRET)), &[])
  164. .await?;
  165. ensure_error(status, &body, "invalid_grant")
  166. .map_err(|error| fail(format!("replay attempt {attempt}: {error}")))?;
  167. }
  168. let (status, _, body) = world
  169. .post_form(
  170. "/token",
  171. &[
  172. ("grant_type", "authorization_code"),
  173. ("code", "one-time-code"),
  174. ("redirect_uri", REDIRECT_URI),
  175. ("client_id", PUBLIC_ID),
  176. ],
  177. None,
  178. &[],
  179. )
  180. .await?;
  181. ensure_error(status, &body, "invalid_grant")?;
  182. Ok(())
  183. }
  184. /// A replayed DPoP `jti` mints once and is `invalid_dpop_proof` on reuse.
  185. #[tokio::test]
  186. async fn e2e_dpop_jti_replay_fail_closed() -> Result<(), TestError> {
  187. let world = setup().await?;
  188. let key = DpopKey::generate()?;
  189. let htu = format!("{}/token", world.state.config.issuer);
  190. let proof = key.proof("POST", &htu)?;
  191. let attempt = [("grant_type", "client_credentials")];
  192. let (status, _, body) = world
  193. .post_form(
  194. "/token",
  195. &attempt,
  196. Some((CLIENT_ID, CLIENT_SECRET)),
  197. &[("dpop", proof.as_str())],
  198. )
  199. .await?;
  200. ensure(
  201. status == reqwest::StatusCode::OK,
  202. format!("fresh proof must mint: {status} {body}"),
  203. )?;
  204. let value = json_body(&body)?;
  205. ensure(
  206. value["token_type"] == "DPoP",
  207. "proof-bound issuance must be DPoP",
  208. )?;
  209. let (status, _, body) = world
  210. .post_form(
  211. "/token",
  212. &attempt,
  213. Some((CLIENT_ID, CLIENT_SECRET)),
  214. &[("dpop", proof.as_str())],
  215. )
  216. .await?;
  217. ensure_error(status, &body, "invalid_dpop_proof")?;
  218. Ok(())
  219. }
  220. /// A DPoP proof bound to the wrong `htu` is `invalid_dpop_proof`.
  221. #[tokio::test]
  222. async fn e2e_dpop_htu_mismatch_fail_closed() -> Result<(), TestError> {
  223. let world = setup().await?;
  224. let key = DpopKey::generate()?;
  225. let wrong = key.proof("POST", "https://auth.example.test/wrong-token")?;
  226. let (status, _, body) = world
  227. .post_form(
  228. "/token",
  229. &[("grant_type", "client_credentials")],
  230. Some((CLIENT_ID, CLIENT_SECRET)),
  231. &[("dpop", wrong.as_str())],
  232. )
  233. .await?;
  234. ensure_error(status, &body, "invalid_dpop_proof")?;
  235. Ok(())
  236. }
  237. /// Refresh reuse rotates once, then `invalid_grant` and cascade-revokes the
  238. /// grant family (the first access token reads inactive).
  239. #[tokio::test]
  240. async fn e2e_refresh_rotation_reuse_cascade_fail_closed() -> Result<(), TestError> {
  241. let world = setup().await?;
  242. let key = DpopKey::generate()?;
  243. let jkt =
  244. d_oauth::jose::jwk::thumbprint(&key.jwk()).map_err(|error| fail(format!("jwk: {error}")))?;
  245. let minted = world
  246. .mint(CLIENT_ID, "read", ClientKind::Confidential, Some(&jkt))
  247. .await?;
  248. let htu = format!("{}/token", world.state.config.issuer);
  249. let refresh = [
  250. ("grant_type", "refresh_token"),
  251. ("refresh_token", minted.refresh_token.as_str()),
  252. ];
  253. let first_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", first_proof.as_str())],
  260. )
  261. .await?;
  262. ensure(
  263. status == reqwest::StatusCode::OK,
  264. format!("first rotation must mint: {status} {body}"),
  265. )?;
  266. let rotated = json_body(&body)?;
  267. ensure(
  268. rotated["access_token"]
  269. .as_str()
  270. .is_some_and(|t| !t.is_empty()),
  271. "rotation must return an access token",
  272. )?;
  273. ensure(
  274. rotated["refresh_token"]
  275. .as_str()
  276. .is_some_and(|t| !t.is_empty()),
  277. "rotation must return a refresh token",
  278. )?;
  279. let second_proof = key.proof("POST", &htu)?;
  280. let (status, _, body) = world
  281. .post_form(
  282. "/token",
  283. &refresh,
  284. Some((CLIENT_ID, CLIENT_SECRET)),
  285. &[("dpop", second_proof.as_str())],
  286. )
  287. .await?;
  288. ensure_error(status, &body, "invalid_grant")?;
  289. // The replay mapping masks the tombstone by design (a same-request retry
  290. // is rejected without cascading); expire it to exercise the reuse path.
  291. let mut conn = world.state.redis.clone();
  292. let found = world
  293. .service
  294. .find_refresh(&mut conn, &world.store, &minted.refresh_token)
  295. .await
  296. .map_err(|error| fail(format!("refresh lookup: {error}")))?
  297. .ok_or_else(|| fail("rotated refresh token must resolve to its tombstone"))?;
  298. let request_id = world
  299. .service
  300. .request_id_for(&found.hash)
  301. .map_err(|error| fail(format!("request id: {error}")))?;
  302. let _: () = redis::cmd("DEL")
  303. .arg(world.store.rotation_map_key(&request_id))
  304. .query_async(&mut conn)
  305. .await?;
  306. let third_proof = key.proof("POST", &htu)?;
  307. let (status, _, body) = world
  308. .post_form(
  309. "/token",
  310. &refresh,
  311. Some((CLIENT_ID, CLIENT_SECRET)),
  312. &[("dpop", third_proof.as_str())],
  313. )
  314. .await?;
  315. ensure_error(status, &body, "invalid_grant")?;
  316. let first_access = minted.access_token.as_str();
  317. let (status, _, body) = world
  318. .post_form(
  319. "/introspect",
  320. &[("token", first_access)],
  321. Some((CLIENT_ID, CLIENT_SECRET)),
  322. &[],
  323. )
  324. .await?;
  325. ensure(
  326. status == reqwest::StatusCode::OK,
  327. "introspection must answer",
  328. )?;
  329. ensure(
  330. json_body(&body)?["active"] == false,
  331. "reuse must cascade-revoke the rotated access token",
  332. )?;
  333. Ok(())
  334. }
  335. /// Concurrent rotations of one refresh token: exactly one mints, every other
  336. /// racer is `invalid_grant`.
  337. #[tokio::test]
  338. async fn e2e_concurrent_rotation_fail_closed() -> Result<(), TestError> {
  339. let world = setup().await?;
  340. let key = DpopKey::generate()?;
  341. let jkt =
  342. d_oauth::jose::jwk::thumbprint(&key.jwk()).map_err(|error| fail(format!("jwk: {error}")))?;
  343. let minted = world
  344. .mint(CLIENT_ID, "read", ClientKind::Confidential, Some(&jkt))
  345. .await?;
  346. let htu = format!("{}/token", world.state.config.issuer);
  347. let mut proofs = Vec::with_capacity(8);
  348. for _ in 0..8 {
  349. proofs.push(key.proof("POST", &htu)?);
  350. }
  351. let mut set = tokio::task::JoinSet::new();
  352. let client = reqwest::Client::builder()
  353. .redirect(reqwest::redirect::Policy::none())
  354. .timeout(std::time::Duration::from_secs(60))
  355. .build()?;
  356. for proof in proofs {
  357. let client = client.clone();
  358. let base = world.base.clone();
  359. let refresh = minted.refresh_token.clone();
  360. set.spawn(async move {
  361. client
  362. .post(format!("{base}/token"))
  363. .basic_auth(CLIENT_ID, Some(CLIENT_SECRET))
  364. .header("dpop", proof)
  365. .form(&[
  366. ("grant_type", "refresh_token"),
  367. ("refresh_token", refresh.as_str()),
  368. ])
  369. .send()
  370. .await
  371. });
  372. }
  373. let mut successes = 0;
  374. let mut rejections = 0;
  375. let mut unavailable = 0;
  376. while let Some(outcome) = set.join_next().await {
  377. let response = outcome.map_err(|error| fail(format!("join failed: {error}")))??;
  378. let status = response.status();
  379. let body = response.text().await?;
  380. if status == reqwest::StatusCode::OK {
  381. successes += 1;
  382. } else if status == reqwest::StatusCode::SERVICE_UNAVAILABLE {
  383. ensure(
  384. json_body(&body)?["error"] == "temporarily_unavailable",
  385. format!("store outage must be temporarily_unavailable: {body}"),
  386. )?;
  387. ensure(!body.contains("access_token"), "failure must mint nothing")?;
  388. unavailable += 1;
  389. } else {
  390. ensure_error(status, &body, "invalid_grant")?;
  391. rejections += 1;
  392. }
  393. }
  394. ensure(
  395. successes == 1,
  396. format!(
  397. "exactly one racer must rotate (got {successes}; invalid_grant={rejections}, \
  398. unavailable={unavailable})"
  399. ),
  400. )?;
  401. ensure(
  402. rejections + unavailable == 7,
  403. "every other racer must be rejected",
  404. )?;
  405. Ok(())
  406. }
  407. /// Concurrent redemptions of one authorization code: exactly one exchanges,
  408. /// every other racer is `invalid_grant` — the code is consumed atomically, so
  409. /// only that one winner can create a token family.
  410. #[tokio::test]
  411. async fn e2e_concurrent_code_redemption_fail_closed() -> Result<(), TestError> {
  412. let world = setup().await?;
  413. let (challenge, verifier) = oauth2_pkce();
  414. let issued = issue_conf_code(&world, CLIENT_ID, &challenge).await?;
  415. let verifier = verifier.secret().to_owned();
  416. let client = reqwest::Client::builder()
  417. .redirect(reqwest::redirect::Policy::none())
  418. .timeout(std::time::Duration::from_secs(60))
  419. .build()?;
  420. let mut set = tokio::task::JoinSet::new();
  421. for _ in 0..2 {
  422. let client = client.clone();
  423. let base = world.base.clone();
  424. let code = issued.code.clone();
  425. let verifier = verifier.clone();
  426. set.spawn(async move {
  427. client
  428. .post(format!("{base}/token"))
  429. .basic_auth(CLIENT_ID, Some(CLIENT_SECRET))
  430. .form(&[
  431. ("grant_type", "authorization_code"),
  432. ("code", code.as_str()),
  433. ("redirect_uri", REDIRECT_URI),
  434. ("client_id", CLIENT_ID),
  435. ("code_verifier", verifier.as_str()),
  436. ])
  437. .send()
  438. .await
  439. });
  440. }
  441. let mut successes = 0;
  442. let mut rejections = 0;
  443. let mut unavailable = 0;
  444. while let Some(outcome) = set.join_next().await {
  445. let response = outcome.map_err(|error| fail(format!("join failed: {error}")))??;
  446. let status = response.status();
  447. let body = response.text().await?;
  448. if status == reqwest::StatusCode::OK {
  449. let minted = json_body(&body)?;
  450. ensure(
  451. minted["access_token"]
  452. .as_str()
  453. .is_some_and(|token| !token.is_empty()),
  454. "the winner must mint an access token",
  455. )?;
  456. ensure(
  457. minted["refresh_token"]
  458. .as_str()
  459. .is_some_and(|token| !token.is_empty()),
  460. "the single winner must create the token family",
  461. )?;
  462. successes += 1;
  463. } else if status == reqwest::StatusCode::SERVICE_UNAVAILABLE {
  464. ensure(
  465. json_body(&body)?["error"] == "temporarily_unavailable",
  466. format!("store outage must be temporarily_unavailable: {body}"),
  467. )?;
  468. ensure(!body.contains("access_token"), "failure must mint nothing")?;
  469. unavailable += 1;
  470. } else {
  471. ensure_error(status, &body, "invalid_grant")?;
  472. rejections += 1;
  473. }
  474. }
  475. ensure(
  476. successes == 1,
  477. format!(
  478. "exactly one racer must redeem the code (got {successes}; invalid_grant={rejections}, \
  479. unavailable={unavailable})"
  480. ),
  481. )?;
  482. ensure(
  483. rejections + unavailable == 1,
  484. "every other racer must be rejected",
  485. )?;
  486. Ok(())
  487. }
  488. /// Concurrent `/authorize` GETs on one PAR `request_uri`: exactly one proceeds
  489. /// into login, every other racer is the fail-closed `invalid_request` — the
  490. /// handle is consumed atomically and cannot be replayed.
  491. #[tokio::test]
  492. async fn e2e_concurrent_par_consumption_fail_closed() -> Result<(), TestError> {
  493. let world = setup().await?;
  494. let (challenge, _verifier) = oauth2_pkce();
  495. let (status, _, body) = world
  496. .post_form(
  497. "/par",
  498. &[
  499. ("client_id", CLIENT_ID),
  500. ("redirect_uri", REDIRECT_URI),
  501. ("response_type", "code"),
  502. ("scope", "read"),
  503. ("state", "par-race-state"),
  504. ("code_challenge", challenge.as_str()),
  505. ("code_challenge_method", "S256"),
  506. ],
  507. Some((CLIENT_ID, CLIENT_SECRET)),
  508. &[],
  509. )
  510. .await?;
  511. ensure(
  512. status == reqwest::StatusCode::CREATED,
  513. format!("PAR push must 201: {status} {body}"),
  514. )?;
  515. let request_uri = json_body(&body)?["request_uri"]
  516. .as_str()
  517. .ok_or_else(|| fail("PAR must return request_uri"))?
  518. .to_owned();
  519. let client = reqwest::Client::builder()
  520. .redirect(reqwest::redirect::Policy::none())
  521. .timeout(std::time::Duration::from_secs(60))
  522. .build()?;
  523. let mut set = tokio::task::JoinSet::new();
  524. for _ in 0..2 {
  525. let client = client.clone();
  526. let base = world.base.clone();
  527. let target = format!(
  528. "/authorize?client_id={CLIENT_ID}&request_uri={}",
  529. encoded(&request_uri)
  530. );
  531. set.spawn(async move { client.get(format!("{base}{target}")).send().await });
  532. }
  533. let mut winners = 0;
  534. let mut rejections = 0;
  535. let mut unavailable = 0;
  536. while let Some(outcome) = set.join_next().await {
  537. let response = outcome.map_err(|error| fail(format!("join failed: {error}")))??;
  538. let status = response.status();
  539. let location = response
  540. .headers()
  541. .get("location")
  542. .and_then(|value| value.to_str().ok())
  543. .map(str::to_owned);
  544. let body = response.text().await?;
  545. if status == reqwest::StatusCode::SEE_OTHER {
  546. ensure(
  547. location
  548. .as_deref()
  549. .is_some_and(|target| target.starts_with("/login?request_id=")),
  550. format!("the winner must enter login: {location:?} {body}"),
  551. )?;
  552. winners += 1;
  553. } else if status == reqwest::StatusCode::SERVICE_UNAVAILABLE {
  554. ensure(
  555. json_body(&body)?["error"] == "temporarily_unavailable",
  556. format!("store outage must be temporarily_unavailable: {body}"),
  557. )?;
  558. unavailable += 1;
  559. } else {
  560. ensure(
  561. status == reqwest::StatusCode::BAD_REQUEST || status == reqwest::StatusCode::FOUND,
  562. format!("a losing PAR authorize must be rejected: {status} {body}"),
  563. )?;
  564. let rejected = body.contains("invalid_request")
  565. || location
  566. .as_deref()
  567. .is_some_and(|target| target.contains("error=invalid_request"));
  568. ensure(
  569. rejected,
  570. format!("a losing PAR authorize must be invalid_request: {status} {location:?} {body}"),
  571. )?;
  572. ensure(
  573. location
  574. .as_deref()
  575. .is_none_or(|target| !target.starts_with("/login")),
  576. "a losing racer must never enter login",
  577. )?;
  578. ensure(
  579. !body.contains("request_id="),
  580. "a losing racer must not start a flow",
  581. )?;
  582. rejections += 1;
  583. }
  584. }
  585. ensure(
  586. winners == 1,
  587. format!("exactly one racer must consume the PAR handle (got {winners})"),
  588. )?;
  589. ensure(
  590. rejections + unavailable == 1,
  591. "every other racer must be rejected",
  592. )?;
  593. Ok(())
  594. }
  595. /// Concurrent `/token` calls reusing one DPoP `jti`: exactly one mints, every
  596. /// other racer is `invalid_dpop_proof` — the replay marker is written with
  597. /// `SET NX`.
  598. #[tokio::test]
  599. async fn e2e_concurrent_dpop_jti_reuse_fail_closed() -> Result<(), TestError> {
  600. let world = setup().await?;
  601. let key = DpopKey::generate()?;
  602. let htu = format!("{}/token", world.state.config.issuer);
  603. let jti = uuid::Uuid::new_v4().to_string();
  604. let mut proofs = Vec::with_capacity(2);
  605. for _ in 0..2 {
  606. proofs.push(key.proof_with_jti("POST", &htu, &jti)?);
  607. }
  608. let client = reqwest::Client::builder()
  609. .redirect(reqwest::redirect::Policy::none())
  610. .timeout(std::time::Duration::from_secs(60))
  611. .build()?;
  612. let mut set = tokio::task::JoinSet::new();
  613. for proof in proofs {
  614. let client = client.clone();
  615. let base = world.base.clone();
  616. set.spawn(async move {
  617. client
  618. .post(format!("{base}/token"))
  619. .basic_auth(CLIENT_ID, Some(CLIENT_SECRET))
  620. .header("dpop", proof)
  621. .form(&[("grant_type", "client_credentials")])
  622. .send()
  623. .await
  624. });
  625. }
  626. let mut successes = 0;
  627. let mut rejections = 0;
  628. let mut unavailable = 0;
  629. while let Some(outcome) = set.join_next().await {
  630. let response = outcome.map_err(|error| fail(format!("join failed: {error}")))??;
  631. let status = response.status();
  632. let body = response.text().await?;
  633. if status == reqwest::StatusCode::OK {
  634. ensure(
  635. json_body(&body)?["token_type"] == "DPoP",
  636. "the winner must mint a DPoP-bound token",
  637. )?;
  638. successes += 1;
  639. } else if status == reqwest::StatusCode::SERVICE_UNAVAILABLE {
  640. ensure(
  641. json_body(&body)?["error"] == "temporarily_unavailable",
  642. format!("store outage must be temporarily_unavailable: {body}"),
  643. )?;
  644. ensure(!body.contains("access_token"), "failure must mint nothing")?;
  645. unavailable += 1;
  646. } else {
  647. ensure_error(status, &body, "invalid_dpop_proof")?;
  648. rejections += 1;
  649. }
  650. }
  651. ensure(
  652. successes == 1,
  653. format!(
  654. "exactly one racer must mint (got {successes}; invalid_dpop_proof={rejections}, \
  655. unavailable={unavailable})"
  656. ),
  657. )?;
  658. ensure(
  659. rejections + unavailable == 1,
  660. "every other racer must be rejected",
  661. )?;
  662. Ok(())
  663. }
  664. /// Concurrent `/token` polls of one approved device code: exactly one mints,
  665. /// every other racer fails closed (`expired_token`/`invalid_grant`, or the
  666. /// RFC 8628 back-off when it races the interval stamp) — the approval is
  667. /// consumed atomically and is never redeemed twice.
  668. #[tokio::test]
  669. async fn e2e_concurrent_device_poll_fail_closed() -> Result<(), TestError> {
  670. let world = setup().await?;
  671. let approver = user_repo::create_user(
  672. &world.pool,
  673. &world.state.keys,
  674. world.state.config.pii_dual_write,
  675. &user_repo::NewUser {
  676. email: "e2e-device-approver@example.test".to_owned(),
  677. password_hash: "e2e-not-a-login-credential".to_owned(),
  678. is_admin: false,
  679. must_change_password: false,
  680. },
  681. )
  682. .await?;
  683. user_scope_repo::entitle(&world.pool, approver.id, "read", None)
  684. .await
  685. .map_err(|error| fail(format!("entitle failed: {error}")))?;
  686. let (status, _, body) = world
  687. .post_form(
  688. "/device_authorization",
  689. &[("client_id", CLIENT_ID), ("scope", "read")],
  690. Some((CLIENT_ID, CLIENT_SECRET)),
  691. &[],
  692. )
  693. .await?;
  694. ensure(
  695. status.is_success(),
  696. format!("device authorization must 200: {status} {body}"),
  697. )?;
  698. let device_code = json_body(&body)?["device_code"]
  699. .as_str()
  700. .ok_or_else(|| fail("device authorization must return device_code"))?
  701. .to_owned();
  702. // Approve through the store decision the `/device` UI records.
  703. let device_hash = hash_token(&world.state.keys, &device_code)
  704. .map_err(|error| fail(format!("device hash failed: {error}")))?;
  705. let mut conn = world.state.redis.clone();
  706. let approved = decide(
  707. &mut conn,
  708. &world.store,
  709. &device_hash,
  710. DeviceStatus::Approved,
  711. &approver.id.to_string(),
  712. &uuid::Uuid::new_v4().to_string(),
  713. )
  714. .await
  715. .map_err(|error| fail(format!("device approval failed: {error}")))?;
  716. ensure(approved, "the device record must be live to approve")?;
  717. let client = reqwest::Client::builder()
  718. .redirect(reqwest::redirect::Policy::none())
  719. .timeout(std::time::Duration::from_secs(60))
  720. .build()?;
  721. let mut set = tokio::task::JoinSet::new();
  722. for _ in 0..2 {
  723. let client = client.clone();
  724. let base = world.base.clone();
  725. let device_code = device_code.clone();
  726. set.spawn(async move {
  727. client
  728. .post(format!("{base}/token"))
  729. .basic_auth(CLIENT_ID, Some(CLIENT_SECRET))
  730. .form(&[
  731. ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
  732. ("device_code", device_code.as_str()),
  733. ])
  734. .send()
  735. .await
  736. });
  737. }
  738. let mut successes = 0;
  739. let mut rejections = 0;
  740. let mut unavailable = 0;
  741. while let Some(outcome) = set.join_next().await {
  742. let response = outcome.map_err(|error| fail(format!("join failed: {error}")))??;
  743. let status = response.status();
  744. let body = response.text().await?;
  745. if status == reqwest::StatusCode::OK {
  746. let minted = json_body(&body)?;
  747. ensure(
  748. minted["access_token"]
  749. .as_str()
  750. .is_some_and(|token| !token.is_empty()),
  751. "the winner must mint an access token",
  752. )?;
  753. ensure(
  754. minted["refresh_token"]
  755. .as_str()
  756. .is_some_and(|token| !token.is_empty()),
  757. "the winner must mint a refresh token",
  758. )?;
  759. successes += 1;
  760. } else if status == reqwest::StatusCode::SERVICE_UNAVAILABLE {
  761. ensure(
  762. json_body(&body)?["error"] == "temporarily_unavailable",
  763. format!("store outage must be temporarily_unavailable: {body}"),
  764. )?;
  765. ensure(!body.contains("access_token"), "failure must mint nothing")?;
  766. unavailable += 1;
  767. } else {
  768. let error_code = json_body(&body)?["error"]
  769. .as_str()
  770. .unwrap_or_default()
  771. .to_owned();
  772. // A racer that lands after the winner's interval stamp gets the RFC 8628
  773. // back-off; one that lands after the atomic consume gets `expired_token`.
  774. ensure(
  775. matches!(
  776. error_code.as_str(),
  777. "expired_token" | "invalid_grant" | "slow_down"
  778. ),
  779. format!("a losing device poll must fail closed, got {status}: {body}"),
  780. )?;
  781. ensure(!body.contains("access_token"), "failure must mint nothing")?;
  782. rejections += 1;
  783. }
  784. }
  785. ensure(
  786. successes == 1,
  787. format!(
  788. "exactly one poll must mint (got {successes}; expired_or_invalid={rejections}, \
  789. unavailable={unavailable})"
  790. ),
  791. )?;
  792. ensure(
  793. rejections + unavailable == 1,
  794. "every other poll must fail closed",
  795. )?;
  796. Ok(())
  797. }
  798. /// A public client's refresh grant without a fresh DPoP proof is
  799. /// `invalid_grant` (the stored `jkt` alone never mints).
  800. #[tokio::test]
  801. async fn e2e_public_client_without_dpop_fail_closed() -> Result<(), TestError> {
  802. let world = setup().await?;
  803. let minted = world
  804. .mint(
  805. PUBLIC_ID,
  806. "read",
  807. ClientKind::Public,
  808. Some("e2e-public-jkt"),
  809. )
  810. .await?;
  811. let (status, _, body) = world
  812. .post_form(
  813. "/token",
  814. &[
  815. ("grant_type", "refresh_token"),
  816. ("refresh_token", minted.refresh_token.as_str()),
  817. ("client_id", PUBLIC_ID),
  818. ],
  819. None,
  820. &[],
  821. )
  822. .await?;
  823. ensure_error(status, &body, "invalid_grant")?;
  824. Ok(())
  825. }
  826. /// A DPoP-bound confidential client must present a fresh proof at token time.
  827. #[tokio::test]
  828. async fn e2e_dpop_bound_client_requires_fresh_proof() -> Result<(), TestError> {
  829. const BOUND_ID: &str = "e2e-grant-dpop-bound";
  830. const BOUND_SECRET: &str = "e2e-grant-bound-secret-0f1e2d3c4b5a6978";
  831. let world = World::start(open_rate_limit()).await?;
  832. seed_client_bound(
  833. &world.pool,
  834. &world.state.keys,
  835. BOUND_ID,
  836. Some(BOUND_SECRET),
  837. &SeedClientOpts {
  838. grants: &["client_credentials"],
  839. scope: "read",
  840. method: TokenEndpointAuthMethod::ClientSecretBasic,
  841. dpop_bound: true,
  842. },
  843. )
  844. .await?;
  845. seed_scope(&world.pool, "read").await?;
  846. let (status, _, body) = world
  847. .post_form(
  848. "/token",
  849. &[("grant_type", "client_credentials")],
  850. Some((BOUND_ID, BOUND_SECRET)),
  851. &[],
  852. )
  853. .await?;
  854. ensure_error(status, &body, "invalid_grant")?;
  855. let key = DpopKey::generate()?;
  856. let proof = key.proof("POST", &format!("{}/token", world.state.config.issuer))?;
  857. let (status, _, body) = world
  858. .post_form(
  859. "/token",
  860. &[("grant_type", "client_credentials"), ("scope", "read")],
  861. Some((BOUND_ID, BOUND_SECRET)),
  862. &[("dpop", proof.as_str())],
  863. )
  864. .await?;
  865. ensure(
  866. status == reqwest::StatusCode::OK,
  867. format!("fresh proof must mint: {status} {body}"),
  868. )?;
  869. ensure(
  870. json_body(&body)?["token_type"] == "DPoP",
  871. "the bound issuance must be a DPoP token",
  872. )?;
  873. Ok(())
  874. }
  875. /// A DPoP-bound confidential client must present a fresh proof on the
  876. /// authorization-code grant too (not only the grants the dispatcher
  877. /// authenticates itself); a proof-less redemption is `invalid_grant`.
  878. #[tokio::test]
  879. async fn e2e_dpop_bound_auth_code_requires_fresh_proof() -> Result<(), TestError> {
  880. const BOUND_ID: &str = "e2e-grant-bound-code";
  881. const BOUND_SECRET: &str = "e2e-grant-bound-code-secret-4d3c2b1a";
  882. let world = World::start(open_rate_limit()).await?;
  883. seed_client_bound(
  884. &world.pool,
  885. &world.state.keys,
  886. BOUND_ID,
  887. Some(BOUND_SECRET),
  888. &SeedClientOpts {
  889. grants: &["authorization_code"],
  890. scope: "read",
  891. method: TokenEndpointAuthMethod::ClientSecretBasic,
  892. dpop_bound: true,
  893. },
  894. )
  895. .await?;
  896. seed_scope(&world.pool, "read").await?;
  897. attach_redirect(&world.pool, &world.state.keys, BOUND_ID, REDIRECT_URI).await?;
  898. let (challenge, verifier) = oauth2_pkce();
  899. let code = issue_conf_code(&world, BOUND_ID, &challenge).await?;
  900. let (status, _, body) = world
  901. .post_form(
  902. "/token",
  903. &[
  904. ("grant_type", "authorization_code"),
  905. ("code", code.code.as_str()),
  906. ("redirect_uri", REDIRECT_URI),
  907. ("client_id", BOUND_ID),
  908. ("code_verifier", verifier.secret()),
  909. ],
  910. Some((BOUND_ID, BOUND_SECRET)),
  911. &[],
  912. )
  913. .await?;
  914. ensure_error(status, &body, "invalid_grant")?;
  915. let code = issue_conf_code(&world, BOUND_ID, &challenge).await?;
  916. let key = DpopKey::generate()?;
  917. let proof = key.proof("POST", &format!("{}/token", world.state.config.issuer))?;
  918. let (status, _, body) = world
  919. .post_form(
  920. "/token",
  921. &[
  922. ("grant_type", "authorization_code"),
  923. ("code", code.code.as_str()),
  924. ("redirect_uri", REDIRECT_URI),
  925. ("client_id", BOUND_ID),
  926. ("code_verifier", verifier.secret()),
  927. ],
  928. Some((BOUND_ID, BOUND_SECRET)),
  929. &[("dpop", proof.as_str())],
  930. )
  931. .await?;
  932. ensure(
  933. status == reqwest::StatusCode::OK,
  934. format!("fresh proof must mint: {status} {body}"),
  935. )?;
  936. ensure(
  937. json_body(&body)?["token_type"] == "DPoP",
  938. "the bound redemption must be a DPoP token",
  939. )?;
  940. Ok(())
  941. }
  942. /// Baseline client-credentials flow via the `oauth2` crate issues a token.
  943. #[tokio::test]
  944. async fn e2e_grant_client_credentials_oauth2_issues_token() -> Result<(), TestError> {
  945. let world = setup().await?;
  946. let client = clients::oauth2_client(&world.base, CLIENT_ID, Some(CLIENT_SECRET))?;
  947. let token = client
  948. .exchange_client_credentials()
  949. .request_async(&world.client)
  950. .await
  951. .map_err(|error| fail(format!("client-credentials failed: {error}")))?;
  952. ensure(
  953. !token.access_token().secret().is_empty(),
  954. "client-credentials must return an access token",
  955. )?;
  956. Ok(())
  957. }
  958. /// RFC 8707 §2: two repeated `resource` values on a `client_credentials`
  959. /// request are captured from the raw body and accepted; the token response
  960. /// never echoes the audience.
  961. #[tokio::test]
  962. async fn token_accepts_repeated_resource() -> Result<(), TestError> {
  963. let world = setup_with_resource_indicators(true).await?;
  964. let (status, _, body) = world
  965. .post_form(
  966. "/token",
  967. &[
  968. ("grant_type", "client_credentials"),
  969. ("resource", RESOURCE_A),
  970. ("resource", RESOURCE_B),
  971. ],
  972. Some((CLIENT_ID, CLIENT_SECRET)),
  973. &[],
  974. )
  975. .await?;
  976. ensure(
  977. status == reqwest::StatusCode::OK,
  978. format!("two catalog resources must mint: {status} {body}"),
  979. )?;
  980. let response = json_body(&body)?;
  981. ensure(
  982. response["access_token"]
  983. .as_str()
  984. .is_some_and(|value| !value.is_empty()),
  985. format!("must return an access token: {body}"),
  986. )?;
  987. for member in ["resource", "audience", "aud"] {
  988. ensure(
  989. response.get(member).is_none(),
  990. format!("the token response must not echo {member}: {body}"),
  991. )?;
  992. }
  993. // The minted token really carries the audience: the stored access record
  994. // pins the sorted catalog set.
  995. let raw = response["access_token"]
  996. .as_str()
  997. .ok_or_else(|| fail(format!("must return an access token: {body}")))?;
  998. let mut conn = world.state.redis.clone();
  999. let found = world
  1000. .service
  1001. .find_access(&mut conn, &world.store, raw)
  1002. .await
  1003. .map_err(|error| fail(format!("access record lookup failed: {error}")))?
  1004. .ok_or_else(|| fail(format!("minted access token not found: {body}")))?;
  1005. ensure(
  1006. found.record.aud == [RESOURCE_A.to_owned(), RESOURCE_B.to_owned()],
  1007. format!("access record audience not pinned: {:?}", found.record.aud),
  1008. )?;
  1009. Ok(())
  1010. }
  1011. /// RFC 8707 §2 fail-closed rules: a resource outside the enabled catalog, a
  1012. /// non-`https` resource, and any resource while the gate is off are all
  1013. /// `invalid_target` 400 with nothing minted.
  1014. #[tokio::test]
  1015. async fn token_rejects_unpinned_resource() -> Result<(), TestError> {
  1016. let enabled = setup_with_resource_indicators(true).await?;
  1017. for resource in [
  1018. "https://unknown.example.test/api",
  1019. "http://rs-a.example.test/api",
  1020. ] {
  1021. let (status, _, body) = enabled
  1022. .post_form(
  1023. "/token",
  1024. &[("grant_type", "client_credentials"), ("resource", resource)],
  1025. Some((CLIENT_ID, CLIENT_SECRET)),
  1026. &[],
  1027. )
  1028. .await?;
  1029. ensure_error(status, &body, "invalid_target")?;
  1030. }
  1031. let disabled = setup_with_resource_indicators(false).await?;
  1032. let (status, _, body) = disabled
  1033. .post_form(
  1034. "/token",
  1035. &[
  1036. ("grant_type", "client_credentials"),
  1037. ("resource", RESOURCE_A),
  1038. ],
  1039. Some((CLIENT_ID, CLIENT_SECRET)),
  1040. &[],
  1041. )
  1042. .await?;
  1043. ensure_error(status, &body, "invalid_target")
  1044. }
  1045. /// Token exchange keeps its RFC 8693 semantics: a `resource` member is
  1046. /// `invalid_request` before any catalog lookup (so a bogus value can never
  1047. /// surface `invalid_target`), while the resource-free request reaches the
  1048. /// existing exchange gate unchanged.
  1049. #[tokio::test]
  1050. async fn exchange_rejects_resource() -> Result<(), TestError> {
  1051. let world = setup_with_resource_indicators(true).await?;
  1052. for resource in [RESOURCE_A, "https://unknown.example.test/api"] {
  1053. let (status, _, body) = world
  1054. .post_form(
  1055. "/token",
  1056. &[
  1057. (
  1058. "grant_type",
  1059. "urn:ietf:params:oauth:grant-type:token-exchange",
  1060. ),
  1061. ("subject_token", "opaque-subject"),
  1062. ("resource", resource),
  1063. ],
  1064. Some((CLIENT_ID, CLIENT_SECRET)),
  1065. &[],
  1066. )
  1067. .await?;
  1068. ensure_error(status, &body, "invalid_request")?;
  1069. ensure(
  1070. json_body(&body)?["error"] != "invalid_target",
  1071. format!("a resource on exchange must never be invalid_target: {body}"),
  1072. )?;
  1073. }
  1074. let (status, _, body) = world
  1075. .post_form(
  1076. "/token",
  1077. &[
  1078. (
  1079. "grant_type",
  1080. "urn:ietf:params:oauth:grant-type:token-exchange",
  1081. ),
  1082. ("subject_token", "opaque-subject"),
  1083. ],
  1084. Some((CLIENT_ID, CLIENT_SECRET)),
  1085. &[],
  1086. )
  1087. .await?;
  1088. ensure_error(status, &body, "invalid_grant")
  1089. }
  1090. /// Baseline authorization-code + PKCE exchange via the `oauth2` crate
  1091. /// redeems a real code issued through [`CodeIssuer`].
  1092. #[tokio::test]
  1093. async fn e2e_grant_authorization_code_oauth2_issues_token() -> Result<(), TestError> {
  1094. let world = setup().await?;
  1095. let (challenge, verifier) = oauth2_pkce();
  1096. let issued = {
  1097. let issuer = CodeIssuer::new(&world.state.config.ttl, &world.state.keys)
  1098. .map_err(|error| fail(format!("issuer: {error}")))?;
  1099. let approved = ScopeSet::parse("read").map_err(|error| fail(format!("scope: {error}")))?;
  1100. let entitled =
  1101. ScopeSet::parse("read write").map_err(|error| fail(format!("scope: {error}")))?;
  1102. let mut conn = world.state.redis.clone();
  1103. let request = CodeIssueRequest {
  1104. client_id: CLIENT_ID,
  1105. client_kind: CodeClientKind::Confidential,
  1106. dpop_required: false,
  1107. user_id: "e2e-subject",
  1108. redirect_uri: REDIRECT_URI,
  1109. approved_scope: &approved,
  1110. user_entitled: &entitled,
  1111. code_challenge: &challenge,
  1112. dpop_jkt: None,
  1113. state: None,
  1114. aud: &[],
  1115. issuer: &world.state.config.issuer,
  1116. };
  1117. issuer
  1118. .issue(&mut conn, &world.store, &request)
  1119. .await
  1120. .map_err(|error| fail(format!("code issue failed: {error}")))?
  1121. };
  1122. let client = clients::oauth2_client(&world.base, CLIENT_ID, Some(CLIENT_SECRET))?;
  1123. let token = client
  1124. .exchange_code(oauth2::AuthorizationCode::new(issued.code))
  1125. .set_pkce_verifier(verifier)
  1126. .request_async(&world.client)
  1127. .await
  1128. .map_err(|error| fail(format!("code redemption failed: {error}")))?;
  1129. ensure(
  1130. !token.access_token().secret().is_empty(),
  1131. "code redemption must return an access token",
  1132. )?;
  1133. ensure(
  1134. token.refresh_token().is_some(),
  1135. "code redemption must return a refresh token",
  1136. )?;
  1137. Ok(())
  1138. }
  1139. /// A stale authorization code via the `oauth2` crate is `invalid_grant`.
  1140. #[tokio::test]
  1141. async fn e2e_grant_authorization_code_stale_is_invalid_grant() -> Result<(), TestError> {
  1142. let world = setup().await?;
  1143. let client = clients::oauth2_client(&world.base, CLIENT_ID, Some(CLIENT_SECRET))?;
  1144. let (_challenge, verifier) = oauth2_pkce();
  1145. let outcome = client
  1146. .exchange_code(oauth2::AuthorizationCode::new("stale-code".to_owned()))
  1147. .set_pkce_verifier(verifier)
  1148. .request_async(&world.client)
  1149. .await;
  1150. match outcome {
  1151. Err(oauth2::RequestTokenError::ServerResponse(response)) => ensure(
  1152. response.error() == &oauth2::basic::BasicErrorResponseType::InvalidGrant,
  1153. "stale code must be invalid_grant",
  1154. ),
  1155. _ => Err(fail("stale code must be an invalid_grant server response")),
  1156. }
  1157. }
  1158. /// An unknown `grant_type` — including the OAuth 2.0-only `password` — is
  1159. /// rejected as `unsupported_grant_type` without minting.
  1160. #[tokio::test]
  1161. async fn e2e_unknown_grant_type_rejected() -> Result<(), TestError> {
  1162. let world = setup().await?;
  1163. let (status, _, body) = world
  1164. .post_form(
  1165. "/token",
  1166. &[
  1167. ("grant_type", "password"),
  1168. ("username", "u"),
  1169. ("password", "p"),
  1170. ],
  1171. Some((CLIENT_ID, CLIENT_SECRET)),
  1172. &[],
  1173. )
  1174. .await?;
  1175. ensure_error(status, &body, "unsupported_grant_type")?;
  1176. Ok(())
  1177. }
  1178. /// Device polling of an unknown code is `expired_token`; a disabled token
  1179. /// exchange is `invalid_grant`.
  1180. #[tokio::test]
  1181. async fn e2e_grant_device_code_and_token_exchange_fail_closed() -> Result<(), TestError> {
  1182. let world = setup().await?;
  1183. let (status, _, body) = world
  1184. .post_form(
  1185. "/token",
  1186. &[
  1187. ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
  1188. ("device_code", "pending-device-code"),
  1189. ],
  1190. Some((CLIENT_ID, CLIENT_SECRET)),
  1191. &[],
  1192. )
  1193. .await?;
  1194. ensure_error(status, &body, "expired_token")?;
  1195. let (status, _, body) = world
  1196. .post_form(
  1197. "/token",
  1198. &[
  1199. (
  1200. "grant_type",
  1201. "urn:ietf:params:oauth:grant-type:token-exchange",
  1202. ),
  1203. ("subject_token", "opaque-subject"),
  1204. ],
  1205. Some((CLIENT_ID, CLIENT_SECRET)),
  1206. &[],
  1207. )
  1208. .await?;
  1209. ensure_error(status, &body, "invalid_grant")?;
  1210. Ok(())
  1211. }
  1212. /// The authorize endpoint verifies a presented DPoP proof and stashes its
  1213. /// `jkt` in the pending-auth record the code issuance binds.
  1214. #[tokio::test]
  1215. async fn e2e_authorize_captures_dpop_jkt() -> Result<(), TestError> {
  1216. let world = setup().await?;
  1217. let (challenge, _verifier) = oauth2_pkce();
  1218. let key = DpopKey::generate()?;
  1219. let htu = format!("{}/authorize", world.state.config.issuer);
  1220. let proof = key.proof("GET", &htu)?;
  1221. let (status, headers, body) = world
  1222. .get(
  1223. &format!(
  1224. "/authorize?client_id={PUBLIC_ID}&redirect_uri={}&response_type=code&scope=read\
  1225. &code_challenge={challenge}&code_challenge_method=S256",
  1226. encoded(REDIRECT_URI)
  1227. ),
  1228. &[("dpop", proof.as_str())],
  1229. )
  1230. .await?;
  1231. ensure(
  1232. status == reqwest::StatusCode::SEE_OTHER,
  1233. format!("authorize with DPoP must 303: {status} {body}"),
  1234. )?;
  1235. let location = location_of(&headers)?;
  1236. let request_id = location
  1237. .split("request_id=")
  1238. .nth(1)
  1239. .ok_or_else(|| fail("authorize redirect must carry request_id"))?;
  1240. let mut conn = world.state.redis.clone();
  1241. let record = d_oauth::store::pending::get(&mut conn, &world.store, request_id)
  1242. .await
  1243. .map_err(|error| fail(format!("pending read failed: {error}")))?
  1244. .ok_or_else(|| fail("pending record must exist"))?;
  1245. let expected =
  1246. d_oauth::jose::jwk::thumbprint(&key.jwk()).map_err(|error| fail(format!("jwk: {error}")))?;
  1247. ensure(
  1248. record.client_id == PUBLIC_ID,
  1249. "pending record must bind the client",
  1250. )?;
  1251. ensure(
  1252. record.dpop_jkt.as_deref() == Some(expected.as_str()),
  1253. "pending record must carry the verified DPoP thumbprint",
  1254. )?;
  1255. Ok(())
  1256. }
  1257. /// PKCE pair from the `oauth2` crate (baseline-flow material only).
  1258. fn oauth2_pkce() -> (String, oauth2::PkceCodeVerifier) {
  1259. let (challenge, verifier) = oauth2::PkceCodeChallenge::new_random_sha256();
  1260. (challenge.as_str().to_owned(), verifier)
  1261. }
  1262. /// Issue a fresh confidential authorization code with no DPoP binding.
  1263. async fn issue_conf_code(
  1264. world: &World,
  1265. client_id: &str,
  1266. challenge: &str,
  1267. ) -> Result<IssuedCode, TestError> {
  1268. let issuer = CodeIssuer::new(&world.state.config.ttl, &world.state.keys)
  1269. .map_err(|error| fail(format!("issuer: {error}")))?;
  1270. let approved = ScopeSet::parse("read").map_err(|error| fail(format!("scope: {error}")))?;
  1271. let mut conn = world.state.redis.clone();
  1272. let request = CodeIssueRequest {
  1273. client_id,
  1274. client_kind: CodeClientKind::Confidential,
  1275. dpop_required: false,
  1276. user_id: "e2e-subject",
  1277. redirect_uri: REDIRECT_URI,
  1278. approved_scope: &approved,
  1279. user_entitled: &approved,
  1280. code_challenge: challenge,
  1281. dpop_jkt: None,
  1282. state: None,
  1283. aud: &[],
  1284. issuer: &world.state.config.issuer,
  1285. };
  1286. issuer
  1287. .issue(&mut conn, &world.store, &request)
  1288. .await
  1289. .map_err(|error| fail(format!("code issue failed: {error}")))
  1290. }
  1291. /// Percent-encode a value for query embedding.
  1292. fn encoded(value: &str) -> String {
  1293. let mut out = String::with_capacity(value.len());
  1294. for byte in value.bytes() {
  1295. if byte.is_ascii_alphanumeric() || b"-_.~".contains(&byte) {
  1296. out.push(byte as char);
  1297. } else {
  1298. out.push_str(&format!("%{byte:02X}"));
  1299. }
  1300. }
  1301. out
  1302. }
  1303. /// Read the `location` header or fail the test.
  1304. fn location_of(headers: &reqwest::header::HeaderMap) -> Result<String, TestError> {
  1305. headers
  1306. .get("location")
  1307. .and_then(|value| value.to_str().ok())
  1308. .map(str::to_owned)
  1309. .ok_or_else(|| fail("response lacks location header"))
  1310. }
  1311. /// 32-byte ASCII JWT signing key, mirroring the config-test fixture material.
  1312. const JWT_TEST_KEY: &str = "bcdefghijklmnopqrstuvwxyzabcdefg";
  1313. /// One world with the global JWT switch pinned and the confidential fixture
  1314. /// client seeded. The client's stored `access_token_format` stays at the
  1315. /// column default (`opaque`) so each test opts in explicitly.
  1316. async fn setup_with_jwt_flag(enabled: bool) -> Result<World, TestError> {
  1317. let overrides = if enabled {
  1318. serde_json::json!({
  1319. "jwt_access_tokens_enabled": true,
  1320. "jwt_signing_key": JWT_TEST_KEY,
  1321. "resource_indicators_enabled": true,
  1322. })
  1323. } else {
  1324. serde_json::json!({})
  1325. };
  1326. let world = World::start_with(open_rate_limit(), overrides).await?;
  1327. if enabled {
  1328. for resource in [RESOURCE_A, RESOURCE_B] {
  1329. resource_repo::upsert(&world.pool, resource, "e2e resource server")
  1330. .await
  1331. .map_err(|error| fail(format!("resource upsert failed: {error}")))?;
  1332. }
  1333. }
  1334. seed_client(
  1335. &world.pool,
  1336. &world.state.keys,
  1337. CLIENT_ID,
  1338. Some(CLIENT_SECRET),
  1339. &["authorization_code", "refresh_token", "client_credentials"],
  1340. "read write",
  1341. TokenEndpointAuthMethod::ClientSecretBasic,
  1342. )
  1343. .await?;
  1344. seed_scope(&world.pool, "read").await?;
  1345. seed_scope(&world.pool, "write").await?;
  1346. Ok(world)
  1347. }
  1348. /// Assert a response is the fail-closed 503 for JWT access tokens and mints
  1349. /// nothing (never the 500 `server_error`).
  1350. fn ensure_temporarily_unavailable(
  1351. status: reqwest::StatusCode,
  1352. body: &str,
  1353. ) -> Result<(), TestError> {
  1354. ensure(
  1355. status == reqwest::StatusCode::SERVICE_UNAVAILABLE,
  1356. format!("jwt client with the global switch off must be 503, got {status}: {body}"),
  1357. )?;
  1358. ensure(
  1359. status != reqwest::StatusCode::INTERNAL_SERVER_ERROR,
  1360. "the gate must never surface server_error/500",
  1361. )?;
  1362. let value = json_body(body)?;
  1363. ensure(
  1364. value["error"] == "temporarily_unavailable",
  1365. format!("failure must be temporarily_unavailable, got {value}"),
  1366. )?;
  1367. ensure(
  1368. value.get("access_token").is_none(),
  1369. "a failed format gate must mint nothing",
  1370. )?;
  1371. Ok(())
  1372. }
  1373. /// An opaque client never enters the JWT stack: with the global switch off
  1374. /// and on (with a fully valid signing key configured) `client_credentials`
  1375. /// still mints the same opaque, dot-free token.
  1376. #[tokio::test]
  1377. async fn opaque_default_ignores_jwt_stack() -> Result<(), TestError> {
  1378. for jwt_enabled in [false, true] {
  1379. let world = setup_with_jwt_flag(jwt_enabled).await?;
  1380. let (status, _, body) = world
  1381. .post_form(
  1382. "/token",
  1383. &[("grant_type", "client_credentials")],
  1384. Some((CLIENT_ID, CLIENT_SECRET)),
  1385. &[],
  1386. )
  1387. .await?;
  1388. ensure(
  1389. status == reqwest::StatusCode::OK,
  1390. format!("flag={jwt_enabled}: an opaque client must mint: {status} {body}"),
  1391. )?;
  1392. let value = json_body(&body)?;
  1393. let token = value["access_token"]
  1394. .as_str()
  1395. .ok_or_else(|| fail("success body lacks access_token"))?;
  1396. ensure(
  1397. !token.is_empty()
  1398. && !token.contains('.')
  1399. && token
  1400. .bytes()
  1401. .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_'),
  1402. "opaque tokens are base64url without a JOSE segment separator",
  1403. )?;
  1404. ensure(
  1405. value["token_type"] == "Bearer",
  1406. "the opaque response shape is unchanged",
  1407. )?;
  1408. }
  1409. Ok(())
  1410. }
  1411. /// A client switched to `jwt` while the global kill-switch is off fails
  1412. /// closed with `temporarily_unavailable` (HTTP 503, never 500) and mints
  1413. /// nothing; both dispatcher-authenticated and internally authenticated grants
  1414. /// observe the same gate.
  1415. #[tokio::test]
  1416. async fn jwt_client_flag_off_is_503() -> Result<(), TestError> {
  1417. let world = setup_with_jwt_flag(false).await?;
  1418. ensure(
  1419. client_repo::set_access_token_format(&world.pool, CLIENT_ID, "jwt").await?,
  1420. "the fixture client must switch to jwt",
  1421. )?;
  1422. let (status, _, body) = world
  1423. .post_form(
  1424. "/token",
  1425. &[("grant_type", "client_credentials")],
  1426. Some((CLIENT_ID, CLIENT_SECRET)),
  1427. &[],
  1428. )
  1429. .await?;
  1430. ensure_temporarily_unavailable(status, &body)?;
  1431. // `authorization_code` authenticates inside the grant module; the
  1432. // dispatcher resolves the candidate client id and applies the same gate.
  1433. let (status, _, body) = world
  1434. .post_form(
  1435. "/token",
  1436. &[
  1437. ("grant_type", "authorization_code"),
  1438. ("code", "stale-code"),
  1439. ("redirect_uri", REDIRECT_URI),
  1440. ("client_id", CLIENT_ID),
  1441. ],
  1442. Some((CLIENT_ID, CLIENT_SECRET)),
  1443. &[],
  1444. )
  1445. .await?;
  1446. ensure_temporarily_unavailable(status, &body)?;
  1447. Ok(())
  1448. }
  1449. /// The positive branch: `jwt` + the global switch on + a valid signing key
  1450. /// passes the format gate instead of 503 and mints a real JWT bound to the
  1451. /// requested `resource` (a JWT without a resource audience is
  1452. /// `invalid_target`).
  1453. #[tokio::test]
  1454. async fn jwt_client_with_flag_on_resolves_jwt() -> Result<(), TestError> {
  1455. let world = setup_with_jwt_flag(true).await?;
  1456. ensure(
  1457. client_repo::set_access_token_format(&world.pool, CLIENT_ID, "jwt").await?,
  1458. "the fixture client must switch to jwt",
  1459. )?;
  1460. let (status, _, body) = world
  1461. .post_form(
  1462. "/token",
  1463. &[
  1464. ("grant_type", "client_credentials"),
  1465. ("resource", RESOURCE_A),
  1466. ],
  1467. Some((CLIENT_ID, CLIENT_SECRET)),
  1468. &[],
  1469. )
  1470. .await?;
  1471. ensure(
  1472. status != reqwest::StatusCode::SERVICE_UNAVAILABLE,
  1473. format!("jwt with the switch on must pass the gate, got 503: {body}"),
  1474. )?;
  1475. ensure(
  1476. status == reqwest::StatusCode::OK,
  1477. format!("the gate's positive branch must reach minting: {status} {body}"),
  1478. )?;
  1479. let token = json_body(&body)?["access_token"]
  1480. .as_str()
  1481. .map(str::to_owned)
  1482. .ok_or_else(|| fail("the positive branch mints a token"))?;
  1483. ensure(
  1484. token.split('.').count() == 3,
  1485. "the positive branch mints a JWT",
  1486. )?;
  1487. Ok(())
  1488. }
  1489. /// Token exchange never mints a JWT: a `jwt`-format caller fails closed with
  1490. /// `temporarily_unavailable` (503) and no token, even with the global switch
  1491. /// on and a valid signing key configured.
  1492. #[tokio::test]
  1493. async fn jwt_exchange_format_rejected() -> Result<(), TestError> {
  1494. const EXCHANGE_CLIENT: &str = "e2e-exchange-jwt-client";
  1495. let world = setup_with_jwt_flag(true).await?;
  1496. seed_client(
  1497. &world.pool,
  1498. &world.state.keys,
  1499. EXCHANGE_CLIENT,
  1500. Some(CLIENT_SECRET),
  1501. &["urn:ietf:params:oauth:grant-type:token-exchange"],
  1502. "read write",
  1503. TokenEndpointAuthMethod::ClientSecretBasic,
  1504. )
  1505. .await?;
  1506. ensure(
  1507. client_repo::set_access_token_format(&world.pool, EXCHANGE_CLIENT, "jwt").await?,
  1508. "the fixture client must switch to jwt",
  1509. )?;
  1510. let (status, _, body) = world
  1511. .post_form(
  1512. "/token",
  1513. &[(
  1514. "grant_type",
  1515. "urn:ietf:params:oauth:grant-type:token-exchange",
  1516. )],
  1517. Some((EXCHANGE_CLIENT, CLIENT_SECRET)),
  1518. &[],
  1519. )
  1520. .await?;
  1521. ensure(
  1522. status == reqwest::StatusCode::SERVICE_UNAVAILABLE,
  1523. format!("a jwt exchange caller must be 503, got {status}: {body}"),
  1524. )?;
  1525. let value = json_body(&body)?;
  1526. ensure(
  1527. value["error"] == "temporarily_unavailable",
  1528. format!("failure must be temporarily_unavailable, got {value}"),
  1529. )?;
  1530. ensure(
  1531. value.get("access_token").is_none(),
  1532. "a rejected exchange must mint nothing",
  1533. )?;
  1534. Ok(())
  1535. }