fix(core): stop normalising issuer and resource identifiers - #15
fix(core): stop normalising issuer and resource identifiers#15RobertoIskandarani wants to merge 1 commit into
Conversation
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 trailing slashes in four places: - AuthplaneClientBuilder rewrote the configured issuer. The rewritten value became the expected iss at token verification (JwtValidator), so an AS whose issuer identifier legitimately ends in "/" had every token rejected — RFC 9068 requires iss to carry the slash. - ProtectedResourceMetadata.wellKnownUrl stripped a trailing slash while its sibling wellKnownPath preserved it, so the two helpers in one class disagreed about the same resource. Both are now pure insertion (raw path appended verbatim), with a test pinning that they always agree. - MetadataUrlBuilder stripped the issuer path's trailing slash against the RFC 8414 §3 insertion rule. - MetadataCache normalised 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) URI with an authority, no fragment — RFC 8707 §2) via the new Identifiers utility, and never transformed. Derivation uses the raw path so percent-encoded octets are preserved as sent. 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.
muralx
left a comment
There was a problem hiding this comment.
The identity half of this PR — storing the issuer verbatim and comparing iss/metadata issuer
byte-for-byte — is correct. The derivation half is not: RFC 8414 §3.3 (identity) and RFC 8414 §3.1
(URL construction) are separate rules, and this PR applies the §3.3 rule to §3.1.
Findings
Critical
-
Critical —
buildMetadataUrlno longer removes the terminating/before insertion, which
RFC 8414 §3.1 requires.
./core/src/main/java/ai/authplane/sdk/core/fetching/MetadataUrlBuilder.java:44-46appends
getRawPath()verbatim.RFC 8414 §3.1: "If the issuer identifier value contains a path component, any terminating
/MUST
be removed before inserting/.well-known/and the well-known URI path suffix between the host
component and the path component."So
https://auth.example.com/must resolve to…/.well-known/oauth-authorization-server, not
…/oauth-authorization-server/, andhttps://auth.example.com/tenant/to
…/oauth-authorization-server/tenant. This breaks exactly the deployment the PR sets out to fix:
trailing-slash issuers (the Auth0 shape,https://tenant.example.com/) serve their metadata at the
slash-free well-known URL, so discovery now 404s against every real one. The new conformance case
only passes because
./core/src/conformance/java/ai/authplane/sdk/core/conformance/Rfc9068ConformanceTest.java:108-111
stubs the mock at/.well-known/oauth-authorization-server/— the test encodes the bug rather than
catching it../core/src/test/java/ai/authplane/sdk/core/fetching/MetadataUrlBuilderTest.java:18,24
pin the same wrong expectations. Fix: keep the issuer stored verbatim (that part is correct and is
what makes the §3.3 comparison and theisscheck work), and strip only the path's terminating
/at derivation time. The two are independent — §3.3 compares against "the issuer identifier value
into which the well-known URI string was inserted", i.e. the configured value with its slash, not the
derived URL. -
Critical — Same defect on the RFC 9728 side, where it changes where the SDK serves PRM.
./core/src/main/java/ai/authplane/sdk/core/prm/ProtectedResourceMetadata.java:72-78,89-91. RFC 9728
§3.1 carries the identical terminating-/removal clause for the resource identifier. With
resource = https://api.example.com/mcp/, the SpringRouterFunction
(./spring/src/main/java/ai/authplane/sdk/spring/security/AuthplaneSecurityConfig.java:272) and
PrmServletnow register/.well-known/oauth-protected-resource/mcp/, while a spec-conformant
client derives/.well-known/oauth-protected-resource/mcp. Spring Framework 6 dropped trailing-slash
match by default, so that client gets a 404 and the whole discovery chain dies. The underlying bug
identified here —wellKnownUrlandwellKnownPathdisagreeing — is real; fix it by making both
strip the terminating slash, and keep the newwellKnownUrl_agreesWithWellKnownPathinvariant test
(./core/src/test/java/ai/authplane/sdk/core/prm/ProtectedResourceMetadataTest.java:128), which is a
good addition either way.
Major
-
Major — The
resourceidentifier is now restricted to http(s)-with-authority, which is stricter
than RFC 8707 §2 and than the released SDKs.
./core/src/main/java/ai/authplane/sdk/core/AuthplaneResource.java:70routes the resource through
the same validator as the issuer. RFC 8707 §2 requires only an absolute URI without a fragment —
urn:example:apiis a legal resource indicator. RFC 8414 §2 does requirehttpsfor the issuer,
so the two labels want different rules. As written, previously-working configurations start throwing
IllegalArgumentExceptionatAuthplaneClient.resource(...). Split the predicate: issuer →
absolute + https (+ no query/fragment), resource → absolute + no fragment, with the http(s)+authority
requirement enforced only where PRM derivation actually needs an origin. -
Major — Issuer validation misses the query component while the derived URL silently drops it.
./core/src/main/java/ai/authplane/sdk/core/Identifiers.java:38-50rejects fragments only. RFC 8414
§2: the issuer identifier "MUST NOT include a query component or fragment component". Today
https://auth.example.com/t?x=1passes validation, thenbuildMetadataUrldiscards?x=1— a
silent rewrite, which is precisely what this PR is trying to eliminate. Reject it instead. -
Major — The exact-match issuer comparison diverges from the three released SDKs.
./core/src/main/java/ai/authplane/sdk/core/fetching/MetadataCache.java:98. Java is the RFC-correct
one here — §3.3 says identical, not equivalent — but Go, TypeScript and Python all trim the trailing
slash on both sides before comparing, so an AS that advertiseshttps://as.example.com/against a
configuredhttps://as.example.comkeeps working in three SDKs and starts failing only in Java.
MetadataCacheTest.getJwksUri_trailingSlashIssuerMismatch_throwspins that divergence. Land it with
a tracking ticket for the coordinated change across the other SDKs, or hold it until they move
together; a one-language behaviour change on a critical-priority validation path is an interop split. -
Major — Three conformance cases now assert things the catalog doesn't say. The catalog is the
source of truth (../conformance/oauth-sdk-conformance-catalog.yaml, present in the sandbox and
checked):rfc9728-well-known-path-must-derive-from-resource-uri(catalog:1361-1382) declares exactly three
resources and aresult_shapeof three entries;
./core/src/conformance/java/ai/authplane/sdk/core/conformance/Rfc9728ConformanceTest.java:152-155
adds a fourth (/mcp/→…/oauth-protected-resource/mcp/) that is absent from the catalog and, per
the first finding, contradicts RFC 9728 §3.1.rfc8414-metadata-issuer-must-match-configured-issuer(catalog:115-133) has a single datum
(https://evil.example.com); the trailing-slash variant at
./core/src/conformance/java/ai/authplane/sdk/core/conformance/Rfc8414ConformanceTest.java:64-71
is new behaviour, not catalogued.rfc9068-issuer-must-match(catalog:759-774) isoutcome: reject;
./core/src/conformance/java/ai/authplane/sdk/core/conformance/Rfc9068ConformanceTest.java:102-122
bolts an accept path onto the same@ConformanceCase, so the case no longer maps 1:1 to its entry.
Catalog entries first (new ids for the new assertions), SDK tests second.
Minor
-
Minor —
Identifiersis a newpublictype in a non-internalpackage
(./core/src/main/java/ai/authplane/sdk/core/Identifiers.java:16). It's an internal validation
helper with no consumer use case; making it public commits it to the API surface permanently.
Package-private, or move it next to the other validation utilities. Low severity only because the
Java SDK is unreleased. -
Minor — Scheme comparison is case-sensitive.
./core/src/main/java/ai/authplane/sdk/core/Identifiers.java:39—HTTPS://auth.example.comis
rejected. RFC 3986 §3.1 makes schemes case-insensitive, and the javadoc two lines up already accepts
host case as a legal variation. UseequalsIgnoreCase. -
Minor —
requireValidIdentifier(null, …)throws a bare NPE out ofURI.create, bypassing the
labelled messages../core/src/main/java/ai/authplane/sdk/core/Identifiers.java:34. Both current
callers null-check first, but the method is public. Add
Objects.requireNonNull(value, label + " must not be null"). -
Minor — The new throw isn't tested at the boundary that users hit.
./core/src/test/java/ai/authplane/sdk/core/IdentifiersTest.javaexercises the helper directly;
nothing coversAuthplaneClient.resource("not-a-uri", …)or
AuthplaneClient.builder("not-a-uri"), which is where the behaviour change actually lands (and where
Spring Boot users will meet it as a context-startup failure). -
Minor — Pre-existing sibling-SDK reference in a file this PR edits.
./core/src/main/java/ai/authplane/sdk/core/fetching/MetadataCache.java:14— "matching the
Python/Go pattern" ships to the OSS repo. Not introduced here, so Minor, but the file is already
being touched: "Validates issuer and endpoint URLs internally when metadata is fetched." keeps the
whole meaning. (./core/src/main/java/ai/authplane/sdk/core/CircuitPolicy.java:13has the same
problem, out of scope.)
Nits
-
Nit —
./core/src/test/java/ai/authplane/sdk/core/IdentifiersTest.java:12-21loops over inputs
inside one@Testwith a fully-qualifiedjava.util.List.of— a failure won't name the offending
input.@ParameterizedTest+@ValueSource(strings = {...}). -
Nit —
slashClientatRfc9068ConformanceTest.java:114is never closed, leaking its refresh
executor for the suite lifetime. Consistent with the surrounding tests, so take it or leave it.
What
RFC 8414 §3.3 and RFC 9728 §3.3 require the advertised issuer/resource to be identical to the configured value ("If these values are not identical, the data contained in the response MUST NOT be used") — a simple string comparison that exists to defeat metadata substitution. Both well-known URLs are formed by inserting the well-known path segment between the authority and the identifier's path (RFC 8414 §3 / RFC 9728 §3), with no normalisation step. The SDK instead stripped trailing slashes in four places; this PR removes all four and replaces silent rewriting with construction-time validation.
The user-visible bug
AuthplaneClientBuilderrewrote the configured issuer (issuer.endsWith("/") ? …substring… : issuer), and that rewritten value becameJwtValidator's expectediss. For an authorization server whose issuer identifier legitimately ends in/, RFC 9068 requires the token'sissto carry the trailing slash — so every token was rejected for such a deployment. Verified by reintroducing the strip: the new end-to-end conformance variant fails (discovery resolves the wrong well-known URL), and passes with the fix.The four sites
AuthplaneClientBuilder— issuer stored verbatim; validated, never rewritten.ProtectedResourceMetadata.wellKnownUrl— stripped a trailing slash while its siblingwellKnownPathpreserved it, so the two helpers in one class disagreed about the same resource. Both are now pure insertion (raw path appended verbatim), with a test pinning that they always agree across input shapes.MetadataUrlBuilder— pure insertion; issuerhttps://auth.example.com/tenant/now resolves to/.well-known/oauth-authorization-server/tenant/.MetadataCache— issuer comparison is an exact string match; a document advertisinghttps://as.example.com/no longer passes against a configuredhttps://as.example.com.Validation instead of repair
New
Identifiers.requireValidIdentifier: identifiers must be absolute http(s) URIs with an authority and no fragment (RFC 8707 §2). Trailing slashes, host case, and explicit ports are legal variations and are preserved verbatim. Applied atAuthplaneClientBuilder(issuer) andAuthplaneResource(resource). Derivation now usesgetRawPath()/getRawAuthority()so percent-encoded octets are preserved as sent.Conformance
rfc9728-well-known-path-must-derive-from-resource-uri— extended with the trailing-slash resource datum (/mcp/→/.well-known/oauth-protected-resource/mcp/).rfc8414-metadata-issuer-must-match-configured-issuer— variant: metadata issuer differing from the configured one only by a trailing slash is rejected (equivalent per RFC 3986 §6.2.3 is not identical).rfc9068-issuer-must-match— variant: a token whoseissis identical to a configured trailing-slash issuer verifies end to end, including discovery at the trailing-slash well-known URL.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. (CHANGELOG entry included.)
Validation
mvn verifygreen across core/mcp/spring — 735 core tests (spotless, checkstyle, conformance suite included).