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 =