tests.rs 42 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347
  1. //! `test(authorize_validate)` suite: validation order + error-delivery matrix.
  2. use std::{collections::HashMap, time::Duration};
  3. use axum::http::{StatusCode, header};
  4. use base64::Engine as _;
  5. use jsonwebtoken::{Algorithm, EncodingKey, Header, encode};
  6. use ring::rand::SystemRandom;
  7. use ring::signature::{Ed25519KeyPair, KeyPair as _};
  8. use secrecy::SecretString;
  9. use time::OffsetDateTime;
  10. use uuid::Uuid;
  11. use super::{AuthorizeFailure, AuthorizeInput, validate};
  12. use crate::{
  13. config::DurabilityMode,
  14. crypto::pkce::challenge_from_verifier,
  15. db::{
  16. client_repo, client_repo::NewClient, redirect_repo, resource_repo, scope_repo,
  17. test_support::migrated_pool,
  18. },
  19. error::{AppError, OAuthErrorCode},
  20. keys::{KeyId, KeyRing},
  21. protocol::{
  22. cimd,
  23. client_auth::ClientAuthRequest,
  24. par::{ParContext, ParStores, push_authorization_request},
  25. },
  26. state::RedisConn,
  27. store::Store,
  28. test_support,
  29. };
  30. /// Boxed test error (no `unwrap`/`expect` under the crate deny lints).
  31. type TestResult<T = ()> = Result<T, Box<dyn std::error::Error + Send + Sync>>;
  32. const ISSUER: &str = "https://auth.example.test";
  33. const REDIRECT: &str = "https://app.example.test/cb";
  34. const VERIFIER: &str = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
  35. /// Build a query map from pairs.
  36. fn query(pairs: &[(&str, &str)]) -> HashMap<String, String> {
  37. pairs
  38. .iter()
  39. .map(|(key, value)| ((*key).to_owned(), (*value).to_owned()))
  40. .collect()
  41. }
  42. /// A minimal client fixture; `public` selects `token_endpoint_auth_method`.
  43. fn fixture(client_id: &str, public: bool) -> NewClient {
  44. NewClient {
  45. id: Uuid::new_v4(),
  46. client_id: client_id.to_owned(),
  47. client_secret_hash: None,
  48. client_secret_expires_at: None,
  49. client_name: "Authorize Test Client".to_owned(),
  50. grant_types: vec!["authorization_code".to_owned()],
  51. response_types: vec!["code".to_owned()],
  52. scope: "api:read".to_owned(),
  53. token_endpoint_auth_method: if public {
  54. "none".to_owned()
  55. } else {
  56. "client_secret_basic".to_owned()
  57. },
  58. jwks: None,
  59. jwks_uri: None,
  60. tls_client_auth_subject_dn: None,
  61. tls_client_auth_cert_thumbprint: None,
  62. request_uris: None,
  63. contacts: None,
  64. software_id: None,
  65. dpop_bound_access_tokens: false,
  66. access_token_format: "opaque".to_owned(),
  67. registration_access_token_hash: None,
  68. }
  69. }
  70. /// Register a client, one redirect URI, and the `api:read` catalog scope.
  71. async fn provision(
  72. pool: &sqlx::PgPool,
  73. keys: &KeyRing,
  74. client_id: &str,
  75. public: bool,
  76. ) -> TestResult {
  77. provision_at(pool, keys, client_id, public, REDIRECT).await
  78. }
  79. /// Register a client with an explicit redirect URI (encrypt-only at rest).
  80. async fn provision_at(
  81. pool: &sqlx::PgPool,
  82. keys: &KeyRing,
  83. client_id: &str,
  84. public: bool,
  85. uri: &str,
  86. ) -> TestResult {
  87. crate::db::client_repo::insert(pool, keys, false, &fixture(client_id, public)).await?;
  88. let (row, _) = redirect_repo::upsert(
  89. pool,
  90. keys,
  91. false,
  92. &redirect_repo::NewRedirectUri {
  93. id: Uuid::new_v4(),
  94. uri,
  95. kind: redirect_repo::RedirectKind::Authorize,
  96. description: "authorize test",
  97. created_by: None,
  98. },
  99. )
  100. .await?;
  101. redirect_repo::attach(pool, client_id, row.id, None).await?;
  102. scope_repo::upsert(pool, "api:read", "read api").await?;
  103. Ok(())
  104. }
  105. /// A never-dialed Redis connection: plain-query validation never touches PAR.
  106. fn offline_redis() -> TestResult<RedisConn> {
  107. let client = redis::Client::open("redis://127.0.0.1:1")?;
  108. let manager = redis::aio::ConnectionManager::new_lazy_with_config(
  109. client,
  110. redis::aio::ConnectionManagerConfig::new()
  111. .set_number_of_retries(0)
  112. .set_response_timeout(Some(Duration::from_millis(200)))
  113. .set_connection_timeout(Some(Duration::from_millis(200))),
  114. )?;
  115. Ok(RedisConn::Single(manager))
  116. }
  117. /// Store handle for the PAR lookup (unused on the plain-query path).
  118. fn test_store() -> Store {
  119. Store::from_parts(
  120. "oauth:{state}:".to_owned(),
  121. "oauth:{rl}:".to_owned(),
  122. DurabilityMode::WaitAof,
  123. )
  124. }
  125. /// Test PII key ring for the encrypted client projection.
  126. fn test_keys() -> TestResult<KeyRing> {
  127. Ok(KeyRing::from_parts(
  128. vec![(
  129. KeyId::new("v1"),
  130. SecretString::from("authorize-test-pepper"),
  131. )],
  132. vec![(KeyId::new("v1"), SecretString::from("authorize-test-totp"))],
  133. vec![(KeyId::new("v1"), SecretString::from("authorize-test-pii"))],
  134. )?)
  135. }
  136. /// An Ed25519 signer plus the matching inline JWKS for a JAR regression.
  137. struct TestSigner {
  138. encoding: EncodingKey,
  139. jwks: serde_json::Value,
  140. }
  141. impl TestSigner {
  142. /// Generate a fresh Ed25519 keypair and its one-key inline JWKS.
  143. fn generate(kid: &str) -> TestResult<Self> {
  144. let rng = SystemRandom::new();
  145. let pkcs8 = Ed25519KeyPair::generate_pkcs8(&rng).map_err(|_| "ed25519 keygen failed")?;
  146. let pair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).map_err(|_| "ed25519 reload failed")?;
  147. let x = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(pair.public_key().as_ref());
  148. Ok(Self {
  149. encoding: EncodingKey::from_ed_der(pkcs8.as_ref()),
  150. jwks: serde_json::json!({
  151. "keys": [{ "kty": "OKP", "crv": "Ed25519", "x": x, "kid": kid }]
  152. }),
  153. })
  154. }
  155. /// Sign a request object bound to `client_id` and the test issuer.
  156. fn request_object(
  157. &self,
  158. kid: &str,
  159. client_id: &str,
  160. extra: serde_json::Value,
  161. ) -> TestResult<String> {
  162. let now = OffsetDateTime::now_utc().unix_timestamp();
  163. let mut claims = serde_json::json!({
  164. "iss": client_id,
  165. "aud": ISSUER,
  166. "exp": now + 300,
  167. "iat": now,
  168. });
  169. let object = claims
  170. .as_object_mut()
  171. .ok_or("request object claims must be an object")?;
  172. let extra = extra
  173. .as_object()
  174. .ok_or("request object extra claims must be an object")?;
  175. for (name, value) in extra {
  176. object.insert(name.clone(), value.clone());
  177. }
  178. let mut header = Header::new(Algorithm::EdDSA);
  179. header.kid = Some(kid.to_owned());
  180. Ok(encode(&header, &claims, &self.encoding)?)
  181. }
  182. }
  183. /// Register a client whose inline JWKS verifies signed request objects.
  184. async fn provision_signing(
  185. pool: &sqlx::PgPool,
  186. keys: &KeyRing,
  187. client_id: &str,
  188. jwks: serde_json::Value,
  189. ) -> TestResult {
  190. let mut new = fixture(client_id, true);
  191. new.jwks = Some(jwks);
  192. client_repo::insert(pool, keys, false, &new).await?;
  193. let (row, _) = redirect_repo::upsert(
  194. pool,
  195. keys,
  196. false,
  197. &redirect_repo::NewRedirectUri {
  198. id: Uuid::new_v4(),
  199. uri: REDIRECT,
  200. kind: redirect_repo::RedirectKind::Authorize,
  201. description: "authorize test",
  202. created_by: None,
  203. },
  204. )
  205. .await?;
  206. redirect_repo::attach(pool, client_id, row.id, None).await?;
  207. scope_repo::upsert(pool, "api:read", "read api").await?;
  208. Ok(())
  209. }
  210. /// A valid plain-query authorize map for the provisioned client.
  211. fn valid_query(client_id: &str, challenge: &str) -> HashMap<String, String> {
  212. query(&[
  213. ("client_id", client_id),
  214. ("redirect_uri", REDIRECT),
  215. ("response_type", "code"),
  216. ("scope", "api:read"),
  217. ("code_challenge", challenge),
  218. ("code_challenge_method", "S256"),
  219. ("state", "xyz-state"),
  220. ])
  221. }
  222. /// A public client with plain query + PKCE proceeds with every field carried.
  223. #[tokio::test]
  224. async fn authorize_validate_public_client_plain_query_proceeds() -> TestResult {
  225. let (_db, pool) = migrated_pool().await?;
  226. let keys = test_keys()?;
  227. provision(&pool, &keys, "authz-public", true).await?;
  228. let challenge = challenge_from_verifier(VERIFIER).map_err(|_| "bad fixture verifier")?;
  229. let params = valid_query("authz-public", &challenge);
  230. let mut conn = offline_redis()?;
  231. let store = test_store();
  232. let validated = validate(AuthorizeInput {
  233. pool: &pool,
  234. keys: &keys,
  235. conn: &mut conn,
  236. store: &store,
  237. issuer: ISSUER,
  238. allow_localhost_redirect: false,
  239. cimd: None,
  240. query: &params,
  241. resources: Vec::new(),
  242. resource_indicators_enabled: false,
  243. })
  244. .await
  245. .map_err(|failure| format!("valid request rejected: {}", failure.code()))?;
  246. assert_eq!(validated.client_id, "authz-public");
  247. assert_eq!(validated.redirect_uri, REDIRECT);
  248. assert_eq!(validated.scope.render(), "api:read");
  249. assert_eq!(validated.code_challenge, challenge);
  250. assert_eq!(validated.state.as_deref(), Some("xyz-state"));
  251. Ok(())
  252. }
  253. /// An unknown `client_id` shows the error page: 400, no `Location`.
  254. #[tokio::test]
  255. async fn authorize_validate_unknown_client_shows_page() -> TestResult {
  256. let (_db, pool) = migrated_pool().await?;
  257. let keys = test_keys()?;
  258. let mut params = valid_query("authz-ghost", "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM");
  259. params.remove("scope");
  260. let mut conn = offline_redis()?;
  261. let store = test_store();
  262. match validate(AuthorizeInput {
  263. pool: &pool,
  264. keys: &keys,
  265. conn: &mut conn,
  266. store: &store,
  267. issuer: ISSUER,
  268. allow_localhost_redirect: false,
  269. cimd: None,
  270. query: &params,
  271. resources: Vec::new(),
  272. resource_indicators_enabled: false,
  273. })
  274. .await
  275. {
  276. Ok(_) => Err("unknown client was accepted".into()),
  277. Err(failure) => {
  278. let response = failure.into_response();
  279. assert_eq!(response.status(), StatusCode::BAD_REQUEST);
  280. assert!(response.headers().get(header::LOCATION).is_none());
  281. Ok(())
  282. }
  283. }
  284. }
  285. /// An unregistered `redirect_uri` shows the error page: 400, no `Location`.
  286. #[tokio::test]
  287. async fn authorize_validate_unregistered_redirect_shows_page() -> TestResult {
  288. let (_db, pool) = migrated_pool().await?;
  289. let keys = test_keys()?;
  290. provision(&pool, &keys, "authz-redirect", true).await?;
  291. let challenge = challenge_from_verifier(VERIFIER).map_err(|_| "bad fixture verifier")?;
  292. let mut params = valid_query("authz-redirect", &challenge);
  293. params.insert(
  294. "redirect_uri".to_owned(),
  295. "https://evil.example.test/cb".to_owned(),
  296. );
  297. let mut conn = offline_redis()?;
  298. let store = test_store();
  299. match validate(AuthorizeInput {
  300. pool: &pool,
  301. keys: &keys,
  302. conn: &mut conn,
  303. store: &store,
  304. issuer: ISSUER,
  305. allow_localhost_redirect: false,
  306. cimd: None,
  307. query: &params,
  308. resources: Vec::new(),
  309. resource_indicators_enabled: false,
  310. })
  311. .await
  312. {
  313. Ok(_) => Err("unregistered redirect was accepted".into()),
  314. Err(failure) => {
  315. assert_eq!(failure.code(), OAuthErrorCode::InvalidRequest);
  316. let response = failure.into_response();
  317. assert_eq!(response.status(), StatusCode::BAD_REQUEST);
  318. assert!(response.headers().get(header::LOCATION).is_none());
  319. Ok(())
  320. }
  321. }
  322. }
  323. /// `response_type=token` redirects with `unsupported_response_type` + `state`.
  324. #[tokio::test]
  325. async fn authorize_validate_token_response_type_redirects() -> TestResult {
  326. let (_db, pool) = migrated_pool().await?;
  327. let keys = test_keys()?;
  328. provision(&pool, &keys, "authz-rt", true).await?;
  329. let challenge = challenge_from_verifier(VERIFIER).map_err(|_| "bad fixture verifier")?;
  330. let mut params = valid_query("authz-rt", &challenge);
  331. params.insert("response_type".to_owned(), "token".to_owned());
  332. let mut conn = offline_redis()?;
  333. let store = test_store();
  334. match validate(AuthorizeInput {
  335. pool: &pool,
  336. keys: &keys,
  337. conn: &mut conn,
  338. store: &store,
  339. issuer: ISSUER,
  340. allow_localhost_redirect: false,
  341. cimd: None,
  342. query: &params,
  343. resources: Vec::new(),
  344. resource_indicators_enabled: false,
  345. })
  346. .await
  347. {
  348. Ok(_) => Err("response_type=token was accepted".into()),
  349. Err(AuthorizeFailure::Redirect {
  350. target,
  351. error,
  352. state,
  353. iss,
  354. }) => {
  355. assert_eq!(target, REDIRECT);
  356. assert_eq!(error.code(), OAuthErrorCode::UnsupportedResponseType);
  357. assert_eq!(state.as_deref(), Some("xyz-state"));
  358. assert_eq!(iss, ISSUER);
  359. let response = AuthorizeFailure::Redirect {
  360. target,
  361. error,
  362. state,
  363. iss,
  364. }
  365. .into_response();
  366. assert_eq!(response.status(), StatusCode::FOUND);
  367. let location = response
  368. .headers()
  369. .get(header::LOCATION)
  370. .and_then(|value| value.to_str().ok())
  371. .unwrap_or_default()
  372. .to_owned();
  373. assert!(location.contains("error=unsupported_response_type"));
  374. assert!(location.contains("state=xyz-state"));
  375. assert!(
  376. location.contains("iss=https%3A%2F%2Fauth.example.test"),
  377. "error redirect missing iss: {location}"
  378. );
  379. Ok(())
  380. }
  381. Err(other) => Err(format!("expected a redirect, got {}", other.code()).into()),
  382. }
  383. }
  384. /// An unknown scope redirects with `invalid_scope` + `state` (no page).
  385. #[tokio::test]
  386. async fn authorize_validate_unknown_scope_redirects() -> TestResult {
  387. let (_db, pool) = migrated_pool().await?;
  388. let keys = test_keys()?;
  389. provision(&pool, &keys, "authz-scope", true).await?;
  390. let challenge = challenge_from_verifier(VERIFIER).map_err(|_| "bad fixture verifier")?;
  391. let mut params = valid_query("authz-scope", &challenge);
  392. params.insert("scope".to_owned(), "admin:all".to_owned());
  393. let mut conn = offline_redis()?;
  394. let store = test_store();
  395. match validate(AuthorizeInput {
  396. pool: &pool,
  397. keys: &keys,
  398. conn: &mut conn,
  399. store: &store,
  400. issuer: ISSUER,
  401. allow_localhost_redirect: false,
  402. cimd: None,
  403. query: &params,
  404. resources: Vec::new(),
  405. resource_indicators_enabled: false,
  406. })
  407. .await
  408. {
  409. Ok(_) => Err("unknown scope was accepted".into()),
  410. Err(AuthorizeFailure::Redirect {
  411. target,
  412. error,
  413. state,
  414. iss,
  415. }) => {
  416. assert_eq!(target, REDIRECT);
  417. assert_eq!(error.code(), OAuthErrorCode::InvalidScope);
  418. assert_eq!(state.as_deref(), Some("xyz-state"));
  419. assert_eq!(iss, ISSUER);
  420. let response = AuthorizeFailure::Redirect {
  421. target,
  422. error,
  423. state,
  424. iss,
  425. }
  426. .into_response();
  427. assert_eq!(response.status(), StatusCode::FOUND);
  428. let location = response
  429. .headers()
  430. .get(header::LOCATION)
  431. .and_then(|value| value.to_str().ok())
  432. .unwrap_or_default()
  433. .to_owned();
  434. assert!(location.contains("error=invalid_scope"));
  435. assert!(location.contains("state=xyz-state"));
  436. assert!(
  437. location.contains("iss=https%3A%2F%2Fauth.example.test"),
  438. "error redirect missing iss: {location}"
  439. );
  440. Ok(())
  441. }
  442. Err(other) => Err(format!("expected a redirect, got {}", other.code()).into()),
  443. }
  444. }
  445. /// A confidential client with only plain query params is rejected with
  446. /// `invalid_request` (no PAR `request_uri`, no signed `request` object).
  447. #[tokio::test]
  448. async fn authorize_validate_confidential_plain_query_rejected() -> TestResult {
  449. let (_db, pool) = migrated_pool().await?;
  450. let keys = test_keys()?;
  451. provision(&pool, &keys, "authz-conf", false).await?;
  452. let challenge = challenge_from_verifier(VERIFIER).map_err(|_| "bad fixture verifier")?;
  453. let params = valid_query("authz-conf", &challenge);
  454. let mut conn = offline_redis()?;
  455. let store = test_store();
  456. match validate(AuthorizeInput {
  457. pool: &pool,
  458. keys: &keys,
  459. conn: &mut conn,
  460. store: &store,
  461. issuer: ISSUER,
  462. allow_localhost_redirect: false,
  463. cimd: None,
  464. query: &params,
  465. resources: Vec::new(),
  466. resource_indicators_enabled: false,
  467. })
  468. .await
  469. {
  470. Ok(_) => Err("confidential plain-query request was accepted".into()),
  471. Err(failure) => {
  472. assert_eq!(failure.code(), OAuthErrorCode::InvalidRequest);
  473. let response = failure.into_response();
  474. assert_eq!(response.status(), StatusCode::BAD_REQUEST);
  475. assert!(response.headers().get(header::LOCATION).is_none());
  476. Ok(())
  477. }
  478. }
  479. }
  480. /// An exact registered redirect matches after decryption; a prefix/evil
  481. /// URI still fails closed; a corrupt `uri_enc` fails as 503 `PiiDecrypt`.
  482. #[tokio::test]
  483. async fn authorize_redirect_exact_match() -> TestResult {
  484. let (_db, pool) = migrated_pool().await?;
  485. let keys = test_keys()?;
  486. provision(&pool, &keys, "authz-exact", true).await?;
  487. let challenge = challenge_from_verifier(VERIFIER).map_err(|_| "bad fixture verifier")?;
  488. let mut conn = offline_redis()?;
  489. let store = test_store();
  490. let params = valid_query("authz-exact", &challenge);
  491. let validated = validate(AuthorizeInput {
  492. pool: &pool,
  493. keys: &keys,
  494. conn: &mut conn,
  495. store: &store,
  496. issuer: ISSUER,
  497. allow_localhost_redirect: false,
  498. cimd: None,
  499. query: &params,
  500. resources: Vec::new(),
  501. resource_indicators_enabled: false,
  502. })
  503. .await
  504. .map_err(|failure| format!("exact redirect rejected: {}", failure.code()))?;
  505. assert_eq!(validated.redirect_uri, REDIRECT);
  506. for hostile in [
  507. "https://evil.example.test/cb",
  508. "https://app.example.test/cb/extra",
  509. ] {
  510. let mut params = valid_query("authz-exact", &challenge);
  511. params.insert("redirect_uri".to_owned(), hostile.to_owned());
  512. match validate(AuthorizeInput {
  513. pool: &pool,
  514. keys: &keys,
  515. conn: &mut conn,
  516. store: &store,
  517. issuer: ISSUER,
  518. allow_localhost_redirect: false,
  519. cimd: None,
  520. query: &params,
  521. resources: Vec::new(),
  522. resource_indicators_enabled: false,
  523. })
  524. .await
  525. {
  526. Ok(_) => return Err(format!("hostile redirect accepted: {hostile}").into()),
  527. Err(failure) => {
  528. assert_eq!(failure.code(), OAuthErrorCode::InvalidRequest);
  529. let response = failure.into_response();
  530. assert_eq!(response.status(), StatusCode::BAD_REQUEST);
  531. assert!(response.headers().get(header::LOCATION).is_none());
  532. }
  533. }
  534. }
  535. let corrupt: &[u8] = b"corrupt-blob";
  536. sqlx::query("UPDATE redirect_uris SET uri_enc = $1")
  537. .bind(corrupt)
  538. .execute(&pool)
  539. .await
  540. .map_err(|error| format!("corrupt fixture failed: {error}"))?;
  541. match validate(AuthorizeInput {
  542. pool: &pool,
  543. keys: &keys,
  544. conn: &mut conn,
  545. store: &store,
  546. issuer: ISSUER,
  547. allow_localhost_redirect: false,
  548. cimd: None,
  549. query: &params,
  550. resources: Vec::new(),
  551. resource_indicators_enabled: false,
  552. })
  553. .await
  554. {
  555. Ok(_) => Err("corrupt uri_enc accepted".into()),
  556. Err(AuthorizeFailure::Page(error)) => {
  557. assert!(matches!(error, AppError::PiiDecrypt));
  558. assert_eq!(error.code(), OAuthErrorCode::TemporarilyUnavailable);
  559. assert_eq!(error.status(), StatusCode::SERVICE_UNAVAILABLE);
  560. Ok(())
  561. }
  562. Err(other) => Err(format!("expected a PiiDecrypt page, got {}", other.code()).into()),
  563. }
  564. }
  565. /// A loopback registration matches a request with a random port (RFC 8252
  566. /// §7.3) after decryption; a different path still fails closed.
  567. #[tokio::test]
  568. async fn authorize_loopback_allows_port() -> TestResult {
  569. const LOOPBACK: &str = "http://127.0.0.1:8080/cb";
  570. let (_db, pool) = migrated_pool().await?;
  571. let keys = test_keys()?;
  572. provision_at(&pool, &keys, "authz-loop", true, LOOPBACK).await?;
  573. let challenge = challenge_from_verifier(VERIFIER).map_err(|_| "bad fixture verifier")?;
  574. let mut conn = offline_redis()?;
  575. let store = test_store();
  576. let mut params = valid_query("authz-loop", &challenge);
  577. params.insert(
  578. "redirect_uri".to_owned(),
  579. "http://127.0.0.1:59231/cb".to_owned(),
  580. );
  581. let validated = validate(AuthorizeInput {
  582. pool: &pool,
  583. keys: &keys,
  584. conn: &mut conn,
  585. store: &store,
  586. issuer: ISSUER,
  587. allow_localhost_redirect: false,
  588. cimd: None,
  589. query: &params,
  590. resources: Vec::new(),
  591. resource_indicators_enabled: false,
  592. })
  593. .await
  594. .map_err(|failure| format!("loopback port rejected: {}", failure.code()))?;
  595. assert_eq!(validated.redirect_uri, "http://127.0.0.1:59231/cb");
  596. params.insert(
  597. "redirect_uri".to_owned(),
  598. "http://127.0.0.1:59231/other".to_owned(),
  599. );
  600. match validate(AuthorizeInput {
  601. pool: &pool,
  602. keys: &keys,
  603. conn: &mut conn,
  604. store: &store,
  605. issuer: ISSUER,
  606. allow_localhost_redirect: false,
  607. cimd: None,
  608. query: &params,
  609. resources: Vec::new(),
  610. resource_indicators_enabled: false,
  611. })
  612. .await
  613. {
  614. Ok(_) => Err("loopback wrong-path accepted".into()),
  615. Err(failure) => {
  616. assert_eq!(failure.code(), OAuthErrorCode::InvalidRequest);
  617. Ok(())
  618. }
  619. }
  620. }
  621. /// A missing PKCE challenge redirects with `invalid_request` (+ `state`).
  622. #[tokio::test]
  623. async fn authorize_validate_missing_challenge_redirects() -> TestResult {
  624. let (_db, pool) = migrated_pool().await?;
  625. let keys = test_keys()?;
  626. provision(&pool, &keys, "authz-pkce", true).await?;
  627. let mut params = valid_query("authz-pkce", "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM");
  628. params.remove("code_challenge");
  629. params.remove("code_challenge_method");
  630. let mut conn = offline_redis()?;
  631. let store = test_store();
  632. match validate(AuthorizeInput {
  633. pool: &pool,
  634. keys: &keys,
  635. conn: &mut conn,
  636. store: &store,
  637. issuer: ISSUER,
  638. allow_localhost_redirect: false,
  639. cimd: None,
  640. query: &params,
  641. resources: Vec::new(),
  642. resource_indicators_enabled: false,
  643. })
  644. .await
  645. {
  646. Ok(_) => Err("challengeless request was accepted".into()),
  647. Err(AuthorizeFailure::Redirect {
  648. target,
  649. error,
  650. state,
  651. iss,
  652. }) => {
  653. assert_eq!(target, REDIRECT);
  654. assert_eq!(error.code(), OAuthErrorCode::InvalidRequest);
  655. assert_eq!(state.as_deref(), Some("xyz-state"));
  656. assert_eq!(iss, ISSUER);
  657. Ok(())
  658. }
  659. Err(other) => Err(format!("expected a redirect, got {}", other.code()).into()),
  660. }
  661. }
  662. /// A direct `Page(temporarily_unavailable)` renders 503 + `Retry-After`
  663. /// (fail-closed Postgres/PII outage pre-validation); `Page(server_error)`
  664. /// renders 500. Client-error pages stay 400.
  665. #[tokio::test]
  666. async fn authorize_page_outage_renders_503_with_retry_after() -> TestResult {
  667. let response = AuthorizeFailure::Page(AppError::temporarily_unavailable()).into_response();
  668. assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
  669. assert_eq!(
  670. response
  671. .headers()
  672. .get(header::RETRY_AFTER)
  673. .and_then(|value| value.to_str().ok()),
  674. Some("1")
  675. );
  676. assert!(response.headers().get(header::LOCATION).is_none());
  677. let response = AuthorizeFailure::Page(AppError::server_error()).into_response();
  678. assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
  679. assert!(response.headers().get(header::LOCATION).is_none());
  680. let response = AuthorizeFailure::Page(AppError::invalid_request("bad")).into_response();
  681. assert_eq!(response.status(), StatusCode::BAD_REQUEST);
  682. assert!(response.headers().get(header::LOCATION).is_none());
  683. Ok(())
  684. }
  685. /// A `post_logout` catalog outage after `redirect_uri` validation redirects
  686. /// (302) with `temporarily_unavailable`, per the error-delivery matrix.
  687. #[tokio::test]
  688. async fn authorize_post_logout_outage_redirects() -> TestResult {
  689. let (_db, pool) = migrated_pool().await?;
  690. let keys = test_keys()?;
  691. provision(&pool, &keys, "authz-post-logout", true).await?;
  692. let (row, _) = redirect_repo::upsert(
  693. &pool,
  694. &keys,
  695. false,
  696. &redirect_repo::NewRedirectUri {
  697. id: Uuid::new_v4(),
  698. uri: "https://app.example.test/signed-out",
  699. kind: redirect_repo::RedirectKind::PostLogout,
  700. description: "post-logout test",
  701. created_by: None,
  702. },
  703. )
  704. .await
  705. .map_err(|error| format!("post-logout fixture failed: {error}"))?;
  706. redirect_repo::attach(&pool, "authz-post-logout", row.id, None)
  707. .await
  708. .map_err(|error| format!("post-logout attach failed: {error}"))?;
  709. sqlx::query("UPDATE redirect_uris SET uri_enc = $1 WHERE kind = 'post_logout'")
  710. .bind(b"corrupt-blob".as_slice())
  711. .execute(&pool)
  712. .await
  713. .map_err(|error| format!("corrupt fixture failed: {error}"))?;
  714. let challenge = challenge_from_verifier(VERIFIER).map_err(|_| "bad fixture verifier")?;
  715. let params = valid_query("authz-post-logout", &challenge);
  716. let mut conn = offline_redis()?;
  717. let store = test_store();
  718. match validate(AuthorizeInput {
  719. pool: &pool,
  720. keys: &keys,
  721. conn: &mut conn,
  722. store: &store,
  723. issuer: ISSUER,
  724. allow_localhost_redirect: false,
  725. cimd: None,
  726. query: &params,
  727. resources: Vec::new(),
  728. resource_indicators_enabled: false,
  729. })
  730. .await
  731. {
  732. Ok(_) => Err("post-logout outage was accepted".into()),
  733. Err(AuthorizeFailure::Redirect { target, error, .. }) => {
  734. assert_eq!(target, REDIRECT);
  735. assert_eq!(error.code(), OAuthErrorCode::TemporarilyUnavailable);
  736. assert_eq!(error.status(), StatusCode::SERVICE_UNAVAILABLE);
  737. let response = AuthorizeFailure::Redirect {
  738. target,
  739. error,
  740. state: None,
  741. iss: ISSUER.to_owned(),
  742. }
  743. .into_response();
  744. assert_eq!(response.status(), StatusCode::FOUND);
  745. Ok(())
  746. }
  747. Err(other) => Err(format!("expected a redirect, got {}", other.code()).into()),
  748. }
  749. }
  750. /// draft-ietf-oauth-v2-1-16 §4.1.2.1 and §7.15.1.
  751. #[tokio::test]
  752. async fn authorize_error_iss() -> TestResult {
  753. let (_db, pool) = migrated_pool().await?;
  754. let keys = test_keys()?;
  755. provision(&pool, &keys, "authz-err-iss", true).await?;
  756. let challenge = challenge_from_verifier(VERIFIER).map_err(|_| "bad fixture verifier")?;
  757. let mut params = valid_query("authz-err-iss", &challenge);
  758. params.insert("response_type".to_owned(), "token".to_owned());
  759. let mut conn = offline_redis()?;
  760. let store = test_store();
  761. match validate(AuthorizeInput {
  762. pool: &pool,
  763. keys: &keys,
  764. conn: &mut conn,
  765. store: &store,
  766. issuer: ISSUER,
  767. allow_localhost_redirect: false,
  768. cimd: None,
  769. query: &params,
  770. resources: Vec::new(),
  771. resource_indicators_enabled: false,
  772. })
  773. .await
  774. {
  775. Ok(_) => Err("response_type=token was accepted".into()),
  776. Err(AuthorizeFailure::Redirect {
  777. target,
  778. error,
  779. state,
  780. iss,
  781. }) => {
  782. assert_eq!(target, REDIRECT);
  783. assert_eq!(error.code(), OAuthErrorCode::UnsupportedResponseType);
  784. assert_eq!(state.as_deref(), Some("xyz-state"));
  785. assert_eq!(iss, ISSUER);
  786. let response = AuthorizeFailure::Redirect {
  787. target,
  788. error,
  789. state,
  790. iss,
  791. }
  792. .into_response();
  793. assert_eq!(response.status(), StatusCode::FOUND);
  794. let location = response
  795. .headers()
  796. .get(header::LOCATION)
  797. .and_then(|value| value.to_str().ok())
  798. .unwrap_or_default()
  799. .to_owned();
  800. assert!(location.contains("error=unsupported_response_type"));
  801. assert!(location.contains("state=xyz-state"));
  802. assert!(
  803. location.contains("iss=https%3A%2F%2Fauth.example.test"),
  804. "error redirect missing iss: {location}"
  805. );
  806. Ok(())
  807. }
  808. Err(other) => Err(format!("expected a redirect, got {}", other.code()).into()),
  809. }
  810. }
  811. /// A missing `response_type` redirects with `invalid_request` (+ `state`):
  812. /// `client_id` and `redirect_uri` are already validated here, so per
  813. /// draft-ietf-oauth-v2-1-16 §4.1.1 and §4.1.2.1 the error rides the redirect.
  814. #[tokio::test]
  815. async fn authorize_validate_missing_response_type_redirects() -> TestResult {
  816. let (_db, pool) = migrated_pool().await?;
  817. let keys = test_keys()?;
  818. provision(&pool, &keys, "authz-missing-rt", true).await?;
  819. let challenge = challenge_from_verifier(VERIFIER).map_err(|_| "bad fixture verifier")?;
  820. let mut conn = offline_redis()?;
  821. let store = test_store();
  822. for params in [
  823. {
  824. let mut q = valid_query("authz-missing-rt", &challenge);
  825. q.remove("response_type");
  826. q
  827. },
  828. {
  829. let mut q = valid_query("authz-missing-rt", &challenge);
  830. q.insert("response_type".to_owned(), String::new());
  831. q
  832. },
  833. ] {
  834. match validate(AuthorizeInput {
  835. pool: &pool,
  836. keys: &keys,
  837. conn: &mut conn,
  838. store: &store,
  839. issuer: ISSUER,
  840. allow_localhost_redirect: false,
  841. cimd: None,
  842. query: &params,
  843. resources: Vec::new(),
  844. resource_indicators_enabled: false,
  845. })
  846. .await
  847. {
  848. Ok(_) => return Err("missing response_type was accepted".into()),
  849. Err(AuthorizeFailure::Redirect {
  850. target,
  851. error,
  852. state,
  853. iss,
  854. }) => {
  855. assert_eq!(target, REDIRECT);
  856. assert_eq!(error.code(), OAuthErrorCode::InvalidRequest);
  857. assert_eq!(state.as_deref(), Some("xyz-state"));
  858. assert_eq!(iss, ISSUER);
  859. let response = AuthorizeFailure::Redirect {
  860. target,
  861. error,
  862. state,
  863. iss,
  864. }
  865. .into_response();
  866. assert_eq!(response.status(), StatusCode::FOUND);
  867. let location = response
  868. .headers()
  869. .get(header::LOCATION)
  870. .and_then(|value| value.to_str().ok())
  871. .unwrap_or_default()
  872. .to_owned();
  873. assert!(location.contains("error=invalid_request"));
  874. assert!(location.contains("error_description="));
  875. assert!(location.contains("state=xyz-state"));
  876. assert!(
  877. location.contains("iss=https%3A%2F%2Fauth.example.test"),
  878. "error redirect missing iss: {location}"
  879. );
  880. }
  881. Err(other) => {
  882. return Err(format!("expected a redirect, got {}", other.code()).into());
  883. }
  884. }
  885. }
  886. Ok(())
  887. }
  888. /// Seed the CIMD document cache for `url`, so a resolve is served from Redis
  889. /// without a network fetch (a cache miss would fail against `.invalid`).
  890. async fn seed_cimd(
  891. conn: &mut RedisConn,
  892. prefix: &str,
  893. url: &str,
  894. document: &cimd::CimdDocument,
  895. ) -> TestResult {
  896. let payload = serde_json::to_string(document)?;
  897. redis::cmd("SET")
  898. .arg(cimd::cache_key(prefix, url))
  899. .arg(payload)
  900. .arg("EX")
  901. .arg(900)
  902. .query_async::<()>(conn)
  903. .await?;
  904. Ok(())
  905. }
  906. /// A minimal `none`-method CIMD document (CIMD draft §4.1).
  907. fn cimd_document(url: &str, redirect_uris: &[&str], scope: &str) -> cimd::CimdDocument {
  908. cimd::CimdDocument {
  909. client_id: url.to_owned(),
  910. redirect_uris: redirect_uris.iter().map(|uri| (*uri).to_owned()).collect(),
  911. token_endpoint_auth_method: Some("none".to_owned()),
  912. grant_types: Some(vec!["authorization_code".to_owned()]),
  913. scope: Some(scope.to_owned()),
  914. jwks: None,
  915. jwks_uri: None,
  916. client_name: None,
  917. dpop_bound_access_tokens: None,
  918. }
  919. }
  920. /// A shared-Redis connection plus its unique `{state}`/`{rl}` prefix pair.
  921. async fn shared_redis() -> TestResult<(RedisConn, String, String)> {
  922. let manager = test_support::redis_manager()
  923. .await
  924. .map_err(|error| format!("shared redis: {error}"))?;
  925. let (token_prefix, ratelimit_prefix) = test_support::unique_prefixes();
  926. Ok((RedisConn::Single(manager), token_prefix, ratelimit_prefix))
  927. }
  928. /// CIMD settings pointing at this test's cache prefix.
  929. fn cimd_settings(prefix: &str) -> super::CimdRequest<'_> {
  930. super::CimdRequest {
  931. token_prefix: prefix,
  932. max_bytes: 5_120,
  933. timeout: Duration::from_secs(2),
  934. }
  935. }
  936. /// A client metadata document client completes `/authorize` with no row and
  937. /// no catalog redirect; a redirect outside the document and a shared-secret
  938. /// document both fail closed (CIMD draft §4.1/§5).
  939. #[tokio::test]
  940. async fn authorize_accepts_cimd_public() -> TestResult {
  941. let (_db, pool) = migrated_pool().await?;
  942. let keys = test_keys()?;
  943. scope_repo::upsert(&pool, "api:read", "read api").await?;
  944. let (mut conn, prefix, ratelimit) = shared_redis().await?;
  945. let store = Store::from_parts(prefix.clone(), ratelimit, DurabilityMode::WaitAof);
  946. let url = "https://client.invalid/cimd.json";
  947. seed_cimd(
  948. &mut conn,
  949. &prefix,
  950. url,
  951. &cimd_document(url, &["https://app.example.test/cb"], "api:read"),
  952. )
  953. .await?;
  954. let challenge = challenge_from_verifier(VERIFIER).map_err(|_| "bad fixture verifier")?;
  955. let params = query(&[
  956. ("client_id", url),
  957. ("redirect_uri", "https://app.example.test/cb"),
  958. ("response_type", "code"),
  959. ("scope", "api:read"),
  960. ("code_challenge", challenge.as_str()),
  961. ("code_challenge_method", "S256"),
  962. ("state", "xyz-state"),
  963. ]);
  964. let validated = validate(AuthorizeInput {
  965. pool: &pool,
  966. keys: &keys,
  967. conn: &mut conn,
  968. store: &store,
  969. issuer: ISSUER,
  970. allow_localhost_redirect: false,
  971. cimd: Some(cimd_settings(&prefix)),
  972. query: &params,
  973. resources: Vec::new(),
  974. resource_indicators_enabled: false,
  975. })
  976. .await
  977. .map_err(|failure| format!("CIMD request rejected: {}", failure.code()))?;
  978. assert_eq!(validated.client_id, url);
  979. assert_eq!(validated.redirect_uri, "https://app.example.test/cb");
  980. assert_eq!(validated.scope.render(), "api:read");
  981. assert_eq!(validated.state.as_deref(), Some("xyz-state"));
  982. // A registration-free client writes no row and attaches no catalog redirect.
  983. assert!(
  984. client_repo::find_active_by_client_id(&pool, &keys, url)
  985. .await?
  986. .is_none(),
  987. "CIMD must not persist a client row"
  988. );
  989. // A redirect outside the document is refused (untrusted: page, no Location).
  990. let mut mismatch = params.clone();
  991. mismatch.insert(
  992. "redirect_uri".to_owned(),
  993. "https://evil.example.test/cb".to_owned(),
  994. );
  995. match validate(AuthorizeInput {
  996. pool: &pool,
  997. keys: &keys,
  998. conn: &mut conn,
  999. store: &store,
  1000. issuer: ISSUER,
  1001. allow_localhost_redirect: false,
  1002. cimd: Some(cimd_settings(&prefix)),
  1003. query: &mismatch,
  1004. resources: Vec::new(),
  1005. resource_indicators_enabled: false,
  1006. })
  1007. .await
  1008. {
  1009. Ok(_) => return Err("document redirect mismatch was accepted".into()),
  1010. Err(failure) => assert_eq!(failure.code(), OAuthErrorCode::InvalidRequest),
  1011. }
  1012. // A document declaring a shared-secret method is invalid metadata.
  1013. let shared_url = "https://client.invalid/secret.json";
  1014. let mut shared = cimd_document(shared_url, &["https://app.example.test/cb"], "api:read");
  1015. shared.token_endpoint_auth_method = Some("client_secret_basic".to_owned());
  1016. seed_cimd(&mut conn, &prefix, shared_url, &shared).await?;
  1017. let mut shared_params = params.clone();
  1018. shared_params.insert("client_id".to_owned(), shared_url.to_owned());
  1019. match validate(AuthorizeInput {
  1020. pool: &pool,
  1021. keys: &keys,
  1022. conn: &mut conn,
  1023. store: &store,
  1024. issuer: ISSUER,
  1025. allow_localhost_redirect: false,
  1026. cimd: Some(cimd_settings(&prefix)),
  1027. query: &shared_params,
  1028. resources: Vec::new(),
  1029. resource_indicators_enabled: false,
  1030. })
  1031. .await
  1032. {
  1033. Ok(_) => Err("shared-secret CIMD document was accepted".into()),
  1034. Err(failure) => {
  1035. assert_eq!(failure.code(), OAuthErrorCode::InvalidClientMetadata);
  1036. Ok(())
  1037. }
  1038. }
  1039. }
  1040. /// A document `http://localhost` redirect is accepted only while
  1041. /// `allow_localhost_redirect` is on; the request's random port is matched by
  1042. /// the RFC 8252 §7.3 loopback rule. With the flag off the document itself is
  1043. /// invalid, because `http://localhost` is not a loopback host.
  1044. #[tokio::test]
  1045. async fn authorize_accepts_cimd_localhost_when_flag_on() -> TestResult {
  1046. let (_db, pool) = migrated_pool().await?;
  1047. let keys = test_keys()?;
  1048. scope_repo::upsert(&pool, "api:read", "read api").await?;
  1049. let (mut conn, prefix, ratelimit) = shared_redis().await?;
  1050. let store = Store::from_parts(prefix.clone(), ratelimit, DurabilityMode::WaitAof);
  1051. let url = "https://client.invalid/loopback.json";
  1052. seed_cimd(
  1053. &mut conn,
  1054. &prefix,
  1055. url,
  1056. &cimd_document(url, &["http://localhost/cb"], "api:read"),
  1057. )
  1058. .await?;
  1059. let challenge = challenge_from_verifier(VERIFIER).map_err(|_| "bad fixture verifier")?;
  1060. let params = query(&[
  1061. ("client_id", url),
  1062. ("redirect_uri", "http://localhost:51234/cb"),
  1063. ("response_type", "code"),
  1064. ("scope", "api:read"),
  1065. ("code_challenge", challenge.as_str()),
  1066. ("code_challenge_method", "S256"),
  1067. ]);
  1068. let validated = validate(AuthorizeInput {
  1069. pool: &pool,
  1070. keys: &keys,
  1071. conn: &mut conn,
  1072. store: &store,
  1073. issuer: ISSUER,
  1074. allow_localhost_redirect: true,
  1075. cimd: Some(cimd_settings(&prefix)),
  1076. query: &params,
  1077. resources: Vec::new(),
  1078. resource_indicators_enabled: false,
  1079. })
  1080. .await
  1081. .map_err(|failure| {
  1082. format!(
  1083. "CIMD loopback rejected with the flag on: {}",
  1084. failure.code()
  1085. )
  1086. })?;
  1087. assert_eq!(validated.redirect_uri, "http://localhost:51234/cb");
  1088. assert_eq!(validated.scope.render(), "api:read");
  1089. match validate(AuthorizeInput {
  1090. pool: &pool,
  1091. keys: &keys,
  1092. conn: &mut conn,
  1093. store: &store,
  1094. issuer: ISSUER,
  1095. allow_localhost_redirect: false,
  1096. cimd: Some(cimd_settings(&prefix)),
  1097. query: &params,
  1098. resources: Vec::new(),
  1099. resource_indicators_enabled: false,
  1100. })
  1101. .await
  1102. {
  1103. Ok(_) => Err("CIMD localhost redirect was accepted with the flag off".into()),
  1104. Err(failure) => {
  1105. assert_eq!(failure.code(), OAuthErrorCode::InvalidRedirectUri);
  1106. Ok(())
  1107. }
  1108. }
  1109. }
  1110. /// Two catalog resources presented as repeated query indicators validate and
  1111. /// pin as a sorted deduped audience set on the validated request.
  1112. #[tokio::test]
  1113. async fn authorize_accepts_repeated_resource() -> TestResult {
  1114. let (_db, pool) = migrated_pool().await?;
  1115. let keys = test_keys()?;
  1116. provision(&pool, &keys, "authz-resource", true).await?;
  1117. resource_repo::upsert(&pool, "https://b.example.test/", "b").await?;
  1118. resource_repo::upsert(&pool, "https://a.example.test/", "a").await?;
  1119. let challenge = challenge_from_verifier(VERIFIER).map_err(|_| "bad fixture verifier")?;
  1120. let params = valid_query("authz-resource", &challenge);
  1121. let mut conn = offline_redis()?;
  1122. let store = test_store();
  1123. let validated = validate(AuthorizeInput {
  1124. pool: &pool,
  1125. keys: &keys,
  1126. conn: &mut conn,
  1127. store: &store,
  1128. issuer: ISSUER,
  1129. allow_localhost_redirect: false,
  1130. cimd: None,
  1131. query: &params,
  1132. resources: vec![
  1133. "https://b.example.test/".to_owned(),
  1134. "https://a.example.test/".to_owned(),
  1135. "https://b.example.test/".to_owned(),
  1136. ],
  1137. resource_indicators_enabled: true,
  1138. })
  1139. .await
  1140. .map_err(|failure| format!("repeated resource rejected: {}", failure.code()))?;
  1141. if validated.aud
  1142. != vec![
  1143. "https://a.example.test/".to_owned(),
  1144. "https://b.example.test/".to_owned(),
  1145. ]
  1146. {
  1147. return Err(format!("aud not pinned sorted deduped: {:?}", validated.aud).into());
  1148. }
  1149. Ok(())
  1150. }
  1151. /// A query `resource` appended to a PAR `request_uri` must not union into the
  1152. /// pinned audience: the request rejects as `invalid_request_object` through
  1153. /// the validated redirect (RFC 9101 §5.2, RFC 8707 §2.1).
  1154. #[tokio::test]
  1155. async fn authorize_rejects_query_resource_beside_par() -> TestResult {
  1156. let (_db, pool) = migrated_pool().await?;
  1157. let keys = test_keys()?;
  1158. let client_id = "authz-par-resource";
  1159. provision(&pool, &keys, client_id, true).await?;
  1160. resource_repo::upsert(&pool, "https://a.example.test/", "a").await?;
  1161. resource_repo::upsert(&pool, "https://b.example.test/", "b").await?;
  1162. let (mut conn, prefix, ratelimit) = shared_redis().await?;
  1163. let store = Store::from_parts(prefix, ratelimit, DurabilityMode::WaitAof);
  1164. let challenge = challenge_from_verifier(VERIFIER).map_err(|_| "bad fixture verifier")?;
  1165. let pushed_params = valid_query(client_id, &challenge);
  1166. let auth = ClientAuthRequest {
  1167. basic_header: None,
  1168. client_id: Some(client_id),
  1169. body_secret: None,
  1170. assertion_type: None,
  1171. assertion: None,
  1172. peer_certs: None,
  1173. };
  1174. let ctx = ParContext {
  1175. token_endpoint: "https://auth.example.test/token",
  1176. now: OffsetDateTime::now_utc(),
  1177. par_ttl: Duration::from_secs(90),
  1178. allow_localhost_redirect: false,
  1179. cimd: None,
  1180. resource_indicators_enabled: true,
  1181. };
  1182. let pinned = ["https://a.example.test/".to_owned()];
  1183. let pushed = {
  1184. let mut stores = ParStores {
  1185. pool: &pool,
  1186. keys: &keys,
  1187. conn: &mut conn,
  1188. store: &store,
  1189. };
  1190. push_authorization_request(&mut stores, &ctx, &auth, &pushed_params, &pinned)
  1191. .await
  1192. .map_err(|error| format!("PAR push rejected: {}", error.code()))?
  1193. };
  1194. let appended = "https://b.example.test/".to_owned();
  1195. let params = query(&[
  1196. ("client_id", client_id),
  1197. ("request_uri", pushed.request_uri.as_str()),
  1198. ("resource", appended.as_str()),
  1199. ]);
  1200. match validate(AuthorizeInput {
  1201. pool: &pool,
  1202. keys: &keys,
  1203. conn: &mut conn,
  1204. store: &store,
  1205. issuer: ISSUER,
  1206. allow_localhost_redirect: false,
  1207. cimd: None,
  1208. query: &params,
  1209. resources: vec![appended],
  1210. resource_indicators_enabled: true,
  1211. })
  1212. .await
  1213. {
  1214. Ok(validated) => Err(format!("PAR audience was widened to {:?}", validated.aud).into()),
  1215. Err(AuthorizeFailure::Redirect { target, error, .. }) => {
  1216. assert_eq!(target, REDIRECT);
  1217. assert_eq!(error.code(), OAuthErrorCode::InvalidRequestObject);
  1218. Ok(())
  1219. }
  1220. Err(other) => Err(format!("expected invalid_request_object, got {}", other.code()).into()),
  1221. }
  1222. }
  1223. /// A query `resource` beside an inline signed request object is rejected:
  1224. /// the audience travels in the object, never the query (RFC 9101 §5.2).
  1225. #[tokio::test]
  1226. async fn authorize_rejects_query_resource_beside_inline_request() -> TestResult {
  1227. let (_db, pool) = migrated_pool().await?;
  1228. let keys = test_keys()?;
  1229. let client_id = "authz-jar-resource";
  1230. let signer = TestSigner::generate("jar-resource-key")?;
  1231. provision_signing(&pool, &keys, client_id, signer.jwks.clone()).await?;
  1232. resource_repo::upsert(&pool, "https://b.example.test/", "b").await?;
  1233. let challenge = challenge_from_verifier(VERIFIER).map_err(|_| "bad fixture verifier")?;
  1234. let request = signer.request_object(
  1235. "jar-resource-key",
  1236. client_id,
  1237. serde_json::json!({
  1238. "response_type": "code",
  1239. "redirect_uri": REDIRECT,
  1240. "scope": "api:read",
  1241. "code_challenge": challenge,
  1242. "code_challenge_method": "S256",
  1243. "state": "jar-state",
  1244. }),
  1245. )?;
  1246. let appended = "https://b.example.test/".to_owned();
  1247. let params = query(&[
  1248. ("client_id", client_id),
  1249. ("request", request.as_str()),
  1250. ("resource", appended.as_str()),
  1251. ]);
  1252. let mut conn = offline_redis()?;
  1253. let store = test_store();
  1254. match validate(AuthorizeInput {
  1255. pool: &pool,
  1256. keys: &keys,
  1257. conn: &mut conn,
  1258. store: &store,
  1259. issuer: ISSUER,
  1260. allow_localhost_redirect: false,
  1261. cimd: None,
  1262. query: &params,
  1263. resources: vec![appended],
  1264. resource_indicators_enabled: true,
  1265. })
  1266. .await
  1267. {
  1268. Ok(validated) => Err(format!("JAR pinned the query audience {:?}", validated.aud).into()),
  1269. Err(AuthorizeFailure::Redirect { target, error, .. }) => {
  1270. assert_eq!(target, REDIRECT);
  1271. assert_eq!(error.code(), OAuthErrorCode::InvalidRequestObject);
  1272. Ok(())
  1273. }
  1274. Err(other) => Err(format!("expected invalid_request_object, got {}", other.code()).into()),
  1275. }
  1276. }