| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590 |
- //! `binary(e2e_stale_catalog)` — stale RFC 8707 and scope catalogs at mint.
- //!
- //! Every test boots the REAL production router over a loopback socket against
- //! the shared Postgres + Redis, with `resource_indicators_enabled` pinned on.
- //! A resource or scope disabled AFTER issuance must be dead at the next
- //! mint-time check: refresh re-validates the grant's stored audience before
- //! rotation (`invalid_target`, presented token left live), the device poll
- //! consumes the code and rejects `invalid_target`, authorization-code
- //! redemption validates a presented audience before consuming the code, and
- //! refresh narrows a disabled scope into the replacement token without ever
- //! widening it back.
- //!
- //! Re-enabling each catalog row proves the disable was the cause: the same
- //! live token/code then succeeds.
- #[path = "e2e/harness.rs"]
- #[allow(dead_code)]
- mod harness;
- #[path = "e2e/browser.rs"]
- #[allow(dead_code)]
- mod browser;
- #[path = "e2e/dpop.rs"]
- #[allow(dead_code)]
- mod dpop;
- #[path = "e2e/device.rs"]
- #[allow(dead_code)]
- mod device;
- use d_oauth::{
- db::{TokenEndpointAuthMethod, resource_repo, scope_repo},
- protocol::token::ClientKind,
- };
- use dpop::DpopKey;
- use harness::{
- TestError, World, attach_redirect, ensure, fail, json_body, open_rate_limit, seed_client,
- seed_scope,
- };
- use reqwest::StatusCode;
- /// Catalog resource pinned into every grant under test.
- const RESOURCE: &str = "https://rs-stale.example.test/api";
- /// Registered loopback redirect for the authorization-code flow.
- const REDIRECT_URI: &str = "http://127.0.0.1:9/cb";
- /// Confidential refresh client.
- const REFRESH_CLIENT: &str = "e2e-stale-refresh";
- /// Refresh client secret.
- const REFRESH_SECRET: &str = "e2e-stale-refresh-secret-1a2b3c4d5e6f";
- /// Public browser client (unbound: no DPoP proof needed).
- const PUBLIC_CLIENT: &str = "e2e-stale-public";
- /// Confidential device client.
- const DEVICE_CLIENT: &str = "e2e-stale-device";
- /// Device client secret.
- const DEVICE_SECRET: &str = "e2e-stale-device-secret-6f5e4d3c2b1a";
- /// Provisioned login email.
- const EMAIL: &str = "stale-catalog@example.test";
- /// Provisioned login password.
- const PASSWORD: &str = "e2e-stale-catalog-password-1a2b";
- /// Start one world with the RFC 8707 gate on and one enabled catalog row.
- ///
- /// Returns the world plus the row id needed to re-enable it: there is no
- /// public enable helper, and `upsert` deliberately preserves `enabled=false`.
- async fn start_world() -> Result<(World, uuid::Uuid), TestError> {
- let world = World::start_with(
- open_rate_limit(),
- serde_json::json!({"resource_indicators_enabled": true}),
- )
- .await?;
- let row = resource_repo::upsert(&world.pool, RESOURCE, "stale catalog e2e")
- .await
- .map_err(|error| fail(format!("resource upsert failed: {error}")))?;
- Ok((world, row.id))
- }
- /// Re-enable a catalog row through direct SQL (no production enable helper).
- async fn enable_resource(pool: &sqlx::PgPool, id: uuid::Uuid) -> Result<(), TestError> {
- sqlx::query("UPDATE resource_servers SET enabled = true WHERE id = $1")
- .bind(id)
- .execute(pool)
- .await?;
- Ok(())
- }
- /// Disable the catalog row, proving the update applied.
- async fn disable_resource(world: &World) -> Result<(), TestError> {
- ensure(
- resource_repo::disable(&world.pool, RESOURCE)
- .await
- .map_err(|error| fail(format!("resource disable failed: {error}")))?,
- "disable must update the seeded row",
- )
- }
- /// Assert a token endpoint failure carries the exact OAuth code and mints
- /// nothing.
- fn ensure_error(status: StatusCode, body: &str, code: &str) -> Result<(), TestError> {
- ensure(
- status == StatusCode::BAD_REQUEST,
- format!("must be 400, got {status}: {body}"),
- )?;
- let value = json_body(body)?;
- ensure(
- value["error"] == code,
- format!("failure must be {code}, got {value}"),
- )?;
- ensure(!body.contains("access_token"), "failure must mint nothing")
- }
- /// Assert a refresh token still resolves live in the store.
- async fn ensure_refresh_live(world: &World, token: &str) -> Result<(), TestError> {
- let mut conn = world.state.redis.clone();
- ensure(
- world
- .service
- .find_refresh(&mut conn, &world.store, token)
- .await
- .map_err(|error| fail(format!("refresh lookup failed: {error}")))?
- .is_some(),
- "the rejected refresh token must stay live",
- )
- }
- /// One authorization-code browser flow that pins `resource`.
- struct ResourceFlow<'a> {
- /// Public client driving the flow.
- client_id: &'a str,
- /// Registered callback.
- redirect_uri: &'a str,
- /// Requested scope.
- scope: &'a str,
- /// Audience the authorize request pins.
- resource: &'a str,
- /// Provisioned account email.
- email: &'a str,
- /// Provisioned account password.
- password: &'a str,
- /// Provisioned account TOTP secret.
- totp_secret: &'a str,
- }
- /// Drive `GET /authorize` (with `resource`) through login and consent and
- /// return the issued code plus the PKCE verifier.
- async fn authorize_with_resource(
- world: &World,
- flow: &ResourceFlow<'_>,
- ) -> Result<(String, String), TestError> {
- let (challenge, verifier) = browser::oauth2_pkce();
- let (status, headers, body) = world
- .get(
- &format!(
- "/authorize?client_id={}&redirect_uri={}&response_type=code&scope={}&state=e2e-stale\
- &resource={}&code_challenge={challenge}&code_challenge_method=S256",
- browser::encoded(flow.client_id),
- browser::encoded(flow.redirect_uri),
- browser::encoded(flow.scope),
- browser::encoded(flow.resource)
- ),
- &[],
- )
- .await?;
- ensure(
- status == StatusCode::SEE_OTHER,
- format!("authorize must 303 to login: {status} {body}"),
- )?;
- let request_id = browser::query_value(&browser::location_of(&headers)?, "request_id")?;
- let (status, headers, body) = world
- .get(&format!("/login?request_id={request_id}"), &[])
- .await?;
- ensure(
- status == StatusCode::OK,
- format!("login page must render: {status} {body}"),
- )?;
- let presession = browser::cookie_value(&headers, browser::SESSION_COOKIE)?;
- let login_csrf = browser::hidden_input(&body, "csrf")?;
- let code_2fa = browser::totp_now(flow.totp_secret).await?;
- let cookie = format!("{}={presession}", browser::SESSION_COOKIE);
- let (status, headers, body) = world
- .post_form(
- "/login",
- &[
- ("email", flow.email),
- ("password", flow.password),
- ("code", code_2fa.as_str()),
- ("csrf", login_csrf.as_str()),
- ],
- None,
- &[("cookie", cookie.as_str())],
- )
- .await?;
- ensure(
- status == StatusCode::SEE_OTHER,
- format!("login must 303 to consent: {status} {body}"),
- )?;
- ensure(
- browser::location_of(&headers)? == "/consent",
- "login must land on consent",
- )?;
- let session = browser::cookie_value(&headers, browser::SESSION_COOKIE)?;
- let cookie = format!("{}={session}", browser::SESSION_COOKIE);
- let (status, _, body) = world
- .get("/consent", &[("cookie", cookie.as_str())])
- .await?;
- ensure(
- status == StatusCode::OK,
- format!("consent must render: {status} {body}"),
- )?;
- ensure(
- browser::hidden_input(&body, "request_id")? == request_id,
- "consent must serve the bound authorization request",
- )?;
- let consent_csrf = browser::hidden_input(&body, "csrf")?;
- let (status, headers, body) = world
- .post_form(
- "/consent",
- &[
- ("request_id", request_id.as_str()),
- ("csrf", consent_csrf.as_str()),
- ("decision", "approve"),
- ],
- None,
- &[("cookie", cookie.as_str())],
- )
- .await?;
- ensure(
- status == StatusCode::SEE_OTHER,
- format!("consent approval must 303 to the redirect: {status} {body}"),
- )?;
- let location = browser::location_of(&headers)?;
- ensure(
- location.starts_with(flow.redirect_uri),
- format!("code must return to the registered redirect, got {location}"),
- )?;
- Ok((browser::query_value(&location, "code")?, verifier))
- }
- /// A resource disabled after issuance rejects refresh with `invalid_target`
- /// before rotation, leaves the presented token live, and the same token
- /// rotates once the catalog row is re-enabled.
- #[tokio::test]
- async fn e2e_refresh_rejects_disabled_resource_as_invalid_target() -> Result<(), TestError> {
- let (world, resource_id) = start_world().await?;
- seed_client(
- &world.pool,
- &world.state.keys,
- REFRESH_CLIENT,
- Some(REFRESH_SECRET),
- &["refresh_token"],
- "read",
- TokenEndpointAuthMethod::ClientSecretBasic,
- )
- .await?;
- seed_scope(&world.pool, "read").await?;
- let minted = world
- .mint_with_aud(
- REFRESH_CLIENT,
- "read",
- ClientKind::Confidential,
- None,
- RESOURCE,
- )
- .await?;
- disable_resource(&world).await?;
- let refresh = [
- ("grant_type", "refresh_token"),
- ("refresh_token", minted.refresh_token.as_str()),
- ];
- let basic = Some((REFRESH_CLIENT, REFRESH_SECRET));
- let (status, _, body) = world.post_form("/token", &refresh, basic, &[]).await?;
- ensure_error(status, &body, "invalid_target")?;
- ensure_refresh_live(&world, &minted.refresh_token).await?;
- enable_resource(&world.pool, resource_id).await?;
- let (status, _, body) = world.post_form("/token", &refresh, basic, &[]).await?;
- ensure(
- status == StatusCode::OK,
- format!("the re-enabled audience must rotate: {status} {body}"),
- )?;
- ensure(
- json_body(&body)?["refresh_token"]
- .as_str()
- .is_some_and(|token| !token.is_empty()),
- "the successful rotation must return a refresh token",
- )
- }
- /// A device code pinned to a resource that is disabled before the poll fails
- /// `invalid_target`; the approval was already consumed, so a retry is
- /// terminal `expired_token` and no token was ever minted.
- #[tokio::test]
- async fn e2e_device_poll_rejects_disabled_resource() -> Result<(), TestError> {
- let (world, _resource_id) = start_world().await?;
- seed_client(
- &world.pool,
- &world.state.keys,
- DEVICE_CLIENT,
- Some(DEVICE_SECRET),
- &[device::DEVICE_GRANT],
- "read",
- TokenEndpointAuthMethod::ClientSecretBasic,
- )
- .await?;
- seed_scope(&world.pool, "read").await?;
- let (status, _, body) = world
- .post_form(
- "/device_authorization",
- &[
- ("client_id", DEVICE_CLIENT),
- ("scope", "read"),
- ("resource", RESOURCE),
- ],
- Some((DEVICE_CLIENT, DEVICE_SECRET)),
- &[],
- )
- .await?;
- ensure(
- status == StatusCode::OK,
- format!("device authorization must 200: {status} {body}"),
- )?;
- let payload = json_body(&body)?;
- let device_code = payload["device_code"]
- .as_str()
- .ok_or_else(|| fail("device authorization must return device_code"))?
- .to_owned();
- let user_code = payload["user_code"]
- .as_str()
- .ok_or_else(|| fail("device authorization must return user_code"))?
- .to_owned();
- disable_resource(&world).await?;
- let browser = browser::login_session(world, EMAIL, PASSWORD).await?;
- let session = browser
- .session
- .as_deref()
- .ok_or_else(|| fail("login_session must return an authenticated session"))?;
- let page = device::review(&browser.world, session, &user_code).await?;
- let csrf = browser::hidden_input(&page, "csrf")?;
- device::decide(
- &browser.world,
- device::Decision {
- session,
- user_code: &user_code,
- csrf: &csrf,
- action: "approve",
- },
- )
- .await?;
- let client = device::Client {
- id: DEVICE_CLIENT,
- secret: Some(DEVICE_SECRET),
- scope: "read",
- };
- let key = DpopKey::generate()?;
- let request = device::Poll {
- client,
- device_code: &device_code,
- };
- let (status, body) = device::poll(&browser.world, request, &key).await?;
- ensure_error(status, &body, "invalid_target")?;
- let (status, body) = device::poll(&browser.world, request, &key).await?;
- ensure(
- status == StatusCode::BAD_REQUEST,
- format!("the consumed device code must be 400: {status} {body}"),
- )?;
- ensure(
- json_body(&body)?["error"] == "expired_token",
- format!("the consumed device code must be expired_token: {body}"),
- )?;
- ensure(
- !body.contains("access_token"),
- "the retry must mint nothing",
- )
- }
- /// A disabled `resource` on `/authorize` redirects with `invalid_target` and
- /// issues no code.
- #[tokio::test]
- async fn e2e_authorize_rejects_disabled_resource_before_code() -> Result<(), TestError> {
- let (world, _resource_id) = start_world().await?;
- seed_client(
- &world.pool,
- &world.state.keys,
- PUBLIC_CLIENT,
- None,
- &["authorization_code", "refresh_token"],
- "read",
- TokenEndpointAuthMethod::None,
- )
- .await?;
- seed_scope(&world.pool, "read").await?;
- attach_redirect(&world.pool, &world.state.keys, PUBLIC_CLIENT, REDIRECT_URI).await?;
- disable_resource(&world).await?;
- let (challenge, _verifier) = browser::oauth2_pkce();
- let (status, headers, body) = world
- .get(
- &format!(
- "/authorize?client_id={PUBLIC_CLIENT}&redirect_uri={}&response_type=code&scope=read\
- &resource={}&code_challenge={challenge}&code_challenge_method=S256",
- browser::encoded(REDIRECT_URI),
- browser::encoded(RESOURCE)
- ),
- &[],
- )
- .await?;
- ensure(
- status == StatusCode::FOUND,
- format!("a disabled resource must redirect, got {status}: {body}"),
- )?;
- let location = browser::location_of(&headers)?;
- ensure(
- location.starts_with(REDIRECT_URI),
- format!("the error must redirect to the client, got {location}"),
- )?;
- ensure(
- location.contains("error=invalid_target"),
- format!("the redirect must carry invalid_target, got {location}"),
- )?;
- ensure(
- !browser::has_query_key(&location, "code"),
- "a rejected authorize must never issue a code",
- )
- }
- /// A code issued with an audience stays live when that resource is disabled
- /// before redemption: the presented audience is invalid_target, never a
- /// burned code, and the same code redeems after re-enable.
- #[tokio::test]
- async fn e2e_auth_code_rejects_resource_disabled_after_authorize() -> Result<(), TestError> {
- let (world, resource_id) = start_world().await?;
- seed_client(
- &world.pool,
- &world.state.keys,
- PUBLIC_CLIENT,
- None,
- &["authorization_code", "refresh_token"],
- "read",
- TokenEndpointAuthMethod::None,
- )
- .await?;
- attach_redirect(&world.pool, &world.state.keys, PUBLIC_CLIENT, REDIRECT_URI).await?;
- seed_scope(&world.pool, "read").await?;
- let totp_secret = browser::seed_user(&world, EMAIL, PASSWORD, "read").await?;
- let (code, verifier) = authorize_with_resource(
- &world,
- &ResourceFlow {
- client_id: PUBLIC_CLIENT,
- redirect_uri: REDIRECT_URI,
- scope: "read",
- resource: RESOURCE,
- email: EMAIL,
- password: PASSWORD,
- totp_secret: &totp_secret,
- },
- )
- .await?;
- disable_resource(&world).await?;
- let redemption = [
- ("grant_type", "authorization_code"),
- ("code", code.as_str()),
- ("redirect_uri", REDIRECT_URI),
- ("client_id", PUBLIC_CLIENT),
- ("code_verifier", verifier.as_str()),
- ("resource", RESOURCE),
- ];
- let (status, _, body) = world.post_form("/token", &redemption, None, &[]).await?;
- ensure_error(status, &body, "invalid_target")?;
- enable_resource(&world.pool, resource_id).await?;
- let (status, _, body) = world.post_form("/token", &redemption, None, &[]).await?;
- ensure(
- status == StatusCode::OK,
- format!("the live code must redeem after re-enable: {status} {body}"),
- )?;
- let tokens = json_body(&body)?;
- ensure(
- tokens["access_token"]
- .as_str()
- .is_some_and(|token| !token.is_empty()),
- "the re-enabled redeem must return an access token",
- )?;
- ensure(
- tokens["refresh_token"]
- .as_str()
- .is_some_and(|token| !token.is_empty()),
- "the re-enabled redeem must return a refresh token",
- )
- }
- /// A scope disabled after issuance drops from the rotated refresh token while
- /// the grant stays live; re-enabling it never widens the grant back.
- #[tokio::test]
- async fn e2e_refresh_drops_disabled_scope_but_stays_live() -> Result<(), TestError> {
- let (world, _resource_id) = start_world().await?;
- seed_client(
- &world.pool,
- &world.state.keys,
- REFRESH_CLIENT,
- Some(REFRESH_SECRET),
- &["refresh_token"],
- "read write",
- TokenEndpointAuthMethod::ClientSecretBasic,
- )
- .await?;
- seed_scope(&world.pool, "read").await?;
- seed_scope(&world.pool, "write").await?;
- let minted = world
- .mint(REFRESH_CLIENT, "read write", ClientKind::Confidential, None)
- .await?;
- ensure(
- scope_repo::set_enabled(&world.pool, "write", false)
- .await
- .map_err(|error| fail(format!("scope disable failed: {error}")))?,
- "write must be a seeded catalog row",
- )?;
- let basic = Some((REFRESH_CLIENT, REFRESH_SECRET));
- let (status, _, body) = world
- .post_form(
- "/token",
- &[
- ("grant_type", "refresh_token"),
- ("refresh_token", minted.refresh_token.as_str()),
- ],
- basic,
- &[],
- )
- .await?;
- ensure(
- status == StatusCode::OK,
- format!("a narrowed refresh must still rotate: {status} {body}"),
- )?;
- let rotated = json_body(&body)?;
- ensure(
- rotated["scope"] == "read",
- format!("the disabled scope must drop, got {rotated}"),
- )?;
- let rotated_refresh = rotated["refresh_token"]
- .as_str()
- .filter(|token| !token.is_empty())
- .ok_or_else(|| fail("the narrowed rotation must return a refresh token"))?
- .to_owned();
- ensure(
- scope_repo::set_enabled(&world.pool, "write", true)
- .await
- .map_err(|error| fail(format!("scope enable failed: {error}")))?,
- "write must still be a catalog row",
- )?;
- let (status, _, body) = world
- .post_form(
- "/token",
- &[
- ("grant_type", "refresh_token"),
- ("refresh_token", rotated_refresh.as_str()),
- ("scope", "write"),
- ],
- basic,
- &[],
- )
- .await?;
- ensure_error(status, &body, "invalid_scope")?;
- let (status, _, body) = world
- .post_form(
- "/token",
- &[
- ("grant_type", "refresh_token"),
- ("refresh_token", rotated_refresh.as_str()),
- ],
- basic,
- &[],
- )
- .await?;
- ensure(
- status == StatusCode::OK,
- format!("the still-live refresh must rotate: {status} {body}"),
- )?;
- ensure(
- json_body(&body)?["scope"] == "read",
- "the re-enabled scope must never be widened back onto the grant",
- )
- }
|