e2e_token_exchange.rs 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300
  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. ensure(
  167. value["expires_in"] == exchange_ttl(&world.service).as_secs(),
  168. format!(
  169. "exchange must use the shortened TTL {}: {value}",
  170. exchange_ttl(&world.service).as_secs()
  171. ),
  172. )?;
  173. let access = value["access_token"]
  174. .as_str()
  175. .ok_or_else(|| fail("exchange must return an access token"))?;
  176. let (status, _, body) = world
  177. .post_form(
  178. "/introspect",
  179. &[("token", access)],
  180. Some((CALLER_ID, CALLER_SECRET)),
  181. &[],
  182. )
  183. .await?;
  184. ensure(status.is_success(), "introspect must 200")?;
  185. let introspected = json_body(&body)?;
  186. ensure(
  187. introspected["active"] == true,
  188. format!("exchanged token must be active: {introspected}"),
  189. )?;
  190. ensure(
  191. introspected["act"]["sub"] == SUBJECT_ID,
  192. format!("act.sub must name the subject-token client: {introspected}"),
  193. )?;
  194. ensure(
  195. introspected["cnf"]["jkt"] == jkt,
  196. format!("cnf.jkt must carry the proof key thumbprint: {introspected}"),
  197. )?;
  198. ensure(
  199. !body.contains(CALLER_SECRET),
  200. "introspection must not leak the caller secret",
  201. )?;
  202. Ok(())
  203. }
  204. /// A request wider than the allowlist is `invalid_scope` and mints nothing.
  205. #[tokio::test]
  206. async fn e2e_token_exchange_rejects_scope_widening() -> Result<(), TestError> {
  207. let world = setup().await?;
  208. seed_entitled_user(&world, USER_EMAIL, USER_PASSWORD, SCOPE).await?;
  209. let subject = subject_token(&world).await?;
  210. let (status, body) = exchange_request(&world, &subject, "read write").await?;
  211. ensure(
  212. status == reqwest::StatusCode::BAD_REQUEST,
  213. format!("scope widening must 400, got {status}: {body}"),
  214. )?;
  215. let value = json_body(&body)?;
  216. ensure(
  217. value["error"] == "invalid_scope",
  218. format!("scope widening must be invalid_scope: {value}"),
  219. )?;
  220. ensure(
  221. value.get("access_token").is_none(),
  222. "a rejected exchange must mint nothing",
  223. )?;
  224. Ok(())
  225. }
  226. /// An already-`act`-bound token is not exchangeable (no chained exchanges).
  227. #[tokio::test]
  228. async fn e2e_token_exchange_rejects_multi_hop() -> Result<(), TestError> {
  229. let world = setup().await?;
  230. seed_entitled_user(&world, USER_EMAIL, USER_PASSWORD, SCOPE).await?;
  231. let subject = subject_token(&world).await?;
  232. let key = DpopKey::generate()?;
  233. let htu = format!("{}/token", world.state.config.issuer);
  234. let first = key.proof("POST", &htu)?;
  235. let (status, _, body) = world
  236. .post_form(
  237. "/token",
  238. &[
  239. ("grant_type", EXCHANGE_GRANT),
  240. ("subject_token", subject.as_str()),
  241. ("subject_token_type", ACCESS_TOKEN_TYPE),
  242. ("requested_token_type", ACCESS_TOKEN_TYPE),
  243. ("scope", SCOPE),
  244. ],
  245. Some((CALLER_ID, CALLER_SECRET)),
  246. &[("dpop", first.as_str())],
  247. )
  248. .await?;
  249. ensure(
  250. status.is_success(),
  251. format!("first exchange must 200, got {status}: {body}"),
  252. )?;
  253. let exchanged = json_body(&body)?;
  254. let act_bound = exchanged["access_token"]
  255. .as_str()
  256. .ok_or_else(|| fail("first exchange must return an access token"))?;
  257. let second = key.proof("POST", &htu)?;
  258. let (status, _, body) = world
  259. .post_form(
  260. "/token",
  261. &[
  262. ("grant_type", EXCHANGE_GRANT),
  263. ("subject_token", act_bound),
  264. ("subject_token_type", ACCESS_TOKEN_TYPE),
  265. ("requested_token_type", ACCESS_TOKEN_TYPE),
  266. ("scope", SCOPE),
  267. ],
  268. Some((CALLER_ID, CALLER_SECRET)),
  269. &[("dpop", second.as_str())],
  270. )
  271. .await?;
  272. ensure(
  273. status == reqwest::StatusCode::BAD_REQUEST,
  274. format!("multi-hop exchange must 400, got {status}: {body}"),
  275. )?;
  276. let value = json_body(&body)?;
  277. ensure(
  278. value["error"] == "invalid_grant",
  279. format!("multi-hop exchange must be invalid_grant: {value}"),
  280. )?;
  281. ensure(
  282. value.get("access_token").is_none(),
  283. "a chained exchange must mint nothing",
  284. )?;
  285. Ok(())
  286. }