audience.rs 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342
  1. //! Refresh audience propagation (RFC 8707 §2 and §2.2).
  2. //!
  3. //! The consumed refresh record's `aud` is the FULL ORIGINAL grant set: an
  4. //! omitted `resource` keeps that set on both rotated records, a present
  5. //! `resource` narrows only the access token while the rotated refresh token
  6. //! stays bound to the full set, and any member outside the original set is
  7. //! `invalid_target` with nothing rotated.
  8. use super::{TestError, TestResult, dpop_proof, mint_grant, now_unix, service, test_redis, unique};
  9. use crate::{
  10. error::OAuthErrorCode,
  11. protocol::{
  12. scope::ScopeSet,
  13. token::{
  14. ClientKind, MintRequest, MintedTokens, SenderProof, TokenFormat, TokenService,
  15. grants::refresh_token::{RefreshInput, refresh},
  16. },
  17. },
  18. state::RedisConn,
  19. store::Store,
  20. };
  21. /// First catalog-registered resource indicator (RFC 8707).
  22. const RES_A: &str = "https://rs-a.example.test/api";
  23. /// Second catalog-registered resource indicator (RFC 8707).
  24. const RES_B: &str = "https://rs-b.example.test/api";
  25. /// Third indicator, outside the `{A, B}` grant set (widening probe).
  26. const RES_C: &str = "https://rs-c.example.test/api";
  27. /// Differing indicator, outside the `{A, B}` grant set (mismatch probe).
  28. const RES_X: &str = "https://rs-x.example.test/api";
  29. /// Whether the rejection is `invalid_target`.
  30. fn is_invalid_target(error: &crate::error::AppError) -> bool {
  31. error.code() == OAuthErrorCode::InvalidTarget
  32. }
  33. /// Mint an initial pair whose records carry `aud` on both halves.
  34. async fn mint_aud_grant(
  35. service: &TokenService,
  36. conn: &mut RedisConn,
  37. store: &Store,
  38. client: &str,
  39. scope: &str,
  40. proof: SenderProof,
  41. aud: &[String],
  42. ) -> Result<MintedTokens, TestError> {
  43. let minted = service
  44. .mint(
  45. conn,
  46. store,
  47. &MintRequest {
  48. client_id: client.to_owned(),
  49. client_kind: ClientKind::Public,
  50. dpop_required: true,
  51. subject: unique("audience-subject"),
  52. scope: scope.to_owned(),
  53. access_scope: scope.to_owned(),
  54. grant_id: unique("audience-grant"),
  55. proof,
  56. aud: aud.to_vec(),
  57. refresh_aud: aud.to_vec(),
  58. format: TokenFormat::Opaque,
  59. },
  60. now_unix(),
  61. )
  62. .await?;
  63. Ok(minted)
  64. }
  65. /// Refresh inputs with an explicit RFC 8707 `resource` set.
  66. fn aud_input<'a>(
  67. client: &'a str,
  68. token: &'a str,
  69. resources: &'a [String],
  70. proof: &'a SenderProof,
  71. enabled: &'a ScopeSet,
  72. ) -> RefreshInput<'a> {
  73. RefreshInput {
  74. client_id: client,
  75. client_kind: ClientKind::Public,
  76. dpop_required: true,
  77. refresh_token: token,
  78. requested_scope: None,
  79. requested_resources: resources,
  80. proof,
  81. enabled,
  82. client_registered: enabled,
  83. user_entitled: enabled,
  84. token_format: TokenFormat::Opaque,
  85. now_unix: now_unix(),
  86. }
  87. }
  88. /// Read back the stored audience of an access token.
  89. async fn access_aud(
  90. service: &TokenService,
  91. conn: &mut RedisConn,
  92. store: &Store,
  93. raw: &str,
  94. ) -> Result<Vec<String>, TestError> {
  95. match service.find_access(conn, store, raw).await? {
  96. Some(found) => Ok(found.record.aud),
  97. None => Err(TestError::Unexpected(
  98. "rotated access token not found".to_owned(),
  99. )),
  100. }
  101. }
  102. /// Read back the stored audience of a refresh token.
  103. async fn refresh_aud(
  104. service: &TokenService,
  105. conn: &mut RedisConn,
  106. store: &Store,
  107. raw: &str,
  108. ) -> Result<Vec<String>, TestError> {
  109. match service.find_refresh(conn, store, raw).await? {
  110. Some(found) => Ok(found.record.aud),
  111. None => Err(TestError::Unexpected(
  112. "rotated refresh token not found".to_owned(),
  113. )),
  114. }
  115. }
  116. /// Refresh without `resource` keeps the original audience on both records.
  117. #[tokio::test]
  118. async fn refresh_preserves_audience() -> TestResult {
  119. let (_container, mut conn, store, _url) = test_redis().await?;
  120. let service = service()?;
  121. let client = unique("preserve-client");
  122. let proof = dpop_proof(&unique("preserve-jkt"));
  123. let original = vec![RES_A.to_owned(), RES_B.to_owned()];
  124. let minted = mint_aud_grant(
  125. &service,
  126. &mut conn,
  127. &store,
  128. &client,
  129. "read",
  130. proof.clone(),
  131. &original,
  132. )
  133. .await?;
  134. let enabled = ScopeSet::parse("read")?;
  135. let rotated = refresh(
  136. &mut conn,
  137. &store,
  138. &service,
  139. &aud_input(&client, &minted.refresh_token, &[], &proof, &enabled),
  140. )
  141. .await?;
  142. if access_aud(&service, &mut conn, &store, &rotated.access_token).await? != original {
  143. return Err(TestError::Unexpected(
  144. "rotated access token lost its audience".to_owned(),
  145. ));
  146. }
  147. if refresh_aud(&service, &mut conn, &store, &rotated.refresh_token).await? != original {
  148. return Err(TestError::Unexpected(
  149. "rotated refresh token lost its audience".to_owned(),
  150. ));
  151. }
  152. Ok(())
  153. }
  154. /// A `resource` outside the original grant set is `invalid_target`, and the
  155. /// presented refresh token stays live for a retry.
  156. #[tokio::test]
  157. async fn refresh_rejects_audience_widening() -> TestResult {
  158. let (_container, mut conn, store, _url) = test_redis().await?;
  159. let service = service()?;
  160. let client = unique("widen-client");
  161. let proof = dpop_proof(&unique("widen-jkt"));
  162. let minted = mint_aud_grant(
  163. &service,
  164. &mut conn,
  165. &store,
  166. &client,
  167. "read",
  168. proof.clone(),
  169. &[RES_A.to_owned(), RES_B.to_owned()],
  170. )
  171. .await?;
  172. let enabled = ScopeSet::parse("read")?;
  173. for widened in [
  174. vec![RES_A.to_owned(), RES_B.to_owned(), RES_C.to_owned()],
  175. vec![RES_A.to_owned(), RES_X.to_owned()],
  176. ] {
  177. let error = match refresh(
  178. &mut conn,
  179. &store,
  180. &service,
  181. &aud_input(&client, &minted.refresh_token, &widened, &proof, &enabled),
  182. )
  183. .await
  184. {
  185. Ok(_) => {
  186. return Err(TestError::Unexpected(format!(
  187. "widened audience rotated: {widened:?}"
  188. )));
  189. }
  190. Err(error) => error,
  191. };
  192. if !is_invalid_target(&error) {
  193. return Err(TestError::Unexpected(format!(
  194. "widen code: {}",
  195. error.code()
  196. )));
  197. }
  198. }
  199. // Nothing was consumed or rotated: the presented token still works.
  200. if service
  201. .find_refresh(&mut conn, &store, &minted.refresh_token)
  202. .await?
  203. .is_none()
  204. {
  205. return Err(TestError::Unexpected(
  206. "the rejected refresh token was consumed".to_owned(),
  207. ));
  208. }
  209. refresh(
  210. &mut conn,
  211. &store,
  212. &service,
  213. &aud_input(&client, &minted.refresh_token, &[], &proof, &enabled),
  214. )
  215. .await?;
  216. Ok(())
  217. }
  218. /// A narrowed `resource` narrows only the access token: the rotated refresh
  219. /// token keeps the full original set, so the next resourceless refresh
  220. /// restores the full audience on the access token.
  221. #[tokio::test]
  222. async fn refresh_narrow_access_keeps_full_refresh_binding() -> TestResult {
  223. let (_container, mut conn, store, _url) = test_redis().await?;
  224. let service = service()?;
  225. let client = unique("narrow-client");
  226. let proof = dpop_proof(&unique("narrow-jkt"));
  227. let full = vec![RES_A.to_owned(), RES_B.to_owned()];
  228. let minted = mint_aud_grant(
  229. &service,
  230. &mut conn,
  231. &store,
  232. &client,
  233. "read",
  234. proof.clone(),
  235. &full,
  236. )
  237. .await?;
  238. let enabled = ScopeSet::parse("read")?;
  239. let narrowed = refresh(
  240. &mut conn,
  241. &store,
  242. &service,
  243. &aud_input(
  244. &client,
  245. &minted.refresh_token,
  246. &[RES_A.to_owned()],
  247. &proof,
  248. &enabled,
  249. ),
  250. )
  251. .await?;
  252. if access_aud(&service, &mut conn, &store, &narrowed.access_token).await?
  253. != vec![RES_A.to_owned()]
  254. {
  255. return Err(TestError::Unexpected(
  256. "rotated access token did not narrow".to_owned(),
  257. ));
  258. }
  259. if refresh_aud(&service, &mut conn, &store, &narrowed.refresh_token).await? != full {
  260. return Err(TestError::Unexpected(
  261. "rotated refresh token lost the full binding".to_owned(),
  262. ));
  263. }
  264. // The preserved binding still authorizes the full set: a resourceless
  265. // refresh restores the full audience on the access token.
  266. let restored = refresh(
  267. &mut conn,
  268. &store,
  269. &service,
  270. &aud_input(&client, &narrowed.refresh_token, &[], &proof, &enabled),
  271. )
  272. .await?;
  273. if access_aud(&service, &mut conn, &store, &restored.access_token).await? != full {
  274. return Err(TestError::Unexpected(
  275. "the full binding did not survive narrowing".to_owned(),
  276. ));
  277. }
  278. Ok(())
  279. }
  280. /// An unbound grant cannot gain an audience: a non-empty `resource` against
  281. /// an empty original set is `invalid_target`.
  282. #[tokio::test]
  283. async fn refresh_unbound_cannot_gain_audience() -> TestResult {
  284. let (_container, mut conn, store, _url) = test_redis().await?;
  285. let service = service()?;
  286. let client = unique("unbound-client");
  287. let proof = dpop_proof(&unique("unbound-jkt"));
  288. let minted = mint_grant(
  289. &service,
  290. &mut conn,
  291. &store,
  292. &client,
  293. ClientKind::Public,
  294. "read",
  295. proof.clone(),
  296. )
  297. .await?;
  298. let enabled = ScopeSet::parse("read")?;
  299. let error = match refresh(
  300. &mut conn,
  301. &store,
  302. &service,
  303. &aud_input(
  304. &client,
  305. &minted.refresh_token,
  306. &[RES_A.to_owned()],
  307. &proof,
  308. &enabled,
  309. ),
  310. )
  311. .await
  312. {
  313. Ok(_) => {
  314. return Err(TestError::Unexpected(
  315. "an unbound grant gained an audience".to_owned(),
  316. ));
  317. }
  318. Err(error) => error,
  319. };
  320. if !is_invalid_target(&error) {
  321. return Err(TestError::Unexpected(format!(
  322. "unbound gain code: {}",
  323. error.code()
  324. )));
  325. }
  326. Ok(())
  327. }