From 014bea4ed342091dc2f02e23bc1598bf513d5eac Mon Sep 17 00:00:00 2001 From: Roberto Iskandarani Date: Thu, 30 Jul 2026 13:54:17 -0300 Subject: [PATCH] fix(core): drop trailing slashes when deriving the PRM well-known path RFC 9728 section 3 inserts the well-known segment between the authority and the resource path. Resource identifiers that differ only by a trailing slash must resolve to the same metadata document, so ProtectedResourceMetadata.wellKnownPath now drops trailing slashes from the resource path before appending it: https://api.example.com/mcp/ -> /.well-known/oauth-protected-resource/mcp https://api.example.com/ -> /.well-known/oauth-protected-resource This matches the derivation already shipped by the TypeScript, Python, and Rust SDKs. Only the well-known path derivation normalizes; the resource field in the PRM document and issuer/resource identity comparisons remain exact-string. wellKnownUrl now delegates the normalization to wellKnownPath instead of pre-stripping a single trailing slash itself. --- .../conformance/Rfc9728ConformanceTest.java | 10 ++++++++ .../core/prm/ProtectedResourceMetadata.java | 24 +++++++++++++------ .../prm/ProtectedResourceMetadataTest.java | 20 ++++++++++++++++ 3 files changed, 47 insertions(+), 7 deletions(-) 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..9e67815 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,15 @@ 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"); + + // Trailing slashes on the resource path are dropped — identifiers differing + // only by a trailing slash resolve to the same metadata document (RFC 9728 §3). + assertThat( + ProtectedResourceMetadata.wellKnownPath( + URI.create("https://api.example.com/mcp/"))) + .isEqualTo("/.well-known/oauth-protected-resource/mcp"); + + assertThat(ProtectedResourceMetadata.wellKnownPath(URI.create("https://api.example.com/"))) + .isEqualTo("/.well-known/oauth-protected-resource"); } } 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..a0e2f80 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 @@ -56,11 +56,15 @@ 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: + * /.well-known/oauth-protected-resource} after the authority. Trailing slashes on the resource + * path are dropped, so identifiers differing only by a trailing slash resolve to the same + * metadata document (RFC 9728 §3): * *

      * "https://api.example.com"        → "/.well-known/oauth-protected-resource"
+     * "https://api.example.com/"       → "/.well-known/oauth-protected-resource"
      * "https://api.example.com/mcp"    → "/.well-known/oauth-protected-resource/mcp"
+     * "https://api.example.com/mcp/"   → "/.well-known/oauth-protected-resource/mcp"
      * "https://api.example.com/v2/mcp" → "/.well-known/oauth-protected-resource/v2/mcp"
      * 
* @@ -69,7 +73,15 @@ private ProtectedResourceMetadata( */ public static String wellKnownPath(URI resourceUri) { String path = resourceUri.getPath(); - if (path == null || path.isEmpty() || path.equals("/")) { + if (path == null || path.isEmpty()) { + return WELL_KNOWN_PREFIX; + } + + // Drop trailing slashes — "/mcp/" and "/mcp" must map to the same document + while (path.endsWith("/")) { + path = path.substring(0, path.length() - 1); + } + if (path.isEmpty()) { return WELL_KNOWN_PREFIX; } @@ -81,15 +93,13 @@ public static String wellKnownPath(URI resourceUri) { /** * Computes the full URL of the PRM document for the given resource URI. * + *

Applies the same trailing-slash normalization as {@link #wellKnownPath(URI)}. + * * @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); + URI uri = URI.create(resourceUri); return uri.getScheme() + "://" + uri.getAuthority() + wellKnownPath(uri); } 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..45f8d9e 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 @@ -27,6 +27,20 @@ void wellKnownPath_resourceWithPath() { .isEqualTo("/.well-known/oauth-protected-resource/mcp"); } + @Test + void wellKnownPath_rootResourceWithTrailingSlash() { + assertThat(ProtectedResourceMetadata.wellKnownPath(URI.create("https://api.example.com/"))) + .isEqualTo("/.well-known/oauth-protected-resource"); + } + + @Test + void wellKnownPath_resourceWithTrailingSlash_dropped() { + assertThat( + ProtectedResourceMetadata.wellKnownPath( + URI.create("https://api.example.com/mcp/"))) + .isEqualTo("/.well-known/oauth-protected-resource/mcp"); + } + @Test void wellKnownPath_resourceWithDeepPath() { assertThat( @@ -117,6 +131,12 @@ void wellKnownUrl_trailingSlash_stripped() { .isEqualTo("https://api.example.com/.well-known/oauth-protected-resource"); } + @Test + void wellKnownUrl_pathWithTrailingSlash_stripped() { + assertThat(ProtectedResourceMetadata.wellKnownUrl("https://api.example.com/mcp/")) + .isEqualTo("https://api.example.com/.well-known/oauth-protected-resource/mcp"); + } + @Test void toJson_producesValidJson() { var prm =