e2e_cimd.rs 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662
  1. //! `test(e2e_cimd)` end-to-end coverage of Client ID Metadata Documents
  2. //! (CIMD draft §3/§4.1/§5) through the real router and the injected fetcher
  3. //! seam (`AppState::cimd_fetcher`).
  4. //!
  5. //! Every world installs its own counting fetcher, so the cache behavior is
  6. //! observed rather than assumed: one fetch for the first resolution, zero for
  7. //! every cached/registered/inactive resolution within the TTL. Each invalid
  8. //! document row gets its own world (and its own URL) so the short-lived
  9. //! `cimd:invalid:` marker cannot replay across rows.
  10. //!
  11. //! The fetcher is the ONLY document source: no test performs a real network
  12. //! call.
  13. use std::{
  14. future::Future,
  15. sync::{
  16. Arc,
  17. atomic::{AtomicUsize, Ordering},
  18. },
  19. };
  20. use base64::Engine as _;
  21. use d_oauth::{
  22. db::{TokenEndpointAuthMethod, client_repo},
  23. protocol::cimd::{CimdDocument, CimdError, CimdFetchFuture, CimdFetcher, CimdLimits},
  24. };
  25. use jsonwebtoken::{Algorithm, EncodingKey, Header, encode};
  26. use ring::signature::{Ed25519KeyPair, KeyPair as _};
  27. use serde_json::{Value, json};
  28. use time::OffsetDateTime;
  29. #[allow(dead_code)]
  30. #[path = "e2e/harness.rs"]
  31. mod harness;
  32. #[path = "e2e/dpop.rs"]
  33. mod dpop;
  34. #[allow(dead_code)]
  35. #[path = "e2e/browser.rs"]
  36. mod browser;
  37. use browser::{
  38. Browser, SESSION_COOKIE, cookie_value, get_consent, hidden_input, location_of, oauth2_pkce,
  39. post_consent, post_login, query_value, seed_user, totp_now,
  40. };
  41. use harness::{
  42. TestError, World, attach_redirect, ensure, fail, json_body, open_rate_limit, seed_client,
  43. seed_scope,
  44. };
  45. /// Registration-free client identifier: an `https://` document URL.
  46. const CLIENT_URL: &str = "https://cimd.example.test/client.json";
  47. /// Loopback redirect declared by the fixture documents (RFC 8252 §7.3).
  48. const REDIRECT: &str = "http://127.0.0.1:9/cb";
  49. /// Catalog scope the fixture documents and users are entitled to.
  50. const SCOPE: &str = "read";
  51. /// `state` echoed from authorize to the code redirect.
  52. const STATE: &str = "cimd-e2e-state";
  53. /// Start a world with a counting fetcher and the CIMD feature gate.
  54. async fn cimd_world(fetcher: CimdFetcher, cimd_enabled: bool) -> Result<World, TestError> {
  55. World::start_with_cimd_fetcher(
  56. open_rate_limit(),
  57. json!({"cimd_enabled": cimd_enabled, "allow_localhost_redirect": true}),
  58. fetcher,
  59. )
  60. .await
  61. }
  62. /// Box one fetch so the higher-ranked [`CimdFetcher`] alias accepts the
  63. /// closure (mirrors the module-private helper production uses).
  64. fn boxed<'a>(
  65. future: impl Future<Output = Result<CimdDocument, CimdError>> + Send + 'a,
  66. ) -> CimdFetchFuture<'a> {
  67. Box::pin(future)
  68. }
  69. /// A document fetcher that counts every call and returns `document`.
  70. fn document_fetcher(document: CimdDocument) -> (CimdFetcher, Arc<AtomicUsize>) {
  71. let calls = Arc::new(AtomicUsize::new(0));
  72. let counter = Arc::clone(&calls);
  73. let fetcher: CimdFetcher = Arc::new(move |_url: &str, _limits: CimdLimits| {
  74. let document = document.clone();
  75. let counter = Arc::clone(&counter);
  76. boxed(async move {
  77. counter.fetch_add(1, Ordering::SeqCst);
  78. Ok(document)
  79. })
  80. });
  81. (fetcher, calls)
  82. }
  83. /// A fetcher that counts every call and always fails. Used to prove that a
  84. /// path never reaches the fetch at all.
  85. fn failing_fetcher() -> (CimdFetcher, Arc<AtomicUsize>) {
  86. let calls = Arc::new(AtomicUsize::new(0));
  87. let counter = Arc::clone(&calls);
  88. let fetcher: CimdFetcher = Arc::new(move |_url: &str, _limits: CimdLimits| {
  89. let counter = Arc::clone(&counter);
  90. boxed(async move {
  91. counter.fetch_add(1, Ordering::SeqCst);
  92. Err(CimdError::Fetch)
  93. })
  94. });
  95. (fetcher, calls)
  96. }
  97. /// A valid `none` metadata document.
  98. fn none_document(url: &str, dpop_bound: bool) -> CimdDocument {
  99. CimdDocument {
  100. client_id: url.to_owned(),
  101. redirect_uris: vec![REDIRECT.to_owned()],
  102. token_endpoint_auth_method: Some("none".to_owned()),
  103. grant_types: Some(vec![
  104. "authorization_code".to_owned(),
  105. "refresh_token".to_owned(),
  106. ]),
  107. scope: Some(SCOPE.to_owned()),
  108. jwks: None,
  109. jwks_uri: None,
  110. client_name: None,
  111. dpop_bound_access_tokens: Some(dpop_bound),
  112. }
  113. }
  114. /// A valid `private_key_jwt` metadata document carrying an inline JWKS.
  115. fn private_key_document(url: &str, jwks: Value) -> CimdDocument {
  116. CimdDocument {
  117. client_id: url.to_owned(),
  118. redirect_uris: vec![REDIRECT.to_owned()],
  119. token_endpoint_auth_method: Some("private_key_jwt".to_owned()),
  120. grant_types: Some(vec![
  121. "authorization_code".to_owned(),
  122. "refresh_token".to_owned(),
  123. ]),
  124. scope: Some(SCOPE.to_owned()),
  125. jwks: Some(jwks),
  126. jwks_uri: None,
  127. client_name: None,
  128. dpop_bound_access_tokens: Some(false),
  129. }
  130. }
  131. /// An Ed25519 signer plus the matching inline JWKS for one document.
  132. struct DocKey {
  133. /// Signing key for request objects and client assertions.
  134. encoding: EncodingKey,
  135. /// Public JWKS the document publishes.
  136. jwks: Value,
  137. }
  138. impl DocKey {
  139. /// Generate a fresh Ed25519 key pair and its JWKS.
  140. fn generate(kid: &str) -> Result<Self, TestError> {
  141. let rng = ring::rand::SystemRandom::new();
  142. let pkcs8 = Ed25519KeyPair::generate_pkcs8(&rng)
  143. .map_err(|error| fail(format!("ed25519 keygen failed: {error}")))?;
  144. let pair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref())
  145. .map_err(|error| fail(format!("ed25519 reload failed: {error}")))?;
  146. let x = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(pair.public_key().as_ref());
  147. Ok(Self {
  148. encoding: EncodingKey::from_ed_der(pkcs8.as_ref()),
  149. jwks: json!({
  150. "keys": [{ "kty": "OKP", "crv": "Ed25519", "x": x, "kid": kid }]
  151. }),
  152. })
  153. }
  154. /// Sign one EdDSA JWT with `kid`.
  155. fn sign(&self, kid: &str, claims: &Value) -> Result<String, TestError> {
  156. let mut header = Header::new(Algorithm::EdDSA);
  157. header.kid = Some(kid.to_owned());
  158. encode(&header, claims, &self.encoding).map_err(|error| fail(format!("jwt encode: {error}")))
  159. }
  160. /// A signed RFC 9101 request object bound to `client_id`/`issuer`.
  161. fn request_object(
  162. &self,
  163. kid: &str,
  164. client_id: &str,
  165. issuer: &str,
  166. extra: Value,
  167. ) -> Result<String, TestError> {
  168. let now = OffsetDateTime::now_utc().unix_timestamp();
  169. let mut claims = json!({
  170. "iss": client_id,
  171. "aud": issuer,
  172. "exp": now + 300,
  173. "iat": now,
  174. });
  175. let object = claims
  176. .as_object_mut()
  177. .ok_or_else(|| fail("request claims must be an object"))?;
  178. let extra_object = extra
  179. .as_object()
  180. .ok_or_else(|| fail("request extras must be an object"))?;
  181. for (key, value) in extra_object {
  182. object.insert(key.clone(), value.clone());
  183. }
  184. self.sign(kid, &claims)
  185. }
  186. /// A `private_key_jwt` client assertion (RFC 7523 §3).
  187. fn assertion(
  188. &self,
  189. kid: &str,
  190. client_id: &str,
  191. audience: &str,
  192. jti: &str,
  193. ) -> Result<String, TestError> {
  194. let now = OffsetDateTime::now_utc().unix_timestamp();
  195. self.sign(
  196. kid,
  197. &json!({
  198. "iss": client_id,
  199. "sub": client_id,
  200. "aud": audience,
  201. "jti": jti,
  202. "exp": now + 300,
  203. "iat": now,
  204. }),
  205. )
  206. }
  207. }
  208. /// Build a plain-query `/authorize` path (public `none` clients).
  209. fn authorize_path(client_id: &str, challenge: &str) -> String {
  210. format!(
  211. "/authorize?client_id={}&redirect_uri={}&response_type=code&scope={}&state={}\
  212. &code_challenge={}&code_challenge_method=S256",
  213. browser::encoded(client_id),
  214. browser::encoded(REDIRECT),
  215. browser::encoded(SCOPE),
  216. browser::encoded(STATE),
  217. browser::encoded(challenge),
  218. )
  219. }
  220. /// Assert an authorize error page renders `expected`.
  221. fn ensure_error_page(
  222. status: reqwest::StatusCode,
  223. body: &str,
  224. expected: &str,
  225. ) -> Result<(), TestError> {
  226. ensure(
  227. status == reqwest::StatusCode::BAD_REQUEST,
  228. format!("{expected} must render 400, got {status}: {body}"),
  229. )?;
  230. ensure(
  231. body.contains(&format!("<h1>{expected}</h1>")),
  232. format!("page must name {expected}: {body}"),
  233. )
  234. }
  235. /// Drive login → consent approval for a [`Browser`] already parked on the
  236. /// login page and return the issued authorization code.
  237. async fn approve(browser: &Browser, expected_state: &str) -> Result<String, TestError> {
  238. let code_2fa = totp_now(&browser.totp_secret).await?;
  239. let (status, headers, body) = post_login(browser, &browser.password, &code_2fa).await?;
  240. ensure(
  241. status == reqwest::StatusCode::SEE_OTHER,
  242. format!("login must 303 to consent: {status} {body}"),
  243. )?;
  244. let session = cookie_value(&headers, SESSION_COOKIE)?;
  245. let (status, _, body) = get_consent(browser, &session).await?;
  246. ensure(
  247. status == reqwest::StatusCode::OK,
  248. format!("consent must render: {status} {body}"),
  249. )?;
  250. let bound = hidden_input(&body, "request_id")?;
  251. ensure(
  252. bound == browser.request_id,
  253. "consent must serve the bound authorization request",
  254. )?;
  255. let consent_csrf = hidden_input(&body, "csrf")?;
  256. let (status, headers, _) =
  257. post_consent(browser, &session, &bound, &consent_csrf, "approve").await?;
  258. ensure(
  259. status == reqwest::StatusCode::SEE_OTHER,
  260. format!("consent approval must 303 to the redirect: {status}"),
  261. )?;
  262. let location = location_of(&headers)?;
  263. ensure(
  264. location.starts_with(REDIRECT),
  265. format!("code must return to the document redirect, got {location}"),
  266. )?;
  267. ensure(
  268. query_value(&location, "state")? == expected_state,
  269. "code redirect must echo state",
  270. )?;
  271. query_value(&location, "code")
  272. }
  273. /// Happy path: a `none` CIMD document carries the full browser flow through
  274. /// authorization-code redemption, and a second authorize inside the cache TTL
  275. /// performs no second fetch.
  276. #[tokio::test]
  277. async fn e2e_cimd_authorize_login_consent_token() -> Result<(), TestError> {
  278. let (fetcher, calls) = document_fetcher(none_document(CLIENT_URL, true));
  279. let world = cimd_world(fetcher, true).await?;
  280. let browser = browser::begin_browser_for(
  281. world,
  282. CLIENT_URL,
  283. REDIRECT,
  284. SCOPE,
  285. "cimd-happy@example.test",
  286. "correct-horse-28-cimd-happy",
  287. )
  288. .await?;
  289. ensure(
  290. calls.load(Ordering::SeqCst) == 1,
  291. format!(
  292. "first authorize must fetch once, got {}",
  293. calls.load(Ordering::SeqCst)
  294. ),
  295. )?;
  296. let code = approve(&browser, browser::STATE).await?;
  297. // CIMD draft §4.1: a DPoP-bound document binds both the code and the token.
  298. let proof = browser.dpop.proof(
  299. "POST",
  300. &format!("{}/token", browser.world.state.config.issuer),
  301. )?;
  302. let (status, _, body) = browser
  303. .world
  304. .post_form(
  305. "/token",
  306. &[
  307. ("grant_type", "authorization_code"),
  308. ("code", code.as_str()),
  309. ("redirect_uri", REDIRECT),
  310. ("client_id", CLIENT_URL),
  311. ("code_verifier", browser.verifier.as_str()),
  312. ],
  313. None,
  314. &[("dpop", proof.as_str())],
  315. )
  316. .await?;
  317. ensure(
  318. status == reqwest::StatusCode::OK,
  319. format!("code redemption must mint: {status} {body}"),
  320. )?;
  321. let tokens = json_body(&body)?;
  322. ensure(
  323. tokens["token_type"] == "DPoP",
  324. format!("a DPoP-bound document must mint DPoP tokens, got {tokens}"),
  325. )?;
  326. ensure(
  327. tokens["access_token"]
  328. .as_str()
  329. .is_some_and(|token| !token.is_empty()),
  330. "redemption must return an access token",
  331. )?;
  332. ensure(
  333. tokens["refresh_token"]
  334. .as_str()
  335. .is_some_and(|token| !token.is_empty()),
  336. "the document grants refresh_token, so one must be returned",
  337. )?;
  338. ensure(
  339. tokens["scope"] == SCOPE,
  340. format!("tokens must carry the document scope, got {tokens}"),
  341. )?;
  342. // A second authorize inside the 15-minute TTL is a cache hit: zero fetches.
  343. let (challenge, _) = oauth2_pkce();
  344. let proof = browser.dpop.proof(
  345. "GET",
  346. &format!("{}/authorize", browser.world.state.config.issuer),
  347. )?;
  348. let (status, headers, body) = browser
  349. .world
  350. .get(
  351. &authorize_path(CLIENT_URL, &challenge),
  352. &[("dpop", proof.as_str())],
  353. )
  354. .await?;
  355. ensure(
  356. status == reqwest::StatusCode::SEE_OTHER,
  357. format!("cached second authorize must 303: {status} {body}"),
  358. )?;
  359. ensure(
  360. location_of(&headers)?.starts_with("/login?request_id="),
  361. "cached second authorize must reach login",
  362. )?;
  363. let count = calls.load(Ordering::SeqCst);
  364. ensure(
  365. count == 1,
  366. format!("second authorize within the TTL must add zero fetches, got {count}"),
  367. )?;
  368. Ok(())
  369. }
  370. /// A `private_key_jwt` document is confidential: a plain query authorize is
  371. /// rejected with `invalid_request`, exactly like a registered confidential
  372. /// client (RFC 9101 §4, `tests/jar.rs:229-251`).
  373. #[tokio::test]
  374. async fn e2e_cimd_private_key_jwt_requires_par_or_jar() -> Result<(), TestError> {
  375. let key = DocKey::generate("cimd-doc-key-plain")?;
  376. let (fetcher, calls) = document_fetcher(private_key_document(CLIENT_URL, key.jwks.clone()));
  377. let world = cimd_world(fetcher, true).await?;
  378. seed_scope(&world.pool, SCOPE).await?;
  379. let (challenge, _) = oauth2_pkce();
  380. let (status, _, body) = world
  381. .get(&authorize_path(CLIENT_URL, &challenge), &[])
  382. .await?;
  383. ensure_error_page(status, &body, "invalid_request")?;
  384. let count = calls.load(Ordering::SeqCst);
  385. ensure(
  386. count == 1,
  387. format!("the rejected authorize must fetch exactly once, got {count}"),
  388. )?;
  389. Ok(())
  390. }
  391. /// The same `private_key_jwt` document authorizes, logs in, consents, and
  392. /// redeems when the request object is signed with the document key; the token
  393. /// request authenticates with a document-key assertion. One fetch total.
  394. #[tokio::test]
  395. async fn e2e_cimd_private_key_jwt_par_jar_positive() -> Result<(), TestError> {
  396. const KID: &str = "cimd-doc-key-jar";
  397. const URL: &str = "https://cimd.example.test/private-key-jar.json";
  398. let key = DocKey::generate(KID)?;
  399. let (fetcher, calls) = document_fetcher(private_key_document(URL, key.jwks.clone()));
  400. let world = cimd_world(fetcher, true).await?;
  401. seed_scope(&world.pool, SCOPE).await?;
  402. let email = "cimd-jar@example.test";
  403. let password = "correct-horse-28-cimd-jar";
  404. let totp_secret = seed_user(&world, email, password, SCOPE).await?;
  405. let (challenge, verifier) = oauth2_pkce();
  406. let issuer = world.state.config.issuer.clone();
  407. let request = key.request_object(
  408. KID,
  409. URL,
  410. &issuer,
  411. json!({
  412. "client_id": URL,
  413. "response_type": "code",
  414. "redirect_uri": REDIRECT,
  415. "scope": SCOPE,
  416. "state": STATE,
  417. "code_challenge": challenge,
  418. "code_challenge_method": "S256",
  419. }),
  420. )?;
  421. let path = format!(
  422. "/authorize?client_id={}&request={}",
  423. browser::encoded(URL),
  424. browser::encoded(&request)
  425. );
  426. let (status, headers, body) = world.get(&path, &[]).await?;
  427. ensure(
  428. status == reqwest::StatusCode::SEE_OTHER,
  429. format!("signed request object must authorize: {status} {body}"),
  430. )?;
  431. let request_id = query_value(&location_of(&headers)?, "request_id")?;
  432. let (status, headers, body) = world
  433. .get(&format!("/login?request_id={request_id}"), &[])
  434. .await?;
  435. ensure(
  436. status == reqwest::StatusCode::OK,
  437. format!("login page must render: {status} {body}"),
  438. )?;
  439. let presession = cookie_value(&headers, SESSION_COOKIE)?;
  440. let login_csrf = hidden_input(&body, "csrf")?;
  441. let browser = Browser {
  442. world,
  443. dpop: browser::DpopKey::generate()?,
  444. verifier,
  445. request_id,
  446. presession,
  447. session: None,
  448. login_csrf,
  449. email: email.to_owned(),
  450. password: password.to_owned(),
  451. totp_secret,
  452. };
  453. let code = approve(&browser, STATE).await?;
  454. let assertion = key.assertion(KID, URL, &format!("{issuer}/token"), "cimd-jar-jti-1")?;
  455. let (status, _, body) = browser
  456. .world
  457. .post_form(
  458. "/token",
  459. &[
  460. ("grant_type", "authorization_code"),
  461. ("code", code.as_str()),
  462. ("redirect_uri", REDIRECT),
  463. ("client_id", URL),
  464. ("code_verifier", browser.verifier.as_str()),
  465. (
  466. "client_assertion_type",
  467. "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
  468. ),
  469. ("client_assertion", assertion.as_str()),
  470. ],
  471. None,
  472. &[],
  473. )
  474. .await?;
  475. ensure(
  476. status == reqwest::StatusCode::OK,
  477. format!("JAR + assertion redemption must mint: {status} {body}"),
  478. )?;
  479. let tokens = json_body(&body)?;
  480. ensure(
  481. tokens["access_token"]
  482. .as_str()
  483. .is_some_and(|token| !token.is_empty()),
  484. "redemption must return an access token",
  485. )?;
  486. let count = calls.load(Ordering::SeqCst);
  487. ensure(
  488. count == 1,
  489. format!("the whole JAR flow must fetch exactly once, got {count}"),
  490. )?;
  491. Ok(())
  492. }
  493. /// Every invalid document fails closed with its typed error, exactly one
  494. /// fetch per row. One world per row: a shared Redis prefix would let the
  495. /// two-second `cimd:invalid:` marker replay the first row's verdict.
  496. #[tokio::test]
  497. async fn e2e_cimd_invalid_documents_fail_closed() -> Result<(), TestError> {
  498. let mut jwks_uri = none_document("https://cimd.example.test/jwks-uri.json", false);
  499. jwks_uri.jwks_uri = Some("https://keys.example.test/jwks.json".to_owned());
  500. let mut unsupported = none_document("https://cimd.example.test/unsupported-method.json", false);
  501. unsupported.token_endpoint_auth_method = Some("client_secret_basic".to_owned());
  502. let mut wildcard = none_document("https://cimd.example.test/wildcard.json", false);
  503. wildcard.redirect_uris = vec!["https://app.example.test/*".to_owned()];
  504. let mut mismatch = none_document("https://cimd.example.test/mismatch.json", false);
  505. mismatch.client_id = "https://cimd.example.test/different.json".to_owned();
  506. let rows = [
  507. (
  508. "https://cimd.example.test/jwks-uri.json",
  509. jwks_uri,
  510. "invalid_client_metadata",
  511. ),
  512. (
  513. "https://cimd.example.test/unsupported-method.json",
  514. unsupported,
  515. "invalid_client_metadata",
  516. ),
  517. (
  518. "https://cimd.example.test/wildcard.json",
  519. wildcard,
  520. "invalid_redirect_uri",
  521. ),
  522. (
  523. "https://cimd.example.test/mismatch.json",
  524. mismatch,
  525. "invalid_client_metadata",
  526. ),
  527. ];
  528. for (url, document, expected) in rows {
  529. let (fetcher, calls) = document_fetcher(document);
  530. let world = cimd_world(fetcher, true).await?;
  531. let (challenge, _) = oauth2_pkce();
  532. let (status, _, body) = world.get(&authorize_path(url, &challenge), &[]).await?;
  533. ensure_error_page(status, &body, expected)?;
  534. let count = calls.load(Ordering::SeqCst);
  535. ensure(
  536. count == 1,
  537. format!("{url} must fetch exactly once, got {count}"),
  538. )?;
  539. }
  540. Ok(())
  541. }
  542. /// Registered rows always win over CIMD: an active row never fetches, an
  543. /// inactive row is dead with zero fetches (todo 5 fail-closed), and a disabled
  544. /// feature keeps the normal unknown-client error with zero fetches.
  545. #[tokio::test]
  546. async fn e2e_cimd_registered_row_wins_and_inactive_is_dead() -> Result<(), TestError> {
  547. // Active registered row with an `https://` id: zero fetches.
  548. const ACTIVE_URL: &str = "https://cimd.example.test/registered.json";
  549. let (fetcher, active_calls) = document_fetcher(none_document(ACTIVE_URL, true));
  550. let world = cimd_world(fetcher, true).await?;
  551. seed_client(
  552. &world.pool,
  553. &world.state.keys,
  554. ACTIVE_URL,
  555. None,
  556. &["authorization_code", "refresh_token"],
  557. SCOPE,
  558. TokenEndpointAuthMethod::None,
  559. )
  560. .await?;
  561. attach_redirect(&world.pool, &world.state.keys, ACTIVE_URL, REDIRECT).await?;
  562. let browser = browser::begin_browser_for(
  563. world,
  564. ACTIVE_URL,
  565. REDIRECT,
  566. SCOPE,
  567. "cimd-registered@example.test",
  568. "correct-horse-28-cimd-row",
  569. )
  570. .await?;
  571. ensure(
  572. !browser.request_id.is_empty(),
  573. "the registered row must authorize to login",
  574. )?;
  575. let active_count = active_calls.load(Ordering::SeqCst);
  576. ensure(
  577. active_count == 0,
  578. format!("an active registered row must not fetch, got {active_count}"),
  579. )?;
  580. // Inactive registered row with an `https://` id: dead, zero fetches.
  581. const INACTIVE_URL: &str = "https://cimd.example.test/inactive.json";
  582. let (fetcher, inactive_calls) = failing_fetcher();
  583. let world = cimd_world(fetcher, true).await?;
  584. seed_client(
  585. &world.pool,
  586. &world.state.keys,
  587. INACTIVE_URL,
  588. None,
  589. &["authorization_code"],
  590. SCOPE,
  591. TokenEndpointAuthMethod::None,
  592. )
  593. .await?;
  594. ensure(
  595. client_repo::deactivate(&world.pool, INACTIVE_URL).await?,
  596. "deactivate must flip the row",
  597. )?;
  598. let (challenge, _) = oauth2_pkce();
  599. let (status, _, body) = world
  600. .get(&authorize_path(INACTIVE_URL, &challenge), &[])
  601. .await?;
  602. ensure_error_page(status, &body, "invalid_client")?;
  603. let inactive_count = inactive_calls.load(Ordering::SeqCst);
  604. ensure(
  605. inactive_count == 0,
  606. format!("an inactive row must never fall through to CIMD, got {inactive_count}"),
  607. )?;
  608. // CIMD disabled: an unknown `https://` id is the normal unknown client.
  609. const UNKNOWN_URL: &str = "https://cimd.example.test/disabled.json";
  610. let (fetcher, disabled_calls) = failing_fetcher();
  611. let world = cimd_world(fetcher, false).await?;
  612. let (challenge, _) = oauth2_pkce();
  613. let (status, _, body) = world
  614. .get(&authorize_path(UNKNOWN_URL, &challenge), &[])
  615. .await?;
  616. ensure_error_page(status, &body, "invalid_client")?;
  617. let disabled_count = disabled_calls.load(Ordering::SeqCst);
  618. ensure(
  619. disabled_count == 0,
  620. format!("a disabled CIMD feature must not fetch, got {disabled_count}"),
  621. )?;
  622. Ok(())
  623. }