Efren Yevale Varela 2 тижнів тому
батько
коміт
006cd1ffdb
6 змінених файлів з 104 додано та 8 видалено
  1. 1 0
      AGENTS.md
  2. 90 0
      docs/CHANGELOG.md
  3. 6 3
      docs/OAUTH.md
  4. 1 1
      docs/REDIS.md
  5. 5 3
      docs/RS-CONTRACT.md
  6. 1 1
      docs/SECURITY.md

+ 1 - 0
AGENTS.md

@@ -72,6 +72,7 @@ Before finishing any change, confirm:
 - [ ] `private_key_jwt` is treated as authentication only, never as a binding.
 - [ ] Master keys (pepper, TOTP/PII keys, and retained previous keys) carry at least 32 bytes; shorter material is rejected at load (`WeakKeyMaterial`).
 - [ ] A disabled subject is dead everywhere: introspection reports inactive, refresh rejects with `invalid_grant` before minting, and refresh re-checks current entitlements; `user disable` purges the subject's Redis state (sessions and their meta, codes, devices with their sidecars and `user_code` mappings, grants and their token sidecars, standalone tokens and their sidecars, lockout) before flipping `users.disabled`, failing closed on a Redis error.
+- [ ] A disabled or deleted resource identifier is dead at mint time: refresh and device grants re-validate the stored audience against the current enabled catalog (`invalid_target`; refresh stays live, device code is already consumed), authorization-code redemption validates a presented audience before consuming the code, and `/token`, `/introspect`, and `/device_authorization` authenticate before the catalog lookup.
 - [ ] The change does not introduce a wildcard scope, redirect URI, or CORS
       origin.
 

+ 90 - 0
docs/CHANGELOG.md

@@ -10,6 +10,45 @@ While the version stays below `1.0.0`, a minor bump may carry a breaking change.
 
 ### Added
 
+- Client ID metadata documents (CIMD): with `cimd_enabled` on, an `https://`
+  `client_id` resolves to a fetched, strictly validated metadata document with
+  no registration row and no initial-access token. The fetch is `https` only,
+  follows no redirects, pins DNS, disables the proxy, rejects
+  special-use/private/loopback targets, and caps the body (`cimd_max_bytes`)
+  and duration (`cimd_fetch_timeout_secs`); only `none` and `private_key_jwt`
+  are accepted, with inline `jwks` only and no wildcard redirect URIs.
+  Documents are cached under `{state}` for 15 minutes, with a 30-second
+  origin-failure negative cache and a 2-second invalid-document marker.
+- RFC 8707 resource indicators: a repeated `resource` parameter on `/authorize`,
+  `/par`, `/token`, `/device_authorization`, and `/introspect` (the last as a
+  local RFC 7662 extension) binds the token audience to the enabled
+  `resource_servers` catalog. The catalog is exact-match only (no wildcard,
+  prefix, or normalization) and is managed with
+  `resource-server create|list|enable|disable|delete` or `config sync`; the
+  feature is gated by `resource_indicators_enabled`. The AS advertises
+  `resource_indicators_supported` (a local RFC 8414 extension) only while the
+  flag is on. The audience is pinned into the pending/PAR/code/device records
+  and must be re-presented exactly at redemption; duplicates are deduplicated
+  and at most 32 raw values are accepted per request.
+- JWT access tokens (the RFC 9068 `at+jwt` profile), opt-in twice: the
+  deployment switch `jwt_access_tokens_enabled` and a per-client
+  `access_token_format = jwt` set only by the operator with
+  `client set-access-token-format --client-id <id> --format opaque|jwt`.
+  Refresh tokens and the grant chain stay opaque and hashed. While the switch
+  is on the AS advertises `jwks_uri` at `/jwks.json` and
+  `access_token_signing_alg_values_supported`, serves an RFC 7517 key set with
+  retained verify-only keys, and keeps a Redis `jwt:<jti-hash>` allowlist
+  marker so AS-side revocation, introspection, and refresh observe a kill; a
+  resource server that verifies locally cannot see that marker before `exp`
+  (see [SECURITY.md](SECURITY.md)). Signing is `ES256` or `EdDSA` over raw
+  32-byte keys with `kid` rotation.
+- RFC 9728 `protected_resources` AS metadata, gated by
+  `protected_resources_enabled` and `protected_resource_ids`, plus the
+  resource-server contract in [RS-CONTRACT.md](RS-CONTRACT.md) and the
+  onboarding runbook in
+  [OPERATIONS.md](OPERATIONS.md#resource-server-onboarding-rfc-9728).
+- `allow_localhost_redirect` opts the RFC 8252 loopback exception in for the
+  host `localhost`; `127.0.0.1` and `[::1]` stay loopback regardless.
 - Container release publishing on GHCR. A `v*` git tag builds and pushes the
   signed multi-arch image under semver tags (`X.Y.Z`, `X.Y`, `X`, `latest`; a
   pre-release gets only `X.Y.Z`) and creates a GitHub Release. The image path is
@@ -90,6 +129,21 @@ While the version stays below `1.0.0`, a minor bump may carry a breaking change.
 
 ### Changed
 
+- Public clients are Bearer by default. DPoP is mandatory only when the client
+  carries `dpop_bound_access_tokens: true`; a DPoP-bound public client still
+  presents a fresh proof at every gate. Operators that require DPoP for public
+  clients must register them with the flag, because unbound public clients
+  become bearer-capable on upgrade.
+- Access tokens remain opaque unless an operator opts a client into
+  `access_token_format = jwt`; dynamic registration always stores `opaque`, and
+  a `jwt` client fails closed with `503` while `jwt_access_tokens_enabled` is
+  off instead of falling back to opaque.
+- Unit and integration tests now use the shared Postgres and Redis from
+  `compose.test.yaml` instead of starting a container per test. Only the
+  lifecycle, topology, durability, and dedicated-node suites keep
+  `testcontainers`. The `fuzz/` workspace stays outside the root gates and is
+  built by CI; build it locally when a change reshapes a type or function a
+  fuzz target constructs.
 - The key ring now holds three independent chains (token pepper, TOTP
   encryption key, and PII field-encryption key), each with an optional retained
   previous key. `pii_dual_write` (default `false`, encrypt-only) turns on
@@ -123,6 +177,21 @@ While the version stays below `1.0.0`, a minor bump may carry a breaking change.
 
 ### Security
 
+- Disabling or deleting a `resource_servers` row is a kill switch for that
+  audience. Refresh and device mints re-validate the stored audience against the
+  current enabled catalog, so a disabled/deleted member (or the indicator flag
+  turned off) fails `invalid_target` instead of minting: a refresh token stays
+  live for a retry, a device code is already consumed by the poll, and an
+  authorization code is validated before redemption so it survives a bad
+  `resource`. Re-enabling the identifier lets the client re-authorize or retry
+  the live refresh token.
+- `/token`, `/introspect`, and `/device_authorization` authenticate the client
+  before validating `resource` values, so an unauthenticated caller observes
+  `invalid_client` rather than a catalog error.
+- Resource identifiers are stricter than RFC 8707 §2: an absolute `https` URI
+  with a host and no userinfo, query, or fragment, exact-match against the
+  enabled catalog; values containing an ASCII control character are
+  `invalid_target` before any catalog lookup.
 - A PII or TOTP ciphertext that fails to open (tamper, truncation, or a missing
   key id) is fail-closed: the request returns `503 temporarily_unavailable` and
   emits a `pii.decrypt_failure` audit event carrying only the request id, never
@@ -162,6 +231,27 @@ While the version stays below `1.0.0`, a minor bump may carry a breaking change.
 
 ### Fixed
 
+- A fresh-fetch CIMD validation failure is cached with its error class
+  (`redirect` or `metadata`), so a retry within the 2-second window replays the
+  original `invalid_redirect_uri` or `invalid_client_metadata` instead of a
+  generic failure; an unknown or legacy marker fails closed as the metadata
+  class.
+- Authorization-code redemption validates the presented audience before
+  consuming the code, so a bad or disabled `resource` fails `invalid_target`
+  without burning a live code; refresh and device mints also re-check the stored
+  audience against the current catalog before minting.
+- CIMD resolution blocks the document fetch for an existing inactive
+  `https://` client row, so deactivation cannot be bypassed, and an inline
+  `jwks` on a `none` document is ignored.
+- CIMD caches the parsed document instead of the raw body, caches origin-level
+  fetch failures negatively for 30 seconds, short-caches fresh-fetch validation
+  failures, and elects one fetcher per URL; a Redis read or lock failure stays a
+  fail-closed `503`.
+- Introspection reports `token_type` (`DPoP` for a `cnf.jkt` token, `Bearer` for
+  unbound or mTLS-only tokens, omitted for refresh) and filters with the
+  validated audience set.
+- A JWT access token always carries a non-empty audience; an unbound `jwt`
+  client fails `invalid_target` rather than minting `aud: []`.
 - `GET /.well-known/oauth-authorization-server` reads the enabled scope catalog
   from Postgres on every request. A scope added, enabled, or disabled via the
   CLI now appears in `scopes_supported` immediately on every replica, instead of

+ 6 - 3
docs/OAUTH.md

@@ -209,9 +209,12 @@ fetch; a document changed at its origin may therefore be served from cache for
 up to the 15-minute TTL. An origin-level fetch failure (network/TLS/non-2xx,
 timeout, or non-JSON body) is negatively cached for 30 seconds under
 `cimd:neg:<sha256(url)>`. A fresh-fetch validation failure is short-cached for
-2 seconds under `cimd:invalid:<sha256(url)>` (a single-flight completion
-marker), so a corrected document is seen within 2 seconds and waiters do not
-re-fetch a deterministically invalid one; a validation failure on an
+2 seconds under `cimd:invalid:<sha256(url)>` as a single-flight completion
+marker tagged with the failure class (`redirect` or `metadata`), so a retry
+replays the original client-visible error (`invalid_redirect_uri` or
+`invalid_client_metadata`) and a corrected document is seen within 2 seconds;
+waiters do not re-fetch a deterministically invalid one, and a legacy or
+corrupt marker fails closed as the metadata class. A validation failure on an
 already-cached document writes no marker. If the cache write after a successful
 validation fails, the validated document is still served, uncached. Concurrent
 misses for the same URL elect one fetcher through a `cimd:lock:<sha256(url)>`

+ 1 - 1
docs/REDIS.md

@@ -46,7 +46,7 @@ differ.
 | `oauth:{state}:jwt:<jti-hash>` | JWT access-token kill marker (allowlist: a signed token is valid only while this hash is live). Carries `grant_id`, `client_id`, and the raw `subject` (email/user-id; empty for client-credentials), matching the `purge_subject` `subject` decoder. | `ttl.access_secs` (default 300s) |
 | `oauth:{state}:cimd:<sha256>` | Cached client-id-metadata document (keyed by a hash of the URL, never the raw URL). | 15 minutes |
 | `oauth:{state}:cimd:neg:<sha256>` | Negative cache for an origin-level CIMD fetch failure (network/TLS/non-2xx, timeout, non-JSON). | 30 seconds |
-| `oauth:{state}:cimd:invalid:<sha256>` | Short completion marker for a fresh-fetch CIMD validation failure, so waiters do not re-fetch a deterministically invalid document. | 2 seconds |
+| `oauth:{state}:cimd:invalid:<sha256>` | Short completion marker for a fresh-fetch CIMD validation failure, tagged `redirect` or `metadata` so a retry replays the original error class; waiters do not re-fetch a deterministically invalid document. | 2 seconds |
 | `oauth:{state}:cimd:lock:<sha256>` | Single-flight fetcher election for one uncached CIMD URL; a random token is released with a compare-and-delete Lua script. | `cimd_fetch_timeout_secs` + 1 s (2–11 s) |
 | `oauth:{rl}:ratelimit:<bucket-hash>` | Fixed-window rate-limit counter. | `rate_limit.window` (60s) |
 

+ 5 - 3
docs/RS-CONTRACT.md

@@ -219,9 +219,11 @@ introspection request as a LOCAL EXTENSION to RFC 7662, which defines no
 `resource` parameter. When present, the token's `aud` must include every
 requested member (set inclusion, so a token pinned to a superset stays active);
 a token that does not include every requested member returns the inactive shape.
-A requested value that is not an enabled catalog row (or a request while
-resource indicators are disabled) is `invalid_target` before the lookup, matching
-the other endpoints. An RS uses this to ask whether a token is bound to its own
+The caller is authenticated before the `resource` values are validated, so an
+unauthenticated request fails `invalid_client` first. A requested value that is
+not an enabled catalog row (or a request while resource indicators are
+disabled) is then `invalid_target` before the token lookup, matching the other
+endpoints. An RS uses this to ask whether a token is bound to its own
 resource identifier.
 
 ## Validating the metadata document

+ 1 - 1
docs/SECURITY.md

@@ -115,7 +115,7 @@ poll; an authorization code is not consumed because catalog validation now runs
 before the redemption. The client must re-authorize (or retry the live refresh
 token) once the identifier is enabled again.
 
-Last reviewed: 2026-10-21. File-mounted DB/Redis passwords, a mutable `:latest`
+Last reviewed: 2026-10-22. File-mounted DB/Redis passwords, a mutable `:latest`
 image reference, NetworkPolicy, `automountServiceAccountToken: false`, scheduled
 fuzzing, and the CIMD outbound fetch surface are implemented in the shipped
 manifests and workflows.