e2e_cimd.rs 21 KB

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