| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428 |
- //! `binary(e2e_dcr)` dynamic-client-registration lifecycle over the live
- //! router (RFC 7591 registration, RFC 7592 management).
- //!
- //! Every request goes through the production router over a loopback socket
- //! against the real shared Postgres + Redis. Initial access tokens are seeded
- //! through the same keyed-hash path the CLI uses, so the registration
- //! authorization the tests observe is the production one (not `sha256_hex`).
- //!
- //! DCR forbids `client_credentials` (`SUPPORTED_GRANTS`), so no case attempts
- //! it. Confidential `client_secret_basic` registration requires a sender
- //! constraint, hence every positive body sets `dpop_bound_access_tokens`.
- #[path = "e2e/harness.rs"]
- #[allow(dead_code)]
- mod harness;
- use std::time::Duration;
- use d_oauth::{
- crypto::tokens::{KeyId as TokenKeyId, mint_token, token_hash},
- db::TokenEndpointAuthMethod,
- protocol::token::ClientKind,
- store::dcr::{self, InitialAccessRecord},
- };
- use harness::{TestError, World, ensure, fail, json_body, open_rate_limit, seed_client};
- use reqwest::StatusCode;
- use secrecy::ExposeSecret as _;
- use serde_json::{Value, json};
- /// Persisted portion of one RFC 7591 registration response used by a test.
- struct Registered {
- client_id: String,
- client_secret: String,
- registration_token: String,
- }
- /// RFC 7591 body for a confidential, DPoP-bound client in its one grant.
- fn registration_body(name: &str, redirect: &str) -> Value {
- json!({
- "redirect_uris": [redirect],
- "client_name": name,
- "token_endpoint_auth_method": "client_secret_basic",
- "grant_types": ["authorization_code"],
- "response_types": ["code"],
- "dpop_bound_access_tokens": true,
- })
- }
- /// Read a required string member of a JSON response.
- fn string_field<'a>(body: &'a Value, key: &str) -> Result<&'a str, TestError> {
- body
- .get(key)
- .and_then(Value::as_str)
- .ok_or_else(|| fail(format!("response is missing string field {key}")))
- }
- /// Seed an IAT exactly as `protocol::dcr::tests::issue_iat` does: the storage
- /// key is the active pepper's keyed hash, not a plain `sha256_hex`.
- async fn seed_iat(world: &World, uses: u64) -> Result<String, TestError> {
- let raw = mint_token().map_err(|error| fail(format!("mint IAT failed: {error}")))?;
- let (key_id, pepper) = world
- .state
- .config
- .keys
- .active_pepper()
- .ok_or_else(|| fail("config has no active token pepper"))?;
- let hash = token_hash(
- &TokenKeyId::new(key_id.as_str()),
- pepper.expose_secret().as_bytes(),
- &raw,
- )
- .to_storage_string();
- let mut conn = world.state.redis.clone();
- dcr::issue(
- &mut conn,
- &world.store,
- &hash,
- &InitialAccessRecord {
- uses_remaining: uses,
- label: "e2e".to_owned(),
- },
- Duration::from_secs(600),
- )
- .await
- .map_err(|error| fail(format!("seed IAT failed: {error}")))?;
- Ok(raw)
- }
- /// `POST {path}` as JSON with a bearer credential.
- async fn post_json(
- world: &World,
- path: &str,
- bearer: &str,
- body: &Value,
- ) -> Result<(StatusCode, String), TestError> {
- let response = world
- .client
- .post(format!("{}{path}", world.base))
- .bearer_auth(bearer)
- .json(body)
- .send()
- .await?;
- Ok((response.status(), response.text().await?))
- }
- /// `PUT {path}` as JSON with a bearer credential.
- async fn put_json(
- world: &World,
- path: &str,
- bearer: &str,
- body: &Value,
- ) -> Result<(StatusCode, String), TestError> {
- let response = world
- .client
- .put(format!("{}{path}", world.base))
- .bearer_auth(bearer)
- .json(body)
- .send()
- .await?;
- Ok((response.status(), response.text().await?))
- }
- /// `DELETE {path}` with a bearer credential.
- async fn delete(
- world: &World,
- path: &str,
- bearer: &str,
- ) -> Result<(StatusCode, String), TestError> {
- let response = world
- .client
- .delete(format!("{}{path}", world.base))
- .bearer_auth(bearer)
- .send()
- .await?;
- Ok((response.status(), response.text().await?))
- }
- /// Register one client and return the credentials the response exposes once.
- async fn register(
- world: &World,
- iat: &str,
- name: &str,
- redirect: &str,
- ) -> Result<Registered, TestError> {
- let (status, body) =
- post_json(world, "/register", iat, ®istration_body(name, redirect)).await?;
- ensure(
- status == StatusCode::CREATED,
- format!("POST /register must be 201, got {status}: {body}"),
- )?;
- let parsed = json_body(&body)?;
- Ok(Registered {
- client_id: string_field(&parsed, "client_id")?.to_owned(),
- client_secret: string_field(&parsed, "client_secret")?.to_owned(),
- registration_token: string_field(&parsed, "registration_access_token")?.to_owned(),
- })
- }
- /// Assert an exact `403 access_denied` management denial.
- fn ensure_forbidden(status: StatusCode, body: &str) -> Result<(), TestError> {
- ensure(
- status == StatusCode::FORBIDDEN,
- format!("cross-client management must be 403, got {status}: {body}"),
- )?;
- let parsed = json_body(body)?;
- ensure(
- parsed["error"].as_str() == Some("access_denied"),
- format!("cross-client denial must be access_denied, got {body}"),
- )
- }
- /// RFC 7591 register → RFC 7592 read/update/delete, with the deleted client's
- /// tokens no longer introspectable.
- #[tokio::test]
- async fn e2e_dcr_register_read_update_delete_lifecycle() -> Result<(), TestError> {
- let world = World::start_with(open_rate_limit(), json!({"dcr_redirect_policy": "open"})).await?;
- let iat = seed_iat(&world, 3).await?;
- let redirect = "https://app.example.test/dcr/lifecycle";
- let registered = register(&world, &iat, "lifecycle client", redirect).await?;
- ensure(
- !registered.client_id.is_empty(),
- "registration must mint a client_id",
- )?;
- ensure(
- !registered.registration_token.is_empty(),
- "registration must mint a management token",
- )?;
- ensure(
- !registered.client_secret.is_empty(),
- "registration must mint a client secret",
- )?;
- let auth = format!("Bearer {}", registered.registration_token);
- let (status, _, body) = world
- .get(
- &format!("/register/{}", registered.client_id),
- &[("Authorization", auth.as_str())],
- )
- .await?;
- ensure(
- status == StatusCode::OK,
- format!("GET /register/{{id}} must be 200, got {status}: {body}"),
- )?;
- let metadata = json_body(&body)?;
- ensure(
- metadata["client_id"].as_str() == Some(registered.client_id.as_str()),
- "read must echo the registered client_id",
- )?;
- ensure(
- metadata["client_name"].as_str() == Some("lifecycle client"),
- "read must echo the registered client_name",
- )?;
- ensure(
- metadata["dpop_bound_access_tokens"].as_bool() == Some(true),
- "read must echo the DPoP binding",
- )?;
- let renamed = registration_body("lifecycle client renamed", redirect);
- let (status, body) = put_json(
- &world,
- &format!("/register/{}", registered.client_id),
- ®istered.registration_token,
- &renamed,
- )
- .await?;
- ensure(
- status == StatusCode::OK,
- format!("PUT /register/{{id}} must be 200, got {status}: {body}"),
- )?;
- let updated = json_body(&body)?;
- ensure(
- updated["client_name"].as_str() == Some("lifecycle client renamed"),
- "PUT must apply the new client_name",
- )?;
- let rotated = string_field(&updated, "registration_access_token")?.to_owned();
- ensure(
- rotated != registered.registration_token,
- "PUT must rotate the management token",
- )?;
- // A successful PUT also rotates a secret-method client's secret, so the
- // secret from the 201 body is stale from here on.
- let rotated_secret = string_field(&updated, "client_secret")?.to_owned();
- let minted = world
- .mint(®istered.client_id, "", ClientKind::Confidential, None)
- .await?;
- let (status, _, body) = world
- .post_form(
- "/introspect",
- &[("token", minted.access_token.as_str())],
- Some((registered.client_id.as_str(), rotated_secret.as_str())),
- &[],
- )
- .await?;
- ensure(
- status == StatusCode::OK,
- format!("introspection must be 200, got {status}: {body}"),
- )?;
- ensure(
- json_body(&body)?["active"].as_bool() == Some(true),
- "the minted token must introspect active before delete",
- )?;
- let (status, _) = delete(
- &world,
- &format!("/register/{}", registered.client_id),
- &rotated,
- )
- .await?;
- ensure(
- status == StatusCode::NO_CONTENT,
- format!("DELETE /register/{{id}} must be 204, got {status}"),
- )?;
- let auth = format!("Bearer {rotated}");
- let (status, _, body) = world
- .get(
- &format!("/register/{}", registered.client_id),
- &[("Authorization", auth.as_str())],
- )
- .await?;
- ensure(
- status == StatusCode::UNAUTHORIZED,
- format!("management after delete must be 401, got {status}: {body}"),
- )?;
- ensure(
- json_body(&body)?["error"].as_str() == Some("invalid_client"),
- format!("management after delete must be invalid_client, got {body}"),
- )?;
- let other_id = "e2e-dcr-introspector";
- let other_secret = "e2e-dcr-introspector-secret-aaaabbbbccccdddd";
- seed_client(
- &world.pool,
- &world.state.keys,
- other_id,
- Some(other_secret),
- &["authorization_code"],
- "",
- TokenEndpointAuthMethod::ClientSecretBasic,
- )
- .await?;
- let (status, _, body) = world
- .post_form(
- "/introspect",
- &[("token", minted.access_token.as_str())],
- Some((other_id, other_secret)),
- &[],
- )
- .await?;
- ensure(
- status == StatusCode::OK,
- format!("foreign introspection must be 200, got {status}: {body}"),
- )?;
- ensure(
- json_body(&body)?["active"].as_bool() == Some(false),
- "the deleted client's token must introspect inactive",
- )
- }
- /// Every bad-metadata row renders its documented RFC 7591 error and creates
- /// no client row.
- #[tokio::test]
- async fn e2e_dcr_rejects_bad_metadata_at_http() -> Result<(), TestError> {
- let world = World::start_with(open_rate_limit(), json!({})).await?;
- let iat = seed_iat(&world, 10).await?;
- let wildcard = json!({
- "redirect_uris": ["https://*.example.test/cb"],
- "token_endpoint_auth_method": "client_secret_basic",
- "dpop_bound_access_tokens": true,
- });
- let plain_http = json!({
- "redirect_uris": ["http://app.example.test/cb"],
- "token_endpoint_auth_method": "client_secret_basic",
- "dpop_bound_access_tokens": true,
- });
- let bad_method = json!({
- "redirect_uris": ["https://app.example.test/cb"],
- "token_endpoint_auth_method": "bogus",
- "dpop_bound_access_tokens": true,
- });
- let both_jwks = json!({
- "redirect_uris": ["https://app.example.test/cb"],
- "token_endpoint_auth_method": "client_secret_basic",
- "dpop_bound_access_tokens": true,
- "jwks": {"keys": []},
- "jwks_uri": "https://app.example.test/jwks.json",
- });
- let rows = [
- (&wildcard, "invalid_redirect_uri"),
- (&plain_http, "invalid_redirect_uri"),
- (&bad_method, "invalid_client_metadata"),
- (&both_jwks, "invalid_client_metadata"),
- ];
- for (body, expected) in rows {
- let (status, response_body) = post_json(&world, "/register", &iat, body).await?;
- ensure(
- status == StatusCode::BAD_REQUEST,
- format!("{expected} row must be 400, got {status}: {response_body}"),
- )?;
- let parsed = json_body(&response_body)?;
- ensure(
- parsed["error"].as_str() == Some(expected),
- format!("expected {expected}, got {response_body}"),
- )?;
- }
- let clients: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM oauth_clients")
- .fetch_one(&world.pool)
- .await?;
- ensure(
- clients == 0,
- format!("no rejected registration may persist a client row, found {clients}"),
- )
- }
- /// A management token is scoped to the client it was minted for.
- #[tokio::test]
- async fn e2e_dcr_management_token_is_client_scoped() -> Result<(), TestError> {
- let world = World::start_with(open_rate_limit(), json!({"dcr_redirect_policy": "open"})).await?;
- let iat = seed_iat(&world, 5).await?;
- let first = register(&world, &iat, "first client", "https://app.example.test/a").await?;
- let second = register(&world, &iat, "second client", "https://app.example.test/b").await?;
- let auth = format!("Bearer {}", second.registration_token);
- let (status, _, body) = world
- .get(
- &format!("/register/{}", first.client_id),
- &[("Authorization", auth.as_str())],
- )
- .await?;
- ensure_forbidden(status, &body)?;
- let rename = registration_body("attacker rename", "https://app.example.test/b");
- let (status, body) = put_json(
- &world,
- &format!("/register/{}", first.client_id),
- &second.registration_token,
- &rename,
- )
- .await?;
- ensure_forbidden(status, &body)?;
- let (status, body) = delete(
- &world,
- &format!("/register/{}", first.client_id),
- &second.registration_token,
- )
- .await?;
- ensure_forbidden(status, &body)?;
- let auth = format!("Bearer {}", first.registration_token);
- let (status, _, body) = world
- .get(
- &format!("/register/{}", first.client_id),
- &[("Authorization", auth.as_str())],
- )
- .await?;
- ensure(
- status == StatusCode::OK,
- format!("the victim client must stay manageable, got {status}: {body}"),
- )?;
- ensure(
- json_body(&body)?["client_name"].as_str() == Some("first client"),
- "cross-client calls must not mutate the victim client",
- )
- }
|