Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .conformance-catalog-ref
Original file line number Diff line number Diff line change
@@ -1 +1 @@
b4c758a7dac698d7fcacd32dafcd4bb2f5dbddaf
583a6d92412543ea352251c88f15f2c5a39d2593
203 changes: 203 additions & 0 deletions CHANGELOG.md

Large diffs are not rendered by default.

8 changes: 5 additions & 3 deletions core/docs/user-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -212,8 +212,8 @@ Every builder method on `AuthplaneClient.builder(...)`:
|---|---|---|---|
| `devMode(boolean)` | `boolean` | `false` | Relax SSRF — allow HTTP, localhost, private networks. Overrides `fetchSettings` unless the latter is explicitly set |
| `fetchSettings(FetchSettings)` | `FetchSettings` | — | Full control over SSRF / fetch behaviour; overrides `devMode` |
| `jwksRefreshSeconds(int)` | `int` | `300` | JWKS background-refresh interval |
| `metadataRefreshSeconds(int)` | `int` | `3600` | AS metadata background-refresh interval |
| `jwksRefreshSeconds(int)` | `int` | `300` | JWKS refresh interval |
| `metadataRefreshSeconds(int)` | `int` | `3600` | AS metadata refresh interval |
| `authProvider(AuthProvider)` | `AuthProvider` | `null` | AS authentication for token / introspection / revocation calls. Pass `new ASCredentials(clientId, clientSecret)` for static HTTP Basic, or a custom provider for credential rotation / non-Basic schemes |
| `outboundDPoP(OutboundDPoPOptions)` | `OutboundDPoPOptions` | `null` | Enables DPoP proofs on AS POSTs and `dpopHeaders(...)` |
| `executor(Executor)` | `Executor` | `ForkJoinPool.commonPool()` | Executor for all async work. **Production deployments should supply a dedicated executor** — the common pool has limited parallelism (CPU cores − 1) and is shared JVM-wide |
Expand All @@ -224,6 +224,8 @@ Every builder method on `AuthplaneClient.builder(...)`:

`AUTHPLANE_DEV_MODE=true` in the environment flips `devMode` on at build time.

Both refresh intervals are driven by traffic, not by a background timer: the first call past the interval pays for the refetch. For the metadata document that call is a `verify()` — a resource server that only verifies tokens therefore still tracks the AS. When `jwks_uri` changes, the metadata read that discovers it rebinds JWKS fetching to the new URI before the token in hand is verified, and if that rebind fails (the new endpoint is briefly down) the next key lookup retries it. A metadata endpoint that is unreachable never fails verification: the last known good document keeps being served, and a failed refresh is not retried on the network for another 30 seconds.

### `ResourceOptions`

Per-resource configuration. Supply to `client.resource(resourceUri, scopes, options)`.
Expand Down Expand Up @@ -467,7 +469,7 @@ Well-known path derivation:
| `https://api.example.com/mcp` | `/.well-known/oauth-protected-resource/mcp` |
| `https://api.example.com/v2/mcp` | `/.well-known/oauth-protected-resource/v2/mcp` |

`ProtectedResourceMetadata.wellKnownUrl(String resourceUri)` returns the full URL. The framework adapters (`authplane-mcp`, `authplane-spring`) register the servlet/router automatically — this is only needed when writing your own adapter.
`ProtectedResourceMetadata.wellKnownUrl(String resourceUri)` returns the full URL. If the resource identifier carries a query component, the returned URL carries it verbatim (`https://api.example.com/mcp?tenant=a` → `https://api.example.com/.well-known/oauth-protected-resource/mcp?tenant=a`) while routing stays path-keyed — the derivation table above is unaffected. The framework adapters (`authplane-mcp`, `authplane-spring`) register the servlet/router automatically — this is only needed when writing your own adapter.

### Dev mode

Expand Down
9 changes: 8 additions & 1 deletion core/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,17 @@

<dependencies>
<!-- JWT/JWK crypto — the only production dependency -->
<!--
10.x is required by GHSA-xwmg-2g98-w7v9: 9.40 sits inside the vulnerable range
(>= 9.38-rc1, < 10.0.2) — a DoS on deeply nested JSON. This library parses
attacker-supplied JWTs on the verification path, before any signature is checked.
Kept current rather than pinned to the minimum 10.0.2: both impose the same major
on consumers, and nimbus types are part of this SDK's public API.
-->
<dependency>
<groupId>com.nimbusds</groupId>
<artifactId>nimbus-jose-jwt</artifactId>
<version>9.40</version>
<version>10.9.1</version>
</dependency>

<!-- Test dependencies — version and scope managed in the parent pom -->
Expand Down
9 changes: 5 additions & 4 deletions core/src/conformance/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,18 +140,19 @@ class RfcXxxxConformanceTest {

## Known gaps

There are **no `not_run` cases** — every catalog case has a test implementation, and all
implemented cases are `passed` in `conformance-report.md`.

Two cases carry **partial** coverage rather than full:
There are **no `not_run` cases** — every catalog case has a test implementation. Three cases carry
less than full coverage:

| Case ID | RFC | Coverage | Reason |
|---------|-----|----------|--------|
| `rfc9449-dpop-inbound-nonce-must-be-validated-when-required` | RFC 9449 §8 | partial (`@Disabled`) | Server-issued inbound nonce enforcement is not yet implemented. RFC 9449 §8 allows but does not require resource servers to enforce nonces. Documented via `@ConformanceCoverage` on the test. |
| `rfc9449-dpop-proof-jwk-must-not-include-private-key-material` | RFC 9449 §4.2 | partial (passed) | The proof is rejected as `invalid_dpop_proof`, but the Java SDK does not surface a stable private-key-material diagnostic independent of Nimbus's parsing error. Documented via `@ConformanceCoverage` on the test. |
| `rfc9728-resource-identifier-must-be-an-absolute-url-with-scheme-and-host` | RFC 9728 §3, RFC 8707 §2 | partial (passed) | Both values the case exercises — `/mcp` and `//api.example.com/mcp` — are now refused at construction by `requireScheme`, from the resource factory and the PRM builder. Partial rather than full because the requirement is scheme *and* host and only the scheme half is gated there: `https:example.com/mcp` carries a scheme and no authority, constructs, and is refused only at derivation. Documented via `@ConformanceCoverage` on the test. |

## Definition of done for a conformance case

- Status is `passed` in `conformance-report.md`
- Coverage level is `full`, or `partial` with all gaps documented in `@ConformanceCoverage`
- A case the SDK does not implement is registered and `@Disabled` with a reason, and reports
`skipped` with coverage `none` — never marked covered, and never left to surface as `not_run`
- No uncatalogued tests (every test method has a `@ConformanceCase` mapping)
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import java.util.Set;
import java.util.TreeSet;

import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;

class ConformanceCatalogTest {
Expand Down Expand Up @@ -99,8 +100,38 @@ void catalogCasesAndConformanceMappingsAgree() throws Exception {
.isEmpty();
}

@Test
void noneCoverageStubsMustCarryDisabled() throws Exception {
// A stub annotated @ConformanceCoverage(level = NONE) is honest only while @Disabled
// keeps it from running: drop the annotation without writing a body and the extension
// records status "passed" for a case with zero coverage — a green report entry that
// asserts nothing. This converts that coupling from a convention into a failure, using
// the same scan the alignment check trusts.
SuiteScan scan = scanConformanceSuites();
List<String> vacuous = new ArrayList<>();
for (Class<?> suite : scan.suiteClasses()) {
for (Method method : suite.getDeclaredMethods()) {
ConformanceCoverage coverage = method.getAnnotation(ConformanceCoverage.class);
if (coverage != null
&& coverage.level() == ConformanceCoverageLevel.NONE
&& !method.isAnnotationPresent(Disabled.class)) {
vacuous.add(suite.getSimpleName() + "#" + method.getName());
}
}
}
assertThat(vacuous)
.withFailMessage(
"%d test(s) declare @ConformanceCoverage(level = NONE) without @Disabled."
+ " Running such a stub reports the case as passed with zero"
+ " coverage. Either implement the case (and raise the coverage"
+ " level) or keep @Disabled attached:%n - %s",
vacuous.size(), String.join(NL + " - ", vacuous))
.isEmpty();
}

/** Case ids declared across the suite, plus whatever the scan could not read. */
private record SuiteScan(TreeSet<String> caseIds, List<String> loadFailures) {}
private record SuiteScan(
TreeSet<String> caseIds, List<String> loadFailures, List<Class<?>> suiteClasses) {}

/**
* Collects every {@link ConformanceCase} case id declared by a {@link ConformanceSuite} test
Expand All @@ -114,6 +145,7 @@ private record SuiteScan(TreeSet<String> caseIds, List<String> loadFailures) {}
private static SuiteScan scanConformanceSuites() throws Exception {
TreeSet<String> ids = new TreeSet<>();
List<String> loadFailures = new ArrayList<>();
List<Class<?>> suiteClasses = new ArrayList<>();
String packageName = ConformanceCatalogTest.class.getPackageName();
String packagePath = packageName.replace('.', '/');
ClassLoader classLoader = Thread.currentThread().getContextClassLoader();
Expand Down Expand Up @@ -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) {
Expand All @@ -153,7 +186,7 @@ private static SuiteScan scanConformanceSuites() throws Exception {
}
}
}
return new SuiteScan(ids, loadFailures);
return new SuiteScan(ids, loadFailures, suiteClasses);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ void close_writesJsonAndMarkdownReports() throws Exception {
cases:
- id: "case-a"
- id: "case-b"
- id: "case-c"
""");

ConformanceRunState state =
Expand All @@ -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,
Expand All @@ -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(
Expand All @@ -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");
}
Expand All @@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
}
Loading
Loading