//! `test(e2e_cimd)` end-to-end coverage of Client ID Metadata Documents //! (CIMD draft §3/§4.1/§5) through the real router and the injected fetcher //! seam (`AppState::cimd_fetcher`). //! //! Every world installs its own counting fetcher, so the cache behavior is //! observed rather than assumed: one fetch for the first resolution, zero for //! every cached/registered/inactive resolution within the TTL. Each invalid //! document row gets its own world (and its own URL) so the short-lived //! `cimd:invalid:` marker cannot replay across rows. //! //! The fetcher is the ONLY document source: no test performs a real network //! call. use std::{ future::Future, sync::{ Arc, atomic::{AtomicUsize, Ordering}, }, }; use base64::Engine as _; use d_oauth::{ db::{TokenEndpointAuthMethod, client_repo}, protocol::cimd::{CimdDocument, CimdError, CimdFetchFuture, CimdFetcher, CimdLimits}, }; use jsonwebtoken::{Algorithm, EncodingKey, Header, encode}; use ring::signature::{Ed25519KeyPair, KeyPair as _}; use serde_json::{Value, json}; use time::OffsetDateTime; #[allow(dead_code)] #[path = "e2e/harness.rs"] mod harness; #[path = "e2e/dpop.rs"] mod dpop; #[allow(dead_code)] #[path = "e2e/browser.rs"] mod browser; use browser::{ Browser, SESSION_COOKIE, cookie_value, get_consent, hidden_input, location_of, oauth2_pkce, post_consent, post_login, query_value, seed_user, totp_now, }; use harness::{ TestError, World, attach_redirect, ensure, fail, json_body, open_rate_limit, seed_client, seed_scope, }; /// Registration-free client identifier: an `https://` document URL. const CLIENT_URL: &str = "https://cimd.example.test/client.json"; /// Loopback redirect declared by the fixture documents (RFC 8252 §7.3). const REDIRECT: &str = "http://127.0.0.1:9/cb"; /// Catalog scope the fixture documents and users are entitled to. const SCOPE: &str = "read"; /// `state` echoed from authorize to the code redirect. const STATE: &str = "cimd-e2e-state"; /// Start a world with a counting fetcher and the CIMD feature gate. async fn cimd_world(fetcher: CimdFetcher, cimd_enabled: bool) -> Result { World::start_with_cimd_fetcher( open_rate_limit(), json!({"cimd_enabled": cimd_enabled, "allow_localhost_redirect": true}), fetcher, ) .await } /// Box one fetch so the higher-ranked [`CimdFetcher`] alias accepts the /// closure (mirrors the module-private helper production uses). fn boxed<'a>( future: impl Future> + Send + 'a, ) -> CimdFetchFuture<'a> { Box::pin(future) } /// A document fetcher bound to `expected_url`: a request for any other URL /// fails, so URL drift cannot pass the fetch-count assertions. fn document_fetcher(expected_url: &str, document: CimdDocument) -> (CimdFetcher, Arc) { let expected_url = expected_url.to_owned(); let calls = Arc::new(AtomicUsize::new(0)); let counter = Arc::clone(&calls); let fetcher: CimdFetcher = Arc::new(move |url: &str, _limits: CimdLimits| { let document = document.clone(); let counter = Arc::clone(&counter); let matches = url == expected_url; boxed(async move { if !matches { return Err(CimdError::Fetch); } counter.fetch_add(1, Ordering::SeqCst); Ok(document) }) }); (fetcher, calls) } /// A fetcher that counts every call and always fails. Used to prove that a /// path never reaches the fetch at all. fn failing_fetcher() -> (CimdFetcher, Arc) { let calls = Arc::new(AtomicUsize::new(0)); let counter = Arc::clone(&calls); let fetcher: CimdFetcher = Arc::new(move |_url: &str, _limits: CimdLimits| { let counter = Arc::clone(&counter); boxed(async move { counter.fetch_add(1, Ordering::SeqCst); Err(CimdError::Fetch) }) }); (fetcher, calls) } /// A valid `none` metadata document. fn none_document(url: &str, dpop_bound: bool) -> CimdDocument { CimdDocument { client_id: url.to_owned(), redirect_uris: vec![REDIRECT.to_owned()], token_endpoint_auth_method: Some("none".to_owned()), grant_types: Some(vec![ "authorization_code".to_owned(), "refresh_token".to_owned(), ]), scope: Some(SCOPE.to_owned()), jwks: None, jwks_uri: None, client_name: None, dpop_bound_access_tokens: Some(dpop_bound), } } /// A valid `private_key_jwt` metadata document carrying an inline JWKS. fn private_key_document(url: &str, jwks: Value) -> CimdDocument { CimdDocument { client_id: url.to_owned(), redirect_uris: vec![REDIRECT.to_owned()], token_endpoint_auth_method: Some("private_key_jwt".to_owned()), grant_types: Some(vec![ "authorization_code".to_owned(), "refresh_token".to_owned(), ]), scope: Some(SCOPE.to_owned()), jwks: Some(jwks), jwks_uri: None, client_name: None, dpop_bound_access_tokens: Some(false), } } /// An Ed25519 signer plus the matching inline JWKS for one document. struct DocKey { /// Signing key for request objects and client assertions. encoding: EncodingKey, /// Public JWKS the document publishes. jwks: Value, } impl DocKey { /// Generate a fresh Ed25519 key pair and its JWKS. fn generate(kid: &str) -> Result { let rng = ring::rand::SystemRandom::new(); let pkcs8 = Ed25519KeyPair::generate_pkcs8(&rng) .map_err(|error| fail(format!("ed25519 keygen failed: {error}")))?; let pair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()) .map_err(|error| fail(format!("ed25519 reload failed: {error}")))?; let x = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(pair.public_key().as_ref()); Ok(Self { encoding: EncodingKey::from_ed_der(pkcs8.as_ref()), jwks: json!({ "keys": [{ "kty": "OKP", "crv": "Ed25519", "x": x, "kid": kid }] }), }) } /// Sign one EdDSA JWT with `kid`. fn sign(&self, kid: &str, claims: &Value) -> Result { let mut header = Header::new(Algorithm::EdDSA); header.kid = Some(kid.to_owned()); encode(&header, claims, &self.encoding).map_err(|error| fail(format!("jwt encode: {error}"))) } /// A signed RFC 9101 request object bound to `client_id`/`issuer`. fn request_object( &self, kid: &str, client_id: &str, issuer: &str, extra: Value, ) -> Result { let now = OffsetDateTime::now_utc().unix_timestamp(); let mut claims = json!({ "iss": client_id, "aud": issuer, "exp": now + 300, "iat": now, }); let object = claims .as_object_mut() .ok_or_else(|| fail("request claims must be an object"))?; let extra_object = extra .as_object() .ok_or_else(|| fail("request extras must be an object"))?; for (key, value) in extra_object { object.insert(key.clone(), value.clone()); } self.sign(kid, &claims) } /// A `private_key_jwt` client assertion (RFC 7523 §3). fn assertion( &self, kid: &str, client_id: &str, audience: &str, jti: &str, ) -> Result { let now = OffsetDateTime::now_utc().unix_timestamp(); self.sign( kid, &json!({ "iss": client_id, "sub": client_id, "aud": audience, "jti": jti, "exp": now + 300, "iat": now, }), ) } } /// Build a plain-query `/authorize` path (public `none` clients). fn authorize_path(client_id: &str, challenge: &str) -> String { format!( "/authorize?client_id={}&redirect_uri={}&response_type=code&scope={}&state={}\ &code_challenge={}&code_challenge_method=S256", browser::encoded(client_id), browser::encoded(REDIRECT), browser::encoded(SCOPE), browser::encoded(STATE), browser::encoded(challenge), ) } /// Assert an authorize error page renders `expected`. fn ensure_error_page( status: reqwest::StatusCode, body: &str, expected: &str, ) -> Result<(), TestError> { ensure( status == reqwest::StatusCode::BAD_REQUEST, format!("{expected} must render 400, got {status}: {body}"), )?; ensure( body.contains(&format!("

{expected}

")), format!("page must name {expected}: {body}"), ) } /// Drive login → consent approval for a [`Browser`] already parked on the /// login page and return the issued authorization code. async fn approve(browser: &Browser, expected_state: &str) -> Result { let code_2fa = totp_now(&browser.totp_secret).await?; let (status, headers, body) = post_login(browser, &browser.password, &code_2fa).await?; ensure( status == reqwest::StatusCode::SEE_OTHER, format!("login must 303 to consent: {status} {body}"), )?; let session = cookie_value(&headers, SESSION_COOKIE)?; let (status, _, body) = get_consent(browser, &session).await?; ensure( status == reqwest::StatusCode::OK, format!("consent must render: {status} {body}"), )?; let bound = hidden_input(&body, "request_id")?; ensure( bound == browser.request_id, "consent must serve the bound authorization request", )?; let consent_csrf = hidden_input(&body, "csrf")?; let (status, headers, _) = post_consent(browser, &session, &bound, &consent_csrf, "approve").await?; ensure( status == reqwest::StatusCode::SEE_OTHER, format!("consent approval must 303 to the redirect: {status}"), )?; let location = location_of(&headers)?; ensure( location.starts_with(REDIRECT), format!("code must return to the document redirect, got {location}"), )?; ensure( query_value(&location, "state")? == expected_state, "code redirect must echo state", )?; query_value(&location, "code") } /// Happy path: a `none` CIMD document carries the full browser flow through /// authorization-code redemption, and a second authorize inside the cache TTL /// performs no second fetch. #[tokio::test] async fn e2e_cimd_authorize_login_consent_token() -> Result<(), TestError> { let (fetcher, calls) = document_fetcher(CLIENT_URL, none_document(CLIENT_URL, true)); let world = cimd_world(fetcher, true).await?; let browser = browser::begin_browser_for( world, CLIENT_URL, REDIRECT, SCOPE, "cimd-happy@example.test", "correct-horse-28-cimd-happy", ) .await?; ensure( calls.load(Ordering::SeqCst) == 1, format!( "first authorize must fetch once, got {}", calls.load(Ordering::SeqCst) ), )?; let code = approve(&browser, browser::STATE).await?; // CIMD draft §4.1: a DPoP-bound document binds both the code and the token. let proof = browser.dpop.proof( "POST", &format!("{}/token", browser.world.state.config.issuer), )?; let (status, _, body) = browser .world .post_form( "/token", &[ ("grant_type", "authorization_code"), ("code", code.as_str()), ("redirect_uri", REDIRECT), ("client_id", CLIENT_URL), ("code_verifier", browser.verifier.as_str()), ], None, &[("dpop", proof.as_str())], ) .await?; ensure( status == reqwest::StatusCode::OK, format!("code redemption must mint: {status} {body}"), )?; let tokens = json_body(&body)?; ensure( tokens["token_type"] == "DPoP", format!("a DPoP-bound document must mint DPoP tokens, got {tokens}"), )?; ensure( tokens["access_token"] .as_str() .is_some_and(|token| !token.is_empty()), "redemption must return an access token", )?; ensure( tokens["refresh_token"] .as_str() .is_some_and(|token| !token.is_empty()), "the document grants refresh_token, so one must be returned", )?; ensure( tokens["scope"] == SCOPE, format!("tokens must carry the document scope, got {tokens}"), )?; // A second authorize inside the 15-minute TTL is a cache hit: zero fetches. let (challenge, _) = oauth2_pkce(); let proof = browser.dpop.proof( "GET", &format!("{}/authorize", browser.world.state.config.issuer), )?; let (status, headers, body) = browser .world .get( &authorize_path(CLIENT_URL, &challenge), &[("dpop", proof.as_str())], ) .await?; ensure( status == reqwest::StatusCode::SEE_OTHER, format!("cached second authorize must 303: {status} {body}"), )?; ensure( location_of(&headers)?.starts_with("/login?request_id="), "cached second authorize must reach login", )?; let count = calls.load(Ordering::SeqCst); ensure( count == 1, format!("second authorize within the TTL must add zero fetches, got {count}"), )?; Ok(()) } /// A `private_key_jwt` document is confidential: a plain query authorize is /// rejected with `invalid_request`, exactly like a registered confidential /// client (RFC 9101 §4, `tests/jar.rs:229-251`). #[tokio::test] async fn e2e_cimd_private_key_jwt_requires_par_or_jar() -> Result<(), TestError> { let key = DocKey::generate("cimd-doc-key-plain")?; let (fetcher, calls) = document_fetcher( CLIENT_URL, private_key_document(CLIENT_URL, key.jwks.clone()), ); let world = cimd_world(fetcher, true).await?; seed_scope(&world.pool, SCOPE).await?; let (challenge, _) = oauth2_pkce(); let (status, _, body) = world .get(&authorize_path(CLIENT_URL, &challenge), &[]) .await?; ensure_error_page(status, &body, "invalid_request")?; let count = calls.load(Ordering::SeqCst); ensure( count == 1, format!("the rejected authorize must fetch exactly once, got {count}"), )?; Ok(()) } /// The same `private_key_jwt` document authorizes, logs in, consents, and /// redeems when the request object is signed with the document key; the token /// request authenticates with a document-key assertion. One fetch total. #[tokio::test] async fn e2e_cimd_private_key_jwt_par_jar_positive() -> Result<(), TestError> { const KID: &str = "cimd-doc-key-jar"; const URL: &str = "https://cimd.example.test/private-key-jar.json"; let key = DocKey::generate(KID)?; let (fetcher, calls) = document_fetcher(URL, private_key_document(URL, key.jwks.clone())); let world = cimd_world(fetcher, true).await?; seed_scope(&world.pool, SCOPE).await?; let email = "cimd-jar@example.test"; let password = "correct-horse-28-cimd-jar"; let totp_secret = seed_user(&world, email, password, SCOPE).await?; let (challenge, verifier) = oauth2_pkce(); let issuer = world.state.config.issuer.clone(); let request = key.request_object( KID, URL, &issuer, json!({ "client_id": URL, "response_type": "code", "redirect_uri": REDIRECT, "scope": SCOPE, "state": STATE, "code_challenge": challenge, "code_challenge_method": "S256", }), )?; let path = format!( "/authorize?client_id={}&request={}", browser::encoded(URL), browser::encoded(&request) ); let (status, headers, body) = world.get(&path, &[]).await?; ensure( status == reqwest::StatusCode::SEE_OTHER, format!("signed request object must authorize: {status} {body}"), )?; let request_id = query_value(&location_of(&headers)?, "request_id")?; let (status, headers, body) = world .get(&format!("/login?request_id={request_id}"), &[]) .await?; ensure( status == reqwest::StatusCode::OK, format!("login page must render: {status} {body}"), )?; let presession = cookie_value(&headers, SESSION_COOKIE)?; let login_csrf = hidden_input(&body, "csrf")?; let browser = Browser { world, dpop: browser::DpopKey::generate()?, verifier, request_id, presession, session: None, login_csrf, email: email.to_owned(), password: password.to_owned(), totp_secret, }; let code = approve(&browser, STATE).await?; let assertion = key.assertion(KID, URL, &format!("{issuer}/token"), "cimd-jar-jti-1")?; let (status, _, body) = browser .world .post_form( "/token", &[ ("grant_type", "authorization_code"), ("code", code.as_str()), ("redirect_uri", REDIRECT), ("client_id", URL), ("code_verifier", browser.verifier.as_str()), ( "client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", ), ("client_assertion", assertion.as_str()), ], None, &[], ) .await?; ensure( status == reqwest::StatusCode::OK, format!("JAR + assertion redemption must mint: {status} {body}"), )?; let tokens = json_body(&body)?; ensure( tokens["access_token"] .as_str() .is_some_and(|token| !token.is_empty()), "redemption must return an access token", )?; let count = calls.load(Ordering::SeqCst); ensure( count == 1, format!("the whole JAR flow must fetch exactly once, got {count}"), )?; Ok(()) } /// Every invalid document fails closed with its typed error, exactly one /// fetch per row. One world per row: a shared Redis prefix would let the /// two-second `cimd:invalid:` marker replay the first row's verdict. #[tokio::test] async fn e2e_cimd_invalid_documents_fail_closed() -> Result<(), TestError> { let mut jwks_uri = none_document("https://cimd.example.test/jwks-uri.json", false); jwks_uri.jwks_uri = Some("https://keys.example.test/jwks.json".to_owned()); let mut unsupported = none_document("https://cimd.example.test/unsupported-method.json", false); unsupported.token_endpoint_auth_method = Some("client_secret_basic".to_owned()); let mut wildcard = none_document("https://cimd.example.test/wildcard.json", false); wildcard.redirect_uris = vec!["https://app.example.test/*".to_owned()]; let mut mismatch = none_document("https://cimd.example.test/mismatch.json", false); mismatch.client_id = "https://cimd.example.test/different.json".to_owned(); let rows = [ ( "https://cimd.example.test/jwks-uri.json", jwks_uri, "invalid_client_metadata", ), ( "https://cimd.example.test/unsupported-method.json", unsupported, "invalid_client_metadata", ), ( "https://cimd.example.test/wildcard.json", wildcard, "invalid_redirect_uri", ), ( "https://cimd.example.test/mismatch.json", mismatch, "invalid_client_metadata", ), ]; for (url, document, expected) in rows { let (fetcher, calls) = document_fetcher(url, document); let world = cimd_world(fetcher, true).await?; let (challenge, _) = oauth2_pkce(); let (status, _, body) = world.get(&authorize_path(url, &challenge), &[]).await?; ensure_error_page(status, &body, expected)?; let count = calls.load(Ordering::SeqCst); ensure( count == 1, format!("{url} must fetch exactly once, got {count}"), )?; } Ok(()) } /// Registered rows always win over CIMD: an active row never fetches, an /// inactive row is dead with zero fetches (todo 5 fail-closed), and a disabled /// feature keeps the normal unknown-client error with zero fetches. #[tokio::test] async fn e2e_cimd_registered_row_wins_and_inactive_is_dead() -> Result<(), TestError> { // Active registered row with an `https://` id: zero fetches. const ACTIVE_URL: &str = "https://cimd.example.test/registered.json"; let (fetcher, active_calls) = document_fetcher(ACTIVE_URL, none_document(ACTIVE_URL, true)); let world = cimd_world(fetcher, true).await?; seed_client( &world.pool, &world.state.keys, ACTIVE_URL, None, &["authorization_code", "refresh_token"], SCOPE, TokenEndpointAuthMethod::None, ) .await?; attach_redirect(&world.pool, &world.state.keys, ACTIVE_URL, REDIRECT).await?; let browser = browser::begin_browser_for( world, ACTIVE_URL, REDIRECT, SCOPE, "cimd-registered@example.test", "correct-horse-28-cimd-row", ) .await?; ensure( !browser.request_id.is_empty(), "the registered row must authorize to login", )?; let active_count = active_calls.load(Ordering::SeqCst); ensure( active_count == 0, format!("an active registered row must not fetch, got {active_count}"), )?; // Inactive registered row with an `https://` id: dead, zero fetches. const INACTIVE_URL: &str = "https://cimd.example.test/inactive.json"; let (fetcher, inactive_calls) = failing_fetcher(); let world = cimd_world(fetcher, true).await?; seed_client( &world.pool, &world.state.keys, INACTIVE_URL, None, &["authorization_code"], SCOPE, TokenEndpointAuthMethod::None, ) .await?; ensure( client_repo::deactivate(&world.pool, INACTIVE_URL).await?, "deactivate must flip the row", )?; let (challenge, _) = oauth2_pkce(); let (status, _, body) = world .get(&authorize_path(INACTIVE_URL, &challenge), &[]) .await?; ensure_error_page(status, &body, "invalid_client")?; let inactive_count = inactive_calls.load(Ordering::SeqCst); ensure( inactive_count == 0, format!("an inactive row must never fall through to CIMD, got {inactive_count}"), )?; // CIMD disabled: an unknown `https://` id is the normal unknown client. const UNKNOWN_URL: &str = "https://cimd.example.test/disabled.json"; let (fetcher, disabled_calls) = failing_fetcher(); let world = cimd_world(fetcher, false).await?; let (challenge, _) = oauth2_pkce(); let (status, _, body) = world .get(&authorize_path(UNKNOWN_URL, &challenge), &[]) .await?; ensure_error_page(status, &body, "invalid_client")?; let disabled_count = disabled_calls.load(Ordering::SeqCst); ensure( disabled_count == 0, format!("a disabled CIMD feature must not fetch, got {disabled_count}"), )?; Ok(()) }