> suiteClasses = new ArrayList<>();
String packageName = ConformanceCatalogTest.class.getPackageName();
String packagePath = packageName.replace('.', '/');
ClassLoader classLoader = Thread.currentThread().getContextClassLoader();
@@ -145,6 +177,7 @@ private static SuiteScan scanConformanceSuites() throws Exception {
if (!clazz.isAnnotationPresent(ConformanceSuite.class)) {
continue;
}
+ suiteClasses.add(clazz);
for (Method method : clazz.getDeclaredMethods()) {
ConformanceCase mapping = method.getAnnotation(ConformanceCase.class);
if (mapping != null) {
@@ -153,7 +186,7 @@ private static SuiteScan scanConformanceSuites() throws Exception {
}
}
}
- return new SuiteScan(ids, loadFailures);
+ return new SuiteScan(ids, loadFailures, suiteClasses);
}
/**
diff --git a/core/src/conformance/java/ai/authplane/sdk/core/conformance/ConformanceCoverageLevel.java b/core/src/conformance/java/ai/authplane/sdk/core/conformance/ConformanceCoverageLevel.java
index 850dd29..820dd5e 100644
--- a/core/src/conformance/java/ai/authplane/sdk/core/conformance/ConformanceCoverageLevel.java
+++ b/core/src/conformance/java/ai/authplane/sdk/core/conformance/ConformanceCoverageLevel.java
@@ -2,7 +2,13 @@
public enum ConformanceCoverageLevel {
FULL("full"),
- PARTIAL("partial");
+ PARTIAL("partial"),
+ /**
+ * No part of the case is exercised. Distinct from {@link #PARTIAL}: the catalog's report
+ * contract uses the level to tell a consciously deferred case apart from one that is partly
+ * covered, so a case whose behaviour is absent altogether must not report as partial.
+ */
+ NONE("none");
private final String wireValue;
diff --git a/core/src/conformance/java/ai/authplane/sdk/core/conformance/ConformanceRunStateTest.java b/core/src/conformance/java/ai/authplane/sdk/core/conformance/ConformanceRunStateTest.java
index c0bb669..1847584 100644
--- a/core/src/conformance/java/ai/authplane/sdk/core/conformance/ConformanceRunStateTest.java
+++ b/core/src/conformance/java/ai/authplane/sdk/core/conformance/ConformanceRunStateTest.java
@@ -24,6 +24,7 @@ void close_writesJsonAndMarkdownReports() throws Exception {
cases:
- id: "case-a"
- id: "case-b"
+ - id: "case-c"
""");
ConformanceRunState state =
@@ -48,6 +49,12 @@ void close_writesJsonAndMarkdownReports() throws Exception {
ConformanceStatus.PASSED,
null,
annotatedCoverage());
+ state.recordMapped(
+ "case-c",
+ "ai.authplane.sdk.core.conformance.ExampleConformanceTest#caseC",
+ ConformanceStatus.SKIPPED,
+ null,
+ noneCoverage());
state.recordUncatalogued(
"ai.authplane.sdk.core.conformance.HarnessSmokeTest#helper",
ConformanceStatus.PASSED,
@@ -64,6 +71,11 @@ void close_writesJsonAndMarkdownReports() throws Exception {
assertThat(json).contains("\"case_id\":\"case-b\"");
assertThat(json).contains("\"status\":\"passed\"");
assertThat(json).contains("\"coverage\":{\"level\":\"partial\"");
+ // The NONE constant's wire value, asserted end-to-end like partial's: a consciously
+ // deferred case must reach the report as "none", never as an absent or partial level.
+ assertThat(json).contains("\"case_id\":\"case-c\"");
+ assertThat(json).contains("\"status\":\"skipped\"");
+ assertThat(json).contains("\"coverage\":{\"level\":\"none\"");
assertThat(json).contains("\"gaps\":[\"expected.error_hint\"]");
assertThat(json)
.contains(
@@ -74,6 +86,7 @@ void close_writesJsonAndMarkdownReports() throws Exception {
assertThat(markdown).contains("`failed`");
assertThat(markdown).contains("`case-b`");
assertThat(markdown).contains("`partial`");
+ assertThat(markdown).contains("`none`");
assertThat(markdown).contains("## Coverage Notes");
assertThat(markdown).contains("## Uncatalogued Test Details");
}
@@ -90,4 +103,15 @@ private static ConformanceCoverage annotatedCoverage() throws NoSuchMethodExcept
.getDeclaredMethod("coverageFixture")
.getAnnotation(ConformanceCoverage.class);
}
+
+ @ConformanceCoverage(
+ level = ConformanceCoverageLevel.NONE,
+ note = "Deferred: the gate this case requires is not implemented yet.")
+ private static void noneCoverageFixture() {}
+
+ private static ConformanceCoverage noneCoverage() throws NoSuchMethodException {
+ return ConformanceRunStateTest.class
+ .getDeclaredMethod("noneCoverageFixture")
+ .getAnnotation(ConformanceCoverage.class);
+ }
}
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..fe4290b 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
@@ -4,7 +4,7 @@
import static com.github.tomakehurst.wiremock.client.WireMock.get;
import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo;
-import java.lang.reflect.Method;
+import java.time.Clock;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ExecutionException;
@@ -98,9 +98,14 @@ static Throwable unwrapExecutionException(Throwable throwable) {
return cursor;
}
- static void forceMetadataRefresh(AuthplaneClient client) throws Exception {
- Method method = AuthplaneClient.class.getDeclaredMethod("forceMetadataRefreshForTest");
- method.setAccessible(true);
- method.invoke(client);
+ /**
+ * Builds a client whose caches read time from {@code clock}, so a refresh interval can be
+ * crossed by advancing the clock. The client is otherwise ordinary: the suite reaches
+ * refresh-driven behaviour through normal verification calls, never through a test-only
+ * trigger.
+ */
+ static AuthplaneClient buildClient(String issuer, Clock clock, int metadataRefreshSeconds)
+ throws Exception {
+ return TestFixtures.clientWithClock(issuer, clock, metadataRefreshSeconds);
}
}
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 832c200..7b04455 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
@@ -2,6 +2,7 @@
import static com.github.tomakehurst.wiremock.client.WireMock.aResponse;
import static com.github.tomakehurst.wiremock.client.WireMock.get;
+import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor;
import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -28,6 +29,8 @@
@ConformanceSuite
class Rfc8414ConformanceTest extends AbstractPlaceholderConformanceTest {
+ private static final String WELL_KNOWN_PATH = "/.well-known/oauth-authorization-server";
+
private static WireMockServer wireMock;
private static String baseUrl;
private static TestFixtures.RSAKeyPair rsaKeys;
@@ -116,9 +119,9 @@ void rfc8414_discovery_url_must_insert_well_known_before_issuer_path() throws Ex
baseUrl + "/jwks")))));
ConformanceTestSupport.stubJwks(wireMock, "/jwks", rsaKeys);
- AuthplaneClient client = ConformanceTestSupport.buildClient(issuerWithPath);
- assertThat(client.issuer()).isEqualTo(issuerWithPath);
- client.close();
+ try (AuthplaneClient client = ConformanceTestSupport.buildClient(issuerWithPath)) {
+ assertThat(client.issuer()).isEqualTo(issuerWithPath);
+ }
}
@Test
@@ -237,42 +240,84 @@ void rfc8414_revocation_endpoint_required_when_revocation_is_used() {
.hasMessageContaining("revocation_endpoint");
}
+ /**
+ * Rotation must be reached by ordinary verification traffic and nothing else.
+ *
+ * A resource server that only verifies tokens never calls the token, introspection or
+ * revocation endpoints, so verification is the only thing on its request path that can notice
+ * the authorization server has moved its key set. This case therefore drives the rotation the
+ * way a deployment would: configured refresh interval, ordinary {@code verify()} calls, and an
+ * injected clock advanced past the interval instead of a sleep. No test-only refresh trigger is
+ * used — if the SDK reads the metadata document once at start-up and never again, this fails.
+ *
+ *
Both key pairs publish the same {@code kid}, which is what makes the assertion sharp: a
+ * cache still bound to the withdrawn {@code jwks_uri} would find that {@code kid} and reject
+ * the token on the signature, so a successful verification can only mean key retrieval actually
+ * moved to the new URI.
+ */
@Test
@ConformanceCase("rfc8414-jwks-uri-rotation-must-reconfigure-jwks-cache")
- void rfc8414_jwks_uri_rotation_must_reconfigure_jwks_cache() {
+ void rfc8414_jwks_uri_rotation_must_reconfigure_jwks_cache() throws Exception {
+ int metadataRefreshSeconds = 60;
TestFixtures.RSAKeyPair rotatedKeys = TestFixtures.generateRsaKeyPair();
+
ConformanceTestSupport.stubMetadata(
wireMock, Map.of("issuer", baseUrl, "jwks_uri", baseUrl + "/jwks-1"));
ConformanceTestSupport.stubJwks(wireMock, "/jwks-1", rsaKeys);
ConformanceTestSupport.stubJwks(wireMock, "/jwks-2", rotatedKeys);
- AuthplaneClient client =
- assertDoesNotThrow(() -> ConformanceTestSupport.buildClient(baseUrl));
- AuthplaneResource verifier =
- ConformanceTestSupport.buildVerifier(
- client, TestFixtures.RESOURCE, List.of("read:data"));
-
- VerifiedClaims initialClaims =
- assertDoesNotThrow(
- () ->
- verifier.verify(ConformanceTestSupport.validToken(rsaKeys, baseUrl))
- .get()
- .claims());
- assertThat(initialClaims.kid()).isEqualTo(TestFixtures.KID);
-
- ConformanceTestSupport.stubMetadata(
- wireMock, Map.of("issuer", baseUrl, "jwks_uri", baseUrl + "/jwks-2"));
-
- assertDoesNotThrow(() -> ConformanceTestSupport.forceMetadataRefresh(client));
+ TestFixtures.AdvanceableClock clock = new TestFixtures.AdvanceableClock();
+ try (AuthplaneClient client =
+ ConformanceTestSupport.buildClient(baseUrl, clock, metadataRefreshSeconds)) {
+ AuthplaneResource verifier =
+ ConformanceTestSupport.buildVerifier(
+ client, TestFixtures.RESOURCE, List.of("read:data"));
+
+ VerifiedClaims initialClaims =
+ verifier.verify(ConformanceTestSupport.validToken(rsaKeys, baseUrl))
+ .get()
+ .claims();
+ assertThat(initialClaims.kid()).isEqualTo(TestFixtures.KID);
+ assertThat(requestCount("/jwks-1")).as("the original key set was fetched").isPositive();
+
+ // The AS rotates: the key set moves to /jwks-2 and the old URI is withdrawn. Nothing
+ // notifies the SDK — the new document is only visible to a client that re-reads
+ // metadata.
+ ConformanceTestSupport.stubMetadata(
+ wireMock, Map.of("issuer", baseUrl, "jwks_uri", baseUrl + "/jwks-2"));
+ wireMock.stubFor(get(urlEqualTo("/jwks-1")).willReturn(aResponse().withStatus(404)));
+
+ // Still inside the refresh interval: the configured interval is respected, so
+ // verification keeps using the document it already holds rather than re-reading on
+ // every request.
+ int metadataReadsBefore = requestCount(WELL_KNOWN_PATH);
+ verifier.verify(ConformanceTestSupport.validToken(rsaKeys, baseUrl)).get();
+ assertThat(requestCount(WELL_KNOWN_PATH))
+ .as("metadata must not be re-read before the interval elapses")
+ .isEqualTo(metadataReadsBefore);
+
+ clock.advanceSeconds(metadataRefreshSeconds + 1);
+
+ // One ordinary verification past the interval is all it takes: the metadata read that
+ // verification performs picks up the new jwks_uri and rebinds key retrieval to it, and
+ // the token signed by the key published only at the new URI verifies.
+ VerifiedClaims rotatedClaims =
+ verifier.verify(ConformanceTestSupport.validToken(rotatedKeys, baseUrl))
+ .get()
+ .claims();
+ assertThat(rotatedClaims.kid()).isEqualTo(TestFixtures.KID);
+ assertThat(requestCount("/jwks-2")).as("the rotated key set was fetched").isPositive();
+
+ // The withdrawn URI is out of the picture: later verifications must not go back to it.
+ int withdrawnUriReads = requestCount("/jwks-1");
+ verifier.verify(ConformanceTestSupport.validToken(rotatedKeys, baseUrl)).get();
+ assertThat(requestCount("/jwks-1"))
+ .as("the withdrawn jwks_uri must not be fetched again after the rebind")
+ .isEqualTo(withdrawnUriReads);
+ }
+ }
- VerifiedClaims rotatedClaims =
- assertDoesNotThrow(
- () ->
- verifier.verify(
- ConformanceTestSupport.validToken(
- rotatedKeys, baseUrl))
- .get()
- .claims());
- assertThat(rotatedClaims.kid()).isEqualTo(TestFixtures.KID);
+ private static int requestCount(String path) {
+ return wireMock.countRequestsMatching(getRequestedFor(urlEqualTo(path)).build()).getCount();
}
}
diff --git a/core/src/conformance/java/ai/authplane/sdk/core/conformance/Rfc8707ConformanceTest.java b/core/src/conformance/java/ai/authplane/sdk/core/conformance/Rfc8707ConformanceTest.java
index 4b09414..1431348 100644
--- a/core/src/conformance/java/ai/authplane/sdk/core/conformance/Rfc8707ConformanceTest.java
+++ b/core/src/conformance/java/ai/authplane/sdk/core/conformance/Rfc8707ConformanceTest.java
@@ -7,6 +7,7 @@
import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import java.util.List;
@@ -28,6 +29,7 @@
import ai.authplane.sdk.core.fetching.FetchSettings;
import ai.authplane.sdk.core.fetching.HttpTransport;
import ai.authplane.sdk.core.oauth.ClientCredentialsGrant;
+import ai.authplane.sdk.core.prm.ProtectedResourceMetadata;
@ConformanceSuite
class Rfc8707ConformanceTest extends AbstractPlaceholderConformanceTest {
@@ -139,4 +141,47 @@ void rfc8707_client_credentials_multiple_resource_parameters_must_be_emitted() {
.withRequestBody(containing("resource=https%3A%2F%2Fapi-one.example.com"))
.withRequestBody(containing("resource=https%3A%2F%2Fapi-two.example.com")));
}
+
+ @Test
+ @ConformanceCase("rfc8707-resource-indicator-must-not-contain-a-fragment")
+ void rfc8707_resource_indicator_must_not_contain_a_fragment() {
+ ConformanceTestSupport.stubMetadata(
+ wireMock, Map.of("issuer", baseUrl, "jwks_uri", baseUrl + "/jwks"));
+ AuthplaneClient client =
+ assertDoesNotThrow(() -> ConformanceTestSupport.buildClient(baseUrl));
+
+ // The case is satisfied only by a rejection observable from the construction call itself
+ // (RFC 8707 §2, RFC 9728 §1.2). Asserted on the operator-facing factory, which is the
+ // `resource.create` stimulus, so a gate that moved to the derivation helpers would fail
+ // here rather than pass on a fragment silently dropped at prmUrl() time.
+ assertThatThrownBy(
+ () ->
+ ConformanceTestSupport.buildVerifier(
+ client,
+ "https://api.example.com/mcp#section",
+ List.of("read:data")))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("must not include a fragment component");
+
+ // Same gate on the PRM builder: an identifier reaching the document through the builder
+ // rather than through a resource must not get past construction either.
+ assertThatThrownBy(
+ () ->
+ ProtectedResourceMetadata.builder()
+ .resource("https://api.example.com/mcp#section")
+ .authorizationServer("https://auth.example.com")
+ .build())
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("must not include a fragment component");
+
+ // A fragment-free identifier is unaffected — the gate rejects the fragment, it does not
+ // narrow what a resource identifier may otherwise be.
+ assertThatCode(
+ () ->
+ ConformanceTestSupport.buildVerifier(
+ client,
+ "https://api.example.com/mcp",
+ List.of("read:data")))
+ .doesNotThrowAnyException();
+ }
}
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 6ed3626..9a11886 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
@@ -1,6 +1,7 @@
package ai.authplane.sdk.core.conformance;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import java.net.URI;
@@ -157,4 +158,73 @@ void rfc9728_well_known_path_must_derive_from_resource_uri() {
URI.create("https://api.example.com/mcp/")))
.isEqualTo("/.well-known/oauth-protected-resource/mcp");
}
+
+ @Test
+ @ConformanceCase("rfc9728-well-known-url-must-preserve-the-resource-query-component")
+ void rfc9728_well_known_url_must_preserve_the_resource_query_component() {
+ // RFC 9728 §3 inserts the well-known string "between the host component and the path
+ // and/or query components", so the query survives the derivation. The stimulus is the
+ // full URL rather than the path, because a path-only accessor cannot express a query.
+ assertThat(ProtectedResourceMetadata.wellKnownUrl("https://api.example.com/mcp?tenant=a"))
+ .isEqualTo(
+ "https://api.example.com/.well-known/oauth-protected-resource/mcp?tenant=a");
+
+ assertThat(ProtectedResourceMetadata.wellKnownUrl("https://api.example.com/mcp?tenant=b"))
+ .isEqualTo(
+ "https://api.example.com/.well-known/oauth-protected-resource/mcp?tenant=b");
+
+ // No path and no terminating slash: §3.1 has no slash to remove, so the suffix goes
+ // directly after the host and the query follows it.
+ assertThat(ProtectedResourceMetadata.wellKnownUrl("https://api.example.com?x=1"))
+ .isEqualTo("https://api.example.com/.well-known/oauth-protected-resource?x=1");
+
+ // The point of the case: two identifiers differing only by query must not collapse onto
+ // one metadata document URL, which is what makes every tenant on a host distinct.
+ assertThat(ProtectedResourceMetadata.wellKnownUrl("https://api.example.com/mcp?tenant=a"))
+ .isNotEqualTo(
+ ProtectedResourceMetadata.wellKnownUrl(
+ "https://api.example.com/mcp?tenant=b"));
+ }
+
+ @Test
+ @ConformanceCase("rfc9728-resource-identifier-must-be-an-absolute-url-with-scheme-and-host")
+ @ConformanceCoverage(
+ level = ConformanceCoverageLevel.PARTIAL,
+ gaps = {
+ "the host half is not gated at construction: \"https:example.com/mcp\" carries a"
+ + " scheme and no authority, and is refused only at derivation"
+ },
+ note =
+ "Both values the case exercises are now rejected from the resource factory and"
+ + " from the PRM builder, by requireScheme. PARTIAL rather than FULL"
+ + " because the requirement is scheme *and* host and only the scheme"
+ + " half is enforced where the stimulus points: an identifier with a"
+ + " scheme but no authority still constructs and throws later, on the"
+ + " 401 challenge path, which is the shape of failure moving these"
+ + " gates to construction was meant to remove.")
+ void rfc9728_resource_identifier_must_be_an_absolute_url_with_scheme_and_host() {
+ // Each value rejects on its own — the case is explicit that rejecting one does not satisfy
+ // it, because a guard that only asks "opaque or authority-less?" catches "/mcp" while
+ // letting the scheme-relative form through.
+ for (String identifier : List.of("/mcp", "//api.example.com/mcp")) {
+ assertThatThrownBy(() -> ProtectedResourceMetadata.requireScheme(identifier))
+ .isInstanceOf(IllegalArgumentException.class);
+
+ assertThatThrownBy(
+ () ->
+ ProtectedResourceMetadata.builder()
+ .resource(identifier)
+ .authorizationServer(TestFixtures.ISSUER)
+ .build())
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+
+ // Scheme-and-host, not https-only: local development loops depend on this one constructing.
+ assertDoesNotThrow(
+ () ->
+ ProtectedResourceMetadata.builder()
+ .resource("http://localhost:8080/mcp")
+ .authorizationServer(TestFixtures.ISSUER)
+ .build());
+ }
}
diff --git a/core/src/main/java/ai/authplane/sdk/core/AuthplaneClient.java b/core/src/main/java/ai/authplane/sdk/core/AuthplaneClient.java
index 6672425..7fbd8d9 100644
--- a/core/src/main/java/ai/authplane/sdk/core/AuthplaneClient.java
+++ b/core/src/main/java/ai/authplane/sdk/core/AuthplaneClient.java
@@ -1,5 +1,6 @@
package ai.authplane.sdk.core;
+import java.time.Clock;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
@@ -9,11 +10,14 @@
import java.util.concurrent.CompletionException;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executor;
+import java.util.concurrent.locks.ReentrantLock;
+import java.util.logging.Level;
import java.util.logging.Logger;
import ai.authplane.sdk.core.dpop.DPoPProvider;
import ai.authplane.sdk.core.dpop.OutboundDPoPOptions;
import ai.authplane.sdk.core.errors.TokenExchangeException;
+import ai.authplane.sdk.core.fetching.DocumentCache;
import ai.authplane.sdk.core.fetching.DocumentFetcher;
import ai.authplane.sdk.core.fetching.HttpTransport;
import ai.authplane.sdk.core.fetching.JwksCache;
@@ -23,6 +27,7 @@
import ai.authplane.sdk.core.oauth.IntrospectionResponse;
import ai.authplane.sdk.core.oauth.Revocation;
import ai.authplane.sdk.core.oauth.TokenExchange;
+import ai.authplane.sdk.core.prm.ProtectedResourceMetadata;
/**
* Central owner of Authorization Server connection state and token operations.
@@ -52,6 +57,9 @@
@SuppressWarnings("checkstyle:FinalClass")
public class AuthplaneClient implements AutoCloseable {
+ /** Depth bound for the cause walk in {@link #isInterrupt}; see the comment there. */
+ private static final int MAX_CAUSE_HOPS = 16;
+
private static final Logger LOG = Logger.getLogger(AuthplaneClient.class.getName());
/** Algorithms that must never be allowed. */
@@ -65,6 +73,45 @@ public class AuthplaneClient implements AutoCloseable {
// Infrastructure
volatile JwksCache jwksCache;
final MetadataCache metadataCache; // null if metadata not available
+
+ /** Installed by the builder right after construction; null when there is no metadata cache. */
+ volatile JwksCacheFactory jwksCacheFactory;
+
+ private final ReentrantLock jwksRebindLock = new ReentrantLock();
+
+ /**
+ * Suppresses rebind attempts after one fails, on the same policy the caches use.
+ *
+ *
The factory builds a fresh {@link JwksCache} per attempt, so the backoff a cache keeps for
+ * itself starts from zero every time and cannot govern this. Without a backoff here, a rotated
+ * {@code jwks_uri} that is down costs a full HTTP timeout on the verification path for as long
+ * as the outage lasts — reconciling means the mismatch is re-detected on every key lookup, so
+ * every lookup pays. Tokens whose keys are already cached do not need that fetch to succeed;
+ * they only need it not to block them.
+ *
+ *
Volatile rather than lock-guarded: the fast path reads it before taking {@link
+ * #jwksRebindLock}, and a read that races a write costs at most one extra attempt.
+ */
+ private volatile long jwksRebindRetryNotBeforeEpochSeconds;
+
+ /**
+ * Time source for the rebind backoff. Replaced by the builder so tests can advance it.
+ *
+ *
{@code volatile} for the same reason {@code jwksCacheFactory} is: it is written after the
+ * constructor returns, so it carries none of the JMM final-field guarantees the other infra
+ * fields on this class get. A client published through a data race could otherwise hand a
+ * request thread {@code clock == null}, which NPEs in {@link #rebindJwksIfMoved}.
+ */
+ private volatile Clock clock = Clock.systemUTC();
+
+ /**
+ * Set by {@link AuthplaneClientBuilder} after construction, alongside {@code jwksCacheFactory},
+ * rather than as a thirteenth constructor parameter.
+ */
+ void setClock(Clock clock) {
+ this.clock = clock;
+ }
+
final HttpTransport transport;
final AuthProvider authProvider; // nullable
final DocumentFetcher fetcher;
@@ -142,6 +189,18 @@ public AuthplaneResource resource(
Objects.requireNonNull(options, "options must not be null");
if (resourceUri.isBlank())
throw new IllegalArgumentException("resourceUri must not be blank");
+ // RFC 8707 §2 / RFC 9728 §1.2: no fragment component. Redundant with the gate in the
+ // AuthplaneResource constructor, kept so the stack trace points at the caller's line.
+ ProtectedResourceMetadata.requireNoFragment(resourceUri);
+ // RFC 3986 §3.4: the query is now part of the identifier and is spliced into the
+ // WWW-Authenticate challenge, so an octet outside the query production must not get
+ // past construction. Same reason, same boundary.
+ ProtectedResourceMetadata.requireValidQuery(resourceUri);
+ // RFC 8707 §2: an absolute URI always carries a scheme. Same reason, same boundary.
+ ProtectedResourceMetadata.requireScheme(resourceUri);
+ // RFC 9110 §4.2.4: no userinfo. The identifier is published to unauthenticated callers
+ // verbatim, so a credential in the authority is disclosed. Same reason, same boundary.
+ ProtectedResourceMetadata.requireNoUserinfo(resourceUri);
// Validate algorithms
Set dangerous = new HashSet<>(options.allowedAlgorithms());
@@ -412,12 +471,161 @@ public void close() {
// -----------------------------------------------------------------------
/**
- * Forces a synchronous metadata refresh, triggering the jwks_uri rotation callback if the
- * metadata document has changed. Package-private — for use in tests only.
+ * Reads through the AS metadata cache and reconciles {@link #jwksCache} against the {@code
+ * jwks_uri} it advertises. This is what makes {@code metadataRefreshSeconds} effective on a
+ * resource server that only verifies tokens.
+ *
+ * Such a server never calls the token, introspection or revocation endpoints, so nothing on
+ * its request path would otherwise touch the metadata document after start-up: the cache would
+ * hold the copy fetched at build time forever, and a rotated {@code jwks_uri} would never be
+ * followed. Verification calls this before every key lookup. The read is cheap while the
+ * document is fresh; once the interval has elapsed the cache re-fetches, and a rotation takes
+ * effect on the very lookup that discovered it.
+ *
+ *
The comparison is against the URI the cache is currently bound to, not against a change in
+ * the document, and that difference is the whole point. {@code DocumentCache} publishes a
+ * refreshed document before it notifies its change listener, so an edge-triggered rebind that
+ * failed — one 503 at the new URI — would leave key retrieval pinned to the withdrawn one with
+ * nothing left to re-trigger it: every later refresh returns that same document, so the edge
+ * never fires again. Comparing desired state to actual state instead means a failed rebind is
+ * simply retried on the next lookup.
+ *
+ *
Failures are swallowed deliberately. A metadata endpoint that is briefly unreachable must
+ * not fail verification of tokens whose signing keys the JWKS cache already holds; the cache
+ * falls back to the last good document, so this only logs when there is nothing to fall back
+ * on.
+ */
+ void refreshMetadataIfDue() {
+ if (metadataCache == null) {
+ return;
+ }
+ String discoveredJwksUri;
+ try {
+ discoveredJwksUri = metadataCache.getJwksUri();
+ } catch (InterruptedException e) {
+ // Shutdown, not a metadata problem. The flag is restored by DocumentCache; re-raising
+ // it here and returning keeps a stack trace out of the log on the way down.
+ Thread.currentThread().interrupt();
+ return;
+ } catch (Exception e) {
+ // The interrupt does not reach the branch above from this call site: MetadataCache
+ // wraps everything that is not a MetadataFetchException, so it arrives here wrapped.
+ // The flag is still restored upstream — only the logging would be wrong, and a stack
+ // trace on the way down is exactly what that branch exists to avoid. The sibling catch
+ // in rebindJwksIfMoved does see it unwrapped, since DocumentCache.fetch rethrows.
+ if (isInterrupt(e)) {
+ Thread.currentThread().interrupt();
+ return;
+ }
+ LOG.log(
+ Level.WARNING,
+ "AS metadata refresh failed; continuing with the current JWKS binding",
+ e);
+ return;
+ }
+ rebindJwksIfMoved(discoveredJwksUri);
+ }
+
+ /**
+ * Whether a failure is an interrupt, however deeply it was wrapped on the way here.
+ *
+ *
Checking the thread's own flag would answer a different question: it stays set from an
+ * interrupt this call had nothing to do with, and would then silence a real metadata failure.
+ */
+ // Package-private rather than private: the wrapped/unwrapped asymmetry the two call sites
+ // rely on, and the cycle bound below, are both worth pinning directly.
+ static boolean isInterrupt(Throwable error) {
+ // Bounded rather than walked to the end. `initCause` refuses a self-reference, so the
+ // `t.getCause() == t` guard alone looks sufficient — but it does not stop a cycle built
+ // through the `Throwable(String, Throwable)` constructors, where A causes B causes A. That
+ // walk never terminates. No real chain approaches this depth.
+ int hops = 0;
+ for (Throwable t = error; t != null && hops < MAX_CAUSE_HOPS; t = t.getCause(), hops++) {
+ if (t instanceof InterruptedException) {
+ return true;
+ }
+ if (t.getCause() == t) {
+ break;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Rebinds {@link #jwksCache} when the metadata document points key retrieval somewhere else.
+ * No-op when the two already agree, which is every call but the one that follows a rotation.
+ */
+ private void rebindJwksIfMoved(String discoveredJwksUri) {
+ if (jwksCacheFactory == null || discoveredJwksUri.equals(jwksCache.getUrl())) {
+ return;
+ }
+ long now = clock.instant().getEpochSecond();
+ if (now < jwksRebindRetryNotBeforeEpochSeconds) {
+ LOG.fine(
+ () ->
+ "jwks_uri rebind backing off after a failed attempt (retry in "
+ + (jwksRebindRetryNotBeforeEpochSeconds - now)
+ + "s); keeping the current binding");
+ return;
+ }
+ // One rebind at a time. A caller that loses the race keeps the current binding for this
+ // lookup rather than queueing behind a JWKS fetch; the winner publishes for everyone, and
+ // a kid miss forces a refresh anyway.
+ if (!jwksRebindLock.tryLock()) {
+ return;
+ }
+ try {
+ String boundUri = jwksCache.getUrl();
+ if (discoveredJwksUri.equals(boundUri)) {
+ return; // another thread got there first
+ }
+ LOG.warning(
+ "jwks_uri changed from '"
+ + boundUri
+ + "' to '"
+ + discoveredJwksUri
+ + "', restarting JWKS cache");
+ jwksCache = jwksCacheFactory.create(discoveredJwksUri);
+ jwksRebindRetryNotBeforeEpochSeconds = 0;
+ LOG.info(() -> "JWKS cache restarted with new URI: " + discoveredJwksUri);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ } catch (Exception e) {
+ long backoff = DocumentCache.failureBackoffSeconds(jwksRefreshSeconds);
+ jwksRebindRetryNotBeforeEpochSeconds = clock.instant().getEpochSecond() + backoff;
+ LOG.log(
+ Level.WARNING,
+ "Failed to initialise new JWKS cache for URI: "
+ + discoveredJwksUri
+ + ". Keeping the existing cache; retrying in "
+ + backoff
+ + "s.",
+ e);
+ } finally {
+ jwksRebindLock.unlock();
+ }
+ }
+
+ /**
+ * Builds a JWKS cache bound to a newly discovered {@code jwks_uri}, already populated. Supplied
+ * by {@link AuthplaneClientBuilder}, which owns the fetcher, the refresh interval and the time
+ * source a new cache needs.
+ */
+ @FunctionalInterface
+ interface JwksCacheFactory {
+ JwksCache create(String jwksUri) throws Exception;
+ }
+
+ /**
+ * Forces a synchronous metadata refresh, bypassing the configured interval. The JWKS binding is
+ * not touched here — it is reconciled by the next {@link #refreshMetadataIfDue()}, which is
+ * what every key lookup calls. Package-private — for use in tests only.
*/
void forceMetadataRefreshForTest() throws Exception {
if (metadataCache != null) {
- metadataCache.forceRefresh();
+ // Bypasses the failure backoff: a test asking for a refresh wants the attempt made, not
+ // the cached copy handed back. The request-path callers deliberately do not.
+ metadataCache.forceRefreshIgnoringFailureBackoff();
}
}
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 2bd662c..593edd8 100644
--- a/core/src/main/java/ai/authplane/sdk/core/AuthplaneClientBuilder.java
+++ b/core/src/main/java/ai/authplane/sdk/core/AuthplaneClientBuilder.java
@@ -1,11 +1,11 @@
package ai.authplane.sdk.core;
+import java.time.Clock;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.Executor;
import java.util.concurrent.ForkJoinPool;
-import java.util.logging.Level;
import java.util.logging.Logger;
import ai.authplane.sdk.core.dpop.OutboundDPoPOptions;
@@ -44,6 +44,7 @@ public final class AuthplaneClientBuilder {
private TokenCacheConfig tokenCacheConfig = TokenCacheConfig.defaults();
private OutboundDPoPOptions outboundDPoP = null;
private Executor executor = null;
+ private Clock clock = Clock.systemUTC();
AuthplaneClientBuilder(String issuer) {
Objects.requireNonNull(issuer, "issuer must not be null");
@@ -77,6 +78,19 @@ public AuthplaneClientBuilder metadataRefreshSeconds(int seconds) {
return this;
}
+ /**
+ * Sets the time source the metadata and JWKS caches use to evaluate their TTLs.
+ *
+ *
Package-private: production callers have no reason to run the caches on anything but the
+ * system clock. It exists so tests can drive refresh intervals by advancing a clock rather than
+ * sleeping against wall time, which is the only way to assert refresh behaviour without a
+ * shortened interval racing the CI runner.
+ */
+ AuthplaneClientBuilder clock(Clock clock) {
+ this.clock = Objects.requireNonNull(clock, "clock must not be null");
+ return this;
+ }
+
/**
* Sets the {@link AuthProvider} for Authorization Server calls (token, introspection,
* revocation). Pass static client credentials as {@code new ASCredentials(clientId,
@@ -190,13 +204,15 @@ private AuthplaneClient buildSync(
metadataRefreshSeconds,
issuer,
effectiveFetchSettings.allowHttp(),
- null);
+ null,
+ clock);
metadataCache.fetch();
String resolvedJwksUri = metadataCache.getJwksUri();
LOG.info(() -> "Discovered JWKS URI: " + resolvedJwksUri);
- JwksCache jwksCache = new JwksCache(fetcher, resolvedJwksUri, jwksRefreshSeconds, null);
+ JwksCache jwksCache =
+ new JwksCache(fetcher, resolvedJwksUri, jwksRefreshSeconds, null, clock);
jwksCache.fetch();
CircuitBreaker circuitBreaker =
@@ -220,15 +236,25 @@ private AuthplaneClient buildSync(
outboundDPoP,
effectiveExecutor);
- wireMetadataCallback(client, metadataCache, fetcher);
+ client.setClock(clock);
+ client.jwksCacheFactory =
+ jwksUri -> {
+ JwksCache newCache =
+ new JwksCache(fetcher, jwksUri, jwksRefreshSeconds, null, clock);
+ newCache.fetch();
+ return newCache;
+ };
+ wireMetadataCallback(metadataCache);
return client;
}
- private void wireMetadataCallback(
- AuthplaneClient client, MetadataCache metadataCache, DocumentFetcher fetcher) {
- // Safe from concurrent races: the MetadataCache invokes this callback
- // inside its fetchLock, so jwks_uri rotation is serialized even if
- // multiple background refreshes overlap.
+ /**
+ * Logs the AS endpoints a refresh moved. The {@code jwks_uri} is deliberately not handled here:
+ * a change callback is edge-triggered, and {@code DocumentCache} publishes the new document
+ * before it fires, so a rebind that failed could never be retried. {@link
+ * AuthplaneClient#refreshMetadataIfDue()} reconciles that binding against the document instead.
+ */
+ private void wireMetadataCallback(MetadataCache metadataCache) {
metadataCache.setOnChangeCallback(
(oldDoc, newDoc) -> {
Object newEp = newDoc.get("introspection_endpoint");
@@ -242,31 +268,6 @@ private void wireMetadataCallback(
if (!Objects.equals(oldTe, newTe)) {
LOG.info(() -> "AS token_endpoint changed to: " + newTe);
}
-
- Object newUriObj = newDoc.get("jwks_uri");
- if (!(newUriObj instanceof String newUri)) return;
- if (newUri.equals(client.jwksCache.getUrl())) return;
-
- LOG.warning(
- "jwks_uri changed from '"
- + client.jwksCache.getUrl()
- + "' to '"
- + newUri
- + "', restarting JWKS cache");
-
- JwksCache newCache = new JwksCache(fetcher, newUri, jwksRefreshSeconds, null);
- try {
- newCache.fetch();
- client.jwksCache = newCache;
- LOG.info(() -> "JWKS cache restarted with new URI: " + newUri);
- } catch (Exception e) {
- LOG.log(
- Level.WARNING,
- "Failed to initialise new JWKS cache for URI: "
- + newUri
- + ". Keeping existing cache.",
- e);
- }
});
}
}
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..51ef6ba 100644
--- a/core/src/main/java/ai/authplane/sdk/core/AuthplaneResource.java
+++ b/core/src/main/java/ai/authplane/sdk/core/AuthplaneResource.java
@@ -5,6 +5,7 @@
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
+import java.util.Optional;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
@@ -64,6 +65,18 @@ public class AuthplaneResource {
String resourceUri,
List scopes,
ResourceOptions options) {
+ // Authoritative fragment gate: every AuthplaneResource is built here, so an identifier
+ // carrying a fragment cannot reach prmResponse(), which publishes it verbatim.
+ ProtectedResourceMetadata.requireNoFragment(resourceUri);
+ ProtectedResourceMetadata.requireValidQuery(resourceUri);
+ // Authoritative scheme gate: the scheme feeds more than the PRM derivation — it is
+ // spliced into the DPoP htu binding target in normalizeRequestUrl below, where a missing
+ // one reads as the literal text "null" and fails every DPoP-bound request.
+ ProtectedResourceMetadata.requireScheme(resourceUri);
+ // Authoritative userinfo gate: the identifier is published verbatim as the PRM `resource`
+ // member and in the resource_metadata parameter of the 401 challenge, both of which reach
+ // unauthenticated callers, so a credential in the authority must not get this far.
+ ProtectedResourceMetadata.requireNoUserinfo(resourceUri);
this.client = client;
this.resourceUri = resourceUri;
this.scopes = List.copyOf(scopes);
@@ -80,14 +93,29 @@ public class AuthplaneResource {
this.failClosed = options.failClosed();
this.inboundDPoP = options.inboundDPoP();
- // KeyLookup reads through the client's JWKS cache
+ // KeyLookup reads through the client's JWKS cache, after giving the metadata cache the
+ // chance to re-read: verification is the only traffic a verify-only resource server has,
+ // so this is what keeps metadataRefreshSeconds honoured and follows a rotated jwks_uri.
+ // The rebind happens before the volatile jwksCache field is read below, so a rotation
+ // takes effect on the very lookup that discovered it.
this.validator =
new JwtValidator(
client.issuer(),
resourceUri,
this.allowedAlgorithms,
options.clockSkewSeconds(),
- (kid, force) -> client.jwksCache.getKeyByKid(kid, force));
+ new JwtValidator.KeyLookup() {
+ @Override
+ public void beforeLookup() {
+ client.refreshMetadataIfDue();
+ }
+
+ @Override
+ public Optional