소스 검색

Resolved gaps

Efren Yevale Varela 3 주 전
부모
커밋
5796624252

+ 13 - 0
docs/CHANGELOG.md

@@ -180,6 +180,19 @@ While the version stays below `1.0.0`, a minor bump may carry a breaking change.
   (`consume_bound` over `client_id`, plus `redirect_uri`/PKCE challenge for
   codes): a foreign-client or wrong-verifier presentation leaves the single-use
   record intact instead of burning the victim's value.
+- The token endpoint accepts a missing or empty `redirect_uri` on
+  `grant_type=authorization_code` and skips the binding check
+  (draft-ietf-oauth-v2-1-16 §4.1.3, §10.2); a supplied value must still match
+  exactly or redemption fails `invalid_grant`.
+- A private-use redirect-URI scheme must contain reverse-DNS dot notation
+  (draft-ietf-oauth-v2-1-16 §2.3.1): `com.example.app:/` is accepted and
+  `myapp:/` is rejected with `invalid_redirect_uri` on DCR and `invalid input`
+  on the CLI; `https` and the RFC 8252 loopback exception are unchanged.
+- Refresh rotation splits the scope (draft-ietf-oauth-v2-1-16 §4.3.1 +
+  §4.3.3): the replacement refresh token retains the presented scope re-checked
+  against the current `enabled` catalog, client registration, and user
+  entitlements (revoked entries drop silently), while an explicit narrower
+  `scope` narrows only the access token and the response `scope`.
 - Grant cascade `revoke` drains in a bounded loop until the member set reads
   empty, and the rotation script carries a grant-record fence (`NOGRANT`
   before any write), so a token minted mid-cascade is deleted by a later pass

+ 2 - 1
docs/DATABASE.md

@@ -148,7 +148,8 @@ once every `uri` is NULL.
 authorization-response callbacks (they carry `code`/`state`); `post_logout` URIs
 are bare navigation targets used only by `POST /logout` (`GET /logout` is
 read-only and issues no redirect). Both kinds are matched
-with the same exact-string + RFC 8252 loopback rule.
+with the same exact-string + RFC 8252 loopback rule, plus the draft-16 §2.3.1
+private-use dot rule (`com.example.app:/` is accepted, `myapp:/` is rejected).
 
 ### client_redirect_uris
 

+ 3 - 0
docs/DEPLOYMENT.md

@@ -80,6 +80,9 @@ so a read-only root filesystem is expected.
 ## TLS-terminating reverse proxy (HAProxy, Caddy)
 
 The server always terminates TLS on `bind_addr`; there is no plaintext mode.
+The server pins a TLS 1.2 minimum (exactly TLS 1.2 + TLS 1.3, per
+draft-ietf-oauth-v2-1-16 §1.5 and §7.14), so clients and proxies must offer
+1.2 or above.
 When a reverse proxy already holds the public certificate and terminates client
 TLS (L7), point the proxy's HTTPS upstream at the server's TLS listener. The
 server keeps its own certificate for the proxy-to-app hop, but that certificate

+ 96 - 59
docs/OAUTH.md

@@ -47,48 +47,67 @@ practice in RFC 9700. This server enforces every one of these:
 | `state` is supported and echoed | The value is returned on the success and error redirects. | RFC 6749 §4.1.2.1 |
 | No open redirect | The redirect URI must be registered, attached, and enabled. | RFC 9700 §4.1.3 |
 
-## Known gaps against draft-ietf-oauth-v2-1-16
-
-An internal audit found the gaps below against the draft cited above. They are
-listed as work "to revise", not as defects that make the server unusable. Each
-entry names the draft section, what the implementation does today, and the
-change to make.
-
-1. **Token endpoint requires `redirect_uri`**: draft-16 §4.1.3 removed
-   `redirect_uri` from the token request, and §10.2 says the back-compat mode
-   must allow it, not require it. The implementation rejects a token request
-   with an empty `redirect_uri` (`invalid_request`), which can break pure-2.1
-   clients. To revise: accept-but-ignore it (or make it a per-client opt-in),
-   never require it.
-
-2. **Authorization error redirects omit `iss`**: draft-16 §4.1.2.1 and
-   §7.15.1 make `iss` required on the authorization response, including errors.
-   The success redirect includes `iss`, but the error redirect (`error`,
-   `error_description`, `state`) does not. To revise: add `iss` to the error
-   redirect.
-
-3. **Missing `response_type` returns a direct error page instead of a redirect**:
-   once `client_id` and `redirect_uri` are validated, draft-16 §4.1.1 and
-   §4.1.2.1 require the error to be delivered via redirect. To revise: move the
-   absent-`response_type` case to the redirect path.
-
-4. **Rotated refresh-token scope narrows instead of staying identical**:
-   draft-16 §4.3.3 requires the new refresh token's scope to be identical to the
-   presented one, while §4.3.1 permits requesting a narrower scope. To revise:
-   keep the new refresh token's scope identical and narrow only the access
-   token, or document the deviation explicitly.
-
-5. **No pinned TLS 1.2 minimum**: draft-16 §1.5 and §7.14 require TLS 1.2 or
-   above. The server relies on rustls defaults rather than asserting a minimum
-   protocol version. To revise: assert the minimum version explicitly and test
-   it.
-
-Two lower-priority SHOULD-level notes:
-
-- The consent success redirect uses `302` where the draft prefers `303`
-  (draft-16 §7.5.4).
-- There is no reverse-DNS `.` check for a private-use URI scheme
-  (draft-16 §2.3.1).
+## Resolved gaps against draft-ietf-oauth-v2-1-16
+
+An internal audit raised the seven findings below against the draft cited
+above. All seven are resolved in the current implementation; there are no
+open gaps. Findings 1, 4, and 7 are also recorded under `[Unreleased]` Fixed
+in [CHANGELOG.md](CHANGELOG.md). Each entry names the draft section, the
+previous behavior, and the current behavior.
+
+1. **Token endpoint accepts a missing or empty `redirect_uri`** (draft-16
+   §4.1.3, §10.2): the back-compat mode must allow `redirect_uri`, not
+   require it, but the implementation used to reject a token request with an
+   empty `redirect_uri` (`invalid_request`), which could break pure-2.1
+   clients. **Resolved**: the token endpoint accepts a missing or empty
+   `redirect_uri` and skips the binding check; a supplied value must still
+   match exactly or redemption fails `invalid_grant`.
+
+2. **Authorization error redirects carry `iss`** (draft-16 §4.1.2.1,
+   §7.15.1): `iss` is required on the authorization response, including
+   errors, but the error redirect (`error`, `error_description`, `state`)
+   used to omit it while the success redirect included it. **Resolved**:
+   every error redirect issued after `client_id` and `redirect_uri` are
+   validated now carries the percent-encoded `iss` alongside `error`,
+   `error_description`, and the echoed `state`.
+
+3. **Missing `response_type` redirects once the client is validated**
+   (draft-16 §4.1.1, §4.1.2.1): the absent-`response_type` case used to
+   return a direct error page even after `client_id` and `redirect_uri` were
+   validated, where the draft requires delivery via redirect. **Resolved**:
+   the absent-`response_type` case now redirects (`invalid_request` with
+   `error_description` + `state`) once `client_id` and `redirect_uri` are
+   validated; before that it still renders the direct `400` page.
+
+4. **Rotated refresh-token scope splits retained from access scope**
+   (draft-16 §4.3.3, §4.3.1): the new refresh token's scope must stay
+   identical to the presented one while a narrower scope may be requested,
+   but rotation used to narrow the replacement refresh token itself.
+   **Resolved**: the replacement refresh token keeps the presented scope
+   re-checked against the current `enabled` catalog, client registration,
+   and user entitlements (revoked entries drop silently), while an explicit
+   narrower `scope` narrows only the access token and the response `scope`.
+
+5. **Pinned TLS 1.2 minimum** (draft-16 §1.5, §7.14): TLS 1.2 or above is
+   required. **Resolved**: the server pins the minimum explicitly in
+   `src/protocol/mtls/verifier.rs` (`TLS_PROTOCOL_VERSIONS`: exactly TLS 1.2
+   + TLS 1.3, wired via `ServerConfig::builder_with_protocol_versions`
+   instead of rustls defaults), covered by the `tls_version_*` tests in
+   `src/routes.rs` (pinned set is exactly 1.2/1.3; version-pinned clients
+   negotiate the expected version).
+
+6. **Consent success redirect uses `303`** (draft-16 §7.5.4, SHOULD): the
+   consent success redirect used `302` where the draft prefers `303`.
+   **Resolved**: `POST /consent` success (approval and denial) now returns
+   `303 See Other` with `Location`, so browsers continue with `GET`;
+   cookies, CSRF, and CSP handling are unchanged.
+
+7. **Private-use URI schemes require reverse-DNS `.` notation** (draft-16
+   §2.3.1, SHOULD): there was no reverse-DNS `.` check for private-use URI
+   schemes. **Resolved**: a private-use scheme must contain a `.`
+   (`com.example.app:/` is accepted, `myapp:/` is rejected with
+   `invalid_redirect_uri` on DCR and `invalid input` on the CLI); `https`
+   and the loopback `http` exception are unchanged.
 
 ## Endpoints
 
@@ -164,12 +183,15 @@ is never registered for a dynamically created client.
 
 Reference: RFC 6749 §4.1.3, RFC 9700 §4.5.3, RFC 7636, RFC 9449 §6.
 
-Parameters: `code`, `redirect_uri`, `code_verifier`, `client_id`,
+Parameters: `code`, `code_verifier`, `client_id`, optional `redirect_uri`,
 `client_secret` or `client_assertion_type` with `client_assertion`, and a DPoP
 proof when the code is DPoP-bound.
 
-Behavior: the code is consumed exactly once. The client, redirect URI, PKCE
-verifier, and sender proof are all checked before minting. The token is minted
+Behavior: the code is consumed exactly once. `redirect_uri` is accept-but-ignore
+(draft-16 §4.1.3, §10.2): a missing or empty value skips the binding check; a
+supplied value must still match exactly or redemption fails `invalid_grant`.
+The client, PKCE verifier, and sender proof are all checked before minting. The
+token is minted
 under the code's grant chain. A refresh token is issued only if the client is
 registered for the refresh grant.
 
@@ -178,10 +200,13 @@ registered for the refresh grant.
 Reference: RFC 6749 §6, RFC 9700 §2.2.2 and §4.14, RFC 9449 §5.
 
 Parameters: `client_id`, `refresh_token`, optional `requested_scope` (which may
-narrow but never widen), and a freshly verified sender proof. The effective
-scope is re-checked against the current `enabled` catalog and the client's
-current registration and the user's current entitlements (fresh Postgres
-reads), so a revoked scope or entitlement drops on refresh.
+narrow but never widen), and a freshly verified sender proof. The replacement
+refresh token keeps the presented scope re-checked against the current
+`enabled` catalog and the client's current registration and the user's current
+entitlements (fresh Postgres reads), so a revoked scope or entitlement drops
+from the retained refresh scope on refresh; an explicit narrower `scope`
+(§4.3.1) narrows only the access token and the response `scope`, and the next
+rotation from the retained refresh token can still use the full scope.
 
 Behavior: rotation on every use. The presented token is tombstoned with a
 `rotated_to` member. Presenting a rotated token again is reuse: the whole grant
@@ -255,19 +280,28 @@ Reference: RFC 3986 §6.2.1, RFC 8252 §7.3, RFC 9700 §2.1.
   `http://127.0.0.1` or `http://[::1]` matches the same scheme, host, and path
   on any port, and the request must carry no userinfo, query, or fragment.
 - `localhost` is never treated as a loopback host.
+- A private-use URI scheme must contain reverse-DNS dot notation (draft-16
+  §2.3.1): `com.example.app:/cb` is accepted, `myapp:/cb` is rejected.
+  `https` and the loopback `http` exception are unaffected.
 - A registered URI with userinfo, query, or fragment is never eligible for the
   loopback exception.
 
 ### Error-delivery matrix
 
 Before a redirect URI is validated, the server must not redirect anywhere. It
-returns a `400` HTML page with no `Location` header for: absent or unknown
+returns an HTML page with no `Location` header for: absent or unknown
 `client_id`, a JAR/PAR resolution failure, a `client_id` binding mismatch, and a
-missing or unregistered `redirect_uri`.
+missing or unregistered `redirect_uri`. The page carries the error's HTTP
+status: client errors render `400`, a fail-closed Postgres/PII outage
+(`temporarily_unavailable`) renders `503` with `Retry-After: 1`, and a masked
+internal failure renders `500`.
 
 After the redirect URI is validated, errors are delivered on the redirect as
-`302` with `error`, `error_description`, and the echoed `state`. This covers
-`unsupported_response_type`, `invalid_scope`, and the PKCE failures.
+`302` with `error`, `error_description`, the echoed `state`, and the RFC 9207
+`iss`. This covers
+a missing or unsupported `response_type` (`invalid_request` when absent,
+`unsupported_response_type` otherwise), `invalid_scope`, the PKCE failures,
+and a `post_logout` catalog outage (`temporarily_unavailable`/`server_error`).
 
 ## Error codes
 
@@ -275,7 +309,7 @@ Errors use the RFC 6749 §5.2 body `{error, error_description}`.
 
 | Code | HTTP | Typical causes |
 | --- | --- | --- |
-| `invalid_request` | 400 | Missing or duplicated parameter; PAR misuse; JAR misuse. |
+| `invalid_request` | 400, or 302 on the `/authorize` error redirect once `client_id` and `redirect_uri` are validated | Missing or duplicated parameter; PAR misuse; JAR misuse. |
 | `invalid_client` | 401 with `WWW-Authenticate` for Basic or Bearer, else 400 | Bad client authentication; ambiguous mTLS certificate. |
 | `invalid_grant` | 400 | Unknown, consumed, or foreign code; rotation reuse; PKCE mismatch; proof against the wrong grant. |
 | `invalid_scope` | 400 | Unknown, disabled, over-registered, or non-entitled scope; refresh widening. |
@@ -362,11 +396,14 @@ Reference: RFC 9101 §4.1 and §5.2, RFC 9126 §2 and §3.
 
 ## Edge cases
 
-- A refresh request that omits `scope` keeps the prior grant only while still
-  enabled, then narrows to the still-registered ∩ still-entitled intersection
-  (possibly empty, from fresh Postgres reads of the client registration, the
-  user entitlements, and the `enabled` catalog); an explicit `scope` must be a
-  subset of the prior grant and all three current sets.
+- A refresh request that omits `scope` keeps the prior grant on both new tokens
+  after re-checking against the current `enabled` catalog, client registration,
+  and user entitlements (fresh Postgres reads), with revoked entries dropped
+  silently from the retained scope (possibly to empty); an explicit `scope`
+  must be a subset of the prior grant and all three current sets, and narrows
+  only the access token and the response `scope` — the replacement refresh
+  token retains the full re-checked scope (draft-16 §4.3.1 + §4.3.3), so a
+  later rotation with an omitted `scope` restores the full retained scope.
 - A second authorization code made from the same request is a distinct code;
   each is single-use.
 - A DPoP proof outside the `iat` freshness window, or with a replayed `jti`, is

+ 13 - 3
docs/OPERATIONS.md

@@ -108,9 +108,19 @@ d-oauth-server client redirect-uris --client-id <id>
 ```
 
 A redirect URI is a global catalog entry; a client attaches to it. `/authorize`
-matches only the client's attached and enabled `authorize` set. Detaching or
-disabling a URI stops future redirects to it; already issued tokens are
-unaffected. Deleting a referenced URI disables it instead.
+matches only the client's attached and enabled `authorize` set. A private-use
+URI scheme must contain reverse-DNS dot notation (draft-16 §2.3.1):
+`com.example.app:/` is accepted and `myapp:/` is rejected at creation.
+Detaching or disabling a URI stops future redirects to it; already issued
+tokens are unaffected. Deleting a referenced URI disables it instead.
+
+Legacy rows predate the §2.3.1 dot check, so a catalog that still holds a
+dotless private-use URI (for example `myapp:/cb`) makes `config sync` fail:
+`redirect::upsert` validates the scheme (`src/cli/redirect.rs:170`) before the
+idempotency short-circuit (`src/cli/redirect.rs:178`, via `src/cli/sync.rs:168`),
+so the sync aborts with `invalid input` instead of reporting no changes.
+`redirect-uri disable` and `redirect-uri delete` bypass the upsert check, so
+disable or delete the legacy row first, then re-run the sync.
 
 `kind` selects the URI's role. `authorize` (the default) is an
 authorization-response callback. `post_logout` is a bare return target used only

+ 10 - 7
docs/SECURITY.md

@@ -321,15 +321,18 @@ configure one as fail-open is a load-time error.
 ## Input and output safety
 
 - Redirect URI matching is exact, with the RFC 8252 loopback exception only.
-  There are no wildcards and no prefix matches.
+  There are no wildcards and no prefix matches. A private-use scheme must
+  contain reverse-DNS dot notation (draft-16 §2.3.1): `com.example.app:/` is
+  accepted, `myapp:/` is rejected; `https` and loopback are unaffected.
 - PKCE is `S256` only. A verifier cannot be omitted to downgrade.
 - Scope is intersected: `requested ∩ client-registered ∩ user-entitled ∩
-  enabled`. Refresh can only narrow and re-checks the current `enabled` catalog
-  plus the current client-registered and user-entitled sets (fresh Postgres
-  reads): an omitted `scope` keeps the prior grant only while still enabled,
-  then silently narrows to the still-registered ∩ still-entitled intersection
-  (possibly empty); an explicit `scope` must be a subset of the prior grant and
-  of all three current sets.
+  enabled`. Refresh re-checks the current `enabled` catalog, the client's
+  current registration, and the user's current entitlements (fresh Postgres
+  reads) and splits the result (draft-16 §4.3.1 + §4.3.3): the replacement
+  refresh token retains the presented scope minus revoked entries (dropped
+  silently, possibly to empty), while an explicit narrower `scope` narrows only
+  the access token and the response `scope` — the next rotation from the
+  retained refresh token can still use the full retained scope.
 - The login, consent, and device pages set a strict CSP with
   `frame-ancestors 'none'`. They use server-side sessions, CSRF protection, and
   no open redirect.

+ 7 - 0
src/cli/redirect.rs

@@ -167,6 +167,13 @@ pub async fn upsert(
   description: Option<&str>,
   enabled: bool,
 ) -> Result<Upsert, CliError> {
+  if !crate::protocol::redirect_repo::is_valid_redirect_scheme(uri) {
+    return Err(CliError::Input(
+      "redirect URI private-use scheme must contain reverse-DNS dot notation \
+       (draft-ietf-oauth-v2-1-16 §2.3.1)"
+        .to_owned(),
+    ));
+  }
   let keys = &context.config().keys;
   if find_existing(context, uri, kind)
     .await?

+ 37 - 0
src/cli/tests.rs

@@ -794,6 +794,43 @@ async fn cli_redirect_upsert_and_attach() -> Result<(), TestError> {
       .is_err()
   );
 
+  // Draft-ietf-oauth-v2-1-16 §2.3.1: a dotless private-use scheme must be
+  // rejected with `CliError::Input`, while reverse-DNS dotted schemes upsert.
+  let dotless = redirect::run_uri(
+    &context,
+    RedirectUriCommand::Create {
+      uri: "myapp:/cb".to_owned(),
+      kind: "authorize".to_owned(),
+      description: None,
+    },
+  )
+  .await;
+  assert!(
+    matches!(dotless, Err(super::context::CliError::Input(_))),
+    "dotless private-use scheme must be rejected"
+  );
+  if let Err(super::context::CliError::Input(message)) = dotless {
+    assert!(
+      message.contains("§2.3.1"),
+      "rejection must cite §2.3.1, got: {message}"
+    );
+  }
+  redirect::run_uri(
+    &context,
+    RedirectUriCommand::Create {
+      uri: "com.example.app:/cb".to_owned(),
+      kind: "authorize".to_owned(),
+      description: None,
+    },
+  )
+  .await?;
+  assert!(
+    redirect::find_redirect_id(&context, "com.example.app:/cb", "authorize")
+      .await
+      .is_ok(),
+    "dotted reverse-DNS scheme must still upsert"
+  );
+
   // An unknown URI is a clean not-found, not a database error.
   let missing =
     redirect::find_redirect_id(&context, "https://unknown.example.test/x", "authorize").await;

+ 41 - 16
src/protocol/authorize.rs

@@ -6,12 +6,15 @@
 //! (`S256`, required), then `state` passthrough. No code is issued here;
 //! a [`ValidatedRequest`] only authorizes the login/consent stages to run.
 //!
-//! Error-delivery matrix: [`AuthorizeFailure::Page`] (HTTP 400, no redirect)
-//! only for an absent/unknown `client_id`, an invalid or unregistered
-//! `redirect_uri`, or a malformed request (including every PAR/JAR
-//! resolution failure, which precedes `redirect_uri` validation and therefore
-//! cannot redirect safely). Every later failure redirects to the validated
-//! `redirect_uri` with `error` (+ `state`).
+//! Error-delivery matrix: [`AuthorizeFailure::Page`] (no redirect) only for
+//! an absent/unknown `client_id`, an invalid or unregistered `redirect_uri`,
+//! or a malformed request (including every PAR/JAR resolution failure, which
+//! precedes `redirect_uri` validation and therefore cannot redirect safely).
+//! A page renders the error's HTTP status: client errors render 400, a
+//! fail-closed outage (`temporarily_unavailable`, Postgres/PII) renders 503
+//! with `Retry-After`, and a masked internal failure renders 500. Every later
+//! failure redirects to the validated `redirect_uri` with `error` (+ `error_description`, + `state`, + RFC 9207
+//! `iss`).
 
 use std::collections::HashMap;
 
@@ -75,9 +78,12 @@ pub struct ValidatedRequest {
 
 /// How an authorize failure reaches the user agent.
 ///
-/// A [`AuthorizeFailure::Page`] renders HTTP 400 with no `Location`; a
+/// A [`AuthorizeFailure::Page`] renders the error's HTTP status with no
+/// `Location` (400 for client errors, 503 + `Retry-After` for a fail-closed
+/// outage, 500 for a masked internal failure); a
 /// [`AuthorizeFailure::Redirect`] renders HTTP 302 to the validated
-/// `redirect_uri` carrying `error` (+ `error_description`, + `state`).
+/// `redirect_uri` carrying `error` (+ `error_description`, + `state`, + `iss`
+/// per RFC 9207).
 #[derive(Debug)]
 pub enum AuthorizeFailure {
   /// Direct error page: unknown client, bad redirect, or malformed request.
@@ -90,6 +96,8 @@ pub enum AuthorizeFailure {
     error: AppError,
     /// The `state` to echo, if the request supplied one.
     state: Option<String>,
+    /// The server issuer echoed as the RFC 9207 `iss` parameter.
+    iss: String,
   },
 }
 
@@ -125,7 +133,8 @@ impl AuthorizeFailure {
         target,
         error,
         state,
-      } => error_redirect(&target, &error, state.as_deref()),
+        iss,
+      } => error_redirect(&target, &error, state.as_deref(), &iss),
     }
   }
 }
@@ -188,6 +197,7 @@ pub async fn validate(input: AuthorizeInput<'_>) -> Result<ValidatedRequest, Aut
     target: redirect.clone(),
     error,
     state: merged.get("state").cloned(),
+    iss: input.issuer.to_owned(),
   };
 
   match present(merged.get("response_type")) {
@@ -198,7 +208,9 @@ pub async fn validate(input: AuthorizeInput<'_>) -> Result<ValidatedRequest, Aut
       )));
     }
     None => {
-      return Err(page(AppError::invalid_request("response_type is required")));
+      return Err(redirect_err(AppError::invalid_request(
+        "response_type is required",
+      )));
     }
   }
 
@@ -206,7 +218,7 @@ pub async fn validate(input: AuthorizeInput<'_>) -> Result<ValidatedRequest, Aut
     .await
     .map_err(redirect_err)?;
   let enabled_names: Vec<String> = enabled.iter().map(|record| record.name.clone()).collect();
-  let enabled_set = scope_set_of(&enabled_names, &redirect, &merged)?;
+  let enabled_set = scope_set_of(&enabled_names, &redirect, &merged, input.issuer)?;
   let client_registered =
     scope::ScopeSet::parse(&client.scope).map_err(|_| redirect_err(AppError::server_error()))?;
   let scope_input = scope::ScopeInput {
@@ -240,7 +252,7 @@ pub async fn validate(input: AuthorizeInput<'_>) -> Result<ValidatedRequest, Aut
   let post_logout_uri =
     redirect_repo::post_logout_for_client(input.pool, input.keys, &client.client_id)
       .await
-      .map_err(AuthorizeFailure::Page)?;
+      .map_err(redirect_err)?;
 
   Ok(ValidatedRequest {
     client_id: client.client_id,
@@ -268,11 +280,13 @@ fn scope_set_of(
   names: &[String],
   redirect: &str,
   merged: &HashMap<String, String>,
+  issuer: &str,
 ) -> Result<scope::ScopeSet, AuthorizeFailure> {
   scope::ScopeSet::from_names(names).map_err(|_| AuthorizeFailure::Redirect {
     target: redirect.to_owned(),
     error: AppError::server_error(),
     state: merged.get("state").cloned(),
+    iss: issuer.to_owned(),
   })
 }
 
@@ -289,7 +303,10 @@ fn check_challenge_shape(challenge: &str) -> Result<(), AppError> {
   }
 }
 
-/// Render a direct error page: HTTP 400 HTML, no `Location`.
+/// Render a direct error page: HTML with the error's HTTP status and no
+/// `Location`. Client errors render 400; a fail-closed outage
+/// (`temporarily_unavailable`) renders 503 with `Retry-After: 1`, and a masked
+/// internal failure renders 500 (RFC 6749 §4.1.2.1, RFC 9700 §4.1.3).
 fn error_page(error: &AppError) -> Response {
   let body = format!(
     "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">\
@@ -300,12 +317,18 @@ fn error_page(error: &AppError) -> Response {
     description = escape_html(error.description()),
   );
   let mut response = Html(body).into_response();
-  *response.status_mut() = StatusCode::BAD_REQUEST;
+  *response.status_mut() = error.status();
+  if error.code() == OAuthErrorCode::TemporarilyUnavailable {
+    response
+      .headers_mut()
+      .insert(header::RETRY_AFTER, header::HeaderValue::from_static("1"));
+  }
   response
 }
 
-/// Render an authorize error redirect: HTTP 302 with `error` (+ `state`).
-fn error_redirect(target: &str, error: &AppError, state: Option<&str>) -> Response {
+/// Render an authorize error redirect: HTTP 302 with `error` (+ `state`, + RFC
+/// 9207 `iss`).
+fn error_redirect(target: &str, error: &AppError, state: Option<&str>, issuer: &str) -> Response {
   let separator = if target.contains('?') { '&' } else { '?' };
   let mut location = String::with_capacity(target.len() + 128);
   location.push_str(target);
@@ -318,6 +341,8 @@ fn error_redirect(target: &str, error: &AppError, state: Option<&str>) -> Respon
     location.push_str("&state=");
     push_percent_encoded(&mut location, state);
   }
+  location.push_str("&iss=");
+  push_percent_encoded(&mut location, issuer);
   match header::HeaderValue::from_str(&location) {
     Ok(value) => Response::builder()
       .status(StatusCode::FOUND)

+ 237 - 0
src/protocol/authorize/tests.rs

@@ -267,14 +267,17 @@ async fn authorize_validate_token_response_type_redirects() -> TestResult {
       target,
       error,
       state,
+      iss,
     }) => {
       assert_eq!(target, REDIRECT);
       assert_eq!(error.code(), OAuthErrorCode::UnsupportedResponseType);
       assert_eq!(state.as_deref(), Some("xyz-state"));
+      assert_eq!(iss, ISSUER);
       let response = AuthorizeFailure::Redirect {
         target,
         error,
         state,
+        iss,
       }
       .into_response();
       assert_eq!(response.status(), StatusCode::FOUND);
@@ -286,6 +289,10 @@ async fn authorize_validate_token_response_type_redirects() -> TestResult {
         .to_owned();
       assert!(location.contains("error=unsupported_response_type"));
       assert!(location.contains("state=xyz-state"));
+      assert!(
+        location.contains("iss=https%3A%2F%2Fauth.example.test"),
+        "error redirect missing iss: {location}"
+      );
       Ok(())
     }
     Err(other) => Err(format!("expected a redirect, got {}", other.code()).into()),
@@ -319,14 +326,17 @@ async fn authorize_validate_unknown_scope_redirects() -> TestResult {
       target,
       error,
       state,
+      iss,
     }) => {
       assert_eq!(target, REDIRECT);
       assert_eq!(error.code(), OAuthErrorCode::InvalidScope);
       assert_eq!(state.as_deref(), Some("xyz-state"));
+      assert_eq!(iss, ISSUER);
       let response = AuthorizeFailure::Redirect {
         target,
         error,
         state,
+        iss,
       }
       .into_response();
       assert_eq!(response.status(), StatusCode::FOUND);
@@ -338,6 +348,10 @@ async fn authorize_validate_unknown_scope_redirects() -> TestResult {
         .to_owned();
       assert!(location.contains("error=invalid_scope"));
       assert!(location.contains("state=xyz-state"));
+      assert!(
+        location.contains("iss=https%3A%2F%2Fauth.example.test"),
+        "error redirect missing iss: {location}"
+      );
       Ok(())
     }
     Err(other) => Err(format!("expected a redirect, got {}", other.code()).into()),
@@ -532,12 +546,235 @@ async fn authorize_validate_missing_challenge_redirects() -> TestResult {
       target,
       error,
       state,
+      iss,
     }) => {
       assert_eq!(target, REDIRECT);
       assert_eq!(error.code(), OAuthErrorCode::InvalidRequest);
       assert_eq!(state.as_deref(), Some("xyz-state"));
+      assert_eq!(iss, ISSUER);
       Ok(())
     }
     Err(other) => Err(format!("expected a redirect, got {}", other.code()).into()),
   }
 }
+
+/// A direct `Page(temporarily_unavailable)` renders 503 + `Retry-After`
+/// (fail-closed Postgres/PII outage pre-validation); `Page(server_error)`
+/// renders 500. Client-error pages stay 400.
+#[tokio::test]
+async fn authorize_page_outage_renders_503_with_retry_after() -> TestResult {
+  let response = AuthorizeFailure::Page(AppError::temporarily_unavailable()).into_response();
+  assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
+  assert_eq!(
+    response
+      .headers()
+      .get(header::RETRY_AFTER)
+      .and_then(|value| value.to_str().ok()),
+    Some("1")
+  );
+  assert!(response.headers().get(header::LOCATION).is_none());
+
+  let response = AuthorizeFailure::Page(AppError::server_error()).into_response();
+  assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
+  assert!(response.headers().get(header::LOCATION).is_none());
+
+  let response = AuthorizeFailure::Page(AppError::invalid_request("bad")).into_response();
+  assert_eq!(response.status(), StatusCode::BAD_REQUEST);
+  assert!(response.headers().get(header::LOCATION).is_none());
+  Ok(())
+}
+
+/// A `post_logout` catalog outage after `redirect_uri` validation redirects
+/// (302) with `temporarily_unavailable`, per the error-delivery matrix.
+#[tokio::test]
+async fn authorize_post_logout_outage_redirects() -> TestResult {
+  let (_db, pool) = migrated_pool().await?;
+  let keys = test_keys()?;
+  provision(&pool, &keys, "authz-post-logout", true).await?;
+  let (row, _) = redirect_repo::upsert(
+    &pool,
+    &keys,
+    false,
+    &redirect_repo::NewRedirectUri {
+      id: Uuid::new_v4(),
+      uri: "https://app.example.test/signed-out",
+      kind: redirect_repo::RedirectKind::PostLogout,
+      description: "post-logout test",
+      created_by: None,
+    },
+  )
+  .await
+  .map_err(|error| format!("post-logout fixture failed: {error}"))?;
+  redirect_repo::attach(&pool, "authz-post-logout", row.id, None)
+    .await
+    .map_err(|error| format!("post-logout attach failed: {error}"))?;
+  sqlx::query("UPDATE redirect_uris SET uri_enc = $1 WHERE kind = 'post_logout'")
+    .bind(b"corrupt-blob".as_slice())
+    .execute(&pool)
+    .await
+    .map_err(|error| format!("corrupt fixture failed: {error}"))?;
+  let challenge = challenge_from_verifier(VERIFIER).map_err(|_| "bad fixture verifier")?;
+  let params = valid_query("authz-post-logout", &challenge);
+  let mut conn = offline_redis()?;
+  let store = test_store();
+
+  match validate(AuthorizeInput {
+    pool: &pool,
+    keys: &keys,
+    conn: &mut conn,
+    store: &store,
+    issuer: ISSUER,
+    query: &params,
+  })
+  .await
+  {
+    Ok(_) => Err("post-logout outage was accepted".into()),
+    Err(AuthorizeFailure::Redirect { target, error, .. }) => {
+      assert_eq!(target, REDIRECT);
+      assert_eq!(error.code(), OAuthErrorCode::TemporarilyUnavailable);
+      assert_eq!(error.status(), StatusCode::SERVICE_UNAVAILABLE);
+      let response = AuthorizeFailure::Redirect {
+        target,
+        error,
+        state: None,
+        iss: ISSUER.to_owned(),
+      }
+      .into_response();
+      assert_eq!(response.status(), StatusCode::FOUND);
+      Ok(())
+    }
+    Err(other) => Err(format!("expected a redirect, got {}", other.code()).into()),
+  }
+}
+/// draft-ietf-oauth-v2-1-16 §4.1.2.1 and §7.15.1.
+#[tokio::test]
+async fn authorize_error_iss() -> TestResult {
+  let (_db, pool) = migrated_pool().await?;
+  let keys = test_keys()?;
+  provision(&pool, &keys, "authz-err-iss", true).await?;
+  let challenge = challenge_from_verifier(VERIFIER).map_err(|_| "bad fixture verifier")?;
+  let mut params = valid_query("authz-err-iss", &challenge);
+  params.insert("response_type".to_owned(), "token".to_owned());
+  let mut conn = offline_redis()?;
+  let store = test_store();
+
+  match validate(AuthorizeInput {
+    pool: &pool,
+    keys: &keys,
+    conn: &mut conn,
+    store: &store,
+    issuer: ISSUER,
+    query: &params,
+  })
+  .await
+  {
+    Ok(_) => Err("response_type=token was accepted".into()),
+    Err(AuthorizeFailure::Redirect {
+      target,
+      error,
+      state,
+      iss,
+    }) => {
+      assert_eq!(target, REDIRECT);
+      assert_eq!(error.code(), OAuthErrorCode::UnsupportedResponseType);
+      assert_eq!(state.as_deref(), Some("xyz-state"));
+      assert_eq!(iss, ISSUER);
+      let response = AuthorizeFailure::Redirect {
+        target,
+        error,
+        state,
+        iss,
+      }
+      .into_response();
+      assert_eq!(response.status(), StatusCode::FOUND);
+      let location = response
+        .headers()
+        .get(header::LOCATION)
+        .and_then(|value| value.to_str().ok())
+        .unwrap_or_default()
+        .to_owned();
+      assert!(location.contains("error=unsupported_response_type"));
+      assert!(location.contains("state=xyz-state"));
+      assert!(
+        location.contains("iss=https%3A%2F%2Fauth.example.test"),
+        "error redirect missing iss: {location}"
+      );
+      Ok(())
+    }
+    Err(other) => Err(format!("expected a redirect, got {}", other.code()).into()),
+  }
+}
+/// A missing `response_type` redirects with `invalid_request` (+ `state`):
+/// `client_id` and `redirect_uri` are already validated here, so per
+/// draft-ietf-oauth-v2-1-16 §4.1.1 and §4.1.2.1 the error rides the redirect.
+#[tokio::test]
+async fn authorize_validate_missing_response_type_redirects() -> TestResult {
+  let (_db, pool) = migrated_pool().await?;
+  let keys = test_keys()?;
+  provision(&pool, &keys, "authz-missing-rt", true).await?;
+  let challenge = challenge_from_verifier(VERIFIER).map_err(|_| "bad fixture verifier")?;
+  let mut conn = offline_redis()?;
+  let store = test_store();
+
+  for params in [
+    {
+      let mut q = valid_query("authz-missing-rt", &challenge);
+      q.remove("response_type");
+      q
+    },
+    {
+      let mut q = valid_query("authz-missing-rt", &challenge);
+      q.insert("response_type".to_owned(), String::new());
+      q
+    },
+  ] {
+    match validate(AuthorizeInput {
+      pool: &pool,
+      keys: &keys,
+      conn: &mut conn,
+      store: &store,
+      issuer: ISSUER,
+      query: &params,
+    })
+    .await
+    {
+      Ok(_) => return Err("missing response_type was accepted".into()),
+      Err(AuthorizeFailure::Redirect {
+        target,
+        error,
+        state,
+        iss,
+      }) => {
+        assert_eq!(target, REDIRECT);
+        assert_eq!(error.code(), OAuthErrorCode::InvalidRequest);
+        assert_eq!(state.as_deref(), Some("xyz-state"));
+        assert_eq!(iss, ISSUER);
+        let response = AuthorizeFailure::Redirect {
+          target,
+          error,
+          state,
+          iss,
+        }
+        .into_response();
+        assert_eq!(response.status(), StatusCode::FOUND);
+        let location = response
+          .headers()
+          .get(header::LOCATION)
+          .and_then(|value| value.to_str().ok())
+          .unwrap_or_default()
+          .to_owned();
+        assert!(location.contains("error=invalid_request"));
+        assert!(location.contains("error_description="));
+        assert!(location.contains("state=xyz-state"));
+        assert!(
+          location.contains("iss=https%3A%2F%2Fauth.example.test"),
+          "error redirect missing iss: {location}"
+        );
+      }
+      Err(other) => {
+        return Err(format!("expected a redirect, got {}", other.code()).into());
+      }
+    }
+  }
+  Ok(())
+}

+ 7 - 0
src/protocol/dcr.rs

@@ -443,6 +443,13 @@ async fn validate_metadata(
       "redirect_uris must not be empty",
     ));
   }
+  for uri in &redirect_uris {
+    if !crate::protocol::redirect_repo::is_valid_redirect_scheme(uri) {
+      return Err(AppError::invalid_redirect_uri(
+        "private-use URI scheme must contain reverse-DNS dot notation",
+      ));
+    }
+  }
 
   Ok(ValidatedRegistration {
     method,

+ 28 - 0
src/protocol/dcr/tests.rs

@@ -697,6 +697,34 @@ async fn dcr_register_open_policy_rejects_custom_scheme() -> TestResult {
   }
 }
 
+#[tokio::test]
+async fn dcr_catalog_policy_private_scheme_dot_rule() -> TestResult {
+  // Isolated catalog-policy cover for the private-use dot rule in
+  // `validate_metadata` (draft-ietf-oauth-v2-1-16 §2.3.1): the dotted scheme
+  // registers via its catalog hit, while the dotless scheme is rejected by
+  // `is_valid_redirect_scheme` even though it is also seeded in the catalog
+  // (so the failure cannot be a catalog miss).
+  let (_guard, mut harness) = harness(false, "catalog").await?;
+  seed_redirect(&harness.pool, &harness.config.keys, "com.example.app:/cb").await?;
+  seed_redirect(&harness.pool, &harness.config.keys, "myapp:/cb").await?;
+  let iat = issue_iat(&mut harness, 3).await?;
+  let dotted = dpop_request("com.example.app:/cb", None);
+  {
+    let (mut stores, ctx) = parts_of(&mut harness);
+    register_client(&mut stores, &ctx, Some(&iat), &dotted)
+      .await
+      .map_err(|error| TestError::Unexpected(format!("dotted scheme failed: {error}")))?;
+  }
+  let dotless = dpop_request("myapp:/cb", None);
+  let (mut stores, ctx) = parts_of(&mut harness);
+  match register_client(&mut stores, &ctx, Some(&iat), &dotless).await {
+    Ok(_) => Err(TestError::Unexpected(
+      "dotless private-use scheme accepted under catalog".to_owned(),
+    )),
+    Err(error) => expect_error(error, OAuthErrorCode::InvalidRedirectUri, 400),
+  }
+}
+
 #[tokio::test]
 async fn dcr_register_open_policy_rejects_fragment() -> TestResult {
   let (_guard, mut harness) = harness(false, "open").await?;

+ 3 - 1
src/protocol/mtls.rs

@@ -14,7 +14,9 @@ mod verifier;
 
 pub use cert::{subject_dn, thumbprint_b64url};
 pub use select::{authenticate, authenticate_by_id, select_by_subject_dn, select_by_thumbprint};
-pub use verifier::{pki_verifier, pki_verifier_from_file, self_signed_verifier, server_config};
+pub use verifier::{
+  TLS_PROTOCOL_VERSIONS, pki_verifier, pki_verifier_from_file, self_signed_verifier, server_config,
+};
 
 use crate::error::{AppError, OAuthErrorCode};
 

+ 10 - 2
src/protocol/mtls/verifier.rs

@@ -12,7 +12,7 @@ use std::{path::Path, sync::Arc};
 // `HandshakeSignatureValid` is only re-exported through `client::danger`;
 use rustls::{
   DigitallySignedStruct, DistinguishedName, Error as RustlsError, RootCertStore, ServerConfig,
-  SignatureScheme,
+  SignatureScheme, SupportedProtocolVersion,
   client::danger::HandshakeSignatureValid,
   crypto::{
     WebPkiSupportedAlgorithms, ring::default_provider, verify_tls12_signature,
@@ -23,6 +23,7 @@ use rustls::{
     ParsedCertificate, WebPkiClientVerifier,
     danger::{ClientCertVerified, ClientCertVerifier},
   },
+  version::{TLS12, TLS13},
 };
 
 use super::MtlsError;
@@ -116,6 +117,13 @@ pub fn pki_verifier_from_file(path: &Path) -> Result<Arc<dyn ClientCertVerifier>
   pki_verifier(&std::fs::read(path).map_err(|err| MtlsError::TrustBundle(err.to_string()))?)
 }
 
+/// Explicit TLS floor for the public listener: TLS 1.2 minimum, per
+/// draft-ietf-oauth-v2-1-16 §1.5 and §7.14.
+///
+/// Pinned explicitly instead of relying on rustls defaults, so a future
+/// default change cannot silently re-enable an older protocol version.
+pub static TLS_PROTOCOL_VERSIONS: &[&SupportedProtocolVersion] = &[&TLS13, &TLS12];
+
 /// Build a TLS server config pairing the server identity with either mTLS
 /// verifier. Wired into the accept loop by the TLS listener task.
 pub fn server_config(
@@ -134,7 +142,7 @@ pub fn server_config(
   }
   let key = PrivateKeyDer::from_pem_slice(key_pem)
     .map_err(|_| MtlsError::TrustBundle("server private key is not valid PEM".to_owned()))?;
-  ServerConfig::builder()
+  ServerConfig::builder_with_protocol_versions(TLS_PROTOCOL_VERSIONS)
     .with_client_cert_verifier(client_verifier)
     .with_single_cert(chain, key)
     .map_err(|_| MtlsError::TrustBundle("server certificate and key do not match".to_owned()))

+ 44 - 0
src/protocol/redirect_repo.rs

@@ -39,13 +39,23 @@ pub enum RedirectDecision {
 /// single exception is the RFC 8252 §7.3 loopback rule. [`RedirectDecision::Exact`]
 /// takes precedence over [`RedirectDecision::Loopback`] when several
 /// registrations could apply.
+///
+/// A requested or registered URI whose private-use scheme lacks reverse-DNS
+/// dot notation (draft-ietf-oauth-v2-1-16 §2.3.1, SHOULD-level) never
+/// matches: the request is rejected without redirecting the user agent.
 #[must_use]
 pub fn match_redirect_uri<S: AsRef<str>>(registered: &[S], requested: &str) -> RedirectDecision {
+  if !is_valid_redirect_scheme(requested) {
+    return RedirectDecision::Reject;
+  }
   let requested_parts = parse_uri(requested);
   let mut loopback = false;
 
   for candidate in registered {
     let candidate = candidate.as_ref();
+    if !is_valid_redirect_scheme(candidate) {
+      continue;
+    }
     let Some(reg) = parse_uri(candidate) else {
       continue;
     };
@@ -175,6 +185,40 @@ fn is_http(scheme: &str) -> bool {
   scheme.eq_ignore_ascii_case("http")
 }
 
+/// True when the scheme is HTTPS (case-insensitively, RFC 3986 §3.1).
+fn is_https(scheme: &str) -> bool {
+  scheme.eq_ignore_ascii_case("https")
+}
+
+/// Whether `uri` satisfies the draft-ietf-oauth-v2-1-16 §2.3.1 SHOULD-level
+/// reverse-DNS rule for private-use URI schemes: any scheme other than
+/// `http`/`https` must contain a `.` (for example `com.example.app:/` is
+/// acceptable while `myapp:/` is not). `http`/`https` URIs — including the
+/// RFC 8252 §7.3 loopback exception — are unaffected. URIs without a
+/// syntactically valid scheme fail closed.
+#[must_use]
+pub fn is_valid_redirect_scheme(uri: &str) -> bool {
+  let before_fragment = match uri.split_once('#') {
+    Some((before, _)) => before,
+    None => uri,
+  };
+  let before_query = match before_fragment.split_once('?') {
+    Some((before, _)) => before,
+    None => before_fragment,
+  };
+  let (scheme, _) = match before_query.split_once(':') {
+    Some(parts) => parts,
+    None => return false,
+  };
+  if !is_valid_scheme(scheme) {
+    return false;
+  }
+  if is_http(scheme) || is_https(scheme) {
+    return true;
+  }
+  scheme.contains('.')
+}
+
 /// The only hosts eligible for the RFC 8252 §7.3 loopback exception.
 ///
 /// `localhost` is deliberately absent: hostname resolution is

+ 44 - 1
src/protocol/redirect_repo/tests.rs

@@ -1,6 +1,6 @@
 //! Tests for `redirect_uri` matching (RFC 3986 §6.2.1 / RFC 8252 §7.3).
 
-use super::{RedirectDecision, match_redirect_uri};
+use super::{RedirectDecision, is_valid_redirect_scheme, match_redirect_uri};
 
 #[test]
 fn redirect_uri_exact_string_match_is_accepted() {
@@ -154,3 +154,46 @@ fn redirect_uri_exact_takes_precedence_over_loopback() {
     RedirectDecision::Exact
   );
 }
+
+#[test]
+fn redirect_uri_private_scheme_without_dot_is_rejected() {
+  // draft-ietf-oauth-v2-1-16 §2.3.1 SHOULD: a private-use scheme is
+  // reverse-DNS based, so it must contain a `.`.
+  let registered = ["myapp:/cb"];
+  assert_eq!(
+    match_redirect_uri(&registered, "myapp:/cb"),
+    RedirectDecision::Reject
+  );
+
+  let registered = ["myapp:/"];
+  assert_eq!(
+    match_redirect_uri(&registered, "myapp:/"),
+    RedirectDecision::Reject
+  );
+}
+
+#[test]
+fn redirect_uri_private_scheme_with_dot_is_accepted() {
+  let registered = ["com.example.app:/cb"];
+  assert_eq!(
+    match_redirect_uri(&registered, "com.example.app:/cb"),
+    RedirectDecision::Exact
+  );
+
+  let registered = ["com.example.app:/"];
+  assert_eq!(
+    match_redirect_uri(&registered, "com.example.app:/"),
+    RedirectDecision::Exact
+  );
+}
+
+#[test]
+fn redirect_uri_private_scheme_dot_check_leaves_https_and_loopback_unchanged() {
+  assert!(is_valid_redirect_scheme("https://client.example.test/cb"));
+  assert!(is_valid_redirect_scheme("http://127.0.0.1:5173/cb"));
+  assert!(is_valid_redirect_scheme("http://[::1]:5173/cb"));
+  assert!(is_valid_redirect_scheme("com.example.app:/"));
+  assert!(is_valid_redirect_scheme("com.example.app:/cb"));
+  assert!(!is_valid_redirect_scheme("myapp:/"));
+  assert!(!is_valid_redirect_scheme("myapp:/cb"));
+}

+ 53 - 17
src/protocol/scope.rs

@@ -154,26 +154,38 @@ pub fn effective_scope(input: &ScopeInput<'_>) -> Result<ScopeSet, AppError> {
   }
 }
 
-/// Refresh may narrow but never widen: `None` keeps the previous grant
-/// (re-checked against the current catalog, then silently narrowed to the
-/// still-registered and still-entitled intersection, which may be empty),
-/// `Some` must be a subset of the previous grant and of every current set.
-pub fn narrow_on_refresh(
+/// The refresh scope split (draft-ietf-oauth-v2-1-16 §4.3.1 + §4.3.3):
+/// `retained` feeds the replacement refresh token, `access` feeds the
+/// access token and the response `scope`.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct RefreshScopes {
+  /// Presented scope re-checked against the current catalog, client
+  /// registration, and user entitlements; revoked entries drop silently.
+  pub retained: ScopeSet,
+  /// `retained` narrowed by an explicit request, or `retained` itself.
+  pub access: ScopeSet,
+}
+
+/// Refresh may narrow but never widen (draft-ietf-oauth-v2-1-16 §4.3.1 +
+/// §4.3.3): the replacement refresh token keeps the presented scope
+/// re-checked against the current sets (revoked entries drop silently, which
+/// may leave it empty), while an explicit `Some` request narrows only the
+/// access token and must be a subset of the presented scope and of every
+/// current set (`invalid_scope` otherwise). `None` leaves both at the
+/// retained scope.
+pub fn refresh_scopes(
   previous: &ScopeSet,
   requested: Option<&str>,
   enabled: &ScopeSet,
   client_registered: &ScopeSet,
   user_entitled: &ScopeSet,
-) -> Result<ScopeSet, AppError> {
-  match requested {
-    None => {
-      require_subset(previous, enabled, "granted scope no longer enabled")?;
-      Ok(
-        previous
-          .intersect(client_registered)
-          .intersect(user_entitled),
-      )
-    }
+) -> Result<RefreshScopes, AppError> {
+  let retained = previous
+    .intersect(enabled)
+    .intersect(client_registered)
+    .intersect(user_entitled);
+  let access = match requested {
+    None => retained.clone(),
     Some(raw) => {
       let candidate = ScopeSet::parse(raw)?;
       require_subset(&candidate, enabled, "unknown or disabled scope")?;
@@ -184,9 +196,33 @@ pub fn narrow_on_refresh(
         "scope exceeds client registration",
       )?;
       require_subset(&candidate, user_entitled, "scope not granted to user")?;
-      Ok(candidate)
+      candidate
     }
-  }
+  };
+  Ok(RefreshScopes { retained, access })
+}
+
+/// Refresh may narrow but never widen: `None` keeps the previous grant
+/// (re-checked against the current catalog, then silently narrowed to the
+/// still-registered and still-entitled intersection, which may be empty),
+/// `Some` must be a subset of the previous grant and of every current set.
+pub fn narrow_on_refresh(
+  previous: &ScopeSet,
+  requested: Option<&str>,
+  enabled: &ScopeSet,
+  client_registered: &ScopeSet,
+  user_entitled: &ScopeSet,
+) -> Result<ScopeSet, AppError> {
+  Ok(
+    refresh_scopes(
+      previous,
+      requested,
+      enabled,
+      client_registered,
+      user_entitled,
+    )?
+    .access,
+  )
 }
 
 fn enforce_grant_term(candidate: &ScopeSet, input: &ScopeInput<'_>) -> Result<(), AppError> {

+ 60 - 18
src/protocol/token/grants/authorization_code.rs

@@ -120,11 +120,17 @@ pub async fn redeem_code(
   state: &mut CodeGrantState<'_>,
   inputs: &CodeGrantInputs<'_>,
 ) -> Result<CodeTokenResponse, AppError> {
-  if inputs.code.trim().is_empty() || inputs.redirect_uri.trim().is_empty() {
-    return Err(AppError::invalid_request(
-      "code and redirect_uri are required",
-    ));
+  if inputs.code.trim().is_empty() {
+    return Err(AppError::invalid_request("code is required"));
   }
+  // draft-ietf-oauth-v2-1-16 §4.1.3 and §10.2: `redirect_uri` is
+  // accept-but-ignore at the token endpoint. An absent or empty value skips
+  // the binding check; a supplied value must still match exactly.
+  let redirect_binding = if inputs.redirect_uri.trim().is_empty() {
+    None
+  } else {
+    Some(inputs.redirect_uri)
+  };
   let used_basic = inputs.basic_header.is_some();
   let unknown_client = || {
     if used_basic {
@@ -158,7 +164,7 @@ pub async fn redeem_code(
     state,
     inputs.code.trim(),
     client.client_id.as_str(),
-    inputs.redirect_uri,
+    redirect_binding,
     expected_challenge.as_str(),
   )
   .await?;
@@ -251,7 +257,7 @@ async fn consume_code(
   state: &mut CodeGrantState<'_>,
   code: &str,
   expected_client_id: &str,
-  expected_redirect_uri: &str,
+  expected_redirect_uri: Option<&str>,
   expected_challenge: &str,
 ) -> Result<codes::CodeRecord, AppError> {
   for (id, secret) in state
@@ -265,18 +271,16 @@ async fn consume_code(
       code,
     )
     .to_storage_string();
-    match codes::consume_bound(
-      state.conn,
-      state.store,
-      &hash,
-      &[
-        ("client_id", expected_client_id),
-        ("redirect_uri", expected_redirect_uri),
-        ("code_challenge", expected_challenge),
-      ],
-    )
-    .await
-    .map_err(AppError::from)?
+    let mut expected = vec![
+      ("client_id", expected_client_id),
+      ("code_challenge", expected_challenge),
+    ];
+    if let Some(redirect_uri) = expected_redirect_uri {
+      expected.push(("redirect_uri", redirect_uri));
+    }
+    match codes::consume_bound(state.conn, state.store, &hash, &expected)
+      .await
+      .map_err(AppError::from)?
     {
       codes::ConsumeOutcome::Consumed(record) => return Ok(record),
       codes::ConsumeOutcome::Mismatched => {
@@ -918,6 +922,44 @@ mod tests {
     Ok(())
   }
 
+  #[tokio::test]
+  async fn test_grant_code_missing_redirect_accepted() -> TestResult {
+    let (_container, mut h) = harness().await?;
+    let client_id = unique("no-redirect");
+    insert_client(
+      &h,
+      &client_id,
+      TokenEndpointAuthMethod::ClientSecretPost,
+      Some("code-secret"),
+      &[
+        super::GRANT_TYPE_AUTHORIZATION_CODE,
+        GRANT_TYPE_REFRESH_TOKEN,
+      ],
+    )
+    .await?;
+    for redirect_uri in ["", "   "] {
+      let issued = issue_code(&mut h, &client_id, CodeClientKind::Confidential, None).await?;
+      let inputs = CodeGrantInputs {
+        code: issued.code.as_str(),
+        redirect_uri,
+        code_verifier: Some(VERIFIER),
+        client_id: Some(client_id.as_str()),
+        basic_header: None,
+        body_secret: Some(secret("code-secret")),
+        assertion_type: None,
+        assertion: None,
+        dpop_proof: None,
+        peer_certs: None,
+      };
+      redeem_once(&mut h, &inputs, now_unix())
+        .await
+        .map_err(|error| {
+          TestError::Unexpected(format!("missing redirect_uri must redeem, got {error}"))
+        })?;
+    }
+    Ok(())
+  }
+
   #[tokio::test]
   async fn test_grant_code_wrong_client_rejected() -> TestResult {
     let (_container, mut h) = harness().await?;

+ 28 - 21
src/protocol/token/grants/refresh_token.rs

@@ -2,10 +2,12 @@
 //!
 //! The caller authenticates the client (task 18) and verifies the sender
 //! proofs fresh on this request (tasks 20/21) before calling [`refresh`];
-//! this grant matches those proofs against the stored `cnf` binding, narrows
-//! the scope, mints the replacement pair, and rotates atomically through
-//! [`TokenService::rotate`] (task-12 Lua compare-and-set with tombstone and
-//! request-id mapping).
+//! this grant matches those proofs against the stored `cnf` binding, splits
+//! the scope (the replacement refresh token retains the presented scope per
+//! draft-ietf-oauth-v2-1-16 §4.3.3 while an explicit request narrows only the
+//! access token per §4.3.1), mints the replacement pair, and rotates
+//! atomically through [`TokenService::rotate`] (task-12 Lua compare-and-set
+//! with tombstone and request-id mapping).
 //!
 //! Ordering is the whole contract: the new raw pair is minted server-side
 //! FIRST (barrier deferred to the rotation, which covers the pre-mint writes
@@ -31,7 +33,7 @@ use crate::{
   },
   error::AppError,
   keys::KeyRing,
-  protocol::scope::{ScopeSet, narrow_on_refresh},
+  protocol::scope::{ScopeSet, refresh_scopes},
   state::RedisConn,
   store::{
     Store,
@@ -105,10 +107,12 @@ pub struct RefreshUserStores<'a> {
 /// for a retry after re-enable. Otherwise the current user entitlements are
 /// read fresh from Postgres (a subject that maps to no user — empty
 /// client-credentials subjects, synthetic principals — carries no user term)
-/// and the rotation narrows against the current `client_registered`,
-/// `user_entitled`, and `enabled` sets, so a revoked entitlement or a removed
-/// client scope drops out on refresh instead of surviving for the token's
-/// whole lifetime. The disabled check preserves unknown-token,
+/// and the rotation re-checks the presented scope against the current
+/// `client_registered`, `user_entitled`, and `enabled` sets for the
+/// replacement refresh token (draft-ietf-oauth-v2-1-16 §4.3.3), so a revoked
+/// entitlement or a removed client scope drops out on refresh instead of
+/// surviving for the token's whole lifetime while an explicit narrower
+/// request narrows only the access token (§4.3.1). The disabled check preserves unknown-token,
 /// tombstone-cascade, and proof-check behavior. A datastore failure propagates
 /// as an error.
 ///
@@ -162,12 +166,16 @@ pub async fn refresh_with_subject_check(
   refresh(conn, store, service, &fresh).await
 }
 
-/// Rotate a refresh token: fresh proof-of-possession, scope narrowing, then
+/// Rotate a refresh token: fresh proof-of-possession, scope split, then
 /// mint-first atomic rotation with tombstone reuse detection.
 ///
 /// The stored `cnf` alone never mints: every bound component must equal its
 /// freshly verified counterpart. Scope may only narrow (`invalid_scope`
-/// otherwise). The replacement pair is minted BEFORE the Lua script runs
+/// otherwise): the replacement refresh token retains the presented scope
+/// re-checked against the current catalog, client registration, and user
+/// entitlements (draft-ietf-oauth-v2-1-16 §4.3.3; revoked entries drop), while
+/// an explicit request narrows only the access token (§4.3.1). The
+/// replacement pair is minted BEFORE the Lua script runs
 /// (barrier deferred to the rotation, which gates the response), so every
 /// non-`Rotated`/`RotatedUnbarriered` outcome revokes the orphaned pre-mint.
 /// A barrier failure after the originating write applied returns the minted
@@ -216,13 +224,15 @@ pub async fn refresh(
   } else {
     input.user_entitled
   };
-  let narrowed = narrow_on_refresh(
+  let scopes = refresh_scopes(
     &previous,
     input.requested_scope,
     input.enabled,
     input.client_registered,
     user_term,
   )?;
+  let retained = scopes.retained;
+  let access = scopes.access;
   let minted = service
     .mint_for_rotation(
       conn,
@@ -231,7 +241,7 @@ pub async fn refresh(
         client_id: client_id.to_owned(),
         client_kind: input.client_kind,
         subject: found.record.subject.clone(),
-        scope: narrowed.render(),
+        scope: retained.render(),
         grant_id: found.record.grant_id.clone(),
         proof: input.proof.clone(),
       },
@@ -251,14 +261,14 @@ pub async fn refresh(
     new_refresh: RefreshRecord {
       client_id: client_id.to_owned(),
       grant_id: found.record.grant_id.clone(),
-      scope: narrowed.render(),
+      scope: retained.render(),
       subject: found.record.subject.clone(),
       cnf: cnf_json.clone(),
     },
     new_access: AccessRecord {
       client_id: client_id.to_owned(),
       grant_id: found.record.grant_id.clone(),
-      scope: narrowed.render(),
+      scope: access.render(),
       subject: found.record.subject.clone(),
       cnf: cnf_json,
     },
@@ -271,12 +281,9 @@ pub async fn refresh(
     .await
     .map_err(AppError::from)?
   {
-    RotateOutcome::Rotated { .. } | RotateOutcome::RotatedUnbarriered { .. } => Ok(response(
-      service,
-      &minted,
-      &narrowed,
-      &found.record.grant_id,
-    )),
+    RotateOutcome::Rotated { .. } | RotateOutcome::RotatedUnbarriered { .. } => {
+      Ok(response(service, &minted, &access, &found.record.grant_id))
+    }
     RotateOutcome::Replay { .. } => {
       discard_orphan(conn, store, service, &minted).await?;
       Err(AppError::invalid_grant("refresh token was already rotated"))

+ 172 - 2
src/protocol/token/grants/refresh_token/tests/scope.rs

@@ -1,11 +1,15 @@
 //! Scope narrowing: narrower is kept, wider is rejected.
 
 use super::{
-  TestError, TestResult, dpop_proof, input, is_invalid_scope, mint_grant, service, test_redis,
+  TestError, TestResult, dpop_proof, input, is_invalid_scope, mint_grant, now_unix, service,
+  test_redis,
 };
 use crate::protocol::{
   scope::ScopeSet,
-  token::{ClientKind, grants::refresh_token::refresh},
+  token::{
+    ClientKind,
+    grants::refresh_token::{RefreshInput, refresh},
+  },
 };
 
 /// Scope narrowing succeeds and the narrowed scope sticks.
@@ -95,3 +99,169 @@ async fn grant_refresh_scope_widen_rejected() -> TestResult {
   }
   Ok(())
 }
+
+/// Narrowing on refresh keeps the new refresh token at the presented scope
+/// (draft-ietf-oauth-v2-1-16 §4.3.3) while the access token narrows
+/// (§4.3.1): the response reports the narrowed access scope, the stored
+/// refresh record keeps the full scope, and the next rotation can still use
+/// the full scope.
+#[tokio::test]
+async fn grant_refresh_narrow_keeps_refresh_scope() -> TestResult {
+  let (_container, mut conn, store, _url) = test_redis().await?;
+  let service = service()?;
+  let client = super::unique("rt-kept-client");
+  let proof = dpop_proof(&super::unique("rt-kept-jkt"));
+  let minted = mint_grant(
+    &service,
+    &mut conn,
+    &store,
+    &client,
+    ClientKind::Public,
+    "read write",
+    proof.clone(),
+  )
+  .await?;
+  let enabled = ScopeSet::parse("read write")?;
+
+  let narrowed = refresh(
+    &mut conn,
+    &store,
+    &service,
+    &input(
+      &client,
+      ClientKind::Public,
+      &minted.refresh_token,
+      Some("read"),
+      &proof,
+      &enabled,
+    ),
+  )
+  .await?;
+  if narrowed.scope != "read" {
+    return Err(TestError::Unexpected(format!(
+      "response scope not narrowed: {}",
+      narrowed.scope
+    )));
+  }
+  let live_refresh = service
+    .find_refresh(&mut conn, &store, &narrowed.refresh_token)
+    .await?;
+  let refresh_scope = match live_refresh {
+    Some(found) => found.record.scope,
+    None => {
+      return Err(TestError::Unexpected(
+        "rotated refresh token not found".to_owned(),
+      ));
+    }
+  };
+  if refresh_scope != "read write" {
+    return Err(TestError::Unexpected(format!(
+      "refresh scope narrowed: {refresh_scope}"
+    )));
+  }
+  let live_access = service
+    .find_access(&mut conn, &store, &narrowed.access_token)
+    .await?;
+  let access_scope = match live_access {
+    Some(found) => found.record.scope,
+    None => {
+      return Err(TestError::Unexpected(
+        "rotated access token not found".to_owned(),
+      ));
+    }
+  };
+  if access_scope != "read" {
+    return Err(TestError::Unexpected(format!(
+      "access scope not narrowed: {access_scope}"
+    )));
+  }
+
+  // The retained refresh scope still authorizes the full grant on the next
+  // rotation with an omitted scope.
+  let restored = refresh(
+    &mut conn,
+    &store,
+    &service,
+    &input(
+      &client,
+      ClientKind::Public,
+      &narrowed.refresh_token,
+      None,
+      &proof,
+      &enabled,
+    ),
+  )
+  .await?;
+  if restored.scope != "read write" {
+    return Err(TestError::Unexpected(format!(
+      "retained scope lost: {}",
+      restored.scope
+    )));
+  }
+  Ok(())
+}
+
+/// A revoked entitlement still drops from the retained refresh scope, while
+/// an explicit narrower request narrows only the access token.
+#[tokio::test]
+async fn grant_refresh_narrow_drops_revoked_from_refresh_scope() -> TestResult {
+  let (_container, mut conn, store, _url) = test_redis().await?;
+  let service = service()?;
+  let client = super::unique("rt-revoked-client");
+  let proof = dpop_proof(&super::unique("rt-revoked-jkt"));
+  let minted = mint_grant(
+    &service,
+    &mut conn,
+    &store,
+    &client,
+    ClientKind::Public,
+    "read write",
+    proof.clone(),
+  )
+  .await?;
+  let enabled = ScopeSet::parse("read write")?;
+  let registered = ScopeSet::parse("read write")?;
+  // `write` was revoked from the user after issuance.
+  let entitled = ScopeSet::parse("read")?;
+
+  let narrowed = refresh(
+    &mut conn,
+    &store,
+    &service,
+    &RefreshInput {
+      client_id: &client,
+      client_kind: ClientKind::Public,
+      refresh_token: &minted.refresh_token,
+      requested_scope: Some("read"),
+      proof: &proof,
+      enabled: &enabled,
+      client_registered: &registered,
+      user_entitled: &entitled,
+      now_unix: now_unix(),
+    },
+  )
+  .await?;
+  if narrowed.scope != "read" {
+    return Err(TestError::Unexpected(format!(
+      "response scope not narrowed: {}",
+      narrowed.scope
+    )));
+  }
+  let live_refresh = service
+    .find_refresh(&mut conn, &store, &narrowed.refresh_token)
+    .await?;
+  let refresh_scope = match live_refresh {
+    Some(found) => found.record.scope,
+    None => {
+      return Err(TestError::Unexpected(
+        "rotated refresh token not found".to_owned(),
+      ));
+    }
+  };
+  if refresh_scope != "read" {
+    return Err(TestError::Unexpected(format!(
+      "revoked scope survived on refresh token: {refresh_scope}"
+    )));
+  }
+  Ok(())
+}

+ 67 - 0
src/routes.rs

@@ -1149,4 +1149,71 @@ mod router_tests {
     }
     Ok(())
   }
+
+  /// draft-ietf-oauth-v2-1-16 §1.5 + §7.14: the public listener pins a
+  /// TLS 1.2 minimum explicitly (not rustls defaults). The pinned set is
+  /// exactly TLS 1.2 + TLS 1.3, so nothing older can be negotiated.
+  #[test]
+  fn tls_version_minimum_is_tls12() -> TestResult {
+    use rustls::ProtocolVersion;
+    let versions: Vec<ProtocolVersion> = crate::protocol::mtls::TLS_PROTOCOL_VERSIONS
+      .iter()
+      .map(|supported| supported.version)
+      .collect();
+    if !versions.contains(&ProtocolVersion::TLSv1_2) {
+      return Err("pinned TLS versions must include TLS 1.2".into());
+    }
+    if !versions.contains(&ProtocolVersion::TLSv1_3) {
+      return Err("pinned TLS versions must include TLS 1.3".into());
+    }
+    for version in &versions {
+      if *version != ProtocolVersion::TLSv1_2 && *version != ProtocolVersion::TLSv1_3 {
+        return Err("no protocol version below TLS 1.2 may be enabled".into());
+      }
+    }
+    Ok(())
+  }
+
+  /// The pinned server config accepts TLS 1.3-only and TLS 1.2-only
+  /// clients, negotiating the offered version each time.
+  #[tokio::test]
+  async fn tls_version_accepts_tls12_and_tls13() -> TestResult {
+    use rustls::ProtocolVersion;
+    use rustls::pki_types::pem::PemObject as _;
+    for (pinned, expected) in [
+      (&rustls::version::TLS13, ProtocolVersion::TLSv1_3),
+      (&rustls::version::TLS12, ProtocolVersion::TLSv1_2),
+    ] {
+      let server_config =
+        build_server_config(TEST_CERT_PEM.as_bytes(), TEST_KEY_PEM.as_bytes(), None)?;
+      let chain = rustls::pki_types::CertificateDer::pem_slice_iter(TEST_CERT_PEM.as_bytes())
+        .collect::<Result<Vec<_>, _>>()?;
+      let Some(leaf) = chain.into_iter().next() else {
+        return Err("test certificate must parse".into());
+      };
+      let mut roots = rustls::RootCertStore::empty();
+      roots.add(leaf)?;
+      let client_config = rustls::ClientConfig::builder_with_protocol_versions(&[pinned])
+        .with_root_certificates(roots)
+        .with_no_client_auth();
+      let (client_io, server_io) = tokio::io::duplex(65_536);
+      let server = tokio_rustls::TlsAcceptor::from(Arc::new(server_config)).accept(server_io);
+      let connector = tokio_rustls::TlsConnector::from(Arc::new(client_config));
+      let server_name = rustls::pki_types::ServerName::try_from("localhost")?.to_owned();
+      let client = connector.connect(server_name, client_io);
+      let (server_out, client_out) = tokio::join!(server, client);
+      let server_tls = server_out.map_err(|err| format!("server handshake failed: {err:?}"))?;
+      client_out.map_err(|err| format!("client handshake failed: {err:?}"))?;
+      if server_tls.get_ref().1.protocol_version() != Some(expected) {
+        return Err(
+          format!(
+            "expected negotiated {expected:?}, got {:?}",
+            server_tls.get_ref().1.protocol_version()
+          )
+          .into(),
+        );
+      }
+    }
+    Ok(())
+  }
 }

+ 4 - 2
src/ui/consent.rs

@@ -499,11 +499,13 @@ pub async fn post_consent(
   }
 }
 
-/// `302 Found` to `location`; an invalid header fails closed to a `500`.
+/// `303 See Other` to `location` (draft-ietf-oauth-v2-1-16 §7.5.4 SHOULD:
+/// POST success redirects to a `GET`); an invalid header fails closed to a
+/// `500`.
 fn redirect(location: &str) -> Response {
   match header::HeaderValue::from_str(location) {
     Ok(value) => Response::builder()
-      .status(StatusCode::FOUND)
+      .status(StatusCode::SEE_OTHER)
       .header(header::LOCATION, value)
       .body(Body::empty())
       .unwrap_or_else(|_| AppError::server_error().into_response()),

+ 46 - 0
tests/e2e_abuse.rs

@@ -97,6 +97,10 @@ async fn e2e_redirect_fail_closed_vs_redirect_with_error() -> Result<(), TestErr
     location.contains("error=") && location.starts_with(REDIRECT_URI),
     "error must ride the validated redirect",
   )?;
+  ensure(
+    location.contains("iss=https%3A%2F%2Fauth.example.test"),
+    "error redirect must carry RFC 9207 iss",
+  )?;
 
   let (status, _, _) = world
     .get(
@@ -257,6 +261,14 @@ async fn e2e_state_csrf_echo_isolated() -> Result<(), TestError> {
     second.contains("state=bbb-csrf-2"),
     "second error must echo its own state",
   )?;
+  ensure(
+    first.contains("iss=https%3A%2F%2Fauth.example.test"),
+    "first error must carry RFC 9207 iss",
+  )?;
+  ensure(
+    second.contains("iss=https%3A%2F%2Fauth.example.test"),
+    "second error must carry RFC 9207 iss",
+  )?;
   ensure(
     !first.contains("bbb-csrf-2") && !second.contains("aaa-csrf-1"),
     "states must not cross",
@@ -264,6 +276,40 @@ async fn e2e_state_csrf_echo_isolated() -> Result<(), TestError> {
   Ok(())
 }
 
+/// A missing `response_type` rides the validated redirect as `invalid_request`
+/// with RFC 9207 `iss` (unit coverage exists; this is the e2e wire proof).
+#[tokio::test]
+async fn e2e_missing_response_type_redirects_with_iss() -> Result<(), TestError> {
+  let world = setup().await?;
+  let (status, headers, _) = world
+    .get(
+      &format!(
+        "/authorize?client_id={PUBLIC_ID}&redirect_uri={}&state=missing-rt-state",
+        urlencoding(REDIRECT_URI)
+      ),
+      &[],
+    )
+    .await?;
+  ensure(
+    status == reqwest::StatusCode::FOUND,
+    "missing response_type must 302",
+  )?;
+  let location = header_str(&headers, "location")?;
+  ensure(
+    location.starts_with(REDIRECT_URI),
+    "error must ride the validated redirect",
+  )?;
+  ensure(
+    location.contains("error=invalid_request"),
+    "missing response_type must be invalid_request",
+  )?;
+  ensure(
+    location.contains("iss=https%3A%2F%2Fauth.example.test"),
+    "error redirect must carry RFC 9207 iss",
+  )?;
+  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]

+ 4 - 4
tests/e2e_flow.rs

@@ -285,8 +285,8 @@ async fn e2e_flow_browser_authorize_login_consent_token() -> Result<(), TestErro
   let (status, headers, _) =
     post_consent(&browser, &session, &bound, &consent_csrf, "approve").await?;
   ensure(
-    status == reqwest::StatusCode::FOUND,
-    format!("consent approval must 302 to the redirect: {status}"),
+    status == reqwest::StatusCode::SEE_OTHER,
+    format!("consent approval must 303 to the redirect: {status}"),
   )?;
   let location = location_of(&headers)?;
   ensure(
@@ -437,8 +437,8 @@ async fn e2e_flow_consent_deny_redirects_access_denied() -> Result<(), TestError
   let (status, headers, _) =
     post_consent(&browser, &session, &bound, &consent_csrf, "deny").await?;
   ensure(
-    status == reqwest::StatusCode::FOUND,
-    format!("consent denial must 302 to the redirect: {status}"),
+    status == reqwest::StatusCode::SEE_OTHER,
+    format!("consent denial must 303 to the redirect: {status}"),
   )?;
   let location = location_of(&headers)?;
   ensure(

+ 59 - 6
tests/scope_engine.rs

@@ -16,7 +16,7 @@ use d_oauth::{
   error::{AppError, OAuthErrorCode},
   protocol::scope::{
     MAX_SCOPE_COUNT, MAX_SCOPE_NAME_LEN, MAX_SCOPE_TOTAL_LEN, ScopeInput, ScopeSet,
-    effective_scope, narrow_on_refresh,
+    effective_scope, narrow_on_refresh, refresh_scopes,
   },
 };
 use proptest::prelude::*;
@@ -178,12 +178,65 @@ fn scope_refresh_narrows_only_and_rechecks_catalog() -> TestResult {
     &enabled,
     &enabled
   )));
-  assert!(is_invalid_scope(narrow_on_refresh(
-    &previous,
+  assert_eq!(
+    narrow_on_refresh(&previous, None, &names(&["read"]), &enabled, &enabled)?.render(),
+    "read"
+  );
+  Ok(())
+}
+
+#[test]
+fn refresh_scopes_retained_vs_access() -> TestResult {
+  let full = names(&["read", "write"]);
+  let read = names(&["read"]);
+  // Explicit narrow: retained keeps the re-checked previous intersection,
+  // access narrows to the candidate.
+  let split = refresh_scopes(
+    &names(&["read", "write"]),
+    Some("read"),
+    &full,
+    &full,
+    &full,
+  )?;
+  assert_eq!(split.retained.render(), "read write");
+  assert_eq!(split.access.render(), "read");
+  // Omitted scope: retained and access agree.
+  let kept = refresh_scopes(&names(&["read", "write"]), None, &full, &full, &full)?;
+  assert_eq!(kept.retained.render(), "read write");
+  assert_eq!(kept.access.render(), "read write");
+  assert_eq!(kept.retained, kept.access);
+  // Revoked entries drop silently from retained (catalog, registration, and
+  // entitlement terms each narrow); an explicit narrow of the survivor still
+  // applies only to access.
+  let catalog_revoked = refresh_scopes(&names(&["read", "write"]), None, &read, &full, &full)?;
+  assert_eq!(catalog_revoked.retained.render(), "read");
+  assert_eq!(catalog_revoked.access.render(), "read");
+  let entitled_revoked = refresh_scopes(
+    &names(&["read", "write"]),
+    Some("read"),
+    &full,
+    &full,
+    &read,
+  )?;
+  assert_eq!(entitled_revoked.retained.render(), "read");
+  assert_eq!(entitled_revoked.access.render(), "read");
+  // A fully revoked grant retains (and grants) nothing rather than failing.
+  let emptied = refresh_scopes(
+    &names(&["read", "write"]),
     None,
-    &names(&["read"]),
-    &enabled,
-    &enabled
+    &full,
+    &full,
+    &ScopeSet::empty(),
+  )?;
+  assert!(emptied.retained.is_empty());
+  assert!(emptied.access.is_empty());
+  // Widening via an explicit request is still `invalid_scope`.
+  assert!(is_invalid_scope(refresh_scopes(
+    &read,
+    Some("read write"),
+    &full,
+    &full,
+    &full
   )));
   Ok(())
 }