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.
- authplane_sdk-0.3.0/CHANGELOG.md +52 -0
- {authplane_sdk-0.2.0 → authplane_sdk-0.3.0}/PKG-INFO +1 -1
- {authplane_sdk-0.2.0 → authplane_sdk-0.3.0}/authplane/__init__.py +4 -0
- authplane_sdk-0.3.0/authplane/_dpop_adapter.py +186 -0
- authplane_sdk-0.3.0/authplane/cache.py +159 -0
- {authplane_sdk-0.2.0 → authplane_sdk-0.3.0}/authplane/client.py +41 -1
- {authplane_sdk-0.2.0 → authplane_sdk-0.3.0}/authplane/errors.py +24 -2
- {authplane_sdk-0.2.0 → authplane_sdk-0.3.0}/authplane/oauth/parsing.py +9 -4
- {authplane_sdk-0.2.0 → authplane_sdk-0.3.0}/authplane/oauth/types.py +6 -1
- {authplane_sdk-0.2.0 → authplane_sdk-0.3.0}/authplane/verifier/claims.py +36 -2
- authplane_sdk-0.2.0/CHANGELOG.md +0 -32
- authplane_sdk-0.2.0/authplane/cache.py +0 -78
- {authplane_sdk-0.2.0 → authplane_sdk-0.3.0}/.gitignore +0 -0
- {authplane_sdk-0.2.0 → authplane_sdk-0.3.0}/LICENSE +0 -0
- {authplane_sdk-0.2.0 → authplane_sdk-0.3.0}/authplane/README.md +0 -0
- {authplane_sdk-0.2.0 → authplane_sdk-0.3.0}/authplane/auth_provider.py +0 -0
- {authplane_sdk-0.2.0 → authplane_sdk-0.3.0}/authplane/circuit_breaker.py +0 -0
- {authplane_sdk-0.2.0 → authplane_sdk-0.3.0}/authplane/credentials.py +0 -0
- {authplane_sdk-0.2.0 → authplane_sdk-0.3.0}/authplane/docs/user-guide.md +0 -0
- {authplane_sdk-0.2.0 → authplane_sdk-0.3.0}/authplane/dpop.py +0 -0
- {authplane_sdk-0.2.0 → authplane_sdk-0.3.0}/authplane/dpop_verification.py +0 -0
- {authplane_sdk-0.2.0 → authplane_sdk-0.3.0}/authplane/internal/__init__.py +0 -0
- {authplane_sdk-0.2.0 → authplane_sdk-0.3.0}/authplane/internal/cache_headers.py +0 -0
- {authplane_sdk-0.2.0 → authplane_sdk-0.3.0}/authplane/internal/document_cache.py +0 -0
- {authplane_sdk-0.2.0 → authplane_sdk-0.3.0}/authplane/internal/document_fetcher.py +0 -0
- {authplane_sdk-0.2.0 → authplane_sdk-0.3.0}/authplane/internal/fetch_result.py +0 -0
- {authplane_sdk-0.2.0 → authplane_sdk-0.3.0}/authplane/internal/jwt.py +0 -0
- {authplane_sdk-0.2.0 → authplane_sdk-0.3.0}/authplane/internal/metadata.py +0 -0
- {authplane_sdk-0.2.0 → authplane_sdk-0.3.0}/authplane/internal/urls.py +0 -0
- {authplane_sdk-0.2.0 → authplane_sdk-0.3.0}/authplane/net/__init__.py +0 -0
- {authplane_sdk-0.2.0 → authplane_sdk-0.3.0}/authplane/net/fetch_settings.py +0 -0
- {authplane_sdk-0.2.0 → authplane_sdk-0.3.0}/authplane/net/http.py +0 -0
- {authplane_sdk-0.2.0 → authplane_sdk-0.3.0}/authplane/net/ip_validation.py +0 -0
- {authplane_sdk-0.2.0 → authplane_sdk-0.3.0}/authplane/net/ssrf.py +0 -0
- {authplane_sdk-0.2.0 → authplane_sdk-0.3.0}/authplane/oauth/__init__.py +0 -0
- {authplane_sdk-0.2.0 → authplane_sdk-0.3.0}/authplane/oauth/client_credentials.py +0 -0
- {authplane_sdk-0.2.0 → authplane_sdk-0.3.0}/authplane/oauth/introspection.py +0 -0
- {authplane_sdk-0.2.0 → authplane_sdk-0.3.0}/authplane/oauth/prm.py +0 -0
- {authplane_sdk-0.2.0 → authplane_sdk-0.3.0}/authplane/oauth/revocation.py +0 -0
- {authplane_sdk-0.2.0 → authplane_sdk-0.3.0}/authplane/oauth/token_exchange.py +0 -0
- {authplane_sdk-0.2.0 → authplane_sdk-0.3.0}/authplane/py.typed +0 -0
- {authplane_sdk-0.2.0 → authplane_sdk-0.3.0}/authplane/verifier/__init__.py +0 -0
- {authplane_sdk-0.2.0 → authplane_sdk-0.3.0}/authplane/verifier/verifier.py +0 -0
- {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.
|
|
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(
|
|
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
|
-
- ``
|
|
257
|
-
|
|
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
|
-
|
|
18
|
-
|
|
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=
|
|
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:
|
|
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: {
|
|
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:
|
authplane_sdk-0.2.0/CHANGELOG.md
DELETED
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|