From 01fa2c7a9beb103381c589ee12241be4249b079e Mon Sep 17 00:00:00 2001 From: Roberto Iskandarani Date: Tue, 28 Jul 2026 19:48:21 -0300 Subject: [PATCH] fix(core): stop normalising issuer and resource identifiers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 19 +++++++ .../conformance/ConformanceTestSupport.java | 11 +++- .../conformance/Rfc8414ConformanceTest.java | 11 ++++ .../conformance/Rfc9068ConformanceTest.java | 22 ++++++++ .../conformance/Rfc9728ConformanceTest.java | 6 ++ .../sdk/core/AuthplaneClientBuilder.java | 8 +-- .../authplane/sdk/core/AuthplaneResource.java | 4 +- .../ai/authplane/sdk/core/Identifiers.java | 53 ++++++++++++++++++ .../sdk/core/fetching/MetadataCache.java | 17 ++---- .../sdk/core/fetching/MetadataUrlBuilder.java | 27 ++++----- .../core/prm/ProtectedResourceMetadata.java | 32 +++++------ .../authplane/sdk/core/IdentifiersTest.java | 55 +++++++++++++++++++ .../sdk/core/fetching/MetadataCacheTest.java | 10 ++-- .../core/fetching/MetadataUrlBuilderTest.java | 12 +++- .../prm/ProtectedResourceMetadataTest.java | 33 ++++++++++- 15 files changed, 260 insertions(+), 60 deletions(-) create mode 100644 core/src/main/java/ai/authplane/sdk/core/Identifiers.java create mode 100644 core/src/test/java/ai/authplane/sdk/core/IdentifiersTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index b233822..fe5e10e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,3 +10,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Initial release. + +### Changed + +- 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). `ProtectedResourceMetadata.wellKnownUrl` + / `wellKnownPath` and the RFC 8414 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) URI with an + authority and no fragment) and throw `IllegalArgumentException` 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. + +### Fixed + +- A configured issuer whose identifier legitimately ends in `/` no longer has every token rejected. + The trailing slash was silently stripped at client construction 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. diff --git a/core/src/conformance/java/ai/authplane/sdk/core/conformance/ConformanceTestSupport.java b/core/src/conformance/java/ai/authplane/sdk/core/conformance/ConformanceTestSupport.java index 821a1b5..b938f27 100644 --- a/core/src/conformance/java/ai/authplane/sdk/core/conformance/ConformanceTestSupport.java +++ b/core/src/conformance/java/ai/authplane/sdk/core/conformance/ConformanceTestSupport.java @@ -23,8 +23,17 @@ final class ConformanceTestSupport { private ConformanceTestSupport() {} static void stubMetadata(WireMockServer wireMock, Map metadataDocument) { + stubMetadataAt(wireMock, "/.well-known/oauth-authorization-server", metadataDocument); + } + + /** + * Stubs the metadata document at an explicit path — e.g. the RFC 8414 §3 well-known URL of an + * issuer whose identifier ends in a slash. + */ + static void stubMetadataAt( + WireMockServer wireMock, String path, Map metadataDocument) { wireMock.stubFor( - get(urlEqualTo("/.well-known/oauth-authorization-server")) + get(urlEqualTo(path)) .willReturn( aResponse() .withStatus(200) diff --git a/core/src/conformance/java/ai/authplane/sdk/core/conformance/Rfc8414ConformanceTest.java b/core/src/conformance/java/ai/authplane/sdk/core/conformance/Rfc8414ConformanceTest.java index 6fd7548..3096efc 100644 --- a/core/src/conformance/java/ai/authplane/sdk/core/conformance/Rfc8414ConformanceTest.java +++ b/core/src/conformance/java/ai/authplane/sdk/core/conformance/Rfc8414ConformanceTest.java @@ -58,6 +58,17 @@ void rfc8414_metadata_issuer_must_match_configured_issuer() { Map.of("issuer", "https://evil.example.com", "jwks_uri", baseUrl + "/jwks")); ConformanceTestSupport.stubJwks(wireMock, "/jwks", rsaKeys); + assertThatThrownBy(() -> ConformanceTestSupport.buildClient(baseUrl)) + .isInstanceOf(Exception.class) + .hasMessageContaining("issuer"); + + // Variant: a trailing-slash difference is equivalent per RFC 3986 §6.2.3 but not + // identical — RFC 8414 §3.3 requires identity, so it must be rejected. + wireMock.resetAll(); + ConformanceTestSupport.stubMetadata( + wireMock, Map.of("issuer", baseUrl + "/", "jwks_uri", baseUrl + "/jwks")); + ConformanceTestSupport.stubJwks(wireMock, "/jwks", rsaKeys); + assertThatThrownBy(() -> ConformanceTestSupport.buildClient(baseUrl)) .isInstanceOf(Exception.class) .hasMessageContaining("issuer"); diff --git a/core/src/conformance/java/ai/authplane/sdk/core/conformance/Rfc9068ConformanceTest.java b/core/src/conformance/java/ai/authplane/sdk/core/conformance/Rfc9068ConformanceTest.java index 6e27360..b08c673 100644 --- a/core/src/conformance/java/ai/authplane/sdk/core/conformance/Rfc9068ConformanceTest.java +++ b/core/src/conformance/java/ai/authplane/sdk/core/conformance/Rfc9068ConformanceTest.java @@ -98,6 +98,28 @@ void rfc9068_issuer_must_match() { .cause() .isInstanceOf(InvalidClaimsException.class) .hasMessageContaining("Issuer mismatch"); + + // Variant: a token whose iss is identical to a configured trailing-slash issuer must + // verify — the issuer is never rewritten, end to end: discovery resolves the + // trailing-slash well-known URL (RFC 8414 §3), the advertised issuer matches exactly + // (§3.3), and the token's iss matches exactly (RFC 9068 §4). + String slashIssuer = baseUrl + "/"; + wireMock.resetAll(); + ConformanceTestSupport.stubMetadataAt( + wireMock, + "/.well-known/oauth-authorization-server/", + Map.of("issuer", slashIssuer, "jwks_uri", baseUrl + "/jwks")); + ConformanceTestSupport.stubJwks(wireMock, "/jwks", rsaKeys); + + AuthplaneClient slashClient = + assertDoesNotThrow(() -> ConformanceTestSupport.buildClient(slashIssuer)); + AuthplaneResource slashVerifier = + ConformanceTestSupport.buildVerifier( + slashClient, TestFixtures.RESOURCE, List.of("read:data")); + String acceptedToken = TestFixtures.token().rsaKey(rsaKeys).issuer(slashIssuer).build(); + VerifiedClaims claims = + assertDoesNotThrow(() -> slashVerifier.verify(acceptedToken).get().claims()); + assertThat(claims.issuer()).isEqualTo(slashIssuer); } @Test diff --git a/core/src/conformance/java/ai/authplane/sdk/core/conformance/Rfc9728ConformanceTest.java b/core/src/conformance/java/ai/authplane/sdk/core/conformance/Rfc9728ConformanceTest.java index cbb0569..b6c5d4e 100644 --- a/core/src/conformance/java/ai/authplane/sdk/core/conformance/Rfc9728ConformanceTest.java +++ b/core/src/conformance/java/ai/authplane/sdk/core/conformance/Rfc9728ConformanceTest.java @@ -148,5 +148,11 @@ void rfc9728_well_known_path_must_derive_from_resource_uri() { ProtectedResourceMetadata.wellKnownPath( URI.create("https://api.example.com/v2/mcp"))) .isEqualTo("/.well-known/oauth-protected-resource/v2/mcp"); + + // RFC 9728 §3 insertion preserves the path exactly, including a trailing slash. + assertThat( + ProtectedResourceMetadata.wellKnownPath( + URI.create("https://api.example.com/mcp/"))) + .isEqualTo("/.well-known/oauth-protected-resource/mcp/"); } } diff --git a/core/src/main/java/ai/authplane/sdk/core/AuthplaneClientBuilder.java b/core/src/main/java/ai/authplane/sdk/core/AuthplaneClientBuilder.java index 43811c0..6f64520 100644 --- a/core/src/main/java/ai/authplane/sdk/core/AuthplaneClientBuilder.java +++ b/core/src/main/java/ai/authplane/sdk/core/AuthplaneClientBuilder.java @@ -48,7 +48,9 @@ public final class AuthplaneClientBuilder { AuthplaneClientBuilder(String issuer) { Objects.requireNonNull(issuer, "issuer must not be null"); if (issuer.isBlank()) throw new IllegalArgumentException("issuer must not be blank"); - this.issuer = normalizeIssuer(issuer); + // RFC 8414 §3.3 — the issuer is an opaque identifier compared with simple string + // equality (against metadata and token iss); it is validated but never rewritten. + this.issuer = Identifiers.requireValidIdentifier(issuer, "issuer"); } /** Sets development mode. When true, SSRF protection is relaxed. */ @@ -267,8 +269,4 @@ private void wireMetadataCallback( } }); } - - private static String normalizeIssuer(String issuer) { - return issuer.endsWith("/") ? issuer.substring(0, issuer.length() - 1) : issuer; - } } diff --git a/core/src/main/java/ai/authplane/sdk/core/AuthplaneResource.java b/core/src/main/java/ai/authplane/sdk/core/AuthplaneResource.java index 119c507..2c0bdc9 100644 --- a/core/src/main/java/ai/authplane/sdk/core/AuthplaneResource.java +++ b/core/src/main/java/ai/authplane/sdk/core/AuthplaneResource.java @@ -65,7 +65,9 @@ public class AuthplaneResource { List scopes, ResourceOptions options) { this.client = client; - this.resourceUri = resourceUri; + // RFC 8707 §2 — the resource identifier is opaque; validated for structure, never + // rewritten (it is compared verbatim against aud and advertised verbatim in PRM). + this.resourceUri = Identifiers.requireValidIdentifier(resourceUri, "resourceUri"); this.scopes = List.copyOf(scopes); this.allowedAlgorithms = Set.copyOf(options.allowedAlgorithms()); diff --git a/core/src/main/java/ai/authplane/sdk/core/Identifiers.java b/core/src/main/java/ai/authplane/sdk/core/Identifiers.java new file mode 100644 index 0000000..a8681d2 --- /dev/null +++ b/core/src/main/java/ai/authplane/sdk/core/Identifiers.java @@ -0,0 +1,53 @@ +package ai.authplane.sdk.core; + +import java.net.URI; + +/** + * Validation for 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, not RFC 3986 equivalence. The SDK therefore never + * rewrites an identifier: 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), and validation + * rejects structurally unusable identifiers instead of repairing them. + * + *

Thread-safe — all methods are stateless. + */ +public final class Identifiers { + + private Identifiers() {} + + /** + * Validates that {@code value} is an absolute http(s) URI with an authority and no fragment + * (RFC 8707 §2 forbids fragments in resource identifiers). Returns {@code value} unchanged — + * trailing slashes, host case, and explicit ports are all legal identifier variations and are + * preserved verbatim. + * + * @param value the identifier to validate + * @param label name used in error messages (e.g. "issuer", "resource") + * @return {@code value}, unchanged + * @throws IllegalArgumentException when the identifier is structurally invalid + */ + public static String requireValidIdentifier(String value, String label) { + URI uri; + try { + uri = URI.create(value); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException(label + " is not a valid URI: '" + value + "'", e); + } + String scheme = uri.getScheme(); + if (scheme == null || !(scheme.equals("https") || scheme.equals("http"))) { + throw new IllegalArgumentException( + label + " must be an absolute http or https URI: '" + value + "'"); + } + if (uri.getRawAuthority() == null || uri.getRawAuthority().isEmpty()) { + throw new IllegalArgumentException( + label + " must include an authority: '" + value + "'"); + } + if (uri.getRawFragment() != null) { + throw new IllegalArgumentException( + label + " must not contain a fragment: '" + value + "'"); + } + return value; + } +} diff --git a/core/src/main/java/ai/authplane/sdk/core/fetching/MetadataCache.java b/core/src/main/java/ai/authplane/sdk/core/fetching/MetadataCache.java index a9d4441..1917587 100644 --- a/core/src/main/java/ai/authplane/sdk/core/fetching/MetadataCache.java +++ b/core/src/main/java/ai/authplane/sdk/core/fetching/MetadataCache.java @@ -93,14 +93,14 @@ private void validateMetadata(Map metadata) throws MetadataFetch "OAuth server metadata is missing or has empty 'issuer' field"); } - String normalizedMetadataIssuer = normalizeIssuer(issuer); - String normalizedExpectedIssuer = normalizeIssuer(expectedIssuer); - if (!normalizedExpectedIssuer.equals(normalizedMetadataIssuer)) { + // RFC 8414 §3.3 — the returned issuer MUST be identical to the configured one; + // simple string comparison, no normalisation. + if (!expectedIssuer.equals(issuer)) { throw new MetadataFetchException( "OAuth server metadata issuer mismatch: expected '" - + normalizedExpectedIssuer + + expectedIssuer + "', got '" - + normalizedMetadataIssuer + + issuer + "'"); } @@ -150,11 +150,4 @@ private void validateEndpointUrl(String field, String value) throws MetadataFetc + "'"); } } - - private static String normalizeIssuer(String issuer) { - if (issuer == null) { - return null; - } - return issuer.endsWith("/") ? issuer.substring(0, issuer.length() - 1) : issuer; - } } diff --git a/core/src/main/java/ai/authplane/sdk/core/fetching/MetadataUrlBuilder.java b/core/src/main/java/ai/authplane/sdk/core/fetching/MetadataUrlBuilder.java index 97cf670..ffc2a3c 100644 --- a/core/src/main/java/ai/authplane/sdk/core/fetching/MetadataUrlBuilder.java +++ b/core/src/main/java/ai/authplane/sdk/core/fetching/MetadataUrlBuilder.java @@ -19,36 +19,31 @@ private MetadataUrlBuilder() {} /** * Builds the metadata URL for the given issuer. * + *

The well-known segment is inserted between the authority and the issuer's path — a pure + * string insertion (RFC 8414 §3) that preserves the path exactly, including any trailing slash. + * *

Examples: "https://auth.example.com" → * "https://auth.example.com/.well-known/oauth-authorization-server" * "https://auth.example.com/t1" → * "https://auth.example.com/.well-known/oauth-authorization-server/t1" - * "https://auth.example.com/a/b" → - * "https://auth.example.com/.well-known/oauth-authorization-server/a/b" + * "https://auth.example.com/t1/" → + * "https://auth.example.com/.well-known/oauth-authorization-server/t1/" * - * @param issuer the issuer URI (no trailing slash required) + * @param issuer the issuer URI * @return the metadata discovery URL */ public static String buildMetadataUrl(String issuer) { - URI uri = - URI.create( - issuer.endsWith("/") ? issuer.substring(0, issuer.length() - 1) : issuer); + URI uri = URI.create(issuer); String scheme = uri.getScheme(); - String authority = uri.getAuthority(); // host[:port] - String path = uri.getPath(); // may be null or "" + String authority = uri.getRawAuthority(); // host[:port] + String path = uri.getRawPath(); // may be null or "" StringBuilder sb = new StringBuilder(); sb.append(scheme).append("://").append(authority).append(WELL_KNOWN); - - if (path != null && !path.isEmpty() && !path.equals("/")) { - // Strip leading slash (WELL_KNOWN already starts with /) - String cleanPath = path.startsWith("/") ? path.substring(1) : path; - if (!cleanPath.isEmpty()) { - sb.append("/").append(cleanPath); - } + if (path != null) { + sb.append(path); } - return sb.toString(); } } diff --git a/core/src/main/java/ai/authplane/sdk/core/prm/ProtectedResourceMetadata.java b/core/src/main/java/ai/authplane/sdk/core/prm/ProtectedResourceMetadata.java index ac979e3..882532a 100644 --- a/core/src/main/java/ai/authplane/sdk/core/prm/ProtectedResourceMetadata.java +++ b/core/src/main/java/ai/authplane/sdk/core/prm/ProtectedResourceMetadata.java @@ -55,42 +55,40 @@ private ProtectedResourceMetadata( /** * Computes the URL path at which this resource server should serve its PRM document. * - *

The path is derived from the resource URI by inserting {@code - * /.well-known/oauth-protected-resource} after the authority: + *

The path is formed by inserting {@code /.well-known/oauth-protected-resource} between the + * authority and the resource URI's path (RFC 9728 §3) — a pure string insertion that preserves + * the path exactly, including any trailing slash: * *

-     * "https://api.example.com"        → "/.well-known/oauth-protected-resource"
-     * "https://api.example.com/mcp"    → "/.well-known/oauth-protected-resource/mcp"
-     * "https://api.example.com/v2/mcp" → "/.well-known/oauth-protected-resource/v2/mcp"
+     * "https://api.example.com"         → "/.well-known/oauth-protected-resource"
+     * "https://api.example.com/mcp"     → "/.well-known/oauth-protected-resource/mcp"
+     * "https://api.example.com/v2/mcp"  → "/.well-known/oauth-protected-resource/v2/mcp"
+     * "https://api.example.com/mcp/"    → "/.well-known/oauth-protected-resource/mcp/"
      * 
* * @param resourceUri the resource server URI * @return the URL path (including leading slash) where the PRM should be served */ public static String wellKnownPath(URI resourceUri) { - String path = resourceUri.getPath(); - if (path == null || path.isEmpty() || path.equals("/")) { + String path = resourceUri.getRawPath(); + if (path == null || path.isEmpty()) { return WELL_KNOWN_PREFIX; } - - // Strip leading slash — WELL_KNOWN_PREFIX already starts with / - String cleanPath = path.startsWith("/") ? path.substring(1) : path; - return WELL_KNOWN_PREFIX + "/" + cleanPath; + return WELL_KNOWN_PREFIX + path; } /** * Computes the full URL of the PRM document for the given resource URI. * + *

Always agrees with {@link #wellKnownPath(URI)}: the URL is the resource's scheme and + * authority followed by the well-known path. + * * @param resourceUri the resource server URI string * @return the full PRM document URL */ public static String wellKnownUrl(String resourceUri) { - String stripped = - resourceUri.endsWith("/") - ? resourceUri.substring(0, resourceUri.length() - 1) - : resourceUri; - URI uri = URI.create(stripped); - return uri.getScheme() + "://" + uri.getAuthority() + wellKnownPath(uri); + URI uri = URI.create(resourceUri); + return uri.getScheme() + "://" + uri.getRawAuthority() + wellKnownPath(uri); } // ----------------------------------------------------------------------- diff --git a/core/src/test/java/ai/authplane/sdk/core/IdentifiersTest.java b/core/src/test/java/ai/authplane/sdk/core/IdentifiersTest.java new file mode 100644 index 0000000..6a06c37 --- /dev/null +++ b/core/src/test/java/ai/authplane/sdk/core/IdentifiersTest.java @@ -0,0 +1,55 @@ +package ai.authplane.sdk.core; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.Test; + +class IdentifiersTest { + + @Test + void validIdentifiers_returnedUnchanged() { + // Trailing slashes, host case, and explicit ports are legal variations — preserved. + for (String value : + java.util.List.of( + "https://auth.example.com", + "https://auth.example.com/", + "https://auth.example.com/tenant/", + "https://Auth.Example.com:443/t1", + "http://localhost:8080/issuer")) { + assertThat(Identifiers.requireValidIdentifier(value, "issuer")).isSameAs(value); + } + } + + @Test + void nonHttpScheme_rejected() { + assertThatThrownBy( + () -> Identifiers.requireValidIdentifier("ftp://x.example.com", "issuer")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("http"); + } + + @Test + void missingScheme_rejected() { + assertThatThrownBy(() -> Identifiers.requireValidIdentifier("auth.example.com", "issuer")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void missingAuthority_rejected() { + assertThatThrownBy(() -> Identifiers.requireValidIdentifier("https:example.com", "issuer")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("authority"); + } + + @Test + void fragment_rejected() { + // RFC 8707 §2 — resource identifiers must not contain a fragment. + assertThatThrownBy( + () -> + Identifiers.requireValidIdentifier( + "https://api.example.com/mcp#frag", "resourceUri")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("fragment"); + } +} diff --git a/core/src/test/java/ai/authplane/sdk/core/fetching/MetadataCacheTest.java b/core/src/test/java/ai/authplane/sdk/core/fetching/MetadataCacheTest.java index 9744506..e2cdd21 100644 --- a/core/src/test/java/ai/authplane/sdk/core/fetching/MetadataCacheTest.java +++ b/core/src/test/java/ai/authplane/sdk/core/fetching/MetadataCacheTest.java @@ -42,15 +42,17 @@ void getJwksUri_validMetadata_returnsJwksUri() throws Exception { } @Test - void getJwksUri_normalizesTrailingSlashOnIssuer() throws Exception { - // Metadata declares issuer with trailing slash; configured value doesn't. - // normalizeIssuer should strip the slash before comparison. + void getJwksUri_trailingSlashIssuerMismatch_throws() { + // RFC 8414 §3.3 — the returned issuer must be identical to the configured one. + // A trailing-slash difference is equivalent per RFC 3986 §6.2.3 but not identical. MetadataCache cache = cacheWith( Map.of("issuer", ISSUER + "/", "jwks_uri", "https://auth.example.com/jwks"), false); - assertThat(cache.getJwksUri()).isEqualTo("https://auth.example.com/jwks"); + assertThatThrownBy(cache::getJwksUri) + .isInstanceOf(MetadataFetchException.class) + .hasMessageContaining("issuer mismatch"); } @Test diff --git a/core/src/test/java/ai/authplane/sdk/core/fetching/MetadataUrlBuilderTest.java b/core/src/test/java/ai/authplane/sdk/core/fetching/MetadataUrlBuilderTest.java index f270081..656c156 100644 --- a/core/src/test/java/ai/authplane/sdk/core/fetching/MetadataUrlBuilderTest.java +++ b/core/src/test/java/ai/authplane/sdk/core/fetching/MetadataUrlBuilderTest.java @@ -13,9 +13,17 @@ void issuerWithNoPath() { } @Test - void issuerWithTrailingSlash() { + void issuerWithTrailingSlash_pathIsPreserved() { + // RFC 8414 §3 — pure insertion; the issuer's path (here "/") is kept verbatim. assertThat(MetadataUrlBuilder.buildMetadataUrl("https://auth.example.com/")) - .isEqualTo("https://auth.example.com/.well-known/oauth-authorization-server"); + .isEqualTo("https://auth.example.com/.well-known/oauth-authorization-server/"); + } + + @Test + void issuerPathWithTrailingSlash_pathIsPreserved() { + assertThat(MetadataUrlBuilder.buildMetadataUrl("https://auth.example.com/tenant/")) + .isEqualTo( + "https://auth.example.com/.well-known/oauth-authorization-server/tenant/"); } @Test diff --git a/core/src/test/java/ai/authplane/sdk/core/prm/ProtectedResourceMetadataTest.java b/core/src/test/java/ai/authplane/sdk/core/prm/ProtectedResourceMetadataTest.java index b7c5804..4c474d2 100644 --- a/core/src/test/java/ai/authplane/sdk/core/prm/ProtectedResourceMetadataTest.java +++ b/core/src/test/java/ai/authplane/sdk/core/prm/ProtectedResourceMetadataTest.java @@ -112,9 +112,38 @@ void toMap_isUnmodifiable() { } @Test - void wellKnownUrl_trailingSlash_stripped() { + void wellKnownUrl_trailingSlash_preserved() { + // RFC 9728 §3 — pure insertion; the resource's path (here "/") is kept verbatim. assertThat(ProtectedResourceMetadata.wellKnownUrl("https://api.example.com/")) - .isEqualTo("https://api.example.com/.well-known/oauth-protected-resource"); + .isEqualTo("https://api.example.com/.well-known/oauth-protected-resource/"); + } + + @Test + void wellKnownUrl_pathTrailingSlash_preserved() { + assertThat(ProtectedResourceMetadata.wellKnownUrl("https://api.example.com/mcp/")) + .isEqualTo("https://api.example.com/.well-known/oauth-protected-resource/mcp/"); + } + + @Test + void wellKnownUrl_agreesWithWellKnownPath() { + // Class invariant: the URL helper is always scheme + authority + the path helper's + // answer, for every input shape. + for (String resource : + java.util.List.of( + "https://api.example.com", + "https://api.example.com/", + "https://api.example.com/mcp", + "https://api.example.com/mcp/", + "https://api.example.com/v2/mcp", + "https://api.example.com:8443/mcp/")) { + URI uri = URI.create(resource); + assertThat(ProtectedResourceMetadata.wellKnownUrl(resource)) + .isEqualTo( + uri.getScheme() + + "://" + + uri.getRawAuthority() + + ProtectedResourceMetadata.wellKnownPath(uri)); + } } @Test