authplane-sdk 0.2.0__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. authplane_sdk-0.3.0/CHANGELOG.md +52 -0
  2. {authplane_sdk-0.2.0 → authplane_sdk-0.3.0}/PKG-INFO +1 -1
  3. {authplane_sdk-0.2.0 → authplane_sdk-0.3.0}/authplane/__init__.py +4 -0
  4. authplane_sdk-0.3.0/authplane/_dpop_adapter.py +186 -0
  5. authplane_sdk-0.3.0/authplane/cache.py +159 -0
  6. {authplane_sdk-0.2.0 → authplane_sdk-0.3.0}/authplane/client.py +41 -1
  7. {authplane_sdk-0.2.0 → authplane_sdk-0.3.0}/authplane/errors.py +24 -2
  8. {authplane_sdk-0.2.0 → authplane_sdk-0.3.0}/authplane/oauth/parsing.py +9 -4
  9. {authplane_sdk-0.2.0 → authplane_sdk-0.3.0}/authplane/oauth/types.py +6 -1
  10. {authplane_sdk-0.2.0 → authplane_sdk-0.3.0}/authplane/verifier/claims.py +36 -2
  11. authplane_sdk-0.2.0/CHANGELOG.md +0 -32
  12. authplane_sdk-0.2.0/authplane/cache.py +0 -78
  13. {authplane_sdk-0.2.0 → authplane_sdk-0.3.0}/.gitignore +0 -0
  14. {authplane_sdk-0.2.0 → authplane_sdk-0.3.0}/LICENSE +0 -0
  15. {authplane_sdk-0.2.0 → authplane_sdk-0.3.0}/authplane/README.md +0 -0
  16. {authplane_sdk-0.2.0 → authplane_sdk-0.3.0}/authplane/auth_provider.py +0 -0
  17. {authplane_sdk-0.2.0 → authplane_sdk-0.3.0}/authplane/circuit_breaker.py +0 -0
  18. {authplane_sdk-0.2.0 → authplane_sdk-0.3.0}/authplane/credentials.py +0 -0
  19. {authplane_sdk-0.2.0 → authplane_sdk-0.3.0}/authplane/docs/user-guide.md +0 -0
  20. {authplane_sdk-0.2.0 → authplane_sdk-0.3.0}/authplane/dpop.py +0 -0
  21. {authplane_sdk-0.2.0 → authplane_sdk-0.3.0}/authplane/dpop_verification.py +0 -0
  22. {authplane_sdk-0.2.0 → authplane_sdk-0.3.0}/authplane/internal/__init__.py +0 -0
  23. {authplane_sdk-0.2.0 → authplane_sdk-0.3.0}/authplane/internal/cache_headers.py +0 -0
  24. {authplane_sdk-0.2.0 → authplane_sdk-0.3.0}/authplane/internal/document_cache.py +0 -0
  25. {authplane_sdk-0.2.0 → authplane_sdk-0.3.0}/authplane/internal/document_fetcher.py +0 -0
  26. {authplane_sdk-0.2.0 → authplane_sdk-0.3.0}/authplane/internal/fetch_result.py +0 -0
  27. {authplane_sdk-0.2.0 → authplane_sdk-0.3.0}/authplane/internal/jwt.py +0 -0
  28. {authplane_sdk-0.2.0 → authplane_sdk-0.3.0}/authplane/internal/metadata.py +0 -0
  29. {authplane_sdk-0.2.0 → authplane_sdk-0.3.0}/authplane/internal/urls.py +0 -0
  30. {authplane_sdk-0.2.0 → authplane_sdk-0.3.0}/authplane/net/__init__.py +0 -0
  31. {authplane_sdk-0.2.0 → authplane_sdk-0.3.0}/authplane/net/fetch_settings.py +0 -0
  32. {authplane_sdk-0.2.0 → authplane_sdk-0.3.0}/authplane/net/http.py +0 -0
  33. {authplane_sdk-0.2.0 → authplane_sdk-0.3.0}/authplane/net/ip_validation.py +0 -0
  34. {authplane_sdk-0.2.0 → authplane_sdk-0.3.0}/authplane/net/ssrf.py +0 -0
  35. {authplane_sdk-0.2.0 → authplane_sdk-0.3.0}/authplane/oauth/__init__.py +0 -0
  36. {authplane_sdk-0.2.0 → authplane_sdk-0.3.0}/authplane/oauth/client_credentials.py +0 -0
  37. {authplane_sdk-0.2.0 → authplane_sdk-0.3.0}/authplane/oauth/introspection.py +0 -0
  38. {authplane_sdk-0.2.0 → authplane_sdk-0.3.0}/authplane/oauth/prm.py +0 -0
  39. {authplane_sdk-0.2.0 → authplane_sdk-0.3.0}/authplane/oauth/revocation.py +0 -0
  40. {authplane_sdk-0.2.0 → authplane_sdk-0.3.0}/authplane/oauth/token_exchange.py +0 -0
  41. {authplane_sdk-0.2.0 → authplane_sdk-0.3.0}/authplane/py.typed +0 -0
  42. {authplane_sdk-0.2.0 → authplane_sdk-0.3.0}/authplane/verifier/__init__.py +0 -0
  43. {authplane_sdk-0.2.0 → authplane_sdk-0.3.0}/authplane/verifier/verifier.py +0 -0
  44. {authplane_sdk-0.2.0 → authplane_sdk-0.3.0}/pyproject.toml +0 -0
@@ -0,0 +1,52 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.3.0] - 2026-07-21
9
+
10
+ ### Added
11
+ - `TokenCache` is now bounded by a configurable `max_entries` cap (default `10_000`, exposed as `TokenCache.DEFAULT_MAX_ENTRIES` and a read-only `cache.max_entries` property) and evicts the least-recently-used entry on overflow; both `get` and `set` bump the touched key to MRU. Plumbed through `AuthplaneClient.create(cache_max_entries=...)`. Token-exchange cache keys are high-cardinality (the subject token is part of the key), so the cap keeps long-lived clients bounded.
12
+ - `VerifiedClaims.require_scopes(scopes: Iterable[str])` — plural AND-style helper that requires all listed scopes. Empty input is a no-op; on failure the raised `InsufficientScopeError` carries the full requested tuple on `required_scopes` and names every missing scope plus the token's available scopes in the message.
13
+ - `authplane-mcp`: new public surface — `AuthplaneRequestContextMiddleware`, `get_current_request()`, `install_request_context(mcp)` — an ASGI middleware that publishes the active request on a `ContextVar` so the verifier can build a `DPoPRequestContext`.
14
+ - `authplane-fastmcp`, `authplane-mcp`: `AuthplaneTokenVerifier` caches the in-flight verify task per request (keyed by access token on `request.state`), so a repeat `verify_token` within the same HTTP request awaits the same task rather than re-entering the inbound DPoP replay store. Cross-request replay protection is unaffected (distinct requests get distinct caches).
15
+
16
+ ### Fixed
17
+ - `TokenCache.set` now distinguishes a missing `expires_in` from `expires_in: 0`. Both previously collapsed into `default_ttl`. The store now applies `default_ttl` only when `expires_in` is absent (`None`), treats `expires_in: 0` (RFC 6749 §5.1) as already expired and refuses to store it, and honors `n` seconds when positive. `parse_token_response` carries the missing-vs-zero distinction through the parser.
18
+ - `authplane-fastmcp`, `authplane-mcp`: inbound DPoP cardinality (RFC 9449 §4.3 #1) is now enforced. `read_dpop_header` reads the full multi-value `DPoP` header list (and splits on `,` defensively to catch proxies that pre-join duplicate headers) and raises `DPoPMultipleProofsError` when more than one non-empty proof is present. `www_authenticate` maps this error to `error="invalid_dpop_proof"` per RFC 9449 §7.1.
19
+ - `authplane-fastmcp`, `authplane-mcp`: inbound DPoP proof-of-possession is now enforced end-to-end. `AuthplaneTokenVerifier.verify_token` forwards a `DPoPRequestContext` (method + reconstructed `htu` + proof header) to `AuthplaneResource.verify`, so `inbound_dpop=InboundDPoPOptions(required=True)` checks the proof on every request. The `htu` origin is always the operator-configured resource URI, never the inbound `Host` / `X-Forwarded-Proto` headers. Operators using `required=True` with `authplane-mcp` should call `install_request_context(mcp)` after constructing `FastMCP` so the verifier can read the per-request context; if it is not installed the request fails closed (401) rather than skipping the check.
20
+ - `authplane-fastmcp`, `authplane-mcp`: DPoP `htu` reconstruction reads `scope["raw_path"]` to preserve percent-encoding (e.g. `%2F`) on the wire under ASGI, falling back to `request.url.path` when the server omits `raw_path`.
21
+ - `authplane-mcp`: `install_request_context(mcp)` is idempotent — repeated calls on the same `FastMCP` instance are no-ops.
22
+ - `require_scope` (singular) now renders an empty token scope set as `(none)` instead of `[]`, matching the plural helper's output. Logging pipelines keyed on the old `Token has scopes: []` string should be updated.
23
+ - Docs and demos now run adapter setup, the async server entry point (`run_streamable_http_async` / `run_async`), and `aclose()` in a single `asyncio.run(main())`, keeping the client's locks, HTTP pool, and background JWKS/metadata refresh tasks on one event loop.
24
+
25
+ ### Changed
26
+ - **BREAKING (pre-1.0)** `TokenResponse.expires_in` and `CacheEntry.expires_in` are now typed `int | None` (was `int`), so a token response that omits `expires_in` is `None` rather than `0`. **Migration:** typed downstream callers reading `resp.expires_in` directly (arithmetic, comparison, formatting) must guard for `None`; treat `None` as "apply your default" and `0` as "already expired".
27
+
28
+ ## [0.2.0] - 2026-05-20
29
+
30
+ ### Security
31
+ - `www_authenticate()` now sanitizes CR, LF, double-quote, and backslash from every value it interpolates (`realm`, `error_description`, `scope`, `resource_metadata`), closing a header-injection path through attacker-influenced error messages.
32
+
33
+ ### Fixed
34
+ - `DPoPNotSupportedError` now emits `WWW-Authenticate: Bearer` instead of `DPoP`. The resource is bearer-only by configuration, so advertising the DPoP scheme misled clients into retries that would fail the same way.
35
+ - `http_status(CircuitOpenError)` now returns `503` (was `500`). The circuit breaker is structurally identical to other temporary-AS-unavailability errors and should be retryable, not surfaced as an internal error.
36
+ - Outbound `Host` header now preserves non-default ports and brackets IPv6 hostnames, fixing DPoP `htu` validation against authservers on non-standard ports.
37
+ - Packaging issues discovered after the first release.
38
+ - Documentation links and demo references.
39
+ - `authplane-fastmcp` dependency range now correctly requires `fastmcp>=3.2,<4` (was `>=2.0`, which could resolve to a version the adapter can't import).
40
+
41
+ ### Added
42
+ - `www_authenticate()` accepts `resource_metadata_url=` (RFC 9728 §5.1) and `scope=` (RFC 6750 §3) keyword arguments. When the caller does not pass `scope=`, the helper auto-populates it from `InsufficientScopeError.required_scopes`.
43
+ - `InsufficientScopeError` now carries a structured `required_scopes` attribute, populated automatically by `VerifiedClaims.require_scope()` so the wire challenge can advertise the missing scope.
44
+ - `response_headers_for(error, *, realm, resource_metadata_url, scope)` — bundled helper returning `(status, {"WWW-Authenticate": challenge})` in one call.
45
+ - Both adapter verifiers (`authplane-mcp`, `authplane-fastmcp`) now emit a `logging.DEBUG` event `authplane.token_verification_failed` with structured `error_class` and `error` fields before returning `None`. Wire behaviour is unchanged; operators can now distinguish expired tokens from JWKS outages and DPoP replays in logs.
46
+
47
+ ### Changed
48
+ - CI and release workflow improvements from first-release learnings.
49
+
50
+ ## [0.1.0] - 2026-05-11
51
+
52
+ - Initial release.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: authplane-sdk
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Authplane SDK for Python — OAuth 2.1 JWT validation and token operations for protected resources
5
5
  Project-URL: Homepage, https://github.com/AuthPlane/python-sdk
6
6
  Project-URL: Repository, https://github.com/AuthPlane/python-sdk
@@ -11,6 +11,7 @@ except _PackageNotFoundError: # pragma: no cover - source tree without an insta
11
11
  # Client
12
12
  # Authentication
13
13
  from .auth_provider import AuthProvider, ClientCredentialsProvider
14
+ from .cache import TokenCache
14
15
  from .client import AuthplaneClient
15
16
  from .credentials import ASCredentials
16
17
  from .dpop import (
@@ -34,6 +35,7 @@ from .errors import (
34
35
  ConsentRequiredError,
35
36
  DPoPBindingMismatchError,
36
37
  DPoPError,
38
+ DPoPMultipleProofsError,
37
39
  DPoPNotSupportedError,
38
40
  DPoPProofMissingError,
39
41
  DPoPReplayDetectedError,
@@ -83,6 +85,7 @@ __all__ = [
83
85
  "DPoPBindingMismatchError",
84
86
  "DPoPError",
85
87
  "DPoPKeyMaterial",
88
+ "DPoPMultipleProofsError",
86
89
  "DPoPNonceStore",
87
90
  "DPoPNotSupportedError",
88
91
  "DPoPProofMissingError",
@@ -109,6 +112,7 @@ __all__ = [
109
112
  "ProtocolError",
110
113
  "RevocationChecker",
111
114
  "ServerError",
115
+ "TokenCache",
112
116
  "TokenExpiredError",
113
117
  "TokenMissingError",
114
118
  "TokenRevokedError",
@@ -0,0 +1,186 @@
1
+ """Shared DPoP-context plumbing for the MCP and FastMCP adapters.
2
+
3
+ The ``authplane-mcp`` and ``authplane-fastmcp`` packages both bridge
4
+ ``AuthplaneResource.verify`` to a Starlette-based ``TokenVerifier``.
5
+ They each need the same four pieces of glue:
6
+
7
+ * a concrete ``DPoPRequestContext`` shape built from the active request,
8
+ * a case-insensitive ``DPoP`` header reader,
9
+ * a path reader that preserves the on-wire percent-encoding (so
10
+ ``htu`` binds identically against the TS sibling), and
11
+ * a per-request verify-task cache anchored on ``request.state`` so
12
+ repeated ``verify_token`` invocations within one HTTP request do
13
+ not re-enter the inbound DPoP replay store.
14
+
15
+ These live here so the two adapters do not drift. The module is
16
+ underscore-prefixed and intentionally not re-exported from
17
+ ``authplane.__init__``; nothing outside the adapter packages should
18
+ import from it.
19
+
20
+ The core ``authplane-sdk`` wheel does *not* take a runtime dependency
21
+ on Starlette. The helpers below duck-type the few attributes they
22
+ need; the ``_RequestLike`` Protocol pins that contract structurally
23
+ so the adapter packages can pass ``starlette.requests.Request`` and
24
+ type-check without forcing Starlette into the core's import graph.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ from typing import TYPE_CHECKING, Protocol
30
+
31
+ from .errors import DPoPMultipleProofsError
32
+
33
+ if TYPE_CHECKING:
34
+ import asyncio
35
+
36
+ from .verifier import VerifiedClaims
37
+
38
+
39
+ __all__ = [
40
+ "BuiltDPoPRequestContext",
41
+ "VerifyTaskCache",
42
+ "get_or_create_verify_cache",
43
+ "raw_request_path",
44
+ "read_dpop_header",
45
+ ]
46
+
47
+
48
+ REQ_STATE_KEY = "_authplane_verify_tasks"
49
+ """Attribute name on ``request.state`` that holds the per-request verify-task cache.
50
+
51
+ Stringly-typed on purpose: ``request.state`` is shared per ASGI *scope*
52
+ (not per ``Request`` instance), so a ``WeakKeyDictionary[Request, ...]``
53
+ would split the cache across middleware-built and handler-built
54
+ ``Request`` objects pointing at the same scope.
55
+ """
56
+
57
+
58
+ VerifyTaskCache = dict[str, "asyncio.Task[VerifiedClaims]"]
59
+
60
+
61
+ class _HeadersLike(Protocol):
62
+ """Minimal slice of ``starlette.datastructures.Headers``."""
63
+
64
+ def get(self, key: str, default: str | None = ...) -> str | None: ...
65
+
66
+ def getlist(self, key: str) -> list[str]: ...
67
+
68
+
69
+ class _URLLike(Protocol):
70
+ """Minimal slice of ``starlette.datastructures.URL``."""
71
+
72
+ @property
73
+ def path(self) -> str: ...
74
+
75
+
76
+ class _RequestLike(Protocol):
77
+ """Structural subset of ``starlette.requests.Request`` consumed here.
78
+
79
+ Defined as a Protocol so this module does not have to import
80
+ Starlette — keeping it out of the core SDK's dependency graph.
81
+ The adapters call these helpers with the real
82
+ ``starlette.requests.Request`` and it satisfies the protocol
83
+ structurally.
84
+ """
85
+
86
+ @property
87
+ def headers(self) -> _HeadersLike: ...
88
+
89
+ @property
90
+ def scope(self) -> dict[str, object]: ...
91
+
92
+ @property
93
+ def state(self) -> object: ...
94
+
95
+ @property
96
+ def url(self) -> _URLLike: ...
97
+
98
+
99
+ class BuiltDPoPRequestContext:
100
+ """Concrete ``DPoPRequestContext`` built from the active HTTP request.
101
+
102
+ Structural conformance to ``authplane.DPoPRequestContext`` is all
103
+ the core verifier checks. ``__slots__`` keeps the per-request
104
+ construction cost negligible.
105
+ """
106
+
107
+ __slots__ = ("method", "proof", "url")
108
+
109
+ def __init__(self, method: str, url: str, proof: str | None) -> None:
110
+ self.method = method
111
+ self.url = url
112
+ self.proof = proof
113
+
114
+
115
+ def read_dpop_header(request: _RequestLike) -> str | None:
116
+ """Read the ``DPoP`` request header, enforcing RFC 9449 §4.3 #1.
117
+
118
+ Returns the single proof JWT when exactly one non-empty ``DPoP``
119
+ header value is present, or ``None`` when no ``DPoP`` header is
120
+ present. Raises :class:`DPoPMultipleProofsError` when the request
121
+ carries more than one ``DPoP`` header value.
122
+
123
+ Two on-wire shapes are rejected:
124
+
125
+ 1. Multiple ``DPoP`` headers on the request (``headers.getlist``
126
+ returns ≥ 2 non-empty entries).
127
+ 2. A single ``DPoP`` header value pre-joined with ``,`` by an
128
+ upstream proxy or framework — RFC 9110 §5.3 permits combining
129
+ repeated headers this way. JWS compact serialization never
130
+ contains a literal comma, so split-on-comma is sound.
131
+
132
+ Trimming and empty-piece filtering mirror the cross-language
133
+ cardinality boundary so a request carrying ``"DPoP: "`` (whitespace
134
+ only) is treated as header-absent rather than as one value.
135
+ """
136
+ raw_values = request.headers.getlist("dpop")
137
+ filtered: list[str] = []
138
+ for raw in raw_values:
139
+ trimmed = raw.strip()
140
+ if not trimmed:
141
+ continue
142
+ # ``split(",", 2)`` caps the allocation on an attacker-controlled
143
+ # header: we only need 0 / 1 / ≥ 2 non-blank pieces, and a third
144
+ # entry already trips the cardinality guard below.
145
+ for part in trimmed.split(",", 2):
146
+ piece = part.strip()
147
+ if piece:
148
+ filtered.append(piece)
149
+ if len(filtered) > 1:
150
+ raise DPoPMultipleProofsError(
151
+ f"request carries {len(filtered)} DPoP proofs (RFC 9449 §4.3 forbids it)"
152
+ )
153
+ return filtered[0] if filtered else None
154
+
155
+
156
+ def raw_request_path(request: _RequestLike) -> str:
157
+ """Return the request path with percent-encoding preserved.
158
+
159
+ ASGI populates ``scope["path"]`` as the percent-decoded path, but
160
+ the client signed its DPoP ``htu`` over the on-wire (percent-encoded)
161
+ target. Prefer ``scope["raw_path"]`` (raw bytes) so a path
162
+ containing e.g. ``%2F`` binds identically here and in the TS
163
+ sibling, which builds ``htu`` from ``IncomingMessage.url``. Falls
164
+ back to the decoded path for the rare ASGI server that omits
165
+ ``raw_path``.
166
+ """
167
+ raw = request.scope.get("raw_path")
168
+ if isinstance(raw, (bytes, bytearray)):
169
+ return bytes(raw).decode("latin-1")
170
+ return request.url.path
171
+
172
+
173
+ def get_or_create_verify_cache(request: _RequestLike) -> VerifyTaskCache:
174
+ """Return the per-request verify-task cache, creating it on first access.
175
+
176
+ Lives on ``request.state`` so it is scoped to the ASGI request and
177
+ disappears with it; cross-request replay protection is preserved.
178
+ The adapters' tests use this accessor to inspect the cache without
179
+ manipulating ``request.state`` or the stringly-typed slot directly.
180
+ """
181
+ state = request.state
182
+ cache: VerifyTaskCache | None = getattr(state, REQ_STATE_KEY, None)
183
+ if cache is None:
184
+ cache = {}
185
+ setattr(state, REQ_STATE_KEY, cache)
186
+ return cache
@@ -0,0 +1,159 @@
1
+ """Token cache with TTL buffer and bounded LRU for client_credentials tokens."""
2
+
3
+ import time
4
+ from collections import OrderedDict
5
+ from dataclasses import dataclass
6
+
7
+
8
+ @dataclass
9
+ class CacheEntry:
10
+ """A cached token."""
11
+
12
+ access_token: str
13
+ token_type: str
14
+ # Mirrors the wire field's tri-state (``None`` = AS omitted it,
15
+ # positive int = AS-issued lifetime). ``0`` never reaches the cache —
16
+ # :meth:`TokenCache.set` refuses it — so callers reading from cache
17
+ # see a sentinel that round-trips to the AS-issued shape.
18
+ expires_in: int | None
19
+ scope: str
20
+ expires_at: float # monotonic time
21
+ # DPoP key thumbprint (RFC 9449 §6.1). Persisted so a sender-constrained
22
+ # token retrieved from cache reports its binding to downstream callers
23
+ # instead of silently degrading to a bearer-only shape.
24
+ cnf_jkt: str = ""
25
+
26
+
27
+ class TokenCache:
28
+ """In-memory token cache with TTL buffer and bounded LRU eviction.
29
+
30
+ Tokens are evicted ``ttl_buffer_seconds`` before their actual expiry to
31
+ avoid using tokens that are about to expire.
32
+
33
+ The cache is bounded by ``max_entries`` (default 10 000) and evicts the
34
+ least-recently-used entry on overflow. Token-exchange cache keys are
35
+ high-cardinality because the subject token is part of the key, so
36
+ never-re-read keys would otherwise accumulate without bound.
37
+
38
+ Recency is tracked via :class:`collections.OrderedDict` — every ``get``
39
+ that hits an entry calls ``move_to_end`` so the iterator's first key
40
+ is always the LRU victim.
41
+ """
42
+
43
+ DEFAULT_MAX_ENTRIES = 10_000
44
+ """Default cap on cached entries."""
45
+
46
+ def __init__(
47
+ self,
48
+ ttl_buffer_seconds: float = 30.0,
49
+ default_ttl_seconds: float = 3600.0,
50
+ max_entries: int = DEFAULT_MAX_ENTRIES,
51
+ ) -> None:
52
+ # Strict `type() is int` rather than `isinstance(..., int)` so that
53
+ # `bool` (a subclass of `int`) is rejected — a stray `True` would
54
+ # otherwise silently produce a cap-1 cache. Catches runtime-typed
55
+ # inputs from JSON config that bypass the static type check.
56
+ if type(max_entries) is not int or max_entries <= 0:
57
+ raise ValueError(f"max_entries must be a positive integer, got {max_entries!r}")
58
+ self._ttl_buffer = ttl_buffer_seconds
59
+ self._default_ttl = default_ttl_seconds
60
+ self._max_entries = max_entries
61
+ self._entries: OrderedDict[str, CacheEntry] = OrderedDict()
62
+
63
+ def __len__(self) -> int:
64
+ """Number of stored entries — useful for tests and operator alerts.
65
+
66
+ Steady-state size tracking ``max_entries`` signals the cap is too low.
67
+ """
68
+ return len(self._entries)
69
+
70
+ @property
71
+ def max_entries(self) -> int:
72
+ """The configured cap on stored entries.
73
+
74
+ Exposed so operators can correlate ``len(cache)`` against the cap
75
+ without having to re-thread the constructor argument through their
76
+ own config layer.
77
+ """
78
+ return self._max_entries
79
+
80
+ def get(self, key: str) -> CacheEntry | None:
81
+ """Get a cached entry if it exists and hasn't expired.
82
+
83
+ On hit, bumps the entry to MRU so the next overflow eviction
84
+ targets a colder key.
85
+ """
86
+ entry = self._entries.get(key)
87
+ if entry is None:
88
+ return None
89
+ if time.monotonic() >= entry.expires_at:
90
+ del self._entries[key]
91
+ return None
92
+ # Bump to MRU.
93
+ self._entries.move_to_end(key)
94
+ return entry
95
+
96
+ def set(
97
+ self,
98
+ key: str,
99
+ access_token: str,
100
+ token_type: str,
101
+ expires_in: int | None = None,
102
+ scope: str = "",
103
+ cnf_jkt: str = "",
104
+ ) -> None:
105
+ """Cache a token.
106
+
107
+ ``expires_in`` is tri-state:
108
+
109
+ * ``None`` ⇒ the AS omitted ``expires_in``; apply ``default_ttl``.
110
+ * ``0`` ⇒ RFC 6749 §5.1 explicit zero (one-shot, born-expired);
111
+ refuse to store so the next ``get`` is a miss instead of a
112
+ stale hit.
113
+ * positive ``n`` ⇒ honor ``n`` seconds.
114
+
115
+ The effective TTL (chosen value minus ``ttl_buffer_seconds``) must
116
+ be > 0; otherwise the entry is not stored.
117
+ """
118
+ if expires_in is None:
119
+ base_ttl = self._default_ttl
120
+ elif expires_in == 0:
121
+ return
122
+ else:
123
+ base_ttl = expires_in
124
+ ttl = base_ttl - self._ttl_buffer
125
+ if ttl <= 0:
126
+ return
127
+ # `move_to_end` after insertion in case the key already exists —
128
+ # a re-set is treated as a touch (the entry the caller just wrote
129
+ # is by definition the most-recently-used).
130
+ self._entries[key] = CacheEntry(
131
+ access_token=access_token,
132
+ token_type=token_type,
133
+ expires_in=expires_in,
134
+ scope=scope,
135
+ expires_at=time.monotonic() + ttl,
136
+ cnf_jkt=cnf_jkt,
137
+ )
138
+ self._entries.move_to_end(key)
139
+ # Evict the LRU victim(s) until we're at-or-under the cap. The
140
+ # constructor pins ``max_entries``, so in practice this trims
141
+ # exactly one entry on overflow.
142
+ while len(self._entries) > self._max_entries:
143
+ self._entries.popitem(last=False)
144
+
145
+ def delete(self, key: str) -> None:
146
+ """Remove a cached entry."""
147
+ self._entries.pop(key, None)
148
+
149
+ @staticmethod
150
+ def cache_key(scope: str = "", resource: str = "") -> str:
151
+ """Generate a deterministic cache key from scope and resource.
152
+
153
+ Scopes are sorted for consistency.
154
+ """
155
+ parts = sorted(scope.split()) if scope else []
156
+ scope_part = " ".join(parts)
157
+ if resource:
158
+ return f"{scope_part}|{resource}" if scope_part else f"|{resource}"
159
+ return scope_part or "_default"
@@ -92,12 +92,46 @@ class AuthplaneClient:
92
92
  metadata_refresh_seconds: int = 3600,
93
93
  cache_ttl_buffer_seconds: float = 30.0,
94
94
  default_ttl_seconds: float = 3600.0,
95
+ cache_max_entries: int = TokenCache.DEFAULT_MAX_ENTRIES,
95
96
  circuit_breaker_threshold: int = 5,
96
97
  circuit_breaker_cooldown_seconds: float = 30.0,
97
98
  ) -> Self:
98
99
  """Create and initialize the client.
99
100
 
100
101
  Discovers AS metadata and starts JWKS background refresh.
102
+
103
+ Args:
104
+ issuer: Authorization-server issuer URL (the prefix RFC 8414 metadata
105
+ is fetched from). Trailing slash is stripped.
106
+ auth: Client authentication for OAuth endpoints. Accepts either a raw
107
+ :class:`AuthProvider` or an :class:`ASCredentials` shorthand (which
108
+ is materialised as :class:`ClientCredentialsProvider`).
109
+ dpop: Optional :class:`DPoPProvider` for sender-constrained outbound
110
+ requests against the AS (token / introspection / revocation).
111
+ dev_mode: When True, relaxes SSRF and HTTPS-only fetch policies for
112
+ local development. Falls back to the ``AUTHPLANE_DEV_MODE``
113
+ environment variable when omitted.
114
+ fetch_settings: Explicit :class:`FetchSettings` override. When None,
115
+ derived from ``dev_mode``.
116
+ jwks_refresh_seconds: Background JWKS refresh interval (must be > 0).
117
+ metadata_refresh_seconds: Background metadata refresh interval
118
+ (must be > 0).
119
+ cache_ttl_buffer_seconds: Safety margin subtracted from each token's
120
+ lifetime before the entry is considered expired. Same shape as
121
+ java-sdk ``TokenCacheConfig.ttlBufferSeconds`` and ts-sdk
122
+ ``TokenCache`` ctor. Default 30s.
123
+ default_ttl_seconds: Fallback lifetime applied when the AS response
124
+ omits ``expires_in``. Cross-SDK parity with java-sdk
125
+ ``TokenCacheConfig.defaultTtlSeconds``. Default 3600s.
126
+ cache_max_entries: Maximum number of cached tokens before
127
+ least-recently-used eviction kicks in. Default
128
+ :attr:`TokenCache.DEFAULT_MAX_ENTRIES` (10_000). Must be a
129
+ positive integer (``bool``/``float`` rejected — see
130
+ :class:`TokenCache`).
131
+ circuit_breaker_threshold: Consecutive AS failures before the
132
+ circuit opens. Default 5.
133
+ circuit_breaker_cooldown_seconds: Half-open probe interval after the
134
+ circuit trips. Default 30s.
101
135
  """
102
136
  client = cls()
103
137
  client._issuer = issuer.rstrip("/")
@@ -135,7 +169,11 @@ class AuthplaneClient:
135
169
  )
136
170
 
137
171
  # Resilience
138
- client._token_cache = TokenCache(cache_ttl_buffer_seconds, default_ttl_seconds)
172
+ client._token_cache = TokenCache(
173
+ cache_ttl_buffer_seconds,
174
+ default_ttl_seconds,
175
+ cache_max_entries,
176
+ )
139
177
  client._circuit_breaker = CircuitBreaker(
140
178
  circuit_breaker_threshold,
141
179
  circuit_breaker_cooldown_seconds,
@@ -270,6 +308,7 @@ class AuthplaneClient:
270
308
  token_type=cached.token_type,
271
309
  expires_in=cached.expires_in,
272
310
  scope=cached.scope,
311
+ cnf_jkt=cached.cnf_jkt,
273
312
  )
274
313
 
275
314
  token_endpoint = await self._get_token_endpoint()
@@ -289,6 +328,7 @@ class AuthplaneClient:
289
328
  result.token_type,
290
329
  result.expires_in,
291
330
  result.scope,
331
+ cnf_jkt=result.cnf_jkt,
292
332
  )
293
333
  return result
294
334
  except Exception as exc:
@@ -122,6 +122,19 @@ class InvalidDPoPProofError(DPoPError):
122
122
  pass
123
123
 
124
124
 
125
+ class DPoPMultipleProofsError(InvalidDPoPProofError):
126
+ """Raised when the inbound request carries more than one ``DPoP`` header
127
+ value (RFC 9449 §4.3 #1).
128
+
129
+ Surfaces with WWW-Authenticate ``error="invalid_dpop_proof"`` per
130
+ RFC 9449 §7.1. The other ``DPoPError`` subclasses keep the SDK's
131
+ historical ``invalid_token`` mapping; only this §4.3 cardinality
132
+ violation gets the proof-specific error code.
133
+ """
134
+
135
+ pass
136
+
137
+
125
138
  class DPoPReplayDetectedError(DPoPError):
126
139
  """Raised when a DPoP proof `jti` has already been seen."""
127
140
 
@@ -253,8 +266,11 @@ def www_authenticate(
253
266
 
254
267
  Maps SDK errors to the correct error code and authentication scheme:
255
268
  - ``InsufficientScopeError`` → ``insufficient_scope``
256
- - ``DPoPError`` subclasses (except ``DPoPNotSupportedError``) ``DPoP``
257
- scheme with ``invalid_token``
269
+ - ``DPoPMultipleProofsError`` ``DPoP`` scheme with
270
+ ``invalid_dpop_proof`` (RFC 9449 §7.1 prescribes this code for §4.3
271
+ cardinality rejections).
272
+ - Other ``DPoPError`` subclasses (except ``DPoPNotSupportedError``) →
273
+ ``DPoP`` scheme with ``invalid_token``
258
274
  - All other ``AuthplaneError`` → ``Bearer`` scheme with ``invalid_token``
259
275
 
260
276
  If ``scope`` is provided (or the error is an :class:`InsufficientScopeError`
@@ -272,6 +288,12 @@ def www_authenticate(
272
288
  """
273
289
  if isinstance(error, InsufficientScopeError):
274
290
  error_code = "insufficient_scope"
291
+ elif isinstance(error, DPoPMultipleProofsError):
292
+ # RFC 9449 §7.1 prescribes `invalid_dpop_proof` for §4.3
293
+ # cardinality rejections, not the SDK's historical `invalid_token`
294
+ # used by the other `DPoPError` shapes. Scoped to this error;
295
+ # a broader sweep is a separate change.
296
+ error_code = "invalid_dpop_proof"
275
297
  else:
276
298
  error_code = "invalid_token"
277
299
 
@@ -13,9 +13,14 @@ def _required_string(data: dict[str, Any], key: str) -> str:
13
13
  return value
14
14
 
15
15
 
16
- def _optional_int(data: dict[str, Any], key: str, *, default: int = 0) -> int:
17
- value = data.get(key, default)
18
- if value in ("", None):
16
+ def _optional_int(data: dict[str, Any], key: str, *, default: int | None = 0) -> int | None:
17
+ """Parse an optional integer field. ``default`` is returned for absent
18
+ or empty-string values; ``None`` is a valid default so callers can
19
+ distinguish "field omitted on the wire" from "field present and zero".
20
+ """
21
+ sentinel = object()
22
+ value = data.get(key, sentinel)
23
+ if value is sentinel or value in ("", None):
19
24
  return default
20
25
  try:
21
26
  parsed = int(value)
@@ -74,7 +79,7 @@ def parse_token_response(
74
79
  return TokenResponse(
75
80
  access_token=access_token,
76
81
  token_type=token_type,
77
- expires_in=_optional_int(data, "expires_in", default=0),
82
+ expires_in=_optional_int(data, "expires_in", default=None),
78
83
  scope=str(data.get("scope", "")),
79
84
  refresh_token=str(data.get("refresh_token", "")),
80
85
  issued_token_type=issued_token_type,
@@ -57,7 +57,12 @@ class TokenResponse:
57
57
 
58
58
  access_token: str
59
59
  token_type: str
60
- expires_in: int
60
+ # ``expires_in`` is tri-state so the wire shape ``expires_in: 0``
61
+ # (RFC 6749 §5.1 — a deliberately-expired one-shot token) is
62
+ # distinguishable from the field being absent. Cache callers honor
63
+ # the AS's intent: ``None`` ⇒ apply the default TTL; ``0`` ⇒ refuse
64
+ # to store.
65
+ expires_in: int | None
61
66
  scope: str
62
67
  refresh_token: str = ""
63
68
  issued_token_type: str = ""
@@ -2,7 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from collections.abc import Mapping
5
+ from collections.abc import Iterable, Mapping
6
6
  from dataclasses import dataclass, field
7
7
  from types import MappingProxyType
8
8
  from typing import TYPE_CHECKING, Any, cast
@@ -55,11 +55,45 @@ class VerifiedClaims:
55
55
  def require_scope(self, scope: str) -> None:
56
56
  """Raise InsufficientScopeError when the validated token lacks the scope."""
57
57
  if not self.has_scope(scope):
58
+ available = list(self.scopes) if self.scopes else "(none)"
58
59
  raise InsufficientScopeError(
59
- f"Token missing required scope '{scope}'. Token has scopes: {list(self.scopes)}",
60
+ f"Token missing required scope '{scope}'. Token has scopes: {available}",
60
61
  required_scopes=(scope,),
61
62
  )
62
63
 
64
+ def require_scopes(self, scopes: Iterable[str]) -> None:
65
+ """Raise InsufficientScopeError unless the token carries every scope in ``scopes``.
66
+
67
+ Empty input is a no-op — no required scopes ⇒ always satisfied. A
68
+ handler that calls ``claims.require_scopes(required)`` doesn't have to
69
+ special-case empty iterables.
70
+
71
+ On failure the raised :class:`InsufficientScopeError` carries the full
72
+ requested tuple on ``required_scopes`` (not just the missing ones, so
73
+ adapters that surface ``scope="…"`` in the WWW-Authenticate challenge
74
+ keep emitting the complete required set), and its message names every
75
+ missing scope plus the scopes the token carries — rendered verbatim
76
+ into the RFC 6750 ``error_description``.
77
+ """
78
+ # Materialise once: the caller may pass any iterable (generator, set,
79
+ # frozenset, etc.). We need to iterate twice — once to find missing
80
+ # entries and once to populate the error's ``required_scopes`` — and
81
+ # the raised error embeds the requested tuple in its public field,
82
+ # so the snapshot has to be ordered + indexable.
83
+ required = tuple(scopes)
84
+ if not required:
85
+ return
86
+ missing = [s for s in required if not self.has_scope(s)]
87
+ if not missing:
88
+ return
89
+ scope_word = "scopes" if len(missing) > 1 else "scope"
90
+ missing_quoted = ", ".join(repr(s) for s in missing)
91
+ available = list(self.scopes) if self.scopes else "(none)"
92
+ raise InsufficientScopeError(
93
+ f"Token missing required {scope_word} {missing_quoted}. Token has scopes: {available}",
94
+ required_scopes=required,
95
+ )
96
+
63
97
  def has_claim(self, key: str, value: Any = _MISSING) -> bool:
64
98
  """Check whether a claim exists and optionally matches an expected value."""
65
99
  if key not in self.raw:
@@ -1,32 +0,0 @@
1
- # Changelog
2
-
3
- All notable changes to this project will be documented in this file.
4
-
5
- The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
- and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
-
8
- ## [0.2.0] - 2026-05-20
9
-
10
- ### Security
11
- - `www_authenticate()` now sanitizes CR, LF, double-quote, and backslash from every value it interpolates (`realm`, `error_description`, `scope`, `resource_metadata`), closing a header-injection path through attacker-influenced error messages.
12
-
13
- ### Fixed
14
- - `DPoPNotSupportedError` now emits `WWW-Authenticate: Bearer` instead of `DPoP`. The resource is bearer-only by configuration, so advertising the DPoP scheme misled clients into retries that would fail the same way.
15
- - `http_status(CircuitOpenError)` now returns `503` (was `500`). The circuit breaker is structurally identical to other temporary-AS-unavailability errors and should be retryable, not surfaced as an internal error.
16
- - Outbound `Host` header now preserves non-default ports and brackets IPv6 hostnames, fixing DPoP `htu` validation against authservers on non-standard ports.
17
- - Packaging issues discovered after the first release.
18
- - Documentation links and demo references.
19
- - `authplane-fastmcp` dependency range now correctly requires `fastmcp>=3.2,<4` (was `>=2.0`, which could resolve to a version the adapter can't import).
20
-
21
- ### Added
22
- - `www_authenticate()` accepts `resource_metadata_url=` (RFC 9728 §5.1) and `scope=` (RFC 6750 §3) keyword arguments. When the caller does not pass `scope=`, the helper auto-populates it from `InsufficientScopeError.required_scopes`.
23
- - `InsufficientScopeError` now carries a structured `required_scopes` attribute, populated automatically by `VerifiedClaims.require_scope()` so the wire challenge can advertise the missing scope.
24
- - `response_headers_for(error, *, realm, resource_metadata_url, scope)` — bundled helper returning `(status, {"WWW-Authenticate": challenge})` in one call.
25
- - Both adapter verifiers (`authplane-mcp`, `authplane-fastmcp`) now emit a `logging.DEBUG` event `authplane.token_verification_failed` with structured `error_class` and `error` fields before returning `None`. Wire behaviour is unchanged; operators can now distinguish expired tokens from JWKS outages and DPoP replays in logs.
26
-
27
- ### Changed
28
- - CI and release workflow improvements from first-release learnings.
29
-
30
- ## [0.1.0] - 2026-05-11
31
-
32
- - Initial release.
@@ -1,78 +0,0 @@
1
- """Token cache with TTL buffer for client_credentials tokens."""
2
-
3
- import time
4
- from dataclasses import dataclass
5
-
6
-
7
- @dataclass
8
- class CacheEntry:
9
- """A cached token."""
10
-
11
- access_token: str
12
- token_type: str
13
- expires_in: int
14
- scope: str
15
- expires_at: float # monotonic time
16
-
17
-
18
- class TokenCache:
19
- """In-memory token cache with configurable TTL buffer.
20
-
21
- Tokens are evicted `ttl_buffer_seconds` before their actual expiry
22
- to avoid using tokens that are about to expire.
23
- """
24
-
25
- def __init__(
26
- self,
27
- ttl_buffer_seconds: float = 30.0,
28
- default_ttl_seconds: float = 3600.0,
29
- ) -> None:
30
- self._ttl_buffer = ttl_buffer_seconds
31
- self._default_ttl = default_ttl_seconds
32
- self._entries: dict[str, CacheEntry] = {}
33
-
34
- def get(self, key: str) -> CacheEntry | None:
35
- """Get a cached entry if it exists and hasn't expired."""
36
- entry = self._entries.get(key)
37
- if entry is None:
38
- return None
39
- if time.monotonic() >= entry.expires_at:
40
- del self._entries[key]
41
- return None
42
- return entry
43
-
44
- def set(
45
- self,
46
- key: str,
47
- access_token: str,
48
- token_type: str,
49
- expires_in: int = 0,
50
- scope: str = "",
51
- ) -> None:
52
- """Cache a token. Skips caching if effective TTL <= 0."""
53
- ttl = (expires_in if expires_in > 0 else self._default_ttl) - self._ttl_buffer
54
- if ttl <= 0:
55
- return
56
- self._entries[key] = CacheEntry(
57
- access_token=access_token,
58
- token_type=token_type,
59
- expires_in=expires_in,
60
- scope=scope,
61
- expires_at=time.monotonic() + ttl,
62
- )
63
-
64
- def delete(self, key: str) -> None:
65
- """Remove a cached entry."""
66
- self._entries.pop(key, None)
67
-
68
- @staticmethod
69
- def cache_key(scope: str = "", resource: str = "") -> str:
70
- """Generate a deterministic cache key from scope and resource.
71
-
72
- Scopes are sorted for consistency.
73
- """
74
- parts = sorted(scope.split()) if scope else []
75
- scope_part = " ".join(parts)
76
- if resource:
77
- return f"{scope_part}|{resource}" if scope_part else f"|{resource}"
78
- return scope_part or "_default"
File without changes
File without changes