Skip to content

Commit 8f7b224

Browse files
fix: stop normalising resource and issuer identifiers
RFC 8414 §3.3 and RFC 9728 §3.3 require the advertised issuer/resource to be identical to the configured value — a simple string comparison — and both well-known URLs are formed by inserting the well-known path segment into the identifier verbatim (RFC 8414 §3 / RFC 9728 §3). The SDK instead stripped slashes in five places: - AuthplaneClient.create rewrote the configured issuer with rstrip("/"). The rewritten value became the verifier's expected iss claim, so an AS whose issuer identifier legitimately ends in "/" had every token rejected (RFC 9068 requires iss to carry the slash). - build_prm_url and build_metadata_url used path.strip("/"), dropping the trailing slash the insertion rule requires be preserved; both are now pure insertion of the parsed path. - MetadataCache rstripped both sides of the issuer comparison, weakening the §3.3 identical-match MUST that defeats metadata substitution. Identifiers are now validated at construction instead (absolute http(s) URL with an authority, no fragment — RFC 8707 §2) via the new internal validate_identifier helper, and never transformed. Conformance: extends the rfc9728 well-known-path case with the trailing-slash resource datum and adds two issuer variants — metadata issuer differing only by a trailing slash is rejected, and a token whose iss matches a configured trailing-slash issuer verifies end to end (discovery at the trailing-slash well-known URL included). Migration: if a configured issuer or resource differs from the authorization server's actual identifier by a trailing slash, correct the config — the SDK no longer silently reconciles them.
1 parent f8553d2 commit 8f7b224

11 files changed

Lines changed: 140 additions & 27 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Changed
11+
- Resource and issuer identifiers are never rewritten (RFC 8414 §3.3 / RFC 9728 §3.3 require the advertised value to be *identical* to the configured one). `build_prm_url` and `build_metadata_url` are formed by pure insertion, preserving the identifier's path exactly — including any trailing slash — and AS-metadata issuer comparison is now an exact string match. Identifiers are validated at construction (absolute http(s) URL with an authority and no fragment) and raise `ValueError` otherwise; trailing slashes, host case, and explicit ports are legal and preserved. **Migration**: if your configured issuer or resource differs from your authorization server's actual identifier by a trailing slash, correct the config — the SDK no longer silently reconciles them.
12+
13+
### Fixed
14+
- A configured issuer whose identifier legitimately ends in `/` no longer has every token rejected. The trailing slash was silently stripped at client creation and the stripped value compared against the token's `iss`, which RFC 9068 requires to carry the slash; discovery now also resolves the RFC 8414 well-known URL for such issuers correctly.
15+
1016
## [0.3.0] - 2026-07-21
1117

1218
### Added

authplane/client.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
JWKSCache,
1818
MetadataCache,
1919
build_metadata_url,
20+
validate_identifier,
2021
)
2122
from .net import FetchSettings
2223
from .net.ssrf import SSRFError
@@ -134,7 +135,10 @@ async def create(
134135
circuit trips. Default 30s.
135136
"""
136137
client = cls()
137-
client._issuer = issuer.rstrip("/")
138+
# RFC 8414 Section 3.3 — the issuer is an opaque identifier compared
139+
# with simple string equality (against metadata and token iss); it is
140+
# validated but never rewritten.
141+
client._issuer = validate_identifier(issuer, "issuer")
138142

139143
# Dev mode
140144
resolved_dev_mode = (

authplane/internal/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
)
1010
from .document_fetcher import DocumentFetcher
1111
from .fetch_result import FetchResult
12+
from .identifiers import validate_identifier
1213
from .metadata import MetadataCache
1314
from .urls import build_metadata_url, build_prm_url
1415

@@ -23,4 +24,5 @@
2324
"build_metadata_url",
2425
"build_prm_url",
2526
"parse_expires_at",
27+
"validate_identifier",
2628
]

authplane/internal/identifiers.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
"""Validation for resource and issuer identifiers.
2+
3+
RFC 8414 Section 3.3 and RFC 9728 Section 3.3 require the advertised
4+
issuer/resource to be identical to the configured value — a simple string
5+
comparison, not RFC 3986 equivalence. The SDK therefore never rewrites an
6+
identifier: well-known URLs are formed by inserting the well-known path
7+
segment between the authority and the identifier's path (RFC 8414 Section 3 /
8+
RFC 9728 Section 3), and validation rejects structurally unusable identifiers
9+
instead of repairing them.
10+
"""
11+
12+
from urllib.parse import urlparse
13+
14+
15+
def validate_identifier(value: str, label: str) -> str:
16+
"""Validate that *value* is an absolute http(s) URL identifier.
17+
18+
The identifier must have an http or https scheme, an authority, and no
19+
fragment (RFC 8707 Section 2 forbids fragments in resource identifiers).
20+
Returns *value* unchanged — trailing slashes, host case, and explicit
21+
ports are all legal identifier variations and are preserved verbatim.
22+
23+
Raises:
24+
ValueError: When the identifier is structurally invalid.
25+
"""
26+
parsed = urlparse(value)
27+
if parsed.scheme not in ("https", "http"):
28+
raise ValueError(f"{label} must be an absolute http or https URL: {value!r}")
29+
if not parsed.netloc:
30+
raise ValueError(f"{label} must include an authority: {value!r}")
31+
if "#" in value:
32+
raise ValueError(f"{label} must not contain a fragment: {value!r}")
33+
return value

authplane/internal/metadata.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ def __init__(
3030
on_change=on_change,
3131
error_factory=lambda msg: MetadataFetchError(msg),
3232
)
33-
self._expected_issuer = expected_issuer.rstrip("/")
33+
self._expected_issuer = expected_issuer
3434
self._allow_http = allow_http
3535

3636
def _validate_endpoint_url(self, field: str, value: str) -> None:
@@ -50,9 +50,11 @@ def _validate_endpoint_url(self, field: str, value: str) -> None:
5050
)
5151

5252
def _validate_metadata(self, metadata: dict[str, Any]) -> dict[str, Any]:
53-
issuer = str(metadata.get("issuer", "")).rstrip("/")
53+
issuer = str(metadata.get("issuer", ""))
5454
if not issuer:
5555
raise MetadataFetchError("AS metadata missing required 'issuer' field")
56+
# RFC 8414 Section 3.3 — the returned issuer MUST be identical to the
57+
# configured one; simple string comparison, no normalisation.
5658
if self._expected_issuer and issuer != self._expected_issuer:
5759
raise MetadataFetchError(
5860
f"AS metadata issuer mismatch: expected {self._expected_issuer!r}, got {issuer!r}"

authplane/internal/urls.py

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ def build_prm_url(resource: str) -> str:
1212
RFC 9728 Section 3:
1313
https://{host}/.well-known/oauth-protected-resource/{path}
1414
15+
The insertion is a pure string operation: the resource's path is preserved
16+
exactly, including any trailing slash.
17+
1518
Examples:
1619
>>> build_prm_url("https://api.example.com")
1720
'https://api.example.com/.well-known/oauth-protected-resource'
@@ -22,19 +25,17 @@ def build_prm_url(resource: str) -> str:
2225
>>> build_prm_url("https://api.example.com/v2/mcp")
2326
'https://api.example.com/.well-known/oauth-protected-resource/v2/mcp'
2427
28+
>>> build_prm_url("https://api.example.com/mcp/")
29+
'https://api.example.com/.well-known/oauth-protected-resource/mcp/'
30+
2531
Args:
2632
resource: The resource server URI.
2733
2834
Returns:
2935
The fully constructed PRM discovery URL.
3036
"""
3137
parsed = urlparse(resource)
32-
path = parsed.path.strip("/")
33-
34-
if path:
35-
well_known_path = f"/.well-known/oauth-protected-resource/{path}"
36-
else:
37-
well_known_path = "/.well-known/oauth-protected-resource"
38+
well_known_path = "/.well-known/oauth-protected-resource" + parsed.path
3839

3940
return urlunparse(
4041
(
@@ -57,6 +58,9 @@ def build_metadata_url(issuer: str) -> str:
5758
RFC 8414 Section 3:
5859
https://{host}/.well-known/oauth-authorization-server/{path}
5960
61+
The insertion is a pure string operation: the issuer's path is preserved
62+
exactly, including any trailing slash.
63+
6064
Examples:
6165
>>> build_metadata_url("https://auth.example.com")
6266
'https://auth.example.com/.well-known/oauth-authorization-server'
@@ -67,21 +71,17 @@ def build_metadata_url(issuer: str) -> str:
6771
>>> build_metadata_url("https://auth.example.com/org/tenant1")
6872
'https://auth.example.com/.well-known/oauth-authorization-server/org/tenant1'
6973
74+
>>> build_metadata_url("https://auth.example.com/tenant1/")
75+
'https://auth.example.com/.well-known/oauth-authorization-server/tenant1/'
76+
7077
Args:
7178
issuer: The OAuth 2.1 authorization server issuer URL.
7279
7380
Returns:
7481
The fully constructed metadata discovery URL.
7582
"""
7683
parsed = urlparse(issuer)
77-
78-
# Strip leading/trailing slashes from the path to normalize
79-
path = parsed.path.strip("/")
80-
81-
if path:
82-
well_known_path = f"/.well-known/oauth-authorization-server/{path}"
83-
else:
84-
well_known_path = "/.well-known/oauth-authorization-server"
84+
well_known_path = "/.well-known/oauth-authorization-server" + parsed.path
8585

8686
return urlunparse(
8787
(

authplane/verifier/verifier.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
TokenRevokedError,
3636
VerifierRuntimeError,
3737
)
38+
from ..internal.identifiers import validate_identifier
3839
from ..internal.jwt import decode_jwt_header
3940
from ..internal.urls import build_prm_url
4041
from ..oauth.prm import build_prm
@@ -71,7 +72,10 @@ def __init__(
7172
)
7273

7374
self._client = client
74-
self._resource = resource
75+
# RFC 8707 Section 2 — the resource identifier is opaque; validated for
76+
# structure, never rewritten (it is compared verbatim against aud and
77+
# advertised verbatim in PRM).
78+
self._resource = validate_identifier(resource, "resource")
7579
self._scopes = tuple(scopes)
7680
self._allowed_algorithms = allowed_algorithms
7781
self._clock_skew_seconds = clock_skew_seconds

conformance-tests/test_jwt_and_dpop_conformance.py

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,10 +166,42 @@ async def test_rfc9068_typ_must_be_at_jwt(verifier: Any, token_factory: Any) ->
166166

167167

168168
@pytest.mark.conformance("rfc9068-issuer-must-match")
169-
async def test_rfc9068_issuer_must_match(verifier: Any, token_factory: Any) -> None:
169+
async def test_rfc9068_issuer_must_match(
170+
verifier: Any, token_factory: Any, jwks_keypair: dict[str, Any]
171+
) -> None:
170172
with pytest.raises(InvalidClaimsError):
171173
await verifier.verify(token_factory(iss="https://wrong-issuer.com"))
172174

175+
# Variant: a token whose iss is identical to a configured trailing-slash
176+
# issuer must verify — the issuer is never rewritten, end to end:
177+
# discovery resolves the trailing-slash well-known URL (RFC 8414 §3), the
178+
# advertised issuer matches exactly (§3.3), and the token's iss matches
179+
# exactly (RFC 9068 §4).
180+
slash_issuer = "https://auth.example.com/"
181+
with respx.mock:
182+
respx.get("https://auth.example.com/.well-known/oauth-authorization-server/").mock(
183+
return_value=respx.MockResponse(
184+
200,
185+
json={
186+
"issuer": slash_issuer,
187+
"jwks_uri": "https://auth.example.com/.well-known/jwks.json",
188+
},
189+
)
190+
)
191+
respx.get("https://auth.example.com/.well-known/jwks.json").mock(
192+
return_value=respx.MockResponse(200, json=jwks_keypair["jwks"])
193+
)
194+
195+
client = await AuthplaneClient.create(issuer=slash_issuer, fetch_settings=_NO_SSRF)
196+
try:
197+
slash_verifier = client.resource(
198+
resource="https://api.example.com", scopes=["read:data"]
199+
)
200+
claims = await slash_verifier.verify(token_factory(iss=slash_issuer))
201+
assert claims.issuer == slash_issuer
202+
finally:
203+
await client.aclose()
204+
173205

174206
@pytest.mark.conformance("rfc9068-audience-must-match-resource")
175207
async def test_rfc9068_audience_must_match_resource(verifier: Any, token_factory: Any) -> None:
@@ -1113,6 +1145,11 @@ async def test_rfc9728_well_known_path_must_derive_from_resource_uri() -> None:
11131145
build_prm_url("https://api.example.com/v2/mcp")
11141146
== "https://api.example.com/.well-known/oauth-protected-resource/v2/mcp"
11151147
)
1148+
# RFC 9728 §3 insertion preserves the path exactly, including a trailing slash.
1149+
assert (
1150+
build_prm_url("https://api.example.com/mcp/")
1151+
== "https://api.example.com/.well-known/oauth-protected-resource/mcp/"
1152+
)
11161153

11171154

11181155
@pytest.mark.conformance("rfc9728-prm-must-contain-required-fields")

conformance-tests/test_rfc8414_conformance.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,29 @@ async def test_rfc8414_metadata_issuer_must_match_configured_issuer(
3838
fetch_settings=_NO_SSRF,
3939
)
4040

41+
# Variant: a trailing-slash difference is equivalent per RFC 3986 §6.2.3
42+
# but not identical — RFC 8414 §3.3 requires identity, so it must be
43+
# rejected.
44+
with respx.mock:
45+
respx.get("https://auth.example.com/.well-known/oauth-authorization-server").mock(
46+
return_value=respx.MockResponse(
47+
200,
48+
json={
49+
"issuer": "https://auth.example.com/",
50+
"jwks_uri": "https://auth.example.com/.well-known/jwks.json",
51+
},
52+
)
53+
)
54+
respx.get("https://auth.example.com/.well-known/jwks.json").mock(
55+
return_value=respx.MockResponse(200, json=jwks_keypair["jwks"])
56+
)
57+
58+
with pytest.raises(MetadataFetchError, match="issuer mismatch"):
59+
await AuthplaneClient.create(
60+
issuer="https://auth.example.com",
61+
fetch_settings=_NO_SSRF,
62+
)
63+
4164

4265
@pytest.mark.conformance("rfc8414-jwks-uri-required-for-jwt-validation")
4366
async def test_rfc8414_jwks_uri_required_for_jwt_validation() -> None:

tests/internal/test_metadata.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -88,17 +88,19 @@ async def test_get_jwks_uri_missing_field() -> None:
8888
await cache.get_jwks_uri()
8989

9090

91-
async def test_expected_issuer_trailing_slash_is_normalized() -> None:
91+
async def test_expected_issuer_trailing_slash_mismatch_raises() -> None:
92+
# RFC 8414 §3.3 — the returned issuer must be identical to the configured
93+
# one. A trailing-slash difference is equivalent per RFC 3986 §6.2.3 but
94+
# not identical.
9295
fetcher = TrackingFetcher(metadata=SAMPLE_METADATA)
9396
cache = MetadataCache(
9497
fetcher,
9598
expected_issuer="https://auth.example.com/",
9699
document_type="metadata",
97100
)
98101

99-
metadata = await cache.get()
100-
101-
assert metadata["issuer"] == "https://auth.example.com"
102+
with pytest.raises(MetadataFetchError, match="issuer mismatch"):
103+
await cache.get()
102104

103105

104106
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)