Skip to content
Closed
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
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added

- Initial release.

### Changed

- Resource and issuer identifiers are never rewritten (RFC 8414 §3.3 / RFC 9728 §3.3 require the
advertised value to be *identical* to the configured one). `ProtectedResourceMetadata.wellKnownUrl`
/ `wellKnownPath` and the RFC 8414 metadata URL are formed by pure insertion, preserving the
identifier's path exactly — including any trailing slash — and AS-metadata issuer comparison is now
an exact string match. Identifiers are validated at construction (absolute http(s) URI with an
authority and no fragment) and throw `IllegalArgumentException` otherwise; trailing slashes, host
case, and explicit ports are legal and preserved. **Migration**: if your configured issuer or
resource differs from your authorization server's actual identifier by a trailing slash, correct
the config — the SDK no longer silently reconciles them.

### Fixed

- A configured issuer whose identifier legitimately ends in `/` no longer has every token rejected.
The trailing slash was silently stripped at client construction and the stripped value compared
against the token's `iss`, which RFC 9068 requires to carry the slash; discovery now also resolves
the RFC 8414 well-known URL for such issuers correctly.
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,17 @@ final class ConformanceTestSupport {
private ConformanceTestSupport() {}

static void stubMetadata(WireMockServer wireMock, Map<String, Object> metadataDocument) {
stubMetadataAt(wireMock, "/.well-known/oauth-authorization-server", metadataDocument);
}

/**
* Stubs the metadata document at an explicit path — e.g. the RFC 8414 §3 well-known URL of an
* issuer whose identifier ends in a slash.
*/
static void stubMetadataAt(
WireMockServer wireMock, String path, Map<String, Object> metadataDocument) {
wireMock.stubFor(
get(urlEqualTo("/.well-known/oauth-authorization-server"))
get(urlEqualTo(path))
.willReturn(
aResponse()
.withStatus(200)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,17 @@ 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");

// Variant: a trailing-slash difference is equivalent per RFC 3986 §6.2.3 but not
// identical — RFC 8414 §3.3 requires identity, so it must be rejected.
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,28 @@ void rfc9068_issuer_must_match() {
.cause()
.isInstanceOf(InvalidClaimsException.class)
.hasMessageContaining("Issuer mismatch");

// Variant: a token whose iss is identical to a configured trailing-slash issuer must
// verify — the issuer is never rewritten, end to end: discovery resolves the
// trailing-slash well-known URL (RFC 8414 §3), the advertised issuer matches exactly
// (§3.3), and the token's iss matches exactly (RFC 9068 §4).
String slashIssuer = baseUrl + "/";
wireMock.resetAll();
ConformanceTestSupport.stubMetadataAt(
wireMock,
"/.well-known/oauth-authorization-server/",
Map.of("issuer", slashIssuer, "jwks_uri", baseUrl + "/jwks"));
ConformanceTestSupport.stubJwks(wireMock, "/jwks", rsaKeys);

AuthplaneClient slashClient =
assertDoesNotThrow(() -> ConformanceTestSupport.buildClient(slashIssuer));
AuthplaneResource slashVerifier =
ConformanceTestSupport.buildVerifier(
slashClient, TestFixtures.RESOURCE, List.of("read:data"));
String acceptedToken = TestFixtures.token().rsaKey(rsaKeys).issuer(slashIssuer).build();
VerifiedClaims claims =
assertDoesNotThrow(() -> slashVerifier.verify(acceptedToken).get().claims());
assertThat(claims.issuer()).isEqualTo(slashIssuer);
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -148,5 +148,11 @@ 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");

// RFC 9728 §3 insertion preserves the path exactly, including a trailing slash.
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);
// RFC 8414 §3.3 — the issuer is an opaque identifier compared with simple string
// equality (against metadata and token iss); it is validated but never rewritten.
this.issuer = Identifiers.requireValidIdentifier(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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,9 @@ public class AuthplaneResource {
List<String> scopes,
ResourceOptions options) {
this.client = client;
this.resourceUri = resourceUri;
// RFC 8707 §2 — the resource identifier is opaque; validated for structure, never
// rewritten (it is compared verbatim against aud and advertised verbatim in PRM).
this.resourceUri = Identifiers.requireValidIdentifier(resourceUri, "resourceUri");
this.scopes = List.copyOf(scopes);
this.allowedAlgorithms = Set.copyOf(options.allowedAlgorithms());

Expand Down
53 changes: 53 additions & 0 deletions core/src/main/java/ai/authplane/sdk/core/Identifiers.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package ai.authplane.sdk.core;

import java.net.URI;

/**
* Validation for resource and issuer identifiers.
*
* <p>RFC 8414 §3.3 and RFC 9728 §3.3 require the advertised issuer/resource to be identical to the
* configured value — a simple string comparison, not RFC 3986 equivalence. The SDK therefore never
* rewrites an identifier: well-known URLs are formed by inserting the well-known path segment
* between the authority and the identifier's path (RFC 8414 §3 / RFC 9728 §3), and validation
* rejects structurally unusable identifiers instead of repairing them.
*
* <p>Thread-safe — all methods are stateless.
*/
public final class Identifiers {

private Identifiers() {}

/**
* Validates that {@code value} is an absolute http(s) URI with an authority and no fragment
* (RFC 8707 §2 forbids fragments in resource identifiers). Returns {@code value} unchanged —
* trailing slashes, host case, and explicit ports are all legal identifier variations and are
* preserved verbatim.
*
* @param value the identifier to validate
* @param label name used in error messages (e.g. "issuer", "resource")
* @return {@code value}, unchanged
* @throws IllegalArgumentException when the identifier is structurally invalid
*/
public static String requireValidIdentifier(String value, String label) {
URI uri;
try {
uri = URI.create(value);
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException(label + " is not a valid URI: '" + value + "'", e);
}
String scheme = uri.getScheme();
if (scheme == null || !(scheme.equals("https") || scheme.equals("http"))) {
throw new IllegalArgumentException(
label + " must be an absolute http or https URI: '" + value + "'");
}
if (uri.getRawAuthority() == null || uri.getRawAuthority().isEmpty()) {
throw new IllegalArgumentException(
label + " must include an authority: '" + value + "'");
}
if (uri.getRawFragment() != null) {
throw new IllegalArgumentException(
label + " must not contain a fragment: '" + value + "'");
}
return value;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -93,14 +93,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 returned issuer MUST be identical to the configured one;
// simple string comparison, no normalisation.
if (!expectedIssuer.equals(issuer)) {
throw new MetadataFetchException(
"OAuth server metadata issuer mismatch: expected '"
+ normalizedExpectedIssuer
+ expectedIssuer
+ "', got '"
+ normalizedMetadataIssuer
+ issuer
+ "'");
}

Expand Down Expand Up @@ -150,11 +150,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 @@ -19,36 +19,31 @@ private MetadataUrlBuilder() {}
/**
* Builds the metadata URL for the given issuer.
*
* <p>The well-known segment is inserted between the authority and the issuer's path — a pure
* string insertion (RFC 8414 §3) that preserves the path exactly, including any trailing slash.
*
* <p>Examples: "https://auth.example.com" →
* "https://auth.example.com/.well-known/oauth-authorization-server"
* "https://auth.example.com/t1" →
* "https://auth.example.com/.well-known/oauth-authorization-server/t1"
* "https://auth.example.com/a/b" →
* "https://auth.example.com/.well-known/oauth-authorization-server/a/b"
* "https://auth.example.com/t1/" →
* "https://auth.example.com/.well-known/oauth-authorization-server/t1/"
*
* @param issuer the issuer URI (no trailing slash required)
* @param issuer the issuer URI
* @return the metadata discovery URL
*/
public static String buildMetadataUrl(String issuer) {
URI uri =
URI.create(
issuer.endsWith("/") ? issuer.substring(0, issuer.length() - 1) : issuer);
URI uri = URI.create(issuer);

String scheme = uri.getScheme();
String authority = uri.getAuthority(); // host[:port]
String path = uri.getPath(); // may be null or ""
String authority = uri.getRawAuthority(); // host[:port]
String path = uri.getRawPath(); // may be null or ""

StringBuilder sb = new StringBuilder();
sb.append(scheme).append("://").append(authority).append(WELL_KNOWN);

if (path != null && !path.isEmpty() && !path.equals("/")) {
// Strip leading slash (WELL_KNOWN already starts with /)
String cleanPath = path.startsWith("/") ? path.substring(1) : path;
if (!cleanPath.isEmpty()) {
sb.append("/").append(cleanPath);
}
if (path != null) {
sb.append(path);
}

return sb.toString();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -55,42 +55,40 @@ private ProtectedResourceMetadata(
/**
* Computes the URL path at which this resource server should serve its PRM document.
*
* <p>The path is derived from the resource URI by inserting {@code
* /.well-known/oauth-protected-resource} after the authority:
* <p>The path is formed by inserting {@code /.well-known/oauth-protected-resource} between the
* authority and the resource URI's path (RFC 9728 §3) — a pure string insertion that preserves
* the path exactly, including any trailing slash:
*
* <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/v2/mcp" → "/.well-known/oauth-protected-resource/v2/mcp"
* "https://api.example.com" → "/.well-known/oauth-protected-resource"
* "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/mcp/" → "/.well-known/oauth-protected-resource/mcp/"
* </pre>
*
* @param resourceUri the resource server URI
* @return the URL path (including leading slash) where the PRM should be served
*/
public static String wellKnownPath(URI resourceUri) {
String path = resourceUri.getPath();
if (path == null || path.isEmpty() || path.equals("/")) {
String path = resourceUri.getRawPath();
if (path == null || path.isEmpty()) {
return WELL_KNOWN_PREFIX;
}

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

/**
* Computes the full URL of the PRM document for the given resource URI.
*
* <p>Always agrees with {@link #wellKnownPath(URI)}: the URL is the resource's scheme and
* authority followed by the well-known path.
*
* @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);
return uri.getScheme() + "://" + uri.getAuthority() + wellKnownPath(uri);
URI uri = URI.create(resourceUri);
return uri.getScheme() + "://" + uri.getRawAuthority() + wellKnownPath(uri);
}

// -----------------------------------------------------------------------
Expand Down
55 changes: 55 additions & 0 deletions core/src/test/java/ai/authplane/sdk/core/IdentifiersTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package ai.authplane.sdk.core;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

import org.junit.jupiter.api.Test;

class IdentifiersTest {

@Test
void validIdentifiers_returnedUnchanged() {
// Trailing slashes, host case, and explicit ports are legal variations — preserved.
for (String value :
java.util.List.of(
"https://auth.example.com",
"https://auth.example.com/",
"https://auth.example.com/tenant/",
"https://Auth.Example.com:443/t1",
"http://localhost:8080/issuer")) {
assertThat(Identifiers.requireValidIdentifier(value, "issuer")).isSameAs(value);
}
}

@Test
void nonHttpScheme_rejected() {
assertThatThrownBy(
() -> Identifiers.requireValidIdentifier("ftp://x.example.com", "issuer"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("http");
}

@Test
void missingScheme_rejected() {
assertThatThrownBy(() -> Identifiers.requireValidIdentifier("auth.example.com", "issuer"))
.isInstanceOf(IllegalArgumentException.class);
}

@Test
void missingAuthority_rejected() {
assertThatThrownBy(() -> Identifiers.requireValidIdentifier("https:example.com", "issuer"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("authority");
}

@Test
void fragment_rejected() {
// RFC 8707 §2 — resource identifiers must not contain a fragment.
assertThatThrownBy(
() ->
Identifiers.requireValidIdentifier(
"https://api.example.com/mcp#frag", "resourceUri"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("fragment");
}
}
Loading
Loading