Skip to content

Commit b69244d

Browse files
authored
fix(cache,verifier): expires_in tri-state, DPoP §4.3 cardinality, cnf_jkt round-trip (#15)
Bundles three related correctness fixes around token caching and inbound DPoP enforcement. * expires_in tri-state: TokenCache.set now distinguishes a missing expires_in from expires_in: 0. The previous branch collapsed both into default_ttl, so an AS-issued one-shot token (RFC 6749 §5.1 permits expires_in: 0) was cached for the default hour. The store now treats None as "apply default TTL", refuses to store when the value is 0 (the entry is born expired), and honors n seconds when positive. parse_token_response and the _optional_int sentinel carry the missing-vs-zero distinction through the parser end-to-end. * DPoP §4.3 cardinality: inbound DPoP proof 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 repeated 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; other DPoPError shapes keep the historical invalid_token mapping. * cnf_jkt round-trip: CacheEntry now persists the DPoP key thumbprint (RFC 9449 §6.1), so a sender-constrained token retrieved from cache reports its binding to downstream callers instead of silently degrading to a bearer-only shape. BREAKING CHANGE (pre-1.0): TokenResponse.expires_in and CacheEntry.expires_in are now typed int | None (was int). A token response that omits expires_in is None rather than 0 — the wire-level prerequisite for the tri-state fix, since an absent field is otherwise indistinguishable from expires_in: 0 once the JSON is decoded. Typed downstream callers reading expires_in directly (arithmetic, comparison, formatting) must guard for None: treat None as "apply your default" and 0 as "already expired".
1 parent 3bf50ed commit b69244d

15 files changed

Lines changed: 441 additions & 23 deletions

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1414
- `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).
1515

1616
### 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.
1719
- `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.
1820
- `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`.
1921
- `authplane-mcp`: `install_request_context(mcp)` is idempotent — repeated calls on the same `FastMCP` instance are no-ops.
2022
- `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.
2123
- 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.
2224

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+
2328
## [0.2.0] - 2026-05-20
2429

2530
### Security

authplane-fastmcp/tests/test_verifier_dpop_cache.py

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -201,13 +201,62 @@ async def test_dpop_context_header_lookup_is_case_insensitive() -> None:
201201
assert mock.verify.await_args.kwargs["dpop_request"].proof == "lower.case.proof"
202202

203203

204+
@pytest.mark.asyncio
205+
async def test_duplicate_dpop_headers_fail_auth() -> None:
206+
"""Two ``DPoP`` headers on one request violate RFC 9449 §4.3 #1.
207+
208+
The adapter must fail authentication (``verify_token`` returns
209+
``None``) without ever invoking the underlying ``verify`` — the
210+
cardinality guard runs before the resource verifier sees the proof.
211+
"""
212+
mock = _mock_verifier()
213+
# Bypass _make_request to inject two DPoP headers; the helper's dict
214+
# shape cannot represent the duplicate.
215+
scope: dict[str, Any] = {
216+
"type": "http",
217+
"method": "POST",
218+
"path": "/mcp",
219+
"raw_path": b"/mcp",
220+
"query_string": b"",
221+
"headers": [
222+
(b"dpop", b"proof.one.x"),
223+
(b"dpop", b"proof.two.y"),
224+
],
225+
"scheme": "http",
226+
"server": ("testserver", 80),
227+
"client": None,
228+
"root_path": "",
229+
"http_version": "1.1",
230+
}
231+
request = Request(scope)
232+
verifier = _make_token_verifier(mock, request)
233+
234+
result = await verifier.verify_token("valid_token")
235+
assert result is None
236+
mock.verify.assert_not_awaited()
237+
238+
239+
@pytest.mark.asyncio
240+
async def test_comma_joined_dpop_value_fails_auth() -> None:
241+
"""A single comma-joined ``DPoP`` value (the proxy-collapsed shape
242+
permitted by RFC 9110 §5.3) is unambiguously two proofs — JWS compact
243+
has no literal comma — and must trip the same §4.3 guard.
244+
"""
245+
mock = _mock_verifier()
246+
request = _make_request(headers={"DPoP": "a.b.c, x.y.z"})
247+
verifier = _make_token_verifier(mock, request)
248+
249+
result = await verifier.verify_token("valid_token")
250+
assert result is None
251+
mock.verify.assert_not_awaited()
252+
253+
204254
@pytest.mark.asyncio
205255
async def test_htu_origin_from_configured_resource_not_host_header() -> None:
206256
"""htu's origin comes from the configured resource, never from Host.
207257
208-
Mirrors the TS sibling: an upstream that controls the Host /
209-
X-Forwarded-Proto headers must not be able to decide which htu the
210-
DPoP proof is validated against.
258+
An upstream that controls the Host / X-Forwarded-Proto headers must
259+
not be able to decide which htu the DPoP proof is validated against.
211260
"""
212261
mock = _mock_verifier(resource="https://api.example.com/mcp")
213262
request = _make_request(

authplane-mcp/tests/test_verifier_dpop_cache.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,56 @@ async def test_dpop_context_header_lookup_is_case_insensitive() -> None:
206206
assert mock.verify.await_args.kwargs["dpop_request"].proof == "lower.case.proof"
207207

208208

209+
@pytest.mark.asyncio
210+
async def test_duplicate_dpop_headers_fail_auth() -> None:
211+
"""Two ``DPoP`` headers on one request violate RFC 9449 §4.3 #1.
212+
213+
The adapter must fail authentication (``verify_token`` returns
214+
``None``) without ever invoking the underlying ``verify`` — the
215+
cardinality guard runs before the resource verifier sees the proof.
216+
"""
217+
mock = _mock_verifier()
218+
# Bypass _make_request to inject two DPoP headers; the helper's dict
219+
# shape cannot represent the duplicate.
220+
scope: dict[str, Any] = {
221+
"type": "http",
222+
"method": "POST",
223+
"path": "/mcp",
224+
"raw_path": b"/mcp",
225+
"query_string": b"",
226+
"headers": [
227+
(b"dpop", b"proof.one.x"),
228+
(b"dpop", b"proof.two.y"),
229+
],
230+
"scheme": "http",
231+
"server": ("testserver", 80),
232+
"client": None,
233+
"root_path": "",
234+
"http_version": "1.1",
235+
}
236+
request = Request(scope)
237+
verifier = _make_token_verifier(mock, request)
238+
239+
result = await verifier.verify_token("valid_token")
240+
assert result is None
241+
mock.verify.assert_not_awaited()
242+
243+
244+
@pytest.mark.asyncio
245+
async def test_comma_joined_dpop_value_fails_auth() -> None:
246+
"""A single comma-joined ``DPoP`` value (the proxy-collapsed shape
247+
permitted by RFC 9110 §5.3) is unambiguously two proofs — JWS compact
248+
has no literal comma — and must trip the same §4.3 guard.
249+
"""
250+
mock = _mock_verifier()
251+
request = _make_request(headers={"DPoP": "a.b.c, x.y.z"})
252+
verifier = _make_token_verifier(mock, request)
253+
254+
result = await verifier.verify_token("valid_token")
255+
assert result is None
256+
mock.verify.assert_not_awaited()
257+
258+
209259
@pytest.mark.asyncio
210260
async def test_htu_origin_from_configured_resource_not_host_header() -> None:
211261
"""htu's origin comes from the configured resource, never from Host.

authplane/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
ConsentRequiredError,
3636
DPoPBindingMismatchError,
3737
DPoPError,
38+
DPoPMultipleProofsError,
3839
DPoPNotSupportedError,
3940
DPoPProofMissingError,
4041
DPoPReplayDetectedError,
@@ -84,6 +85,7 @@
8485
"DPoPBindingMismatchError",
8586
"DPoPError",
8687
"DPoPKeyMaterial",
88+
"DPoPMultipleProofsError",
8789
"DPoPNonceStore",
8890
"DPoPNotSupportedError",
8991
"DPoPProofMissingError",

authplane/_dpop_adapter.py

Lines changed: 40 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@
2828

2929
from typing import TYPE_CHECKING, Protocol
3030

31+
from .errors import DPoPMultipleProofsError
32+
3133
if TYPE_CHECKING:
3234
import asyncio
3335

@@ -61,6 +63,8 @@ class _HeadersLike(Protocol):
6163

6264
def get(self, key: str, default: str | None = ...) -> str | None: ...
6365

66+
def getlist(self, key: str) -> list[str]: ...
67+
6468

6569
class _URLLike(Protocol):
6670
"""Minimal slice of ``starlette.datastructures.URL``."""
@@ -109,15 +113,44 @@ def __init__(self, method: str, url: str, proof: str | None) -> None:
109113

110114

111115
def read_dpop_header(request: _RequestLike) -> str | None:
112-
"""Read the ``DPoP`` request header (case-insensitive, first value).
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.
113131
114-
Starlette's ``Headers.get`` already does case-insensitive lookup
115-
and returns the first occurrence when a header is repeated. Strict
116-
rejection of repeated ``DPoP`` headers (RFC 9449 §4.3 #1) is
117-
tracked separately and is intentionally not enforced here so this
118-
layer does not overlap with that work.
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.
119135
"""
120-
return request.headers.get("dpop")
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
121154

122155

123156
def raw_request_path(request: _RequestLike) -> str:

authplane/cache.py

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,17 @@ class CacheEntry:
1111

1212
access_token: str
1313
token_type: str
14-
expires_in: int
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
1519
scope: str
1620
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 = ""
1725

1826

1927
class TokenCache:
@@ -90,11 +98,30 @@ def set(
9098
key: str,
9199
access_token: str,
92100
token_type: str,
93-
expires_in: int = 0,
101+
expires_in: int | None = None,
94102
scope: str = "",
103+
cnf_jkt: str = "",
95104
) -> None:
96-
"""Cache a token. Skips caching if effective TTL <= 0."""
97-
ttl = (expires_in if expires_in > 0 else self._default_ttl) - self._ttl_buffer
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
98125
if ttl <= 0:
99126
return
100127
# `move_to_end` after insertion in case the key already exists —
@@ -106,6 +133,7 @@ def set(
106133
expires_in=expires_in,
107134
scope=scope,
108135
expires_at=time.monotonic() + ttl,
136+
cnf_jkt=cnf_jkt,
109137
)
110138
self._entries.move_to_end(key)
111139
# Evict the LRU victim(s) until we're at-or-under the cap. The

authplane/client.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,7 @@ async def client_credentials(
308308
token_type=cached.token_type,
309309
expires_in=cached.expires_in,
310310
scope=cached.scope,
311+
cnf_jkt=cached.cnf_jkt,
311312
)
312313

313314
token_endpoint = await self._get_token_endpoint()
@@ -327,6 +328,7 @@ async def client_credentials(
327328
result.token_type,
328329
result.expires_in,
329330
result.scope,
331+
cnf_jkt=result.cnf_jkt,
330332
)
331333
return result
332334
except Exception as exc:

authplane/errors.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,19 @@ class InvalidDPoPProofError(DPoPError):
122122
pass
123123

124124

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+
125138
class DPoPReplayDetectedError(DPoPError):
126139
"""Raised when a DPoP proof `jti` has already been seen."""
127140

@@ -253,8 +266,11 @@ def www_authenticate(
253266
254267
Maps SDK errors to the correct error code and authentication scheme:
255268
- ``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``
258274
- All other ``AuthplaneError`` → ``Bearer`` scheme with ``invalid_token``
259275
260276
If ``scope`` is provided (or the error is an :class:`InsufficientScopeError`
@@ -272,6 +288,12 @@ def www_authenticate(
272288
"""
273289
if isinstance(error, InsufficientScopeError):
274290
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"
275297
else:
276298
error_code = "invalid_token"
277299

authplane/oauth/parsing.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,14 @@ def _required_string(data: dict[str, Any], key: str) -> str:
1313
return value
1414

1515

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):
1924
return default
2025
try:
2126
parsed = int(value)
@@ -74,7 +79,7 @@ def parse_token_response(
7479
return TokenResponse(
7580
access_token=access_token,
7681
token_type=token_type,
77-
expires_in=_optional_int(data, "expires_in", default=0),
82+
expires_in=_optional_int(data, "expires_in", default=None),
7883
scope=str(data.get("scope", "")),
7984
refresh_token=str(data.get("refresh_token", "")),
8085
issued_token_type=issued_token_type,

authplane/oauth/types.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,12 @@ class TokenResponse:
5757

5858
access_token: str
5959
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
6166
scope: str
6267
refresh_token: str = ""
6368
issued_token_type: str = ""

0 commit comments

Comments
 (0)