Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -267,8 +269,4 @@ private void wireMetadataCallback(
}
});
}

private static String normalizeIssuer(String issuer) {
return issuer.endsWith("/") ? issuer.substring(0, issuer.length() - 1) : issuer;
}
}
4 changes: 2 additions & 2 deletions core/src/main/java/ai/authplane/sdk/core/CircuitPolicy.java
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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).
*
* <p>If you refactor {@code AuthplaneResource.validateDpop} so that this exception is thrown for a
* <em>different</em> reason (e.g. proof present but malformed), update the swallow logic in {@code
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<Map<String, Object>, Map<String, Object>> onChangeCallback;

private final ReentrantLock fetchLock = new ReentrantLock();
Expand All @@ -36,7 +38,10 @@ public class DocumentCache {
private Map<String, Object> cachedDocument;
private long cachedAtEpochSeconds; // when the current cache was stored
private Long serverExpiresAtSeconds; // from HTTP cache headers, or null
private CompletableFuture<Void> bgRefreshFuture;

// Written under fetchLock; volatile so the package-private accessor can read it without
// taking fetchLock.
private volatile CompletableFuture<Void> bgRefreshFuture;

/**
* @param fetcher document fetcher (SSRF-safe or direct)
Expand All @@ -52,11 +57,43 @@ public DocumentCache(
String documentType,
BiConsumer<Map<String, Object>, Map<String, Object>> onChangeCallback) {

this(
fetcher,
url,
configuredRefreshSeconds,
documentType,
onChangeCallback,
Clock.systemUTC());
}

/**
* Test seam. Same as the public constructor, but with the time source injected so TTL expiry
* can be driven by advancing a clock rather than by sleeping against wall time — the difference
* between a deterministic assertion and a race with the CI runner.
*
* <p>The seam is {@link Clock} rather than a {@code LongSupplier} of epoch seconds, even though
* this class represents time as {@code long} epoch seconds throughout. {@code Clock} is the
* platform idiom, it composes ({@code Clock.fixed}, {@code Clock.offset}), and the conversion
* cost is one call in {@code nowEpochSeconds()} — not one per use site. Several other classes
* in the SDK still read the wall clock directly and will want the same seam; this is the shape
* to copy.
*
* @param clock the time source
*/
DocumentCache(
DocumentFetcher fetcher,
String url,
int configuredRefreshSeconds,
String documentType,
BiConsumer<Map<String, Object>, Map<String, Object>> onChangeCallback,
Clock clock) {

this.fetcher = fetcher;
this.url = url;
this.configuredRefreshSeconds = configuredRefreshSeconds;
this.documentType = documentType;
this.onChangeCallback = onChangeCallback;
this.clock = clock;
}

/** Returns the URL this cache fetches from. */
Expand Down Expand Up @@ -207,6 +244,15 @@ private boolean backgroundRefreshScheduled() {
return bgRefreshFuture != null && !bgRefreshFuture.isDone();
}

/**
* Test seam: the in-flight background refresh, or {@code null} if none has been scheduled. Lets
* a test await the refresh it just triggered instead of guessing how long the async fetch will
* take.
*/
CompletableFuture<Void> backgroundRefreshFuture() {
return bgRefreshFuture;
}

private void scheduleBackgroundRefresh() {
bgRefreshFuture =
CompletableFuture.runAsync(
Expand All @@ -227,7 +273,7 @@ private void scheduleBackgroundRefresh() {
});
}

private static long nowEpochSeconds() {
return System.currentTimeMillis() / 1000L;
private long nowEpochSeconds() {
return clock.instant().getEpochSecond();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import java.net.URI;
import java.util.Map;
import java.util.Objects;
import java.util.function.BiConsumer;
import java.util.logging.Logger;

Expand All @@ -11,9 +12,8 @@
* Cache for OAuth Authorization Server Metadata (RFC 8414).
*
* <p>Extracts and validates the {@code jwks_uri} field. Validates issuer and endpoint URLs
* internally when metadata is fetched, matching the Python/Go pattern. Triggers the change callback
* when the document changes, allowing the caller to detect jwks_uri rotation and restart the
* JwksCache.
* internally when metadata is fetched. Triggers the change callback when the document changes,
* allowing the caller to detect jwks_uri rotation and restart the JwksCache.
*/
public class MetadataCache extends DocumentCache {

Expand Down Expand Up @@ -45,7 +45,11 @@ public MetadataCache(
boolean allowHttp,
BiConsumer<Map<String, Object>, Map<String, Object>> onChangeCallback) {
super(fetcher, metadataUrl, refreshSeconds, "metadata", onChangeCallback);
this.expectedIssuer = expectedIssuer;
// Required: the RFC 8414 §3.3 comparison in getJwksUri() dereferences this. Without the
// check a null surfaces as a bare NPE from the first metadata read rather than as a
// contract violation at construction.
this.expectedIssuer =
Objects.requireNonNull(expectedIssuer, "expectedIssuer must not be null");
this.allowHttp = allowHttp;
}

Expand Down Expand Up @@ -93,14 +97,14 @@ private void validateMetadata(Map<String, Object> 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 issuer is compared byte-for-byte against the configured value.
// No trailing-slash reconciliation — a difference in the terminating slash is a mismatch.
if (!expectedIssuer.equals(issuer)) {
throw new MetadataFetchException(
"OAuth server metadata issuer mismatch: expected '"
+ normalizedExpectedIssuer
+ expectedIssuer
+ "', got '"
+ normalizedMetadataIssuer
+ issuer
+ "'");
}

Expand Down Expand Up @@ -150,11 +154,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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -61,38 +61,85 @@ private ProtectedResourceMetadata(
* <pre>
* "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/mcp//" → "/.well-known/oauth-protected-resource/mcp"
* "https://api.example.com/v2/mcp" → "/.well-known/oauth-protected-resource/v2/mcp"
* "https://api.example.com/a%2Fb" → "/.well-known/oauth-protected-resource/a%2Fb"
* </pre>
*
* @param resourceUri the resource server URI
* <p>Per RFC 9728 §3.1 every terminating slash of the resource path is stripped when deriving
* the well-known path; it does not affect the resource identifier itself. The derivation reads
* the raw (percent-encoded) path, so an encoded octet such as {@code %2F} is carried through
* verbatim rather than decoded into a path separator — decoding it would name a different path
* than the resource identifier does.
*
* @param resourceUri the resource server URI; must be hierarchical and carry an authority
* @return the URL path (including leading slash) where the PRM should be served
* @throws IllegalArgumentException if {@code resourceUri} is opaque or has no authority
*/
public static String wellKnownPath(URI resourceUri) {
String path = resourceUri.getPath();
if (path == null || path.isEmpty() || path.equals("/")) {
requireDerivable(resourceUri);

// Read the raw path: URI.getPath() percent-decodes, which would turn a resource
// identifier of ".../a%2Fb" into the well-known path ".../a/b" — a different path than
// the identifier names, and the silent rewrite this derivation exists to avoid.
String path = resourceUri.getRawPath();
if (path == null || path.isEmpty()) {
return WELL_KNOWN_PREFIX;
}

// Strip every terminating slash before deriving the well-known path (RFC 9728 §3.1):
// the resource identity is preserved elsewhere, but the derived .well-known path must
// not carry a trailing slash ("/mcp/" and "/mcp//" both → ".../mcp"). Stripping only one
// would make this helper and wellKnownUrl disagree on a doubled slash.
String derivedPath = path.replaceAll("/+$", "");
if (derivedPath.isEmpty()) {
return WELL_KNOWN_PREFIX;
}

// Strip leading slash — WELL_KNOWN_PREFIX already starts with /
String cleanPath = path.startsWith("/") ? path.substring(1) : path;
String cleanPath = derivedPath.startsWith("/") ? derivedPath.substring(1) : derivedPath;
return WELL_KNOWN_PREFIX + "/" + cleanPath;
}

/**
* Computes the full URL of the PRM document for the given resource URI.
*
* @param resourceUri the resource server URI string
* <p>The path component is derived by {@link #wellKnownPath(URI)}, so both helpers agree by
* construction: the slash stripping happens in exactly one place.
*
* @param resourceUri the resource server URI string; must be hierarchical and carry an
* authority
* @return the full PRM document URL
* @throws IllegalArgumentException if {@code resourceUri} is opaque or has no authority
*/
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);
requireDerivable(uri);
return uri.getScheme() + "://" + uri.getAuthority() + wellKnownPath(uri);
}

/**
* Guards the PRM derivation helpers against identifiers they cannot derive from.
*
* <p>RFC 8707 §2 permits a resource indicator that is any absolute URI, and this class stores
* whatever it is given verbatim — {@code urn:example:api} is a valid resource identifier. But
* an opaque URI has no authority and no hierarchical path, so there is no PRM URL to publish
* for it: the derivation would otherwise emit {@code urn://null/.well-known/...} and hand that
* to the {@code resource_metadata} parameter of the 401 challenge.
*/
private static void requireDerivable(URI resourceUri) {
if (resourceUri.isOpaque() || resourceUri.getAuthority() == null) {
throw new IllegalArgumentException(
"Cannot derive a Protected Resource Metadata URL from \""
+ resourceUri
+ "\": PRM derivation requires a hierarchical resource identifier with"
+ " an authority (e.g. https://api.example.com/mcp). The resource"
+ " identifier itself may be any absolute URI permitted by RFC 8707 §2"
+ " and is stored verbatim; only the derivation is restricted.");
}
}

// -----------------------------------------------------------------------
// Document serialization
// -----------------------------------------------------------------------
Expand Down
Loading
Loading