Skip to content

fix(core): stop normalising issuer and resource identifiers - #15

Open
RobertoIskandarani wants to merge 1 commit into
mainfrom
fix/identifier-identity-trailing-slash
Open

fix(core): stop normalising issuer and resource identifiers#15
RobertoIskandarani wants to merge 1 commit into
mainfrom
fix/identifier-identity-trailing-slash

Conversation

@RobertoIskandarani

Copy link
Copy Markdown
Collaborator

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

AuthplaneClientBuilder rewrote the configured issuer (issuer.endsWith("/") ? …substring… : issuer), and that rewritten value became JwtValidator's expected iss. For an authorization server whose issuer identifier legitimately ends in /, RFC 9068 requires the token's iss to 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 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 across input shapes.
  • MetadataUrlBuilder — pure insertion; issuer https://auth.example.com/tenant/ now resolves to /.well-known/oauth-authorization-server/tenant/.
  • MetadataCache — issuer comparison is an exact string match; a document advertising https://as.example.com/ no longer passes against a configured https://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 at AuthplaneClientBuilder (issuer) and AuthplaneResource (resource). Derivation now uses getRawPath()/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 whose iss is 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 verify green across core/mcp/spring — 735 core tests (spotless, checkstyle, conformance suite included).

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.
@RobertoIskandarani
RobertoIskandarani requested a review from a team as a code owner July 28, 2026 22:48
@RobertoIskandarani RobertoIskandarani self-assigned this Jul 28, 2026

@muralx muralx left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  • CriticalbuildMetadataUrl no longer removes the terminating / before insertion, which
    RFC 8414 §3.1 requires.
    ./core/src/main/java/ai/authplane/sdk/core/fetching/MetadataUrlBuilder.java:44-46 appends
    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/, and https://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 the iss check 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 Spring RouterFunction
    (./spring/src/main/java/ai/authplane/sdk/spring/security/AuthplaneSecurityConfig.java:272) and
    PrmServlet now 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 — wellKnownUrl and wellKnownPath disagreeing — is real; fix it by making both
    strip the terminating slash, and keep the new wellKnownUrl_agreesWithWellKnownPath invariant test
    (./core/src/test/java/ai/authplane/sdk/core/prm/ProtectedResourceMetadataTest.java:128), which is a
    good addition either way.

Major

  • Major — The resource identifier 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:70 routes the resource through
    the same validator as the issuer. RFC 8707 §2 requires only an absolute URI without a fragment —
    urn:example:api is a legal resource indicator. RFC 8414 §2 does require https for the issuer,
    so the two labels want different rules. As written, previously-working configurations start throwing
    IllegalArgumentException at AuthplaneClient.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-50 rejects fragments only. RFC 8414
    §2: the issuer identifier "MUST NOT include a query component or fragment component". Today
    https://auth.example.com/t?x=1 passes validation, then buildMetadataUrl discards ?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 advertises https://as.example.com/ against a
    configured https://as.example.com keeps working in three SDKs and starts failing only in Java.
    MetadataCacheTest.getJwksUri_trailingSlashIssuerMismatch_throws pins 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 a result_shape of 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) is outcome: 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

  • MinorIdentifiers is a new public type in a non-internal package
    (./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:39HTTPS://auth.example.com is
    rejected. RFC 3986 §3.1 makes schemes case-insensitive, and the javadoc two lines up already accepts
    host case as a legal variation. Use equalsIgnoreCase.

  • MinorrequireValidIdentifier(null, …) throws a bare NPE out of URI.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.java exercises the helper directly;
    nothing covers AuthplaneClient.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:13 has the same
    problem, out of scope.)

Nits

  • Nit./core/src/test/java/ai/authplane/sdk/core/IdentifiersTest.java:12-21 loops over inputs
    inside one @Test with a fully-qualified java.util.List.of — a failure won't name the offending
    input. @ParameterizedTest + @ValueSource(strings = {...}).

  • NitslashClient at Rfc9068ConformanceTest.java:114 is never closed, leaking its refresh
    executor for the suite lifetime. Consistent with the surrounding tests, so take it or leave it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants