e2e_token_exchange.rs 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308
  1. //! `test(e2e_)` token exchange (RFC 8693) over the live router.
  2. //!
  3. //! Same harness contract as the sibling suites: the REAL production router
  4. //! over a REAL loopback socket against the shared Postgres + Redis, asserting
  5. //! only HTTP behavior. The caller is a DPoP-bound confidential client holding
  6. //! the exchange grant; the subject token is a real user's access token, whose
  7. //! `subject` is the account EMAIL so entitlement resolution can find it.
  8. #[allow(dead_code)]
  9. #[path = "e2e/harness.rs"]
  10. mod harness;
  11. #[path = "e2e/dpop.rs"]
  12. mod dpop;
  13. use d_oauth::{
  14. db::{TokenEndpointAuthMethod, user_repo},
  15. protocol::token::{ClientKind, grants::token_exchange::exchange_ttl},
  16. };
  17. use dpop::DpopKey;
  18. use harness::{
  19. SeedClientOpts, TestError, World, ensure, fail, json_body, open_rate_limit, seed_client,
  20. seed_client_bound, seed_entitled_user, seed_scope,
  21. };
  22. /// Grant-type URN for token exchange (RFC 8693 §2.1).
  23. const EXCHANGE_GRANT: &str = "urn:ietf:params:oauth:grant-type:token-exchange";
  24. /// Access-token type URN used for `subject_token_type`/`requested_token_type`.
  25. const ACCESS_TOKEN_TYPE: &str = "urn:ietf:params:oauth:token-type:access_token";
  26. /// Authenticated caller holding the exchange grant (DPoP-bound).
  27. const CALLER_ID: &str = "e2e-exchange-caller";
  28. /// Caller secret (also the leak probe).
  29. const CALLER_SECRET: &str = "e2e-exchange-secret-1a2b3c4d5e6f708192a0";
  30. /// Subject client that owns the delegated access token.
  31. const SUBJECT_ID: &str = "e2e-exchange-subject";
  32. /// Provisioned user's email: the subject-token `subject` value.
  33. const USER_EMAIL: &str = "exchange-user@example.test";
  34. /// Provisioned user's password (never used for HTTP login here).
  35. const USER_PASSWORD: &str = "e2e-exchange-password-1a2b3c4d";
  36. /// The only scope the allowlist exposes.
  37. const SCOPE: &str = "read";
  38. /// One world with token exchange enabled, the allowlist pair, and the clients.
  39. async fn setup() -> Result<World, TestError> {
  40. let world = World::start_with(
  41. open_rate_limit(),
  42. serde_json::json!({
  43. "allow_token_exchange": true,
  44. "token_exchange_allowlist": [
  45. {"caller": CALLER_ID, "subject": SUBJECT_ID, "scopes": ["read"]},
  46. {"caller": CALLER_ID, "subject": CALLER_ID, "scopes": ["read"]}
  47. ],
  48. }),
  49. )
  50. .await?;
  51. seed_client_bound(
  52. &world.pool,
  53. &world.state.keys,
  54. CALLER_ID,
  55. Some(CALLER_SECRET),
  56. &SeedClientOpts {
  57. grants: &[EXCHANGE_GRANT],
  58. scope: "read write",
  59. method: TokenEndpointAuthMethod::ClientSecretBasic,
  60. dpop_bound: true,
  61. },
  62. )
  63. .await?;
  64. seed_client(
  65. &world.pool,
  66. &world.state.keys,
  67. SUBJECT_ID,
  68. None,
  69. &["authorization_code"],
  70. "read",
  71. TokenEndpointAuthMethod::None,
  72. )
  73. .await?;
  74. seed_scope(&world.pool, "read").await?;
  75. seed_scope(&world.pool, "write").await?;
  76. Ok(world)
  77. }
  78. /// The subject's unbound access token: user EMAIL as `subject`, `read` scope.
  79. async fn subject_token(world: &World) -> Result<String, TestError> {
  80. let minted = world
  81. .mint_for_subject(
  82. SUBJECT_ID,
  83. USER_EMAIL,
  84. "read",
  85. ClientKind::Confidential,
  86. None,
  87. )
  88. .await?;
  89. Ok(minted.access_token)
  90. }
  91. /// Send one exchange request with Basic auth and a fresh DPoP proof.
  92. async fn exchange_request(
  93. world: &World,
  94. subject_token: &str,
  95. scope: &str,
  96. ) -> Result<(reqwest::StatusCode, String), TestError> {
  97. let key = DpopKey::generate()?;
  98. let htu = format!("{}/token", world.state.config.issuer);
  99. let proof = key.proof("POST", &htu)?;
  100. let (status, _, body) = world
  101. .post_form(
  102. "/token",
  103. &[
  104. ("grant_type", EXCHANGE_GRANT),
  105. ("subject_token", subject_token),
  106. ("subject_token_type", ACCESS_TOKEN_TYPE),
  107. ("requested_token_type", ACCESS_TOKEN_TYPE),
  108. ("scope", scope),
  109. ],
  110. Some((CALLER_ID, CALLER_SECRET)),
  111. &[("dpop", proof.as_str())],
  112. )
  113. .await?;
  114. Ok((status, body))
  115. }
  116. /// Allowlisted exchange mints a DPoP-bound, `act`-bound access token with no
  117. /// refresh token and the shortened exchange TTL.
  118. #[tokio::test]
  119. async fn e2e_token_exchange_allowlisted_mints_act_bound_token() -> Result<(), TestError> {
  120. let world = setup().await?;
  121. let (user_id, _totp) = seed_entitled_user(&world, USER_EMAIL, USER_PASSWORD, SCOPE).await?;
  122. let user = user_repo::find_by_email(&world.pool, &world.state.keys, USER_EMAIL)
  123. .await?
  124. .ok_or_else(|| fail("seeded user must resolve by email"))?;
  125. ensure(
  126. user.id == user_id,
  127. "the subject-token email must resolve to the seeded account",
  128. )?;
  129. let subject = subject_token(&world).await?;
  130. let key = DpopKey::generate()?;
  131. let htu = format!("{}/token", world.state.config.issuer);
  132. let proof = key.proof("POST", &htu)?;
  133. let jkt = d_oauth::jose::jwk::thumbprint(&key.jwk())
  134. .map_err(|error| fail(format!("proof thumbprint: {error}")))?;
  135. let (status, _, body) = world
  136. .post_form(
  137. "/token",
  138. &[
  139. ("grant_type", EXCHANGE_GRANT),
  140. ("subject_token", subject.as_str()),
  141. ("subject_token_type", ACCESS_TOKEN_TYPE),
  142. ("requested_token_type", ACCESS_TOKEN_TYPE),
  143. ("scope", SCOPE),
  144. ],
  145. Some((CALLER_ID, CALLER_SECRET)),
  146. &[("dpop", proof.as_str())],
  147. )
  148. .await?;
  149. ensure(
  150. status.is_success(),
  151. format!("allowlisted exchange must 200, got {status}: {body}"),
  152. )?;
  153. let value = json_body(&body)?;
  154. ensure(
  155. value["token_type"] == "DPoP",
  156. format!("exchange must mint a DPoP token: {value}"),
  157. )?;
  158. ensure(
  159. value["issued_token_type"] == ACCESS_TOKEN_TYPE,
  160. format!("exchange must report the access-token type: {value}"),
  161. )?;
  162. ensure(
  163. value.get("refresh_token").is_none(),
  164. "exchange must never issue a refresh token",
  165. )?;
  166. let exchanged_ttl = exchange_ttl(&world.service);
  167. ensure(
  168. value["expires_in"] == exchanged_ttl.as_secs(),
  169. format!(
  170. "exchange must use the shortened TTL {}: {value}",
  171. exchanged_ttl.as_secs()
  172. ),
  173. )?;
  174. ensure(
  175. exchanged_ttl < world.state.config.ttl.access,
  176. format!(
  177. "the exchange TTL ({exchanged_ttl:?}) must be shorter than the access TTL ({:?})",
  178. world.state.config.ttl.access
  179. ),
  180. )?;
  181. let access = value["access_token"]
  182. .as_str()
  183. .ok_or_else(|| fail("exchange must return an access token"))?;
  184. let (status, _, body) = world
  185. .post_form(
  186. "/introspect",
  187. &[("token", access)],
  188. Some((CALLER_ID, CALLER_SECRET)),
  189. &[],
  190. )
  191. .await?;
  192. ensure(status.is_success(), "introspect must 200")?;
  193. let introspected = json_body(&body)?;
  194. ensure(
  195. introspected["active"] == true,
  196. format!("exchanged token must be active: {introspected}"),
  197. )?;
  198. ensure(
  199. introspected["act"]["sub"] == SUBJECT_ID,
  200. format!("act.sub must name the subject-token client: {introspected}"),
  201. )?;
  202. ensure(
  203. introspected["cnf"]["jkt"] == jkt,
  204. format!("cnf.jkt must carry the proof key thumbprint: {introspected}"),
  205. )?;
  206. ensure(
  207. !body.contains(CALLER_SECRET),
  208. "introspection must not leak the caller secret",
  209. )?;
  210. Ok(())
  211. }
  212. /// A request wider than the allowlist is `invalid_scope` and mints nothing.
  213. #[tokio::test]
  214. async fn e2e_token_exchange_rejects_scope_widening() -> Result<(), TestError> {
  215. let world = setup().await?;
  216. seed_entitled_user(&world, USER_EMAIL, USER_PASSWORD, SCOPE).await?;
  217. let subject = subject_token(&world).await?;
  218. let (status, body) = exchange_request(&world, &subject, "read write").await?;
  219. ensure(
  220. status == reqwest::StatusCode::BAD_REQUEST,
  221. format!("scope widening must 400, got {status}: {body}"),
  222. )?;
  223. let value = json_body(&body)?;
  224. ensure(
  225. value["error"] == "invalid_scope",
  226. format!("scope widening must be invalid_scope: {value}"),
  227. )?;
  228. ensure(
  229. value.get("access_token").is_none(),
  230. "a rejected exchange must mint nothing",
  231. )?;
  232. Ok(())
  233. }
  234. /// An already-`act`-bound token is not exchangeable (no chained exchanges).
  235. #[tokio::test]
  236. async fn e2e_token_exchange_rejects_multi_hop() -> Result<(), TestError> {
  237. let world = setup().await?;
  238. seed_entitled_user(&world, USER_EMAIL, USER_PASSWORD, SCOPE).await?;
  239. let subject = subject_token(&world).await?;
  240. let key = DpopKey::generate()?;
  241. let htu = format!("{}/token", world.state.config.issuer);
  242. let first = key.proof("POST", &htu)?;
  243. let (status, _, body) = world
  244. .post_form(
  245. "/token",
  246. &[
  247. ("grant_type", EXCHANGE_GRANT),
  248. ("subject_token", subject.as_str()),
  249. ("subject_token_type", ACCESS_TOKEN_TYPE),
  250. ("requested_token_type", ACCESS_TOKEN_TYPE),
  251. ("scope", SCOPE),
  252. ],
  253. Some((CALLER_ID, CALLER_SECRET)),
  254. &[("dpop", first.as_str())],
  255. )
  256. .await?;
  257. ensure(
  258. status.is_success(),
  259. format!("first exchange must 200, got {status}: {body}"),
  260. )?;
  261. let exchanged = json_body(&body)?;
  262. let act_bound = exchanged["access_token"]
  263. .as_str()
  264. .ok_or_else(|| fail("first exchange must return an access token"))?;
  265. let second = key.proof("POST", &htu)?;
  266. let (status, _, body) = world
  267. .post_form(
  268. "/token",
  269. &[
  270. ("grant_type", EXCHANGE_GRANT),
  271. ("subject_token", act_bound),
  272. ("subject_token_type", ACCESS_TOKEN_TYPE),
  273. ("requested_token_type", ACCESS_TOKEN_TYPE),
  274. ("scope", SCOPE),
  275. ],
  276. Some((CALLER_ID, CALLER_SECRET)),
  277. &[("dpop", second.as_str())],
  278. )
  279. .await?;
  280. ensure(
  281. status == reqwest::StatusCode::BAD_REQUEST,
  282. format!("multi-hop exchange must 400, got {status}: {body}"),
  283. )?;
  284. let value = json_body(&body)?;
  285. ensure(
  286. value["error"] == "invalid_grant",
  287. format!("multi-hop exchange must be invalid_grant: {value}"),
  288. )?;
  289. ensure(
  290. value.get("access_token").is_none(),
  291. "a chained exchange must mint nothing",
  292. )?;
  293. Ok(())
  294. }