diff --git a/CHANGELOG.md b/CHANGELOG.md
index b233822..9f15c73 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,3 +10,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Initial release.
+
+### Changed
+
+- Identifier handling now preserves issuer and resource identity. Issuers are stored and compared
+ byte-for-byte (RFC 8414 §3.3) with no trailing-slash reconciliation; the terminating slash is
+ stripped only when *deriving* a `.well-known` discovery URL (RFC 8414/9728 §3.1). The resource
+ identifier is likewise preserved verbatim: deriving the Protected Resource Metadata path now
+ strips the terminating slash of the resource path (`/mcp/` →
+ `/.well-known/oauth-protected-resource/mcp`, RFC 9728 §3.1) without altering the resource
+ identifier itself.
+
+ **Migration:** If your configured issuer differs from your authorization server's actual
+ identifier by a trailing slash, correct the config — the SDK no longer silently reconciles them.
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..832c200 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,18 @@ 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");
+
+ // Catalog variant: the §3.3 comparison is exact, so a metadata issuer differing from the
+ // configured issuer only by a terminating slash is rejected too. This is the case a
+ // normalizing comparison would silently accept.
+ 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..2c63498 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,30 @@ void rfc9068_issuer_must_match() {
.cause()
.isInstanceOf(InvalidClaimsException.class)
.hasMessageContaining("Issuer mismatch");
+
+ // Catalog variant: an authorization server whose identifier genuinely ends in "/" mints
+ // tokens whose iss carries that slash. Matching is exact in both directions, so the token
+ // verifies — the pair is identical, not normalized into agreement. This is the leg a
+ // trailing-slash-stripping comparison broke: it compared the token's "…/" against a
+ // stripped configured issuer and rejected every token the AS issued.
+ String slashIssuer = baseUrl + "/";
+ wireMock.resetAll();
+ ConformanceTestSupport.stubMetadata(
+ wireMock, Map.of("issuer", slashIssuer, "jwks_uri", baseUrl + "/jwks"));
+ ConformanceTestSupport.stubJwks(wireMock, "/jwks", rsaKeys);
+
+ AuthplaneResource slashVerifier =
+ assertDoesNotThrow(
+ () ->
+ ConformanceTestSupport.buildVerifier(
+ ConformanceTestSupport.buildClient(slashIssuer),
+ TestFixtures.RESOURCE,
+ List.of("read:data")));
+ String slashToken = TestFixtures.token().rsaKey(rsaKeys).issuer(slashIssuer).build();
+
+ VerifiedClaims slashClaims =
+ assertDoesNotThrow(() -> slashVerifier.verify(slashToken).get().claims());
+ assertThat(slashClaims.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..6ed3626 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,13 @@ 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");
+
+ // Catalog variant: a resource identifier published with a terminating slash serves its
+ // metadata at the slash-less well-known path, so identifiers differing only by that slash
+ // resolve to the same document (RFC 9728 §3.1).
+ 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..2bd662c 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);
+ // Store the issuer verbatim (identity is preserved). Any trailing slash is stripped only
+ // where a URL is *derived* (RFC 8414/9728 §3.1), never on the stored/compared identifier.
+ this.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/CircuitPolicy.java b/core/src/main/java/ai/authplane/sdk/core/CircuitPolicy.java
index cddd9f4..60f307f 100644
--- a/core/src/main/java/ai/authplane/sdk/core/CircuitPolicy.java
+++ b/core/src/main/java/ai/authplane/sdk/core/CircuitPolicy.java
@@ -10,8 +10,8 @@
/**
* Decides whether a failure from AS token/introspection/revocation flows should increment the
- * circuit breaker (Python {@code AuthplaneClient._handle_failure} semantics, extended for OAuth
- * business errors vs infra).
+ * circuit breaker, distinguishing OAuth business errors (which do not trip it) from infrastructure
+ * failures (which do).
*/
public final class CircuitPolicy {
diff --git a/core/src/main/java/ai/authplane/sdk/core/dpop/DPoPProofMissingException.java b/core/src/main/java/ai/authplane/sdk/core/dpop/DPoPProofMissingException.java
index 296052c..c9d4e39 100644
--- a/core/src/main/java/ai/authplane/sdk/core/dpop/DPoPProofMissingException.java
+++ b/core/src/main/java/ai/authplane/sdk/core/dpop/DPoPProofMissingException.java
@@ -9,8 +9,7 @@
* depends on the exact semantics of this exception type: "the token is DPoP-bound ({@code cnf.jkt}
* present) but the call site provided no {@code VerificationRequestContext} to bind a proof
* against." The MCP adapter swallows this specific exception in its bearer-only pre-validation pass
- * and defers proof binding to its second hook (the context extractor) — that is the Java equivalent
- * of the TS SDK's FastMCP DPoP workaround.
+ * and defers proof binding to its second hook (the context extractor).
*
*
If you refactor {@code AuthplaneResource.validateDpop} so that this exception is thrown for a
* different reason (e.g. proof present but malformed), update the swallow logic in {@code
diff --git a/core/src/main/java/ai/authplane/sdk/core/fetching/DocumentCache.java b/core/src/main/java/ai/authplane/sdk/core/fetching/DocumentCache.java
index ad08bd7..a2f920b 100644
--- a/core/src/main/java/ai/authplane/sdk/core/fetching/DocumentCache.java
+++ b/core/src/main/java/ai/authplane/sdk/core/fetching/DocumentCache.java
@@ -1,5 +1,6 @@
package ai.authplane.sdk.core.fetching;
+import java.time.Clock;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.locks.ReentrantLock;
@@ -28,6 +29,7 @@ public class DocumentCache {
private final String url;
private final int configuredRefreshSeconds;
private final String documentType; // "JWKS" or "metadata" — for log messages
+ private final Clock clock;
private volatile BiConsumer