| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508 |
- //! `test(e2e_)` abuse matrix over the live router: wired-endpoint rows.
- //!
- //! Every test boots the REAL production router over a REAL loopback socket
- //! against the shared Postgres + Redis from `compose.test.yaml` (see
- //! `e2e/harness.rs`) and asserts only observable HTTP behavior. `#[test]` names all start with
- //! `e2e_` so `cargo nextest run -E 'test(e2e_)'` selects exactly this suite.
- //!
- //! Rows whose endpoint is wired live here; `/token` grant rows live in
- //! `e2e_grants.rs` as real 4xx/success assertions.
- #[path = "e2e/harness.rs"]
- mod harness;
- use d_oauth::db::TokenEndpointAuthMethod;
- use d_oauth::protocol::token::ClientKind;
- use harness::{
- TestError, World, attach_redirect, ensure, fail, json_body, open_rate_limit, seed_client,
- seed_scope,
- };
- /// Confidential fixture client for the abuse suite.
- const CLIENT_ID: &str = "e2e-abuse-client";
- /// Fixture secret (also used to prove it never leaks into responses).
- const CLIENT_SECRET: &str = "e2e-abuse-secret-9f8b7a6c5d4e3f2a1b0";
- /// Public fixture client (direct `/authorize` query flow; confidential
- /// clients must use PAR/JAR and page-400 without one).
- const PUBLIC_ID: &str = "e2e-abuse-public";
- /// Registered loopback redirect (unbound port: registration only).
- const REDIRECT_URI: &str = "http://127.0.0.1:9/cb";
- /// One world plus the seeded client, scopes, and redirect this suite needs.
- async fn setup() -> Result<World, TestError> {
- setup_with_rate_limit(open_rate_limit()).await
- }
- /// One world with a caller-chosen limiter budget.
- async fn setup_with_rate_limit(rate_limit: serde_json::Value) -> Result<World, TestError> {
- let world = World::start(rate_limit).await?;
- seed_client(
- &world.pool,
- &world.state.keys,
- CLIENT_ID,
- Some(CLIENT_SECRET),
- &["authorization_code", "refresh_token", "client_credentials"],
- "read write",
- TokenEndpointAuthMethod::ClientSecretBasic,
- )
- .await?;
- seed_scope(&world.pool, "read").await?;
- seed_scope(&world.pool, "write").await?;
- attach_redirect(&world.pool, &world.state.keys, CLIENT_ID, REDIRECT_URI).await?;
- seed_client(
- &world.pool,
- &world.state.keys,
- PUBLIC_ID,
- None,
- &["authorization_code", "refresh_token"],
- "read write",
- TokenEndpointAuthMethod::None,
- )
- .await?;
- attach_redirect(&world.pool, &world.state.keys, PUBLIC_ID, REDIRECT_URI).await?;
- Ok(world)
- }
- /// Unknown client fails closed with a direct 400 page (never a redirect);
- /// a validated redirect carries the error (`redirect-with-error`).
- #[tokio::test]
- async fn e2e_redirect_fail_closed_vs_redirect_with_error() -> Result<(), TestError> {
- let world = setup().await?;
- let (status, _, _) = world
- .get(
- "/authorize?client_id=nobody&redirect_uri=https%3A%2F%2Fevil.example%2Fcb",
- &[],
- )
- .await?;
- ensure(
- status == reqwest::StatusCode::BAD_REQUEST,
- "unknown client must page 400",
- )?;
- let (status, headers, _) = world
- .get(
- &format!(
- "/authorize?client_id={PUBLIC_ID}&redirect_uri={}&response_type=token",
- urlencoding(REDIRECT_URI)
- ),
- &[],
- )
- .await?;
- ensure(
- status == reqwest::StatusCode::FOUND,
- "bad response_type must 302",
- )?;
- let location = header_str(&headers, "location")?;
- ensure(
- location.contains("error=") && location.starts_with(REDIRECT_URI),
- "error must ride the validated redirect",
- )?;
- let (status, _, _) = world
- .get(
- &format!("/authorize?client_id={PUBLIC_ID}&redirect_uri=https%3A%2F%2Fevil.example%2Fcb"),
- &[],
- )
- .await?;
- ensure(
- status == reqwest::StatusCode::BAD_REQUEST,
- "foreign redirect must page 400",
- )?;
- Ok(())
- }
- /// Introspection without credentials is `401 invalid_client` (+ challenge),
- /// never debug output and never a successful introspection.
- #[tokio::test]
- async fn e2e_unauthenticated_introspection_rejected() -> Result<(), TestError> {
- let world = setup().await?;
- let minted = world
- .mint(CLIENT_ID, "read", ClientKind::Confidential, None)
- .await?;
- let (status, headers, body) = world
- .post_form(
- "/introspect",
- &[("token", minted.access_token.as_str())],
- None,
- &[],
- )
- .await?;
- ensure(
- status == reqwest::StatusCode::UNAUTHORIZED,
- "introspect without auth must 401",
- )?;
- ensure(
- headers.contains_key("www-authenticate"),
- "401 must carry WWW-Authenticate",
- )?;
- ensure(
- body.contains("invalid_client"),
- "body must be invalid_client",
- )?;
- let (status, _, _) = world
- .post_form("/introspect", &[("token", "bogus")], None, &[])
- .await?;
- ensure(
- status == reqwest::StatusCode::UNAUTHORIZED,
- "bogus token without auth must 401",
- )?;
- Ok(())
- }
- /// Revocation is idempotent: revoke → 200, revoke again → 200, introspect → inactive.
- #[tokio::test]
- async fn e2e_revocation_idempotent() -> Result<(), TestError> {
- let world = setup().await?;
- let minted = world
- .mint(CLIENT_ID, "read", ClientKind::Confidential, None)
- .await?;
- let auth = Some((CLIENT_ID, CLIENT_SECRET));
- let (status, _, _) = world
- .post_form(
- "/revoke",
- &[("token", minted.access_token.as_str())],
- auth,
- &[],
- )
- .await?;
- ensure(status.is_success(), "first revoke must 200")?;
- let (status, _, _) = world
- .post_form(
- "/revoke",
- &[("token", minted.access_token.as_str())],
- auth,
- &[],
- )
- .await?;
- ensure(status.is_success(), "second revoke must still 200")?;
- let (status, _, body) = world
- .post_form(
- "/introspect",
- &[("token", minted.access_token.as_str())],
- auth,
- &[],
- )
- .await?;
- ensure(status.is_success(), "introspect must 200")?;
- let value = json_body(&body)?;
- ensure(
- value["active"] == false,
- "revoked token must introspect inactive",
- )?;
- let (status, _, _) = world
- .post_form("/revoke", &[("token", "unknown")], None, &[])
- .await?;
- ensure(
- status == reqwest::StatusCode::UNAUTHORIZED,
- "unauthenticated revoke must 401",
- )?;
- Ok(())
- }
- /// Scope beyond the client's registration (and unknown scopes) redirect with
- /// `invalid_scope`; the flow never proceeds to login.
- #[tokio::test]
- async fn e2e_scope_over_request_rejected() -> Result<(), TestError> {
- let world = setup().await?;
- let (challenge, _verifier) = oauth2_pkce();
- let (status, headers, _) = world
- .get(
- &format!(
- "/authorize?client_id={PUBLIC_ID}&redirect_uri={}&response_type=code\
- &scope=read%20admin&code_challenge={challenge}&code_challenge_method=S256",
- urlencoding(REDIRECT_URI)
- ),
- &[],
- )
- .await?;
- ensure(
- status == reqwest::StatusCode::FOUND,
- "over-request must 302",
- )?;
- let location = header_str(&headers, "location")?;
- ensure(
- location.contains("error=invalid_scope"),
- "over-request must be invalid_scope",
- )?;
- ensure(
- !location.contains("/login"),
- "over-request must never reach login",
- )?;
- Ok(())
- }
- /// Error redirects echo the request's own `state` (CSRF binding); two
- /// concurrent requests never see each other's state.
- #[tokio::test]
- async fn e2e_state_csrf_echo_isolated() -> Result<(), TestError> {
- let world = setup().await?;
- let error_url = |state: &str| {
- format!(
- "/authorize?client_id={PUBLIC_ID}&redirect_uri={}&response_type=token&state={state}",
- urlencoding(REDIRECT_URI)
- )
- };
- let (_, first_headers, _) = world.get(&error_url("aaa-csrf-1"), &[]).await?;
- let (_, second_headers, _) = world.get(&error_url("bbb-csrf-2"), &[]).await?;
- let first = header_str(&first_headers, "location")?;
- let second = header_str(&second_headers, "location")?;
- ensure(
- first.contains("state=aaa-csrf-1"),
- "first error must echo its own state",
- )?;
- ensure(
- second.contains("state=bbb-csrf-2"),
- "second error must echo its own state",
- )?;
- ensure(
- !first.contains("bbb-csrf-2") && !second.contains("aaa-csrf-1"),
- "states must not cross",
- )?;
- Ok(())
- }
- /// A forged `iss` query parameter never steers the flow: the server ignores it
- /// and 303-redirects into its own login with its own issuer binding.
- #[tokio::test]
- async fn e2e_mixup_iss_forged_is_ignored() -> Result<(), TestError> {
- let world = setup().await?;
- let (challenge, _verifier) = oauth2_pkce();
- let (status, headers, _) = world
- .get(
- &format!(
- "/authorize?client_id={PUBLIC_ID}&redirect_uri={}&response_type=code\
- &code_challenge={challenge}&code_challenge_method=S256&iss=https%3A%2F%2Fevil.example",
- urlencoding(REDIRECT_URI)
- ),
- &[],
- )
- .await?;
- ensure(
- status == reqwest::StatusCode::SEE_OTHER,
- "forged iss must not break the flow",
- )?;
- let location = header_str(&headers, "location")?;
- ensure(
- location.starts_with("/login?request_id="),
- "flow must enter the server login",
- )?;
- ensure(
- !location.contains("evil.example"),
- "evil issuer must appear nowhere",
- )?;
- Ok(())
- }
- /// A tight per-IP budget yields `429` with `Retry-After` on a protected
- /// endpoint; health and metadata stay ungated by construction.
- #[tokio::test]
- async fn e2e_rate_limit_429_with_retry_after() -> Result<(), TestError> {
- let budget = serde_json::json!({
- "enabled": true, "window_secs": 60,
- "per_ip": 3, "per_client": 100_000,
- "login_per_ip": 100_000, "token_per_client": 100_000,
- });
- let world = setup_with_rate_limit(budget).await?;
- let mut limited = 0;
- for _ in 0..10 {
- let (status, headers, _) = world
- .post_form("/introspect", &[("token", "x")], None, &[])
- .await?;
- if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
- limited += 1;
- ensure(
- headers.contains_key("retry-after"),
- "429 must carry Retry-After",
- )?;
- }
- }
- ensure(limited > 0, "tight budget must 429 eventually")?;
- let (status, _, _) = world.get("/health/live", &[]).await?;
- ensure(status.is_success(), "liveness must stay ungated")?;
- Ok(())
- }
- /// Forged proxy client-cert headers are stripped: the request is treated as
- /// uncertified (401 without credentials), and valid credentials still work
- /// with the forged headers present.
- #[tokio::test]
- async fn e2e_forged_mtls_header_stripped() -> Result<(), TestError> {
- let world = setup().await?;
- let minted = world
- .mint(CLIENT_ID, "read", ClientKind::Confidential, None)
- .await?;
- let forged = [
- ("x-forwarded-tls-client-cert", "Zm9yZ2Vk"),
- ("x-forwarded-tls-client-verify", "SUCCESS"),
- ];
- let (status, _, body) = world
- .post_form(
- "/introspect",
- &[("token", minted.access_token.as_str())],
- None,
- &forged,
- )
- .await?;
- ensure(
- status == reqwest::StatusCode::UNAUTHORIZED,
- "forged cert alone must not authenticate",
- )?;
- ensure(
- !body.contains("active"),
- "stripped request must not introspect",
- )?;
- let (status, _, body) = world
- .post_form(
- "/introspect",
- &[("token", minted.access_token.as_str())],
- Some((CLIENT_ID, CLIENT_SECRET)),
- &forged,
- )
- .await?;
- ensure(
- status.is_success(),
- "real credentials must work despite forged headers",
- )?;
- ensure(
- json_body(&body)?["active"] == true,
- "token must introspect active",
- )?;
- Ok(())
- }
- /// Unauthenticated DCR fails closed: no client is created, no secret
- /// material is emitted, and the response is `401 invalid_client` with a
- /// Bearer challenge (RFC 7591 §3.2.2).
- #[tokio::test]
- async fn e2e_unauthenticated_dcr_fail_closed() -> Result<(), TestError> {
- let world = setup().await?;
- let response = world
- .client
- .post(format!("{}/register", world.base))
- .json(&serde_json::json!({"redirect_uris": ["https://evil.example/cb"]}))
- .send()
- .await?;
- let status = response.status();
- let challenge = response
- .headers()
- .get("www-authenticate")
- .and_then(|value| value.to_str().ok())
- .unwrap_or_default()
- .to_owned();
- let body = response.text().await?;
- ensure(
- status == reqwest::StatusCode::UNAUTHORIZED,
- "unauthenticated DCR must 401",
- )?;
- ensure(
- challenge.starts_with("Bearer"),
- "DCR 401 must carry a Bearer challenge",
- )?;
- ensure(
- !body.contains(CLIENT_SECRET),
- "DCR failure must not leak secrets",
- )?;
- ensure(
- json_body(&body)?["error"] == "invalid_client",
- "DCR failure must be invalid_client",
- )?;
- ensure(
- !body.contains("client_secret"),
- "DCR failure must not emit client credentials",
- )?;
- Ok(())
- }
- /// Disabling a scope in the catalog removes it from discovery and rejects new
- /// authorize requests carrying it (`invalid_scope` redirect).
- #[tokio::test]
- async fn e2e_scope_catalog_disable_stops_authorize() -> Result<(), TestError> {
- let world = setup().await?;
- let (status, body) = world
- .get("/.well-known/oauth-authorization-server", &[])
- .await
- .map(|(status, _, body)| (status, body))?;
- ensure(status.is_success(), "metadata must 200")?;
- ensure(
- json_body(&body)?["scopes_supported"].as_array().is_some(),
- "metadata lists scopes",
- )?;
- disable_scope(&world.pool, "read").await?;
- let (status, body) = world
- .get("/.well-known/oauth-authorization-server", &[])
- .await
- .map(|(status, _, body)| (status, body))?;
- ensure(status.is_success(), "metadata must still 200")?;
- let scopes = json_body(&body)?["scopes_supported"]
- .as_array()
- .cloned()
- .ok_or_else(|| fail("scopes_supported must be an array"))?;
- ensure(
- !scopes.iter().any(|scope| scope == "read"),
- "disabled scope must leave discovery",
- )?;
- let (challenge, _verifier) = oauth2_pkce();
- let (status, headers, _) = world
- .get(
- &format!(
- "/authorize?client_id={PUBLIC_ID}&redirect_uri={}&response_type=code\
- &scope=read&code_challenge={challenge}&code_challenge_method=S256",
- urlencoding(REDIRECT_URI)
- ),
- &[],
- )
- .await?;
- ensure(
- status == reqwest::StatusCode::FOUND,
- "disabled scope must 302",
- )?;
- ensure(
- header_str(&headers, "location")?.contains("error=invalid_scope"),
- "disabled scope must be invalid_scope",
- )?;
- Ok(())
- }
- /// Disable one catalog scope (operator action; suite-local: only this suite
- /// mutates the catalog directly).
- async fn disable_scope(pool: &sqlx::PgPool, name: &str) -> Result<(), TestError> {
- d_oauth::db::scope_repo::set_enabled(pool, name, false)
- .await
- .map_err(|error| fail(format!("scope disable failed: {error}")))?;
- Ok(())
- }
- /// Read one response header or fail the test.
- fn header_str(headers: &reqwest::header::HeaderMap, name: &str) -> Result<String, TestError> {
- headers
- .get(name)
- .and_then(|value| value.to_str().ok())
- .map(str::to_owned)
- .ok_or_else(|| fail(format!("response lacks {name} header")))
- }
- /// Percent-encode a redirect URI for query embedding (unreserved passthrough).
- fn urlencoding(value: &str) -> String {
- let mut encoded = String::with_capacity(value.len());
- for byte in value.bytes() {
- if byte.is_ascii_alphanumeric() || b"-_.~".contains(&byte) {
- encoded.push(byte as char);
- } else {
- encoded.push_str(&format!("%{byte:02X}"));
- }
- }
- encoded
- }
- /// PKCE pair from the `oauth2` crate (baseline-flow material only).
- fn oauth2_pkce() -> (String, oauth2::PkceCodeVerifier) {
- let (challenge, verifier) = oauth2::PkceCodeChallenge::new_random_sha256();
- (challenge.as_str().to_owned(), verifier)
- }
|