tests.rs 35 KB

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