diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index f336ba0..7e3e98c 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -36,7 +36,9 @@ AEP_NODE_DIR=../aep-node make interoperability ``` The flow runs the Java Agent against the Node.js Service and Platform, then the Node.js Agent -against the Java Service and Platform. Its report is written under `.interop/reports/`. +against the Java Service and Platform. It also tests Java Service hosted verification against the +Node.js Platform, including retries, forged assertions, resource binding, and replay rejection. +Its report is written under `.interop/reports/`. ## Module boundaries diff --git a/README.md b/README.md index 8acc529..a525cae 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,10 @@ The shared Agent, Service, Platform, and credential-profile conformance reports Set `AEP_SPECS_DIR` when the specifications repository is not located at `../aep-specs`. Reports are written to `.conformance/reports/`. +Agent reports include the hosted-identity provider. Protected-resource redirect checks cover +origin rejection and fresh resource-bound authentication headers; the application owns HTTP +forwarding and anonymous restarts, as described in the [Agent guide](aep-agent/README.md#authenticate-and-revoke). + Format Java sources with: ```sh diff --git a/aep-agent/README.md b/aep-agent/README.md index a972107..c245055 100644 --- a/aep-agent/README.md +++ b/aep-agent/README.md @@ -13,6 +13,9 @@ See the root [installation guide](../README.md#installation) for dependency coor Supply an HTTP transport and an identity provider. The transport boundary is asynchronous and can adapt the HTTP client already used by the application. The identity provider returns a Service-scoped Agent identity whose signer receives the complete client-assertion claims. +The signer must use an algorithm advertised by the Service. Configure transports to return +redirect responses without following them automatically, so the SDK can enforce origin checks +before credentials or assertions are sent elsewhere. ```java AepAgent agent = AepAgent.builder() @@ -30,6 +33,15 @@ Production identity providers should scope identities by Service, protect signin the same identity for repeated calls. Production credential stores should encrypt credentials at rest and make replacement, expiry, and revocation atomic. +Implement `AgentCredentialStore.find(serviceDid, grantType, now)` to select a credential whose +`expiresAt` is later than the supplied Agent time. An expired credential must not hide another +usable credential of the same type. A custom credential with no expiry can remain selectable. +This local selection does not authorize access: the Service validates the credential using its own +clock. Changing the Agent's clock cannot extend a credential's lifetime at the Service. + +Custom Inspect caches must preserve `AgentInspectCache.Entry.cacheHeaders` alongside the document +and validators, so a `304 Not Modified` response retains the cached freshness policy. + ## Use a hosted identity Platform `PlatformIdentityProvider` implements the Agent identity boundary against an AEP Platform. It @@ -77,8 +89,9 @@ ClaimValues claims = ClaimValues.builder() .build(); EnrollResponse enrolled = service.enroll(claims).join(); -StatusResponse active = service.waitForActive( - Duration.ofSeconds(2), Duration.ofMinutes(2)).join(); +if (enrolled.status() == AgentStatus.PENDING) { + service.waitForActive(Duration.ofSeconds(2), Duration.ofMinutes(2)).join(); +} AgentGrantResult grant = service.grant("api-key", List.of("catalog:read")).join(); ``` @@ -87,6 +100,10 @@ AgentGrantResult grant = service.grant("api-key", List.of("catalog:read")).join( for a Service that returns a pending enrollment; an immediately active enrollment can proceed directly to Grant. +Polling timeout and cancellation stop the returned wait and prevent further polls. They do not +guarantee cancellation of an HTTP request already running in an application-supplied transport; +configure request timeouts on that transport as well. + Built-in credential handlers parse and store OAuth Bearer, API-key, and Basic responses. A custom Grant Type remains available through `AgentCredentialHandler`. @@ -109,5 +126,12 @@ Do not forward the returned headers to another origin. The Agent refuses cross-o presentation, command redirects, cross-origin Inspect redirects, oversized responses, and invalid Service DID bindings. +The application sends protected-resource requests and handles their redirects; `authenticate` +only returns headers. For a same-origin redirect, discard the previous authentication headers and +call `authenticate` for the target URL. When JWT authentication is selected, this creates a fresh +assertion bound to that URL. For a cross-origin +redirect, remove all Service authentication headers, including any API-key header, and restart +anonymously. These forwarding decisions belong to the application's HTTP client, not this SDK. + See the runnable [Agent and Service example](../examples/README.md#agent-and-service) for a complete JDK `HttpClient` transport and lifecycle flow. diff --git a/aep-agent/src/main/java/foundation/aep/agent/AepServiceSession.java b/aep-agent/src/main/java/foundation/aep/agent/AepServiceSession.java index 0dcaa97..5636a5c 100644 --- a/aep-agent/src/main/java/foundation/aep/agent/AepServiceSession.java +++ b/aep-agent/src/main/java/foundation/aep/agent/AepServiceSession.java @@ -38,6 +38,7 @@ public final class AepServiceSession { private final AepAgent agent; private final URI serviceOrigin; private Optional> inspectionInFlight = Optional.empty(); + private volatile Optional activeEnrollment = Optional.empty(); AepServiceSession(AepAgent agent, URI origin) { this.agent = agent; @@ -66,20 +67,34 @@ public CompletableFuture enroll(ClaimValues claims) { String key = idempotencyKey(context, AepCommand.ENROLL, ""); EnrollRequest request = new EnrollRequest(context.identity.did(), claims, key); requireValid("Enroll request", AepValidation.enrollRequest(request)); - return execute(context, AepCommand.ENROLL, "POST", request, key, AepJson::parseEnrollResponse); + return execute(context, AepCommand.ENROLL, "POST", request, key, AepJson::parseEnrollResponse) + .thenApply(response -> { + recordEnrollment(context, response.status()); + return response; + }); }); } public CompletableFuture status() { return commandContext(AepCommand.STATUS) - .thenCompose(context -> - execute(context, AepCommand.STATUS, "GET", null, null, AepJson::parseStatusResponse)); + .thenCompose(context -> execute( + context, AepCommand.STATUS, "GET", null, null, AepJson::parseStatusResponse) + .thenApply(response -> { + recordEnrollment(context, response.status()); + return response; + })); } public CompletableFuture waitForActive(Duration interval, Duration timeout) { requirePositive(interval, "interval"); requirePositive(timeout, "timeout"); CompletableFuture result = new CompletableFuture<>(); + CompletableFuture deadline = new CompletableFuture<>(); + deadline.orTimeout(timeoutNanos(timeout), TimeUnit.NANOSECONDS).whenComplete((ignored, failure) -> { + if (failure != null) + result.completeExceptionally(new AepAgentException("poll_timeout", "AEP Status polling timed out")); + }); + result.whenComplete((ignored, failure) -> deadline.complete(null)); pollStatus(result, interval, System.nanoTime(), timeoutNanos(timeout)); return result; } @@ -98,8 +113,9 @@ public CompletableFuture grant(GrantRequest request) { "grant_type_not_advertised", "AEP Service does not advertise Grant Type " + request.grantType()); } - if (!commands.supported().contains(AepCommand.STATUS.value())) { - return failed("command_not_advertised", "AEP Grant requires advertised Status support"); + if (activeEnrollment.filter(enrollmentIdentity(context)::equals).isPresent() + || !commands.supported().contains(AepCommand.STATUS.value())) { + return issueGrant(context, request); } return execute(context, AepCommand.STATUS, "GET", null, null, AepJson::parseStatusResponse) .thenCompose(status -> grantActive(context, status, request)); @@ -110,6 +126,12 @@ public CompletableFuture revoke(RevokeRequest request) { Objects.requireNonNull(request, "request"); requireValid("Revoke request", AepValidation.revokeRequest(request)); return commandContext(AepCommand.REVOKE).thenCompose(context -> { + if (request.grantType() != null + && !context.inspection.document().commands().grantTypes().contains(request.grantType())) { + return failed( + "grant_type_not_advertised", + "AEP Service does not advertise Grant Type " + request.grantType()); + } requirePerCredentialRevoke(context.inspection.document(), request); String discriminator = request.credentialId() == null ? request.grantType() : request.credentialId(); String key = idempotencyKey(context, AepCommand.REVOKE, discriminator == null ? "all" : discriminator); @@ -159,13 +181,15 @@ private CompletableFuture fetchInspect( } if (response.status() == 304 && cached.isPresent()) { AgentInspectCache.Entry prior = cached.orElseThrow(); + AepHttpTransport.Response metadata = AgentHttp.revalidated(response, prior.cacheHeaders()); AgentInspectCache.Entry refreshed = new AgentInspectCache.Entry( uri, prior.json(), AgentHttp.header(response, "ETag").orElse(prior.etag()), AgentHttp.header(response, "Last-Modified").orElse(prior.lastModified()), - AgentHttp.expiresAt(response, agent.clock.instant(), agent.defaultInspectFreshness)); - CompletionStage update = AgentHttp.isNoStore(response) + AgentHttp.expiresAt(metadata, agent.clock.instant(), agent.defaultInspectFreshness), + AgentHttp.cacheHeaders(metadata)); + CompletionStage update = AgentHttp.isNoStore(metadata) ? agent.inspectCache.delete(serviceOrigin) : agent.inspectCache.put(serviceOrigin, refreshed); return future(update).thenApply(ignored -> parseInspection(prior.json(), uri)); @@ -181,7 +205,8 @@ private CompletableFuture fetchInspect( json, AgentHttp.header(response, "ETag").orElse(null), AgentHttp.header(response, "Last-Modified").orElse(null), - AgentHttp.expiresAt(response, agent.clock.instant(), agent.defaultInspectFreshness)); + AgentHttp.expiresAt(response, agent.clock.instant(), agent.defaultInspectFreshness), + AgentHttp.cacheHeaders(response)); CompletionStage update = AgentHttp.isNoStore(response) ? agent.inspectCache.delete(serviceOrigin) : agent.inspectCache.put(serviceOrigin, entry); @@ -235,10 +260,8 @@ private AgentInspection parseInspection(String json, URI documentUri) { } private boolean bindsServiceDid(String serviceDid) { - if (!agent.allowInsecureLoopback) { - return DidWeb.bindsOrigin(serviceDid, serviceOrigin); - } try { + if (!agent.allowInsecureLoopback) return DidWeb.bindsOrigin(serviceDid, serviceOrigin); return AgentHttp.sameOrigin(DidWeb.documentUri(serviceDid, true), serviceOrigin); } catch (IllegalArgumentException exception) { return false; @@ -310,20 +333,38 @@ private CompletableFuture assertion(CommandContext context, AepCommand c private CompletableFuture grantActive( CommandContext context, StatusResponse status, GrantRequest request) { + recordEnrollment(context, status.status()); if (status.status() != AgentStatus.ACTIVE) { return failed("enrollment_not_active", "AEP Grant requires active enrollment"); } + return issueGrant(context, request); + } + + private void recordEnrollment(CommandContext context, AgentStatus status) { + activeEnrollment = status == AgentStatus.ACTIVE ? Optional.of(enrollmentIdentity(context)) : Optional.empty(); + } + + private static EnrollmentIdentity enrollmentIdentity(CommandContext context) { + return new EnrollmentIdentity(context.inspection.document().service().did(), context.identity.did()); + } + + private record EnrollmentIdentity(String serviceDid, String agentDid) {} + + private CompletableFuture issueGrant(CommandContext context, GrantRequest request) { String key = idempotencyKey(context, AepCommand.GRANT, request.grantType()); return execute(context, AepCommand.GRANT, "POST", request, key, Function.identity()) .thenCompose(json -> storeGrant(context, request.grantType(), json)); } private CompletableFuture storeGrant(CommandContext context, String grantType, String json) { + Object identifier = AepJson.parseObject(json, "Grant response").get("credential_id"); + if (!(identifier instanceof String credentialId) || credentialId.isEmpty()) { + return failed("invalid_credential", "Grant response must contain a non-empty credential_id"); + } Optional handler = agent.credentialHandlers.stream() .filter(candidate -> candidate.grantType().equals(grantType)) .findFirst(); if (handler.isEmpty()) { - AepJson.requireObject(json, "Grant response"); return CompletableFuture.completedFuture(new AgentGrantResult(grantType, json, Optional.empty())); } AgentCredential credential = handler.orElseThrow() @@ -331,7 +372,8 @@ private CompletableFuture storeGrant(CommandContext context, S if (!credential .serviceDid() .equals(context.inspection.document().service().did()) - || !credential.grantType().equals(grantType)) { + || !credential.grantType().equals(grantType) + || !credential.credentialId().equals(credentialId)) { return failed("invalid_credential", "Credential handler returned mismatched credential metadata"); } return future(agent.credentialStore.save(credential)) @@ -375,7 +417,8 @@ private CompletableFuture authenticate( return authenticate(inspection, resource, methodIndex + 1); } AgentCredentialHandler selected = handler.orElseThrow(); - return future(agent.credentialStore.find(inspection.document().service().did(), selected.grantType())) + return future(agent.credentialStore.find( + inspection.document().service().did(), selected.grantType(), agent.clock.instant())) .thenCompose(credential -> { if (credential.isEmpty() || (credential.orElseThrow().expiresAt() != null @@ -400,6 +443,10 @@ private void pollStatus( if (result.isDone()) { return; } + if (remainingNanos(startedNanos, timeoutNanos) == 0) { + result.completeExceptionally(new AepAgentException("poll_timeout", "AEP Status polling timed out")); + return; + } status().whenComplete((status, failure) -> { if (result.isDone()) { return; @@ -420,13 +467,11 @@ private void pollStatus( "AEP enrollment is " + status.status().value())); return; } - if (remainingNanos(startedNanos, timeoutNanos) <= timeoutNanos(interval)) { - result.completeExceptionally(new AepAgentException("poll_timeout", "AEP Status polling timed out")); - return; - } CompletableFuture.runAsync( () -> pollStatus(result, interval, startedNanos, timeoutNanos), - CompletableFuture.delayedExecutor(delayMillis(interval), TimeUnit.MILLISECONDS)); + CompletableFuture.delayedExecutor( + Math.min(remainingNanos(startedNanos, timeoutNanos), timeoutNanos(interval)), + TimeUnit.NANOSECONDS)); }); } @@ -455,7 +500,7 @@ private static void requirePerCredentialRevoke(InspectDocument document, RevokeR } private String idempotencyKey(CommandContext context, AepCommand command, String discriminator) { - return requireNonBlank( + return AgentHttp.requireFieldValue( agent.idempotencyKeyProvider.keyFor( context.inspection.document().service().did(), command, discriminator), "idempotency key"); @@ -512,14 +557,6 @@ private static long timeoutNanos(Duration timeout) { } } - private static long delayMillis(Duration duration) { - try { - return Math.max(1, duration.toMillis()); - } catch (ArithmeticException exception) { - return Long.MAX_VALUE; - } - } - private static long remainingNanos(long startedNanos, long timeoutNanos) { long elapsed = System.nanoTime() - startedNanos; return elapsed >= timeoutNanos ? 0 : timeoutNanos - elapsed; diff --git a/aep-agent/src/main/java/foundation/aep/agent/AgentCredentialStore.java b/aep-agent/src/main/java/foundation/aep/agent/AgentCredentialStore.java index 4909b73..8e0f137 100644 --- a/aep-agent/src/main/java/foundation/aep/agent/AgentCredentialStore.java +++ b/aep-agent/src/main/java/foundation/aep/agent/AgentCredentialStore.java @@ -1,5 +1,6 @@ package foundation.aep.agent; +import java.time.Instant; import java.util.LinkedHashMap; import java.util.Map; import java.util.Optional; @@ -7,7 +8,8 @@ import java.util.concurrent.CompletionStage; public interface AgentCredentialStore { - CompletionStage> find(String serviceDid, String grantType); + /** Selects a credential unexpired at the Agent's time; the Service independently validates it. */ + CompletionStage> find(String serviceDid, String grantType, Instant now); CompletionStage save(AgentCredential credential); @@ -22,11 +24,14 @@ static AgentCredentialStore inMemory() { private final Map credentials = new LinkedHashMap<>(); @Override - public synchronized CompletionStage> find(String serviceDid, String grantType) { + public synchronized CompletionStage> find( + String serviceDid, String grantType, Instant now) { AgentCredential selected = null; for (AgentCredential credential : credentials.values()) { if (credential.serviceDid().equals(serviceDid) - && credential.grantType().equals(grantType)) { + && credential.grantType().equals(grantType) + && (credential.expiresAt() == null + || credential.expiresAt().isAfter(now))) { selected = credential; } } diff --git a/aep-agent/src/main/java/foundation/aep/agent/AgentHttp.java b/aep-agent/src/main/java/foundation/aep/agent/AgentHttp.java index 9b8e7fc..1ef6a7b 100644 --- a/aep-agent/src/main/java/foundation/aep/agent/AgentHttp.java +++ b/aep-agent/src/main/java/foundation/aep/agent/AgentHttp.java @@ -10,15 +10,24 @@ import java.time.DateTimeException; import java.time.Duration; import java.time.Instant; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Optional; +import java.util.regex.Pattern; final class AgentHttp { private static final String CACHE_NO_STORE = "no-store"; private static final String CACHE_NO_CACHE = "no-cache"; + private static final String TOKEN = "[!#$%&'*+.^_`|~0-9a-zA-Z-]+"; + private static final Pattern MEDIA_TYPE = Pattern.compile(TOKEN + "/" + TOKEN + + "(?:[ \\t]*;[ \\t]*" + TOKEN + "=(?:" + TOKEN + + "|\"(?:[\\t !#-\\[\\]-~\\x80-\\xFF]|\\\\[\\t !-~\\x80-\\xFF])*\"))*[ \\t]*"); private AgentHttp() {} @@ -41,15 +50,29 @@ static URI origin(URI value, boolean allowInsecureLoopback) { } static boolean sameOrigin(URI left, URI right) { - return left != null - && right != null - && left.getScheme() != null - && right.getScheme() != null - && left.getHost() != null - && right.getHost() != null - && left.getScheme().equalsIgnoreCase(right.getScheme()) - && IDN.toASCII(left.getHost()).equalsIgnoreCase(IDN.toASCII(right.getHost())) - && effectivePort(left) == effectivePort(right); + if (left == null + || right == null + || left.getScheme() == null + || right.getScheme() == null + || left.getHost() == null + || right.getHost() == null + || !left.getScheme().equalsIgnoreCase(right.getScheme()) + || effectivePort(left) != effectivePort(right)) { + return false; + } + // A redirect Location is attacker-controlled, and IDN.toASCII rejects a malformed label by + // throwing. An unparseable host is not the same origin; it is not an error to propagate. + String leftHost = asciiHost(left.getHost()); + String rightHost = asciiHost(right.getHost()); + return leftHost != null && leftHost.equalsIgnoreCase(rightHost); + } + + private static String asciiHost(String host) { + try { + return IDN.toASCII(host); + } catch (IllegalArgumentException exception) { + return null; + } } static boolean validInspectTarget(URI origin, URI target) { @@ -79,7 +102,7 @@ static String body(AepHttpTransport.Response response) { static void requireMediaType(AepHttpTransport.Response response, String expected) { String actual = header(response, "Content-Type").orElse(""); String essence = actual.split(";", 2)[0].trim(); - if (!expected.equalsIgnoreCase(essence)) { + if (!MEDIA_TYPE.matcher(actual).matches() || !expected.equalsIgnoreCase(essence)) { throw new AepAgentException("invalid_media_type", "AEP response media type is invalid"); } } @@ -96,22 +119,100 @@ static AepAgentException commandError(AepHttpTransport.Response response) { } static Instant expiresAt(AepHttpTransport.Response response, Instant now, Duration fallback) { - String cacheControl = String.join(",", headerValues(response, "Cache-Control")); - for (String directive : cacheControl.split(",")) { - String value = directive.trim().toLowerCase(Locale.ROOT); - if (CACHE_NO_STORE.equals(value) || CACHE_NO_CACHE.equals(value)) { - return now; + List directives = new ArrayList<>(); + for (String value : headerValues(response, "Cache-Control")) { + for (String directive : value.split(",")) { + directives.add(directive.trim().toLowerCase(Locale.ROOT)); } - if (value.startsWith("max-age=")) { + } + if (directives.contains(CACHE_NO_STORE) || directives.contains(CACHE_NO_CACHE)) { + return now; + } + for (String directive : directives) { + if (directive.startsWith("max-age=")) { try { - long seconds = Long.parseLong(value.substring("max-age=".length())); - return safeAdd(now, Duration.ofSeconds(Math.max(0, seconds)), fallback); + String value = directive.substring("max-age=".length()); + if (value.startsWith("\"") && value.endsWith("\"") && value.length() >= 2) { + value = value.substring(1, value.length() - 1); + } + long seconds = Long.parseLong(value); + return freshUntil(response, now, Duration.ofSeconds(Math.max(0, seconds)), fallback); } catch (ArithmeticException | NumberFormatException exception) { - return safeAdd(now, fallback, Duration.ZERO); + return freshUntil(response, now, fallback, Duration.ZERO); } } } - return safeAdd(now, fallback, Duration.ZERO); + Instant date = httpDate(response, "Date").orElse(now); + Duration lifetime = httpDate(response, "Expires") + .map(expires -> Duration.between(date, expires)) + .orElse(fallback); + return freshUntil(response, now, lifetime, Duration.ZERO); + } + + private static Instant freshUntil( + AepHttpTransport.Response response, Instant now, Duration lifetime, Duration fallback) { + Duration apparentAge = httpDate(response, "Date") + .map(date -> Duration.between(date, now)) + .orElse(Duration.ZERO); + long ageSeconds; + try { + ageSeconds = Long.parseLong(header(response, "Age").orElse("0")); + } catch (NumberFormatException exception) { + ageSeconds = 0; + } + Duration age = Duration.ofSeconds(Math.max(0, ageSeconds)); + if (apparentAge.compareTo(age) > 0) age = apparentAge; + if (lifetime.compareTo(age) <= 0) return now; + return safeAdd(now, lifetime.minus(age), fallback); + } + + private static Optional httpDate(AepHttpTransport.Response response, String name) { + try { + return header(response, name) + .map(value -> ZonedDateTime.parse(value, DateTimeFormatter.RFC_1123_DATE_TIME) + .toInstant()); + } catch (DateTimeParseException exception) { + return Optional.empty(); + } + } + + static Map> cacheHeaders(AepHttpTransport.Response response) { + Map> headers = new LinkedHashMap<>(); + for (String name : List.of("Cache-Control", "Date", "Expires")) { + List values = headerValues(response, name); + if (!values.isEmpty()) headers.put(name, values); + } + return Map.copyOf(headers); + } + + static AepHttpTransport.Response revalidated(AepHttpTransport.Response response, Map> prior) { + Map> headers = new LinkedHashMap<>(response.headers()); + prior.forEach((name, values) -> { + if (headerValues(response, name).isEmpty()) headers.put(name, values); + }); + return new AepHttpTransport.Response(response.status(), headers, response.body()); + } + + /** + * Checks a value the SDK is about to send as an HTTP field value. + * + * The idempotency key comes from an application-supplied provider and is written straight into + * a request header. A value carrying a control character or a line break is not a field value + * at all, and surrounding whitespace is stripped in transit, so the Service would record a + * different key than the Agent did. + */ + static String requireFieldValue(String value, String name) { + if (value == null || value.isBlank() || !value.equals(value.strip())) { + throw new AepAgentException( + "invalid_configuration", name + " must be a non-empty value without surrounding whitespace"); + } + for (int index = 0; index < value.length(); index++) { + char character = value.charAt(index); + if (character < 0x20 || character == 0x7f) { + throw new AepAgentException("invalid_configuration", name + " must not contain control characters"); + } + } + return value; } static boolean isNoStore(AepHttpTransport.Response response) { diff --git a/aep-agent/src/main/java/foundation/aep/agent/AgentInspectCache.java b/aep-agent/src/main/java/foundation/aep/agent/AgentInspectCache.java index 9dcd847..4e28324 100644 --- a/aep-agent/src/main/java/foundation/aep/agent/AgentInspectCache.java +++ b/aep-agent/src/main/java/foundation/aep/agent/AgentInspectCache.java @@ -2,6 +2,7 @@ import java.net.URI; import java.time.Instant; +import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Optional; @@ -16,11 +17,24 @@ public interface AgentInspectCache { CompletionStage delete(URI origin); - record Entry(URI documentUri, String json, String etag, String lastModified, Instant expiresAt) { + record Entry( + URI documentUri, + String json, + String etag, + String lastModified, + Instant expiresAt, + Map> cacheHeaders) { + public Entry(URI documentUri, String json, String etag, String lastModified, Instant expiresAt) { + this(documentUri, json, etag, lastModified, expiresAt, Map.of()); + } + public Entry { Objects.requireNonNull(documentUri, "documentUri"); Objects.requireNonNull(json, "json"); Objects.requireNonNull(expiresAt, "expiresAt"); + cacheHeaders = Map.copyOf(cacheHeaders.entrySet().stream() + .collect(java.util.stream.Collectors.toUnmodifiableMap( + Map.Entry::getKey, entry -> List.copyOf(entry.getValue())))); } } diff --git a/aep-agent/src/main/java/foundation/aep/agent/PlatformIdentityProvider.java b/aep-agent/src/main/java/foundation/aep/agent/PlatformIdentityProvider.java index b453c21..fda0615 100644 --- a/aep-agent/src/main/java/foundation/aep/agent/PlatformIdentityProvider.java +++ b/aep-agent/src/main/java/foundation/aep/agent/PlatformIdentityProvider.java @@ -332,6 +332,8 @@ private DiscoveryEntry createDiscoveryEntry( AepHttpTransport.Response response, Optional prior) { Instant now = clock.instant(); + AepHttpTransport.Response metadata = AgentHttp.revalidated( + response, prior.map(DiscoveryEntry::cacheHeaders).orElse(Map.of())); return new DiscoveryEntry( document, finalUri, @@ -339,8 +341,9 @@ private DiscoveryEntry createDiscoveryEntry( .orElseGet(() -> prior.map(DiscoveryEntry::etag).orElse(null)), AgentHttp.header(response, "Last-Modified") .orElseGet(() -> prior.map(DiscoveryEntry::lastModified).orElse(null)), - AgentHttp.expiresAt(response, now, defaultDiscoveryFreshness), - AgentHttp.isNoStore(response)); + AgentHttp.expiresAt(metadata, now, defaultDiscoveryFreshness), + AgentHttp.isNoStore(metadata), + AgentHttp.cacheHeaders(metadata)); } private URI redirectTarget(URI current, AepHttpTransport.Response response) { @@ -393,13 +396,24 @@ private URI endpoint(String path, String identityId) { private String idempotencyKey() { String value = idempotencyKeys.get(); - if (value == null || value.isBlank()) { + if (value == null || value.isBlank() || !value.equals(value.strip()) || hasControlCharacter(value)) { throw new AepAgentException( - "platform_idempotency_key_invalid", "AEP Platform idempotency key must not be blank"); + "platform_idempotency_key_invalid", + "AEP Platform idempotency key must be a field value without surrounding whitespace"); } return value; } + private static boolean hasControlCharacter(String value) { + for (int index = 0; index < value.length(); index++) { + char character = value.charAt(index); + if (character < 0x20 || character == 0x7f) { + return true; + } + } + return false; + } + private static boolean ownedHeader(String name) { return name != null && ("Accept".equalsIgnoreCase(name) @@ -504,8 +518,8 @@ private void requireUsableIdentity(PlatformAgentIdentity identity, PlatformDisco } private boolean bindsServiceDid(String serviceDid, URI serviceOrigin) { - if (!allowInsecureLoopback) return DidWeb.bindsOrigin(serviceDid, serviceOrigin); try { + if (!allowInsecureLoopback) return DidWeb.bindsOrigin(serviceDid, serviceOrigin); return AgentHttp.sameOrigin(DidWeb.documentUri(serviceDid, true), serviceOrigin); } catch (IllegalArgumentException exception) { return false; @@ -541,7 +555,8 @@ private record DiscoveryEntry( String etag, String lastModified, Instant expiresAt, - boolean noStore) {} + boolean noStore, + Map> cacheHeaders) {} public static final class Builder { private final URI requestedPlatformOrigin; diff --git a/aep-agent/src/test/java/foundation/aep/agent/AepServiceSessionTest.java b/aep-agent/src/test/java/foundation/aep/agent/AepServiceSessionTest.java index 849b9a4..10afed8 100644 --- a/aep-agent/src/test/java/foundation/aep/agent/AepServiceSessionTest.java +++ b/aep-agent/src/test/java/foundation/aep/agent/AepServiceSessionTest.java @@ -69,6 +69,82 @@ void inspectRejectsCrossOriginRedirect() { assertEquals(1, inspect.requests.size()); } + @Test + void inspectPreservesNoCacheAcrossNotModifiedResponses() { + QueueTransport transport = new QueueTransport( + response( + 200, + Map.of( + "Content-Type", + List.of(Aep.MEDIA_TYPE), + "Cache-Control", + List.of("no-cache"), + "ETag", + List.of("v1")), + document(List.of("inspect"), List.of(), List.of())), + response(304, Map.of(), ""), + response(304, Map.of("Cache-Control", List.of("max-age=60")), "")); + AepServiceSession session = + agent(transport, request -> failedTransport()).build().service(ORIGIN); + session.inspect().join(); + session.inspect().join(); + session.inspect().join(); + session.inspect().join(); + assertEquals(3, transport.requests.size()); + assertEquals(List.of("v1"), transport.requests.get(2).headers().get("If-None-Match")); + } + + @Test + void inspectDoesNotReuseResponsesWhoseAgeOrExpiresHasConsumedFreshness() { + for (Map> policy : List.of( + Map.of("Cache-Control", List.of("max-age=60"), "Age", List.of("120")), + Map.of("Expires", List.of("Tue, 1 Sep 2026 00:00:00 GMT")))) { + Map> headers = new java.util.LinkedHashMap<>(policy); + headers.put("Content-Type", List.of(Aep.MEDIA_TYPE)); + QueueTransport transport = new QueueTransport( + response(200, headers, document(List.of("inspect"), List.of(), List.of())), + aepResponse(200, document(List.of("inspect"), List.of(), List.of()))); + AepServiceSession session = + agent(transport, request -> failedTransport()).build().service(ORIGIN); + session.inspect().join(); + session.inspect().join(); + assertEquals(2, transport.requests.size()); + } + } + + @Test + void inspectRejectsMalformedParametersAndAcceptsValidQuotedParameters() { + for (String media : List.of( + "application/aep+json; garbage", + "application/aep+json; charset=", + "application/aep+json; charset=\"unterminated", + "application/aep+json; x=one two")) { + QueueTransport transport = new QueueTransport(response( + 200, Map.of("Content-Type", List.of(media)), document(List.of("inspect"), List.of(), List.of()))); + assertEquals( + "invalid_media_type", + agentError(agent(transport, request -> failedTransport()) + .build() + .service(ORIGIN) + .inspect()) + .code()); + } + QueueTransport transport = new QueueTransport(response( + 200, + Map.of("Content-Type", List.of("Application/AEP+JSON; charset=utf-8; note=\"one; two\"")), + document(List.of("inspect"), List.of(), List.of()))); + assertEquals( + SERVICE_DID, + agent(transport, request -> failedTransport()) + .build() + .service(ORIGIN) + .inspect() + .join() + .document() + .service() + .did()); + } + @Test void enrollRejectsMissingRequiredClaimsBeforeCommandRequest() { QueueTransport inspect = new QueueTransport( @@ -213,7 +289,7 @@ void inMemoryStoreDeletesEveryCredentialForRevokedGrantType() { store.deleteGrantType(SERVICE_DID, "custom-grant").toCompletableFuture().join(); - assertTrue(store.find(SERVICE_DID, "custom-grant") + assertTrue(store.find(SERVICE_DID, "custom-grant", NOW) .toCompletableFuture() .join() .isEmpty()); @@ -270,6 +346,27 @@ void staleRedirectedInspectionRevalidatesFinalDocumentUri() { assertEquals(List.of("etag-1"), inspect.requests.get(0).headers().get("If-None-Match")); } + @Test + void notModifiedNoStoreEvictsTheCachedDocumentAndItsValidators() { + String json = document(List.of("inspect"), List.of(), List.of()); + MemoryInspectCache cache = new MemoryInspectCache(); + cache.entry = Optional.of(new AgentInspectCache.Entry( + ORIGIN.resolve("/.well-known/aep"), json, "etag-1", null, NOW.minusSeconds(1))); + QueueTransport inspect = new QueueTransport( + response(304, Map.of("Cache-Control", List.of("no-store")), ""), aepResponse(200, json)); + AepServiceSession session = agent(inspect, request -> failedTransport()) + .inspectCache(cache) + .build() + .service(ORIGIN); + + assertEquals(SERVICE_DID, session.inspect().join().document().service().did()); + assertTrue(cache.entry.isEmpty()); + assertEquals(SERVICE_DID, session.inspect().join().document().service().did()); + assertEquals(2, inspect.requests.size()); + assertEquals(List.of("etag-1"), inspect.requests.get(0).headers().get("If-None-Match")); + assertFalse(inspect.requests.get(1).headers().containsKey("If-None-Match")); + } + @Test void customGrantResponseMustBeAnObject() { QueueTransport inspect = new QueueTransport(aepResponse( @@ -439,7 +536,7 @@ private static final class MemoryCredentialStore implements AgentCredentialStore private Optional credential = Optional.empty(); @Override - public CompletableFuture> find(String serviceDid, String grantType) { + public CompletableFuture> find(String serviceDid, String grantType, Instant now) { return CompletableFuture.completedFuture( credential.filter(value -> value.grantType().equals(grantType))); } diff --git a/aep-agent/src/test/java/foundation/aep/agent/AgentConformanceTest.java b/aep-agent/src/test/java/foundation/aep/agent/AgentConformanceTest.java new file mode 100644 index 0000000..3c54bc8 --- /dev/null +++ b/aep-agent/src/test/java/foundation/aep/agent/AgentConformanceTest.java @@ -0,0 +1,522 @@ +package foundation.aep.agent; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import foundation.aep.core.Aep; +import foundation.aep.core.AepHttpTransport; +import foundation.aep.core.ClaimValues; +import foundation.aep.core.RevokeRequest; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import org.junit.jupiter.api.Test; + +/** Spec-grounded Agent behaviour across Inspect, the commands, and protected-resource use. */ +class AgentConformanceTest { + private static final URI ORIGIN = URI.create("https://service.example"); + private static final Instant NOW = Instant.parse("2026-09-01T00:00:00Z"); + + /** + * CORE-RVK-002: an Agent names only a Grant Type the Service advertises. Revoke checked the + * per-credential configuration but never the advertisement itself, so a request for an + * unadvertised type spent a signature on something the Service was always going to refuse. + */ + @Test + void revokeRefusesAnUnadvertisedGrantType() { + QueueTransport inspect = new QueueTransport(aepResponse(200, document(List.of("inspect", "revoke")))); + QueueTransport command = new QueueTransport(); + AepServiceSession session = agent(inspect, command).build().service(ORIGIN); + + AepAgentException error = agentError(session.revoke(new RevokeRequest("absent-grant", null, null))); + + assertEquals("grant_type_not_advertised", error.code()); + assertTrue(command.requests.isEmpty(), "no assertion is spent on a refused Grant Type"); + } + + @Test + void revokeAcceptsAnAdvertisedGrantTypeAndTheAllGrantTypesForm() { + QueueTransport inspect = new QueueTransport( + aepResponse(200, document(List.of("inspect", "revoke"))), + aepResponse(200, document(List.of("inspect", "revoke")))); + QueueTransport command = new QueueTransport(aepResponse(200, "{}"), aepResponse(200, "{}")); + AepAgent configured = agent(inspect, command).build(); + + configured + .service(ORIGIN) + .revoke(new RevokeRequest("custom-grant", null, null)) + .join(); + configured.service(ORIGIN).revoke(new RevokeRequest(null, null, "true")).join(); + + assertEquals(2, command.requests.size()); + // CORE-RVK-006: all_grant_types carries no grant type to check against the advertisement. + assertEquals("POST", command.requests.get(1).method()); + } + + /** CORE-INS-011: per-credential Revoke is used only where the configuration advertises it. */ + @Test + void revokeRefusesAnUnadvertisedPerCredentialForm() { + QueueTransport inspect = new QueueTransport(aepResponse(200, documentWithoutPerCredentialRevoke())); + QueueTransport command = new QueueTransport(); + AepServiceSession session = agent(inspect, command).build().service(ORIGIN); + + AepAgentException error = agentError(session.revoke(new RevokeRequest("custom-grant", "credential-1", null))); + + assertEquals("per_credential_revoke_unavailable", error.code()); + assertTrue(command.requests.isEmpty()); + } + + /** + * CORE-IDP-001 and CORE-IDP-002: the key the Agent records has to be the key the Service sees. + * A provider value with surrounding whitespace is stripped in transit, and one carrying a + * control character is not a field value at all. + */ + @Test + void refusesIdempotencyKeysThatCannotSurviveAsAFieldValue() { + for (String key : List.of(" key", "key ", "\tkey", "key\r\nX-Injected: 1", "key\u0000", "")) { + QueueTransport inspect = new QueueTransport(aepResponse(200, document(List.of("inspect", "enroll")))); + QueueTransport command = new QueueTransport(); + AepServiceSession session = agent(inspect, command) + .idempotencyKeyProvider((serviceDid, operation, discriminator) -> key) + .build() + .service(ORIGIN); + + AepAgentException error = + agentError(session.enroll(ClaimValues.builder().build())); + + assertEquals("invalid_configuration", error.code(), key); + assertTrue(command.requests.isEmpty(), key); + } + } + + /** CORE-IDP-009: the header and the Enroll body carry the same key. */ + @Test + void sendsOneIdempotencyKeyInBothTheHeaderAndTheEnrollBody() { + QueueTransport inspect = new QueueTransport(aepResponse(200, document(List.of("inspect", "enroll")))); + QueueTransport command = new QueueTransport(aepResponse(200, "{\"status\":\"active\"}")); + agent(inspect, command) + .idempotencyKeyProvider((serviceDid, operation, discriminator) -> "enroll-key") + .build() + .service(ORIGIN) + .enroll(ClaimValues.builder().build()) + .join(); + + AepHttpTransport.Request request = command.requests.get(0); + assertEquals(List.of("enroll-key"), request.headers().get("Idempotency-Key")); + assertTrue(new String(request.body(), StandardCharsets.UTF_8).contains("\"idempotency_key\":\"enroll-key\"")); + } + + /** CORE-CA-001: Inspect is unauthenticated and carries no assertion. */ + @Test + void doesNotAuthenticateInspect() { + QueueTransport inspect = new QueueTransport(aepResponse(200, document(List.of("inspect")))); + agent(inspect, new QueueTransport()).build().service(ORIGIN).inspect().join(); + + AepHttpTransport.Request request = inspect.requests.get(0); + assertFalse(request.headers().containsKey("Authorization")); + assertFalse(request.headers().containsKey(Aep.AUTHORIZATION_HEADER)); + assertFalse(request.headers().containsKey("Idempotency-Key")); + assertEquals(List.of(Aep.MEDIA_TYPE), request.headers().get("Accept")); + } + + /** + * CORE-AUTH-005: the challenged Service DID must match the fetched document, which for did:web + * means the DID has to bind the origin the document came from. + */ + @Test + void refusesADocumentWhoseServiceDidDoesNotBindTheOrigin() { + QueueTransport inspect = new QueueTransport(aepResponse( + 200, document(List.of("inspect")).replace("did:web:service.example", "did:web:other.example"))); + AepServiceSession session = agent(inspect, new QueueTransport()).build().service(ORIGIN); + + assertEquals("service_identity_mismatch", agentError(session.inspect()).code()); + } + + @Test + void refusesUnsupportedServiceDidBeforeRequestingIdentity() { + QueueTransport inspect = new QueueTransport( + aepResponse(200, document(List.of("inspect")).replace("did:web:service.example", "did:key:example"))); + AepServiceSession session = agent(inspect, new QueueTransport()) + .identityProvider((origin, did) -> { + throw new AssertionError("Identity must not be requested"); + }) + .build() + .service(ORIGIN); + assertEquals("service_identity_mismatch", agentError(session.inspect()).code()); + } + + /** CORE-PR-031: AEP credentials never cross an origin boundary. */ + @Test + void refusesToAuthenticateAgainstAnotherOrigin() { + AepServiceSession session = + agent(new QueueTransport(), new QueueTransport()).build().service(ORIGIN); + + for (URI resource : List.of( + URI.create("https://attacker.example/private"), + URI.create("http://service.example/private"), + URI.create("https://service.example:8443/private"))) { + assertEquals( + "origin_mismatch", + agentError(session.authenticate(resource)).code(), + resource.toString()); + } + } + + /** CORE-PR-001: an authenticate assertion binds to the exact protected-resource target. */ + @Test + void bindsTheAuthenticateAssertionToTheRequestedResource() { + QueueTransport inspect = new QueueTransport(aepResponse(200, document(List.of("inspect"), List.of("aep-jwt")))); + List signed = new ArrayList<>(); + AgentIdentity identity = new AgentIdentity("did:web:agent.example", claims -> { + signed.add(claims); + return CompletableFuture.completedFuture("assertion"); + }); + AgentAuthentication authentication = AepAgent.builder() + .inspectTransport(inspect) + .commandTransport(new QueueTransport()) + .identityProvider((origin, serviceDid) -> CompletableFuture.completedFuture(identity)) + .clock(Clock.fixed(NOW, ZoneOffset.UTC)) + .jwtIdSupplier(() -> "jwt-id") + .build() + .service(ORIGIN) + .authenticate(URI.create("https://service.example/private?q=1")) + .join(); + + assertEquals(Aep.AUTHENTICATION_METHOD_JWT, authentication.method()); + assertEquals(List.of("AEP assertion"), authentication.headers().get("Authorization")); + assertEquals( + foundation.aep.core.AssertionOperation.AUTHENTICATE, + signed.get(0).operation()); + assertEquals("https://service.example/private?q=1", signed.get(0).resource()); + assertEquals("did:web:service.example", signed.get(0).audience()); + } + + /** CORE-PR-038: with no advertised method the Agent has nothing to present. */ + @Test + void reportsWhenNoAdvertisedAuthenticationMethodIsAvailable() { + QueueTransport inspect = new QueueTransport(aepResponse(200, document(List.of("inspect")))); + AepServiceSession session = agent(inspect, new QueueTransport()).build().service(ORIGIN); + + assertEquals( + "authentication_unavailable", + agentError(session.authenticate(URI.create("https://service.example/private"))) + .code()); + } + + /** + * CORE-DISC-014: `no-cache` requires revalidation. Cache-Control directives are unordered, so + * the directive that forbids reuse has to win wherever it appears in the field value. + */ + @Test + void revalidatesWhenNoCacheAccompaniesAFreshnessLifetime() { + String body = document(List.of("inspect")); + QueueTransport inspect = new QueueTransport( + response( + 200, + Map.of( + "Content-Type", List.of(Aep.MEDIA_TYPE), + "Cache-Control", List.of("max-age=600, no-cache"), + "ETag", List.of("\"v1\"")), + body), + response( + 200, + Map.of("Content-Type", List.of(Aep.MEDIA_TYPE), "Cache-Control", List.of("max-age=600")), + body)); + MemoryInspectCache cache = new MemoryInspectCache(); + AepAgent configured = + agent(inspect, new QueueTransport()).inspectCache(cache).build(); + + configured.service(ORIGIN).inspect().join(); + configured.service(ORIGIN).inspect().join(); + + assertEquals(2, inspect.requests.size(), "no-cache must force revalidation"); + assertEquals(List.of("\"v1\""), inspect.requests.get(1).headers().get("If-None-Match")); + + // A plain max-age is reused without a second request. + configured.service(ORIGIN).inspect().join(); + assertEquals(2, inspect.requests.size()); + } + + /** CORE-DISC: `no-store` prohibits persistence beyond the current fetch. */ + @Test + void doesNotPersistANoStoreDocument() { + String body = document(List.of("inspect")); + QueueTransport inspect = new QueueTransport( + response( + 200, + Map.of("Content-Type", List.of(Aep.MEDIA_TYPE), "Cache-Control", List.of("no-store")), + body), + aepResponse(200, body)); + MemoryInspectCache cache = new MemoryInspectCache(); + AepAgent configured = + agent(inspect, new QueueTransport()).inspectCache(cache).build(); + + configured.service(ORIGIN).inspect().join(); + assertTrue(cache.entry.isEmpty(), "a no-store document is not persisted"); + configured.service(ORIGIN).inspect().join(); + assertEquals(2, inspect.requests.size()); + } + + /** CORE-DISC-013: a 304 refreshes the validators without refetching the body. */ + @Test + void reusesACachedDocumentOnNotModified() { + String body = document(List.of("inspect")); + QueueTransport inspect = new QueueTransport( + response( + 200, + Map.of( + "Content-Type", List.of(Aep.MEDIA_TYPE), + "Cache-Control", List.of("max-age=0"), + "ETag", List.of("\"v1\""), + "Last-Modified", List.of("Mon, 01 Sep 2026 00:00:00 GMT")), + body), + response(304, Map.of("ETag", List.of("\"v2\""), "Cache-Control", List.of("max-age=600")), "")); + MemoryInspectCache cache = new MemoryInspectCache(); + AepAgent configured = + agent(inspect, new QueueTransport()).inspectCache(cache).build(); + + configured.service(ORIGIN).inspect().join(); + AgentInspection second = configured.service(ORIGIN).inspect().join(); + + assertEquals("did:web:service.example", second.document().service().did()); + AepHttpTransport.Request revalidation = inspect.requests.get(1); + assertEquals(List.of("\"v1\""), revalidation.headers().get("If-None-Match")); + assertEquals( + List.of("Mon, 01 Sep 2026 00:00:00 GMT"), revalidation.headers().get("If-Modified-Since")); + assertEquals("\"v2\"", cache.entry.orElseThrow().etag()); + } + + /** CORE-DISC-017: Inspect redirects stay on the origin and never carry user information. */ + @Test + void refusesUnsafeInspectRedirects() { + for (String location : List.of( + "https://attacker.example/aep", + "http://service.example/aep", + "https://user:secret@service.example/aep", + "https://service.example/aep#fragment")) { + QueueTransport inspect = new QueueTransport(response(302, Map.of("Location", List.of(location)), "")); + AepServiceSession session = + agent(inspect, new QueueTransport()).build().service(ORIGIN); + String code = agentError(session.inspect()).code(); + assertTrue( + "cross_origin_redirect".equals(code) || "invalid_redirect".equals(code), + location + " produced " + code); + } + QueueTransport noLocation = new QueueTransport(response(302, Map.of(), "")); + assertEquals( + "invalid_redirect", + agentError(agent(noLocation, new QueueTransport()) + .build() + .service(ORIGIN) + .inspect()) + .code()); + } + + /** CORE-PR-033: a command endpoint never follows a redirect. */ + @Test + void refusesToFollowACommandRedirect() { + QueueTransport inspect = new QueueTransport(aepResponse(200, document(List.of("inspect", "status")))); + QueueTransport command = new QueueTransport(response(302, Map.of("Location", List.of("/elsewhere")), "")); + AepServiceSession session = agent(inspect, command).build().service(ORIGIN); + + assertEquals("command_redirect", agentError(session.status()).code()); + assertEquals(1, command.requests.size()); + } + + /** CORE-ERR-001: a Problem Details body becomes the reported failure. */ + @Test + void surfacesProblemDetailsFromACommandFailure() { + QueueTransport inspect = new QueueTransport(aepResponse(200, document(List.of("inspect", "status")))); + QueueTransport command = new QueueTransport(response( + 403, + Map.of("Content-Type", List.of(Aep.PROBLEM_MEDIA_TYPE)), + "{\"type\":\"urn:aep:error:verification_pending\",\"title\":\"Verification pending\"," + + "\"status\":403,\"code\":\"verification_pending\"}")); + AepServiceSession session = agent(inspect, command).build().service(ORIGIN); + + AepAgentException error = agentError(session.status()); + assertEquals("verification_pending", error.code()); + assertEquals(403, error.status()); + } + + /** A failure body that is not Problem Details still fails, without inventing a code. */ + @Test + void reportsANonProblemCommandFailure() { + QueueTransport inspect = new QueueTransport(aepResponse(200, document(List.of("inspect", "status")))); + QueueTransport command = + new QueueTransport(response(500, Map.of("Content-Type", List.of("text/plain")), "boom")); + AepServiceSession session = agent(inspect, command).build().service(ORIGIN); + + AepAgentException error = agentError(session.status()); + assertEquals("command_failed", error.code()); + assertEquals(500, error.status()); + } + + /** A command endpoint answers in the AEP media type or not at all. */ + @Test + void requiresTheAepMediaTypeOnSuccessfulResponses() { + QueueTransport inspect = new QueueTransport(aepResponse(200, document(List.of("inspect", "status")))); + QueueTransport command = + new QueueTransport(response(200, Map.of("Content-Type", List.of("application/json")), "{}")); + AepServiceSession session = agent(inspect, command).build().service(ORIGIN); + + assertEquals("invalid_media_type", agentError(session.status()).code()); + } + + /** A Service that does not advertise a command never receives it. */ + @Test + void refusesCommandsTheServiceDoesNotAdvertise() { + QueueTransport inspect = new QueueTransport( + aepResponse(200, document(List.of("inspect"))), + aepResponse(200, document(List.of("inspect"))), + aepResponse(200, document(List.of("inspect")))); + QueueTransport command = new QueueTransport(); + AepAgent configured = agent(inspect, command).build(); + + assertEquals( + "command_not_advertised", + agentError(configured.service(ORIGIN).status()).code()); + assertEquals( + "command_not_advertised", + agentError(configured + .service(ORIGIN) + .enroll(ClaimValues.builder().build())) + .code()); + assertEquals( + "command_not_advertised", + agentError(configured.service(ORIGIN).revoke(new RevokeRequest(null, null, "true"))) + .code()); + assertTrue(command.requests.isEmpty()); + } + + /** A response larger than the configured bound is refused before it is parsed. */ + @Test + void refusesAnOversizedResponse() { + QueueTransport inspect = new QueueTransport(aepResponse(200, document(List.of("inspect")))); + AepServiceSession session = agent(inspect, new QueueTransport()) + .maximumResponseBytes(8) + .build() + .service(ORIGIN); + + assertEquals("response_too_large", agentError(session.inspect()).code()); + } + + private static AepAgent.Builder agent(AepHttpTransport inspect, AepHttpTransport command) { + AgentIdentity identity = + new AgentIdentity("did:web:agent.example", claims -> CompletableFuture.completedFuture("signed")); + return AepAgent.builder() + .inspectTransport(inspect) + .commandTransport(command) + .identityProvider((origin, serviceDid) -> CompletableFuture.completedFuture(identity)) + .clock(Clock.fixed(NOW, ZoneOffset.UTC)) + .jwtIdSupplier(() -> "jwt-id"); + } + + private static String document(List commands) { + return document(commands, List.of()); + } + + private static String document(List commands, List methods) { + boolean grants = commands.contains("grant") || commands.contains("revoke"); + String grantTypes = grants ? "[\"custom-grant\"]" : "[]"; + String grantTypesConfig = grants ? "{\"custom-grant\":{\"supports_per_credential_revoke\":\"true\"}}" : "{}"; + String authentication = methods.isEmpty() ? "" : "\"authentication\":{\"methods\":" + array(methods) + "},"; + return """ + { + "aep_version":"1.0", + %s + "bindings":{"supported":["http"]}, + "commands":{"supported":%s,"grant_types":%s,"grant_types_config":%s}, + "core":{"signing_algorithms":["EdDSA","ES256"]}, + "http":{"endpoint_base":"/aep/"}, + "identity":{"methods":["did:web"]}, + "service":{"did":"did:web:service.example"} + } + """.formatted(authentication, array(commands), grantTypes, grantTypesConfig); + } + + private static String documentWithoutPerCredentialRevoke() { + return """ + { + "aep_version":"1.0", + "bindings":{"supported":["http"]}, + "commands":{"supported":["inspect","revoke"],"grant_types":["custom-grant"], + "grant_types_config":{"custom-grant":{"supports_per_credential_revoke":"false"}}}, + "core":{"signing_algorithms":["EdDSA","ES256"]}, + "http":{"endpoint_base":"/aep/"}, + "identity":{"methods":["did:web"]}, + "service":{"did":"did:web:service.example"} + } + """; + } + + private static String array(List values) { + return values.stream() + .map(value -> "\"" + value + "\"") + .collect(java.util.stream.Collectors.joining(",", "[", "]")); + } + + private static AepHttpTransport.Response aepResponse(int status, String body) { + return response(status, Map.of("Content-Type", List.of(Aep.MEDIA_TYPE)), body); + } + + private static AepHttpTransport.Response response(int status, Map> headers, String body) { + return new AepHttpTransport.Response(status, headers, body.getBytes(StandardCharsets.UTF_8)); + } + + private static AepAgentException agentError(CompletableFuture future) { + CompletionException failure = assertThrows(CompletionException.class, future::join); + return (AepAgentException) failure.getCause(); + } + + private static final class QueueTransport implements AepHttpTransport { + private final Deque responses = new ArrayDeque<>(); + private final List requests = new ArrayList<>(); + + QueueTransport(Response... values) { + responses.addAll(List.of(values)); + } + + @Override + public CompletableFuture execute(Request request) { + requests.add(request); + if (responses.isEmpty()) { + return CompletableFuture.failedFuture(new AssertionError("Unexpected request: " + request)); + } + return CompletableFuture.completedFuture(responses.removeFirst()); + } + } + + private static final class MemoryInspectCache implements AgentInspectCache { + private Optional entry = Optional.empty(); + + @Override + public CompletableFuture> get(URI origin) { + return CompletableFuture.completedFuture(entry); + } + + @Override + public CompletableFuture put(URI origin, Entry value) { + entry = Optional.of(value); + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletableFuture delete(URI origin) { + entry = Optional.empty(); + return CompletableFuture.completedFuture(null); + } + } +} diff --git a/aep-agent/src/test/java/foundation/aep/agent/AgentCoverageTest.java b/aep-agent/src/test/java/foundation/aep/agent/AgentCoverageTest.java new file mode 100644 index 0000000..2599ea9 --- /dev/null +++ b/aep-agent/src/test/java/foundation/aep/agent/AgentCoverageTest.java @@ -0,0 +1,602 @@ +package foundation.aep.agent; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import foundation.aep.core.Aep; +import foundation.aep.core.AepHttpTransport; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import org.junit.jupiter.api.Test; + +/** Configuration, caching, and helper behaviour across the Agent module. */ +class AgentCoverageTest { + private static final URI ORIGIN = URI.create("https://service.example"); + private static final Instant NOW = Instant.parse("2026-09-01T00:00:00Z"); + + private static AepHttpTransport.Response response(Map> headers) { + return new AepHttpTransport.Response(200, headers, new byte[0]); + } + + @Test + void normalizesAndRejectsServiceOrigins() { + assertEquals(ORIGIN, AgentHttp.origin(URI.create("https://SERVICE.example/"), false)); + assertEquals( + URI.create("https://service.example:8443"), + AgentHttp.origin(URI.create("https://service.example:8443"), false)); + assertEquals(URI.create("http://localhost:8080"), AgentHttp.origin(URI.create("http://localhost:8080"), true)); + for (URI value : List.of( + URI.create("http://service.example"), + URI.create("https://user:secret@service.example"), + URI.create("https://service.example/path"), + URI.create("https://service.example?query"), + URI.create("https://service.example#fragment"), + URI.create("/relative"), + URI.create("mailto:agent@example.com"))) { + assertThrows(IllegalArgumentException.class, () -> AgentHttp.origin(value, false), value.toString()); + } + assertThrows(IllegalArgumentException.class, () -> AgentHttp.origin(null, false)); + // The loopback opt-in does not extend to a name that merely resolves to loopback. + assertThrows( + IllegalArgumentException.class, () -> AgentHttp.origin(URI.create("http://loopback.example"), true)); + } + + @Test + void comparesOriginsWithoutThrowingOnAMalformedHost() { + assertTrue(AgentHttp.sameOrigin(ORIGIN, URI.create("https://service.example:443/x"))); + assertTrue(AgentHttp.sameOrigin(URI.create("http://a.example"), URI.create("http://a.example:80/x"))); + assertFalse(AgentHttp.sameOrigin(ORIGIN, URI.create("https://service.example:8443/x"))); + assertFalse(AgentHttp.sameOrigin(ORIGIN, null)); + assertFalse(AgentHttp.sameOrigin(null, ORIGIN)); + assertFalse(AgentHttp.sameOrigin(ORIGIN, URI.create("/relative"))); + assertFalse(AgentHttp.sameOrigin(URI.create("/relative"), ORIGIN)); + // A redirect Location is attacker-controlled; an unparseable label is simply not a match. + assertFalse(AgentHttp.sameOrigin(ORIGIN, URI.create("https://xn--/x"))); + assertFalse(AgentHttp.sameOrigin(URI.create("https://xn--/x"), ORIGIN)); + } + + @Test + void identifiesValidInspectTargets() { + assertTrue(AgentHttp.validInspectTarget(ORIGIN, URI.create("https://service.example/.well-known/aep"))); + assertFalse(AgentHttp.validInspectTarget(ORIGIN, null)); + assertFalse(AgentHttp.validInspectTarget(ORIGIN, URI.create("https://user:pw@service.example/aep"))); + assertFalse(AgentHttp.validInspectTarget(ORIGIN, URI.create("https://service.example/aep#x"))); + assertFalse(AgentHttp.validInspectTarget(ORIGIN, URI.create("https://other.example/aep"))); + } + + @Test + void resolvesFreshnessFromCacheControl() { + assertEquals( + NOW.plusSeconds(60), + AgentHttp.expiresAt( + response(Map.of("Cache-Control", List.of("max-age=60"))), NOW, Duration.ofSeconds(300))); + // Directives are unordered, so the one that forbids reuse wins wherever it appears. + assertEquals( + NOW, + AgentHttp.expiresAt( + response(Map.of("Cache-Control", List.of("max-age=600, no-cache"))), + NOW, + Duration.ofSeconds(300))); + assertEquals( + NOW, + AgentHttp.expiresAt( + response(Map.of("Cache-Control", List.of("max-age=600", "no-store"))), + NOW, + Duration.ofSeconds(300))); + assertEquals( + NOW, + AgentHttp.expiresAt( + response(Map.of("Cache-Control", List.of("no-cache"))), NOW, Duration.ofSeconds(300))); + // A negative or unparseable lifetime falls back rather than reaching backwards. + assertEquals( + NOW, + AgentHttp.expiresAt( + response(Map.of("Cache-Control", List.of("max-age=-5"))), NOW, Duration.ofSeconds(300))); + assertEquals( + NOW.plusSeconds(300), + AgentHttp.expiresAt( + response(Map.of("Cache-Control", List.of("max-age=abc"))), NOW, Duration.ofSeconds(300))); + assertEquals( + NOW.plusSeconds(300), + AgentHttp.expiresAt( + response(Map.of("Cache-Control", List.of("public, must-revalidate"))), + NOW, + Duration.ofSeconds(300))); + assertEquals(NOW.plusSeconds(300), AgentHttp.expiresAt(response(Map.of()), NOW, Duration.ofSeconds(300))); + // An overflowing lifetime saturates instead of throwing. + assertEquals( + Instant.MAX, + AgentHttp.expiresAt( + response(Map.of("Cache-Control", List.of("max-age=" + Long.MAX_VALUE))), NOW, Duration.ZERO)); + + assertTrue(AgentHttp.isNoStore(response(Map.of("Cache-Control", List.of("public, NO-STORE"))))); + assertFalse(AgentHttp.isNoStore(response(Map.of("Cache-Control", List.of("no-cache"))))); + assertFalse(AgentHttp.isNoStore(response(Map.of()))); + } + + @Test + void readsHeadersCaseInsensitively() { + AepHttpTransport.Response value = response(Map.of("etag", List.of("\"v1\""), "X-Other", List.of("a"))); + assertEquals(Optional.of("\"v1\""), AgentHttp.header(value, "ETag")); + assertEquals(Optional.empty(), AgentHttp.header(value, "Absent")); + assertEquals(Map.of("A", List.of("1")), AgentHttp.headers(Map.of("A", "1"))); + } + + @Test + void computesRemainingFreshnessUsingDateAgeAndExpires() { + Duration fallback = Duration.ofSeconds(300); + assertEquals( + NOW.plusSeconds(40), + AgentHttp.expiresAt( + response(Map.of("Cache-Control", List.of("max-age=\"60\""), "Age", List.of("20"))), + NOW, + fallback)); + assertEquals( + NOW.plusSeconds(40), + AgentHttp.expiresAt( + response(Map.of( + "Cache-Control", + List.of("max-age=60"), + "Date", + List.of("Mon, 31 Aug 2026 23:59:40 GMT"))), + NOW, + fallback)); + assertEquals( + NOW.plusSeconds(60), + AgentHttp.expiresAt( + response(Map.of( + "Date", List.of("Tue, 1 Sep 2026 00:00:00 GMT"), + "Expires", List.of("Tue, 1 Sep 2026 00:01:00 GMT"))), + NOW, + fallback)); + assertEquals( + NOW.plusSeconds(300), + AgentHttp.expiresAt( + response(Map.of( + "Date", List.of("invalid"), "Expires", List.of("invalid"), "Age", List.of("invalid"))), + NOW, + fallback)); + } + + @Test + void cacheMetadataCannotBeMutatedByTheCaller() { + java.util.ArrayList policy = new java.util.ArrayList<>(List.of("no-cache")); + java.util.HashMap> headers = new java.util.HashMap<>(); + headers.put("Cache-Control", policy); + AgentInspectCache.Entry entry = new AgentInspectCache.Entry(ORIGIN, "{}", "v1", null, NOW, headers); + policy.clear(); + headers.clear(); + assertEquals(List.of("no-cache"), entry.cacheHeaders().get("Cache-Control")); + assertThrows( + UnsupportedOperationException.class, () -> entry.cacheHeaders().clear()); + assertThrows( + UnsupportedOperationException.class, + () -> entry.cacheHeaders().get("Cache-Control").clear()); + } + + @Test + void refusesFieldValuesThatCannotTravelInAHeader() { + assertEquals("key", AgentHttp.requireFieldValue("key", "idempotency key")); + assertEquals("a b", AgentHttp.requireFieldValue("a b", "idempotency key")); + for (String value : List.of(" key", "key ", "\tkey", "key\t", "", " ", "key\r\n", "key\u0000", "key\u007f")) { + AepAgentException error = assertThrows( + AepAgentException.class, () -> AgentHttp.requireFieldValue(value, "idempotency key"), value); + assertEquals("invalid_configuration", error.code()); + } + assertThrows(AepAgentException.class, () -> AgentHttp.requireFieldValue(null, "idempotency key")); + } + + @Test + void reportsBodyLimitsAndMediaTypes() { + AepHttpTransport.Response large = + new AepHttpTransport.Response(200, Map.of(), "0123456789".getBytes(StandardCharsets.UTF_8)); + assertEquals( + "response_too_large", + assertThrows(AepAgentException.class, () -> AgentHttp.requireBodyLimit(large, 4)) + .code()); + AgentHttp.requireBodyLimit(large, 10); + assertEquals("0123456789", AgentHttp.body(large)); + + AepHttpTransport.Response typed = new AepHttpTransport.Response( + 200, Map.of("Content-Type", List.of(Aep.MEDIA_TYPE + "; charset=utf-8")), new byte[0]); + AgentHttp.requireMediaType(typed, Aep.MEDIA_TYPE); + assertEquals( + "invalid_media_type", + assertThrows(AepAgentException.class, () -> AgentHttp.requireMediaType(typed, "application/json")) + .code()); + assertEquals( + "invalid_media_type", + assertThrows( + AepAgentException.class, + () -> AgentHttp.requireMediaType( + new AepHttpTransport.Response(200, Map.of(), new byte[0]), Aep.MEDIA_TYPE)) + .code()); + } + + @Test + void validatesAgentConfiguration() { + AepAgent.Builder builder = AepAgent.builder(); + assertThrows(NullPointerException.class, () -> builder.inspectTransport(null)); + assertThrows(NullPointerException.class, () -> builder.commandTransport(null)); + assertThrows(NullPointerException.class, () -> builder.identityProvider(null)); + assertThrows(NullPointerException.class, () -> builder.credentialStore(null)); + assertThrows(NullPointerException.class, () -> builder.inspectCache(null)); + assertThrows(NullPointerException.class, () -> builder.idempotencyKeyProvider(null)); + assertThrows(NullPointerException.class, () -> builder.credentialHandler(null)); + assertThrows(NullPointerException.class, () -> builder.clock(null)); + assertThrows(NullPointerException.class, () -> builder.jwtIdSupplier(null)); + assertThrows(NullPointerException.class, () -> builder.requestTimeout(null)); + + for (Duration invalid : List.of(Duration.ZERO, Duration.ofSeconds(-1))) { + assertThrows(IllegalArgumentException.class, () -> builder.requestTimeout(invalid)); + assertThrows(IllegalArgumentException.class, () -> builder.assertionLifetime(invalid)); + assertThrows(IllegalArgumentException.class, () -> builder.defaultInspectFreshness(invalid)); + } + // CORE-CA-019 caps an assertion at five minutes, so the Agent cannot be configured past it. + assertThrows( + IllegalArgumentException.class, + () -> builder.assertionLifetime(Duration.ofMinutes(5).plusSeconds(1))); + builder.assertionLifetime(Duration.ofMinutes(5)); + assertThrows(IllegalArgumentException.class, () -> builder.maximumResponseBytes(0)); + assertThrows(IllegalArgumentException.class, () -> builder.maximumResponseBytes(-1)); + assertThrows(IllegalArgumentException.class, () -> builder.maximumRedirects(-1)); + builder.maximumRedirects(0).maximumResponseBytes(1024).allowInsecureLoopback(true); + + // build() names the collaborator it still needs. + assertThrows(NullPointerException.class, AepAgent.builder()::build); + assertThrows( + NullPointerException.class, + () -> AepAgent.builder().inspectTransport(nullTransport()).build()); + assertThrows( + NullPointerException.class, + () -> AepAgent.builder() + .inspectTransport(nullTransport()) + .commandTransport(nullTransport()) + .build()); + } + + @Test + void refusesCredentialHandlersThatCollide() { + AgentCredentialHandler duplicate = handler("api-key", "api-key"); + assertThrows( + IllegalArgumentException.class, + () -> AepAgent.builder() + .inspectTransport(nullTransport()) + .commandTransport(nullTransport()) + .identityProvider((origin, did) -> CompletableFuture.completedFuture(null)) + .credentialHandler(duplicate) + .build()); + for (AgentCredentialHandler blank : List.of(handler("", "grant"), handler("method", ""))) { + assertThrows( + IllegalArgumentException.class, + () -> AepAgent.builder() + .inspectTransport(nullTransport()) + .commandTransport(nullTransport()) + .identityProvider((origin, did) -> CompletableFuture.completedFuture(null)) + .credentialHandler(blank) + .build()); + } + AepAgent agent = AepAgent.builder() + .inspectTransport(nullTransport()) + .commandTransport(nullTransport()) + .identityProvider((origin, did) -> CompletableFuture.completedFuture(null)) + .credentialHandler(handler("custom-session", "custom-grant")) + .build(); + assertEquals(ORIGIN, agent.service(ORIGIN).origin()); + } + + @Test + void servesInMemoryAndNoOpInspectCaches() { + AgentInspectCache none = AgentInspectCache.none(); + AgentInspectCache.Entry entry = + new AgentInspectCache.Entry(ORIGIN, "{}", "\"v1\"", "Mon, 01 Sep 2026 00:00:00 GMT", NOW); + assertEquals(Optional.empty(), join(none.get(ORIGIN))); + assertNull(join(none.put(ORIGIN, entry))); + assertEquals(Optional.empty(), join(none.get(ORIGIN))); + assertNull(join(none.delete(ORIGIN))); + + AgentInspectCache memory = AgentInspectCache.inMemory(); + assertEquals(Optional.empty(), join(memory.get(ORIGIN))); + join(memory.put(ORIGIN, entry)); + assertEquals(Optional.of(entry), join(memory.get(ORIGIN))); + join(memory.delete(ORIGIN)); + assertEquals(Optional.empty(), join(memory.get(ORIGIN))); + + assertThrows(NullPointerException.class, () -> new AgentInspectCache.Entry(null, "{}", null, null, NOW)); + assertThrows(NullPointerException.class, () -> new AgentInspectCache.Entry(ORIGIN, null, null, null, NOW)); + assertThrows(NullPointerException.class, () -> new AgentInspectCache.Entry(ORIGIN, "{}", null, null, null)); + } + + @Test + void servesTheInMemoryCredentialStore() { + AgentCredentialStore store = AgentCredentialStore.inMemory(); + AgentCredential first = new AgentCredential("did:web:a.example", "api-key", "c-1", NOW.plusSeconds(60), "{}"); + AgentCredential second = + new AgentCredential("did:web:a.example", "oauth-bearer", "c-2", NOW.plusSeconds(60), "{}"); + AgentCredential other = new AgentCredential("did:web:b.example", "api-key", "c-3", NOW.plusSeconds(60), "{}"); + + assertEquals(Optional.empty(), join(store.find("did:web:a.example", "api-key", NOW))); + join(store.save(first)); + join(store.save(second)); + join(store.save(other)); + assertEquals(Optional.of(first), join(store.find("did:web:a.example", "api-key", NOW))); + assertEquals(Optional.empty(), join(store.find("did:web:a.example", "absent", NOW))); + + join(store.delete("did:web:a.example", "c-1")); + assertEquals(Optional.empty(), join(store.find("did:web:a.example", "api-key", NOW))); + assertEquals(Optional.of(second), join(store.find("did:web:a.example", "oauth-bearer", NOW))); + + join(store.deleteGrantType("did:web:a.example", "oauth-bearer")); + assertEquals(Optional.empty(), join(store.find("did:web:a.example", "oauth-bearer", NOW))); + assertEquals(Optional.of(other), join(store.find("did:web:b.example", "api-key", NOW))); + + join(store.deleteAll("did:web:b.example")); + assertEquals(Optional.empty(), join(store.find("did:web:b.example", "api-key", NOW))); + // Deleting what is not there is not an error. + join(store.delete("did:web:absent.example", "c-9")); + join(store.deleteGrantType("did:web:absent.example", "api-key")); + join(store.deleteAll("did:web:absent.example")); + } + + @Test + void copiesPlatformContextDefensivelyAndRejectsNonJson() { + assertNull(PlatformJson.copyMap(null)); + Map source = new LinkedHashMap<>(); + source.put("text", "value"); + source.put("number", 7); + source.put("flag", true); + source.put("absent", null); + source.put("list", new ArrayList<>(List.of(1, "two"))); + Map nested = new LinkedHashMap<>(); + nested.put("deep", List.of("x")); + source.put("nested", nested); + + Map copy = PlatformJson.copyMap(source); + source.put("text", "mutated"); + nested.put("deep", List.of("y")); + assertEquals("value", copy.get("text")); + assertEquals(List.of("x"), ((Map) copy.get("nested")).get("deep")); + assertThrows(UnsupportedOperationException.class, () -> copy.put("added", 1)); + + Map unsupported = new LinkedHashMap<>(); + unsupported.put("value", new Object()); + assertThrows(IllegalArgumentException.class, () -> PlatformJson.copyMap(unsupported)); + + Map badKey = new LinkedHashMap<>(); + badKey.put(1, "value"); + Map wrapper = new LinkedHashMap<>(); + wrapper.put("nested", badKey); + assertThrows(IllegalArgumentException.class, () -> PlatformJson.copyMap(wrapper)); + } + + @Test + void carriesFailureDetailOnAgentExceptions() { + AepAgentException plain = new AepAgentException("code", "message"); + assertEquals("code", plain.code()); + assertEquals(0, plain.status()); + assertNull(plain.getCause()); + + IllegalStateException cause = new IllegalStateException("cause"); + AepAgentException wrapped = new AepAgentException("code", "message", cause); + assertSame(cause, wrapped.getCause()); + assertEquals(0, wrapped.status()); + + AepAgentException statused = new AepAgentException("code", "message", 503); + assertEquals(503, statused.status()); + assertEquals("message", statused.getMessage()); + } + + @Test + void authenticationHeadersAreIndependentOfCallerMutation() { + List values = new ArrayList<>(List.of("AEP secret")); + Map> headers = new LinkedHashMap<>(); + headers.put("Authorization", values); + AgentAuthentication authentication = new AgentAuthentication("aep-jwt", headers); + values.clear(); + headers.clear(); + assertEquals(Map.of("Authorization", List.of("AEP secret")), authentication.headers()); + assertThrows( + UnsupportedOperationException.class, + () -> authentication.headers().clear()); + assertThrows( + UnsupportedOperationException.class, + () -> authentication.headers().get("Authorization").clear()); + } + + @Test + void exposesValueTypesWithoutLeakingSecrets() { + AgentAuthentication authentication = + new AgentAuthentication("aep-jwt", Map.of("Authorization", List.of("AEP secret-assertion"))); + assertEquals("aep-jwt", authentication.method()); + assertEquals(List.of("AEP secret-assertion"), authentication.headers().get("Authorization")); + assertFalse(authentication.toString().contains("secret-assertion")); + assertEquals(Map.of(), new AgentAuthentication("custom", null).headers()); + + AgentCredential credential = + new AgentCredential("did:web:a.example", "api-key", "c-1", NOW, "{\"api_key\":\"secret\"}"); + assertFalse(credential.toString().contains("secret")); + assertEquals("c-1", credential.credentialId()); + + AgentGrantResult result = new AgentGrantResult("api-key", "{\"api_key\":\"secret\"}", Optional.of(credential)); + assertEquals("api-key", result.grantType()); + assertFalse(result.toString().contains("secret")); + + AgentIdentity identity = new AgentIdentity( + "did:web:agent.example", claims -> CompletableFuture.completedFuture("signed-assertion")); + assertEquals("did:web:agent.example", identity.did()); + assertFalse(identity.toString().contains("signed-assertion")); + + foundation.aep.core.PlatformAgentIdentity hosted = new foundation.aep.core.PlatformAgentIdentity( + "did:web:p.example:a:one", + "pai_1", + "2026-09-01T00:00:00Z", + "https://p.example/a/one/did.json", + "did:web:p.example:a:one", + "did:web:service.example", + List.of("ES256"), + foundation.aep.core.ManagedAgentStatus.ACTIVE, + "2026-09-01T00:00:00Z"); + PlatformPendingSign pending = new PlatformPendingSign(hosted, Map.of("handle", "opaque"), 5); + assertEquals(5, pending.retryAfterSeconds()); + assertEquals(hosted, pending.identity()); + assertEquals(Map.of("handle", "opaque"), pending.platformContext()); + assertEquals(pending, new PlatformSignPendingException(pending).pending()); + } + + @Test + void reusesOneInspectionAcrossConcurrentCallers() { + AepHttpTransport inspect = request -> CompletableFuture.completedFuture(new AepHttpTransport.Response( + 200, + Map.of("Content-Type", List.of(Aep.MEDIA_TYPE), "Cache-Control", List.of("no-store")), + document().getBytes(StandardCharsets.UTF_8))); + AepServiceSession session = AepAgent.builder() + .inspectTransport(inspect) + .commandTransport(request -> CompletableFuture.failedFuture(new AssertionError("unused"))) + .identityProvider((origin, did) -> CompletableFuture.completedFuture( + new AgentIdentity("did:web:agent.example", claims -> CompletableFuture.completedFuture("s")))) + .inspectCache(AgentInspectCache.none()) + .clock(Clock.fixed(NOW, ZoneOffset.UTC)) + .build() + .service(ORIGIN); + + CompletableFuture first = session.inspect(); + CompletableFuture second = session.inspect(); + assertEquals(first.join().documentUri(), second.join().documentUri()); + assertEquals( + "did:web:service.example", first.join().document().service().did()); + } + + @Test + void rejectsNonPositivePollingBounds() { + AepServiceSession session = AepAgent.builder() + .inspectTransport(nullTransport()) + .commandTransport(nullTransport()) + .identityProvider((origin, did) -> CompletableFuture.completedFuture(null)) + .build() + .service(ORIGIN); + assertThrows(IllegalArgumentException.class, () -> session.waitForActive(Duration.ZERO, Duration.ofSeconds(1))); + assertThrows( + IllegalArgumentException.class, + () -> session.waitForActive(Duration.ofSeconds(1), Duration.ofSeconds(-1))); + assertThrows(NullPointerException.class, () -> session.waitForActive(null, Duration.ofSeconds(1))); + assertThrows(NullPointerException.class, () -> session.waitForActive(Duration.ofSeconds(1), null)); + assertThrows(NullPointerException.class, () -> session.revoke(null)); + assertThrows(NullPointerException.class, () -> session.grant((foundation.aep.core.GrantRequest) null)); + assertThrows(NullPointerException.class, () -> session.authenticate(null)); + } + + private static T join(CompletionStage stage) { + return stage.toCompletableFuture().join(); + } + + private static AepHttpTransport nullTransport() { + return request -> CompletableFuture.failedFuture(new AssertionError("Transport must not be called")); + } + + private static AgentCredentialHandler handler(String method, String grantType) { + return new AgentCredentialHandler() { + @Override + public String authenticationMethod() { + return method; + } + + @Override + public String grantType() { + return grantType; + } + + @Override + public AgentCredential parse(String serviceDid, String responseJson) { + return new AgentCredential(serviceDid, grantType, "c-1", NOW, responseJson); + } + + @Override + public Map authorizationHeaders(AgentCredential credential, URI resource) { + return Map.of("X-Credential", "value"); + } + }; + } + + private static String document() { + return """ + { + "aep_version":"1.0", + "bindings":{"supported":["http"]}, + "commands":{"supported":["inspect"],"grant_types":[],"grant_types_config":{}}, + "core":{"signing_algorithms":["EdDSA","ES256"]}, + "http":{"endpoint_base":"/aep/"}, + "identity":{"methods":["did:web"]}, + "service":{"did":"did:web:service.example"} + } + """; + } + + @Test + void appliesTheRemainingBuilderDefaultsAndBounds() { + AepAgent agent = AepAgent.builder() + .inspectTransport(nullTransport()) + .commandTransport(nullTransport()) + .identityProvider((origin, did) -> CompletableFuture.completedFuture(null)) + .requestTimeout(Duration.ofSeconds(10)) + .defaultInspectFreshness(Duration.ofSeconds(120)) + .build(); + assertEquals(ORIGIN, agent.service(ORIGIN).origin()); + + // The default idempotency key provider and JWT id supplier produce distinct usable values. + AepAgent defaults = AepAgent.builder() + .inspectTransport(nullTransport()) + .commandTransport(nullTransport()) + .identityProvider((origin, did) -> CompletableFuture.completedFuture(null)) + .build(); + assertEquals(ORIGIN, defaults.service(ORIGIN).origin()); + } + + @Test + void refusesAPendingSignOutsideTheRetryRange() { + foundation.aep.core.PlatformAgentIdentity identity = new foundation.aep.core.PlatformAgentIdentity( + "did:web:p.example:a:one", + "pai_1", + "2026-09-01T00:00:00Z", + "https://p.example/a/one/did.json", + "did:web:p.example:a:one", + "did:web:service.example", + List.of("ES256"), + foundation.aep.core.ManagedAgentStatus.ACTIVE, + "2026-09-01T00:00:00Z"); + for (int retry : List.of(0, -1, 301)) { + assertThrows( + IllegalArgumentException.class, + () -> new PlatformPendingSign(identity, Map.of(), retry), + Integer.toString(retry)); + } + assertEquals(1, new PlatformPendingSign(identity, null, 1).retryAfterSeconds()); + assertEquals(300, new PlatformPendingSign(identity, null, 300).retryAfterSeconds()); + assertThrows(NullPointerException.class, () -> new PlatformSignPendingException(null)); + } + + @Test + void reportsAnUnparseableCredentialExpiry() { + assertThrows( + IllegalArgumentException.class, + () -> AgentCredentialHandlers.oauthBearer() + .parse( + "did:web:service.example", + "{\"access_token\":\"t\",\"credential_id\":\"c-1\"," + + "\"expires_at\":\"2026-13-45T99:00:00Z\",\"token_type\":\"Bearer\"}")); + } +} diff --git a/aep-agent/src/test/java/foundation/aep/agent/AgentCredentialHandlersTest.java b/aep-agent/src/test/java/foundation/aep/agent/AgentCredentialHandlersTest.java index efdea29..4f72ad7 100644 --- a/aep-agent/src/test/java/foundation/aep/agent/AgentCredentialHandlersTest.java +++ b/aep-agent/src/test/java/foundation/aep/agent/AgentCredentialHandlersTest.java @@ -58,7 +58,7 @@ void inMemoryStoreRejectsCredentialIdentifierReassignment() { () -> store.save(replacement).toCompletableFuture().join()); assertEquals( first, - store.find(SERVICE_DID, Aep.GRANT_TYPE_API_KEY) + store.find(SERVICE_DID, Aep.GRANT_TYPE_API_KEY, Instant.parse("2026-09-01T00:00:00Z")) .toCompletableFuture() .join() .orElseThrow()); diff --git a/aep-agent/src/test/java/foundation/aep/agent/AgentFlowsTest.java b/aep-agent/src/test/java/foundation/aep/agent/AgentFlowsTest.java new file mode 100644 index 0000000..0f0488d --- /dev/null +++ b/aep-agent/src/test/java/foundation/aep/agent/AgentFlowsTest.java @@ -0,0 +1,1214 @@ +package foundation.aep.agent; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import foundation.aep.core.Aep; +import foundation.aep.core.AepHttpTransport; +import foundation.aep.core.AssertionOperation; +import foundation.aep.core.ClaimValues; +import foundation.aep.core.ClientAssertionClaims; +import foundation.aep.core.GrantRequest; +import foundation.aep.core.RevokeRequest; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.CompletionStage; +import org.junit.jupiter.api.Test; + +/** Command flows, credential handling, and Platform delegation failure paths. */ +class AgentFlowsTest { + private static final URI ORIGIN = URI.create("https://service.example"); + private static final URI PLATFORM = URI.create("https://platform.example"); + private static final String SERVICE_DID = "did:web:service.example"; + private static final Instant NOW = Instant.parse("2026-09-01T00:00:00Z"); + + @Test + void discoveryFollowsRedirectsButAuthenticatedCommandsNeverDo() { + for (int status : List.of(301, 302, 303, 307, 308)) { + QueueTransport inspect = new QueueTransport( + response(status, Map.of("Location", List.of("/metadata/aep")), ""), + aepResponse(200, document(List.of("inspect", "status")))); + QueueTransport commands = new QueueTransport( + response(status, Map.of("Location", List.of("https://attacker.example/collect")), "")); + AepServiceSession session = agent(inspect, commands).build().service(ORIGIN); + assertEquals("command_redirect", error(session.status()).code()); + assertEquals(2, inspect.requests.size()); + assertEquals( + ORIGIN.resolve("/metadata/aep"), inspect.requests.get(1).uri()); + assertTrue(inspect.requests.stream() + .noneMatch(request -> request.headers().containsKey("Authorization"))); + assertEquals(1, commands.requests.size()); + assertEquals("service.example", commands.requests.get(0).uri().getHost()); + + QueueTransport platform = new QueueTransport( + response(status, Map.of("Location", List.of("/metadata/platform")), ""), + aepResponse(200, discovery()), + response(status, Map.of("Location", List.of("https://attacker.example/collect")), "")); + PlatformIdentityProvider provider = PlatformIdentityProvider.builder(PLATFORM) + .transport(platform) + .authorization("Bearer owner-token") + .build(); + assertEquals( + "platform_command_redirect", + error(provider.findIdentityByServiceDid("did:web:api.service.example") + .toCompletableFuture()) + .code()); + assertEquals(3, platform.requests.size()); + assertFalse(platform.requests.get(0).headers().containsKey("Authorization")); + assertFalse(platform.requests.get(1).headers().containsKey("Authorization")); + assertEquals( + PLATFORM.resolve("/metadata/platform"), + platform.requests.get(1).uri()); + assertEquals("platform.example", platform.requests.get(2).uri().getHost()); + } + } + + @Test + void customGrantParserFailureDoesNotPersistACredential() { + MemoryStore store = new MemoryStore(); + IllegalArgumentException cause = new IllegalArgumentException("unsupported credential payload"); + AepServiceSession session = agent( + new QueueTransport(aepResponse(200, document(List.of("inspect", "grant")))), + new QueueTransport(aepResponse(200, "{\"credential_id\":\"c-1\"}"))) + .credentialStore(store) + .credentialHandler(handler("custom-session", "custom-grant", (serviceDid, json) -> { + throw cause; + })) + .build() + .service(ORIGIN); + assertSame( + cause, + assertThrows( + CompletionException.class, + () -> session.grant("custom-grant", List.of()).join()) + .getCause()); + assertTrue(store.saved.isEmpty()); + } + + @Test + void customGrantCannotChangeTheGrantTypeBeforePersistence() { + MemoryStore store = new MemoryStore(); + AepServiceSession session = agent( + new QueueTransport(aepResponse(200, document(List.of("inspect", "grant")))), + new QueueTransport(aepResponse(200, "{\"credential_id\":\"c-1\"}"))) + .credentialStore(store) + .credentialHandler(handler( + "custom-session", + "custom-grant", + (serviceDid, json) -> new AgentCredential(serviceDid, "another-grant", "c-1", null, json))) + .build() + .service(ORIGIN); + assertEquals( + "invalid_credential", + error(session.grant("custom-grant", List.of())).code()); + assertTrue(store.saved.isEmpty()); + } + + @Test + void customCredentialsWithoutAnExpiryRemainUsable() { + AgentCredentialStore store = AgentCredentialStore.inMemory(); + store.save(new AgentCredential(SERVICE_DID, "custom-grant", "c-1", null, "{}")) + .toCompletableFuture() + .join(); + AepServiceSession session = agent( + new QueueTransport(aepResponse( + 200, document(List.of("inspect"), "custom-grant", List.of("custom-session")))), + new QueueTransport()) + .credentialStore(store) + .credentialHandler(handler("custom-session", "custom-grant", null)) + .build() + .service(ORIGIN); + AgentAuthentication authentication = + session.authenticate(ORIGIN.resolve("/private")).join(); + assertEquals("custom-session", authentication.method()); + assertEquals(List.of("value"), authentication.headers().get("X-Credential")); + } + + @Test + void failedSigningDoesNotSendTheCommandOrMutateTheSignersFuture() { + for (boolean wrapped : List.of(true, false)) { + IllegalStateException cause = new IllegalStateException("signer unavailable"); + CompletableFuture signing = new CompletableFuture<>(); + QueueTransport commands = new QueueTransport(); + AepServiceSession session = agent( + new QueueTransport(aepResponse(200, document(List.of("inspect", "status")))), commands) + .identityProvider((origin, serviceDid) -> CompletableFuture.completedFuture( + new AgentIdentity("did:web:agent.example", claims -> signing))) + .build() + .service(ORIGIN); + CompletableFuture status = session.status(); + assertFalse(signing.isDone()); + signing.completeExceptionally(wrapped ? new CompletionException(cause) : cause); + assertSame( + cause, assertThrows(CompletionException.class, status::join).getCause()); + assertTrue(commands.requests.isEmpty()); + } + } + + @Test + void defaultAssertionIdentifiersAreFreshForEachCommand() { + List assertions = new ArrayList<>(); + AgentIdentity identity = new AgentIdentity("did:web:agent.example", claims -> { + assertions.add(claims); + return CompletableFuture.completedFuture("signed"); + }); + AepServiceSession session = AepAgent.builder() + .inspectTransport(new QueueTransport(aepResponse(200, document(List.of("inspect", "status"))))) + .commandTransport(new QueueTransport( + aepResponse(200, "{\"status\":\"active\"}"), aepResponse(200, "{\"status\":\"active\"}"))) + .identityProvider((origin, serviceDid) -> CompletableFuture.completedFuture(identity)) + .clock(Clock.fixed(NOW, ZoneOffset.UTC)) + .build() + .service(ORIGIN); + session.status().join(); + session.status().join(); + assertEquals(2, assertions.size()); + assertFalse(assertions.get(0).jwtId().isBlank()); + assertFalse(assertions.get(1).jwtId().isBlank()); + assertFalse(assertions.get(0).jwtId().equals(assertions.get(1).jwtId())); + assertTrue(assertions.stream().allMatch(claims -> claims.operation() == AssertionOperation.STATUS)); + } + + @Test + void concurrentInspectionsShareTransportWithoutSharingCancellation() { + CompletableFuture response = new CompletableFuture<>(); + List requests = new ArrayList<>(); + AepHttpTransport transport = request -> { + requests.add(request); + return response; + }; + AepServiceSession session = + agent(transport, new QueueTransport()).build().service(ORIGIN); + CompletableFuture first = session.inspect(); + CompletableFuture second = session.inspect(); + assertEquals(1, requests.size()); + assertTrue(first.cancel(false)); + assertFalse(response.isCancelled()); + assertFalse(second.isDone()); + response.complete(aepResponse(200, document(List.of("inspect")))); + assertEquals(SERVICE_DID, second.join().document().service().did()); + } + + @Test + void inspectionCanRetryAfterAnAsynchronousTransportFailure() { + CompletableFuture failed = new CompletableFuture<>(); + List requests = new ArrayList<>(); + AepHttpTransport transport = request -> { + requests.add(request); + return requests.size() == 1 + ? failed + : CompletableFuture.completedFuture(aepResponse(200, document(List.of("inspect")))); + }; + AepServiceSession session = + agent(transport, new QueueTransport()).build().service(ORIGIN); + CompletableFuture inspection = session.inspect(); + IllegalStateException failure = new IllegalStateException("connection closed"); + failed.completeExceptionally(new CompletionException(failure)); + assertSame( + failure, + assertThrows(CompletionException.class, inspection::join).getCause()); + assertEquals(SERVICE_DID, session.inspect().join().document().service().did()); + assertEquals(2, requests.size()); + } + + @Test + void platformProvisioningRejectsAnInactiveOrDifferentlyScopedIdentity() { + for (String body : List.of( + identity().replace("did:web:api.service.example", "did:web:other.example"), + identity().replace("\"status\":\"active\"", "\"status\":\"suspended\""))) { + QueueTransport transport = new QueueTransport( + aepResponse(200, discovery()), aepResponse(200, emptyList()), aepResponse(200, body)); + PlatformIdentityProvider provider = PlatformIdentityProvider.builder(PLATFORM) + .transport(transport) + .build(); + assertEquals( + "platform_identity_mismatch", + error(provider.getOrCreate(URI.create("https://api.service.example"), "did:web:api.service.example") + .toCompletableFuture()) + .code()); + assertEquals(3, transport.requests.size()); + assertEquals("POST", transport.requests.get(2).method()); + } + } + + @Test + void grantChecksAdvertisedTypeAndPreflightsStatusWhenAvailable() { + QueueTransport inspect = new QueueTransport(aepResponse(200, document(List.of("inspect", "grant", "status")))); + AepServiceSession advertised = + agent(inspect, new QueueTransport()).build().service(ORIGIN); + assertEquals( + "grant_type_not_advertised", + error(advertised.grant("absent-grant", List.of())).code()); + + QueueTransport withoutStatus = new QueueTransport(aepResponse(200, document(List.of("inspect", "grant")))); + assertEquals( + "{\"credential_id\":\"c-1\"}", + agent(withoutStatus, new QueueTransport(aepResponse(200, "{\"credential_id\":\"c-1\"}"))) + .build() + .service(ORIGIN) + .grant("custom-grant", List.of()) + .join() + .responseJson()); + + QueueTransport pendingInspect = + new QueueTransport(aepResponse(200, document(List.of("inspect", "grant", "status")))); + QueueTransport pendingCommand = new QueueTransport(aepResponse(200, "{\"status\":\"pending\"}")); + assertEquals( + "enrollment_not_active", + error(agent(pendingInspect, pendingCommand) + .build() + .service(ORIGIN) + .grant("custom-grant", List.of())) + .code()); + } + + @Test + void grantStoresAHandledCredentialAndPassesThroughAnUnhandledOne() { + String credentialJson = "{\"api_key\":\"key-value\",\"credential_id\":\"c-1\"," + + "\"expires_at\":\"2026-09-01T01:00:00Z\",\"header\":\"x-api-key\"}"; + QueueTransport inspect = + new QueueTransport(aepResponse(200, document(List.of("inspect", "grant", "status"), "api-key"))); + QueueTransport command = + new QueueTransport(aepResponse(200, "{\"status\":\"active\"}"), aepResponse(200, credentialJson)); + MemoryStore store = new MemoryStore(); + AgentGrantResult handled = agent(inspect, command) + .credentialStore(store) + .build() + .service(ORIGIN) + .grant("api-key", List.of()) + .join(); + + assertEquals("api-key", handled.grantType()); + assertEquals("c-1", handled.credential().orElseThrow().credentialId()); + assertEquals(Optional.of("c-1"), store.saved.map(AgentCredential::credentialId)); + + // A Grant Type with no registered handler still returns its raw object. + QueueTransport rawInspect = + new QueueTransport(aepResponse(200, document(List.of("inspect", "grant", "status")))); + QueueTransport rawCommand = new QueueTransport( + aepResponse(200, "{\"status\":\"active\"}"), aepResponse(200, "{\"credential_id\":\"c-2\"}")); + AgentGrantResult raw = agent(rawInspect, rawCommand) + .build() + .service(ORIGIN) + .grant("custom-grant", List.of()) + .join(); + assertTrue(raw.credential().isEmpty()); + assertEquals("{\"credential_id\":\"c-2\"}", raw.responseJson()); + } + + @Test + void grantRefusesACredentialHandlerThatChangesTheCredentialScope() { + QueueTransport inspect = new QueueTransport(aepResponse(200, document(List.of("inspect", "grant", "status")))); + QueueTransport command = new QueueTransport( + aepResponse(200, "{\"status\":\"active\"}"), aepResponse(200, "{\"credential_id\":\"c-1\"}")); + AgentCredentialHandler rogue = handler( + "custom-session", + "custom-grant", + (serviceDid, json) -> + new AgentCredential("did:web:attacker.example", "custom-grant", "c-1", NOW, json)); + + assertEquals( + "invalid_credential", + error(agent(inspect, command) + .credentialHandler(rogue) + .build() + .service(ORIGIN) + .grant("custom-grant", List.of())) + .code()); + } + + @Test + void revokeDeletesTheMatchingStoredCredentials() { + for (RevokeRequest request : List.of( + new RevokeRequest("custom-grant", null, null), + new RevokeRequest("custom-grant", "c-1", null), + new RevokeRequest(null, null, "true"))) { + QueueTransport inspect = new QueueTransport(aepResponse(200, document(List.of("inspect", "revoke")))); + QueueTransport command = new QueueTransport(aepResponse(200, "{}")); + MemoryStore store = new MemoryStore(); + store.saved = Optional.of(new AgentCredential(SERVICE_DID, "custom-grant", "c-1", NOW, "{}")); + + agent(inspect, command) + .credentialStore(store) + .build() + .service(ORIGIN) + .revoke(request) + .join(); + + assertTrue(store.saved.isEmpty(), request.toString()); + } + } + + @Test + void authenticateFallsThroughToTheNextUsableMethod() { + String credentialJson = "{\"api_key\":\"key-value\",\"credential_id\":\"c-1\"," + + "\"expires_at\":\"2026-09-01T01:00:00Z\",\"header\":\"x-api-key\"}"; + QueueTransport inspect = new QueueTransport(aepResponse( + 200, document(List.of("inspect"), "api-key", List.of("custom-session", "oauth-bearer", "api-key")))); + MemoryStore store = new MemoryStore(); + store.saved = + Optional.of(new AgentCredential(SERVICE_DID, "api-key", "c-1", NOW.plusSeconds(3_600), credentialJson)); + + AgentAuthentication authentication = agent(inspect, new QueueTransport()) + .credentialStore(store) + .build() + .service(ORIGIN) + .authenticate(URI.create("https://service.example/private")) + .join(); + + assertEquals("api-key", authentication.method()); + assertEquals(List.of("key-value"), authentication.headers().get("x-api-key")); + } + + @Test + void authenticateSkipsAnExpiredCredential() { + String credentialJson = "{\"api_key\":\"key-value\",\"credential_id\":\"c-1\"," + + "\"expires_at\":\"2026-08-01T00:00:00Z\",\"header\":\"x-api-key\"}"; + QueueTransport inspect = + new QueueTransport(aepResponse(200, document(List.of("inspect"), "api-key", List.of("api-key")))); + MemoryStore store = new MemoryStore(); + store.saved = Optional.of( + new AgentCredential(SERVICE_DID, "api-key", "c-1", NOW.minusSeconds(3_600), credentialJson)); + + assertEquals( + "authentication_unavailable", + error(agent(inspect, new QueueTransport()) + .credentialStore(store) + .build() + .service(ORIGIN) + .authenticate(URI.create("https://service.example/private"))) + .code()); + } + + @Test + void authenticateRefusesAStoredCredentialFromAnotherService() { + QueueTransport inspect = new QueueTransport( + aepResponse(200, document(List.of("inspect"), "custom-grant", List.of("custom-session")))); + MemoryStore store = new MemoryStore(); + store.saved = Optional.of( + new AgentCredential("did:web:attacker.example", "custom-grant", "c-1", NOW.plusSeconds(60), "{}")); + + assertEquals( + "invalid_credential", + error(agent(inspect, new QueueTransport()) + .credentialStore(store) + .credentialHandler(handler("custom-session", "custom-grant", null)) + .build() + .service(ORIGIN) + .authenticate(URI.create("https://service.example/private"))) + .code()); + } + + @Test + void waitForActiveResolvesRejectedAndTimedOutEnrollments() { + for (String status : List.of("rejected", "suspended", "terminated")) { + QueueTransport inspect = new QueueTransport(aepResponse(200, document(List.of("inspect", "status")))); + QueueTransport command = new QueueTransport(aepResponse(200, "{\"status\":\"" + status + "\"}")); + assertEquals( + "enrollment_not_active", + error(agent(inspect, command) + .build() + .service(ORIGIN) + .waitForActive(Duration.ofMillis(10), Duration.ofSeconds(5))) + .code(), + status); + } + + QueueTransport activeInspect = new QueueTransport(aepResponse(200, document(List.of("inspect", "status")))); + QueueTransport activeCommand = new QueueTransport(aepResponse(200, "{\"status\":\"active\"}")); + assertEquals( + foundation.aep.core.AgentStatus.ACTIVE, + agent(activeInspect, activeCommand) + .build() + .service(ORIGIN) + .waitForActive(Duration.ofMillis(10), Duration.ofSeconds(5)) + .join() + .status()); + + // A pending status with no remaining budget stops rather than polling forever. + QueueTransport pendingInspect = new QueueTransport(aepResponse(200, document(List.of("inspect", "status")))); + QueueTransport pendingCommand = new QueueTransport(aepResponse(200, "{\"status\":\"pending\"}")); + assertEquals( + "poll_timeout", + error(agent(pendingInspect, pendingCommand) + .build() + .service(ORIGIN) + .waitForActive(Duration.ofSeconds(30), Duration.ofSeconds(1))) + .code()); + } + + @Test + void waitForActiveSurfacesACommandFailure() { + QueueTransport inspect = new QueueTransport(aepResponse(200, document(List.of("inspect", "status")))); + QueueTransport command = + new QueueTransport(response(500, Map.of("Content-Type", List.of("text/plain")), "boom")); + assertEquals( + "command_failed", + error(agent(inspect, command) + .build() + .service(ORIGIN) + .waitForActive(Duration.ofMillis(10), Duration.ofSeconds(5))) + .code()); + } + + @Test + void inspectReportsFailureStatusesAndRedirectLimits() { + QueueTransport oversizedHost = new QueueTransport(redirect("https://" + "a".repeat(64) + ".example/aep")); + assertEquals( + "cross_origin_redirect", + error(agent(oversizedHost, new QueueTransport()) + .build() + .service(ORIGIN) + .inspect()) + .code()); + assertEquals(1, oversizedHost.requests.size()); + QueueTransport malformed = new QueueTransport(redirect("https://[")); + assertEquals( + "invalid_redirect", + error(agent(malformed, new QueueTransport()) + .build() + .service(ORIGIN) + .inspect()) + .code()); + assertEquals(1, malformed.requests.size()); + QueueTransport failing = new QueueTransport(aepResponse(503, "")); + assertEquals( + "inspect_failed", + error(agent(failing, new QueueTransport()) + .build() + .service(ORIGIN) + .inspect()) + .code()); + + QueueTransport looping = + new QueueTransport(redirect("/one"), redirect("/two"), redirect("/three"), redirect("/four")); + assertEquals( + "too_many_redirects", + error(agent(looping, new QueueTransport()) + .maximumRedirects(2) + .build() + .service(ORIGIN) + .inspect()) + .code()); + } + + @Test + void inspectRefusesADocumentThatIsNotValid() { + QueueTransport invalid = new QueueTransport(aepResponse(200, "{\"aep_version\":\"1.0\"}")); + AepAgentException error = error( + agent(invalid, new QueueTransport()).build().service(ORIGIN).inspect()); + assertEquals("document_invalid", error.code()); + assertTrue(error.getMessage().contains("$."), error.getMessage()); + } + + @Test + void inspectAcceptsALoopbackServiceUnderTheOptIn() { + String document = document(List.of("inspect")).replace(SERVICE_DID, "did:web:localhost%3A8080"); + QueueTransport inspect = new QueueTransport(aepResponse(200, document)); + AgentInspection inspection = agent(inspect, new QueueTransport()) + .allowInsecureLoopback(true) + .build() + .service(URI.create("http://localhost:8080")) + .inspect() + .join(); + assertEquals("did:web:localhost%3A8080", inspection.document().service().did()); + + QueueTransport mismatch = + new QueueTransport(aepResponse(200, document(List.of("inspect")).replace(SERVICE_DID, "did:key:z6Mk"))); + assertEquals( + "service_identity_mismatch", + error(agent(mismatch, new QueueTransport()) + .allowInsecureLoopback(true) + .build() + .service(URI.create("http://localhost:8080")) + .inspect()) + .code()); + } + + @Test + void refusesAnIdempotencyKeyProviderThatReturnsNothing() { + QueueTransport inspect = new QueueTransport(aepResponse(200, document(List.of("inspect", "enroll")))); + assertEquals( + "invalid_configuration", + error(agent(inspect, new QueueTransport()) + .idempotencyKeyProvider((did, command, discriminator) -> null) + .build() + .service(ORIGIN) + .enroll(ClaimValues.builder().build())) + .code()); + } + + @Test + void refusesAJwtIdSupplierThatReturnsNothing() { + QueueTransport inspect = new QueueTransport(aepResponse(200, document(List.of("inspect", "status")))); + assertEquals( + "invalid_configuration", + error(agent(inspect, new QueueTransport()) + .jwtIdSupplier(() -> " ") + .build() + .service(ORIGIN) + .status()) + .code()); + } + + @Test + void rejectsInvalidCommandRequestsBeforeAnyNetworkCall() { + AepServiceSession session = + agent(new QueueTransport(), new QueueTransport()).build().service(ORIGIN); + assertThrows( + foundation.aep.core.AepValidationException.class, + () -> session.grant(new GrantRequest("", null, null, null))); + assertThrows( + foundation.aep.core.AepValidationException.class, + () -> session.revoke(new RevokeRequest(null, "c-1", null))); + } + + @Test + void builtInHandlersRejectMismatchedStoredCredentials() { + AgentCredentialHandler apiKey = AgentCredentialHandlers.apiKey(); + assertThrows( + IllegalArgumentException.class, + () -> apiKey.parse( + SERVICE_DID, + "{\"api_key\":\"k\",\"credential_id\":\"c-1\"," + + "\"expires_at\":\"not-a-date\",\"header\":\"x-api-key\"}")); + String json = "{\"api_key\":\"k\",\"credential_id\":\"c-1\",\"expires_at\":\"2026-09-01T01:00:00Z\"," + + "\"header\":\"x-api-key\"}"; + AgentCredential wrongGrantType = new AgentCredential(SERVICE_DID, "oauth-bearer", "c-1", NOW, json); + assertThrows(IllegalArgumentException.class, () -> apiKey.authorizationHeaders(wrongGrantType, null)); + AgentCredential wrongId = + new AgentCredential(SERVICE_DID, "api-key", "c-9", Instant.parse("2026-09-01T01:00:00Z"), json); + assertThrows(IllegalArgumentException.class, () -> apiKey.authorizationHeaders(wrongId, null)); + AgentCredential wrongExpiry = new AgentCredential(SERVICE_DID, "api-key", "c-1", NOW, json); + assertThrows(IllegalArgumentException.class, () -> apiKey.authorizationHeaders(wrongExpiry, null)); + + AgentCredential valid = + new AgentCredential(SERVICE_DID, "api-key", "c-1", Instant.parse("2026-09-01T01:00:00Z"), json); + assertEquals(Map.of("x-api-key", "k"), apiKey.authorizationHeaders(valid, null)); + assertEquals("api-key", apiKey.authenticationMethod()); + assertEquals(3, AgentCredentialHandlers.builtIn().size()); + + String bearerJson = "{\"access_token\":\"t\",\"credential_id\":\"c-1\"," + + "\"expires_at\":\"2026-09-01T01:00:00Z\",\"token_type\":\"Bearer\"}"; + AgentCredential bearer = new AgentCredential( + SERVICE_DID, "oauth-bearer", "c-1", Instant.parse("2026-09-01T01:00:00Z"), bearerJson); + assertEquals( + Map.of("Authorization", "Bearer t"), + AgentCredentialHandlers.oauthBearer().authorizationHeaders(bearer, null)); + + String basicJson = "{\"credential_id\":\"c-1\",\"expires_at\":\"2026-09-01T01:00:00Z\"," + + "\"password\":\"pw\",\"username\":\"user\"}"; + AgentCredential basic = + new AgentCredential(SERVICE_DID, "basic", "c-1", Instant.parse("2026-09-01T01:00:00Z"), basicJson); + assertEquals( + Map.of("Authorization", "Basic dXNlcjpwdw=="), + AgentCredentialHandlers.basic().authorizationHeaders(basic, null)); + } + + @Test + void platformProviderRefusesSigningForAnotherIdentity() { + QueueTransport transport = new QueueTransport(aepResponse(200, discovery()), aepResponse(200, activeList())); + PlatformIdentityProvider provider = PlatformIdentityProvider.builder(PLATFORM) + .transport(transport) + .clock(Clock.fixed(NOW, ZoneOffset.UTC)) + .build(); + AgentIdentity identity = provider.getOrCreate( + URI.create("https://api.service.example"), "did:web:api.service.example") + .toCompletableFuture() + .join(); + + ClientAssertionClaims foreign = new ClientAssertionClaims( + "did:web:other.example", + "did:web:other.example", + "did:web:api.service.example", + AssertionOperation.ENROLL, + 1_783_425_600L, + 1_783_425_900L, + "jti-1", + null); + assertEquals( + "platform_identity_mismatch", + error(identity.signer().sign(foreign).toCompletableFuture()).code()); + } + + @Test + void platformProviderRefusesAnUnboundServiceDidAndANonDid() { + PlatformIdentityProvider provider = PlatformIdentityProvider.builder(PLATFORM) + .transport(request -> CompletableFuture.failedFuture(new AssertionError("unused"))) + .build(); + assertEquals( + "service_identity_mismatch", + error(provider.getOrCreate(URI.create("https://api.service.example"), "did:web:other.example") + .toCompletableFuture()) + .code()); + assertThrows( + IllegalArgumentException.class, + () -> provider.getOrCreate(URI.create("https://api.service.example"), "not-a-did")); + assertThrows(IllegalArgumentException.class, () -> provider.findIdentityByServiceDid("did:")); + } + + @Test + void platformProviderValidatesItsConfiguration() { + assertThrows(NullPointerException.class, () -> PlatformIdentityProvider.builder(null)); + PlatformIdentityProvider.Builder builder = PlatformIdentityProvider.builder(PLATFORM); + assertThrows(NullPointerException.class, () -> builder.transport(null)); + assertThrows(NullPointerException.class, () -> builder.authenticationHeaders(null)); + assertThrows(NullPointerException.class, () -> builder.idempotencyKeys(null)); + assertThrows(NullPointerException.class, () -> builder.platformContext(null)); + assertThrows(NullPointerException.class, () -> builder.pendingSignResolver(null)); + assertThrows(NullPointerException.class, () -> builder.clock(null)); + assertThrows(NullPointerException.class, PlatformIdentityProvider.builder(PLATFORM)::build); + for (String value : List.of("", " ", "Bearer a\rb", "Bearer a\nb")) { + assertThrows(IllegalArgumentException.class, () -> builder.authorization(value), value); + } + for (Duration invalid : List.of(Duration.ZERO, Duration.ofSeconds(-1))) { + assertThrows(IllegalArgumentException.class, () -> builder.defaultDiscoveryFreshness(invalid)); + assertThrows(IllegalArgumentException.class, () -> builder.requestTimeout(invalid)); + } + assertThrows(NullPointerException.class, () -> builder.requestTimeout(null)); + assertThrows(IllegalArgumentException.class, () -> builder.maximumRedirects(-1)); + assertThrows(IllegalArgumentException.class, () -> builder.maximumResponseBytes(0)); + builder.maximumRedirects(1) + .maximumResponseBytes(2048) + .defaultDiscoveryFreshness(Duration.ofSeconds(30)) + .requestTimeout(Duration.ofSeconds(5)) + .allowInsecureLoopback(true) + .authorization("Bearer token") + .transport(request -> CompletableFuture.failedFuture(new AssertionError("unused"))) + .build(); + + for (URI origin : List.of( + URI.create("http://platform.example"), + URI.create("https://user:pw@platform.example"), + URI.create("https://platform.example/path"), + URI.create("/relative"))) { + assertThrows( + IllegalArgumentException.class, + () -> PlatformIdentityProvider.builder(origin) + .transport(request -> CompletableFuture.failedFuture(new AssertionError("unused"))) + .build(), + origin.toString()); + } + } + + @Test + void platformProviderRefusesUnusableSuppliedHeaders() { + for (Map> headers : List.of( + Map.of("X-Bad\r\nName", List.of("value")), + Map.of(" ", List.of("value")), + Map.>of("X-Tenant", List.of()), + Map.of("X-Tenant", List.of("bad\rvalue")))) { + QueueTransport transport = new QueueTransport(aepResponse(200, discovery())); + PlatformIdentityProvider provider = PlatformIdentityProvider.builder(PLATFORM) + .transport(transport) + .authenticationHeaders(() -> CompletableFuture.completedFuture(headers)) + .build(); + assertThrows( + CompletionException.class, + () -> provider.findIdentityByServiceDid("did:web:api.service.example") + .toCompletableFuture() + .join(), + headers.toString()); + } + } + + @Test + void platformProviderRefusesIdempotencyKeysThatCannotTravel() { + for (String key : List.of(" key", "key ", "key\r\nX: 1", "")) { + QueueTransport transport = new QueueTransport(aepResponse(200, discovery()), aepResponse(200, emptyList())); + PlatformIdentityProvider provider = PlatformIdentityProvider.builder(PLATFORM) + .transport(transport) + .idempotencyKeys(() -> key) + .build(); + assertEquals( + "platform_idempotency_key_invalid", + error(provider.getOrCreate(URI.create("https://api.service.example"), "did:web:api.service.example") + .toCompletableFuture()) + .code(), + key); + } + } + + @Test + void platformProviderRefusesAnInvalidHostedIdentity() { + String fragmentedKeyId = identity() + .replace( + "\"key_id\":\"did:web:p.example:a:4Yf7p2xQd9\"", + "\"key_id\":\"did:web:p.example:a:4Yf7p2xQd9#key-1\""); + String wrongDocumentUrl = identity() + .replace("https://p.example/a/4Yf7p2xQd9/did.json", "https://elsewhere.example/a/4Yf7p2xQd9/did.json"); + String unadvertisedAlgorithm = identity().replace("[\"ES256\"]", "[\"EdDSA\"]"); + for (String body : List.of(fragmentedKeyId, wrongDocumentUrl, unadvertisedAlgorithm)) { + QueueTransport transport = new QueueTransport( + aepResponse(200, discovery()), + aepResponse(200, "{\"count\":\"1\",\"data\":[" + body + "],\"total\":\"1\"}")); + PlatformIdentityProvider provider = PlatformIdentityProvider.builder(PLATFORM) + .transport(transport) + .build(); + assertEquals( + "platform_identity_invalid", + error(provider.findIdentityByServiceDid("did:web:api.service.example") + .toCompletableFuture()) + .code(), + body); + } + + // A list whose totals disagree is not usable either. + QueueTransport totals = new QueueTransport( + aepResponse(200, discovery()), + aepResponse(200, "{\"count\":\"1\",\"data\":[" + identity() + "],\"total\":\"0\"}")); + assertThrows( + CompletionException.class, + () -> PlatformIdentityProvider.builder(PLATFORM) + .transport(totals) + .build() + .findIdentityByServiceDid("did:web:api.service.example") + .toCompletableFuture() + .join()); + } + + @Test + void platformProviderRefusesUnsafeDiscoveryRedirectsAndEndpoints() { + for (String location : List.of( + "https://[", + "https://attacker.example/.well-known/aep-platform", + "http://platform.example/.well-known/aep-platform", + "https://user:pw@platform.example/x", + "https://platform.example/x#fragment")) { + QueueTransport transport = new QueueTransport(redirect(location)); + assertEquals( + "platform_discovery_redirect", + error(PlatformIdentityProvider.builder(PLATFORM) + .transport(transport) + .build() + .findIdentityByServiceDid("did:web:api.service.example") + .toCompletableFuture()) + .code(), + location); + } + QueueTransport noLocation = new QueueTransport(response(302, Map.of(), "")); + assertEquals( + "platform_discovery_redirect", + error(PlatformIdentityProvider.builder(PLATFORM) + .transport(noLocation) + .build() + .findIdentityByServiceDid("did:web:api.service.example") + .toCompletableFuture()) + .code()); + + QueueTransport limit = new QueueTransport(redirect("/one"), redirect("/two")); + assertEquals( + "platform_discovery_redirect", + error(PlatformIdentityProvider.builder(PLATFORM) + .transport(limit) + .maximumRedirects(1) + .build() + .findIdentityByServiceDid("did:web:api.service.example") + .toCompletableFuture()) + .code()); + + // An advertised endpoint that is not an origin-relative path is refused. The shared document + // validator rejects these first, so the provider's own endpoint check is the second line. + for (String list : List.of("\"relative\"", "\"//attacker.example/x\"", "\"/x?y=1\"", "\"/x#y\"")) { + QueueTransport transport = new QueueTransport( + aepResponse(200, discovery().replace("\"/v1/aep/agent-identities\",", list + ","))); + CompletionException failure = assertThrows( + CompletionException.class, + () -> PlatformIdentityProvider.builder(PLATFORM) + .transport(transport) + .build() + .findIdentityByServiceDid("did:web:api.service.example") + .toCompletableFuture() + .join(), + list); + assertTrue( + failure.getCause() instanceof foundation.aep.core.AepValidationException + || failure.getCause() instanceof AepAgentException, + list + " produced " + failure.getCause()); + } + } + + @Test + void platformProviderReportsDiscoveryAndCommandFailures() { + QueueTransport failing = new QueueTransport(aepResponse(503, "")); + assertEquals( + "platform_discovery_failed", + error(PlatformIdentityProvider.builder(PLATFORM) + .transport(failing) + .build() + .findIdentityByServiceDid("did:web:api.service.example") + .toCompletableFuture()) + .code()); + + QueueTransport notModified = new QueueTransport(response(304, Map.of(), "")); + assertEquals( + "platform_discovery_invalid", + error(PlatformIdentityProvider.builder(PLATFORM) + .transport(notModified) + .build() + .findIdentityByServiceDid("did:web:api.service.example") + .toCompletableFuture()) + .code()); + + QueueTransport redirected = new QueueTransport(aepResponse(200, discovery()), redirect("/elsewhere")); + assertEquals( + "platform_command_redirect", + error(PlatformIdentityProvider.builder(PLATFORM) + .transport(redirected) + .build() + .findIdentityByServiceDid("did:web:api.service.example") + .toCompletableFuture()) + .code()); + + QueueTransport problem = new QueueTransport( + aepResponse(200, discovery()), + response( + 403, + Map.of("Content-Type", List.of(Aep.PROBLEM_MEDIA_TYPE)), + "{\"type\":\"urn:aep:error:not_recognized\",\"title\":\"Not recognized\"," + + "\"status\":403,\"code\":\"not_recognized\"}")); + assertEquals( + "not_recognized", + error(PlatformIdentityProvider.builder(PLATFORM) + .transport(problem) + .build() + .findIdentityByServiceDid("did:web:api.service.example") + .toCompletableFuture()) + .code()); + } + + @Test + void activeEnrollmentSkipsStatusButExplicitPendingStatusClearsIt() { + QueueTransport inspect = + new QueueTransport(aepResponse(200, document(List.of("inspect", "enroll", "grant", "status")))); + QueueTransport commands = new QueueTransport( + aepResponse(200, "{\"status\":\"active\"}"), + aepResponse(200, "{\"credential_id\":\"c-1\"}"), + aepResponse(200, "{\"status\":\"pending\"}"), + aepResponse(200, "{\"status\":\"pending\"}")); + AepServiceSession session = agent(inspect, commands) + .inspectCache(AgentInspectCache.inMemory()) + .build() + .service(ORIGIN); + session.enroll(ClaimValues.builder().build()).join(); + session.grant("custom-grant", List.of()).join(); + assertEquals("/aep/grant", commands.requests.get(1).uri().getPath()); + session.status().join(); + assertEquals( + "enrollment_not_active", + error(session.grant("custom-grant", List.of())).code()); + assertEquals(4, commands.requests.size()); + } + + @Test + void customGrantRequiresAnIdentifierAndPreservesExtensionFields() { + for (String json : + List.of("{}", "{\"credential_id\":null}", "{\"credential_id\":1}", "{\"credential_id\":\"\"}")) { + QueueTransport inspect = new QueueTransport(aepResponse(200, document(List.of("inspect", "grant")))); + assertEquals( + "invalid_credential", + error(agent(inspect, new QueueTransport(aepResponse(200, json))) + .build() + .service(ORIGIN) + .grant("custom-grant", List.of())) + .code()); + } + String json = "{\"credential_id\":\"c-1\",\"custom_material\":{\"token\":\"value\"}}"; + QueueTransport inspect = new QueueTransport(aepResponse(200, document(List.of("inspect", "grant")))); + assertEquals( + json, + agent(inspect, new QueueTransport(aepResponse(200, json))) + .build() + .service(ORIGIN) + .grant("custom-grant", List.of()) + .join() + .responseJson()); + } + + @Test + void handlerCannotReplaceTheServiceIssuedIdentifier() { + QueueTransport inspect = new QueueTransport(aepResponse(200, document(List.of("inspect", "grant")))); + AgentCredentialHandler changed = handler( + "custom-session", + "custom-grant", + (serviceDid, json) -> new AgentCredential(serviceDid, "custom-grant", "different", null, json)); + assertEquals( + "invalid_credential", + error(agent(inspect, new QueueTransport(aepResponse(200, "{\"credential_id\":\"c-1\"}"))) + .credentialHandler(changed) + .build() + .service(ORIGIN) + .grant("custom-grant", List.of())) + .code()); + } + + @Test + void pollingDeadlineCompletesWhileStatusTransportIsStillPending() { + QueueTransport inspect = new QueueTransport(aepResponse(200, document(List.of("inspect", "status")))); + CompletableFuture response = new CompletableFuture<>(); + java.util.concurrent.atomic.AtomicInteger calls = new java.util.concurrent.atomic.AtomicInteger(); + AepServiceSession session = agent(inspect, request -> { + calls.incrementAndGet(); + return response; + }) + .build() + .service(ORIGIN); + CompletableFuture waiting = session.waitForActive(Duration.ofMillis(10), Duration.ofMillis(100)); + assertEquals( + "poll_timeout", + error(waiting.orTimeout(3, java.util.concurrent.TimeUnit.SECONDS)) + .code()); + response.complete(aepResponse(200, "{\"status\":\"active\"}")); + assertEquals("poll_timeout", error(waiting).code()); + assertEquals(1, calls.get()); + } + + @Test + void cancellingPollingIgnoresALatePendingResponse() { + QueueTransport inspect = new QueueTransport(aepResponse(200, document(List.of("inspect", "status")))); + CompletableFuture response = new CompletableFuture<>(); + AepServiceSession session = agent(inspect, request -> response).build().service(ORIGIN); + CompletableFuture waiting = session.waitForActive(Duration.ofMillis(10), Duration.ofSeconds(5)); + assertTrue(waiting.cancel(false)); + response.complete(aepResponse(200, "{\"status\":\"pending\"}")); + assertTrue(waiting.isCancelled()); + } + + @Test + void authenticationUsesTheAgentClockToSelectAnUnexpiredCredential() { + AgentCredentialStore store = AgentCredentialStore.inMemory(); + store.save(new AgentCredential(SERVICE_DID, "custom-grant", "live", NOW.plusSeconds(60), "{}")) + .toCompletableFuture() + .join(); + store.save(new AgentCredential(SERVICE_DID, "custom-grant", "expired", NOW.minusSeconds(1), "{}")) + .toCompletableFuture() + .join(); + store.save(new AgentCredential(SERVICE_DID, "custom-grant", "at-expiry", NOW, "{}")) + .toCompletableFuture() + .join(); + QueueTransport inspect = new QueueTransport( + aepResponse(200, document(List.of("inspect"), "custom-grant", List.of("custom-session")))); + AgentCredentialHandler selected = new AgentCredentialHandler() { + public String authenticationMethod() { + return "custom-session"; + } + + public String grantType() { + return "custom-grant"; + } + + public AgentCredential parse(String serviceDid, String json) { + throw new AssertionError("No Grant request"); + } + + public Map authorizationHeaders(AgentCredential credential, URI resource) { + return Map.of("X-Credential", credential.credentialId()); + } + }; + AgentAuthentication authentication = agent(inspect, new QueueTransport()) + .credentialStore(store) + .credentialHandler(selected) + .build() + .service(ORIGIN) + .authenticate(ORIGIN.resolve("/private")) + .join(); + assertEquals(List.of("live"), authentication.headers().get("X-Credential")); + assertTrue(store.find(SERVICE_DID, "custom-grant", NOW.plusSeconds(60)) + .toCompletableFuture() + .join() + .isEmpty()); + } + + private static AepAgent.Builder agent(AepHttpTransport inspect, AepHttpTransport command) { + AgentIdentity identity = + new AgentIdentity("did:web:agent.example", claims -> CompletableFuture.completedFuture("signed")); + return AepAgent.builder() + .inspectTransport(inspect) + .commandTransport(command) + .identityProvider((origin, serviceDid) -> CompletableFuture.completedFuture(identity)) + .inspectCache(AgentInspectCache.none()) + .clock(Clock.fixed(NOW, ZoneOffset.UTC)) + .jwtIdSupplier(() -> "jwt-id"); + } + + private static String document(List commands) { + return document(commands, "custom-grant", List.of()); + } + + private static String document(List commands, String grantType) { + return document(commands, grantType, List.of()); + } + + private static String document(List commands, String grantType, List methods) { + boolean grants = commands.contains("grant") || commands.contains("revoke"); + String grantTypes = grants ? "[\"" + grantType + "\"]" : "[]"; + String config = grants ? "{\"" + grantType + "\":{\"supports_per_credential_revoke\":\"true\"}}" : "{}"; + String authentication = methods.isEmpty() + ? "" + : "\"authentication\":{\"methods\":[" + + methods.stream() + .map(value -> "\"" + value + "\"") + .collect(java.util.stream.Collectors.joining(",")) + + "]},"; + return """ + { + "aep_version":"1.0", + %s + "bindings":{"supported":["http"]}, + "commands":{"supported":%s,"grant_types":%s,"grant_types_config":%s}, + "core":{"signing_algorithms":["EdDSA","ES256"]}, + "http":{"endpoint_base":"/aep/"}, + "identity":{"methods":["did:web"]}, + "service":{"did":"did:web:service.example"} + } + """.formatted( + authentication, + commands.stream() + .map(value -> "\"" + value + "\"") + .collect(java.util.stream.Collectors.joining(",", "[", "]")), + grantTypes, + config); + } + + private static String discovery() { + return "{\"aep_version\":\"1.0\",\"endpoints\":{\"lifecycle\":\"/v1/aep/agent-identities/{agent_identity_id}\"," + + "\"list\":\"/v1/aep/agent-identities\",\"provision\":\"/v1/aep/agent-identities\"," + + "\"sign\":\"/v1/aep/agent-identities/{agent_identity_id}/sign\"}," + + "\"http\":{\"endpoint_base\":\"/v1/aep\"}," + + "\"identity\":{\"did_methods\":[\"did:web\"]," + + "\"did_url_template\":\"https://p.example/a/{agent_did_id}/did.json\"}," + + "\"platform\":{\"did\":\"did:web:p.example\",\"hosted_verification\":false,\"name\":\"Example\"}," + + "\"signing\":{\"algorithms\":[\"ES256\"],\"default_lifetime_seconds\":\"300\"}}"; + } + + private static String identity() { + return "{\"agent_did\":\"did:web:p.example:a:4Yf7p2xQd9\"," + + "\"agent_identity_id\":\"pai_1\",\"created_at\":\"2026-07-06T12:00:00Z\"," + + "\"did_document_url\":\"https://p.example/a/4Yf7p2xQd9/did.json\"," + + "\"key_id\":\"did:web:p.example:a:4Yf7p2xQd9\"," + + "\"service_did\":\"did:web:api.service.example\",\"signing_algorithms\":[\"ES256\"]," + + "\"status\":\"active\",\"updated_at\":\"2026-07-06T12:00:00Z\"}"; + } + + private static String activeList() { + return "{\"count\":\"1\",\"data\":[" + identity() + "],\"total\":\"1\"}"; + } + + private static String emptyList() { + return "{\"count\":\"0\",\"data\":[],\"total\":\"0\"}"; + } + + private static AepHttpTransport.Response redirect(String location) { + return response(302, Map.of("Location", List.of(location)), ""); + } + + private static AepHttpTransport.Response aepResponse(int status, String body) { + return response(status, Map.of("Content-Type", List.of(Aep.MEDIA_TYPE)), body); + } + + private static AepHttpTransport.Response response(int status, Map> headers, String body) { + return new AepHttpTransport.Response(status, headers, body.getBytes(StandardCharsets.UTF_8)); + } + + private static AepAgentException error(CompletableFuture future) { + CompletionException failure = assertThrows(CompletionException.class, future::join); + return (AepAgentException) failure.getCause(); + } + + private interface Parser { + AgentCredential parse(String serviceDid, String json); + } + + private static AgentCredentialHandler handler(String method, String grantType, Parser parser) { + return new AgentCredentialHandler() { + @Override + public String authenticationMethod() { + return method; + } + + @Override + public String grantType() { + return grantType; + } + + @Override + public AgentCredential parse(String serviceDid, String responseJson) { + return parser == null + ? new AgentCredential(serviceDid, grantType, "c-1", NOW.plusSeconds(60), responseJson) + : parser.parse(serviceDid, responseJson); + } + + @Override + public Map authorizationHeaders(AgentCredential credential, URI resource) { + return Map.of("X-Credential", "value"); + } + }; + } + + private static final class QueueTransport implements AepHttpTransport { + private final Deque responses = new ArrayDeque<>(); + private final List requests = new ArrayList<>(); + + QueueTransport(Response... values) { + responses.addAll(List.of(values)); + } + + @Override + public CompletableFuture execute(Request request) { + requests.add(request); + if (responses.isEmpty()) { + return CompletableFuture.failedFuture(new AssertionError("Unexpected request: " + request)); + } + return CompletableFuture.completedFuture(responses.removeFirst()); + } + } + + private static final class MemoryStore implements AgentCredentialStore { + private Optional saved = Optional.empty(); + + @Override + public CompletionStage> find(String serviceDid, String grantType, Instant now) { + return CompletableFuture.completedFuture( + saved.filter(value -> value.grantType().equals(grantType))); + } + + @Override + public CompletionStage save(AgentCredential credential) { + saved = Optional.of(credential); + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletionStage delete(String serviceDid, String credentialId) { + saved = Optional.empty(); + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletionStage deleteGrantType(String serviceDid, String grantType) { + saved = Optional.empty(); + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletionStage deleteAll(String serviceDid) { + saved = Optional.empty(); + return CompletableFuture.completedFuture(null); + } + } +} diff --git a/aep-agent/src/test/java/foundation/aep/agent/PlatformIdentityProviderTest.java b/aep-agent/src/test/java/foundation/aep/agent/PlatformIdentityProviderTest.java index ae28408..9fbb9c0 100644 --- a/aep-agent/src/test/java/foundation/aep/agent/PlatformIdentityProviderTest.java +++ b/aep-agent/src/test/java/foundation/aep/agent/PlatformIdentityProviderTest.java @@ -176,6 +176,36 @@ void revalidatesExpiredDiscoveryAtItsFinalRedirectTarget() { assertEquals(List.of("discovery-1"), transport.requests.get(3).headers().get("If-None-Match")); } + @Test + void discoveryPreservesNoCacheOnNotModifiedResponses() { + QueueTransport transport = new QueueTransport( + aepResponse(200, Map.of("Cache-Control", List.of("no-cache"), "ETag", List.of("v1")), discovery()), + aepResponse(200, identityList()), + response(304, Map.of(), ""), + aepResponse(200, identityList()), + response(304, Map.of(), ""), + aepResponse(200, identityList())); + PlatformIdentityProvider provider = provider(transport); + for (int call = 0; call < 3; call++) { + provider.findIdentityByServiceDid(SERVICE_DID).toCompletableFuture().join(); + } + assertEquals(6, transport.requests.size()); + assertEquals(List.of("v1"), transport.requests.get(4).headers().get("If-None-Match")); + } + + @Test + void discoveryRevalidatesAnAlreadyAgedResponse() { + QueueTransport transport = new QueueTransport( + aepResponse(200, Map.of("Cache-Control", List.of("max-age=60"), "Age", List.of("120")), discovery()), + aepResponse(200, identityList()), + aepResponse(200, discovery()), + aepResponse(200, identityList())); + PlatformIdentityProvider provider = provider(transport); + provider.findIdentityByServiceDid(SERVICE_DID).toCompletableFuture().join(); + provider.findIdentityByServiceDid(SERVICE_DID).toCompletableFuture().join(); + assertEquals(4, transport.requests.size()); + } + @Test void rejectsCrossOriginDiscoveryRedirectsAndMismatchedSignResponses() { PlatformIdentityProvider redirecting = @@ -211,6 +241,66 @@ void rejectsCrossOriginDiscoveryRedirectsAndMismatchedSignResponses() { assertEquals("platform_sign_invalid", ((AepAgentException) signFailure.getCause()).code()); } + @Test + void rejectsCompletedSigningWithWrongAgentJwtIdLifetimeOrHttpStatus() { + for (AepHttpTransport.Response response : List.of( + aepResponse(200, completedSign().replace(AGENT_DID, "did:web:other.example")), + aepResponse(200, completedSign().replace("01J0AEPASSERTION0000000001", "different-jwt")), + aepResponse(200, completedSign().replace("12:05:00Z", "12:06:00Z")), + aepResponse(202, completedSign()))) { + QueueTransport transport = + new QueueTransport(aepResponse(200, discovery()), aepResponse(200, activeList()), response); + AgentIdentity identity = provider(transport) + .getOrCreate(SERVICE, SERVICE_DID) + .toCompletableFuture() + .join(); + CompletionException failure = assertThrows( + CompletionException.class, + () -> identity.signer().sign(claims()).toCompletableFuture().join()); + assertEquals("platform_sign_invalid", ((AepAgentException) failure.getCause()).code()); + assertEquals(3, transport.requests.size()); + } + } + + @Test + void encodesIdentityIdentifiersAsOnePathSegment() { + QueueTransport transport = new QueueTransport( + aepResponse(200, discovery()), + aepResponse(200, activeList().replace(IDENTITY_ID, "id/part ?#%é")), + aepResponse(200, completedSign())); + AgentIdentity identity = provider(transport) + .getOrCreate(SERVICE, SERVICE_DID) + .toCompletableFuture() + .join(); + assertEquals( + "client-assertion", + identity.signer().sign(claims()).toCompletableFuture().join()); + assertEquals( + URI.create("https://platform.example/v1/aep/agent-identities/id%2Fpart%20%3F%23%25%C3%A9/sign"), + transport.requests.get(2).uri()); + } + + @Test + void retriesDiscoveryAfterTransportFailureWithoutCachingTheFailure() { + CompletableFuture failed = new CompletableFuture<>(); + QueueTransport succeeding = new QueueTransport(aepResponse(200, discovery()), aepResponse(200, activeList())); + AtomicInteger attempts = new AtomicInteger(); + PlatformIdentityProvider provider = + provider(request -> attempts.getAndIncrement() == 0 ? failed : succeeding.execute(request)); + var first = provider.findIdentityByServiceDid(SERVICE_DID).toCompletableFuture(); + IllegalStateException cause = new IllegalStateException("connection closed"); + failed.completeExceptionally(new CompletionException(cause)); + assertEquals(cause, assertThrows(CompletionException.class, first::join).getCause()); + assertEquals( + AGENT_DID, + provider.findIdentityByServiceDid(SERVICE_DID) + .toCompletableFuture() + .join() + .orElseThrow() + .agentDid()); + assertEquals(3, attempts.get()); + } + @Test void noStoreDiscoveryIsFetchedForEveryOperation() { QueueTransport transport = new QueueTransport( @@ -379,6 +469,19 @@ void rejectsUnsafeConfigurationBeforeSendingRequests() { assertTrue(transport.requests.isEmpty()); } + @Test + void unsupportedServiceDidFailsBeforePlatformDiscovery() { + QueueTransport transport = new QueueTransport(); + CompletionException failure = assertThrows( + CompletionException.class, + () -> provider(transport) + .getOrCreate(URI.create("https://service.example"), "did:key:example") + .toCompletableFuture() + .join()); + assertEquals("service_identity_mismatch", ((AepAgentException) failure.getCause()).code()); + assertTrue(transport.requests.isEmpty()); + } + private static PlatformIdentityProvider provider(AepHttpTransport transport) { return PlatformIdentityProvider.builder(PLATFORM) .transport(transport) diff --git a/aep-core/src/main/java/foundation/aep/core/AepHttp.java b/aep-core/src/main/java/foundation/aep/core/AepHttp.java index 4abd419..95c08d8 100644 --- a/aep-core/src/main/java/foundation/aep/core/AepHttp.java +++ b/aep-core/src/main/java/foundation/aep/core/AepHttp.java @@ -7,6 +7,24 @@ public final class AepHttp { private AepHttp() {} + /** + * Reports whether a credential is the visible US-ASCII the AEP, Bearer, and Basic grammars + * share. Anything else, notably a control character or an embedded CRLF, would travel back + * into a request header through {@link #renderAuthorization}. + */ + private static boolean isCredentialToken(String value) { + if (value.isEmpty()) { + return false; + } + for (int index = 0; index < value.length(); index++) { + char character = value.charAt(index); + if (character < 0x21 || character > 0x7e) { + return false; + } + } + return true; + } + public static String normalizeEndpointBase(String endpointBase) { String value = endpointBase == null ? Aep.DEFAULT_ENDPOINT_BASE : endpointBase; if (!value.startsWith("/") || value.startsWith("//")) { @@ -44,6 +62,10 @@ public static Map renderAuthorization(ProtectedResourceAuthoriza if (value.credentials() == null || value.credentials().isEmpty()) { throw new AepAuthorizationException("invalid_request", "Authorization credentials must not be empty."); } + if (!isCredentialToken(value.credentials())) { + throw new AepAuthorizationException( + "invalid_request", "Authorization credentials must be visible US-ASCII."); + } return Map.of(authorizationHeaderName(value.carrier()), value.scheme().value() + " " + value.credentials()); } @@ -57,8 +79,8 @@ public static ProtectedResourceAuthorization parseAuthorization(String value, Au if (carrier == AuthorizationCarrier.DEDICATED && value.indexOf(',') >= 0) { throw new AepAuthorizationException("not_recognized", "The dedicated authorization field is ambiguous."); } - int separator = value.indexOf(' '); - if (separator <= 0 || separator == value.length() - 1) { + int separator = separatorIndex(value); + if (separator <= 0) { throw unrecognizedAuthorization(); } String scheme = value.substring(0, separator).toLowerCase(Locale.ROOT); @@ -69,13 +91,38 @@ public static ProtectedResourceAuthorization parseAuthorization(String value, Au case "basic" -> AuthorizationScheme.BASIC; default -> throw unrecognizedAuthorization(); }; - String credentials = value.substring(separator + 1); - if (credentials.isBlank() || Character.isWhitespace(credentials.charAt(0))) { + // CORE-AUTH-001 spells the separator as 1*SP, so repeated spaces belong to the grammar + // rather than marking a malformed presentation, and a field value may carry trailing + // optional whitespace that the sender is entitled to add. + String credentials = trimOptionalWhitespace(value.substring(separator)); + if (!isCredentialToken(credentials)) { throw unrecognizedAuthorization(); } return new ProtectedResourceAuthorization(carrier, parsed, credentials); } + private static int separatorIndex(String value) { + for (int index = 0; index < value.length(); index++) { + char character = value.charAt(index); + if (character == ' ' || character == '\t') { + return index; + } + } + return -1; + } + + private static String trimOptionalWhitespace(String value) { + int start = 0; + int end = value.length(); + while (start < end && (value.charAt(start) == ' ' || value.charAt(start) == '\t')) { + start++; + } + while (end > start && (value.charAt(end - 1) == ' ' || value.charAt(end - 1) == '\t')) { + end--; + } + return value.substring(start, end); + } + private static AepAuthorizationException unrecognizedAuthorization() { return new AepAuthorizationException("not_recognized", "The authorization presentation was not recognized."); } diff --git a/aep-core/src/main/java/foundation/aep/core/AepJson.java b/aep-core/src/main/java/foundation/aep/core/AepJson.java index f9723c2..5466f11 100644 --- a/aep-core/src/main/java/foundation/aep/core/AepJson.java +++ b/aep-core/src/main/java/foundation/aep/core/AepJson.java @@ -68,6 +68,9 @@ public static EnrollRequest parseEnrollRequest(String json) { Map value = object(json, ENROLL_REQUEST); AepRawJson.requireMembers(value, ENROLL_REQUEST, AGENT_DID); AepRawJson.rejectNullPaths(value, ENROLL_REQUEST, AGENT_DID, "claims", "idempotency_key"); + if (value.get("claims") instanceof Map claims) { + AepRawJson.claimValues(claims); + } return parse(json, EnrollRequest.class, ENROLL_REQUEST, AepValidation::enrollRequest); } diff --git a/aep-core/src/main/java/foundation/aep/core/AepOpenApi.java b/aep-core/src/main/java/foundation/aep/core/AepOpenApi.java index 899ff33..5c9345e 100644 --- a/aep-core/src/main/java/foundation/aep/core/AepOpenApi.java +++ b/aep-core/src/main/java/foundation/aep/core/AepOpenApi.java @@ -2,12 +2,18 @@ import java.net.URI; import java.util.ArrayList; +import java.util.Collection; import java.util.Comparator; +import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; +import java.util.Map; import java.util.Objects; +import java.util.Set; public final class AepOpenApi { + private static final int SINGLE = 1; + private AepOpenApi() {} public static URI resolveDocumentUri(URI finalInspectUri, String reference) { @@ -54,6 +60,61 @@ public static PathMatch matchPath( return new PathMatch(method.toUpperCase(Locale.ROOT), selected.template()); } + /** + * Resolves the effective security for one operation (CORE-OAS-011/013/014/015). + * + *

Operation-level security replaces the root value rather than adding to it, an empty list + * makes the operation public, an empty requirement object is an anonymous alternative, several + * requirement objects are alternatives, and several schemes inside one object are compound. An + * Agent that cannot satisfy every member of a compound requirement treats that requirement as + * unsupported, and never reduces it to the members it happens to support: the returned + * alternatives are always whole requirement objects. + * + * @param rootSecurity the document-level {@code security} value, or {@code null} when absent + * @param operationSecurity the operation's own {@code security} value, or {@code null} when the + * operation omits the member and therefore inherits the root value + * @param supportedSchemes the security-scheme names this Agent is able to satisfy + */ + public static SecurityResolution resolveSecurity( + List>> rootSecurity, + List>> operationSecurity, + Collection supportedSchemes) { + Set supported = supportedSchemes == null ? Set.of() : new LinkedHashSet<>(supportedSchemes); + List>> effective = effectiveSecurity(rootSecurity, operationSecurity); + if (effective.isEmpty()) { + return new SecurityResolution(SecurityDisposition.PUBLIC, List.of()); + } + List> requirements = new ArrayList<>(effective.size()); + for (Map> requirement : effective) { + if (requirement == null) { + throw new IllegalArgumentException("An OpenAPI security requirement must not be null."); + } + requirements.add(List.copyOf(requirement.keySet())); + } + if (requirements.stream().anyMatch(List::isEmpty)) { + return new SecurityResolution(SecurityDisposition.OPTIONAL_ANONYMOUS, List.of()); + } + List> satisfiable = + requirements.stream().filter(supported::containsAll).toList(); + if (satisfiable.isEmpty()) { + return new SecurityResolution(SecurityDisposition.FALLBACK_LIVE_CHALLENGE, List.of()); + } + if (requirements.size() > SINGLE) { + return new SecurityResolution(SecurityDisposition.ALTERNATIVES, satisfiable); + } + SecurityDisposition disposition = + satisfiable.get(0).size() > SINGLE ? SecurityDisposition.ALL_REQUIRED : SecurityDisposition.REQUIRED; + return new SecurityResolution(disposition, satisfiable); + } + + private static List>> effectiveSecurity( + List>> rootSecurity, List>> operationSecurity) { + if (operationSecurity != null) { + return operationSecurity; + } + return rootSecurity == null ? List.of() : rootSecurity; + } + private static Candidate match(String template, String path, TrailingSlashMode mode) { if (template == null || !template.startsWith("/") || template.indexOf('?') >= 0 || template.indexOf('#') >= 0) { return null; @@ -86,7 +147,7 @@ private static String requestPath(String value) { throw new IllegalArgumentException("Request path must not be blank."); } URI reference = URI.create(value); - String path = reference.getPath(); + String path = reference.getRawPath(); if (path == null || !path.startsWith("/")) { throw new IllegalArgumentException("Request path must be absolute."); } @@ -120,6 +181,43 @@ private static void requireSafeUri(URI value, boolean allowInsecureLoopback, Str } } + /** How an Agent must treat the security an operation resolves to. */ + public enum SecurityDisposition { + PUBLIC("public"), + OPTIONAL_ANONYMOUS("optional-anonymous"), + REQUIRED("required"), + ALTERNATIVES("alternatives"), + ALL_REQUIRED("all-required"), + FALLBACK_LIVE_CHALLENGE("fallback-live-challenge"); + + private final String wireValue; + + SecurityDisposition(String wireValue) { + this.wireValue = wireValue; + } + + public String value() { + return wireValue; + } + } + + /** + * The resolved security for an operation. + * + * @param satisfiable the complete requirement objects the Agent can satisfy, each listed as the + * scheme names it must present together; empty unless the disposition selects credentials + */ + public record SecurityResolution(SecurityDisposition disposition, List> satisfiable) { + public SecurityResolution { + Objects.requireNonNull(disposition, "disposition"); + List> copied = new ArrayList<>(); + if (satisfiable != null) { + satisfiable.forEach(requirement -> copied.add(Copies.list(requirement))); + } + satisfiable = List.copyOf(copied); + } + } + public enum TrailingSlashMode { STRICT, EQUIVALENT diff --git a/aep-core/src/main/java/foundation/aep/core/AepRawJson.java b/aep-core/src/main/java/foundation/aep/core/AepRawJson.java index d627f00..778d89a 100644 --- a/aep-core/src/main/java/foundation/aep/core/AepRawJson.java +++ b/aep-core/src/main/java/foundation/aep/core/AepRawJson.java @@ -11,7 +11,7 @@ final class AepRawJson { private AepRawJson() {} - static void claimValues(Map value) { + static void claimValues(Map value) { rejectNullPaths( value, CLAIM_VALUES, @@ -123,7 +123,7 @@ static void rejectUnknownMembers(Map value, String documentType, } } - static void rejectNullPaths(Map value, String documentType, String... members) { + static void rejectNullPaths(Map value, String documentType, String... members) { for (String member : members) { if (value.containsKey(member) && value.get(member) == null) { throw new AepValidationException( @@ -141,7 +141,7 @@ static void requireMembers(Map value, String documentType, Strin } } - static void requireStrings(Map value, String documentType, String... members) { + static void requireStrings(Map value, String documentType, String... members) { for (String member : members) { if (value.containsKey(member) && !(value.get(member) instanceof String)) { throw new AepValidationException( diff --git a/aep-core/src/main/java/foundation/aep/core/AepValidation.java b/aep-core/src/main/java/foundation/aep/core/AepValidation.java index d1c98cb..c1b17bc 100644 --- a/aep-core/src/main/java/foundation/aep/core/AepValidation.java +++ b/aep-core/src/main/java/foundation/aep/core/AepValidation.java @@ -27,6 +27,7 @@ public final class AepValidation { private static final Set AUTHENTICATED_COMMANDS = Set.of("enroll", "grant", "revoke", "status"); private static final String COMMANDS_SUPPORTED_PATH = "$.commands.supported"; private static final String AGENT_DID_PATH = "$.agent_did"; + private static final String OPENAPI_URL_PATH = "$.http.openapi.url"; private static final String OPERATION_PATH = "$.op"; private static final String OPERATION_REQUIRED = "Expected a registered assertion operation."; private static final String RESOURCE_PATH = "$.resource"; @@ -669,14 +670,18 @@ private static void validateHttp(InspectDocument.Http value, Issues issues) { issues.required("$.http", JSON_OBJECT); return; } - if (value.endpointBase() != null && !value.endpointBase().startsWith("/")) { - issues.add("$.http.endpoint_base", "Expected a path beginning with '/'."); + // An endpoint_base is origin-relative, so a protocol-relative "//host" value would move + // every command URL to another origin once it is resolved against the Service origin. + if (value.endpointBase() != null && !isEndpointPath(value.endpointBase())) { + issues.add("$.http.endpoint_base", "Expected an absolute path without a query or fragment."); } if (value.openapi() != null) { - requiredNonEmpty(value.openapi().url(), "$.http.openapi.url", issues); + requiredNonEmpty(value.openapi().url(), OPENAPI_URL_PATH, issues); if (nonEmpty(value.openapi().url()) && !isUriReference(value.openapi().url())) { - issues.add("$.http.openapi.url", "Expected a URI reference."); + issues.add(OPENAPI_URL_PATH, "Expected a URI reference."); + } else if (nonEmpty(value.openapi().url())) { + openApiUrlTransport(value.openapi().url(), issues); } String slash = value.openapi().pathMatching() == null ? null @@ -687,6 +692,32 @@ private static void validateHttp(InspectDocument.Http value, Issues issues) { } } + /** + * Applies CORE-OAS-003 and CORE-OAS-004 to an absolute OpenAPI URL. A relative reference + * resolves against the final Inspect response URL and inherits its transport, so only an + * absolute reference carries a transport of its own to check here. + */ + private static void openApiUrlTransport(String value, Issues issues) { + URI uri; + try { + uri = URI.create(value); + } catch (IllegalArgumentException exception) { + return; + } + if (!uri.isAbsolute()) { + return; + } + if (uri.getRawUserInfo() != null) { + issues.add(OPENAPI_URL_PATH, "Expected no user information."); + } + if (HTTPS.equalsIgnoreCase(uri.getScheme())) { + return; + } + if (!"http".equalsIgnoreCase(uri.getScheme()) || uri.getHost() == null || !isLoopbackHost(uri.getHost())) { + issues.add(OPENAPI_URL_PATH, "Expected HTTPS, or HTTP for a loopback literal."); + } + } + private static void validateIdentity( InspectDocument.Identity value, InspectDocument.Commands commands, Issues issues) { if (value == null) { @@ -920,6 +951,18 @@ private static boolean nonEmpty(String value) { return value != null && !value.isEmpty(); } + /** + * Reports whether a host is one of the three loopback literals the development opt-in permits. + * {@link URI#getHost()} keeps the brackets around an IPv6 literal, so the bracketed spelling is + * the one a parsed URI actually presents. + */ + private static boolean isLoopbackHost(String host) { + String normalized = host.toLowerCase(java.util.Locale.ROOT); + return "localhost".equals(normalized) + || "127.0.0.1".equals(normalized) // NOPMD - Explicit development opt-in permits loopback HTTP. + || "[::1]".equals(normalized); + } + private static boolean isApiKeyValue(String value) { for (int index = 0; index < value.length(); index++) { char character = value.charAt(index); @@ -1049,7 +1092,8 @@ private static boolean isAddressLiteral(String value) { } String tag = content.substring(0, separator); String literal = content.substring(separator + 1); - return tag.matches("[A-Za-z0-9-]*[A-Za-z0-9]") + // RFC 5321 spells the standardized tag as an Ldh-str, which begins with a letter or digit. + return tag.matches("[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?") && literal.chars() .allMatch(character -> (character >= 33 && character <= 90) || (character >= 94 && character <= 126)); @@ -1114,19 +1158,19 @@ private static boolean isProtectedResourceUri(String value, boolean allowInsecur } try { URI uri = URI.create(value); - if (!uri.isAbsolute() || uri.getHost() == null || uri.getFragment() != null) { + // An authenticate assertion names one target, so user information has no place in it: + // credentials in the resource claim would travel wherever the claim does, and the + // request target a Service compares against never carries them. + if (!uri.isAbsolute() + || uri.getHost() == null + || uri.getRawUserInfo() != null + || uri.getFragment() != null) { return false; } if (HTTPS.equalsIgnoreCase(uri.getScheme())) { return true; } - return allowInsecureLoopback - && "http".equalsIgnoreCase(uri.getScheme()) - && Set.of( - "localhost", - "127.0.0.1", // NOPMD - Explicit development opt-in permits loopback HTTP. - "::1") // NOPMD - Explicit development opt-in permits loopback HTTP. - .contains(uri.getHost().toLowerCase(java.util.Locale.ROOT)); + return allowInsecureLoopback && "http".equalsIgnoreCase(uri.getScheme()) && isLoopbackHost(uri.getHost()); } catch (IllegalArgumentException exception) { return false; } diff --git a/aep-core/src/main/java/foundation/aep/core/DidWeb.java b/aep-core/src/main/java/foundation/aep/core/DidWeb.java index 4368fb4..9929245 100644 --- a/aep-core/src/main/java/foundation/aep/core/DidWeb.java +++ b/aep-core/src/main/java/foundation/aep/core/DidWeb.java @@ -25,9 +25,14 @@ public static URI documentUri(String did, boolean allowInsecureLoopback) { String authority = decode(components[0]); URI authorityUri = URI.create("https://" + authority); String host = authorityUri.getHost(); + // The decoded authority is concatenated back into the document URI, so it has to be an + // authority and nothing else. Without the round-trip check a value such as + // "example.com%3Fx=1" parsed as a host plus a query, passed every individual check, and + // produced a document URI whose query swallowed the whole did.json path. if (host == null || authorityUri.getUserInfo() != null - || !authorityUri.getPath().isEmpty()) { + || !authorityUri.getPath().isEmpty() + || !authority.equals(authorityUri.getRawAuthority())) { throw new IllegalArgumentException("Invalid did:web authority: " + authority); } List path = new ArrayList<>(); @@ -36,7 +41,7 @@ public static URI documentUri(String did, boolean allowInsecureLoopback) { if (component.isEmpty() || component.contains("/") || ".".equals(component) || "..".equals(component)) { throw new IllegalArgumentException("Invalid did:web path component."); } - path.add(component); + path.add(components[index]); } String documentPath = path.isEmpty() ? "/.well-known/did.json" : "/" + String.join("/", path) + "/did.json"; String scheme = allowInsecureLoopback && isLoopback(host) ? "http" : "https"; @@ -55,7 +60,11 @@ && normalizedHost(document).equals(normalizedHost(origin)) private static String decode(String value) { try { - return URI.create("https://did.invalid/" + value).getPath().substring(1); + URI uri = URI.create("https://did.invalid/" + value); + if (uri.getRawQuery() != null || uri.getRawFragment() != null) { + throw new IllegalArgumentException("Invalid did:web component."); + } + return uri.getPath().substring(1); } catch (IllegalArgumentException exception) { throw new IllegalArgumentException("Invalid did:web percent encoding.", exception); } diff --git a/aep-core/src/test/java/foundation/aep/core/AepCoreConformanceTest.java b/aep-core/src/test/java/foundation/aep/core/AepCoreConformanceTest.java new file mode 100644 index 0000000..3492f2b --- /dev/null +++ b/aep-core/src/test/java/foundation/aep/core/AepCoreConformanceTest.java @@ -0,0 +1,522 @@ +package foundation.aep.core; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.URI; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +/** Spec-grounded behaviour for the transport-independent contracts in Core. */ +final class AepCoreConformanceTest { + + private static List paths(List issues) { + return issues.stream().map(ValidationIssue::path).toList(); + } + + private static InspectDocument.Builder validDocument() { + return InspectDocument.builder() + .version(Aep.VERSION) + .bindings(new InspectDocument.Bindings(List.of("http"))) + .commands(new InspectDocument.Commands(List.of("inspect"), List.of(), Map.of())) + .core(new InspectDocument.Core(List.of("EdDSA", "ES256"))) + .http(new InspectDocument.Http("/aep/", null)) + .identity(new InspectDocument.Identity(List.of("did:web"))) + .service(new InspectDocument.Service("did:web:service.example")); + } + + /** + * CORE-AUTH-001 spells the credential grammar as {@code "AEP" 1*SP compact-jws}, so one or more + * separators is the grammar rather than a malformed presentation, and trailing optional + * whitespace belongs to the field value rather than to the credential. + */ + @Test + void acceptsTheRepeatedSeparatorTheCredentialGrammarAllows() { + for (String value : List.of("AEP token", "AEP token", "AEP\ttoken", "AEP \t token", "AEP token ")) { + ProtectedResourceAuthorization parsed = AepHttp.parseAuthorization(value, AuthorizationCarrier.STANDARD); + assertEquals("token", parsed.credentials(), value); + assertEquals(AuthorizationScheme.AEP, parsed.scheme(), value); + } + assertEquals( + AuthorizationScheme.BEARER, + AepHttp.parseAuthorization("bearer token", AuthorizationCarrier.STANDARD) + .scheme()); + assertEquals( + AuthorizationScheme.BASIC, + AepHttp.parseAuthorization("BASIC dXNlcjpwdw==", AuthorizationCarrier.STANDARD) + .scheme()); + } + + /** + * The AEP, Bearer, and Basic grammars share a visible US-ASCII credential. A value carrying a + * control character or an embedded CRLF is not a credential, and accepting one would let a + * parsed presentation travel back into a request header through {@link AepHttp#renderAuthorization}. + */ + @Test + void rejectsCredentialsThatAreNotVisibleAscii() { + for (String credentials : List.of("tok\ren", "tok\nen", "tok\u0001en", "tok\u007fen", "tokén")) { + assertThrows( + AepAuthorizationException.class, + () -> AepHttp.parseAuthorization("AEP " + credentials, AuthorizationCarrier.STANDARD), + credentials); + assertThrows( + AepAuthorizationException.class, + () -> AepHttp.renderAuthorization(new ProtectedResourceAuthorization( + AuthorizationCarrier.STANDARD, AuthorizationScheme.AEP, credentials)), + credentials); + } + assertEquals( + Map.of("Authorization", "AEP token"), + AepHttp.renderAuthorization(new ProtectedResourceAuthorization( + AuthorizationCarrier.STANDARD, AuthorizationScheme.AEP, "token"))); + assertEquals( + Map.of(Aep.AUTHORIZATION_HEADER, "Bearer token"), + AepHttp.renderAuthorization(new ProtectedResourceAuthorization( + AuthorizationCarrier.DEDICATED, AuthorizationScheme.BEARER, "token"))); + } + + /** CORE-PR-018: one dedicated field value must not encode more than one credential. */ + @Test + void rejectsACombinedDedicatedPresentation() { + assertThrows( + AepAuthorizationException.class, + () -> AepHttp.parseAuthorization("AEP one, Bearer two", AuthorizationCarrier.DEDICATED)); + assertEquals( + "token", + AepHttp.parseAuthorization("AEP token", AuthorizationCarrier.DEDICATED) + .credentials()); + } + + /** + * CORE-CA-014 and CORE-CA-015: an authenticate assertion names one absolute HTTPS target with no + * fragment. User information has no place in it either, because the request target a Service + * compares the claim against never carries credentials. + */ + @Test + void boundsTheResourceClaimToASafeAbsoluteTarget() { + for (String resource : List.of( + "https://api.example/x#", + "https://api.example/x#fragment", + "https://user:secret@api.example/x", + "http://api.example/x", + "/x", + "mailto:agent@example.com")) { + assertFalse( + AepValidation.clientAssertionClaims(authenticateClaims(resource)) + .isEmpty(), + resource); + } + assertTrue(AepValidation.clientAssertionClaims(authenticateClaims("https://api.example/x")) + .isEmpty()); + assertTrue(AepValidation.clientAssertionClaims(authenticateClaims("https://api.example/x?q=1")) + .isEmpty()); + } + + /** The development opt-in permits the three loopback literals a parsed URI actually presents. */ + @Test + void permitsLoopbackResourcesOnlyUnderTheExplicitOptIn() { + for (String resource : List.of("http://localhost/x", "http://127.0.0.1/x", "http://[::1]/x")) { + assertFalse( + AepValidation.clientAssertionClaims(authenticateClaims(resource), false) + .isEmpty(), + resource); + assertTrue( + AepValidation.clientAssertionClaims(authenticateClaims(resource), true) + .isEmpty(), + resource); + } + assertFalse(AepValidation.clientAssertionClaims(authenticateClaims("http://loopback.example/x"), true) + .isEmpty()); + } + + /** CORE-CA-026: a non-authenticate assertion must not carry a resource. */ + @Test + void keepsTheResourceClaimToAuthenticate() { + ClientAssertionClaims claims = new ClientAssertionClaims( + "did:web:agent.example", + "did:web:agent.example", + "did:web:service.example", + AssertionOperation.ENROLL, + 1_000, + 1_060, + "jti-1", + "https://api.example/x"); + assertEquals(List.of("$.resource"), paths(AepValidation.clientAssertionClaims(claims))); + } + + /** CORE-CA-019: the assertion lifetime is bounded at 300 seconds, and must be positive. */ + @Test + void boundsTheAssertionLifetime() { + assertTrue( + AepValidation.clientAssertionClaims(commandClaims(1_000, 1_300)).isEmpty()); + assertTrue( + AepValidation.clientAssertionClaims(commandClaims(1_000, 1_001)).isEmpty()); + assertEquals(List.of("$.exp"), paths(AepValidation.clientAssertionClaims(commandClaims(1_000, 1_301)))); + assertEquals(List.of("$.exp"), paths(AepValidation.clientAssertionClaims(commandClaims(1_000, 1_000)))); + assertEquals(List.of("$.exp"), paths(AepValidation.clientAssertionClaims(commandClaims(1_000, 999)))); + // Near the top of the clock the bound is guarded against overflow, and a lifetime that + // cannot exceed 300 seconds without wrapping stays valid. + assertTrue(AepValidation.clientAssertionClaims(commandClaims(Long.MAX_VALUE - 1, Long.MAX_VALUE)) + .isEmpty()); + } + + /** CORE-CA-010: iss and sub name the same Agent. */ + @Test + void requiresTheIssuerAndSubjectToAgree() { + ClientAssertionClaims claims = new ClientAssertionClaims( + "did:web:agent.example", + "did:web:other.example", + "did:web:service.example", + AssertionOperation.STATUS, + 1_000, + 1_060, + "jti-1", + null); + assertEquals(List.of("$.sub"), paths(AepValidation.clientAssertionClaims(claims))); + } + + /** + * A did:web authority is concatenated back into the document URI, so it must be an authority and + * nothing else. A value decoding to a query or fragment would yield a document URI whose query + * swallows the did.json path, so it is rejected. + */ + @Test + void rejectsDidWebAuthoritiesThatAreNotPurelyAnAuthority() { + for (String did : List.of( + "did:web:example.com%3Fx=1", + "did:web:example.com%23y", "did:web:user%40example.com", "did:web:example.com%2Fpath")) { + assertThrows(IllegalArgumentException.class, () -> DidWeb.documentUri(did), did); + } + assertEquals(URI.create("https://example.com/.well-known/did.json"), DidWeb.documentUri("did:web:example.com")); + assertEquals( + URI.create("https://example.com:8443/.well-known/did.json"), + DidWeb.documentUri("did:web:example.com%3A8443")); + } + + /** DWB path components are single segments; traversal never reaches the resolved URL. */ + @Test + void rejectsTraversalInDidWebPathComponents() { + for (String did : List.of( + "did:web:example.com:.:agent", + "did:web:example.com:..:agent", + "did:web:example.com:%2E%2E:agent", + "did:web:example.com:a%2Fb", + "did:web:example.com::agent")) { + assertThrows(IllegalArgumentException.class, () -> DidWeb.documentUri(did), did); + } + assertEquals( + URI.create("https://example.com/agents/one/did.json"), + DidWeb.documentUri("did:web:example.com:agents:one")); + } + + /** + * CORE-OAS-003 and CORE-OAS-004: an advertised OpenAPI URL must not downgrade transport or carry + * user information. A relative reference inherits the Inspect response transport and is fine. + */ + @Test + void appliesOpenApiTransportRulesToTheAdvertisedDocument() { + for (String url : List.of( + "http://docs.example/openapi.json", + "https://user:secret@docs.example/openapi.json", + "ftp://docs.example/openapi.json")) { + InspectDocument document = validDocument() + .http(new InspectDocument.Http( + "/aep/", new InspectDocument.OpenApi(url, new InspectDocument.PathMatching("strict")))) + .build(); + assertTrue(paths(AepValidation.inspectDocument(document)).contains("$.http.openapi.url"), url); + } + for (String url : List.of( + "/openapi.json", + "openapi.json", + "https://docs.example/openapi.json", + "http://localhost:8080/openapi.json", + "http://127.0.0.1/openapi.json", + "http://[::1]/openapi.json")) { + InspectDocument document = validDocument() + .http(new InspectDocument.Http( + "/aep/", new InspectDocument.OpenApi(url, new InspectDocument.PathMatching("strict")))) + .build(); + assertTrue(AepValidation.inspectDocument(document).isEmpty(), url); + } + } + + /** + * An endpoint_base is origin-relative. A protocol-relative value would move every command URL to + * another origin once resolved against the Service origin, so the document that advertises one is + * invalid rather than merely unusable at the point a command path is built. + */ + @Test + void rejectsAnEndpointBaseThatCouldLeaveTheServiceOrigin() { + for (String base : List.of("//attacker.example", "//attacker.example/aep", "aep/", "/aep?x=1", "/aep#y")) { + InspectDocument document = + validDocument().http(new InspectDocument.Http(base, null)).build(); + assertTrue(paths(AepValidation.inspectDocument(document)).contains("$.http.endpoint_base"), base); + assertThrows(IllegalArgumentException.class, () -> AepHttp.normalizeEndpointBase(base), base); + } + assertTrue(AepValidation.inspectDocument(validDocument() + .http(new InspectDocument.Http("/aep", null)) + .build()) + .isEmpty()); + } + + /** CORE-HTTP-003 and CORE-HTTP-004: both /aep and /aep/ produce /aep/enroll. */ + @Test + void buildsCommandPathsFromTheAdvertisedBase() { + assertEquals(Aep.DEFAULT_ENDPOINT_BASE, AepHttp.normalizeEndpointBase(null)); + assertEquals("/aep/", AepHttp.normalizeEndpointBase("/aep")); + assertEquals("/aep/enroll", AepHttp.commandPath(AepCommand.ENROLL, "/aep")); + assertEquals("/aep/enroll", AepHttp.commandPath(AepCommand.ENROLL, "/aep/")); + assertEquals("/v1/aep/status", AepHttp.commandPath(AepCommand.STATUS, "/v1/aep")); + assertEquals( + URI.create("https://api.example/aep/grant"), + AepHttp.commandUri(URI.create("https://api.example/"), AepCommand.GRANT, null)); + // Inspect is served from the well-known path, never from endpoint_base. + assertThrows(IllegalArgumentException.class, () -> AepHttp.commandPath(AepCommand.INSPECT, "/aep/")); + } + + /** CORE-INS-009 and CORE-INS-022 constrain what a document may advertise. */ + @Test + void constrainsTheAdvertisedCommandSet() { + InspectDocument withoutGrantTypes = validDocument() + .commands(new InspectDocument.Commands(List.of("inspect", "grant", "revoke"), List.of(), Map.of())) + .build(); + assertTrue(paths(AepValidation.inspectDocument(withoutGrantTypes)).contains("$.commands.grant_types")); + + InspectDocument withoutIdentity = validDocument() + .commands(new InspectDocument.Commands(List.of("inspect", "enroll"), List.of(), Map.of())) + .identity(new InspectDocument.Identity(List.of())) + .build(); + assertTrue(paths(AepValidation.inspectDocument(withoutIdentity)).contains("$.identity.methods")); + + // CORE-IANA-005: authenticate is an assertion operation, never a command. + InspectDocument withAuthenticate = validDocument() + .commands(new InspectDocument.Commands(List.of("inspect", "authenticate"), List.of(), Map.of())) + .build(); + assertTrue(paths(AepValidation.inspectDocument(withAuthenticate)).contains("$.commands.supported")); + + InspectDocument withoutInspect = validDocument() + .commands(new InspectDocument.Commands(List.of("enroll"), List.of(), Map.of())) + .build(); + assertTrue(paths(AepValidation.inspectDocument(withoutInspect)).contains("$.commands.supported")); + } + + /** + * CORE-CA-003: a Service must advertise both required algorithms. The document schema leaves the + * array open beyond those two so an extension can register another algorithm, and the allow-list + * that keeps `none` and symmetric algorithms out of an assertion lives at verification time. + */ + @Test + void requiresTheRegisteredSigningAlgorithms() { + for (List algorithms : List.of(List.of(), List.of("EdDSA"), List.of("ES256"))) { + assertFalse( + AepValidation.inspectDocument(validDocument() + .core(new InspectDocument.Core(algorithms)) + .build()) + .isEmpty(), + algorithms.toString()); + } + for (List algorithms : List.of(List.of("ES256", "EdDSA"), List.of("EdDSA", "ES256", "ML-DSA-44"))) { + assertTrue( + AepValidation.inspectDocument(validDocument() + .core(new InspectDocument.Core(algorithms)) + .build()) + .isEmpty(), + algorithms.toString()); + } + // CORE-CA-005: an advertised symmetric algorithm still never verifies an assertion. + assertThrows( + IllegalArgumentException.class, + () -> ClientAssertions.verify( + "a.b.c", + null, + ClientAssertionVerification.builder( + "did:web:service.example", "did:web:agent.example", AssertionOperation.STATUS) + .build())); + } + + /** CORE-ERR-002, CORE-ERR-003 and CORE-ERR-007 shape a Problem Details response. */ + @Test + void shapesProblemDetails() { + assertTrue(AepValidation.problemDetails(ProblemDetails.of("not_recognized", "Not recognized", 401)) + .isEmpty()); + assertTrue(paths(AepValidation.problemDetails(new ProblemDetails( + "urn:aep:error:other", "Title", 401, null, null, "not_recognized", null, null, null))) + .contains("$.type")); + assertTrue(paths(AepValidation.problemDetails( + new ProblemDetails("urn:aep:error:bad", "", 400, null, null, "bad", null, null, null))) + .contains("$.title")); + assertTrue(paths(AepValidation.problemDetails(new ProblemDetails( + "urn:aep:error:Bad-Code", "Title", 400, null, null, "Bad-Code", null, null, null))) + .contains("$.code")); + // A not_recognized response stays non-disclosing. + assertTrue(paths(AepValidation.problemDetails(new ProblemDetails( + "urn:aep:error:not_recognized", + "Not recognized", + 401, + null, + null, + "not_recognized", + "true", + null, + null))) + .contains("$")); + assertTrue(paths(AepValidation.problemDetails(new ProblemDetails( + "urn:aep:error:not_recognized", + "Not recognized", + 401, + null, + null, + "not_recognized", + null, + List.of("contact.email"), + null))) + .contains("$")); + // The same metadata is legitimate on a lifecycle problem. + assertTrue(AepValidation.problemDetails(new ProblemDetails( + "urn:aep:error:verification_pending", + "Verification pending", + 403, + null, + null, + "verification_pending", + "true", + List.of("contact.email"), + List.of("domain-control"))) + .isEmpty()); + } + + /** CORE-RVK-006 and CORE-RVK-008: the Revoke targets are mutually exclusive. */ + @Test + void constrainsRevokeTargeting() { + assertTrue(AepValidation.revokeRequest(new RevokeRequest("api-key", null, null)) + .isEmpty()); + assertTrue(AepValidation.revokeRequest(new RevokeRequest("api-key", "credential-1", null)) + .isEmpty()); + assertTrue(AepValidation.revokeRequest(new RevokeRequest(null, null, "true")) + .isEmpty()); + for (RevokeRequest request : List.of( + new RevokeRequest(null, null, null), + new RevokeRequest(null, "credential-1", null), + new RevokeRequest("api-key", null, "true"), + new RevokeRequest(null, "credential-1", "true"), + new RevokeRequest(null, null, "false"))) { + assertFalse(AepValidation.revokeRequest(request).isEmpty(), request.toString()); + } + } + + /** APK-RSP-004 and RFC 7617 keep issued credentials unambiguous on the wire. */ + @Test + void constrainsIssuedCredentialShapes() { + assertTrue(AepValidation.grantResponse(new GrantResponses.ApiKey( + "key-value", "credential-1", "2026-09-01T12:00:00Z", "x-api-key", List.of("read"))) + .isEmpty()); + for (String apiKey : List.of("key value", "key\"value", "key,value", "key;value", "key\\value", "key\u0001")) { + assertTrue( + paths(AepValidation.grantResponse(new GrantResponses.ApiKey( + apiKey, "credential-1", "2026-09-01T12:00:00Z", "x-api-key", null))) + .contains("$.api_key"), + apiKey); + } + assertTrue(paths(AepValidation.grantResponse( + new GrantResponses.ApiKey("key", "credential-1", "2026-09-01T12:00:00Z", "bad header", null))) + .contains("$.header")); + assertTrue(paths(AepValidation.grantResponse(new GrantResponses.Basic( + "credential-1", "2026-09-01T12:00:00Z", "secret", null, null, "user:name"))) + .contains("$.username")); + assertTrue(paths(AepValidation.grantResponse(new GrantResponses.Basic( + "credential-1", "2026-09-01T12:00:00Z", "sec\u0001ret", null, null, "user"))) + .contains("$.password")); + assertTrue(paths(AepValidation.grantResponse( + new GrantResponses.OAuthBearer("token", "credential-1", "2026-09-01T12:00:00Z", null, "MAC"))) + .contains("$.token_type")); + assertTrue(paths(AepValidation.grantResponse(new GrantResponses.OAuthBearer( + "token", "credential-1", "2026-09-01T12:00:00Z", null, "saml", "Bearer"))) + .contains("$.token_format")); + assertTrue(AepValidation.grantResponse(new GrantResponses.OAuthBearer( + "token", "credential-1", "2026-09-01T12:00:00Z", List.of(), "jwt", "Bearer")) + .isEmpty()); + } + + /** PLT-VER-009: an unrecognized verification response discloses nothing about an identity. */ + @Test + void keepsUnrecognizedVerificationResponsesNonDisclosing() { + assertTrue(AepValidation.platformVerificationResponse(new PlatformVerificationResponse( + null, null, null, "not_recognized", "did:web:service.example", null, false)) + .isEmpty()); + assertTrue(paths(AepValidation.platformVerificationResponse(new PlatformVerificationResponse( + "did:web:agent.example", null, null, "not_recognized", "did:web:service.example", null, false))) + .contains("$")); + assertTrue(paths(AepValidation.platformVerificationResponse(new PlatformVerificationResponse( + null, null, null, "verified", "did:web:service.example", null, false))) + .contains("$.reason")); + assertTrue(AepValidation.platformVerificationResponse(new PlatformVerificationResponse( + "did:web:agent.example", + "pai_1", + AssertionOperation.STATUS, + "verified", + "did:web:service.example", + ManagedAgentStatus.ACTIVE, + true)) + .isEmpty()); + } + + /** RFC 5321: a standardized address-literal tag is an Ldh-str, which starts alphanumeric. */ + @Test + void constrainsMailboxSyntax() { + for (String mailbox : List.of( + "agent@example.com", + "\"quoted local\"@example.com", + "agent@[127.0.0.1]", + "agent@[IPv6:::1]", + "agent@[x25:value]")) { + assertTrue( + AepValidation.claimValues( + ClaimValues.builder().contactEmail(mailbox).build()) + .isEmpty(), + mailbox); + } + for (String mailbox : List.of( + "agent@[-tag:value]", + "agent@[:value]", + "agent@[tag:]", + "agent@@example.com", + "@example.com", + "agent@", + "agent..name@example.com", + "agent@-example.com", + "agent@[not closed")) { + assertFalse( + AepValidation.claimValues( + ClaimValues.builder().contactEmail(mailbox).build()) + .isEmpty(), + mailbox); + } + } + + private static ClientAssertionClaims authenticateClaims(String resource) { + return new ClientAssertionClaims( + "did:web:agent.example", + "did:web:agent.example", + "did:web:service.example", + AssertionOperation.AUTHENTICATE, + 1_000, + 1_060, + "jti-1", + resource); + } + + private static ClientAssertionClaims commandClaims(long issuedAt, long expiresAt) { + return new ClientAssertionClaims( + "did:web:agent.example", + "did:web:agent.example", + "did:web:service.example", + AssertionOperation.STATUS, + issuedAt, + expiresAt, + "jti-1", + null); + } +} diff --git a/aep-core/src/test/java/foundation/aep/core/AepCoreCoverageTest.java b/aep-core/src/test/java/foundation/aep/core/AepCoreCoverageTest.java new file mode 100644 index 0000000..4b71d3d --- /dev/null +++ b/aep-core/src/test/java/foundation/aep/core/AepCoreCoverageTest.java @@ -0,0 +1,860 @@ +package foundation.aep.core; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.URI; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +/** Edge and failure paths across the Core contracts. */ +final class AepCoreCoverageTest { + + private static List paths(List issues) { + return issues.stream().map(ValidationIssue::path).toList(); + } + + @Test + void rejectsAbsentDocumentsAcrossEveryValidator() { + List> results = List.of( + AepValidation.inspectDocument(null), + AepValidation.claimValues(null), + AepValidation.enrollRequest(null), + AepValidation.enrollResponse(null), + AepValidation.statusResponse(null), + AepValidation.grantRequest(null), + AepValidation.revokeRequest(null), + AepValidation.clientAssertionClaims(null), + AepValidation.idempotencyMetadata(null), + AepValidation.problemDetails(null), + AepValidation.platformDiscoveryDocument(null), + AepValidation.platformAgentIdentity(null), + AepValidation.platformAgentIdentityListResponse(null), + AepValidation.platformProvisionRequest(null), + AepValidation.platformLifecycleRequest(null), + AepValidation.platformSignRequest(null), + AepValidation.platformSignResponse(null), + AepValidation.platformVerificationRequest(null), + AepValidation.platformVerificationResponse(null), + AepValidation.grantResponse(null), + AepValidation.protectedResourceAuthorization(null), + AepValidation.openApiSecurityScheme(null)); + for (List issues : results) { + assertEquals(List.of("$"), paths(issues)); + } + } + + @Test + void requireHelpersRaiseTypedFailures() { + AepValidationException failure = + assertThrows(AepValidationException.class, () -> AepValidation.requireInspectDocument(null)); + assertEquals("Inspect document", failure.documentType()); + assertEquals(List.of("$"), paths(failure.issues())); + assertNull(failure.getCause()); + assertTrue(failure.getMessage().contains("Inspect document")); + + assertThrows(AepValidationException.class, () -> AepValidation.requireClaimValues(null)); + assertThrows(AepValidationException.class, () -> AepValidation.requireClientAssertionClaims(null, false)); + assertThrows(AepValidationException.class, () -> AepValidation.requirePlatformDiscoveryDocument(null)); + assertThrows(AepValidationException.class, () -> AepValidation.requirePlatformAgentIdentity(null)); + assertThrows(AepValidationException.class, () -> AepValidation.requirePlatformSignResponse(null)); + + AepValidationException wrapped = new AepValidationException( + "thing", List.of(new ValidationIssue("$", "bad")), new IllegalStateException()); + assertNotNull(wrapped.getCause()); + + AepAuthorizationException authorization = new AepAuthorizationException("not_recognized", "message"); + assertEquals("not_recognized", authorization.code()); + assertEquals("message", authorization.getMessage()); + } + + @Test + void acceptsAFullyPopulatedInspectDocument() { + InspectDocument document = InspectDocument.builder() + .version(Aep.VERSION) + .authentication(new InspectDocument.Authentication(List.of("aep-jwt", "api-key"))) + .bindings(new InspectDocument.Bindings(List.of("http"))) + .claims(new InspectDocument.Claims( + List.of("contact.email"), List.of("person.first_name"), List.of("person.username"))) + .commands(new InspectDocument.Commands( + List.of("inspect", "enroll", "grant", "revoke", "status"), + List.of("api-key", "oauth-bearer"), + Map.of( + "api-key", + new InspectDocument.GrantTypeConfig( + null, + "300", + List.of("x-api-key"), + null, + List.of("read"), + "true", + null, + null), + "oauth-bearer", + new InspectDocument.GrantTypeConfig( + List.of("opaque", "jwt"), + "60", + null, + null, + List.of("read"), + "false", + "https://api.example/introspect", + "https://api.example/revoke")))) + .core(new InspectDocument.Core(List.of("EdDSA", "ES256"))) + .extensions(new InspectDocument.Extensions(List.of("https://example.com/aep-extension"))) + .http(new InspectDocument.Http( + "/v1/aep", + new InspectDocument.OpenApi( + "https://api.example/openapi.json", new InspectDocument.PathMatching("equivalent")))) + .identity(new InspectDocument.Identity(List.of("did:web"))) + .service(new InspectDocument.Service("did:web:service.example")) + .build(); + assertTrue(AepValidation.inspectDocument(document).isEmpty()); + assertSame(document, AepValidation.requireInspectDocument(document)); + } + + @Test + void reportsEveryInspectDocumentDefect() { + InspectDocument document = InspectDocument.builder() + .version("2.0") + .authentication(new InspectDocument.Authentication(List.of("Bad Method", "api-key", "api-key"))) + .bindings(new InspectDocument.Bindings(List.of("grpc"))) + .claims(new InspectDocument.Claims(List.of("Contact.Email"), List.of(), List.of())) + .commands(new InspectDocument.Commands( + List.of("inspect", "grant"), + List.of("api-key"), + Map.of( + "Not A Grant Type", + new InspectDocument.GrantTypeConfig( + List.of("saml"), + "0", + List.of("bad header", "X-Api-Key", "x-api-key"), + null, + List.of(), + "maybe", + "http://api.example/introspect", + null)))) + .core(new InspectDocument.Core(List.of())) + .extensions(new InspectDocument.Extensions(List.of("not-a-uri"))) + .http(new InspectDocument.Http( + "relative", new InspectDocument.OpenApi("", new InspectDocument.PathMatching("loose")))) + .identity(new InspectDocument.Identity(List.of("BadMethod"))) + .service(new InspectDocument.Service("service.example")) + .build(); + List reported = paths(AepValidation.inspectDocument(document)); + for (String expected : List.of( + "$.aep_version", + "$.authentication.methods[0]", + "$.bindings.supported", + "$.claims.required[0]", + "$.core.signing_algorithms", + "$.extensions.supported[0]", + "$.http.endpoint_base", + "$.http.openapi.url", + "$.http.openapi.path_matching.trailing_slash", + "$.identity.methods[0]", + "$.service.did")) { + assertTrue(reported.contains(expected), expected + " in " + reported); + } + } + + @Test + void reportsGrantTypeConfigurationDefects() { + InspectDocument document = InspectDocument.builder() + .version(Aep.VERSION) + .bindings(new InspectDocument.Bindings(List.of("http"))) + .commands(new InspectDocument.Commands( + List.of("inspect", "grant", "revoke"), + List.of("api-key", "oauth-bearer"), + Map.of( + "api-key", + new InspectDocument.GrantTypeConfig( + null, + null, + List.of("X-Api-Key", "x-api-key"), + null, + null, + "yes", + null, + null), + "oauth-bearer", + new InspectDocument.GrantTypeConfig( + List.of("saml"), + null, + null, + null, + null, + null, + "http://api.example/introspect", + null)))) + .core(new InspectDocument.Core(List.of("EdDSA", "ES256"))) + .http(new InspectDocument.Http("/aep/", null)) + .identity(new InspectDocument.Identity(List.of("did:web"))) + .service(new InspectDocument.Service("did:web:service.example")) + .build(); + List reported = paths(AepValidation.inspectDocument(document)); + assertTrue(reported.contains("$.commands.grant_types_config.api-key.header_names[1]"), reported.toString()); + assertTrue( + reported.contains("$.commands.grant_types_config.api-key.supports_per_credential_revoke"), + reported.toString()); + assertTrue( + reported.contains("$.commands.grant_types_config.oauth-bearer.access_token_formats[0]"), + reported.toString()); + assertTrue( + reported.contains("$.commands.grant_types_config.oauth-bearer.introspection_endpoint"), + reported.toString()); + } + + @Test + void reportsUnadvertisedGrantTypeConfiguration() { + Map configs = new LinkedHashMap<>(); + configs.put("basic", new InspectDocument.GrantTypeConfig("true")); + configs.put("Not A Grant Type", new InspectDocument.GrantTypeConfig("true")); + InspectDocument document = InspectDocument.builder() + .version(Aep.VERSION) + .bindings(new InspectDocument.Bindings(List.of("http"))) + .commands(new InspectDocument.Commands( + List.of("inspect", "grant", "revoke"), List.of("api-key"), configs)) + .core(new InspectDocument.Core(List.of("EdDSA", "ES256"))) + .http(new InspectDocument.Http("/aep/", null)) + .identity(new InspectDocument.Identity(List.of("did:web"))) + .service(new InspectDocument.Service("did:web:service.example")) + .build(); + List reported = paths(AepValidation.inspectDocument(document)); + assertTrue(reported.contains("$.commands.grant_types_config.basic"), reported.toString()); + assertTrue(reported.contains("$.commands.grant_types_config.Not A Grant Type"), reported.toString()); + } + + @Test + void recognizesCompatibleAndIncompatibleVersions() { + assertTrue(AepValidation.isCompatibleVersion("1.0")); + assertTrue(AepValidation.isCompatibleVersion("1.7")); + assertFalse(AepValidation.isCompatibleVersion("2.0")); + assertFalse(AepValidation.isCompatibleVersion("1")); + assertFalse(AepValidation.isCompatibleVersion("1.0.0")); + assertFalse(AepValidation.isCompatibleVersion("01.0")); + assertFalse(AepValidation.isCompatibleVersion(null)); + } + + @Test + void validatesClaimValueShapes() { + ClaimValues values = ClaimValues.builder() + .contactAddressPrimary(new ContactAddressPrimary( + "Springfield", "US", "Ada", "Lovelace", "1 Main Street", "Apt 2", "Floor 3", "12345", "IL")) + .contactEmail("agent@example.com") + .contactMobile("+14155550123") + .personBirthdate("1815-12-10") + .personFirstName("Ada") + .personLastName("Lovelace") + .personUsername("ada") + .additional("example.list", java.util.Arrays.asList(1, "two", true, null)) + .additional("example.nested", Map.of("inner", Map.of("deep", 1.5))) + .build(); + assertTrue(AepValidation.claimValues(values).isEmpty()); + assertTrue(values.contains(Aep.CLAIM_CONTACT_EMAIL)); + assertTrue(values.contains("example.list")); + assertFalse(values.contains("example.absent")); + assertSame(values, AepValidation.requireClaimValues(values)); + + ClaimValues invalidAddress = ClaimValues.builder() + .contactAddressPrimary(new ContactAddressPrimary("", "USA", "", "", "", null, null, null, null)) + .build(); + List reported = paths(AepValidation.claimValues(invalidAddress)); + assertTrue(reported.contains("$.contact.address.primary.city"), reported.toString()); + assertTrue(reported.contains("$.contact.address.primary.country"), reported.toString()); + assertTrue(reported.contains("$.contact.address.primary.first_name"), reported.toString()); + assertTrue(reported.contains("$.contact.address.primary.last_name"), reported.toString()); + assertTrue(reported.contains("$.contact.address.primary.line1"), reported.toString()); + } + + @Test + void rejectsClaimValuesThatAreNotJson() { + ClaimValues values = + ClaimValues.builder().additional("example.object", new Object()).build(); + assertEquals(List.of("$.example.object"), paths(AepValidation.claimValues(values))); + + ClaimValues infinite = ClaimValues.builder() + .additional("example.number", Double.POSITIVE_INFINITY) + .build(); + assertEquals(List.of("$.example.number"), paths(AepValidation.claimValues(infinite))); + + ClaimValues infiniteFloat = + ClaimValues.builder().additional("example.float", Float.NaN).build(); + assertEquals(List.of("$.example.float"), paths(AepValidation.claimValues(infiniteFloat))); + + ClaimValues nestedBad = ClaimValues.builder() + .additional("example.list", List.of(List.of(new Object()))) + .build(); + assertEquals(List.of("$.example.list[0][0]"), paths(AepValidation.claimValues(nestedBad))); + + Map nonStringKey = new LinkedHashMap<>(); + nonStringKey.put(1, "value"); + ClaimValues badKey = + ClaimValues.builder().additional("example.map", nonStringKey).build(); + assertEquals(List.of("$.example.map"), paths(AepValidation.claimValues(badKey))); + } + + @Test + void validatesDateAndTimeFormats() { + assertTrue(AepValidation.statusResponse( + new StatusResponse(AgentStatus.ACTIVE, null, null, null, "2026-09-01T12:00:00Z")) + .isEmpty()); + assertTrue(AepValidation.statusResponse( + new StatusResponse(AgentStatus.ACTIVE, null, null, null, "2026-09-01T12:00:00.123+01:00")) + .isEmpty()); + assertTrue(AepValidation.statusResponse(new StatusResponse(AgentStatus.ACTIVE, null, null, null, null)) + .isEmpty()); + for (String value : List.of("2026-09-01", "2026-09-01T12:00:00", "", "not-a-date")) { + assertFalse( + AepValidation.statusResponse(new StatusResponse(AgentStatus.ACTIVE, null, null, null, value)) + .isEmpty(), + value); + } + for (String birthdate : List.of("1815-12-10")) { + assertTrue(AepValidation.claimValues( + ClaimValues.builder().personBirthdate(birthdate).build()) + .isEmpty()); + } + for (String birthdate : List.of("1815-12-10T00:00:00Z", "815-12-10", "2025-02-29", "1815-1-1", "")) { + assertFalse( + AepValidation.claimValues(ClaimValues.builder() + .personBirthdate(birthdate) + .build()) + .isEmpty(), + birthdate); + } + } + + @Test + void validatesLifecycleMetadataAndStatusMessages() { + assertTrue(paths(AepValidation.enrollResponse(new EnrollResponse(null, null, null, null))) + .contains("$.status")); + assertTrue(paths(AepValidation.enrollResponse(new EnrollResponse(AgentStatus.ACTIVE, "maybe", null, null))) + .contains("$.owner_action_required")); + assertTrue(AepValidation.enrollResponse(new EnrollResponse(AgentStatus.ACTIVE, "false", null, null)) + .isEmpty()); + assertTrue(paths(AepValidation.statusResponse(new StatusResponse(null, null, null, null, null))) + .contains("$.status")); + assertTrue(paths(AepValidation.enrollResponse( + new EnrollResponse(AgentStatus.ACTIVE, null, List.of("a", "a"), null))) + .contains("$.verification_pending[1]")); + assertTrue(paths(AepValidation.enrollResponse( + new EnrollResponse(AgentStatus.ACTIVE, null, null, List.of("", "b")))) + .contains("$.requirements_pending[0]")); + } + + @Test + void validatesEnrollAndGrantRequests() { + assertTrue(paths(AepValidation.enrollRequest(new EnrollRequest("did:web:agent.example", null, ""))) + .contains("$.idempotency_key")); + assertTrue(paths(AepValidation.enrollRequest(new EnrollRequest( + "did:web:agent.example", + ClaimValues.builder().contactEmail("bad").build(), + null))) + .contains("$.claims.contact.email")); + assertTrue(paths(AepValidation.grantRequest(new GrantRequest("", null, null, null))) + .contains("$.grant_type")); + assertTrue(paths(AepValidation.grantRequest(new GrantRequest("oauth-bearer", null, null, "saml"))) + .contains("$.token_format")); + assertTrue(paths(AepValidation.grantRequest(new GrantRequest("api-key", null, null, ""))) + .contains("$.token_format")); + // The Grant request schema puts no minimum length on a requested scope, so an empty entry is + // the Service's business rather than a wire-shape defect. + assertTrue(AepValidation.grantRequest(new GrantRequest("api-key", null, List.of("", "read"), null)) + .isEmpty()); + assertTrue(AepValidation.grantRequest(new GrantRequest("oauth-bearer", null, List.of(), "opaque")) + .isEmpty()); + } + + @Test + void validatesIdempotencyMetadata() { + assertTrue(AepValidation.idempotencyMetadata(new IdempotencyMetadata( + "did:web:agent.example", "key-1", "sha256:" + "a".repeat(64), "sha256:" + "b".repeat(64))) + .isEmpty()); + assertTrue(AepValidation.idempotencyMetadata(new IdempotencyMetadata(null, "key-1", null, null)) + .isEmpty()); + List reported = paths(AepValidation.idempotencyMetadata( + new IdempotencyMetadata("", "", "sha256:short", "md5:" + "a".repeat(64)))); + assertTrue(reported.contains("$.agent_did"), reported.toString()); + assertTrue(reported.contains("$.idempotency_key"), reported.toString()); + assertTrue(reported.contains("$.first_body_hash"), reported.toString()); + assertTrue(reported.contains("$.second_body_hash"), reported.toString()); + } + + @Test + void validatesPlatformDiscoveryDocuments() { + PlatformDiscoveryDocument valid = new PlatformDiscoveryDocument( + Aep.VERSION, + new PlatformDiscoveryDocument.Endpoints( + "/v1/aep/verifications", + "/v1/aep/identities/{agent_identity_id}", + "/v1/aep/identities", + "/v1/aep/identities", + "/v1/aep/identities/{agent_identity_id}/sign"), + new PlatformDiscoveryDocument.Http("/v1/aep"), + new PlatformDiscoveryDocument.Identity( + List.of("did:web"), "https://platform.example/agents/{agent_did_id}/did.json"), + new PlatformDiscoveryDocument.Platform("did:web:platform.example", true, "Example Platform"), + new PlatformDiscoveryDocument.Signing(List.of("EdDSA", "ES256"), "300")); + assertTrue(AepValidation.platformDiscoveryDocument(valid).isEmpty()); + assertSame(valid, AepValidation.requirePlatformDiscoveryDocument(valid)); + + PlatformDiscoveryDocument missing = new PlatformDiscoveryDocument("2.0", null, null, null, null, null); + List reported = paths(AepValidation.platformDiscoveryDocument(missing)); + for (String expected : + List.of("$.aep_version", "$.endpoints", "$.http", "$.identity", "$.platform", "$.signing")) { + assertTrue(reported.contains(expected), expected + " in " + reported); + } + + PlatformDiscoveryDocument invalid = new PlatformDiscoveryDocument( + Aep.VERSION, + new PlatformDiscoveryDocument.Endpoints("relative", "/no-template", "", "/list?x=1", "/sign#y"), + new PlatformDiscoveryDocument.Http("//attacker.example"), + new PlatformDiscoveryDocument.Identity(List.of(), "https://platform.example/did.json"), + new PlatformDiscoveryDocument.Platform("platform.example", false, ""), + new PlatformDiscoveryDocument.Signing(List.of("HS256"), "0")); + List invalidPaths = paths(AepValidation.platformDiscoveryDocument(invalid)); + for (String expected : List.of( + "$.endpoints.hosted_verification", + "$.endpoints.lifecycle", + "$.endpoints.list", + "$.endpoints.provision", + "$.endpoints.sign", + "$.http.endpoint_base", + "$.identity.did_methods", + "$.identity.did_url_template", + "$.platform.did", + "$.platform.name", + "$.signing.algorithms", + "$.signing.default_lifetime_seconds")) { + assertTrue(invalidPaths.contains(expected), expected + " in " + invalidPaths); + } + } + + @Test + void validatesPlatformIdentityMessages() { + PlatformAgentIdentity identity = new PlatformAgentIdentity( + "did:web:platform.example:agents:one", + "pai_1", + "2026-09-01T12:00:00Z", + "https://platform.example/agents/one/did.json", + "did:web:platform.example:agents:one", + "did:web:service.example", + List.of("ES256"), + ManagedAgentStatus.ACTIVE, + "2026-09-01T12:00:00Z"); + assertTrue(AepValidation.platformAgentIdentity(identity).isEmpty()); + assertSame(identity, AepValidation.requirePlatformAgentIdentity(identity)); + + PlatformAgentIdentity broken = new PlatformAgentIdentity( + "agent", "", "not-a-date", "http://platform.example/did.json", "", "service", List.of(), null, ""); + List reported = paths(AepValidation.platformAgentIdentity(broken)); + for (String expected : List.of( + "$.agent_did", + "$.agent_identity_id", + "$.created_at", + "$.did_document_url", + "$.key_id", + "$.service_did", + "$.signing_algorithms", + "$.status", + "$.updated_at")) { + assertTrue(reported.contains(expected), expected + " in " + reported); + } + + assertTrue(AepValidation.platformAgentIdentityListResponse( + new PlatformAgentIdentityListResponse("1", List.of(identity), "4")) + .isEmpty()); + assertTrue(paths(AepValidation.platformAgentIdentityListResponse( + new PlatformAgentIdentityListResponse("2", List.of(identity), "4"))) + .contains("$.count")); + assertTrue(paths(AepValidation.platformAgentIdentityListResponse( + new PlatformAgentIdentityListResponse("1", List.of(identity), "0"))) + .contains("$.total")); + assertTrue(paths(AepValidation.platformAgentIdentityListResponse( + new PlatformAgentIdentityListResponse("-1", List.of(), "x"))) + .contains("$.count")); + // The record copies an absent list to an empty one, so an absent data member surfaces as a + // count that disagrees with the entries rather than as a missing array. + assertTrue(paths(AepValidation.platformAgentIdentityListResponse( + new PlatformAgentIdentityListResponse(null, null, null))) + .contains("$.count")); + assertTrue(paths(AepValidation.platformAgentIdentityListResponse( + new PlatformAgentIdentityListResponse("1", List.of(broken), "1"))) + .contains("$.data[0].agent_did")); + } + + @Test + void validatesPlatformRequestsAndResponses() { + assertTrue(AepValidation.platformProvisionRequest(new PlatformProvisionRequest("did:web:service.example")) + .isEmpty()); + assertTrue(paths(AepValidation.platformProvisionRequest(new PlatformProvisionRequest("service.example"))) + .contains("$.service_did")); + + assertTrue(AepValidation.platformLifecycleRequest(new PlatformLifecycleRequest(ManagedAgentStatus.REVOKED)) + .isEmpty()); + assertTrue(paths(AepValidation.platformLifecycleRequest(new PlatformLifecycleRequest(null))) + .contains("$.status")); + + assertTrue(AepValidation.platformSignRequest(new PlatformSignRequest( + "jti-1", + "60", + AssertionOperation.STATUS, + Map.of("tenant", "acme"), + null, + "did:web:service.example")) + .isEmpty()); + List signPaths = paths(AepValidation.platformSignRequest( + new PlatformSignRequest("", "0", null, null, "https://api.example/x", "service.example"))); + for (String expected : List.of("$.jti", "$.lifetime_seconds", "$.op", "$.service_did")) { + assertTrue(signPaths.contains(expected), expected + " in " + signPaths); + } + assertTrue(paths(AepValidation.platformSignRequest(new PlatformSignRequest( + "jti-1", null, AssertionOperation.AUTHENTICATE, null, null, "did:web:service.example"))) + .contains("$.resource")); + + assertTrue(AepValidation.platformVerificationRequest(new PlatformVerificationRequest( + "assertion", AssertionOperation.STATUS, null, "did:web:service.example")) + .isEmpty()); + assertTrue(paths(AepValidation.platformVerificationRequest( + new PlatformVerificationRequest("", null, "https://api.example/x", "service.example"))) + .contains("$.client_assertion")); + } + + @Test + void validatesPlatformSignResponses() { + PlatformSignResponses.Completed completed = new PlatformSignResponses.Completed( + "completed", + "did:web:platform.example:agents:one", + "assertion", + "2026-09-01T12:05:00Z", + "2026-09-01T12:00:00Z", + "jti-1", + Map.of("tenant", "acme"), + "did:web:service.example"); + assertTrue(AepValidation.platformSignResponse(completed).isEmpty()); + assertSame(completed, AepValidation.requirePlatformSignResponse(completed)); + assertEquals("completed", completed.status()); + assertEquals(Map.of("tenant", "acme"), completed.platformContext()); + + List reported = paths(AepValidation.platformSignResponse(new PlatformSignResponses.Completed( + "pending", "agent", "", "not-a-date", "not-a-date", "", null, "service"))); + for (String expected : List.of( + "$.status", + "$.agent_did", + "$.client_assertion", + "$.expires_at", + "$.issued_at", + "$.jti", + "$.service_did")) { + assertTrue(reported.contains(expected), expected + " in " + reported); + } + + PlatformSignResponses.Pending pending = new PlatformSignResponses.Pending("pending", null, "5"); + assertTrue(AepValidation.platformSignResponse(pending).isEmpty()); + assertNull(pending.platformContext()); + assertEquals("pending", pending.status()); + List pendingPaths = + paths(AepValidation.platformSignResponse(new PlatformSignResponses.Pending("done", null, "301"))); + assertTrue(pendingPaths.contains("$.status"), pendingPaths.toString()); + assertTrue(pendingPaths.contains("$.retry_after_seconds"), pendingPaths.toString()); + for (String retry : List.of("0", "+5", "05", "", "abc", "5.0")) { + assertFalse( + AepValidation.platformSignResponse(new PlatformSignResponses.Pending("pending", null, retry)) + .isEmpty(), + retry); + } + } + + @Test + void validatesAuthorizationAndSecuritySchemeDocuments() { + assertTrue(AepValidation.protectedResourceAuthorization(new ProtectedResourceAuthorization( + AuthorizationCarrier.STANDARD, AuthorizationScheme.AEP, "token")) + .isEmpty()); + List reported = + paths(AepValidation.protectedResourceAuthorization(new ProtectedResourceAuthorization(null, null, ""))); + assertTrue(reported.contains("$.carrier"), reported.toString()); + assertTrue(reported.contains("$.scheme"), reported.toString()); + assertTrue(reported.contains("$.credentials"), reported.toString()); + + assertTrue(AepValidation.openApiSecurityScheme(new OpenApiAepSecurityScheme("aep-jwt")) + .isEmpty()); + assertTrue(paths(AepValidation.openApiSecurityScheme(new OpenApiAepSecurityScheme("Not A Method"))) + .contains("$.x-aep-authentication-method")); + assertTrue(paths(AepValidation.openApiSecurityScheme(new OpenApiAepSecurityScheme(null))) + .contains("$.x-aep-authentication-method")); + } + + @Test + void resolvesAndRejectsOpenApiReferences() { + URI inspect = URI.create("https://api.example/.well-known/aep"); + assertEquals( + URI.create("https://api.example/openapi.json"), + AepOpenApi.resolveDocumentUri(inspect, "/openapi.json")); + assertEquals( + URI.create("https://docs.example/openapi.json"), + AepOpenApi.resolveDocumentUri(inspect, "https://docs.example/openapi.json")); + assertThrows(NullPointerException.class, () -> AepOpenApi.resolveDocumentUri(null, "/openapi.json")); + for (String reference : List.of("", " ")) { + assertThrows(IllegalArgumentException.class, () -> AepOpenApi.resolveDocumentUri(inspect, reference)); + } + assertThrows(IllegalArgumentException.class, () -> AepOpenApi.resolveDocumentUri(inspect, null)); + assertThrows( + IllegalArgumentException.class, + () -> AepOpenApi.resolveDocumentUri(inspect, "http://docs.example/openapi.json")); + assertThrows( + IllegalArgumentException.class, + () -> AepOpenApi.resolveDocumentUri(inspect, "https://user:pw@docs.example/openapi.json")); + assertThrows( + IllegalArgumentException.class, + () -> AepOpenApi.resolveDocumentUri(inspect, "https://docs.example/openapi.json#section")); + assertThrows( + IllegalArgumentException.class, + () -> AepOpenApi.resolveDocumentUri(URI.create("/relative"), "/openapi.json")); + assertEquals( + URI.create("http://localhost:8080/openapi.json"), + AepOpenApi.resolveDocumentUri( + URI.create("http://localhost:8080/.well-known/aep"), "/openapi.json", true)); + assertEquals( + URI.create("http://[::1]/openapi.json"), + AepOpenApi.resolveDocumentUri(URI.create("http://[::1]/.well-known/aep"), "/openapi.json", true)); + assertEquals( + URI.create("http://127.0.0.1/openapi.json"), + AepOpenApi.resolveDocumentUri(URI.create("http://127.0.0.1/.well-known/aep"), "/openapi.json", true)); + } + + @Test + void matchesOpenApiPathTemplates() { + List templates = List.of("/items", "/items/{id}", "/items/latest", "?bad", "/deep/{a}/{b}"); + assertEquals( + "/items", + AepOpenApi.matchPath(templates, "get", "/items", AepOpenApi.TrailingSlashMode.STRICT) + .template()); + assertEquals( + "GET", + AepOpenApi.matchPath(templates, "get", "/items", AepOpenApi.TrailingSlashMode.STRICT) + .method()); + assertEquals( + "/items/latest", + AepOpenApi.matchPath(templates, "GET", "/items/latest", AepOpenApi.TrailingSlashMode.STRICT) + .template()); + assertEquals( + "/items/{id}", + AepOpenApi.matchPath(templates, "GET", "/items/7", AepOpenApi.TrailingSlashMode.STRICT) + .template()); + assertEquals( + "/deep/{a}/{b}", + AepOpenApi.matchPath(templates, "GET", "/deep/one/two?q=1", AepOpenApi.TrailingSlashMode.STRICT) + .template()); + assertThrows( + IllegalArgumentException.class, + () -> AepOpenApi.matchPath(templates, "GET", "/items/", AepOpenApi.TrailingSlashMode.STRICT)); + assertEquals( + "/items", + AepOpenApi.matchPath(templates, "GET", "/items/", AepOpenApi.TrailingSlashMode.EQUIVALENT) + .template()); + assertThrows( + IllegalArgumentException.class, + () -> AepOpenApi.matchPath(templates, "GET", "/absent", AepOpenApi.TrailingSlashMode.STRICT)); + assertThrows( + IllegalArgumentException.class, + () -> AepOpenApi.matchPath( + List.of("/items/{id}", "/items/{name}"), + "GET", + "/items/7", + AepOpenApi.TrailingSlashMode.STRICT)); + assertThrows( + IllegalArgumentException.class, + () -> AepOpenApi.matchPath(templates, " ", "/items", AepOpenApi.TrailingSlashMode.STRICT)); + assertThrows( + IllegalArgumentException.class, + () -> AepOpenApi.matchPath(templates, null, "/items", AepOpenApi.TrailingSlashMode.STRICT)); + assertThrows( + IllegalArgumentException.class, + () -> AepOpenApi.matchPath(templates, "GET", "relative", AepOpenApi.TrailingSlashMode.STRICT)); + assertThrows( + IllegalArgumentException.class, + () -> AepOpenApi.matchPath(templates, "GET", " ", AepOpenApi.TrailingSlashMode.STRICT)); + assertThrows( + NullPointerException.class, + () -> AepOpenApi.matchPath(null, "GET", "/items", AepOpenApi.TrailingSlashMode.STRICT)); + assertThrows(NullPointerException.class, () -> AepOpenApi.matchPath(templates, "GET", "/items", null)); + // A template segment that is only braces is a literal, not a parameter. + assertThrows( + IllegalArgumentException.class, + () -> AepOpenApi.matchPath(List.of("/{}"), "GET", "/value", AepOpenApi.TrailingSlashMode.STRICT)); + // An empty request segment never satisfies a parameter. + assertThrows( + IllegalArgumentException.class, + () -> AepOpenApi.matchPath( + List.of("/items/{id}"), "GET", "/items//", AepOpenApi.TrailingSlashMode.STRICT)); + } + + @Test + void resolvesDidWebDocumentsAndOrigins() { + assertEquals(URI.create("https://example.com/.well-known/did.json"), DidWeb.documentUri("did:web:example.com")); + assertEquals( + URI.create("http://localhost:8080/.well-known/did.json"), + DidWeb.documentUri("did:web:localhost%3A8080", true)); + assertEquals( + URI.create("http://127.0.0.1/.well-known/did.json"), DidWeb.documentUri("did:web:127.0.0.1", true)); + assertEquals( + URI.create("https://127.0.0.1/.well-known/did.json"), DidWeb.documentUri("did:web:127.0.0.1", false)); + for (String did : List.of("", "did:key:z6Mk", "did:web:", "web:example.com")) { + assertThrows(IllegalArgumentException.class, () -> DidWeb.documentUri(did), did); + } + assertThrows(IllegalArgumentException.class, () -> DidWeb.documentUri(null)); + assertThrows(IllegalArgumentException.class, () -> DidWeb.documentUri("did:web:example.com:%ZZ")); + + assertTrue(DidWeb.bindsOrigin("did:web:example.com", URI.create("https://example.com/x"))); + assertTrue(DidWeb.bindsOrigin("did:web:EXAMPLE.com", URI.create("https://example.com:443/x"))); + assertTrue(DidWeb.bindsOrigin("did:web:example.com%3A8443", URI.create("https://example.com:8443/x"))); + assertFalse(DidWeb.bindsOrigin("did:web:example.com", URI.create("https://other.example/x"))); + assertFalse(DidWeb.bindsOrigin("did:web:example.com", URI.create("http://example.com/x"))); + assertFalse(DidWeb.bindsOrigin("did:web:example.com%3A8443", URI.create("https://example.com/x"))); + assertFalse(DidWeb.bindsOrigin("did:web:example.com", URI.create("/relative"))); + assertFalse(DidWeb.bindsOrigin("did:web:example.com", null)); + } + + @Test + void copiesAndRedactsTransportMessages() { + byte[] body = "payload".getBytes(java.nio.charset.StandardCharsets.UTF_8); + Map> headers = new LinkedHashMap<>(); + headers.put("Authorization", new ArrayList<>(List.of("AEP token"))); + AepHttpTransport.Request request = new AepHttpTransport.Request( + "POST", URI.create("https://api.example/aep/enroll"), headers, body, Duration.ofSeconds(5)); + body[0] = 'X'; + assertEquals("payload", new String(request.body(), java.nio.charset.StandardCharsets.UTF_8)); + assertFalse(request.toString().contains("api.example")); + assertFalse(request.toString().contains("AEP token")); + assertTrue(request.toString().contains("bodyLength=7")); + + AepHttpTransport.Request empty = + new AepHttpTransport.Request("GET", URI.create("https://api.example/"), null, null, null); + assertEquals(0, empty.body().length); + assertEquals(Map.of(), empty.headers()); + + AepHttpTransport.Response response = new AepHttpTransport.Response(200, headers, null); + assertEquals(0, response.body().length); + assertFalse(response.toString().contains("AEP token")); + assertTrue(response.toString().contains("status=200")); + assertEquals( + 7, + new AepHttpTransport.Response(200, null, "payload".getBytes(java.nio.charset.StandardCharsets.UTF_8)) + .body() + .length); + } + + @Test + void redactsIssuedCredentialsFromDiagnostics() { + assertFalse(new GrantResponses.OAuthBearer("secret", "credential-1", "2026-09-01T12:00:00Z", null, "Bearer") + .toString() + .contains("secret")); + assertFalse(new GrantResponses.ApiKey("secret", "credential-1", "2026-09-01T12:00:00Z", "x-api-key", null) + .toString() + .contains("secret")); + assertFalse(new GrantResponses.Basic("credential-1", "2026-09-01T12:00:00Z", "secret", null, null, "user") + .toString() + .contains("secret")); + } + + @Test + void evaluatesClaimSupport() { + InspectDocument.Claims requested = new InspectDocument.Claims( + List.of("contact.email", "person.birthdate"), List.of("person.first_name"), List.of("person.username")); + ClaimSupport.Evaluation evaluation = + ClaimSupport.evaluate(requested, List.of("contact.email", "person.first_name")); + assertFalse(evaluation.canSatisfyRequired()); + assertEquals(List.of("person.birthdate"), evaluation.unsupportedRequired()); + assertEquals(List.of("person.first_name"), evaluation.supportedPreferred()); + assertEquals(List.of(), evaluation.supportedOptional()); + + ClaimSupport.Evaluation none = ClaimSupport.evaluate(null, List.of("contact.email")); + assertTrue(none.canSatisfyRequired()); + + ClaimValues values = + ClaimValues.builder().contactEmail("agent@example.com").build(); + assertEquals( + List.of("person.birthdate"), + ClaimSupport.missingRequired(List.of("contact.email", "person.birthdate"), values)); + assertEquals(List.of("contact.email"), ClaimSupport.missingRequired(List.of("contact.email"), null)); + } + + @Test + void translatesRegisteredEnumValues() { + assertEquals(AgentStatus.ACTIVE, AgentStatus.fromValue("active")); + assertEquals("unavailable", AgentStatus.UNAVAILABLE.value()); + assertThrows(IllegalArgumentException.class, () -> AgentStatus.fromValue("archived")); + assertEquals(ManagedAgentStatus.TERMINATED, ManagedAgentStatus.fromValue("terminated")); + assertEquals("revoked", ManagedAgentStatus.REVOKED.value()); + assertThrows(IllegalArgumentException.class, () -> ManagedAgentStatus.fromValue("archived")); + assertEquals(AssertionOperation.AUTHENTICATE, AssertionOperation.fromValue("authenticate")); + assertEquals("revoke", AssertionOperation.REVOKE.value()); + assertThrows(IllegalArgumentException.class, () -> AssertionOperation.fromValue("inspect")); + assertEquals("status", AepCommand.STATUS.value()); + assertEquals("inspect", AepCommand.INSPECT.value()); + assertEquals("Authorization", AuthorizationCarrier.STANDARD.value()); + assertEquals(Aep.AUTHORIZATION_HEADER, AuthorizationCarrier.DEDICATED.value()); + assertEquals("AEP", AuthorizationScheme.AEP.value()); + } + + @Test + void buildsVerificationConfiguration() { + Clock clock = Clock.fixed(Instant.parse("2026-09-01T12:00:00Z"), ZoneOffset.UTC); + ClientAssertionVerification verification = ClientAssertionVerification.builder( + "did:web:service.example", "did:web:agent.example", AssertionOperation.AUTHENTICATE) + .resource("https://api.example/x") + .clock(clock) + .clockSkew(Duration.ofSeconds(10)) + .allowInsecureLoopback(true) + .build(); + assertEquals("did:web:service.example", verification.audience()); + assertEquals("did:web:agent.example", verification.issuer()); + assertEquals(AssertionOperation.AUTHENTICATE, verification.operation()); + assertEquals("https://api.example/x", verification.resource()); + assertSame(clock, verification.clock()); + assertEquals(Duration.ofSeconds(10), verification.clockSkew()); + assertTrue(verification.allowInsecureLoopback()); + + assertThrows( + IllegalArgumentException.class, + () -> ClientAssertionVerification.builder(null, "did:web:agent.example", AssertionOperation.STATUS) + .build()); + assertThrows( + IllegalArgumentException.class, + () -> ClientAssertionVerification.builder("did:web:service.example", null, AssertionOperation.STATUS) + .build()); + assertThrows( + IllegalArgumentException.class, + () -> ClientAssertionVerification.builder("did:web:service.example", "did:web:agent.example", null) + .build()); + assertThrows( + IllegalArgumentException.class, + () -> ClientAssertionVerification.builder( + "did:web:service.example", "did:web:agent.example", AssertionOperation.STATUS) + .clockSkew(Duration.ofSeconds(-1)) + .build()); + assertThrows( + NullPointerException.class, + () -> ClientAssertionVerification.builder( + "did:web:service.example", "did:web:agent.example", AssertionOperation.STATUS) + .clock(null)); + assertThrows( + NullPointerException.class, + () -> ClientAssertionVerification.builder( + "did:web:service.example", "did:web:agent.example", AssertionOperation.STATUS) + .clockSkew(null)); + } +} diff --git a/aep-core/src/test/java/foundation/aep/core/AepCoreEdgeTest.java b/aep-core/src/test/java/foundation/aep/core/AepCoreEdgeTest.java new file mode 100644 index 0000000..67d0fa3 --- /dev/null +++ b/aep-core/src/test/java/foundation/aep/core/AepCoreEdgeTest.java @@ -0,0 +1,563 @@ +package foundation.aep.core; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.jwk.Curve; +import com.nimbusds.jose.jwk.JWK; +import com.nimbusds.jose.jwk.gen.ECKeyGenerator; +import com.nimbusds.jose.jwk.gen.OctetKeyPairGenerator; +import com.nimbusds.jose.jwk.gen.RSAKeyGenerator; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +/** Failure paths that only a malformed input or an unusual key reaches. */ +final class AepCoreEdgeTest { + private static final Instant NOW = Instant.parse("2026-09-01T12:00:00Z"); + private static final String AGENT = "did:web:agent.example"; + private static final String SERVICE = "did:web:service.example"; + + @Test + void jsonUseWithoutAnAdapterReportsTheMissingDependency() { + ExceptionInInitializerError failure = + assertThrows(ExceptionInInitializerError.class, () -> AepJson.parseObject("{}", "example")); + assertTrue(failure.getCause() instanceof IllegalStateException); + assertEquals( + "Exactly one AEP JSON adapter is required; found 0.", + failure.getCause().getMessage()); + } + + private static List paths(List issues) { + return issues.stream().map(ValidationIssue::path).toList(); + } + + private static ClientAssertionClaims claims(AssertionOperation operation, String resource) { + return new ClientAssertionClaims( + AGENT, + AGENT, + SERVICE, + operation, + NOW.getEpochSecond(), + NOW.plusSeconds(300).getEpochSecond(), + "assertion-1", + resource); + } + + private static ClientAssertionVerification verification(AssertionOperation operation, String resource) { + return ClientAssertionVerification.builder(SERVICE, AGENT, operation) + .resource(resource) + .clock(Clock.fixed(NOW, ZoneOffset.UTC)) + .clockSkew(Duration.ZERO) + .build(); + } + + /** The verification key decides the algorithm, so a token cannot choose a weaker one. */ + @Test + void bindsTheAlgorithmToTheVerificationKey() throws JOSEException { + JWK ecKey = new ECKeyGenerator(Curve.P_256).generate(); + JWK edKey = new OctetKeyPairGenerator(Curve.Ed25519).generate(); + String ecToken = ClientAssertions.sign(claims(AssertionOperation.ENROLL, null), ecKey, AGENT); + + assertEquals( + claims(AssertionOperation.ENROLL, null), + ClientAssertions.verify(ecToken, ecKey.toPublicJWK(), verification(AssertionOperation.ENROLL, null))); + // An EdDSA key never verifies an ES256 token, and the failure is the common one. + assertThrows( + IllegalArgumentException.class, + () -> ClientAssertions.verify( + ecToken, edKey.toPublicJWK(), verification(AssertionOperation.ENROLL, null))); + // A second key of the same type does not verify another Agent's signature. + JWK otherKey = new ECKeyGenerator(Curve.P_256).generate(); + assertThrows( + IllegalArgumentException.class, + () -> ClientAssertions.verify( + ecToken, otherKey.toPublicJWK(), verification(AssertionOperation.ENROLL, null))); + } + + /** AEP assertions are signed with P-256 or Ed25519; nothing else is a usable key. */ + @Test + void rejectsUnusableSigningAndVerificationKeys() throws JOSEException { + JWK rsaKey = new RSAKeyGenerator(2048).generate(); + JWK p384Key = new ECKeyGenerator(Curve.P_384).generate(); + JWK x25519Key = new OctetKeyPairGenerator(Curve.X25519).generate(); + for (JWK key : List.of(rsaKey, p384Key, x25519Key)) { + assertThrows( + IllegalArgumentException.class, + () -> ClientAssertions.sign(claims(AssertionOperation.STATUS, null), key, AGENT), + key.getKeyType().toString()); + assertThrows( + IllegalArgumentException.class, + () -> ClientAssertions.verify("a.b.c", key, verification(AssertionOperation.STATUS, null)), + key.getKeyType().toString()); + } + } + + /** CORE-CA-008: the DID portion of kid must be the Agent the claims name. */ + @Test + void requiresTheKeyIdToNameTheClaimedAgent() throws JOSEException { + JWK key = new ECKeyGenerator(Curve.P_256).generate(); + assertThrows( + IllegalArgumentException.class, + () -> ClientAssertions.sign(claims(AssertionOperation.STATUS, null), key, "did:web:other.example")); + assertThrows( + IllegalArgumentException.class, + () -> ClientAssertions.sign( + new ClientAssertionClaims( + AGENT, + "did:web:other.example", + SERVICE, + AssertionOperation.STATUS, + NOW.getEpochSecond(), + NOW.plusSeconds(60).getEpochSecond(), + "j", + null), + key, + AGENT)); + // A fragment selects a verification method and does not change the DID. + String token = ClientAssertions.sign(claims(AssertionOperation.STATUS, null), key, AGENT + "#key-1"); + assertEquals( + AGENT, + ClientAssertions.verify(token, key.toPublicJWK(), verification(AssertionOperation.STATUS, null)) + .issuer()); + // An absent key identifier falls back to the issuer. + String implied = ClientAssertions.sign(claims(AssertionOperation.STATUS, null), key, null); + assertEquals( + AGENT, + ClientAssertions.verify(implied, key.toPublicJWK(), verification(AssertionOperation.STATUS, null)) + .issuer()); + } + + /** Claims that do not validate never leave the signer, and never come back from a verifier. */ + @Test + void refusesClaimsThatDoNotValidate() throws JOSEException { + JWK key = new ECKeyGenerator(Curve.P_256).generate(); + ClientAssertionClaims overlong = new ClientAssertionClaims( + AGENT, + AGENT, + SERVICE, + AssertionOperation.STATUS, + NOW.getEpochSecond(), + NOW.plusSeconds(301).getEpochSecond(), + "assertion-1", + null); + AepValidationException failure = + assertThrows(AepValidationException.class, () -> ClientAssertions.sign(overlong, key, AGENT)); + assertEquals("client assertion claims", failure.documentType()); + + // An authenticate assertion needs a resource the loopback opt-in agrees with. + ClientAssertionClaims loopback = claims(AssertionOperation.AUTHENTICATE, "http://localhost/x"); + assertThrows(AepValidationException.class, () -> ClientAssertions.sign(loopback, key, AGENT)); + String signed = ClientAssertions.sign(loopback, key, AGENT, true); + assertEquals( + loopback, + ClientAssertions.verify( + signed, + key.toPublicJWK(), + ClientAssertionVerification.builder(SERVICE, AGENT, AssertionOperation.AUTHENTICATE) + .resource("http://localhost/x") + .clock(Clock.fixed(NOW, ZoneOffset.UTC)) + .clockSkew(Duration.ZERO) + .allowInsecureLoopback(true) + .build())); + // The same token is not recognized when the verifier does not permit loopback. + assertThrows( + AepValidationException.class, + () -> ClientAssertions.verify( + signed, + key.toPublicJWK(), + ClientAssertionVerification.builder(SERVICE, AGENT, AssertionOperation.AUTHENTICATE) + .resource("http://localhost/x") + .clock(Clock.fixed(NOW, ZoneOffset.UTC)) + .clockSkew(Duration.ZERO) + .build())); + } + + @Test + void rejectsTokensThatAreNotSignedAepAssertions() throws JOSEException { + JWK key = new ECKeyGenerator(Curve.P_256).generate(); + for (String token : List.of("", "not-a-token", "a.b.c", "a.b")) { + assertThrows( + IllegalArgumentException.class, + () -> ClientAssertions.verify( + token, key.toPublicJWK(), verification(AssertionOperation.STATUS, null)), + token); + assertThrows(IllegalArgumentException.class, () -> ClientAssertions.decodeUnverified(token), token); + } + String token = ClientAssertions.sign(claims(AssertionOperation.STATUS, null), key, AGENT); + assertEquals(claims(AssertionOperation.STATUS, null), ClientAssertions.decodeUnverified(token)); + // The operation and the clock are both part of recognition. + assertThrows( + IllegalArgumentException.class, + () -> ClientAssertions.verify(token, key.toPublicJWK(), verification(AssertionOperation.GRANT, null))); + assertThrows( + IllegalArgumentException.class, + () -> ClientAssertions.verify( + token, + key.toPublicJWK(), + ClientAssertionVerification.builder(SERVICE, AGENT, AssertionOperation.STATUS) + .clock(Clock.fixed(NOW.plusSeconds(3_600), ZoneOffset.UTC)) + .clockSkew(Duration.ZERO) + .build())); + assertThrows( + IllegalArgumentException.class, + () -> ClientAssertions.verify( + token, + key.toPublicJWK(), + ClientAssertionVerification.builder(SERVICE, AGENT, AssertionOperation.STATUS) + .clock(Clock.fixed(NOW.minusSeconds(3_600), ZoneOffset.UTC)) + .clockSkew(Duration.ZERO) + .build())); + } + + @Test + void reportsAbsentInspectSubObjects() { + InspectDocument document = InspectDocument.builder() + .version(Aep.VERSION) + .service(new InspectDocument.Service("did:web:service.example")) + .build(); + List reported = paths(AepValidation.inspectDocument(document)); + for (String expected : List.of("$.bindings", "$.commands", "$.core", "$.http", "$.identity")) { + assertTrue(reported.contains(expected), expected + " in " + reported); + } + assertTrue(paths(AepValidation.inspectDocument( + InspectDocument.builder().version(Aep.VERSION).build())) + .contains("$.service.did")); + } + + /** CORE-INS: the advertised authentication method list is bounded. */ + @Test + void boundsTheAdvertisedAuthenticationMethods() { + List methods = new ArrayList<>(); + for (int index = 0; index < 17; index++) { + methods.add("method-" + index); + } + InspectDocument document = InspectDocument.builder() + .version(Aep.VERSION) + .authentication(new InspectDocument.Authentication(methods)) + .bindings(new InspectDocument.Bindings(List.of("http"))) + .commands(new InspectDocument.Commands(List.of("inspect"), List.of(), Map.of())) + .core(new InspectDocument.Core(List.of("EdDSA", "ES256"))) + .http(new InspectDocument.Http("/aep/", null)) + .identity(new InspectDocument.Identity(List.of("did:web"))) + .service(new InspectDocument.Service("did:web:service.example")) + .build(); + assertTrue(paths(AepValidation.inspectDocument(document)).contains("$.authentication.methods")); + } + + @Test + void validatesMailboxLocalAndDomainForms() { + for (String mailbox : List.of( + "\"escaped \\\" quote\"@example.com", + "\"with@at\"@example.com", + "agent@[IPv6:2001:db8::1]", + "agent@[IPv6:::]", + "agent@[0.0.0.0]", + "a.b.c@example.co.uk", + "agent@[01.2.3.4]")) { + assertTrue( + AepValidation.claimValues( + ClaimValues.builder().contactEmail(mailbox).build()) + .isEmpty(), + mailbox); + } + for (String mailbox : List.of( + "\"unterminated@example.com", + "\"quote\"x@example.com", + "\"\\\u0001\"@example.com", + "\"ctl\u0001\"@example.com", + "\"trailing\\\\\"@example.com".replace("\\\\", "\\"), + "agent@[IPv6:2001:db8::1::2]", + "agent@[IPv6:2001:db8:0:0:0:0:0:0:1]", + "agent@[256.0.0.1]", + "agent@[1.2.3]", + "agent@[1.2.3.4.5]", + "agent@[1234.2.3.4]", + "agent@example..com", + "a".repeat(65) + "@example.com", + "agent@" + ("a".repeat(63) + ".").repeat(4) + "example.com")) { + assertFalse( + AepValidation.claimValues( + ClaimValues.builder().contactEmail(mailbox).build()) + .isEmpty(), + mailbox); + } + } + + @Test + void validatesIpv4MappedAndCompressedIpv6Literals() { + for (String literal : List.of("[IPv6:::ffff:127.0.0.1]", "[IPv6:2001:db8::]", "[IPv6:::1]")) { + assertTrue( + AepValidation.claimValues(ClaimValues.builder() + .contactEmail("agent@" + literal) + .build()) + .isEmpty(), + literal); + } + for (String literal : List.of("[IPv6:127.0.0.1:1]", "[IPv6:gggg::1]", "[IPv6:]", "[IPv6:1:2:3:4:5:6:7:8:9]")) { + assertFalse( + AepValidation.claimValues(ClaimValues.builder() + .contactEmail("agent@" + literal) + .build()) + .isEmpty(), + literal); + } + } + + @Test + void rejectsValuesThatAreNotParseableUris() { + // An unparseable URI is reported rather than thrown from every URI-shaped validator. + assertFalse(AepValidation.clientAssertionClaims(new ClientAssertionClaims( + AGENT, AGENT, SERVICE, AssertionOperation.AUTHENTICATE, 1_000, 1_060, "j", "http://[bad")) + .isEmpty()); + assertTrue(paths(AepValidation.platformAgentIdentity(new PlatformAgentIdentity( + AGENT, + "pai_1", + "2026-09-01T12:00:00Z", + "https://[bad", + AGENT, + SERVICE, + List.of("ES256"), + ManagedAgentStatus.ACTIVE, + "2026-09-01T12:00:00Z"))) + .contains("$.did_document_url")); + PlatformDiscoveryDocument discovery = new PlatformDiscoveryDocument( + Aep.VERSION, + new PlatformDiscoveryDocument.Endpoints( + null, "/x/{agent_identity_id}", "/x", "/x", "/x/{agent_identity_id}"), + new PlatformDiscoveryDocument.Http("/x"), + new PlatformDiscoveryDocument.Identity(List.of("did:web"), "https://[bad/{agent_did_id}"), + new PlatformDiscoveryDocument.Platform(null, false, "Example"), + new PlatformDiscoveryDocument.Signing(List.of("EdDSA", "ES256"), "300")); + assertTrue(paths(AepValidation.platformDiscoveryDocument(discovery)).contains("$.identity.did_url_template")); + } + + @Test + void validatesNumericStringsAcrossTheirRanges() { + InspectDocument.GrantTypeConfig config = new InspectDocument.GrantTypeConfig( + null, "99999999999999999999999", null, null, null, "true", null, null); + InspectDocument document = InspectDocument.builder() + .version(Aep.VERSION) + .bindings(new InspectDocument.Bindings(List.of("http"))) + .commands(new InspectDocument.Commands( + List.of("inspect", "grant", "revoke"), List.of("api-key"), Map.of("api-key", config))) + .core(new InspectDocument.Core(List.of("EdDSA", "ES256"))) + .http(new InspectDocument.Http("/aep/", null)) + .identity(new InspectDocument.Identity(List.of("did:web"))) + .service(new InspectDocument.Service("did:web:service.example")) + .build(); + // A default lifetime is an unbounded positive integer string at this level. + assertTrue(AepValidation.inspectDocument(document).isEmpty()); + + for (String value : List.of("0", "-1", "+1", "01", "", "x", "1.0")) { + InspectDocument.GrantTypeConfig invalid = + new InspectDocument.GrantTypeConfig(null, value, null, null, null, "true", null, null); + InspectDocument bad = InspectDocument.builder() + .version(Aep.VERSION) + .bindings(new InspectDocument.Bindings(List.of("http"))) + .commands(new InspectDocument.Commands( + List.of("inspect", "grant", "revoke"), List.of("api-key"), Map.of("api-key", invalid))) + .core(new InspectDocument.Core(List.of("EdDSA", "ES256"))) + .http(new InspectDocument.Http("/aep/", null)) + .identity(new InspectDocument.Identity(List.of("did:web"))) + .service(new InspectDocument.Service("did:web:service.example")) + .build(); + assertTrue( + paths(AepValidation.inspectDocument(bad)) + .contains("$.commands.grant_types_config.api-key.default_lifetime_seconds"), + value); + } + } + + @Test + void reportsEveryRegisteredClaimByName() { + ClaimValues values = ClaimValues.builder() + .contactAddressPrimary(new ContactAddressPrimary( + null, "US", "Ada", "Lovelace", "1 Main Street", null, null, null, null)) + .contactEmail("agent@example.com") + .contactMobile("+14155550123") + .personBirthdate("1815-12-10") + .personFirstName("Ada") + .personLastName("Lovelace") + .personUsername("ada") + .build(); + for (String name : Aep.REGISTERED_CLAIMS) { + assertTrue(values.contains(name), name); + } + ClaimValues empty = ClaimValues.builder().build(); + for (String name : Aep.REGISTERED_CLAIMS) { + assertFalse(empty.contains(name), name); + } + assertFalse(empty.contains("example.absent")); + } + + @Test + void resolvesLoopbackDidWebIdentifiersInEveryLiteralForm() { + assertEquals( + java.net.URI.create("http://localhost/.well-known/did.json"), + DidWeb.documentUri("did:web:localhost", true)); + assertEquals( + java.net.URI.create("http://[::1]/.well-known/did.json"), + DidWeb.documentUri("did:web:%5B%3A%3A1%5D", true)); + assertEquals( + java.net.URI.create("https://[::1]/.well-known/did.json"), + DidWeb.documentUri("did:web:%5B%3A%3A1%5D", false)); + } + + @Test + void copiesPlatformContextDefensively() { + Map context = new java.util.LinkedHashMap<>(); + context.put("tenant", "acme"); + context.put("nested", new java.util.LinkedHashMap<>(Map.of("deep", List.of(1, 2)))); + PlatformSignRequest request = + new PlatformSignRequest("jti-1", null, AssertionOperation.STATUS, context, null, SERVICE); + context.put("tenant", "other"); + assertEquals("acme", request.platformContext().get("tenant")); + assertThrows( + UnsupportedOperationException.class, + () -> request.platformContext().put("added", "value")); + assertNull(new PlatformSignRequest("jti-1", null, AssertionOperation.STATUS, null, null, SERVICE) + .platformContext()); + assertEquals(new RevokeResponse(), new RevokeResponse()); + } + + /** A JOSE header that is not an AEP client assertion header never reaches claim validation. */ + @Test + void rejectsJoseHeadersThatAreNotAepAssertions() throws Exception { + com.nimbusds.jose.jwk.ECKey key = new ECKeyGenerator(Curve.P_256).generate(); + com.nimbusds.jwt.JWTClaimsSet claimsSet = new com.nimbusds.jwt.JWTClaimsSet.Builder() + .issuer(AGENT) + .subject(AGENT) + .audience(AGENT) + .issueTime(java.util.Date.from(NOW)) + .expirationTime(java.util.Date.from(NOW.plusSeconds(60))) + .jwtID("assertion-1") + .claim("op", "status") + .build(); + + // CORE-CA-006: typ must be JWT. + com.nimbusds.jwt.SignedJWT wrongType = new com.nimbusds.jwt.SignedJWT( + new com.nimbusds.jose.JWSHeader.Builder(com.nimbusds.jose.JWSAlgorithm.ES256) + .type(new com.nimbusds.jose.JOSEObjectType("at+jwt")) + .keyID(AGENT) + .build(), + claimsSet); + wrongType.sign(new com.nimbusds.jose.crypto.ECDSASigner(key)); + assertThrows( + IllegalArgumentException.class, + () -> ClientAssertions.verify( + wrongType.serialize(), key.toPublicJWK(), verification(AssertionOperation.STATUS, null))); + + // CORE-CA-002: kid is required. + com.nimbusds.jwt.SignedJWT noKeyId = new com.nimbusds.jwt.SignedJWT( + new com.nimbusds.jose.JWSHeader.Builder(com.nimbusds.jose.JWSAlgorithm.ES256) + .type(com.nimbusds.jose.JOSEObjectType.JWT) + .build(), + claimsSet); + noKeyId.sign(new com.nimbusds.jose.crypto.ECDSASigner(key)); + assertThrows( + IllegalArgumentException.class, + () -> ClientAssertions.verify( + noKeyId.serialize(), key.toPublicJWK(), verification(AssertionOperation.STATUS, null))); + + // CORE-CA-005: a symmetric algorithm is refused before any key is consulted. + byte[] secret = new byte[32]; + java.util.Arrays.fill(secret, (byte) 7); + com.nimbusds.jwt.SignedJWT symmetric = new com.nimbusds.jwt.SignedJWT( + new com.nimbusds.jose.JWSHeader.Builder(com.nimbusds.jose.JWSAlgorithm.HS256) + .type(com.nimbusds.jose.JOSEObjectType.JWT) + .keyID(AGENT) + .build(), + claimsSet); + symmetric.sign(new com.nimbusds.jose.crypto.MACSigner(secret)); + assertThrows( + IllegalArgumentException.class, + () -> ClientAssertions.verify( + symmetric.serialize(), key.toPublicJWK(), verification(AssertionOperation.STATUS, null))); + + // An assertion binds to exactly one audience. + com.nimbusds.jwt.SignedJWT manyAudiences = new com.nimbusds.jwt.SignedJWT( + new com.nimbusds.jose.JWSHeader.Builder(com.nimbusds.jose.JWSAlgorithm.ES256) + .type(com.nimbusds.jose.JOSEObjectType.JWT) + .keyID(AGENT) + .build(), + new com.nimbusds.jwt.JWTClaimsSet.Builder(claimsSet) + .audience(List.of(SERVICE, "did:web:other.example")) + .build()); + manyAudiences.sign(new com.nimbusds.jose.crypto.ECDSASigner(key)); + assertThrows( + IllegalArgumentException.class, + () -> ClientAssertions.verify( + manyAudiences.serialize(), key.toPublicJWK(), verification(AssertionOperation.STATUS, null))); + assertThrows( + IllegalArgumentException.class, () -> ClientAssertions.decodeUnverified(manyAudiences.serialize())); + } + + @Test + void reportsRemainingLifecycleAndConfigurationDefects() { + // A lifecycle Problem carries owner_action_required only as "true". + assertTrue(paths(AepValidation.problemDetails(new ProblemDetails( + "urn:aep:error:verification_pending", + "Verification pending", + 403, + null, + null, + "verification_pending", + "false", + null, + null))) + .contains("$.owner_action_required")); + + // An advertised API-key header name must be an HTTP field name. + InspectDocument.GrantTypeConfig config = + new InspectDocument.GrantTypeConfig(null, null, List.of("x api key"), null, null, "true", null, null); + InspectDocument document = InspectDocument.builder() + .version(Aep.VERSION) + .bindings(new InspectDocument.Bindings(List.of("http"))) + .commands(new InspectDocument.Commands( + List.of("inspect", "grant", "revoke"), List.of("api-key"), Map.of("api-key", config))) + .core(new InspectDocument.Core(List.of("EdDSA", "ES256"))) + .http(new InspectDocument.Http("/aep/", null)) + .identity(new InspectDocument.Identity(List.of("did:web"))) + .service(new InspectDocument.Service("did:web:service.example")) + .build(); + assertTrue(paths(AepValidation.inspectDocument(document)) + .contains("$.commands.grant_types_config.api-key.header_names[0]")); + + // An OpenAPI reference that is present but not a URI reference is reported once. + InspectDocument openapi = InspectDocument.builder() + .version(Aep.VERSION) + .bindings(new InspectDocument.Bindings(List.of("http"))) + .commands(new InspectDocument.Commands(List.of("inspect"), List.of(), Map.of())) + .core(new InspectDocument.Core(List.of("EdDSA", "ES256"))) + .http(new InspectDocument.Http( + "/aep/", new InspectDocument.OpenApi("not a URI", new InspectDocument.PathMatching("strict")))) + .identity(new InspectDocument.Identity(List.of("did:web"))) + .service(new InspectDocument.Service("did:web:service.example")) + .build(); + assertEquals(List.of("$.http.openapi.url"), paths(AepValidation.inspectDocument(openapi))); + + // An absent path_matching is reported through the same path as an invalid one. + InspectDocument noMatching = InspectDocument.builder() + .version(Aep.VERSION) + .bindings(new InspectDocument.Bindings(List.of("http"))) + .commands(new InspectDocument.Commands(List.of("inspect"), List.of(), Map.of())) + .core(new InspectDocument.Core(List.of("EdDSA", "ES256"))) + .http(new InspectDocument.Http("/aep/", new InspectDocument.OpenApi("/openapi.json", null))) + .identity(new InspectDocument.Identity(List.of("did:web"))) + .service(new InspectDocument.Service("did:web:service.example")) + .build(); + assertEquals( + List.of("$.http.openapi.path_matching.trailing_slash"), + paths(AepValidation.inspectDocument(noMatching))); + } +} diff --git a/aep-core/src/test/java/foundation/aep/core/AepHttpTest.java b/aep-core/src/test/java/foundation/aep/core/AepHttpTest.java index be03982..2e09334 100644 --- a/aep-core/src/test/java/foundation/aep/core/AepHttpTest.java +++ b/aep-core/src/test/java/foundation/aep/core/AepHttpTest.java @@ -82,9 +82,9 @@ void rejectsMalformedAuthorizationPresentations() { () -> AepHttp.parseAuthorization("Unknown credential", AuthorizationCarrier.STANDARD)); assertThrows( AepAuthorizationException.class, - () -> AepHttp.parseAuthorization("Bearer credential", AuthorizationCarrier.STANDARD)); + () -> AepHttp.parseAuthorization("Bearer", AuthorizationCarrier.STANDARD)); assertThrows( AepAuthorizationException.class, - () -> AepHttp.parseAuthorization("Bearer", AuthorizationCarrier.STANDARD)); + () -> AepHttp.parseAuthorization("Bearer ", AuthorizationCarrier.STANDARD)); } } diff --git a/aep-core/src/test/java/foundation/aep/core/AepOpenApiTest.java b/aep-core/src/test/java/foundation/aep/core/AepOpenApiTest.java index 3a62aa3..b344a1b 100644 --- a/aep-core/src/test/java/foundation/aep/core/AepOpenApiTest.java +++ b/aep-core/src/test/java/foundation/aep/core/AepOpenApiTest.java @@ -5,9 +5,33 @@ import java.net.URI; import java.util.List; +import java.util.Map; import org.junit.jupiter.api.Test; final class AepOpenApiTest { + private static final String AEP = "aepScheme"; + private static final String KEY = "apiKeyScheme"; + + @Test + void keepsEncodedSeparatorsInsideTheirPathSegment() { + assertEquals( + "/objects/{id}", + AepOpenApi.matchPath( + List.of("/objects/{id}", "/objects/{folder}/{id}"), + "get", + "https://service.example/objects/a%2Fb?view=full", + AepOpenApi.TrailingSlashMode.STRICT) + .template()); + assertEquals( + "/objects/a%2Fb", + AepOpenApi.matchPath( + List.of("/objects/{id}", "/objects/a%2Fb"), + "get", + "/objects/a%2Fb", + AepOpenApi.TrailingSlashMode.STRICT) + .template()); + } + @Test void resolvesAnonymousHttpsDocuments() { URI inspect = URI.create("https://api.example.com/discovery/aep"); @@ -76,4 +100,79 @@ void appliesTrailingSlashPolicyAndRejectsAmbiguity() { AepOpenApi.TrailingSlashMode.STRICT) .template()); } + + @Test + void resolvesOperationSecurityInheritance() { + List>> root = List.of(Map.of(AEP, List.of())); + List supported = List.of(AEP, KEY); + + assertEquals( + AepOpenApi.SecurityDisposition.REQUIRED, + AepOpenApi.resolveSecurity(root, null, supported).disposition(), + "an operation that omits security inherits the root requirement"); + assertEquals( + AepOpenApi.SecurityDisposition.PUBLIC, + AepOpenApi.resolveSecurity(root, List.of(), supported).disposition(), + "an empty array at the operation replaces the root requirement"); + assertEquals( + AepOpenApi.SecurityDisposition.PUBLIC, + AepOpenApi.resolveSecurity(null, null, supported).disposition(), + "a document with no security anywhere is public"); + assertEquals( + AepOpenApi.SecurityDisposition.OPTIONAL_ANONYMOUS, + AepOpenApi.resolveSecurity(root, List.of(Map.of(), Map.of(AEP, List.of())), supported) + .disposition(), + "an empty requirement object permits anonymous access as one alternative"); + assertEquals( + AepOpenApi.SecurityDisposition.ALTERNATIVES, + AepOpenApi.resolveSecurity(root, List.of(Map.of(AEP, List.of()), Map.of(KEY, List.of())), supported) + .disposition(), + "several requirement objects are alternatives"); + assertEquals( + AepOpenApi.SecurityDisposition.ALL_REQUIRED, + AepOpenApi.resolveSecurity(root, List.of(Map.of(AEP, List.of(), KEY, List.of())), supported) + .disposition(), + "several schemes in one object are compound"); + } + + @Test + void refusesToReduceAnUnsatisfiableCompoundRequirement() { + List>> compound = List.of(Map.of(AEP, List.of(), "mtlsScheme", List.of())); + + AepOpenApi.SecurityResolution resolution = AepOpenApi.resolveSecurity(null, compound, List.of(AEP)); + + assertEquals( + AepOpenApi.SecurityDisposition.FALLBACK_LIVE_CHALLENGE, + resolution.disposition(), + "a compound requirement with an unsupported member is unsupported as a whole"); + assertEquals(List.of(), resolution.satisfiable(), "the supported member alone is not an alternative"); + assertEquals("fallback-live-challenge", resolution.disposition().value()); + } + + @Test + void selectsTheCompleteAlternativeAmongCompoundRequirements() { + List>> alternatives = + List.of(Map.of(AEP, List.of(), "mtlsScheme", List.of()), Map.of(KEY, List.of())); + + AepOpenApi.SecurityResolution resolution = AepOpenApi.resolveSecurity(null, alternatives, List.of(AEP, KEY)); + + assertEquals(AepOpenApi.SecurityDisposition.ALTERNATIVES, resolution.disposition()); + assertEquals(List.of(List.of(KEY)), resolution.satisfiable(), "only the complete alternative is selectable"); + } + + @Test + void rejectsMalformedSecurityInput() { + List>> withNull = new java.util.ArrayList<>(); + withNull.add(null); + + assertThrows(IllegalArgumentException.class, () -> AepOpenApi.resolveSecurity(null, withNull, List.of(AEP))); + assertEquals( + AepOpenApi.SecurityDisposition.FALLBACK_LIVE_CHALLENGE, + AepOpenApi.resolveSecurity(null, List.of(Map.of(AEP, List.of())), null) + .disposition(), + "an Agent that supports nothing falls back to a live challenge"); + assertEquals( + List.of(), + new AepOpenApi.SecurityResolution(AepOpenApi.SecurityDisposition.PUBLIC, null).satisfiable()); + } } diff --git a/aep-core/src/test/java/foundation/aep/core/DidWebTest.java b/aep-core/src/test/java/foundation/aep/core/DidWebTest.java index dc904b9..451ac27 100644 --- a/aep-core/src/test/java/foundation/aep/core/DidWebTest.java +++ b/aep-core/src/test/java/foundation/aep/core/DidWebTest.java @@ -9,6 +9,18 @@ import org.junit.jupiter.api.Test; final class DidWebTest { + @Test + void preservesEncodedPathCharacters() { + for (String component : java.util.List.of("account%3A123", "%252e%252e", "alice%3Factive", "alice%23key")) { + assertEquals( + URI.create("https://example.com/" + component + "/did.json"), + DidWeb.documentUri("did:web:example.com:" + component)); + } + for (String component : java.util.List.of("alice?query", "alice#fragment", "%2e", "%2e%2e")) { + assertThrows(IllegalArgumentException.class, () -> DidWeb.documentUri("did:web:example.com:" + component)); + } + } + @Test void resolvesRootAndPathIdentifiers() { assertEquals(URI.create("https://example.com/.well-known/did.json"), DidWeb.documentUri("did:web:example.com")); diff --git a/aep-httpserver/src/main/java/foundation/aep/httpserver/AepHttpServer.java b/aep-httpserver/src/main/java/foundation/aep/httpserver/AepHttpServer.java index 3471356..f70def4 100644 --- a/aep-httpserver/src/main/java/foundation/aep/httpserver/AepHttpServer.java +++ b/aep-httpserver/src/main/java/foundation/aep/httpserver/AepHttpServer.java @@ -4,13 +4,17 @@ import com.sun.net.httpserver.HttpExchange; import com.sun.net.httpserver.HttpHandler; import com.sun.net.httpserver.HttpServer; +import foundation.aep.core.Aep; import foundation.aep.core.AepCommand; +import foundation.aep.core.AepJson; +import foundation.aep.core.ProblemDetails; import foundation.aep.service.AepHttpAuthenticationResult; import foundation.aep.service.AepHttpRequest; import foundation.aep.service.AepHttpResponse; import foundation.aep.service.AepServiceHttpHandler; import java.io.IOException; import java.net.URI; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -54,19 +58,31 @@ public static HttpHandler protect(AepServiceHttpHandler handler, URI publicOrigi private static void handle( HttpExchange exchange, AepServiceHttpHandler handler, AepCommand command, String registeredPath) throws IOException { + // The JDK server matches a context by prefix, so /aep/enroll also receives /aep/enrollments + // and /aep/enroll/anything. Those are not this command's path. CORE-ERR-001 makes every AEP + // endpoint answer errors as Problem Details, so the refusal is a document rather than + // whatever error page the caller's client invents for an empty 404. if (!registeredPath.equals(exchange.getRequestURI().getRawPath())) { - exchange.sendResponseHeaders(404, -1); - exchange.close(); + write(exchange, notFound()); return; } + // The request URL is built from the path this context is registered at, not from the raw + // target. Resolving the target against a base would let a protocol-relative //host/path + // decide the authority of a URL the Service treats as its own. AepHttpRequest request = request( - exchange, - URI.create("http://localhost").resolve(exchange.getRequestURI()), - true, - handler.maximumRequestBytes()); + exchange, URI.create("http://localhost").resolve(registeredPath), true, handler.maximumRequestBytes()); write(exchange, await(handler.handle(command, request))); } + private static AepHttpResponse notFound() { + ProblemDetails problem = ProblemDetails.of("invalid_request", "AEP route not found", 404); + return new AepHttpResponse( + 404, + Aep.PROBLEM_MEDIA_TYPE, + Map.of("Cache-Control", List.of("no-store")), + AepJson.write(problem).getBytes(StandardCharsets.UTF_8)); + } + private static AepHttpRequest request(HttpExchange exchange, URI url, boolean includeBody, int maximumRequestBytes) throws IOException { byte[] body = includeBody ? exchange.getRequestBody().readNBytes(maximumRequestBytes + 1) : new byte[0]; @@ -76,16 +92,20 @@ private static AepHttpRequest request(HttpExchange exchange, URI url, boolean in return new AepHttpRequest(exchange.getRequestMethod(), url, headers, body); } + // RFC 9110 sections 9.3.2 and 15.4.5: a HEAD response and a 304 carry the header fields a GET + // would have produced, and no content. The JDK server needs -1 to mean "no body at all"; passing + // a length here would make it frame content the client must not receive. private static void write(HttpExchange exchange, AepHttpResponse response) throws IOException { response.headers() .forEach((name, values) -> values.forEach(value -> exchange.getResponseHeaders().add(name, value))); exchange.getResponseHeaders().set("Content-Type", response.contentType()); byte[] body = response.body(); - exchange.sendResponseHeaders(response.status(), body.length); + boolean contentless = response.status() == 304 || "HEAD".equalsIgnoreCase(exchange.getRequestMethod()); + exchange.sendResponseHeaders(response.status(), contentless ? -1 : body.length); try (exchange; var output = exchange.getResponseBody()) { - output.write(body); + if (!contentless) output.write(body); } } diff --git a/aep-httpserver/src/test/java/foundation/aep/httpserver/AepHttpServerConformanceTest.java b/aep-httpserver/src/test/java/foundation/aep/httpserver/AepHttpServerConformanceTest.java new file mode 100644 index 0000000..0e6976a --- /dev/null +++ b/aep-httpserver/src/test/java/foundation/aep/httpserver/AepHttpServerConformanceTest.java @@ -0,0 +1,323 @@ +package foundation.aep.httpserver; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.nimbusds.jose.jwk.Curve; +import com.nimbusds.jose.jwk.gen.ECKeyGenerator; +import com.sun.net.httpserver.HttpServer; +import foundation.aep.core.Aep; +import foundation.aep.core.AgentStatus; +import foundation.aep.core.AssertionOperation; +import foundation.aep.core.ClientAssertionClaims; +import foundation.aep.core.ClientAssertions; +import foundation.aep.core.InspectDocument; +import foundation.aep.service.AepHttpRequest; +import foundation.aep.service.AepService; +import foundation.aep.service.AepServiceHttpHandler; +import foundation.aep.service.ClientAssertionVerifier; +import foundation.aep.service.EnrollmentDecision; +import foundation.aep.service.EnrollmentRecord; +import foundation.aep.service.EnrollmentStore; +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; +import org.junit.jupiter.api.Test; + +/** End-to-end checks of the JDK HTTP server binding. */ +final class AepHttpServerConformanceTest { + private static final Instant NOW = Instant.parse("2026-09-01T12:00:00Z"); + private static final String AGENT_DID = "did:web:agent.example"; + private static final String ORIGIN = "https://service.example"; + private static final String ENROLL_BODY = "{\"agent_did\":\"did:web:agent.example\"}"; + + @Test + void authenticatesTheExactEncodedResourceOverHttp() throws Exception { + var key = new ECKeyGenerator(Curve.P_256).generate(); + EnrollmentStore store = EnrollmentStore.inMemory(); + store.save(EnrollmentRecord.builder(AGENT_DID, "enrollment", AgentStatus.ACTIVE, NOW) + .build()) + .toCompletableFuture() + .join(); + AepService service = AepService.builder( + service().inspectDocument(), + ClientAssertionVerifier.withKeyResolver( + (jwt, claims, context) -> CompletableFuture.completedFuture(key.toPublicJWK()))) + .enrollmentStore(store) + .clock(Clock.fixed(NOW, ZoneOffset.UTC)) + .build(); + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext( + "/", AepHttpServer.protect(new AepServiceHttpHandler(service), URI.create(ORIGIN), exchange -> { + exchange.sendResponseHeaders(204, -1); + exchange.close(); + })); + server.start(); + try { + for (String path : List.of("/plain", "/objects/a%2Fb?filter=c%2Fd", "/objects/a%3Fb%23c")) { + URI target = + URI.create("http://127.0.0.1:" + server.getAddress().getPort() + path); + for (String resource : List.of(ORIGIN + path, ORIGIN + "/different", "https://other.example" + path)) { + String assertion = ClientAssertions.sign( + new ClientAssertionClaims( + AGENT_DID, + AGENT_DID, + "did:web:service.example", + AssertionOperation.AUTHENTICATE, + NOW.getEpochSecond(), + NOW.plusSeconds(60).getEpochSecond(), + java.util.UUID.randomUUID().toString(), + resource), + key, + AGENT_DID); + HttpResponse response = send(HttpRequest.newBuilder(target) + .timeout(java.time.Duration.ofSeconds(5)) + .header("Authorization", "AEP " + assertion)); + assertEquals(resource.equals(ORIGIN + path) ? 204 : 401, response.statusCode()); + } + } + } finally { + server.stop(0); + } + } + + /** RFC 9110 section 9.3.2: HEAD repeats a GET's header fields and sends no content. */ + @Test + void answersHeadWithTheHeadersOfAGetAndNoBody() throws Exception { + served(base -> { + HttpResponse get = send(HttpRequest.newBuilder(base.resolve(Aep.WELL_KNOWN_PATH))); + HttpResponse head = send( + HttpRequest.newBuilder(base.resolve(Aep.WELL_KNOWN_PATH)).method("HEAD", noBody())); + + assertEquals(200, head.statusCode()); + assertEquals("", head.body()); + assertEquals(get.headers().firstValue("ETag"), head.headers().firstValue("ETag")); + assertEquals( + get.headers().firstValue("Cache-Control"), head.headers().firstValue("Cache-Control")); + assertFalse(get.body().isEmpty()); + }); + } + + /** CORE-CACHE-004: a matching validator revalidates through 304 with no content. */ + @Test + void revalidatesThroughNotModified() throws Exception { + served(base -> { + HttpResponse first = send(HttpRequest.newBuilder(base.resolve(Aep.WELL_KNOWN_PATH))); + String entityTag = first.headers().firstValue("ETag").orElseThrow(); + HttpResponse conditional = send( + HttpRequest.newBuilder(base.resolve(Aep.WELL_KNOWN_PATH)).header("If-None-Match", entityTag)); + + assertEquals(304, conditional.statusCode()); + assertEquals("", conditional.body()); + assertEquals(entityTag, conditional.headers().firstValue("ETag").orElseThrow()); + assertTrue(conditional.headers().firstValue("Content-Length").isEmpty()); + }); + } + + /** CORE-ERR-001: the prefix-matched paths this context also receives answer as Problem Details. */ + @Test + void refusesPrefixMatchesWithAProblemDocument() throws Exception { + served(base -> { + HttpResponse nested = send(HttpRequest.newBuilder(base.resolve("/aep/enroll/extra"))); + HttpResponse extended = send(HttpRequest.newBuilder(base.resolve("/aep/enrollments"))); + + assertEquals(404, nested.statusCode()); + assertEquals( + Aep.PROBLEM_MEDIA_TYPE, + nested.headers().firstValue("Content-Type").orElseThrow()); + assertTrue(nested.body().contains("\"urn:aep:error:invalid_request\"")); + assertTrue(nested.body().contains("\"status\":404")); + assertEquals(404, extended.statusCode()); + }); + } + + /** RFC 9110 section 9.3.2 again: the refusal names both safe methods. */ + @Test + void namesEverySafeMethodWhenRefusingOne() throws Exception { + served(base -> { + HttpResponse refused = send(HttpRequest.newBuilder(base.resolve(Aep.WELL_KNOWN_PATH)) + .POST(HttpRequest.BodyPublishers.ofString(""))); + + assertEquals(405, refused.statusCode()); + assertEquals("GET, HEAD", refused.headers().firstValue("Allow").orElseThrow()); + }); + } + + /** The command path carries a body, a media type, and an idempotency key end to end. */ + @Test + void servesAnEnrollCommandOverTheWire() throws Exception { + served(base -> { + HttpResponse enrolled = send(HttpRequest.newBuilder(base.resolve("/aep/enroll")) + .header("Content-Type", Aep.MEDIA_TYPE) + .header("Authorization", "AEP assertion-1") + .header("Idempotency-Key", "key-1") + .POST(HttpRequest.BodyPublishers.ofString(ENROLL_BODY))); + HttpResponse wrongMedia = send(HttpRequest.newBuilder(base.resolve("/aep/enroll")) + .header("Content-Type", "application/json") + .header("Authorization", "AEP assertion-2") + .header("Idempotency-Key", "key-2") + .POST(HttpRequest.BodyPublishers.ofString(ENROLL_BODY))); + + assertEquals(200, enrolled.statusCode()); + assertTrue(enrolled.body().contains("\"status\":\"active\"")); + assertEquals( + "no-store", enrolled.headers().firstValue("Cache-Control").orElseThrow()); + assertEquals(415, wrongMedia.statusCode()); + }); + } + + /** An oversized body is refused before it is parsed. */ + @Test + void refusesBodiesPastTheConfiguredLimit() throws Exception { + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + AepHttpServer.register(server, new AepServiceHttpHandler(service(), 8)); + server.start(); + try { + URI base = URI.create("http://127.0.0.1:" + server.getAddress().getPort()); + HttpResponse response = send(HttpRequest.newBuilder(base.resolve("/aep/enroll")) + .header("Content-Type", Aep.MEDIA_TYPE) + .header("Authorization", "AEP assertion") + .header("Idempotency-Key", "key") + .POST(HttpRequest.BodyPublishers.ofString(ENROLL_BODY))); + + assertEquals(413, response.statusCode()); + } finally { + server.stop(0); + } + } + + /** A protected route reaches the application only once a credential has been accepted. */ + @Test + void passesAuthenticatedRequestsToTheApplication() throws Exception { + AtomicReference principal = new AtomicReference<>(); + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + AepServiceHttpHandler handler = new AepServiceHttpHandler(service()); + server.createContext("/orders", AepHttpServer.protect(handler, URI.create(ORIGIN), exchange -> { + principal.set(exchange.getAttribute(AepHttpServer.PRINCIPAL_ATTRIBUTE)); + exchange.sendResponseHeaders(204, -1); + exchange.close(); + })); + server.start(); + try { + URI base = URI.create("http://127.0.0.1:" + server.getAddress().getPort()); + enroll(handler); + HttpResponse rejected = send(HttpRequest.newBuilder(base.resolve("/orders"))); + HttpResponse accepted = + send(HttpRequest.newBuilder(base.resolve("/orders")).header("Authorization", "AEP assertion")); + HttpResponse head = + send(HttpRequest.newBuilder(base.resolve("/orders")).method("HEAD", noBody())); + + assertEquals(401, rejected.statusCode()); + assertTrue(rejected.headers() + .firstValue("WWW-Authenticate") + .orElseThrow() + .startsWith("AEP ")); + assertFalse(rejected.body().isEmpty(), "a rejection is a Problem Details document"); + assertEquals(204, accepted.statusCode()); + assertEquals(AGENT_DID, ((foundation.aep.service.AuthenticatedPrincipal) principal.get()).agentDid()); + assertEquals(401, head.statusCode()); + assertEquals("", head.body(), "a HEAD rejection carries no content either"); + } finally { + server.stop(0); + } + } + + @Test + void refusesUnusableRegistrationArguments() throws Exception { + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + AepServiceHttpHandler handler = new AepServiceHttpHandler(service()); + + assertThrows(NullPointerException.class, () -> AepHttpServer.register(null, handler)); + assertThrows(NullPointerException.class, () -> AepHttpServer.register(server, null)); + assertThrows(NullPointerException.class, () -> AepHttpServer.protect(null, URI.create(ORIGIN), e -> {})); + assertThrows(NullPointerException.class, () -> AepHttpServer.protect(handler, URI.create(ORIGIN), null)); + assertThrows(IllegalArgumentException.class, () -> AepHttpServer.protect(handler, null, e -> {})); + assertThrows( + IllegalArgumentException.class, + () -> AepHttpServer.protect(handler, URI.create("https://service.example/base"), e -> {})); + assertEquals(3, AepHttpServer.register(server, handler).size(), "inspect, enroll, and status"); + } + + private static void enroll(AepServiceHttpHandler handler) { + handler.handle( + foundation.aep.core.AepCommand.ENROLL, + new AepHttpRequest( + "POST", + URI.create(ORIGIN + "/aep/enroll"), + Map.of( + "Content-Type", + List.of(Aep.MEDIA_TYPE), + "Authorization", + List.of("AEP enrollment"), + "Idempotency-Key", + List.of("enroll-key")), + ENROLL_BODY.getBytes(java.nio.charset.StandardCharsets.UTF_8))) + .toCompletableFuture() + .join(); + } + + private static void served(Consumer body) throws IOException { + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + AepHttpServer.register(server, new AepServiceHttpHandler(service())); + server.start(); + try { + body.accept(URI.create("http://127.0.0.1:" + server.getAddress().getPort())); + } finally { + server.stop(0); + } + } + + private static HttpRequest.BodyPublisher noBody() { + return HttpRequest.BodyPublishers.noBody(); + } + + private static HttpResponse send(HttpRequest.Builder request) { + try { + return HttpClient.newHttpClient().send(request.build(), HttpResponse.BodyHandlers.ofString()); + } catch (IOException | InterruptedException exception) { + throw new IllegalStateException(exception); + } + } + + private static AepService service() { + InspectDocument document = InspectDocument.builder() + .version(Aep.VERSION) + .authentication(new InspectDocument.Authentication(List.of(Aep.AUTHENTICATION_METHOD_JWT))) + .bindings(new InspectDocument.Bindings(List.of("http"))) + .claims(new InspectDocument.Claims(List.of(), List.of(), List.of())) + .commands(new InspectDocument.Commands(List.of("enroll", "inspect", "status"), List.of(), Map.of())) + .core(new InspectDocument.Core(Aep.REQUIRED_SIGNING_ALGORITHMS)) + .http(new InspectDocument.Http("/aep", null)) + .identity(new InspectDocument.Identity(List.of("did:web"))) + .service(new InspectDocument.Service("did:web:service.example")) + .build(); + return AepService.builder( + document, + (assertion, context) -> CompletableFuture.completedFuture(new ClientAssertionClaims( + AGENT_DID, + AGENT_DID, + context.serviceDid(), + context.operation(), + NOW.minusSeconds(1).getEpochSecond(), + NOW.plusSeconds(60).getEpochSecond(), + assertion, + context.resource()))) + .clock(Clock.fixed(NOW, ZoneOffset.UTC)) + .enrollmentPolicy((request, now) -> CompletableFuture.completedFuture( + new EnrollmentDecision(AgentStatus.ACTIVE, false, List.of(), List.of()))) + .build(); + } +} diff --git a/aep-json-jackson2/src/main/java/foundation/aep/json/jackson2/Jackson2JsonProvider.java b/aep-json-jackson2/src/main/java/foundation/aep/json/jackson2/Jackson2JsonProvider.java index 3c0b3cd..5a43e91 100644 --- a/aep-json-jackson2/src/main/java/foundation/aep/json/jackson2/Jackson2JsonProvider.java +++ b/aep-json-jackson2/src/main/java/foundation/aep/json/jackson2/Jackson2JsonProvider.java @@ -6,6 +6,10 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder; +import com.fasterxml.jackson.databind.cfg.CoercionAction; +import com.fasterxml.jackson.databind.cfg.CoercionInputShape; +import com.fasterxml.jackson.databind.json.JsonMapper; +import com.fasterxml.jackson.databind.type.LogicalType; import foundation.aep.core.AepJsonProvider; import foundation.aep.core.ClaimValues; import foundation.aep.core.InspectDocument; @@ -20,13 +24,20 @@ private interface InspectDocumentMixin {} @JsonDeserialize(builder = ClaimValues.Builder.class) private interface ClaimValuesMixin {} - private final ObjectMapper mapper = new ObjectMapper() + private final ObjectMapper mapper = JsonMapper.builder() .addMixIn(InspectDocument.class, InspectDocumentMixin.class) .addMixIn(InspectDocument.Builder.class, BuilderMixin.class) .addMixIn(ClaimValues.class, ClaimValuesMixin.class) .addMixIn(ClaimValues.Builder.class, BuilderMixin.class) .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) - .setSerializationInclusion(JsonInclude.Include.NON_NULL); + .enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS) + .withCoercionConfig( + LogicalType.Textual, + coercion -> coercion.setCoercion(CoercionInputShape.Integer, CoercionAction.Fail) + .setCoercion(CoercionInputShape.Float, CoercionAction.Fail) + .setCoercion(CoercionInputShape.Boolean, CoercionAction.Fail)) + .serializationInclusion(JsonInclude.Include.NON_NULL) + .build(); @Override public T decode(String json, Class type) { diff --git a/aep-json-jackson2/src/test/java/foundation/aep/json/jackson2/Jackson2JsonProviderFailureTest.java b/aep-json-jackson2/src/test/java/foundation/aep/json/jackson2/Jackson2JsonProviderFailureTest.java new file mode 100644 index 0000000..512f1b1 --- /dev/null +++ b/aep-json-jackson2/src/test/java/foundation/aep/json/jackson2/Jackson2JsonProviderFailureTest.java @@ -0,0 +1,54 @@ +package foundation.aep.json.jackson2; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import foundation.aep.core.AepJsonProvider; +import foundation.aep.core.EnrollResponse; +import org.junit.jupiter.api.Test; + +/** The provider turns Jackson's failures into the contract AepJson callers handle. */ +final class Jackson2JsonProviderFailureTest { + private final AepJsonProvider provider = new Jackson2JsonProvider(); + + @Test + void reportsUndecodableJsonAsAnInvalidArgument() { + IllegalArgumentException truncated = + assertThrows(IllegalArgumentException.class, () -> provider.decode("{", EnrollResponse.class)); + IllegalArgumentException wrongShape = assertThrows( + IllegalArgumentException.class, () -> provider.decode("[\"active\"]", EnrollResponse.class)); + IllegalArgumentException wrongType = assertThrows( + IllegalArgumentException.class, () -> provider.decode("{\"status\":42}", EnrollResponse.class)); + + assertEquals("Unable to decode AEP JSON", truncated.getMessage()); + assertEquals("Unable to decode AEP JSON", wrongShape.getMessage()); + assertEquals("Unable to decode AEP JSON", wrongType.getMessage()); + assertTrue(truncated.getCause() instanceof com.fasterxml.jackson.core.JsonProcessingException); + } + + @Test + void reportsUnencodableValuesAsAnInvalidArgument() { + IllegalArgumentException failure = + assertThrows(IllegalArgumentException.class, () -> provider.write(new Unserializable())); + + assertEquals("Unable to encode AEP JSON", failure.getMessage()); + assertTrue(failure.getCause() instanceof com.fasterxml.jackson.core.JsonProcessingException); + } + + /** A value whose accessor fails mid-serialization, which is how Jackson reports write errors. */ + public static final class Unserializable { + public String getValue() { + throw new IllegalStateException("boom"); + } + } + + @Test + void ignoresUnknownMembersAndOmitsAbsentOnes() { + EnrollResponse decoded = + provider.decode("{\"status\":\"active\",\"future_member\":{\"nested\":true}}", EnrollResponse.class); + + assertEquals(foundation.aep.core.AgentStatus.ACTIVE, decoded.status()); + assertEquals("{\"status\":\"active\"}", provider.write(decoded)); + } +} diff --git a/aep-json-jackson2/src/test/java/foundation/aep/json/jackson2/Jackson2JsonProviderTest.java b/aep-json-jackson2/src/test/java/foundation/aep/json/jackson2/Jackson2JsonProviderTest.java index 0e55c16..e03e95d 100644 --- a/aep-json-jackson2/src/test/java/foundation/aep/json/jackson2/Jackson2JsonProviderTest.java +++ b/aep-json-jackson2/src/test/java/foundation/aep/json/jackson2/Jackson2JsonProviderTest.java @@ -1,7 +1,9 @@ package foundation.aep.json.jackson2; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import foundation.aep.core.AepJson; import foundation.aep.core.AepValidationException; @@ -11,6 +13,46 @@ import org.junit.jupiter.api.Test; final class Jackson2JsonProviderTest { + @Test + void validatesNestedEnrollmentClaimsBeforeConversion() { + for (String claims : List.of( + "{\"person.first_name\":42}", + "{\"person.first_name\":null}", + "{\"contact.address.primary\":null}", + "{\"contact.address.primary\":{\"first_name\":\"Ada\",\"last_name\":\"Lovelace\",\"line1\":\"1 Main Street\",\"country\":\"GB\",\"postal_code\":\"123\"}}", + "{\"contact.address.primary\":{\"first_name\":\"Ada\",\"last_name\":\"Lovelace\",\"line1\":\"1 Main Street\",\"country\":\"GB\",\"line2\":null}}")) { + String json = "{\"agent_did\":\"did:web:agent.example\",\"claims\":" + claims + "}"; + assertThrows(AepValidationException.class, () -> AepJson.parseEnrollRequest(json)); + } + String json = """ + {"agent_did":"did:web:agent.example","claims":{"person.first_name":"Ada", + "custom.claim":null,"contact.address.primary":{"first_name":"Ada","last_name":"Lovelace", + "line1":"1 Main Street","country":"GB","line2":""}}} + """; + var claims = AepJson.parseEnrollRequest(json).claims(); + assertEquals("Ada", claims.personFirstName()); + assertEquals("", claims.contactAddressPrimary().line2()); + assertTrue(claims.additional().containsKey("custom.claim")); + assertNull(claims.additional().get("custom.claim")); + } + + @Test + void rejectsCoercionAndTrailingJson() { + String valid = """ + {"jti":"request-1","op":"status","service_did":"did:web:service.example","lifetime_seconds":"60"} + """; + assertEquals("60", AepJson.parsePlatformSignRequest(valid).lifetimeSeconds()); + for (String value : List.of("60", "60.0", "true")) { + assertThrows( + AepValidationException.class, + () -> AepJson.parsePlatformSignRequest(valid.replace("\"60\"", value))); + } + for (String suffix : List.of(" garbage", " {}", " null")) { + assertThrows(AepValidationException.class, () -> AepJson.parsePlatformSignRequest(valid + suffix)); + } + assertEquals("60", AepJson.parsePlatformSignRequest(valid + " \n\t").lifetimeSeconds()); + } + @Test void parsesAndWritesInspectDocument() { String json = """ @@ -46,6 +88,46 @@ void parsesAndWritesInspectDocument() { .version()); } + @Test + void validatesOpenApiObjectsAndUriReferencesWithoutRejectingTopLevelExtensions() { + String template = """ + {"aep_version":"1.0","bindings":{"supported":["http"]}, + "commands":{"supported":["inspect"]},"core":{"signing_algorithms":["EdDSA","ES256"]}, + "http":{"endpoint_base":"/aep","openapi":%s}, + "identity":{"methods":["did:web"]},"service":{"did":"did:web:service.example"}, + "example.extension":{"enabled":true}} + """; + String valid = """ + {"url":"/openapi.json","path_matching":{"trailing_slash":"strict"}} + """; + assertEquals( + "strict", + AepJson.parseInspectDocument(template.formatted(valid)) + .http() + .openapi() + .pathMatching() + .trailingSlash()); + String document = template.formatted(valid); + assertThrows( + AepValidationException.class, + () -> AepJson.parseInspectDocument(document.replace("/aep", "/bad path"))); + assertThrows( + AepValidationException.class, + () -> AepJson.parseInspectDocument(document.replace( + "\"example.extension\":", + "\"extensions\":{\"supported\":[\"not a URI\"]},\"example.extension\":"))); + for (String openapi : List.of( + "{\"url\":null,\"path_matching\":{\"trailing_slash\":\"strict\"}}", + "{\"url\":\"/openapi.json\",\"future\":true,\"path_matching\":{\"trailing_slash\":\"strict\"}}", + "{\"url\":\"/openapi.json\",\"path_matching\":{\"trailing_slash\":null}}", + "{\"url\":\"/openapi.json\",\"path_matching\":{\"trailing_slash\":\"strict\",\"future\":true}}")) { + assertThrows( + AepValidationException.class, + () -> AepJson.parseInspectDocument(template.formatted(openapi)), + openapi); + } + } + @Test void appliesGrantResponseNullability() { String valid = """ @@ -210,4 +292,54 @@ void parsesPlatformWireDocuments() { () -> AepJson.parsePlatformProvisionRequest( "{\"service_did\":\"did:web:service.example\",\"extra\":true}")); } + + @Test + void parsesProvisioningAndLifecycleWithoutAcceptingUnknownMembers() { + assertEquals( + "did:web:service.example", + AepJson.parsePlatformProvisionRequest("{\"service_did\":\"did:web:service.example\"}") + .serviceDid()); + for (String status : List.of("active", "revoked", "suspended", "terminated")) { + assertEquals( + status, + AepJson.parsePlatformLifecycleRequest("{\"status\":\"" + status + "\"}") + .status() + .value()); + } + for (String json : List.of( + "{}", "{\"status\":null}", "{\"status\":\"pending\"}", "{\"status\":\"active\",\"extra\":true}")) { + assertThrows(AepValidationException.class, () -> AepJson.parsePlatformLifecycleRequest(json), json); + } + assertThrows(AepValidationException.class, () -> AepJson.parsePlatformSignResponse("{\"status\":\"unknown\"}")); + } + + @Test + void serializesStatusWithoutEmptyLifecycleMetadata() { + var status = new foundation.aep.core.StatusResponse( + foundation.aep.core.AgentStatus.ACTIVE, "false", List.of(), List.of(), null); + assertEquals("{\"status\":\"active\"}", AepJson.write(status)); + var pending = new foundation.aep.core.StatusResponse( + foundation.aep.core.AgentStatus.PENDING, + "true", + List.of("contact.email"), + List.of("contact.mobile"), + null); + assertEquals(pending, AepJson.parseStatusResponse(AepJson.write(pending))); + AepJson.requireObject("{}", "example"); + assertThrows(AepValidationException.class, () -> AepJson.requireObject("[]", "example")); + } + + @Test + void rejectsAddressTypeCoercion() { + for (String address : List.of( + "42", + "[]", + "\"address\"", + "{\"country\":\"US\",\"first_name\":\"Ada\",\"last_name\":\"Lovelace\",\"line1\":42}")) { + assertThrows( + AepValidationException.class, + () -> AepJson.parseClaimValues("{\"contact.address.primary\":" + address + "}"), + address); + } + } } diff --git a/aep-json-jackson3/src/main/java/foundation/aep/json/jackson3/Jackson3JsonProvider.java b/aep-json-jackson3/src/main/java/foundation/aep/json/jackson3/Jackson3JsonProvider.java index 0963729..c63137e 100644 --- a/aep-json-jackson3/src/main/java/foundation/aep/json/jackson3/Jackson3JsonProvider.java +++ b/aep-json-jackson3/src/main/java/foundation/aep/json/jackson3/Jackson3JsonProvider.java @@ -8,7 +8,10 @@ import tools.jackson.databind.DeserializationFeature; import tools.jackson.databind.annotation.JsonDeserialize; import tools.jackson.databind.annotation.JsonPOJOBuilder; +import tools.jackson.databind.cfg.CoercionAction; +import tools.jackson.databind.cfg.CoercionInputShape; import tools.jackson.databind.json.JsonMapper; +import tools.jackson.databind.type.LogicalType; public final class Jackson3JsonProvider implements AepJsonProvider { @JsonPOJOBuilder(withPrefix = "") @@ -26,6 +29,12 @@ private interface ClaimValuesMixin {} .addMixIn(ClaimValues.class, ClaimValuesMixin.class) .addMixIn(ClaimValues.Builder.class, BuilderMixin.class) .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS) + .withCoercionConfig( + LogicalType.Textual, + coercion -> coercion.setCoercion(CoercionInputShape.Integer, CoercionAction.Fail) + .setCoercion(CoercionInputShape.Float, CoercionAction.Fail) + .setCoercion(CoercionInputShape.Boolean, CoercionAction.Fail)) .changeDefaultPropertyInclusion(inclusion -> inclusion.withValueInclusion(JsonInclude.Include.NON_NULL)) .build(); diff --git a/aep-json-jackson3/src/test/java/foundation/aep/json/jackson3/Jackson3JsonProviderFailureTest.java b/aep-json-jackson3/src/test/java/foundation/aep/json/jackson3/Jackson3JsonProviderFailureTest.java new file mode 100644 index 0000000..6b56b7a --- /dev/null +++ b/aep-json-jackson3/src/test/java/foundation/aep/json/jackson3/Jackson3JsonProviderFailureTest.java @@ -0,0 +1,54 @@ +package foundation.aep.json.jackson3; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import foundation.aep.core.AepJsonProvider; +import foundation.aep.core.EnrollResponse; +import org.junit.jupiter.api.Test; + +/** The provider turns Jackson's failures into the contract AepJson callers handle. */ +final class Jackson3JsonProviderFailureTest { + private final AepJsonProvider provider = new Jackson3JsonProvider(); + + @Test + void reportsUndecodableJsonAsAnInvalidArgument() { + IllegalArgumentException truncated = + assertThrows(IllegalArgumentException.class, () -> provider.decode("{", EnrollResponse.class)); + IllegalArgumentException wrongShape = assertThrows( + IllegalArgumentException.class, () -> provider.decode("[\"active\"]", EnrollResponse.class)); + IllegalArgumentException wrongType = assertThrows( + IllegalArgumentException.class, () -> provider.decode("{\"status\":42}", EnrollResponse.class)); + + assertEquals("Unable to decode AEP JSON", truncated.getMessage()); + assertEquals("Unable to decode AEP JSON", wrongShape.getMessage()); + assertEquals("Unable to decode AEP JSON", wrongType.getMessage()); + assertTrue(truncated.getCause() instanceof tools.jackson.core.JacksonException); + } + + @Test + void reportsUnencodableValuesAsAnInvalidArgument() { + IllegalArgumentException failure = + assertThrows(IllegalArgumentException.class, () -> provider.write(new Unserializable())); + + assertEquals("Unable to encode AEP JSON", failure.getMessage()); + assertTrue(failure.getCause() instanceof tools.jackson.core.JacksonException); + } + + /** A value whose accessor fails mid-serialization, which is how Jackson reports write errors. */ + public static final class Unserializable { + public String getValue() { + throw new IllegalStateException("boom"); + } + } + + @Test + void ignoresUnknownMembersAndOmitsAbsentOnes() { + EnrollResponse decoded = + provider.decode("{\"status\":\"active\",\"future_member\":{\"nested\":true}}", EnrollResponse.class); + + assertEquals(foundation.aep.core.AgentStatus.ACTIVE, decoded.status()); + assertEquals("{\"status\":\"active\"}", provider.write(decoded)); + } +} diff --git a/aep-json-jackson3/src/test/java/foundation/aep/json/jackson3/Jackson3JsonProviderTest.java b/aep-json-jackson3/src/test/java/foundation/aep/json/jackson3/Jackson3JsonProviderTest.java index 7d8c3fc..21295a6 100644 --- a/aep-json-jackson3/src/test/java/foundation/aep/json/jackson3/Jackson3JsonProviderTest.java +++ b/aep-json-jackson3/src/test/java/foundation/aep/json/jackson3/Jackson3JsonProviderTest.java @@ -1,7 +1,9 @@ package foundation.aep.json.jackson3; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import foundation.aep.core.AepJson; import foundation.aep.core.AepValidationException; @@ -11,6 +13,46 @@ import org.junit.jupiter.api.Test; final class Jackson3JsonProviderTest { + @Test + void validatesNestedEnrollmentClaimsBeforeConversion() { + for (String claims : List.of( + "{\"person.first_name\":42}", + "{\"person.first_name\":null}", + "{\"contact.address.primary\":null}", + "{\"contact.address.primary\":{\"first_name\":\"Ada\",\"last_name\":\"Lovelace\",\"line1\":\"1 Main Street\",\"country\":\"GB\",\"postal_code\":\"123\"}}", + "{\"contact.address.primary\":{\"first_name\":\"Ada\",\"last_name\":\"Lovelace\",\"line1\":\"1 Main Street\",\"country\":\"GB\",\"line2\":null}}")) { + String json = "{\"agent_did\":\"did:web:agent.example\",\"claims\":" + claims + "}"; + assertThrows(AepValidationException.class, () -> AepJson.parseEnrollRequest(json)); + } + String json = """ + {"agent_did":"did:web:agent.example","claims":{"person.first_name":"Ada", + "custom.claim":null,"contact.address.primary":{"first_name":"Ada","last_name":"Lovelace", + "line1":"1 Main Street","country":"GB","line2":""}}} + """; + var claims = AepJson.parseEnrollRequest(json).claims(); + assertEquals("Ada", claims.personFirstName()); + assertEquals("", claims.contactAddressPrimary().line2()); + assertTrue(claims.additional().containsKey("custom.claim")); + assertNull(claims.additional().get("custom.claim")); + } + + @Test + void rejectsCoercionAndTrailingJson() { + String valid = """ + {"jti":"request-1","op":"status","service_did":"did:web:service.example","lifetime_seconds":"60"} + """; + assertEquals("60", AepJson.parsePlatformSignRequest(valid).lifetimeSeconds()); + for (String value : List.of("60", "60.0", "true")) { + assertThrows( + AepValidationException.class, + () -> AepJson.parsePlatformSignRequest(valid.replace("\"60\"", value))); + } + for (String suffix : List.of(" garbage", " {}", " null")) { + assertThrows(AepValidationException.class, () -> AepJson.parsePlatformSignRequest(valid + suffix)); + } + assertEquals("60", AepJson.parsePlatformSignRequest(valid + " \n\t").lifetimeSeconds()); + } + @Test void parsesAndWritesInspectDocument() { String json = """ @@ -46,6 +88,46 @@ void parsesAndWritesInspectDocument() { .version()); } + @Test + void validatesOpenApiObjectsAndUriReferencesWithoutRejectingTopLevelExtensions() { + String template = """ + {"aep_version":"1.0","bindings":{"supported":["http"]}, + "commands":{"supported":["inspect"]},"core":{"signing_algorithms":["EdDSA","ES256"]}, + "http":{"endpoint_base":"/aep","openapi":%s}, + "identity":{"methods":["did:web"]},"service":{"did":"did:web:service.example"}, + "example.extension":{"enabled":true}} + """; + String valid = """ + {"url":"/openapi.json","path_matching":{"trailing_slash":"strict"}} + """; + assertEquals( + "strict", + AepJson.parseInspectDocument(template.formatted(valid)) + .http() + .openapi() + .pathMatching() + .trailingSlash()); + String document = template.formatted(valid); + assertThrows( + AepValidationException.class, + () -> AepJson.parseInspectDocument(document.replace("/aep", "/bad path"))); + assertThrows( + AepValidationException.class, + () -> AepJson.parseInspectDocument(document.replace( + "\"example.extension\":", + "\"extensions\":{\"supported\":[\"not a URI\"]},\"example.extension\":"))); + for (String openapi : List.of( + "{\"url\":null,\"path_matching\":{\"trailing_slash\":\"strict\"}}", + "{\"url\":\"/openapi.json\",\"future\":true,\"path_matching\":{\"trailing_slash\":\"strict\"}}", + "{\"url\":\"/openapi.json\",\"path_matching\":{\"trailing_slash\":null}}", + "{\"url\":\"/openapi.json\",\"path_matching\":{\"trailing_slash\":\"strict\",\"future\":true}}")) { + assertThrows( + AepValidationException.class, + () -> AepJson.parseInspectDocument(template.formatted(openapi)), + openapi); + } + } + @Test void appliesGrantResponseNullability() { String valid = """ @@ -210,4 +292,54 @@ void parsesPlatformWireDocuments() { () -> AepJson.parsePlatformProvisionRequest( "{\"service_did\":\"did:web:service.example\",\"extra\":true}")); } + + @Test + void parsesProvisioningAndLifecycleWithoutAcceptingUnknownMembers() { + assertEquals( + "did:web:service.example", + AepJson.parsePlatformProvisionRequest("{\"service_did\":\"did:web:service.example\"}") + .serviceDid()); + for (String status : List.of("active", "revoked", "suspended", "terminated")) { + assertEquals( + status, + AepJson.parsePlatformLifecycleRequest("{\"status\":\"" + status + "\"}") + .status() + .value()); + } + for (String json : List.of( + "{}", "{\"status\":null}", "{\"status\":\"pending\"}", "{\"status\":\"active\",\"extra\":true}")) { + assertThrows(AepValidationException.class, () -> AepJson.parsePlatformLifecycleRequest(json), json); + } + assertThrows(AepValidationException.class, () -> AepJson.parsePlatformSignResponse("{\"status\":\"unknown\"}")); + } + + @Test + void serializesStatusWithoutEmptyLifecycleMetadata() { + var status = new foundation.aep.core.StatusResponse( + foundation.aep.core.AgentStatus.ACTIVE, "false", List.of(), List.of(), null); + assertEquals("{\"status\":\"active\"}", AepJson.write(status)); + var pending = new foundation.aep.core.StatusResponse( + foundation.aep.core.AgentStatus.PENDING, + "true", + List.of("contact.email"), + List.of("contact.mobile"), + null); + assertEquals(pending, AepJson.parseStatusResponse(AepJson.write(pending))); + AepJson.requireObject("{}", "example"); + assertThrows(AepValidationException.class, () -> AepJson.requireObject("[]", "example")); + } + + @Test + void rejectsAddressTypeCoercion() { + for (String address : List.of( + "42", + "[]", + "\"address\"", + "{\"country\":\"US\",\"first_name\":\"Ada\",\"last_name\":\"Lovelace\",\"line1\":42}")) { + assertThrows( + AepValidationException.class, + () -> AepJson.parseClaimValues("{\"contact.address.primary\":" + address + "}"), + address); + } + } } diff --git a/aep-platform/README.md b/aep-platform/README.md index d181d85..76ae973 100644 --- a/aep-platform/README.md +++ b/aep-platform/README.md @@ -46,21 +46,25 @@ requested Service and operation: ```java PlatformRequestContext provisionContext = new PlatformRequestContext(principal, idempotencyKey); -PlatformAgentIdentity identity = platform.provision( +PlatformResponse provisioned = platform.provision( new PlatformProvisionRequest(serviceDid), provisionContext) .toCompletableFuture() - .join() - .body(); + .join(); +if (provisioned.status() != 200) { + throw new IllegalStateException("Provisioning failed with HTTP " + provisioned.status()); +} +PlatformAgentIdentity identity = provisioned.body(); PlatformSignRequest request = new PlatformSignRequest( jwtId, "300", AssertionOperation.ENROLL, platformContext, null, serviceDid); -PlatformSignResponses.Response response = platform.sign( +PlatformResponse signed = platform.sign( identity.agentIdentityId(), request, signContext) .toCompletableFuture() - .join() - .body(); + .join(); ``` +Check `signed.status()` before consuming the result: `200` contains a completed assertion, `202` +contains a pending response, and an error response exposes Problem Details through `problem()`. An application may return a pending signing response through `PlatformSignHandler` when signing requires an approval. Hosted verification is optional and must match the discovery document. The Platform applies lifecycle state to signing and DID document availability. diff --git a/aep-platform/src/main/java/foundation/aep/platform/AepPlatform.java b/aep-platform/src/main/java/foundation/aep/platform/AepPlatform.java index a1a98ba..dee9f44 100644 --- a/aep-platform/src/main/java/foundation/aep/platform/AepPlatform.java +++ b/aep-platform/src/main/java/foundation/aep/platform/AepPlatform.java @@ -1,10 +1,12 @@ package foundation.aep.platform; +import com.nimbusds.jwt.SignedJWT; import foundation.aep.core.Aep; import foundation.aep.core.AepValidation; import foundation.aep.core.ClientAssertionClaims; import foundation.aep.core.ClientAssertionVerification; import foundation.aep.core.ClientAssertions; +import foundation.aep.core.DidWeb; import foundation.aep.core.ManagedAgentStatus; import foundation.aep.core.PlatformAgentIdentity; import foundation.aep.core.PlatformAgentIdentityListResponse; @@ -22,15 +24,19 @@ import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; +import java.text.ParseException; import java.time.Clock; import java.time.Duration; import java.time.Instant; +import java.time.OffsetDateTime; import java.time.ZoneOffset; import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; import java.util.ArrayList; import java.util.Comparator; import java.util.LinkedHashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Objects; import java.util.Optional; @@ -46,6 +52,10 @@ public final class AepPlatform { private static final String IDENTITY_NOT_RECOGNIZED = "Identity not recognized."; private static final String INVALID_REQUEST = "invalid_request"; private static final String NOT_RECOGNIZED = "not_recognized"; + private static final String DID_URL_PROBE = "aep-platform-did-url-probe"; + private static final DateTimeFormatter HTTP_DATE = DateTimeFormatter.ofPattern( + "EEE, dd MMM yyyy HH:mm:ss 'GMT'", Locale.ROOT) + .withZone(ZoneOffset.UTC); private final PlatformDiscoveryDocument platformDiscovery; private final String didHost; private final String didPathPrefix; @@ -62,6 +72,8 @@ public final class AepPlatform { private final Duration maximumLifetime; private final Supplier identityIdSupplier; private final Supplier agentDidIdSupplier; + private final String discoveryETag; + private final String discoveryModified; private AepPlatform(Builder builder) { platformDiscovery = AepValidation.requirePlatformDiscoveryDocument(builder.configuredDiscovery); @@ -93,8 +105,9 @@ private AepPlatform(Builder builder) { throw new IllegalArgumentException( "Hosted verification capability and endpoint must be configured together."); } - renderDidDocumentUrl("validation"); - createServiceScopedAgentDid("validation"); + validateDidUrlTemplate(); + discoveryETag = entityTag(platformDiscovery); + discoveryModified = HTTP_DATE.format(clock.instant()); } public static Builder builder( @@ -107,7 +120,14 @@ public static Builder builder( } public PlatformResponse discovery() { - return success(200, platformDiscovery, Aep.MEDIA_TYPE, Map.of("Cache-Control", List.of("max-age=300"))); + // PLT-DISC-012 asks for validators alongside the freshness lifetime, and PLT-DISC-013 makes + // a client honour them. Sending only Cache-Control left every expiry as a full refetch, + // because the client had nothing to put in an If-None-Match or If-Modified-Since request. + Map> headers = new LinkedHashMap<>(); + headers.put("Cache-Control", List.of("max-age=300")); + headers.put("ETag", List.of(discoveryETag)); + headers.put("Last-Modified", List.of(discoveryModified)); + return success(200, platformDiscovery, Aep.MEDIA_TYPE, headers); } public CompletionStage> getDidDocument( @@ -170,23 +190,27 @@ public CompletionStage> provision( if (!AepValidation.platformProvisionRequest(request).isEmpty()) { return completed(problem(400, INVALID_REQUEST, "service_did must be a DID.")); } - return idempotent( - PlatformIdempotentOperation.PROVISION, - List.of(request.serviceDid()), - effectiveContext, - () -> authorizer - .authorize(PlatformAuthorizationRequest.provision(request), effectiveContext) - .thenCompose(authorized -> { - if (!authorized) { - return completed(problem(404, NOT_RECOGNIZED, IDENTITY_NOT_RECOGNIZED)); - } - return serviceDidResolver + if (!hasIdempotencyContext(effectiveContext)) { + return completed( + problem(400, INVALID_REQUEST, "Idempotency-Key and authenticated principal are required.")); + } + return authorizer + .authorize(PlatformAuthorizationRequest.provision(request), effectiveContext) + .thenCompose(authorized -> { + if (!authorized) { + return completed(problem(404, NOT_RECOGNIZED, IDENTITY_NOT_RECOGNIZED)); + } + return idempotent( + PlatformIdempotentOperation.PROVISION, + List.of(request.serviceDid()), + effectiveContext, + () -> serviceDidResolver .resolve(request.serviceDid(), effectiveContext) .thenCompose(resolved -> resolved ? provisionResolved(request.serviceDid(), effectiveContext) : completed(problem( - 400, INVALID_REQUEST, "Service DID could not be resolved."))); - })); + 400, INVALID_REQUEST, "Service DID could not be resolved.")))); + }); } public CompletionStage> sign( @@ -204,17 +228,22 @@ public CompletionStage> sign( if (lifetime.compareTo(maximumLifetime) > 0) { return completed(problem(400, INVALID_REQUEST, "lifetime_seconds exceeds the configured maximum.")); } - return idempotent( - PlatformIdempotentOperation.SIGN, - List.of(agentIdentityId, requestMaterial(request)), - effectiveContext, - () -> authorizedIdentity( - agentIdentityId, + if (!hasIdempotencyContext(effectiveContext)) { + return completed( + problem(400, INVALID_REQUEST, "Idempotency-Key and authenticated principal are required.")); + } + return authorizedIdentity( + agentIdentityId, + effectiveContext, + identity -> PlatformAuthorizationRequest.sign(identity, request)) + .thenCompose(identity -> identity.filter( + value -> value.serviceDid().equals(request.serviceDid())) + .map(value -> idempotent( + PlatformIdempotentOperation.SIGN, + List.of(agentIdentityId, requestMaterial(request)), effectiveContext, - identity -> PlatformAuthorizationRequest.sign(identity, request)) - .thenCompose(identity -> identity.map( - value -> signAuthorized(value, request, lifetime, effectiveContext)) - .orElseGet(() -> completed(problem(404, NOT_RECOGNIZED, IDENTITY_NOT_RECOGNIZED))))); + () -> signAuthorized(value, request, lifetime, effectiveContext))) + .orElseGet(() -> completed(problem(404, NOT_RECOGNIZED, IDENTITY_NOT_RECOGNIZED)))); } public CompletionStage> updateIdentity( @@ -260,22 +289,16 @@ public CompletionStage> verify( if (!AepValidation.platformVerificationRequest(request).isEmpty()) { return completed(problem(400, INVALID_REQUEST, "Hosted verification request is invalid.")); } - return idempotent( - PlatformIdempotentOperation.HOSTED_VERIFICATION, - listIncludingNulls( - request.clientAssertion(), - request.operation().value(), - request.resource(), - request.serviceDid()), - effectiveContext, - () -> verifyNew(request, effectiveContext)); + if (!hasIdempotencyContext(effectiveContext)) { + return completed( + problem(400, INVALID_REQUEST, "Idempotency-Key and authenticated principal are required.")); + } + return verifyAuthorized(request, effectiveContext); } + // Provision validates and authorizes the principal before the store scopes the identity to it. private CompletionStage> provisionResolved( String serviceDid, PlatformRequestContext context) { - if (!hasPrincipal(context)) { - return completed(problem(400, INVALID_REQUEST, "An authenticated principal is required.")); - } return identityStore .findOrCreate(context.principal(), serviceDid, () -> createIdentity(serviceDid, context)) .thenApply(selection -> success(200, publicIdentity(selection.identity()))); @@ -311,7 +334,10 @@ private CompletionStage> signAu return completed(problem(404, NOT_RECOGNIZED, IDENTITY_NOT_RECOGNIZED)); } return lifecyclePolicy.canSign(identity, context).thenCompose(allowed -> { - if (!allowed) { + // PLT-LIFE-002 and PLT-SIGN-007 forbid signing for a revoked, suspended, or terminated + // identity outright, so the configured policy may only narrow this floor. Consulting the + // policy alone let a custom one widen a MUST NOT back open. + if (!allowed || identity.status() != ManagedAgentStatus.ACTIVE) { return completed(problem(403, lifecycleProblemCode(identity.status()), "Identity cannot sign.")); } if (signHandler == null) return signWithKeyStore(identity, request, lifetime, context); @@ -355,9 +381,23 @@ private CompletionStage> valida PlatformResponse response, PlatformIdentityRecord identity, PlatformSignRequest request) { + // PLT-SIGN-017 keeps the HTTP Retry-After header off this contract entirely, not only off a + // pending response: polling cadence travels in retry_after_seconds. The header is checked + // before the Problem Details branch so a handler cannot smuggle it in on an error either. + if (hasRetryAfter(response.headers())) { + return CompletableFuture.failedFuture( + new IllegalArgumentException("Platform sign handler must not send a Retry-After header.")); + } if (validProblem(response)) return completed(response); - if (response == null - || (response.status() != 200 && response.status() != 202) + // PLT-SIGN-010 caps the effective assertion lifetime, and the configured maximum may cut it + // shorter. This runs ahead of the schema gate so an unparseable or unbounded validity window + // is reported as the lifetime failure it is rather than as a generic shape mismatch. + if (response.body() instanceof PlatformSignResponses.Completed bounded && !boundedLifetime(bounded)) { + return CompletableFuture.failedFuture(new IllegalArgumentException( + "Platform sign handler returned an assertion lifetime outside the permitted window.")); + } + if ((response.status() != 200 && response.status() != 202) + || !Aep.MEDIA_TYPE.equals(response.contentType()) || response.body() == null || response.problem() != null || !AepValidation.platformSignResponse(response.body()).isEmpty()) { @@ -379,17 +419,28 @@ private CompletionStage> valida return completed(response); } - private CompletionStage> verifyNew( + private CompletionStage> verifyAuthorized( PlatformVerificationRequest request, PlatformRequestContext context) { + String keyId = assertionKeyId(request.clientAssertion()); ClientAssertionClaims decoded; try { decoded = ClientAssertions.decodeUnverified(request.clientAssertion()); } catch (IllegalArgumentException exception) { return completed(unrecognized(request.serviceDid())); } - if (!decoded.issuer().equals(decoded.subject())) return completed(unrecognized(request.serviceDid())); + // PLT-SIGN-022 binds the JOSE kid of a Platform-hosted identity to the Service-scoped Agent + // DID carried in key_id, with no verification-method fragment. AEP core accepts a fragment + // because an ordinary did:web Agent may publish several methods, so the Platform-specific + // narrowing has to happen here. + if (decoded.issuer() == null + || !decoded.issuer().equals(decoded.subject()) + || !decoded.issuer().equals(keyId)) { + return completed(unrecognized(request.serviceDid())); + } return identityStore.findByAgentDid(decoded.issuer()).thenCompose(identity -> { - if (identity.isEmpty() || !identity.get().serviceDid().equals(request.serviceDid())) { + if (identity.isEmpty() + || !identity.get().serviceDid().equals(request.serviceDid()) + || !identity.get().keyId().equals(keyId)) { return completed(unrecognized(request.serviceDid())); } PlatformIdentityRecord record = identity.get(); @@ -397,11 +448,23 @@ private CompletionStage> verifyNe .authorize(PlatformAuthorizationRequest.verify(record, request), context) .thenCompose(authorized -> { if (!authorized) return completed(unrecognized(request.serviceDid())); - return lifecyclePolicy - .canVerify(record, context) - .thenCompose(allowed -> allowed - ? verifyAssertion(record, request, context) - : completed(unrecognized(request.serviceDid()))); + // PLT-VER-004 makes the current lifecycle state part of what verification + // checks, and PLT-LIFE-002 settles what that state has to be. As on the + // signing path, the configured policy narrows this floor rather than + // replacing it. + return idempotent( + PlatformIdempotentOperation.HOSTED_VERIFICATION, + listIncludingNulls( + request.clientAssertion(), + request.operation().value(), + request.resource(), + request.serviceDid()), + context, + () -> lifecyclePolicy + .canVerify(record, context) + .thenCompose(allowed -> allowed && record.status() == ManagedAgentStatus.ACTIVE + ? verifyAssertion(record, request, context) + : completed(unrecognized(request.serviceDid())))); }); }); } @@ -472,12 +535,6 @@ private CompletionStage> idempotent( Object material, PlatformRequestContext context, Supplier>> execute) { - if (!hasPrincipal(context) - || context.idempotencyKey() == null - || context.idempotencyKey().isBlank()) { - return completed( - problem(400, INVALID_REQUEST, "Idempotency-Key and authenticated principal are required.")); - } PlatformIdempotencyInput input = new PlatformIdempotencyInput( context.principal(), context.idempotencyKey(), operation, fingerprint(material)); return idempotencyStore @@ -487,6 +544,12 @@ private CompletionStage> idempotent( : result.response()); } + private static boolean hasIdempotencyContext(PlatformRequestContext context) { + return hasPrincipal(context) + && context.idempotencyKey() != null + && !context.idempotencyKey().isBlank(); + } + private static PlatformAgentIdentity publicIdentity(PlatformIdentityRecord identity) { return new PlatformAgentIdentity( identity.agentDid(), @@ -521,27 +584,86 @@ private String createServiceScopedAgentDid(String agentDidId) { List components = new ArrayList<>(); components.add("did:web"); components.add(encodeDidComponent(didHost)); + // normalizeDidPathPrefix has already refused a blank, ".", or ".." component, so every split + // part is a real path segment. for (String component : didPathPrefix.split("/")) { - if (!component.isEmpty()) components.add(encodeDidComponent(component)); + components.add(encodeDidComponent(component)); } components.add(encodeDidComponent(agentDidId)); return String.join(":", components); } + // requirePlatformDiscoveryDocument has already established that the template names + // {agent_did_id} and renders an absolute HTTPS URL carrying no user information and no fragment, + // and encodePathSegment keeps a generated identifier inside a single path segment. The shape the + // constructor probed is therefore the shape every identity gets. private String renderDidDocumentUrl(String agentDidId) { - String template = platformDiscovery.identity().didUrlTemplate(); - if (!template.contains("{agent_did_id}")) { - throw new IllegalArgumentException("did_url_template must include {agent_did_id}."); + return platformDiscovery.identity().didUrlTemplate().replace("{agent_did_id}", encodePathSegment(agentDidId)); + } + + // PLT-DID-005 lets a Service treat a Platform-hosted Agent DID as an ordinary did:web DID, and + // PLT-DISC-006 makes did_url_template describe where those documents actually live. The DID host, + // the DID path prefix, and the template are configured independently, so nothing stopped a + // Platform from advertising a did_document_url no Service would ever fetch. Probing both with one + // identifier settles it at construction instead of at the first Service handshake. + private void validateDidUrlTemplate() { + String rendered = renderDidDocumentUrl(DID_URL_PROBE); + URI resolved = DidWeb.documentUri(createServiceScopedAgentDid(DID_URL_PROBE)); + if (!rendered.equals(resolved.toString())) { + throw new IllegalArgumentException("did_url_template renders " + rendered + + " where resolving the minted Agent DID reaches " + resolved + "."); } - String rendered = template.replace("{agent_did_id}", encodePathSegment(agentDidId)); - URI uri = URI.create(rendered); - if (!"https".equalsIgnoreCase(uri.getScheme()) - || uri.getHost() == null - || uri.getUserInfo() != null - || uri.getFragment() != null) { - throw new IllegalArgumentException("did_url_template must render an absolute HTTPS URL."); + } + + // A sign handler mints its own issued_at and expires_at, so the bound the key-store path inherits + // from the validated request has to be re-checked against what the handler actually returned. + private boolean boundedLifetime(PlatformSignResponses.Completed completed) { + Instant issuedAt; + Instant expiresAt; + try { + issuedAt = OffsetDateTime.parse(completed.issuedAt()).toInstant(); + expiresAt = OffsetDateTime.parse(completed.expiresAt()).toInstant(); + } catch (DateTimeParseException exception) { + return false; } - return rendered; + Duration lifetime = Duration.between(issuedAt, expiresAt); + return !lifetime.isZero() && !lifetime.isNegative() && lifetime.compareTo(maximumLifetime) <= 0; + } + + private static boolean hasRetryAfter(Map> headers) { + return headers.keySet().stream().anyMatch(name -> "retry-after".equalsIgnoreCase(name)); + } + + // Returns null for a token that is not a signed JWT or carries no kid, which the caller then + // fails on the Agent DID comparison rather than by unwinding a second parse failure. + private static String assertionKeyId(String assertion) { + try { + return SignedJWT.parse(assertion).getHeader().getKeyID(); + } catch (ParseException exception) { + return null; + } + } + + private static String entityTag(PlatformDiscoveryDocument document) { + return "\"sha256:" + fingerprint(discoveryMaterial(document)) + "\""; + } + + private static List discoveryMaterial(PlatformDiscoveryDocument document) { + return listIncludingNulls( + document.version(), + document.endpoints().hostedVerification(), + document.endpoints().lifecycle(), + document.endpoints().list(), + document.endpoints().provision(), + document.endpoints().sign(), + document.http().endpointBase(), + document.identity().didMethods(), + document.identity().didUrlTemplate(), + document.platform().did(), + document.platform().hostedVerification(), + document.platform().name(), + document.signing().algorithms(), + document.signing().defaultLifetimeSeconds()); } private static boolean validListQuery(PlatformIdentityListQuery query) { @@ -664,12 +786,15 @@ private static PlatformRequestContext context(PlatformRequestContext context) { return context == null ? new PlatformRequestContext(null, null) : context; } + // RFC 9457 makes the status member the HTTP status code of the response it travels in, so a + // handler reporting one code in the body and another on the wire has produced a Problem Details + // document no client can act on consistently. private static boolean validProblem(PlatformResponse response) { - return response != null - && response.status() >= 400 + return response.status() >= 400 && Aep.PROBLEM_MEDIA_TYPE.equals(response.contentType()) && response.body() == null && response.problem() != null + && response.problem().status() == response.status() && AepValidation.problemDetails(response.problem()).isEmpty(); } diff --git a/aep-platform/src/main/java/foundation/aep/platform/PlatformCopies.java b/aep-platform/src/main/java/foundation/aep/platform/PlatformCopies.java index efc5d4b..3c72a6c 100644 --- a/aep-platform/src/main/java/foundation/aep/platform/PlatformCopies.java +++ b/aep-platform/src/main/java/foundation/aep/platform/PlatformCopies.java @@ -8,13 +8,53 @@ final class PlatformCopies { private PlatformCopies() {} + // Platform responses carry headers straight out into an HTTP message, and a sign handler or an + // embedding application supplies them. An unchecked name or value let a bare CR or LF terminate + // the field early and inject a header, or a whole response, that nothing in AEP ever authored. static Map> headers(Map> source) { if (source == null) return Map.of(); Map> result = new LinkedHashMap<>(); - source.forEach((name, values) -> result.put(name, List.copyOf(values))); + source.forEach((name, values) -> result.put(requireFieldName(name), requireFieldValues(values))); return Map.copyOf(result); } + private static String requireFieldName(String value) { + if (value == null || value.isEmpty()) throw new IllegalArgumentException("Header names must not be empty."); + for (int index = 0; index < value.length(); index++) { + if (!isTokenCharacter(value.charAt(index))) { + throw new IllegalArgumentException("Header names must be RFC 9110 tokens."); + } + } + return value; + } + + private static List requireFieldValues(List values) { + if (values == null || values.isEmpty()) { + throw new IllegalArgumentException("Header values must not be empty."); + } + return values.stream().map(PlatformCopies::requireFieldValue).toList(); + } + + private static String requireFieldValue(String value) { + if (value == null || !value.equals(value.strip())) { + throw new IllegalArgumentException("Header values must not be null or surrounded by whitespace."); + } + for (int index = 0; index < value.length(); index++) { + char character = value.charAt(index); + if (character != '\t' && (character < ' ' || character == 0x7f)) { + throw new IllegalArgumentException("Header values must not contain control characters."); + } + } + return value; + } + + private static boolean isTokenCharacter(char value) { + return value >= '0' && value <= '9' + || value >= 'a' && value <= 'z' + || value >= 'A' && value <= 'Z' + || "!#$%&'*+-.^_`|~".indexOf(value) >= 0; + } + static Map jsonObject(Map source) { if (source == null) return Map.of(); Map result = new LinkedHashMap<>(); diff --git a/aep-platform/src/main/java/foundation/aep/platform/PlatformIdempotencyStore.java b/aep-platform/src/main/java/foundation/aep/platform/PlatformIdempotencyStore.java index bad9322..442ce87 100644 --- a/aep-platform/src/main/java/foundation/aep/platform/PlatformIdempotencyStore.java +++ b/aep-platform/src/main/java/foundation/aep/platform/PlatformIdempotencyStore.java @@ -92,9 +92,11 @@ private synchronized void complete( destination.complete(response); } + // PLT-IDP-004 requires the replayable result to survive for at least an hour, so a record + // whose age is exactly the retention window is still inside it and has to be kept. private synchronized void expire() { Instant cutoff = clock.instant().minus(RETENTION); - entries.values().removeIf(entry -> !entry.createdAt.isAfter(cutoff)); + entries.values().removeIf(entry -> entry.createdAt.isBefore(cutoff)); } private static boolean sameRequest(PlatformIdempotencyInput first, PlatformIdempotencyInput second) { diff --git a/aep-platform/src/main/java/foundation/aep/platform/PlatformIdentityStore.java b/aep-platform/src/main/java/foundation/aep/platform/PlatformIdentityStore.java index a6b1313..eabc209 100644 --- a/aep-platform/src/main/java/foundation/aep/platform/PlatformIdentityStore.java +++ b/aep-platform/src/main/java/foundation/aep/platform/PlatformIdentityStore.java @@ -131,7 +131,7 @@ public synchronized CompletionStage> update( private synchronized CompletionStage> find(String identityId) { return CompletableFuture.completedFuture( - identityId == null ? Optional.empty() : Optional.of(records.get(identityId))); + identityId == null ? Optional.empty() : Optional.ofNullable(records.get(identityId))); } private synchronized void complete( diff --git a/aep-platform/src/test/java/foundation/aep/platform/AepPlatformTest.java b/aep-platform/src/test/java/foundation/aep/platform/AepPlatformTest.java index fae70e0..6e5f3f9 100644 --- a/aep-platform/src/test/java/foundation/aep/platform/AepPlatformTest.java +++ b/aep-platform/src/test/java/foundation/aep/platform/AepPlatformTest.java @@ -346,6 +346,7 @@ private static AepPlatform.Builder platformBuilder( AtomicInteger did = new AtomicInteger(); return AepPlatform.builder(discovery(hostedVerification), "p.example", authorizer, keyStore, resolve()) .clock(Clock.fixed(NOW, ZoneOffset.UTC)) + .didPathPrefix("a") .identityIdSupplier(() -> Integer.toString(identity.incrementAndGet())) .agentDidIdSupplier(() -> "opaque" + did.incrementAndGet()) .replayStore(PlatformReplayStore.inMemory()); @@ -385,12 +386,12 @@ private static String agentDidId(String agentDid) { private static PlatformIdentityRecord identityRecord() { return PlatformIdentityRecord.builder() - .agentDid("did:web:p.example:agents:opaque") + .agentDid("did:web:p.example:a:opaque") .agentDidId("opaque") .agentIdentityId("pai_identity") .createdAt(NOW) .didDocumentUrl("https://p.example/a/opaque/did.json") - .keyId("did:web:p.example:agents:opaque") + .keyId("did:web:p.example:a:opaque") .principal("principal") .serviceDid(SERVICE_DID) .signingAlgorithms(List.of("ES256")) diff --git a/aep-platform/src/test/java/foundation/aep/platform/PlatformConformanceTest.java b/aep-platform/src/test/java/foundation/aep/platform/PlatformConformanceTest.java new file mode 100644 index 0000000..3ba50e3 --- /dev/null +++ b/aep-platform/src/test/java/foundation/aep/platform/PlatformConformanceTest.java @@ -0,0 +1,838 @@ +package foundation.aep.platform; + +import static foundation.aep.platform.PlatformFixtures.DID_URL_TEMPLATE; +import static foundation.aep.platform.PlatformFixtures.JWT_ID; +import static foundation.aep.platform.PlatformFixtures.NOW; +import static foundation.aep.platform.PlatformFixtures.OTHER_SERVICE_DID; +import static foundation.aep.platform.PlatformFixtures.PROVISION_KEY; +import static foundation.aep.platform.PlatformFixtures.SERVICE_DID; +import static foundation.aep.platform.PlatformFixtures.allow; +import static foundation.aep.platform.PlatformFixtures.builder; +import static foundation.aep.platform.PlatformFixtures.completed; +import static foundation.aep.platform.PlatformFixtures.context; +import static foundation.aep.platform.PlatformFixtures.discovery; +import static foundation.aep.platform.PlatformFixtures.platform; +import static foundation.aep.platform.PlatformFixtures.provision; +import static foundation.aep.platform.PlatformFixtures.resolver; +import static foundation.aep.platform.PlatformFixtures.signRequest; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.JOSEObjectType; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.crypto.ECDSASigner; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; +import foundation.aep.core.Aep; +import foundation.aep.core.AssertionOperation; +import foundation.aep.core.ManagedAgentStatus; +import foundation.aep.core.PlatformAgentIdentity; +import foundation.aep.core.PlatformLifecycleRequest; +import foundation.aep.core.PlatformProvisionRequest; +import foundation.aep.core.PlatformSignRequest; +import foundation.aep.core.PlatformSignResponses; +import foundation.aep.core.PlatformVerificationRequest; +import foundation.aep.core.ProblemDetails; +import java.time.Duration; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; + +/** Requirement-level checks for draft-kavian-aep-platform-hosted-identity-01. */ +final class PlatformConformanceTest { + private static final String SIGN_KEY = "sign-1"; + private static final String VERIFY_KEY = "verify-1"; + private static final String NOT_RECOGNIZED = "not_recognized"; + private static final String ISSUED_AT = "2026-07-06T12:00:00Z"; + private static final String RETRY_AFTER = "Retry-After"; + + @Test + void authorizesEveryRetryWithoutRepeatingCompletedOperations() throws JOSEException { + var allowed = new AtomicBoolean(true); + var checks = new AtomicInteger(); + var keys = new PlatformFixtures.Keys(); + var platform = builder( + (request, context) -> { + checks.incrementAndGet(); + return completed(allowed.get()); + }, + keys, + true) + .build(); + var provisionRequest = new PlatformProvisionRequest(SERVICE_DID); + var provisioned = platform.provision(provisionRequest, context(PROVISION_KEY)) + .toCompletableFuture() + .join(); + var identity = provisioned.body(); + var signed = platform.sign(identity.agentIdentityId(), signRequest(null), context(SIGN_KEY)) + .toCompletableFuture() + .join(); + var assertion = ((PlatformSignResponses.Completed) signed.body()).clientAssertion(); + var verification = new PlatformVerificationRequest(assertion, AssertionOperation.ENROLL, null, SERVICE_DID); + var verified = platform.verify(verification, context(VERIFY_KEY)) + .toCompletableFuture() + .join(); + assertTrue(verified.body().verified()); + + allowed.set(false); + assertEquals( + 404, + platform.provision(provisionRequest, context(PROVISION_KEY)) + .toCompletableFuture() + .join() + .status()); + assertEquals( + 404, + platform.sign(identity.agentIdentityId(), signRequest(null), context(SIGN_KEY)) + .toCompletableFuture() + .join() + .status()); + assertFalse(platform.verify(verification, context(VERIFY_KEY)) + .toCompletableFuture() + .join() + .body() + .verified()); + assertEquals(6, checks.get()); + + allowed.set(true); + assertEquals( + provisioned, + platform.provision(provisionRequest, context(PROVISION_KEY)) + .toCompletableFuture() + .join()); + assertEquals( + signed, + platform.sign(identity.agentIdentityId(), signRequest(null), context(SIGN_KEY)) + .toCompletableFuture() + .join()); + assertEquals( + verified, + platform.verify(verification, context(VERIFY_KEY)) + .toCompletableFuture() + .join()); + assertEquals(9, checks.get()); + assertEquals(1, keys.created.get()); + assertFalse(platform.verify(verification, context("another-verification")) + .toCompletableFuture() + .join() + .body() + .verified()); + } + + @Test + void deniedProvisionDoesNotReserveTheIdempotencyKey() throws JOSEException { + var allowed = new AtomicBoolean(false); + var platform = platform((request, context) -> completed(allowed.get()), new PlatformFixtures.Keys(), true); + var request = new PlatformProvisionRequest(SERVICE_DID); + assertEquals( + 404, + platform.provision(request, context(PROVISION_KEY)) + .toCompletableFuture() + .join() + .status()); + allowed.set(true); + assertEquals( + 200, + platform.provision(request, context(PROVISION_KEY)) + .toCompletableFuture() + .join() + .status()); + } + + @Test + void authorizesInFlightRetriesAndPreservesPendingResponses() throws JOSEException { + var allowed = new AtomicBoolean(true); + var calls = new AtomicInteger(); + var result = new CompletableFuture>(); + var platform = builder((request, context) -> completed(allowed.get()), new PlatformFixtures.Keys(), true) + .signHandler((identity, request, context) -> { + calls.incrementAndGet(); + return result.thenApply(Optional::of); + }) + .build(); + var identity = provision(platform, SERVICE_DID, PROVISION_KEY); + var first = platform.sign(identity.agentIdentityId(), signRequest(null), context(SIGN_KEY)) + .toCompletableFuture(); + allowed.set(false); + assertEquals( + 404, + platform.sign(identity.agentIdentityId(), signRequest(null), context(SIGN_KEY)) + .toCompletableFuture() + .join() + .status()); + allowed.set(true); + var retry = platform.sign(identity.agentIdentityId(), signRequest(null), context(SIGN_KEY)) + .toCompletableFuture(); + assertFalse(retry.isDone()); + result.complete(pending(Map.of())); + assertEquals(202, first.join().status()); + assertEquals(first.join(), retry.join()); + assertEquals( + first.join(), + platform.sign(identity.agentIdentityId(), signRequest(null), context(SIGN_KEY)) + .toCompletableFuture() + .join()); + assertEquals(1, calls.get()); + } + + @Test + void missingAssertionBindingsReturnUnrecognized() throws JOSEException { + var keys = new PlatformFixtures.Keys(); + var platform = platform(allow(), keys, true); + var identity = provision(platform, SERVICE_DID, PROVISION_KEY); + for (String missing : List.of("iss", "sub", "kid")) { + var claims = new JWTClaimsSet.Builder() + .issuer(missing.equals("iss") ? null : identity.agentDid()) + .subject(missing.equals("sub") ? null : identity.agentDid()) + .audience(SERVICE_DID) + .claim("op", "enroll") + .issueTime(Date.from(NOW)) + .expirationTime(Date.from(NOW.plusSeconds(60))) + .jwtID("missing-" + missing) + .build(); + var jwt = new SignedJWT( + new JWSHeader.Builder(JWSAlgorithm.ES256) + .type(JOSEObjectType.JWT) + .keyID(missing.equals("kid") ? null : identity.agentDid()) + .build(), + claims); + jwt.sign(new ECDSASigner(keys.key)); + var response = platform.verify( + new PlatformVerificationRequest( + jwt.serialize(), AssertionOperation.ENROLL, null, SERVICE_DID), + context("missing-" + missing)) + .toCompletableFuture() + .join(); + assertEquals(200, response.status()); + assertFalse(response.body().verified()); + assertEquals(NOT_RECOGNIZED, response.body().reason()); + } + } + + /** PLT-DISC-012, PLT-DISC-013, PLT-DISC-015: freshness plus validators on Discovery. */ + @Test + void discoveryCarriesFreshnessAndCacheValidators() throws JOSEException { + var headers = platform(allow(), new PlatformFixtures.Keys(), false) + .discovery() + .headers(); + + assertEquals(List.of("max-age=300"), headers.get("Cache-Control")); + assertTrue(headers.get("ETag").get(0).startsWith("\"sha256:")); + assertEquals(List.of("Mon, 06 Jul 2026 12:00:00 GMT"), headers.get("Last-Modified")); + } + + /** PLT-DISC-012: the validator is derived from the document, so unlike documents differ. */ + @Test + void discoveryEntityTagTracksDocumentContent() throws JOSEException { + PlatformFixtures.Keys keys = new PlatformFixtures.Keys(); + String first = + platform(allow(), keys, false).discovery().headers().get("ETag").get(0); + String same = + platform(allow(), keys, false).discovery().headers().get("ETag").get(0); + String other = builder(discovery(false, DID_URL_TEMPLATE, "120"), allow(), keys, resolver(true)) + .build() + .discovery() + .headers() + .get("ETag") + .get(0); + + assertEquals(first, same); + assertNotEquals(first, other); + } + + /** PLT-DID-005, PLT-DISC-006: the advertised template must be where did:web resolution lands. */ + @Test + void rejectsDidUrlTemplateThatDoesNotMatchDidWebResolution() throws JOSEException { + PlatformFixtures.Keys keys = new PlatformFixtures.Keys(); + var mismatched = builder( + discovery(false, "https://p.example/b/{agent_did_id}/did.json", "300"), allow(), keys, resolver(true)); + var offHost = builder( + discovery(false, "https://other.example/a/{agent_did_id}/did.json", "300"), + allow(), + keys, + resolver(true)); + + assertTrue(assertThrows(IllegalArgumentException.class, mismatched::build) + .getMessage() + .contains("did_url_template renders")); + assertThrows(IllegalArgumentException.class, offHost::build); + } + + /** PLT-LIFE-002, PLT-SIGN-007: a policy hook may narrow the active-only floor, never widen it. */ + @Test + void refusesDelegatedSigningOutsideTheActiveState() throws JOSEException { + AepPlatform platform = builder(allow(), new PlatformFixtures.Keys(), false) + .lifecyclePolicy(permissive()) + .build(); + PlatformAgentIdentity identity = provision(platform, SERVICE_DID, PROVISION_KEY); + + assertEquals("identity_suspended", refusedSign(platform, identity, ManagedAgentStatus.SUSPENDED)); + assertEquals("identity_suspended", refusedSign(platform, identity, ManagedAgentStatus.REVOKED)); + assertEquals("identity_terminated", refusedSign(platform, identity, ManagedAgentStatus.TERMINATED)); + } + + /** PLT-VER-004, PLT-LIFE-002: hosted verification checks the current lifecycle state too. */ + @Test + void refusesHostedVerificationOutsideTheActiveState() throws JOSEException { + PlatformFixtures.Keys keys = new PlatformFixtures.Keys(); + AepPlatform platform = + builder(allow(), keys, true).lifecyclePolicy(permissive()).build(); + PlatformAgentIdentity identity = provision(platform, SERVICE_DID, PROVISION_KEY); + String assertion = signedAssertion(platform, identity); + platform.updateIdentity( + identity.agentIdentityId(), + new PlatformLifecycleRequest(ManagedAgentStatus.SUSPENDED), + context(null)) + .toCompletableFuture() + .join(); + + var response = platform.verify( + new PlatformVerificationRequest(assertion, AssertionOperation.ENROLL, null, SERVICE_DID), + context(VERIFY_KEY)) + .toCompletableFuture() + .join() + .body(); + + assertFalse(response.verified()); + assertEquals(NOT_RECOGNIZED, response.reason()); + assertEquals(null, response.status()); + } + + /** PLT-SIGN-022: a Platform-hosted kid is the Agent DID itself, with no method fragment. */ + @Test + void rejectsAssertionsWhoseKeyIdCarriesAVerificationMethodFragment() throws JOSEException { + PlatformFixtures.Keys keys = new PlatformFixtures.Keys(); + keys.keyIdSuffix = "#key-1"; + AepPlatform platform = platform(allow(), keys, true); + PlatformAgentIdentity identity = provision(platform, SERVICE_DID, PROVISION_KEY); + String assertion = signedAssertion(platform, identity); + + var response = platform.verify( + new PlatformVerificationRequest(assertion, AssertionOperation.ENROLL, null, SERVICE_DID), + context(VERIFY_KEY)) + .toCompletableFuture() + .join() + .body(); + + assertFalse(response.verified()); + assertEquals(NOT_RECOGNIZED, response.reason()); + } + + /** PLT-VER-008, PLT-VER-010: an unrecognized assertion discloses nothing about the identity. */ + @Test + void reportsUnrecognizedForAssertionsItCannotTieToAnIdentity() throws JOSEException { + PlatformFixtures.Keys keys = new PlatformFixtures.Keys(); + AepPlatform platform = platform(allow(), keys, true); + PlatformAgentIdentity identity = provision(platform, SERVICE_DID, PROVISION_KEY); + String assertion = signedAssertion(platform, identity); + + assertFalse(verified(platform, "not-a-jwt", SERVICE_DID, "v-garbage")); + assertFalse(verified(platform, assertion, OTHER_SERVICE_DID, "v-service")); + assertTrue(verified(platform, assertion, SERVICE_DID, "v-good")); + } + + /** PLT-SIGN-017: the HTTP Retry-After header stays off this contract, success or problem. */ + @Test + void rejectsSignHandlerResponsesCarryingRetryAfter() throws JOSEException { + assertThrows( + CompletionException.class, + () -> handledSign(pending(Map.of(RETRY_AFTER, List.of("5")))), + "pending response"); + assertThrows( + CompletionException.class, + () -> handledSign(pending(Map.of("retry-after", List.of("5")))), + "header names are case-insensitive"); + assertThrows( + CompletionException.class, + () -> handledSign(new PlatformResponse<>( + 503, + Aep.PROBLEM_MEDIA_TYPE, + null, + ProblemDetails.of("temporarily_unavailable", "Try later.", 503), + Map.of(RETRY_AFTER, List.of("5")))), + "problem response"); + assertEquals(202, handledSign(pending(Map.of())).status()); + } + + /** PLT-SIGN-010: a handler-minted validity window is bounded like a Platform-minted one. */ + @Test + void boundsSignHandlerAssertionLifetimes() throws JOSEException { + assertEquals( + 200, + handledSign(completedSign(ISSUED_AT, "2026-07-06T12:05:00Z")).status()); + assertEquals( + 200, + handledSign(completedSign(ISSUED_AT, "2026-07-06T12:05:00+00:00")) + .status()); + assertThrows( + CompletionException.class, + () -> handledSign(completedSign(ISSUED_AT, "2026-07-06T12:05:01Z")), + "301 seconds exceeds the maximum"); + assertThrows( + CompletionException.class, + () -> handledSign(completedSign(ISSUED_AT, ISSUED_AT)), + "a zero-length window never validates"); + assertThrows( + CompletionException.class, + () -> handledSign(completedSign("2026-07-06T12:05:00Z", ISSUED_AT)), + "expiry must follow issuance"); + assertThrows( + CompletionException.class, + () -> handledSign(completedSign("whenever", ISSUED_AT)), + "an unparseable instant is not a bounded window"); + } + + /** PLT-SIGN-010, PLT-SIGN-011: a shorter configured maximum also binds the handler path. */ + @Test + void appliesTheConfiguredMaximumToBothSigningPaths() throws JOSEException { + PlatformFixtures.Keys keys = new PlatformFixtures.Keys(); + AepPlatform platform = builder(discovery(false, DID_URL_TEMPLATE, "60"), allow(), keys, resolver(true)) + .maximumLifetime(Duration.ofSeconds(60)) + .build(); + PlatformAgentIdentity identity = provision(platform, SERVICE_DID, PROVISION_KEY); + + assertEquals( + 400, + platform.sign(identity.agentIdentityId(), signRequest("61"), context(SIGN_KEY)) + .toCompletableFuture() + .join() + .status()); + assertEquals( + 200, + platform.sign(identity.agentIdentityId(), signRequest("60"), context("sign-2")) + .toCompletableFuture() + .join() + .status()); + } + + /** PLT-SIGN-008: lifetime_seconds is a canonical decimal string within 1..300. */ + @Test + void rejectsNonCanonicalLifetimeEncodings() throws JOSEException { + AepPlatform platform = platform(allow(), new PlatformFixtures.Keys(), false); + PlatformAgentIdentity identity = provision(platform, SERVICE_DID, PROVISION_KEY); + + assertEquals(400, signStatus(platform, identity, "+60", "s1")); + assertEquals(400, signStatus(platform, identity, "060", "s2")); + assertEquals(400, signStatus(platform, identity, "0", "s3")); + assertEquals(400, signStatus(platform, identity, "301", "s4")); + assertEquals(200, signStatus(platform, identity, "300", "s5")); + } + + /** PLT-IDP-001, PLT-IDP-003: every replayable operation needs a key and a principal. */ + @Test + void requiresAnIdempotencyKeyAndPrincipalOnReplayableOperations() throws JOSEException { + PlatformFixtures.Keys keys = new PlatformFixtures.Keys(); + AepPlatform platform = platform(allow(), keys, true); + PlatformAgentIdentity identity = provision(platform, SERVICE_DID, PROVISION_KEY); + PlatformRequestContext anonymous = new PlatformRequestContext(" ", PROVISION_KEY, NOW, Map.of()); + + assertEquals( + 400, + platform.provision(new foundation.aep.core.PlatformProvisionRequest(SERVICE_DID), context(null)) + .toCompletableFuture() + .join() + .status()); + assertEquals( + 400, + platform.sign(identity.agentIdentityId(), signRequest(null), anonymous) + .toCompletableFuture() + .join() + .status()); + assertEquals( + 400, + platform.verify( + new PlatformVerificationRequest("token", AssertionOperation.ENROLL, null, SERVICE_DID), + context(" ")) + .toCompletableFuture() + .join() + .status()); + } + + /** PLT-IDP-009: the same key for different material is a conflict, not a silent replay. */ + @Test + void reportsConflictWhenAKeyIsReusedForDifferentMaterial() throws JOSEException { + AepPlatform platform = platform(allow(), new PlatformFixtures.Keys(), false); + PlatformAgentIdentity identity = provision(platform, SERVICE_DID, PROVISION_KEY); + + var conflict = platform.sign(identity.agentIdentityId(), signRequest("60"), context(SIGN_KEY)) + .thenCompose( + ignored -> platform.sign(identity.agentIdentityId(), signRequest("120"), context(SIGN_KEY))) + .toCompletableFuture() + .join(); + + assertEquals(409, conflict.status()); + assertEquals("idempotency_conflict", conflict.problem().code()); + } + + /** PLT-VER-001: hosted verification answers only where the Platform advertises it. */ + @Test + void refusesHostedVerificationWhenItIsNotAdvertised() throws JOSEException { + AepPlatform platform = platform(allow(), new PlatformFixtures.Keys(), false); + + var response = platform.verify( + new PlatformVerificationRequest("token", AssertionOperation.ENROLL, null, SERVICE_DID), + context(VERIFY_KEY)) + .toCompletableFuture() + .join(); + + assertEquals(404, response.status()); + assertEquals(NOT_RECOGNIZED, response.problem().code()); + } + + /** PLT-VER-001: the advertised capability and the advertised endpoint travel together. */ + @Test + void rejectsPartialHostedVerificationConfiguration() throws JOSEException { + PlatformFixtures.Keys keys = new PlatformFixtures.Keys(); + var withoutReplayStore = AepPlatform.builder(discovery(true), "p.example", allow(), keys, resolver(true)) + .didPathPrefix("a"); + var capabilityOnly = new foundation.aep.core.PlatformDiscoveryDocument( + "1.0", + new foundation.aep.core.PlatformDiscoveryDocument.Endpoints( + null, + "/v1/aep/agent-identities/{agent_identity_id}", + "/v1/aep/agent-identities", + "/v1/aep/agent-identities", + "/v1/aep/agent-identities/{agent_identity_id}/sign"), + new foundation.aep.core.PlatformDiscoveryDocument.Http("/v1/aep"), + new foundation.aep.core.PlatformDiscoveryDocument.Identity(List.of("did:web"), DID_URL_TEMPLATE), + new foundation.aep.core.PlatformDiscoveryDocument.Platform("did:web:p.example", true, "Example"), + new foundation.aep.core.PlatformDiscoveryDocument.Signing(List.of("ES256"), "300")); + + assertThrows(IllegalArgumentException.class, withoutReplayStore::build); + var endpointMismatch = builder(capabilityOnly, allow(), keys, resolver(true)); + assertThrows(IllegalArgumentException.class, endpointMismatch::build); + } + + /** PLT-VER-006, PLT-VER-007: resource travels with authenticate and with nothing else. */ + @Test + void bindsTheProtectedResourceToAuthenticateOnly() throws JOSEException { + PlatformFixtures.Keys keys = new PlatformFixtures.Keys(); + AepPlatform platform = platform(allow(), keys, true); + PlatformAgentIdentity identity = provision(platform, SERVICE_DID, PROVISION_KEY); + PlatformSignRequest request = new PlatformSignRequest( + JWT_ID, null, AssertionOperation.AUTHENTICATE, null, "https://api.service.example/orders", SERVICE_DID); + var signed = + (PlatformSignResponses.Completed) platform.sign(identity.agentIdentityId(), request, context(SIGN_KEY)) + .toCompletableFuture() + .join() + .body(); + + assertEquals( + 400, + platform.verify( + new PlatformVerificationRequest( + signed.clientAssertion(), AssertionOperation.AUTHENTICATE, null, SERVICE_DID), + context("v-missing")) + .toCompletableFuture() + .join() + .status()); + assertFalse(platform.verify( + new PlatformVerificationRequest( + signed.clientAssertion(), + AssertionOperation.AUTHENTICATE, + "https://api.service.example/other", + SERVICE_DID), + context("v-wrong")) + .toCompletableFuture() + .join() + .body() + .verified()); + assertTrue(platform.verify( + new PlatformVerificationRequest( + signed.clientAssertion(), + AssertionOperation.AUTHENTICATE, + "https://api.service.example/orders", + SERVICE_DID), + context("v-right")) + .toCompletableFuture() + .join() + .body() + .verified()); + } + + /** PLT-PROV-006, PLT-PROV-007: a syntactic DID that no resolver recognizes is refused. */ + @Test + void refusesProvisioningForUnresolvableOrMalformedServiceDids() throws JOSEException { + PlatformFixtures.Keys keys = new PlatformFixtures.Keys(); + AepPlatform unresolvable = + builder(discovery(false), allow(), keys, resolver(false)).build(); + AepPlatform platform = platform(allow(), keys, false); + + assertEquals( + 400, + platform.provision(new foundation.aep.core.PlatformProvisionRequest("not-a-did"), context("p1")) + .toCompletableFuture() + .join() + .status()); + assertEquals( + 400, + unresolvable + .provision(new foundation.aep.core.PlatformProvisionRequest(SERVICE_DID), context("p2")) + .toCompletableFuture() + .join() + .status()); + assertEquals(0, keys.created.get()); + } + + /** PLT-PROV-008, PLT-PROV-012: Service-scoped DIDs share only the Platform-controlled prefix. */ + @Test + void mintsDistinctServiceScopedDidsPerService() throws JOSEException { + AepPlatform platform = platform(allow(), new PlatformFixtures.Keys(), false); + + PlatformAgentIdentity first = provision(platform, SERVICE_DID, "p1"); + PlatformAgentIdentity second = provision(platform, OTHER_SERVICE_DID, "p2"); + + assertNotEquals(first.agentDid(), second.agentDid()); + assertTrue(first.agentDid().startsWith("did:web:p.example:a:")); + assertTrue(second.agentDid().startsWith("did:web:p.example:a:")); + assertFalse(first.agentDid().contains("api.service")); + assertFalse(second.agentDid().contains("billing.service")); + assertEquals(first.didDocumentUrl(), "https://p.example/a/opaque1/did.json"); + } + + /** PLT-LIFE-007, PLT-LIFE-009: a Platform defines its own transitions and reports refusals. */ + @Test + void reportsRefusedLifecycleTransitions() throws JOSEException { + AepPlatform platform = builder(allow(), new PlatformFixtures.Keys(), false) + .lifecyclePolicy(new PlatformLifecyclePolicy() { + @Override + public CompletionStage canSign( + PlatformIdentityRecord identity, PlatformRequestContext context) { + return completed(true); + } + + @Override + public CompletionStage canTransition( + PlatformIdentityRecord identity, + ManagedAgentStatus nextStatus, + PlatformRequestContext context) { + return completed(false); + } + + @Override + public CompletionStage canVerify( + PlatformIdentityRecord identity, PlatformRequestContext context) { + return completed(true); + } + }) + .build(); + PlatformAgentIdentity identity = provision(platform, SERVICE_DID, PROVISION_KEY); + + var refused = platform.updateIdentity( + identity.agentIdentityId(), + new PlatformLifecycleRequest(ManagedAgentStatus.SUSPENDED), + context(null)) + .toCompletableFuture() + .join(); + + assertEquals(403, refused.status()); + assertEquals("identity_unavailable", refused.problem().code()); + } + + /** PLT-ERR-004, PLT-ERR-005: a denied management operation is a bare 404 with no side effect. */ + @Test + void deniedOperationsLookLikeMissingOnes() throws JOSEException { + PlatformFixtures.Keys keys = new PlatformFixtures.Keys(); + AtomicReference authorized = new AtomicReference<>(true); + AepPlatform platform = platform((request, context) -> completed(authorized.get()), keys, false); + PlatformAgentIdentity identity = provision(platform, SERVICE_DID, PROVISION_KEY); + authorized.set(false); + + assertEquals(404, statusOf(platform.getIdentity(identity.agentIdentityId(), context(null)))); + assertEquals(404, statusOf(platform.list(PlatformIdentityListQuery.defaults(), context(null)))); + assertEquals( + 404, + statusOf(platform.updateIdentity( + identity.agentIdentityId(), + new PlatformLifecycleRequest(ManagedAgentStatus.SUSPENDED), + context(null)))); + assertEquals(404, statusOf(platform.sign(identity.agentIdentityId(), signRequest(null), context(SIGN_KEY)))); + assertEquals(1, keys.created.get()); + } + + /** PLT-LIST-003, PLT-LIST-004, PLT-LIST-006: bounded, deterministic, self-describing pages. */ + @Test + void pagesIdentitiesDeterministicallyWithinBounds() throws JOSEException { + AepPlatform platform = platform(allow(), new PlatformFixtures.Keys(), false); + provision(platform, SERVICE_DID, "p1"); + provision(platform, OTHER_SERVICE_DID, "p2"); + + var ascending = platform.list(new PlatformIdentityListQuery(false, 100, 0, null, null), context(null)) + .toCompletableFuture() + .join() + .body(); + var descending = platform.list(new PlatformIdentityListQuery(true, 100, 0, null, null), context(null)) + .toCompletableFuture() + .join() + .body(); + var scoped = platform.list(new PlatformIdentityListQuery(false, 100, 0, SERVICE_DID, null), context(null)) + .toCompletableFuture() + .join() + .body(); + + assertEquals("2", ascending.count()); + assertEquals("2", ascending.total()); + assertEquals( + ascending.data().get(0).agentDid(), descending.data().get(1).agentDid()); + assertEquals("1", scoped.count()); + assertEquals(SERVICE_DID, scoped.data().get(0).serviceDid()); + assertEquals( + 400, statusOf(platform.list(new PlatformIdentityListQuery(false, 0, 0, null, null), context(null)))); + assertEquals( + 400, statusOf(platform.list(new PlatformIdentityListQuery(false, 1, -1, null, null), context(null)))); + assertEquals( + 400, statusOf(platform.list(new PlatformIdentityListQuery(false, 1, 0, "nope", null), context(null)))); + } + + /** PLT-DID-001, PLT-DID-006: DID documents are public, and only for active identities. */ + @Test + void publishesDidDocumentsForActiveIdentitiesOnly() throws JOSEException { + AepPlatform platform = platform(allow(), new PlatformFixtures.Keys(), false); + PlatformAgentIdentity identity = provision(platform, SERVICE_DID, PROVISION_KEY); + String agentDidId = identity.agentDid().substring(identity.agentDid().lastIndexOf(':') + 1); + + var published = + platform.getDidDocument(agentDidId, null).toCompletableFuture().join(); + platform.updateIdentity( + identity.agentIdentityId(), + new PlatformLifecycleRequest(ManagedAgentStatus.REVOKED), + context(null)) + .toCompletableFuture() + .join(); + + assertEquals(200, published.status()); + assertEquals(Aep.DID_MEDIA_TYPE, published.contentType()); + assertEquals(identity.agentDid(), published.body().id()); + assertEquals(List.of(identity.agentDid()), published.body().authentication()); + assertEquals(404, statusOf(platform.getDidDocument(agentDidId, null))); + assertEquals(404, statusOf(platform.getDidDocument(" ", null))); + } + + /** PLT-SIGN-004: platform_context never reaches the assertion it accompanies. */ + @Test + void keepsPlatformContextOutOfAssertionClaims() throws JOSEException { + PlatformFixtures.Keys keys = new PlatformFixtures.Keys(); + AepPlatform platform = platform(allow(), keys, false); + PlatformAgentIdentity identity = provision(platform, SERVICE_DID, PROVISION_KEY); + PlatformSignRequest request = new PlatformSignRequest( + JWT_ID, null, AssertionOperation.ENROLL, Map.of("approval", "opaque"), null, SERVICE_DID); + + var signed = + (PlatformSignResponses.Completed) platform.sign(identity.agentIdentityId(), request, context(SIGN_KEY)) + .toCompletableFuture() + .join() + .body(); + String payload = new String( + java.util.Base64.getUrlDecoder().decode(signed.clientAssertion().split("\\.")[1]), + java.nio.charset.StandardCharsets.UTF_8); + + assertEquals("opaque", signed.platformContext().get("approval")); + assertFalse(payload.contains("platform_context")); + assertFalse(payload.contains("approval")); + } + + private static PlatformLifecyclePolicy permissive() { + return new PlatformLifecyclePolicy() { + @Override + public CompletionStage canSign(PlatformIdentityRecord identity, PlatformRequestContext context) { + return completed(true); + } + + @Override + public CompletionStage canTransition( + PlatformIdentityRecord identity, ManagedAgentStatus nextStatus, PlatformRequestContext context) { + return completed(true); + } + + @Override + public CompletionStage canVerify(PlatformIdentityRecord identity, PlatformRequestContext context) { + return completed(true); + } + }; + } + + private static String refusedSign(AepPlatform platform, PlatformAgentIdentity identity, ManagedAgentStatus status) { + platform.updateIdentity(identity.agentIdentityId(), new PlatformLifecycleRequest(status), context(null)) + .toCompletableFuture() + .join(); + var response = platform.sign(identity.agentIdentityId(), signRequest(null), context("sign-" + status)) + .toCompletableFuture() + .join(); + assertEquals(403, response.status()); + return response.problem().code(); + } + + private static String signedAssertion(AepPlatform platform, PlatformAgentIdentity identity) { + return ((PlatformSignResponses.Completed) + platform.sign(identity.agentIdentityId(), signRequest(null), context(SIGN_KEY)) + .toCompletableFuture() + .join() + .body()) + .clientAssertion(); + } + + private static boolean verified(AepPlatform platform, String assertion, String serviceDid, String key) { + return platform.verify( + new PlatformVerificationRequest(assertion, AssertionOperation.ENROLL, null, serviceDid), + context(key)) + .toCompletableFuture() + .join() + .body() + .verified(); + } + + private static int signStatus( + AepPlatform platform, PlatformAgentIdentity identity, String lifetimeSeconds, String key) { + return statusOf(platform.sign(identity.agentIdentityId(), signRequest(lifetimeSeconds), context(key))); + } + + private static int statusOf(CompletionStage> stage) { + return stage.toCompletableFuture().join().status(); + } + + private static PlatformResponse pending(Map> headers) { + return new PlatformResponse<>( + 202, Aep.MEDIA_TYPE, new PlatformSignResponses.Pending("pending", null, "5"), null, headers); + } + + private static PlatformResponse completedSign(String issuedAt, String expiresAt) { + return new PlatformResponse<>( + 200, + Aep.MEDIA_TYPE, + new PlatformSignResponses.Completed( + "completed", + "did:web:p.example:a:opaque1", + "assertion", + expiresAt, + issuedAt, + JWT_ID, + null, + SERVICE_DID), + null, + Map.of()); + } + + private static PlatformResponse handledSign(PlatformResponse handled) + throws JOSEException { + AepPlatform platform = builder(allow(), new PlatformFixtures.Keys(), false) + .signHandler((identity, request, context) -> completed(Optional.of(handled))) + .build(); + PlatformAgentIdentity identity = provision(platform, SERVICE_DID, PROVISION_KEY); + return platform.sign(identity.agentIdentityId(), signRequest(null), context(SIGN_KEY)) + .toCompletableFuture() + .join(); + } +} diff --git a/aep-platform/src/test/java/foundation/aep/platform/PlatformCoverageTest.java b/aep-platform/src/test/java/foundation/aep/platform/PlatformCoverageTest.java new file mode 100644 index 0000000..7d414dc --- /dev/null +++ b/aep-platform/src/test/java/foundation/aep/platform/PlatformCoverageTest.java @@ -0,0 +1,609 @@ +package foundation.aep.platform; + +import static foundation.aep.platform.PlatformFixtures.DID_HOST; +import static foundation.aep.platform.PlatformFixtures.DID_PREFIX; +import static foundation.aep.platform.PlatformFixtures.JWT_ID; +import static foundation.aep.platform.PlatformFixtures.NOW; +import static foundation.aep.platform.PlatformFixtures.PRINCIPAL; +import static foundation.aep.platform.PlatformFixtures.PROVISION_KEY; +import static foundation.aep.platform.PlatformFixtures.SERVICE_DID; +import static foundation.aep.platform.PlatformFixtures.allow; +import static foundation.aep.platform.PlatformFixtures.builder; +import static foundation.aep.platform.PlatformFixtures.completed; +import static foundation.aep.platform.PlatformFixtures.context; +import static foundation.aep.platform.PlatformFixtures.discovery; +import static foundation.aep.platform.PlatformFixtures.platform; +import static foundation.aep.platform.PlatformFixtures.provision; +import static foundation.aep.platform.PlatformFixtures.record; +import static foundation.aep.platform.PlatformFixtures.resolver; +import static foundation.aep.platform.PlatformFixtures.signRequest; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.nimbusds.jose.JOSEException; +import foundation.aep.core.Aep; +import foundation.aep.core.AssertionOperation; +import foundation.aep.core.ClientAssertionClaims; +import foundation.aep.core.ManagedAgentStatus; +import foundation.aep.core.PlatformAgentIdentity; +import foundation.aep.core.PlatformLifecycleRequest; +import foundation.aep.core.PlatformProvisionRequest; +import foundation.aep.core.PlatformSignRequest; +import foundation.aep.core.PlatformSignResponses; +import foundation.aep.core.PlatformVerificationRequest; +import foundation.aep.core.ProblemDetails; +import java.time.Duration; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; + +/** Exercises the remaining refusal, accessor, and store paths of the AEP Platform module. */ +final class PlatformCoverageTest { + private static final String AGENT_DID = "did:web:p.example:a:opaque"; + private static final String SIGN_KEY = "sign"; + private static final String BLANK = " "; + private static final String CONTROLLER = "did:web:p.example:a:other"; + private static final String HEADER = "X-Trace"; + private static final String OTHER_SERVICE = "did:web:other.example"; + + @Test + void refusesMissingOrMalformedPathParametersAndBodies() throws JOSEException { + AepPlatform platform = platform(allow(), new PlatformFixtures.Keys(), false); + PlatformAgentIdentity identity = provision(platform, SERVICE_DID, PROVISION_KEY); + + assertEquals(404, status(platform.sign(BLANK, signRequest(null), context(SIGN_KEY)))); + assertEquals(400, status(platform.sign(identity.agentIdentityId(), null, context(SIGN_KEY)))); + assertEquals(404, status(platform.updateIdentity(null, active(), context(null)))); + assertEquals(400, status(platform.updateIdentity(identity.agentIdentityId(), null, context(null)))); + assertEquals(404, status(platform.updateIdentity("pai_missing", active(), context(null)))); + assertEquals(404, status(platform.getIdentity(null, context(null)))); + assertEquals(404, status(platform.getIdentity("pai_missing", context(null)))); + assertEquals( + 400, status(platform(allow(), new PlatformFixtures.Keys(), true).verify(null, context("verify")))); + } + + @Test + void refusesSigningForAServiceTheIdentityIsNotScopedTo() throws JOSEException { + AepPlatform platform = platform(allow(), new PlatformFixtures.Keys(), false); + PlatformAgentIdentity identity = provision(platform, SERVICE_DID, PROVISION_KEY); + PlatformSignRequest crossService = new PlatformSignRequest( + JWT_ID, null, AssertionOperation.ENROLL, null, null, "did:web:elsewhere.example"); + + assertEquals(404, status(platform.sign(identity.agentIdentityId(), crossService, context(SIGN_KEY)))); + } + + @Test + void rejectsSignHandlerResponsesThatAreNotAValidSigningResult() throws JOSEException { + assertEquals(200, handled(null).status(), "an absent handler result falls back to the key store"); + assertThrows( + CompletionException.class, + () -> handled(new PlatformResponse<>(500, Aep.MEDIA_TYPE, null, null, Map.of())), + "a 500 with no body is neither success nor problem"); + assertThrows( + CompletionException.class, + () -> handled(new PlatformResponse<>(202, "text/plain", pending(), null, Map.of())), + "the media type has to be application/aep+json"); + assertThrows( + CompletionException.class, + () -> handled(new PlatformResponse<>(200, Aep.MEDIA_TYPE, pending(), null, Map.of())), + "a pending response has to be 202"); + assertThrows( + CompletionException.class, + () -> handled(new PlatformResponse<>( + 400, Aep.PROBLEM_MEDIA_TYPE, null, ProblemDetails.of("invalid_request", "No.", 503), Map.of())), + "a problem whose status disagrees with the response is not valid"); + } + + @Test + void rejectsDidDocumentsWhoseVerificationMethodIsNotTheAgentDid() throws JOSEException { + PlatformFixtures.Keys keys = new PlatformFixtures.Keys() { + @Override + public CompletionStage didVerificationMethod( + PlatformIdentityRecord identity, PlatformRequestContext context) { + return completed(new PlatformDidVerificationMethod( + CONTROLLER, CONTROLLER, key.toPublicJWK().toJSONObject(), "JsonWebKey2020")); + } + }; + AepPlatform platform = platform(allow(), keys, false); + PlatformAgentIdentity identity = provision(platform, SERVICE_DID, PROVISION_KEY); + String agentDidId = identity.agentDid().substring(identity.agentDid().lastIndexOf(':') + 1); + + assertThrows( + CompletionException.class, + () -> platform.getDidDocument(agentDidId, context(null)) + .toCompletableFuture() + .join()); + } + + @Test + void rejectsUnusableConstructionInput() throws JOSEException { + PlatformFixtures.Keys keys = new PlatformFixtures.Keys(); + + assertThrows( + IllegalArgumentException.class, + () -> AepPlatform.builder(discovery(false), BLANK, allow(), keys, resolver(true)) + .build(), + "didHost is required"); + assertThrows( + IllegalArgumentException.class, + () -> AepPlatform.builder(discovery(false), "host/path", allow(), keys, resolver(true)) + .build(), + "didHost is a host and a port, nothing else"); + assertThrows( + IllegalArgumentException.class, + () -> builder(allow(), keys, false).didPathPrefix("a/../b").build(), + "dot segments never reach a did:web path"); + assertThrows( + IllegalArgumentException.class, + () -> builder(allow(), keys, false) + .maximumLifetime(Duration.ofMillis(1500)) + .build(), + "the maximum is a whole number of seconds"); + assertThrows( + IllegalArgumentException.class, + () -> builder(discovery(false, "https://p.example/a/did.json", "300"), allow(), keys, resolver(true)) + .build(), + "the template has to name the identifier"); + assertThrows( + IllegalArgumentException.class, + () -> builder( + discovery(false, "http://p.example/a/{agent_did_id}/did.json", "300"), + allow(), + keys, + resolver(true)) + .build(), + "the template renders an HTTPS URL"); + assertThrows( + CompletionException.class, + () -> builder(allow(), keys, false) + .identityIdSupplier(() -> BLANK) + .build() + .provision(new PlatformProvisionRequest(SERVICE_DID), context(PROVISION_KEY)) + .toCompletableFuture() + .join() + .body(), + "a generated identifier must not be blank"); + } + + @Test + void acceptsEveryConfigurableCollaborator() throws JOSEException { + PlatformIdentityStore identityStore = PlatformIdentityStore.inMemory(); + PlatformIdempotencyStore idempotencyStore = PlatformIdempotencyStore.inMemory(PlatformFixtures.CLOCK); + AepPlatform platform = builder(allow(), new PlatformFixtures.Keys(), false) + .identityStore(identityStore) + .idempotencyStore(idempotencyStore) + .lifecyclePolicy(PlatformLifecyclePolicy.permissiveTransitions()) + .maximumLifetime(Duration.ofSeconds(300)) + .build(); + + assertEquals( + 200, status(platform.provision(new PlatformProvisionRequest(SERVICE_DID), context(PROVISION_KEY)))); + assertThrows( + NullPointerException.class, + () -> builder(allow(), new PlatformFixtures.Keys(), false).identityStore(null)); + assertThrows( + NullPointerException.class, + () -> builder(allow(), new PlatformFixtures.Keys(), false).idempotencyStore(null)); + assertThrows( + NullPointerException.class, + () -> builder(allow(), new PlatformFixtures.Keys(), false).signHandler(null)); + assertThrows( + NullPointerException.class, + () -> builder(allow(), new PlatformFixtures.Keys(), false).replayStore(null)); + } + + @Test + void generatesOpaqueIdentifiersWhenNoSupplierIsConfigured() throws JOSEException { + AepPlatform platform = AepPlatform.builder( + discovery(false), DID_HOST, allow(), new PlatformFixtures.Keys(), resolver(true)) + .clock(PlatformFixtures.CLOCK) + .didPathPrefix(DID_PREFIX) + .build(); + + PlatformAgentIdentity identity = provision(platform, SERVICE_DID, PROVISION_KEY); + + assertTrue(identity.agentIdentityId().startsWith("pai_")); + assertFalse(identity.agentDid().contains("-")); + assertEquals( + 32, + identity.agentDid() + .substring(identity.agentDid().lastIndexOf(':') + 1) + .length()); + } + + @Test + void foldsNumericPlatformContextIntoTheIdempotencyFingerprint() throws JOSEException { + AepPlatform platform = platform(allow(), new PlatformFixtures.Keys(), false); + PlatformAgentIdentity identity = provision(platform, SERVICE_DID, PROVISION_KEY); + Map first = new LinkedHashMap<>(); + first.put("attempt", 1); + first.put("nested", List.of(true, "x")); + Map second = new LinkedHashMap<>(); + second.put("attempt", 2); + second.put("nested", List.of(true, "x")); + + assertEquals(200, status(platform.sign(identity.agentIdentityId(), contextual(first), context(SIGN_KEY)))); + assertEquals(200, status(platform.sign(identity.agentIdentityId(), contextual(first), context(SIGN_KEY)))); + assertEquals(409, status(platform.sign(identity.agentIdentityId(), contextual(second), context(SIGN_KEY)))); + } + + @Test + void reportsIdempotencyConflictsAcrossOperationsAndInFlightRequests() { + PlatformIdempotencyStore store = PlatformIdempotencyStore.inMemory(PlatformFixtures.CLOCK); + PlatformIdempotencyInput sign = + new PlatformIdempotencyInput(PRINCIPAL, "k", PlatformIdempotentOperation.SIGN, "hash"); + PlatformIdempotencyInput provision = + new PlatformIdempotencyInput(PRINCIPAL, "k", PlatformIdempotentOperation.PROVISION, "hash"); + CompletableFuture> inFlight = new CompletableFuture<>(); + + assertEquals( + PlatformIdempotencyResult.State.CREATED, + store.execute(sign, () -> completed(ok("first"))) + .toCompletableFuture() + .join() + .state()); + assertEquals( + PlatformIdempotencyResult.State.REPLAYED, + store.execute(sign, () -> completed(ok("second"))) + .toCompletableFuture() + .join() + .state()); + assertEquals( + PlatformIdempotencyResult.State.CONFLICT, + store.execute(provision, () -> completed(ok("third"))) + .toCompletableFuture() + .join() + .state()); + + PlatformIdempotencyStore pendingStore = PlatformIdempotencyStore.inMemory(PlatformFixtures.CLOCK); + var started = pendingStore.execute(sign, () -> inFlight).toCompletableFuture(); + var joined = pendingStore.execute(sign, () -> completed(ok("ignored"))).toCompletableFuture(); + assertEquals( + PlatformIdempotencyResult.State.CONFLICT, + pendingStore + .execute(provision, () -> completed(ok("nope"))) + .toCompletableFuture() + .join() + .state()); + inFlight.complete(ok("done")); + + assertEquals("done", started.join().response().body()); + assertEquals(PlatformIdempotencyResult.State.REPLAYED, joined.join().state()); + } + + @Test + void surfacesIdempotentOperationFailuresWithoutStoringThem() { + PlatformIdempotencyStore store = PlatformIdempotencyStore.inMemory(PlatformFixtures.CLOCK); + PlatformIdempotencyInput input = + new PlatformIdempotencyInput(PRINCIPAL, "k", PlatformIdempotentOperation.SIGN, "hash"); + + assertThrows( + CompletionException.class, + () -> store.execute(input, () -> { + throw new IllegalStateException("boom"); + }) + .toCompletableFuture() + .join()); + assertThrows( + CompletionException.class, + () -> store.execute(input, () -> completed(null)) + .toCompletableFuture() + .join()); + assertThrows( + IllegalArgumentException.class, + () -> store.execute( + new PlatformIdempotencyInput(BLANK, "k", PlatformIdempotentOperation.SIGN, "hash"), + () -> completed(ok("x")))); + assertThrows(IllegalArgumentException.class, () -> store.execute(null, () -> completed(ok("x")))); + assertEquals( + PlatformIdempotencyResult.State.CREATED, + store.execute(input, () -> completed(ok("retry"))) + .toCompletableFuture() + .join() + .state()); + } + + @Test + void keepsIdempotentResultsForTheFullRetentionWindow() { + AtomicInteger executions = new AtomicInteger(); + java.time.Instant[] instant = {NOW}; + java.time.Clock moving = new java.time.Clock() { + @Override + public java.time.ZoneId getZone() { + return java.time.ZoneOffset.UTC; + } + + @Override + public java.time.Clock withZone(java.time.ZoneId zone) { + return this; + } + + @Override + public java.time.Instant instant() { + return instant[0]; + } + }; + PlatformIdempotencyStore store = PlatformIdempotencyStore.inMemory(moving); + PlatformIdempotencyInput input = + new PlatformIdempotencyInput(PRINCIPAL, "k", PlatformIdempotentOperation.SIGN, "hash"); + java.util.function.Supplier>> operation = + () -> completed(ok("run-" + executions.incrementAndGet())); + + store.execute(input, operation).toCompletableFuture().join(); + instant[0] = NOW.plus(Duration.ofHours(1)); + var atTheBoundary = + store.execute(input, operation).toCompletableFuture().join(); + instant[0] = NOW.plus(Duration.ofHours(1)).plusNanos(1); + var afterTheBoundary = + store.execute(input, operation).toCompletableFuture().join(); + + assertEquals(PlatformIdempotencyResult.State.REPLAYED, atTheBoundary.state()); + assertEquals("run-1", atTheBoundary.response().body()); + assertEquals(PlatformIdempotencyResult.State.CREATED, afterTheBoundary.state()); + assertEquals(2, executions.get()); + } + + @Test + void refusesIdentityRecordsThatDoNotMatchTheirRequestedScope() { + PlatformIdentityStore store = PlatformIdentityStore.inMemory(); + + assertThrows( + CompletionException.class, + () -> store.findOrCreate(PRINCIPAL, SERVICE_DID, () -> { + throw new IllegalStateException("boom"); + }) + .toCompletableFuture() + .join(), + "a creation failure reaches the caller"); + assertThrows( + CompletionException.class, + () -> store.findOrCreate("other", SERVICE_DID, () -> completed(record(ManagedAgentStatus.ACTIVE))) + .toCompletableFuture() + .join(), + "a record has to belong to the scope it was created for"); + assertThrows( + CompletionException.class, + () -> store.findOrCreate( + PRINCIPAL, + SERVICE_DID, + () -> CompletableFuture.failedFuture(new IllegalStateException("boom"))) + .toCompletableFuture() + .join(), + "an asynchronous creation failure reaches the caller too"); + + store.findOrCreate(PRINCIPAL, SERVICE_DID, () -> completed(record(ManagedAgentStatus.ACTIVE))) + .toCompletableFuture() + .join(); + + assertThrows( + CompletionException.class, + () -> store.findOrCreate( + PRINCIPAL, + OTHER_SERVICE, + () -> completed(record(ManagedAgentStatus.ACTIVE).toBuilder() + .serviceDid(OTHER_SERVICE) + .build())) + .toCompletableFuture() + .join(), + "identity material stays unique across scopes"); + assertThrows( + IllegalArgumentException.class, + () -> store.list(PRINCIPAL, new PlatformIdentityListQuery(false, 0, 0, null, null))); + assertThrows(IllegalArgumentException.class, () -> store.findOrCreate(BLANK, SERVICE_DID, () -> null)); + assertEquals( + Optional.empty(), + store.findByAgentDid("did:web:absent.example") + .toCompletableFuture() + .join()); + assertEquals( + Optional.empty(), + store.findByAgentDidId("absent").toCompletableFuture().join()); + assertEquals( + Optional.empty(), + store.update("pai_absent", ManagedAgentStatus.REVOKED, NOW) + .toCompletableFuture() + .join()); + } + + @Test + void refusesUnusableReplayInput() { + PlatformReplayStore store = PlatformReplayStore.inMemory(); + + assertThrows( + CompletionException.class, + () -> store.consume(BLANK, NOW.plusSeconds(60), NOW, null) + .toCompletableFuture() + .join()); + assertFalse(store.consume("k", NOW, NOW, null).toCompletableFuture().join(), "an expired key is never fresh"); + assertTrue(store.consume("k", NOW.plusSeconds(60), NOW, null) + .toCompletableFuture() + .join()); + } + + @Test + void copiesAndValidatesResponseHeaders() { + assertThrows(IllegalArgumentException.class, () -> headers(Map.of("Bad Name", List.of("v")))); + assertThrows(IllegalArgumentException.class, () -> headers(Map.of(HEADER, List.of()))); + assertThrows(IllegalArgumentException.class, () -> headers(Map.of(HEADER, List.of(" padded")))); + assertThrows(IllegalArgumentException.class, () -> headers(Map.of(HEADER, List.of("a\r\nX-Evil: 1")))); + assertThrows(IllegalArgumentException.class, () -> headers(Map.of(HEADER, List.of("a\u007f")))); + assertEquals(Map.of(), new PlatformResponse<>(200, Aep.MEDIA_TYPE, "b", null, null).headers()); + assertEquals(List.of("a\tb"), headers(Map.of(HEADER, List.of("a\tb"))).get(HEADER)); + } + + @Test + void copiesAndValidatesJsonValues() { + Map nonStringKey = new LinkedHashMap<>(); + nonStringKey.put(1, "value"); + Map nested = new LinkedHashMap<>(); + nested.put("inner", nonStringKey); + + assertThrows(IllegalArgumentException.class, () -> PlatformCopies.jsonValue(nested)); + assertThrows(IllegalArgumentException.class, () -> PlatformCopies.jsonValue(Double.NaN)); + assertThrows(IllegalArgumentException.class, () -> PlatformCopies.jsonValue(Float.POSITIVE_INFINITY)); + assertThrows(IllegalArgumentException.class, () -> PlatformCopies.jsonValue(new Object())); + assertThrows( + IllegalArgumentException.class, + () -> PlatformCopies.jsonObject(java.util.Collections.singletonMap(null, "v"))); + assertEquals(List.of("a", "b"), PlatformCopies.jsonValue(new ArrayList<>(List.of("a", "b")))); + assertEquals(1.5d, PlatformCopies.jsonValue(1.5d)); + assertEquals(Map.of(), PlatformCopies.jsonObject(null)); + } + + @Test + void exposesAuthorizationRequestSubjects() throws JOSEException { + PlatformIdentityRecord identity = record(ManagedAgentStatus.ACTIVE); + PlatformSignRequest sign = signRequest(null); + PlatformProvisionRequest provision = new PlatformProvisionRequest(SERVICE_DID); + PlatformLifecycleRequest lifecycle = active(); + PlatformVerificationRequest verification = + new PlatformVerificationRequest("token", AssertionOperation.ENROLL, null, SERVICE_DID); + PlatformIdentityListQuery query = PlatformIdentityListQuery.defaults(); + + assertSame(identity, PlatformAuthorizationRequest.getIdentity(identity).identity()); + assertSame(query, PlatformAuthorizationRequest.list(query).listQuery()); + assertSame(provision, PlatformAuthorizationRequest.provision(provision).provisionRequest()); + assertSame(sign, PlatformAuthorizationRequest.sign(identity, sign).signRequest()); + assertSame( + lifecycle, + PlatformAuthorizationRequest.update(identity, lifecycle).lifecycleRequest()); + assertSame( + verification, + PlatformAuthorizationRequest.verify(identity, verification).verificationRequest()); + assertEquals( + PlatformAuthorizationOperation.VERIFY, + PlatformAuthorizationRequest.verify(identity, verification).operation()); + assertEquals(null, PlatformAuthorizationRequest.list(query).identity()); + assertThrows(NullPointerException.class, () -> new PlatformIdentityListResult(null, 0)); + } + + @Test + void guardsRecordAndDocumentInvariants() { + PlatformIdentityRecord identity = record(ManagedAgentStatus.ACTIVE); + PlatformDidVerificationMethod method = + new PlatformDidVerificationMethod(AGENT_DID, AGENT_DID, Map.of("kty", "EC"), "JsonWebKey2020"); + PlatformDidDocument document = new PlatformDidDocument( + List.of("https://www.w3.org/ns/did/v1"), List.of(AGENT_DID), AGENT_DID, List.of(method)); + + assertThrows( + IllegalArgumentException.class, + () -> identity.toBuilder().signingAlgorithms(List.of()).build()); + assertThrows( + IllegalArgumentException.class, + () -> identity.toBuilder().agentDid(BLANK).build()); + assertThrows( + NullPointerException.class, + () -> identity.toBuilder().status(null).build()); + assertThrows( + IllegalArgumentException.class, + () -> new PlatformDidVerificationMethod(AGENT_DID, AGENT_DID, Map.of(), "JsonWebKey2020")); + assertThrows( + IllegalArgumentException.class, + () -> new PlatformDidVerificationMethod(BLANK, AGENT_DID, Map.of("kty", "EC"), "JsonWebKey2020")); + assertThrows( + IllegalArgumentException.class, + () -> new PlatformDidVerificationMethod(AGENT_DID, BLANK, Map.of("kty", "EC"), "JsonWebKey2020")); + assertThrows( + IllegalArgumentException.class, + () -> new PlatformDidVerificationMethod(AGENT_DID, AGENT_DID, Map.of("kty", "EC"), BLANK)); + assertThrows(IllegalArgumentException.class, () -> new PlatformIdentityListResult(List.of(identity), 0)); + assertEquals(List.of("https://www.w3.org/ns/did/v1"), document.context()); + assertEquals(List.of(method), document.verificationMethods()); + assertEquals(Map.of("kty", "EC"), method.publicKeyJwk()); + assertFalse(identity.toString().contains(PRINCIPAL)); + assertTrue(identity.toString().contains(AGENT_DID)); + assertEquals( + 1, + new PlatformIdentityListResult(List.of(identity), 1) + .identities() + .size()); + } + + @Test + void appliesRequestContextDefaults() { + PlatformRequestContext terse = new PlatformRequestContext(PRINCIPAL, "key"); + + assertEquals(null, terse.now()); + assertEquals(Map.of(), terse.values()); + assertTrue(terse.toString().contains("valueNames=[]")); + } + + @Test + void signsAgainstTheConfiguredClockWhenNoRequestInstantIsSupplied() throws JOSEException { + AepPlatform platform = platform(allow(), new PlatformFixtures.Keys(), false); + PlatformAgentIdentity identity = provision(platform, SERVICE_DID, PROVISION_KEY); + + var signed = (PlatformSignResponses.Completed) platform.sign( + identity.agentIdentityId(), signRequest(null), new PlatformRequestContext(PRINCIPAL, SIGN_KEY)) + .toCompletableFuture() + .join() + .body(); + + assertEquals("2026-07-06T12:00:00Z", signed.issuedAt()); + assertEquals("2026-07-06T12:05:00Z", signed.expiresAt()); + } + + @Test + void reportsBlankAssertionsFromAKeyStoreAsAFailure() throws JOSEException { + PlatformFixtures.Keys keys = new PlatformFixtures.Keys() { + @Override + public CompletionStage sign( + PlatformIdentityRecord identity, ClientAssertionClaims claims, PlatformRequestContext context) { + return completed(BLANK); + } + }; + AepPlatform platform = platform(allow(), keys, false); + PlatformAgentIdentity identity = provision(platform, SERVICE_DID, PROVISION_KEY); + + assertThrows( + CompletionException.class, + () -> platform.sign(identity.agentIdentityId(), signRequest(null), context(SIGN_KEY)) + .toCompletableFuture() + .join()); + } + + private static PlatformLifecycleRequest active() { + return new PlatformLifecycleRequest(ManagedAgentStatus.ACTIVE); + } + + private static PlatformSignRequest contextual(Map platformContext) { + return new PlatformSignRequest(JWT_ID, null, AssertionOperation.ENROLL, platformContext, null, SERVICE_DID); + } + + private static PlatformSignResponses.Response pending() { + return new PlatformSignResponses.Pending("pending", null, "5"); + } + + private static PlatformResponse ok(String body) { + return new PlatformResponse<>(200, Aep.MEDIA_TYPE, body, null, Map.of()); + } + + private static Map> headers(Map> source) { + return new PlatformResponse<>(200, Aep.MEDIA_TYPE, "body", null, source).headers(); + } + + private static int status(CompletionStage> stage) { + return stage.toCompletableFuture().join().status(); + } + + private static PlatformResponse handled(PlatformResponse response) + throws JOSEException { + AepPlatform platform = builder(allow(), new PlatformFixtures.Keys(), false) + .signHandler((identity, request, context) -> completed(Optional.ofNullable(response))) + .build(); + PlatformAgentIdentity identity = provision(platform, SERVICE_DID, PROVISION_KEY); + return platform.sign(identity.agentIdentityId(), signRequest(null), context(SIGN_KEY)) + .toCompletableFuture() + .join(); + } +} diff --git a/aep-platform/src/test/java/foundation/aep/platform/PlatformEdgeTest.java b/aep-platform/src/test/java/foundation/aep/platform/PlatformEdgeTest.java new file mode 100644 index 0000000..16aa02e --- /dev/null +++ b/aep-platform/src/test/java/foundation/aep/platform/PlatformEdgeTest.java @@ -0,0 +1,701 @@ +package foundation.aep.platform; + +import static foundation.aep.platform.PlatformFixtures.CLOCK; +import static foundation.aep.platform.PlatformFixtures.DID_HOST; +import static foundation.aep.platform.PlatformFixtures.JWT_ID; +import static foundation.aep.platform.PlatformFixtures.NOW; +import static foundation.aep.platform.PlatformFixtures.OTHER_SERVICE_DID; +import static foundation.aep.platform.PlatformFixtures.PRINCIPAL; +import static foundation.aep.platform.PlatformFixtures.PROVISION_KEY; +import static foundation.aep.platform.PlatformFixtures.SERVICE_DID; +import static foundation.aep.platform.PlatformFixtures.allow; +import static foundation.aep.platform.PlatformFixtures.builder; +import static foundation.aep.platform.PlatformFixtures.completed; +import static foundation.aep.platform.PlatformFixtures.context; +import static foundation.aep.platform.PlatformFixtures.discovery; +import static foundation.aep.platform.PlatformFixtures.platform; +import static foundation.aep.platform.PlatformFixtures.provision; +import static foundation.aep.platform.PlatformFixtures.record; +import static foundation.aep.platform.PlatformFixtures.resolver; +import static foundation.aep.platform.PlatformFixtures.signRequest; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.nimbusds.jose.JOSEException; +import foundation.aep.core.Aep; +import foundation.aep.core.AssertionOperation; +import foundation.aep.core.ClientAssertionClaims; +import foundation.aep.core.ClientAssertions; +import foundation.aep.core.ManagedAgentStatus; +import foundation.aep.core.PlatformAgentIdentity; +import foundation.aep.core.PlatformSignResponses; +import foundation.aep.core.PlatformVerificationRequest; +import foundation.aep.core.ProblemDetails; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CompletionException; +import java.util.concurrent.CompletionStage; +import org.junit.jupiter.api.Test; + +/** Boundary conditions across the AEP Platform surface. */ +final class PlatformEdgeTest { + private static final String SIGN_KEY = "sign"; + private static final String BLANK = " "; + private static final String AGENT_DID = "did:web:p.example:a:opaque1"; + private static final String NOT_RECOGNIZED = "not_recognized"; + private static final String KTY = "kty"; + private static final String EC = "EC"; + private static final String METHOD_TYPE = "JsonWebKey2020"; + private static final String ISSUED_AT = "2026-07-06T12:00:00Z"; + private static final String EXPIRES_AT = "2026-07-06T12:05:00Z"; + + @Test + void treatsAbsentAndBlankPathParametersAlike() throws JOSEException { + AepPlatform platform = platform(allow(), new PlatformFixtures.Keys(), false); + provision(platform, SERVICE_DID, PROVISION_KEY); + + assertEquals(404, status(platform.getDidDocument(null, context(null)))); + assertEquals(404, status(platform.getDidDocument("absent", context(null)))); + assertEquals(404, status(platform.sign(null, signRequest(null), context(SIGN_KEY)))); + assertEquals(404, status(platform.updateIdentity(BLANK, lifecycle(), context(null)))); + assertEquals(404, status(platform.getIdentity(BLANK, context(null)))); + } + + @Test + void appliesListDefaultsAndPrincipalScoping() throws JOSEException { + AepPlatform platform = platform(allow(), new PlatformFixtures.Keys(), false); + provision(platform, SERVICE_DID, PROVISION_KEY); + + var defaults = platform.list(null, context(null)).toCompletableFuture().join(); + var anonymous = platform.list(null, new PlatformRequestContext(null, null, NOW, Map.of())) + .toCompletableFuture() + .join(); + var filtered = platform.list( + new PlatformIdentityListQuery(false, 100, 0, null, ManagedAgentStatus.REVOKED), context(null)) + .toCompletableFuture() + .join(); + var pastTheEnd = platform.list(new PlatformIdentityListQuery(false, 100, 5, null, null), context(null)) + .toCompletableFuture() + .join(); + + assertEquals("1", defaults.body().count()); + assertEquals(404, anonymous.status()); + assertEquals("0", filtered.body().count()); + assertEquals("0", filtered.body().total(), "total counts what the whole query matches"); + assertEquals("0", pastTheEnd.body().count()); + } + + @Test + void keepsSuppliedIdentityIdentifierPrefixes() throws JOSEException { + AepPlatform platform = builder(allow(), new PlatformFixtures.Keys(), false) + .identityIdSupplier(() -> "pai_supplied") + .build(); + + assertEquals( + "pai_supplied", provision(platform, SERVICE_DID, PROVISION_KEY).agentIdentityId()); + } + + @Test + void rejectsEveryShapeOfInvalidSignHandlerResult() throws JOSEException { + assertThrows( + CompletionException.class, + () -> handled(new PlatformResponse<>(200, Aep.MEDIA_TYPE, null, null, Map.of())), + "a success needs a body"); + assertThrows( + CompletionException.class, + () -> handled(new PlatformResponse<>( + 200, + Aep.MEDIA_TYPE, + completedBody(AGENT_DID, SERVICE_DID, JWT_ID), + ProblemDetails.of("invalid_request", "No.", 200), + Map.of())), + "a success carries no problem"); + assertThrows( + CompletionException.class, + () -> handled(new PlatformResponse<>( + 200, Aep.MEDIA_TYPE, completedBody("not-a-did", SERVICE_DID, JWT_ID), null, Map.of())), + "the body still has to satisfy the schema"); + assertThrows( + CompletionException.class, + () -> handled(new PlatformResponse<>( + 200, Aep.MEDIA_TYPE, completedBody(AGENT_DID, OTHER_SERVICE_DID, JWT_ID), null, Map.of())), + "service_did has to echo the request"); + assertThrows( + CompletionException.class, + () -> handled(new PlatformResponse<>( + 200, Aep.MEDIA_TYPE, completedBody(AGENT_DID, SERVICE_DID, "other"), null, Map.of())), + "jti has to echo the request"); + assertThrows( + CompletionException.class, + () -> handled(new PlatformResponse<>( + 202, Aep.MEDIA_TYPE, completedBody(AGENT_DID, SERVICE_DID, JWT_ID), null, Map.of())), + "a completed response is 200"); + assertThrows( + CompletionException.class, + () -> handled(new PlatformResponse<>( + 404, + Aep.PROBLEM_MEDIA_TYPE, + null, + new ProblemDetails(null, null, 404, null, null, null, null, null, null), + Map.of())), + "a problem document still has to validate"); + assertThrows( + CompletionException.class, + () -> handled(new PlatformResponse<>( + 404, Aep.MEDIA_TYPE, null, ProblemDetails.of(NOT_RECOGNIZED, "Gone.", 404), Map.of())), + "a problem uses the problem media type"); + assertThrows( + CompletionException.class, + () -> handled(new PlatformResponse<>( + 200, Aep.PROBLEM_MEDIA_TYPE, null, ProblemDetails.of(NOT_RECOGNIZED, "Gone.", 200), Map.of())), + "a problem status is an error status"); + } + + @Test + void refusesAssertionsThatDoNotBindIssuerSubjectAndIdentity() throws JOSEException { + PlatformFixtures.Keys keys = new PlatformFixtures.Keys(); + AepPlatform platform = platform(allow(), keys, true); + PlatformAgentIdentity identity = provision(platform, SERVICE_DID, PROVISION_KEY); + String mismatchedSubject = + unbound(keys, identity.agentDid(), "did:web:p.example:a:someone-else", identity.agentDid()); + String unknownIssuer = ClientAssertions.sign( + new ClientAssertionClaims( + "did:web:p.example:a:unknown", + "did:web:p.example:a:unknown", + SERVICE_DID, + AssertionOperation.ENROLL, + NOW.getEpochSecond(), + NOW.plusSeconds(60).getEpochSecond(), + JWT_ID, + null), + keys.key, + "did:web:p.example:a:unknown"); + + assertFalse(verified(platform, mismatchedSubject, "v1")); + assertFalse(verified(platform, unknownIssuer, "v2")); + } + + @Test + void refusesHostedVerificationTheAuthorizerOrPolicyDeclines() throws JOSEException { + PlatformFixtures.Keys keys = new PlatformFixtures.Keys(); + AepPlatform denying = platform((request, context) -> completed(false), keys, true); + AepPlatform refusing = builder(allow(), keys, true) + .lifecyclePolicy(policy(true, false)) + .build(); + AepPlatform signing = platform(allow(), keys, true); + PlatformAgentIdentity identity = provision(signing, SERVICE_DID, PROVISION_KEY); + String assertion = ((PlatformSignResponses.Completed) + signing.sign(identity.agentIdentityId(), signRequest(null), context(SIGN_KEY)) + .toCompletableFuture() + .join() + .body()) + .clientAssertion(); + provision(denying, SERVICE_DID, PROVISION_KEY); + provision(refusing, SERVICE_DID, PROVISION_KEY); + + assertFalse(verified(denying, assertion, "v1")); + assertFalse(verified(refusing, assertion, "v2")); + } + + @Test + void verifiesAgainstTheConfiguredClockWhenNoRequestInstantIsSupplied() throws JOSEException { + PlatformFixtures.Keys keys = new PlatformFixtures.Keys(); + AepPlatform platform = platform(allow(), keys, true); + PlatformAgentIdentity identity = provision(platform, SERVICE_DID, PROVISION_KEY); + String assertion = ((PlatformSignResponses.Completed) + platform.sign(identity.agentIdentityId(), signRequest(null), context(SIGN_KEY)) + .toCompletableFuture() + .join() + .body()) + .clientAssertion(); + + assertTrue(platform.verify( + new PlatformVerificationRequest(assertion, AssertionOperation.ENROLL, null, SERVICE_DID), + new PlatformRequestContext(PRINCIPAL, "verify")) + .toCompletableFuture() + .join() + .body() + .verified()); + } + + @Test + void scopesAuthorizedIdentitiesToTheCallingPrincipal() throws JOSEException { + AepPlatform platform = platform(allow(), new PlatformFixtures.Keys(), false); + PlatformAgentIdentity identity = provision(platform, SERVICE_DID, PROVISION_KEY); + + assertEquals( + 404, + status(platform.getIdentity( + identity.agentIdentityId(), new PlatformRequestContext("someone-else", null, NOW, Map.of())))); + assertEquals( + 404, + status(platform.getIdentity( + identity.agentIdentityId(), new PlatformRequestContext(null, null, NOW, Map.of())))); + assertEquals(200, status(platform.getIdentity(identity.agentIdentityId(), context(null)))); + } + + @Test + void rejectsDidDocumentsWhoseControllerIsNotTheAgentDid() throws JOSEException { + PlatformFixtures.Keys keys = new PlatformFixtures.Keys() { + @Override + public CompletionStage didVerificationMethod( + PlatformIdentityRecord identity, PlatformRequestContext context) { + return completed(new PlatformDidVerificationMethod( + "did:web:p.example:a:other", + identity.agentDid(), + key.toPublicJWK().toJSONObject(), + METHOD_TYPE)); + } + }; + AepPlatform platform = platform(allow(), keys, false); + PlatformAgentIdentity identity = provision(platform, SERVICE_DID, PROVISION_KEY); + + assertThrows( + CompletionException.class, + () -> platform.getDidDocument("opaque1", context(null)) + .toCompletableFuture() + .join()); + assertEquals(identity.agentDid(), AGENT_DID); + } + + @Test + void validatesEveryConstructionArgumentIndependently() throws JOSEException { + PlatformFixtures.Keys keys = new PlatformFixtures.Keys(); + + assertThrows( + IllegalArgumentException.class, + () -> AepPlatform.builder(discovery(false), null, allow(), keys, resolver(true)) + .build(), + "didHost is required"); + assertThrows( + IllegalArgumentException.class, + () -> AepPlatform.builder(discovery(false), "user@p.example", allow(), keys, resolver(true)) + .build(), + "didHost carries no user information"); + assertThrows( + IllegalArgumentException.class, + () -> builder(allow(), keys, false).didPathPrefix("a/./b").build(), + "a dot segment is not a path component"); + assertThrows( + IllegalArgumentException.class, + () -> builder(allow(), keys, false).didPathPrefix("a//b").build(), + "an empty path component is not a path component"); + assertThrows( + IllegalArgumentException.class, + () -> builder(allow(), keys, false).didPathPrefix("").build(), + "a blank prefix falls back to the default, which no longer matches the template"); + assertThrows( + IllegalArgumentException.class, + () -> AepPlatform.builder(discovery(false), ":8443", allow(), keys, resolver(true)) + .build(), + "didHost names a host, not a bare port"); + assertThrows( + IllegalArgumentException.class, + () -> builder(allow(), keys, false) + .maximumLifetime(Duration.ZERO) + .build()); + assertThrows( + IllegalArgumentException.class, + () -> builder(allow(), keys, false) + .maximumLifetime(Duration.ofSeconds(-1)) + .build()); + assertThrows( + IllegalArgumentException.class, + () -> builder(allow(), keys, false) + .maximumLifetime(Duration.ofSeconds(301)) + .build()); + assertThrows( + NullPointerException.class, () -> builder(allow(), keys, false).maximumLifetime(null)); + assertThrows( + NullPointerException.class, + () -> AepPlatform.builder(discovery(false), DID_HOST, null, keys, resolver(true)) + .build()); + assertThrows( + NullPointerException.class, + () -> AepPlatform.builder(discovery(false), DID_HOST, allow(), null, resolver(true)) + .build()); + assertThrows( + NullPointerException.class, + () -> AepPlatform.builder(discovery(false), DID_HOST, allow(), keys, null) + .build()); + assertThrows( + CompletionException.class, + () -> builder(allow(), keys, false) + .agentDidIdSupplier(() -> null) + .build() + .provision(new foundation.aep.core.PlatformProvisionRequest(SERVICE_DID), context("p")) + .toCompletableFuture() + .join()); + } + + @Test + void appliesTheDefaultDidPathPrefixWhenNoneIsConfigured() throws JOSEException { + AepPlatform platform = builder( + discovery(false, "https://p.example/agents/{agent_did_id}/did.json", "300"), + allow(), + new PlatformFixtures.Keys(), + resolver(true)) + .didPathPrefix(null) + .build(); + + assertEquals( + "did:web:p.example:agents:opaque1", + provision(platform, SERVICE_DID, PROVISION_KEY).agentDid()); + } + + @Test + void validatesIdempotencyInputMemberByMember() { + PlatformIdempotencyStore store = PlatformIdempotencyStore.inMemory(CLOCK); + + assertThrows(IllegalArgumentException.class, () -> execute(store, null, "k", operation(), "h")); + assertThrows(IllegalArgumentException.class, () -> execute(store, BLANK, "k", operation(), "h")); + assertThrows(IllegalArgumentException.class, () -> execute(store, PRINCIPAL, null, operation(), "h")); + assertThrows(IllegalArgumentException.class, () -> execute(store, PRINCIPAL, BLANK, operation(), "h")); + assertThrows(IllegalArgumentException.class, () -> execute(store, PRINCIPAL, "k", null, "h")); + assertThrows(IllegalArgumentException.class, () -> execute(store, PRINCIPAL, "k", operation(), null)); + assertThrows(IllegalArgumentException.class, () -> execute(store, PRINCIPAL, "k", operation(), BLANK)); + assertThrows( + NullPointerException.class, + () -> store.execute(new PlatformIdempotencyInput(PRINCIPAL, "k", operation(), "h"), null)); + assertThrows(NullPointerException.class, () -> PlatformIdempotencyStore.inMemory(null)); + } + + @Test + void validatesReplayInputMemberByMember() { + PlatformReplayStore store = PlatformReplayStore.inMemory(); + + assertThrows( + CompletionException.class, + () -> store.consume(null, NOW, NOW, null).toCompletableFuture().join()); + assertThrows( + CompletionException.class, + () -> store.consume("k", null, NOW, null).toCompletableFuture().join()); + assertThrows( + CompletionException.class, + () -> store.consume("k", NOW, null, null).toCompletableFuture().join()); + assertTrue(store.consume("k", NOW.plusSeconds(60), NOW, null) + .toCompletableFuture() + .join()); + assertFalse(store.consume("k", NOW.plusSeconds(60), NOW, null) + .toCompletableFuture() + .join()); + assertTrue( + store.consume("k", NOW.plusSeconds(120), NOW.plusSeconds(61), null) + .toCompletableFuture() + .join(), + "an expired entry stops blocking its own key"); + } + + @Test + void validatesStoreAndRecordArgumentsMemberByMember() { + PlatformIdentityStore store = PlatformIdentityStore.inMemory(); + + assertThrows(IllegalArgumentException.class, () -> store.findOrCreate(null, SERVICE_DID, () -> null)); + assertThrows(IllegalArgumentException.class, () -> store.findOrCreate(PRINCIPAL, null, () -> null)); + assertThrows(NullPointerException.class, () -> store.findOrCreate(PRINCIPAL, SERVICE_DID, null)); + assertThrows(IllegalArgumentException.class, () -> store.list(null, PlatformIdentityListQuery.defaults())); + assertThrows(NullPointerException.class, () -> store.list(PRINCIPAL, null)); + assertThrows( + IllegalArgumentException.class, + () -> store.list(PRINCIPAL, new PlatformIdentityListQuery(false, 101, 0, null, null))); + assertThrows( + IllegalArgumentException.class, + () -> store.list(PRINCIPAL, new PlatformIdentityListQuery(false, 1, -1, null, null))); + assertThrows( + CompletionException.class, + () -> store.findOrCreate(PRINCIPAL, SERVICE_DID, () -> completed(null)) + .toCompletableFuture() + .join()); + assertThrows( + NullPointerException.class, + () -> record(ManagedAgentStatus.ACTIVE).toBuilder() + .createdAt(null) + .build()); + assertThrows( + IllegalArgumentException.class, + () -> record(ManagedAgentStatus.ACTIVE).toBuilder().keyId(null).build()); + } + + @Test + void validatesVerificationMethodMembersIndependently() { + assertThrows( + IllegalArgumentException.class, + () -> new PlatformDidVerificationMethod(null, AGENT_DID, Map.of(KTY, EC), METHOD_TYPE)); + assertThrows( + IllegalArgumentException.class, + () -> new PlatformDidVerificationMethod(AGENT_DID, null, Map.of(KTY, EC), METHOD_TYPE)); + assertThrows( + IllegalArgumentException.class, + () -> new PlatformDidVerificationMethod(AGENT_DID, AGENT_DID, Map.of(KTY, EC), null)); + assertThrows( + IllegalArgumentException.class, + () -> new PlatformDidVerificationMethod(AGENT_DID, AGENT_DID, null, METHOD_TYPE)); + } + + @Test + void validatesHeaderAndJsonCopiesMemberByMember() { + assertThrows( + IllegalArgumentException.class, () -> headers(java.util.Collections.singletonMap(null, List.of("v")))); + assertThrows(IllegalArgumentException.class, () -> headers(Map.of("", List.of("v")))); + assertThrows( + IllegalArgumentException.class, () -> headers(java.util.Collections.singletonMap("X-Trace", null))); + assertThrows( + IllegalArgumentException.class, + () -> headers(Map.of("X-Trace", java.util.Collections.singletonList(null)))); + assertThrows(IllegalArgumentException.class, () -> headers(Map.of("X-Trace", List.of("padded ")))); + assertEquals( + List.of("ok!#$%&'*+-.^_`|~"), + headers(Map.of("X-Trace", List.of("ok!#$%&'*+-.^_`|~"))).get("X-Trace")); + assertEquals( + List.of("v"), + headers(Map.of("x-trace-2!#$%&'*+.^_`|~", List.of("v"))).get("x-trace-2!#$%&'*+.^_`|~"), + "digits, both cases, and every RFC 9110 token character are header-name material"); + assertEquals(null, PlatformCopies.jsonValue(null)); + assertEquals(true, PlatformCopies.jsonValue(true)); + assertEquals(1.5f, PlatformCopies.jsonValue(1.5f)); + assertThrows(IllegalArgumentException.class, () -> PlatformCopies.jsonValue(Float.NaN)); + assertThrows(IllegalArgumentException.class, () -> PlatformCopies.jsonValue(Double.NEGATIVE_INFINITY)); + } + + @Test + void listsOnlyTheRecordsOwnedByTheCallingPrincipal() { + PlatformIdentityStore store = PlatformIdentityStore.inMemory(); + store.findOrCreate(PRINCIPAL, SERVICE_DID, () -> completed(record(ManagedAgentStatus.ACTIVE))) + .toCompletableFuture() + .join(); + store.findOrCreate( + "other-principal", + SERVICE_DID, + () -> completed(record(ManagedAgentStatus.ACTIVE).toBuilder() + .agentDid("did:web:p.example:a:second") + .agentDidId("second") + .agentIdentityId("pai_second") + .keyId("did:web:p.example:a:second") + .principal("other-principal") + .build())) + .toCompletableFuture() + .join(); + + var mine = store.list(PRINCIPAL, PlatformIdentityListQuery.defaults()) + .toCompletableFuture() + .join(); + + assertEquals(1, mine.identities().size()); + assertEquals(1, mine.total()); + assertEquals(PRINCIPAL, mine.identities().get(0).principal()); + } + + @Test + void refusesIdentityMaterialThatCollidesOnAnyIndex() { + assertThrows( + CompletionException.class, + () -> collide(builder -> builder.agentDidId("distinct").agentIdentityId("pai_distinct")), + "the Agent DID is unique"); + assertThrows( + CompletionException.class, + () -> collide(builder -> + builder.agentDid("did:web:p.example:a:distinct").agentIdentityId("pai_distinct")), + "the Agent DID identifier is unique"); + assertThrows( + CompletionException.class, + () -> collide(builder -> + builder.agentDid("did:web:p.example:a:distinct").agentDidId("distinct")), + "the Agent identity identifier is unique"); + } + + @Test + void refusesHostedVerificationForRecordsWhoseKeyIdIsNotTheAgentDid() throws JOSEException { + PlatformFixtures.Keys keys = new PlatformFixtures.Keys(); + PlatformIdentityStore store = PlatformIdentityStore.inMemory(); + AepPlatform platform = builder(allow(), keys, true).identityStore(store).build(); + PlatformAgentIdentity identity = provision(platform, SERVICE_DID, PROVISION_KEY); + String assertion = ((PlatformSignResponses.Completed) + platform.sign(identity.agentIdentityId(), signRequest(null), context(SIGN_KEY)) + .toCompletableFuture() + .join() + .body()) + .clientAssertion(); + PlatformIdentityStore rekeyed = PlatformIdentityStore.inMemory(); + rekeyed.findOrCreate( + PRINCIPAL, + SERVICE_DID, + () -> completed(record(ManagedAgentStatus.ACTIVE).toBuilder() + .agentDid(identity.agentDid()) + .agentDidId("opaque1") + .keyId(identity.agentDid() + "#key-1") + .build())) + .toCompletableFuture() + .join(); + + assertFalse(verified(builder(allow(), keys, true).identityStore(rekeyed).build(), assertion, "v-rekeyed")); + } + + @Test + void refusesHostedVerificationTheAuthorizerDeclinesForThatOperationAlone() throws JOSEException { + PlatformFixtures.Keys keys = new PlatformFixtures.Keys(); + AepPlatform platform = builder( + discovery(true), + (request, context) -> completed(request.operation() != PlatformAuthorizationOperation.VERIFY), + keys, + resolver(true)) + .build(); + AepPlatform signing = platform(allow(), keys, true); + PlatformAgentIdentity identity = provision(signing, SERVICE_DID, PROVISION_KEY); + provision(platform, SERVICE_DID, PROVISION_KEY); + String assertion = ((PlatformSignResponses.Completed) + signing.sign(identity.agentIdentityId(), signRequest(null), context(SIGN_KEY)) + .toCompletableFuture() + .join() + .body()) + .clientAssertion(); + + assertFalse(verified(platform, assertion, "v-denied")); + } + + @Test + void doesNotTreatAProblemBodyCarryingContentAsAValidProblem() throws JOSEException { + assertThrows( + CompletionException.class, + () -> handled(new PlatformResponse<>( + 400, + Aep.PROBLEM_MEDIA_TYPE, + completedBody(AGENT_DID, SERVICE_DID, JWT_ID), + ProblemDetails.of("invalid_request", "No.", 400), + Map.of())), + "a problem response carries no success body"); + assertThrows( + CompletionException.class, + () -> handled(new PlatformResponse<>(400, Aep.PROBLEM_MEDIA_TYPE, null, null, Map.of())), + "a problem response carries a problem document"); + } + + @Test + void permissiveTransitionPolicyStillGatesOnTheActiveState() { + PlatformLifecyclePolicy policy = PlatformLifecyclePolicy.permissiveTransitions(); + PlatformIdentityRecord suspended = record(ManagedAgentStatus.SUSPENDED); + + assertFalse(join(policy.canSign(suspended, null))); + assertFalse(join(policy.canVerify(suspended, null))); + assertTrue(join(policy.canTransition(suspended, ManagedAgentStatus.ACTIVE, null))); + assertTrue(join(policy.canSign(record(ManagedAgentStatus.ACTIVE), null))); + assertTrue(join(policy.canVerify(record(ManagedAgentStatus.ACTIVE), null))); + } + + // AEP core refuses to mint an assertion whose subject is not its issuer, so an assertion that + // breaks that binding has to be assembled directly to reach the Platform's own check. + private static String unbound(PlatformFixtures.Keys keys, String issuer, String subject, String keyId) + throws JOSEException { + com.nimbusds.jwt.SignedJWT jwt = new com.nimbusds.jwt.SignedJWT( + new com.nimbusds.jose.JWSHeader.Builder(com.nimbusds.jose.JWSAlgorithm.ES256) + .type(com.nimbusds.jose.JOSEObjectType.JWT) + .keyID(keyId) + .build(), + new com.nimbusds.jwt.JWTClaimsSet.Builder() + .issuer(issuer) + .subject(subject) + .audience(SERVICE_DID) + .issueTime(java.util.Date.from(NOW)) + .expirationTime(java.util.Date.from(NOW.plusSeconds(60))) + .jwtID(JWT_ID) + .claim("op", "enroll") + .build()); + jwt.sign(new com.nimbusds.jose.crypto.ECDSASigner(keys.key)); + return jwt.serialize(); + } + + private static void collide(java.util.function.UnaryOperator change) { + PlatformIdentityStore store = PlatformIdentityStore.inMemory(); + store.findOrCreate(PRINCIPAL, SERVICE_DID, () -> completed(record(ManagedAgentStatus.ACTIVE))) + .toCompletableFuture() + .join(); + store.findOrCreate( + PRINCIPAL, + OTHER_SERVICE_DID, + () -> completed(change.apply(record(ManagedAgentStatus.ACTIVE).toBuilder() + .serviceDid(OTHER_SERVICE_DID)) + .build())) + .toCompletableFuture() + .join(); + } + + private static PlatformIdempotentOperation operation() { + return PlatformIdempotentOperation.SIGN; + } + + private static void execute( + PlatformIdempotencyStore store, + String principal, + String key, + PlatformIdempotentOperation operation, + String hash) { + store.execute( + new PlatformIdempotencyInput(principal, key, operation, hash), + () -> completed(new PlatformResponse<>(200, Aep.MEDIA_TYPE, "b", null, Map.of()))); + } + + private static PlatformLifecyclePolicy policy(boolean sign, boolean verify) { + return new PlatformLifecyclePolicy() { + @Override + public CompletionStage canSign(PlatformIdentityRecord identity, PlatformRequestContext context) { + return completed(sign); + } + + @Override + public CompletionStage canTransition( + PlatformIdentityRecord identity, ManagedAgentStatus nextStatus, PlatformRequestContext context) { + return completed(true); + } + + @Override + public CompletionStage canVerify(PlatformIdentityRecord identity, PlatformRequestContext context) { + return completed(verify); + } + }; + } + + private static foundation.aep.core.PlatformLifecycleRequest lifecycle() { + return new foundation.aep.core.PlatformLifecycleRequest(ManagedAgentStatus.ACTIVE); + } + + private static PlatformSignResponses.Response completedBody(String agentDid, String serviceDid, String jwtId) { + return new PlatformSignResponses.Completed( + "completed", agentDid, "assertion", EXPIRES_AT, ISSUED_AT, jwtId, null, serviceDid); + } + + private static Map> headers(Map> source) { + return new PlatformResponse<>(200, Aep.MEDIA_TYPE, "body", null, source).headers(); + } + + private static boolean verified(AepPlatform platform, String assertion, String key) { + return platform.verify( + new PlatformVerificationRequest(assertion, AssertionOperation.ENROLL, null, SERVICE_DID), + context(key)) + .toCompletableFuture() + .join() + .body() + .verified(); + } + + private static boolean join(CompletionStage stage) { + return stage.toCompletableFuture().join(); + } + + private static int status(CompletionStage> stage) { + return stage.toCompletableFuture().join().status(); + } + + private static PlatformResponse handled(PlatformResponse response) + throws JOSEException { + AepPlatform platform = builder(allow(), new PlatformFixtures.Keys(), false) + .signHandler((identity, request, context) -> completed(Optional.of(response))) + .build(); + PlatformAgentIdentity identity = provision(platform, SERVICE_DID, PROVISION_KEY); + return platform.sign(identity.agentIdentityId(), signRequest(null), context(SIGN_KEY)) + .toCompletableFuture() + .join(); + } +} diff --git a/aep-platform/src/test/java/foundation/aep/platform/PlatformFixtures.java b/aep-platform/src/test/java/foundation/aep/platform/PlatformFixtures.java new file mode 100644 index 0000000..9feb3f9 --- /dev/null +++ b/aep-platform/src/test/java/foundation/aep/platform/PlatformFixtures.java @@ -0,0 +1,160 @@ +package foundation.aep.platform; + +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.jwk.Curve; +import com.nimbusds.jose.jwk.ECKey; +import com.nimbusds.jose.jwk.JWK; +import com.nimbusds.jose.jwk.gen.ECKeyGenerator; +import foundation.aep.core.AssertionOperation; +import foundation.aep.core.ClientAssertionClaims; +import foundation.aep.core.ClientAssertions; +import foundation.aep.core.ManagedAgentStatus; +import foundation.aep.core.PlatformAgentIdentity; +import foundation.aep.core.PlatformDiscoveryDocument; +import foundation.aep.core.PlatformProvisionRequest; +import foundation.aep.core.PlatformSignRequest; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.atomic.AtomicInteger; + +/** Shared construction helpers for the AEP Platform tests. */ +final class PlatformFixtures { + static final Instant NOW = Instant.parse("2026-07-06T12:00:00Z"); + static final Clock CLOCK = Clock.fixed(NOW, ZoneOffset.UTC); + static final String SERVICE_DID = "did:web:api.service.example"; + static final String OTHER_SERVICE_DID = "did:web:billing.service.example"; + static final String DID_HOST = "p.example"; + static final String DID_PREFIX = "a"; + static final String DID_URL_TEMPLATE = "https://p.example/a/{agent_did_id}/did.json"; + static final String PRINCIPAL = "principal-1"; + static final String JWT_ID = "assertion-1"; + static final String PROVISION_KEY = "provision"; + + private PlatformFixtures() {} + + static PlatformDiscoveryDocument discovery(boolean hostedVerification) { + return discovery(hostedVerification, DID_URL_TEMPLATE, "300"); + } + + static PlatformDiscoveryDocument discovery( + boolean hostedVerification, String didUrlTemplate, String defaultLifetimeSeconds) { + return new PlatformDiscoveryDocument( + "1.0", + new PlatformDiscoveryDocument.Endpoints( + hostedVerification ? "/v1/aep/verifications" : null, + "/v1/aep/agent-identities/{agent_identity_id}", + "/v1/aep/agent-identities", + "/v1/aep/agent-identities", + "/v1/aep/agent-identities/{agent_identity_id}/sign"), + new PlatformDiscoveryDocument.Http("/v1/aep"), + new PlatformDiscoveryDocument.Identity(List.of("did:web"), didUrlTemplate), + new PlatformDiscoveryDocument.Platform("did:web:p.example", hostedVerification, "Example Platform"), + new PlatformDiscoveryDocument.Signing(List.of("ES256"), defaultLifetimeSeconds)); + } + + static AepPlatform.Builder builder(PlatformAuthorizer authorizer, Keys keys, boolean hostedVerification) { + return builder(discovery(hostedVerification), authorizer, keys, resolver(true)); + } + + static AepPlatform.Builder builder( + PlatformDiscoveryDocument discovery, + PlatformAuthorizer authorizer, + Keys keys, + PlatformServiceDidResolver resolver) { + AtomicInteger identity = new AtomicInteger(); + AtomicInteger did = new AtomicInteger(); + return AepPlatform.builder(discovery, DID_HOST, authorizer, keys, resolver) + .clock(CLOCK) + .didPathPrefix(DID_PREFIX) + .identityIdSupplier(() -> Integer.toString(identity.incrementAndGet())) + .agentDidIdSupplier(() -> "opaque" + did.incrementAndGet()) + .replayStore(PlatformReplayStore.inMemory()); + } + + static AepPlatform platform(PlatformAuthorizer authorizer, Keys keys, boolean hostedVerification) { + return builder(authorizer, keys, hostedVerification).build(); + } + + static PlatformAuthorizer allow() { + return (request, context) -> completed(true); + } + + static PlatformServiceDidResolver resolver(boolean resolved) { + return (serviceDid, context) -> completed(resolved); + } + + static PlatformRequestContext context(String key) { + return new PlatformRequestContext(PRINCIPAL, key, NOW, Map.of()); + } + + static PlatformAgentIdentity provision(AepPlatform platform, String serviceDid, String key) { + return (PlatformAgentIdentity) platform.provision(new PlatformProvisionRequest(serviceDid), context(key)) + .toCompletableFuture() + .join() + .body(); + } + + static PlatformSignRequest signRequest(String lifetimeSeconds) { + return new PlatformSignRequest(JWT_ID, lifetimeSeconds, AssertionOperation.ENROLL, null, null, SERVICE_DID); + } + + static PlatformIdentityRecord record(ManagedAgentStatus status) { + return PlatformIdentityRecord.builder() + .agentDid("did:web:p.example:a:opaque") + .agentDidId("opaque") + .agentIdentityId("pai_identity") + .createdAt(NOW) + .didDocumentUrl("https://p.example/a/opaque/did.json") + .keyId("did:web:p.example:a:opaque") + .principal(PRINCIPAL) + .serviceDid(SERVICE_DID) + .signingAlgorithms(List.of("ES256")) + .status(status) + .updatedAt(NOW) + .build(); + } + + static CompletionStage completed(T value) { + return CompletableFuture.completedFuture(value); + } + + /** A key store backed by one P-256 key, with hooks for the failure paths under test. */ + static class Keys implements PlatformKeyStore { + final ECKey key; + final AtomicInteger created = new AtomicInteger(); + String keyIdSuffix = ""; + + Keys() throws JOSEException { + key = new ECKeyGenerator(Curve.P_256).keyID("platform-key").generate(); + } + + @Override + public CompletionStage create(PlatformIdentityRecord identity, PlatformRequestContext context) { + created.incrementAndGet(); + return completed(null); + } + + @Override + public CompletionStage didVerificationMethod( + PlatformIdentityRecord identity, PlatformRequestContext context) { + return completed(new PlatformDidVerificationMethod( + identity.agentDid(), identity.agentDid(), key.toPublicJWK().toJSONObject(), "JsonWebKey2020")); + } + + @Override + public CompletionStage sign( + PlatformIdentityRecord identity, ClientAssertionClaims claims, PlatformRequestContext context) { + return completed(ClientAssertions.sign(claims, key, identity.keyId() + keyIdSuffix)); + } + + @Override + public CompletionStage verificationKey(PlatformIdentityRecord identity, PlatformRequestContext context) { + return completed(key.toPublicJWK()); + } + } +} diff --git a/aep-service/README.md b/aep-service/README.md index f9c8ff4..af17083 100644 --- a/aep-service/README.md +++ b/aep-service/README.md @@ -41,6 +41,31 @@ The default stores are process-local and intended for development. Production Se provide durable `EnrollmentStore` and `IdempotencyStore` implementations and a replay store whose consume operation is atomic across every Service instance. +### Use a trusted Platform for verification + +`HostedPlatformClientAssertionVerifier` sends assertions to a Platform with which your Service has +an established trust relationship. Configure the endpoint yourself; do not derive it from an +unverified assertion. + +```java +ClientAssertionVerifier assertionVerifier = HostedPlatformClientAssertionVerifier + .builder(URI.create("https://platform.example/v1/aep/verifications"), transport) + .authorization("Bearer service-credential") + .build(); +``` + +The `transport` is an `AepHttpTransport` implementation. It must honor the request timeout and +return redirects without following them, so Platform credentials cannot be forwarded to another +endpoint. The verifier requires HTTPS, defaults to a 30-second timeout, and rejects unsuccessful +or mismatched verification responses. Identical requests reuse a verification idempotency key; +a fresh assertion produces a different key. `AepService` also validates the returned claims and +applies its own replay protection. The timeout bounds the verifier's wait; stopping network work +is the transport's responsibility. + +Hosted verification is optional. Services must retain a local verification path for otherwise valid +Agent assertions, rather than requiring every Agent to use their trusted Platform. Applications +supporting both paths can select the appropriate verifier through `ClientAssertionVerifier`. + ## Expose HTTP commands `AepServiceHttpHandler` provides the shared HTTP boundary used by the JDK HTTP server, Servlet, and @@ -75,7 +100,12 @@ or adapt `AepServiceHttpHandler`. ## Add a credential profile Use a shared credential store with one stored definition for every built-in Grant Type advertised by -the Inspect document: +the Inspect document. This example requires a document that advertises `api-key` in +`commands.grant_types`, provides its `commands.grant_types_config` entry, and enables the `grant` +and `revoke` commands. To use that key on protected resources, also advertise `api-key` in +`authentication.methods`. The command-only document above does not include this configuration; +see [the runnable example's Inspect document](../examples/src/main/java/foundation/aep/examples/AgentServiceExample.java) +for the complete setup. ```java ServiceCredentialStore credentialStore = ServiceCredentialStore.inMemory(); @@ -103,7 +133,7 @@ values: - `required` values must be present before enrollment can succeed; - `preferred` values improve the Service experience but do not block enrollment; -- `optional` values may be supplied without being requested. +- `optional` values are accepted but are not needed for enrollment. `AepService` checks required advertised values before invoking the enrollment policy. The policy can return additional `requirementsPending` or `verificationPending` values when an application diff --git a/aep-service/src/main/java/foundation/aep/service/AepHttpRequest.java b/aep-service/src/main/java/foundation/aep/service/AepHttpRequest.java index 510a635..62d73e3 100644 --- a/aep-service/src/main/java/foundation/aep/service/AepHttpRequest.java +++ b/aep-service/src/main/java/foundation/aep/service/AepHttpRequest.java @@ -51,14 +51,14 @@ public static URI publicUrl(URI publicOrigin, String rawPath, String rawQuery) { throw new IllegalArgumentException("rawPath must be an absolute path."); } try { - return new URI( - publicOrigin.getScheme(), - null, - publicOrigin.getHost(), - publicOrigin.getPort(), - rawPath, - rawQuery, - null); + URI result = new URI(publicOrigin.getScheme() + "://" + publicOrigin.getRawAuthority() + rawPath + + (rawQuery == null ? "" : "?" + rawQuery)); + if (!rawPath.equals(result.getRawPath()) + || !Objects.equals(rawQuery, result.getRawQuery()) + || result.getRawFragment() != null) { + throw new IllegalArgumentException("Request target is invalid."); + } + return result; } catch (URISyntaxException exception) { throw new IllegalArgumentException("Request target is invalid.", exception); } diff --git a/aep-service/src/main/java/foundation/aep/service/AepService.java b/aep-service/src/main/java/foundation/aep/service/AepService.java index c14b479..b4ac916 100644 --- a/aep-service/src/main/java/foundation/aep/service/AepService.java +++ b/aep-service/src/main/java/foundation/aep/service/AepService.java @@ -33,6 +33,7 @@ import java.util.Map; import java.util.Objects; import java.util.Optional; +import java.util.Set; import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionStage; @@ -40,6 +41,7 @@ public final class AepService { private static final String ERROR_NOT_RECOGNIZED = "not_recognized"; + private static final Set AEP_SCHEMES = Set.of("aep", "bearer", "basic"); private static final String TITLE_NOT_RECOGNIZED = "Not recognized"; private static final String ERROR_INVALID_REQUEST = "invalid_request"; private static final String TITLE_INVALID_REQUEST = "Invalid request"; @@ -645,8 +647,12 @@ private static Presentation selectPresentation(Map> headers for (String value : standard) { try { recognized.add(AepHttp.parseAuthorization(value, AuthorizationCarrier.STANDARD)); - } catch (IllegalArgumentException ignored) { - // Unrelated Authorization schemes remain available to other protocols. + } catch (IllegalArgumentException exception) { + // Unrelated Authorization schemes remain available to other protocols. A value that + // names an AEP scheme is not one of those: under CORE-PR-038 it is a malformed AEP + // presentation rather than an absent one, which answers not_recognized instead of + // authentication_required. + if (namesAepScheme(value)) return Presentation.invalid(); } } if (recognized.size() > SINGLE_VALUE || (recognized.size() == SINGLE_VALUE && standard.size() > SINGLE_VALUE)) @@ -654,6 +660,15 @@ private static Presentation selectPresentation(Map> headers return Presentation.valid(recognized.isEmpty() ? null : recognized.get(0)); } + private static boolean namesAepScheme(String value) { + if (value == null) return false; + int end = 0; + while (end < value.length() && value.charAt(end) != ' ' && value.charAt(end) != '\t') { + end++; + } + return AEP_SCHEMES.contains(value.substring(0, end).toLowerCase(Locale.ROOT)); + } + private static boolean isRecognizedAuthorization(String value) { try { AepHttp.parseAuthorization(value, AuthorizationCarrier.STANDARD); diff --git a/aep-service/src/main/java/foundation/aep/service/AepServiceHttpHandler.java b/aep-service/src/main/java/foundation/aep/service/AepServiceHttpHandler.java index fe4d269..dcf66c4 100644 --- a/aep-service/src/main/java/foundation/aep/service/AepServiceHttpHandler.java +++ b/aep-service/src/main/java/foundation/aep/service/AepServiceHttpHandler.java @@ -12,6 +12,8 @@ import java.nio.charset.CharacterCodingException; import java.nio.charset.CodingErrorAction; import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.util.EnumMap; import java.util.List; import java.util.Locale; @@ -22,21 +24,32 @@ public final class AepServiceHttpHandler { public static final int DEFAULT_MAXIMUM_REQUEST_BYTES = 65_536; + public static final String DEFAULT_INSPECT_CACHE_CONTROL = "public, max-age=300"; private static final String ERROR_INVALID_REQUEST = "invalid_request"; + private static final String CACHE_CONTROL = "Cache-Control"; + private static final String NO_STORE = "no-store"; + private static final String SAFE_METHODS = "GET, HEAD"; private static final int SINGLE_HEADER_VALUE = 1; private final AepService service; private final Map commandRoutes; private final int requestByteLimit; + private final String cacheControlValue; + private final String entityTagValue; public AepServiceHttpHandler(AepService service) { this(service, DEFAULT_MAXIMUM_REQUEST_BYTES); } public AepServiceHttpHandler(AepService service, int maximumRequestBytes) { + this(service, maximumRequestBytes, DEFAULT_INSPECT_CACHE_CONTROL); + } + + public AepServiceHttpHandler(AepService service, int maximumRequestBytes, String inspectCacheControl) { this.service = Objects.requireNonNull(service, "service"); if (maximumRequestBytes <= 0) throw new IllegalArgumentException("maximumRequestBytes must be positive."); requestByteLimit = maximumRequestBytes; + cacheControlValue = requireFieldValue(inspectCacheControl, "inspectCacheControl"); Map configuredRoutes = new EnumMap<>(AepCommand.class); configuredRoutes.put(AepCommand.INSPECT, Aep.WELL_KNOWN_PATH); for (String command : service.inspectDocument().commands().supported()) { @@ -47,6 +60,15 @@ public AepServiceHttpHandler(AepService service, int maximumRequestBytes) { AepHttp.commandPath(parsed, service.inspectDocument().http().endpointBase())); } commandRoutes = Map.copyOf(configuredRoutes); + entityTagValue = entityTag(AepJson.write(service.inspectDocument())); + } + + public String inspectCacheControl() { + return cacheControlValue; + } + + public String inspectEntityTag() { + return entityTagValue; } public Map routes() { @@ -63,10 +85,16 @@ public CompletionStage handle(AepCommand command, AepHttpReques if (!commandRoutes.containsKey(command)) { return completed(problem(404, ERROR_INVALID_REQUEST, "AEP route not found")); } - String expectedMethod = command == AepCommand.INSPECT || command == AepCommand.STATUS ? "GET" : "POST"; - if (!expectedMethod.equals(request.method().toUpperCase(Locale.ROOT))) { + // RFC 9110 section 9.3.2 makes HEAD identical to GET apart from the content, so a resource + // that answers GET answers HEAD as well. Allowing only GET turned every cache revalidation + // and every link check into a 405. + boolean safe = command == AepCommand.INSPECT || command == AepCommand.STATUS; + String method = request.method().toUpperCase(Locale.ROOT); + if (!(safe ? "GET".equals(method) || "HEAD".equals(method) : "POST".equals(method))) { return completed(withHeader( - problem(405, ERROR_INVALID_REQUEST, "Method not allowed"), "Allow", List.of(expectedMethod))); + problem(405, ERROR_INVALID_REQUEST, "Method not allowed"), + "Allow", + List.of(safe ? SAFE_METHODS : "POST"))); } if (request.body().length > requestByteLimit) { return completed(problem(413, ERROR_INVALID_REQUEST, "AEP request exceeds its byte limit")); @@ -75,7 +103,16 @@ public CompletionStage handle(AepCommand command, AepHttpReques if (request.body().length != 0) { return completed(problem(400, ERROR_INVALID_REQUEST, "Inspect has no body")); } - return completed(json(200, Aep.MEDIA_TYPE, service.inspectDocument(), Map.of())); + // CORE-CACHE-001 and CORE-CACHE-002 ask for cache metadata on Inspect, and CORE-CACHE-004 + // makes an Agent revalidate through 304. Without a validator to echo, every expiry cost a + // full refetch of a document that rarely changes. + Map> cache = new java.util.LinkedHashMap<>(); + cache.put(CACHE_CONTROL, List.of(cacheControlValue)); + cache.put("ETag", List.of(entityTagValue)); + if (matchesEntityTag(request.headerValues("If-None-Match"), entityTagValue)) { + return completed(new AepHttpResponse(304, Aep.MEDIA_TYPE, cache, new byte[0])); + } + return completed(json(200, Aep.MEDIA_TYPE, service.inspectDocument(), cache)); } if (command == AepCommand.STATUS) { if (request.body().length != 0) { @@ -112,9 +149,14 @@ public CompletionStage authenticate(AepHttpRequest : AepHttpAuthenticationResult.rejected(encode(result.response()))); } + // Authenticated command responses carry enrollment state and, for Grant, the credential secret + // itself. Nothing on this path is a shared representation, so no intermediary or browser cache + // should keep one. private AepHttpResponse encode(ServiceResponse response) { Object body = response.problem() == null ? response.body() : response.problem(); - return json(response.status(), response.contentType(), body, response.headers()); + Map> headers = new java.util.LinkedHashMap<>(response.headers()); + headers.putIfAbsent(CACHE_CONTROL, List.of(NO_STORE)); + return json(response.status(), response.contentType(), body, headers); } private static AepHttpResponse json( @@ -124,7 +166,48 @@ private static AepHttpResponse json( } private static AepHttpResponse problem(int status, String code, String title) { - return json(status, Aep.PROBLEM_MEDIA_TYPE, ProblemDetails.of(code, title, status), Map.of()); + return json( + status, + Aep.PROBLEM_MEDIA_TYPE, + ProblemDetails.of(code, title, status), + Map.of(CACHE_CONTROL, List.of(NO_STORE))); + } + + private static String requireFieldValue(String value, String name) { + Objects.requireNonNull(value, name); + if (value.isBlank() || !value.equals(value.strip()) || value.chars().anyMatch(AepServiceHttpHandler::control)) { + throw new IllegalArgumentException(name + " must be a valid HTTP field value."); + } + return value; + } + + private static boolean control(int character) { + return character != '\t' && (character < ' ' || character == 0x7f); + } + + private static String entityTag(String body) { + try { + byte[] digest = MessageDigest.getInstance("SHA-256").digest(body.getBytes(StandardCharsets.UTF_8)); + return "\"sha256:" + java.util.HexFormat.of().formatHex(digest) + "\""; + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 is unavailable.", exception); + } + } + + // RFC 9110 section 13.1.2: If-None-Match is a comma-separated list, "*" matches any + // representation, and the comparison is weak, so a W/ prefix on either side still matches. + private static boolean matchesEntityTag(List values, String entityTag) { + for (String value : values) { + for (String candidate : value.split(",")) { + String trimmed = candidate.trim(); + if ("*".equals(trimmed) || entityTag.equals(strongTag(trimmed))) return true; + } + } + return false; + } + + private static String strongTag(String value) { + return value.startsWith("W/") ? value.substring(2) : value; } private static AepHttpResponse withHeader(AepHttpResponse response, String name, List values) { diff --git a/aep-service/src/main/java/foundation/aep/service/CredentialAuthenticationInput.java b/aep-service/src/main/java/foundation/aep/service/CredentialAuthenticationInput.java index 7fbb1e5..5008453 100644 --- a/aep-service/src/main/java/foundation/aep/service/CredentialAuthenticationInput.java +++ b/aep-service/src/main/java/foundation/aep/service/CredentialAuthenticationInput.java @@ -14,4 +14,13 @@ public record CredentialAuthenticationInput(Map> headers, S public Map> headers() { return ServiceCopies.headers(headers); } + + // Every request header reaches this record, so a generated toString would print the Authorization + // field value, the API-key field value, and base64(username:password) verbatim into whatever log + // a third-party authenticator or credential store keeps. The header names alone carry no secret. + @Override + public String toString() { + return "CredentialAuthenticationInput[headerNames=" + headers.keySet() + ", method=" + method + ", url=" + url + + ", now=" + now + "]"; + } } diff --git a/aep-service/src/main/java/foundation/aep/service/HostedPlatformClientAssertionVerifier.java b/aep-service/src/main/java/foundation/aep/service/HostedPlatformClientAssertionVerifier.java new file mode 100644 index 0000000..74987d5 --- /dev/null +++ b/aep-service/src/main/java/foundation/aep/service/HostedPlatformClientAssertionVerifier.java @@ -0,0 +1,119 @@ +package foundation.aep.service; + +import foundation.aep.core.Aep; +import foundation.aep.core.AepHttpTransport; +import foundation.aep.core.AepJson; +import foundation.aep.core.ClientAssertionClaims; +import foundation.aep.core.ClientAssertions; +import foundation.aep.core.PlatformVerificationRequest; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Duration; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.TimeUnit; + +/** Verifies assertions through an explicitly trusted Platform, not an assertion-supplied URL. */ +public final class HostedPlatformClientAssertionVerifier implements ClientAssertionVerifier { + private final URI endpoint; + private final AepHttpTransport transport; + private final String authorization; + private final Duration timeout; + + private HostedPlatformClientAssertionVerifier(Builder builder) { + endpoint = Objects.requireNonNull(builder.endpoint, "endpoint"); + if (!"https".equalsIgnoreCase(endpoint.getScheme()) + || endpoint.getHost() == null + || endpoint.getRawUserInfo() != null + || endpoint.getRawFragment() != null) { + throw new IllegalArgumentException( + "Hosted verification requires an HTTPS endpoint without credentials or a fragment."); + } + transport = Objects.requireNonNull(builder.transport, "transport"); + authorization = builder.authorizationValue; + timeout = Objects.requireNonNull(builder.timeoutValue, "timeout"); + if (timeout.isNegative() || timeout.isZero() || timeout.toMillis() < 1) { + throw new IllegalArgumentException("Hosted verification timeout must be at least one millisecond."); + } + } + + public static Builder builder(URI endpoint, AepHttpTransport transport) { + return new Builder(endpoint, transport); + } + + @Override + public CompletionStage verify(String assertion, AssertionVerificationContext context) { + ClientAssertionClaims claims = ClientAssertions.decodeUnverified(assertion); + byte[] body = AepJson.write(new PlatformVerificationRequest( + assertion, context.operation(), context.resource(), context.serviceDid())) + .getBytes(StandardCharsets.UTF_8); + Map> headers = new LinkedHashMap<>(); + headers.put("Accept", List.of(Aep.MEDIA_TYPE)); + headers.put("Content-Type", List.of(Aep.MEDIA_TYPE)); + if (authorization != null) headers.put("Authorization", List.of(authorization)); + // Command retries can carry fresh assertions; only an identical verification request reuses this key. + headers.put("Idempotency-Key", List.of(digest(body))); + return transport + .execute(new AepHttpTransport.Request("POST", endpoint, headers, body, timeout)) + .toCompletableFuture() + .copy() + .orTimeout(timeout.toMillis(), TimeUnit.MILLISECONDS) + .thenApply(response -> { + if (response.status() < 200 || response.status() >= 300) { + throw new IllegalArgumentException( + "Hosted verification failed with HTTP " + response.status() + "."); + } + var verification = AepJson.parsePlatformVerificationResponse( + new String(response.body(), StandardCharsets.UTF_8)); + if (!verification.verified() + || !Objects.equals(verification.agentDid(), claims.subject()) + || verification.operation() != context.operation() + || !Objects.equals(verification.serviceDid(), context.serviceDid())) { + throw new IllegalArgumentException( + "Hosted verification did not recognize the client assertion."); + } + return claims; + }); + } + + private static String digest(byte[] body) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(body)); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 is unavailable.", exception); + } + } + + public static final class Builder { + private final URI endpoint; + private final AepHttpTransport transport; + private String authorizationValue; + private Duration timeoutValue = Duration.ofSeconds(30); + + private Builder(URI endpoint, AepHttpTransport transport) { + this.endpoint = endpoint; + this.transport = transport; + } + + public Builder authorization(String value) { + authorizationValue = value; + return this; + } + + public Builder timeout(Duration value) { + timeoutValue = value; + return this; + } + + /** The transport must honor request timeouts and return redirects without following them. */ + public HostedPlatformClientAssertionVerifier build() { + return new HostedPlatformClientAssertionVerifier(this); + } + } +} diff --git a/aep-service/src/main/java/foundation/aep/service/IdempotencyStore.java b/aep-service/src/main/java/foundation/aep/service/IdempotencyStore.java index 42ff70a..72913d3 100644 --- a/aep-service/src/main/java/foundation/aep/service/IdempotencyStore.java +++ b/aep-service/src/main/java/foundation/aep/service/IdempotencyStore.java @@ -87,9 +87,11 @@ private synchronized void complete( destination.complete(response); } + // A record whose age is exactly the retention window is still inside it, so an exact retry at + // the one-hour boundary has to replay the stored result rather than re-run the operation. private synchronized void expire() { Instant cutoff = clock.instant().minus(RETENTION); - entries.values().removeIf(entry -> !entry.createdAt.isAfter(cutoff)); + entries.values().removeIf(entry -> entry.createdAt.isBefore(cutoff)); } @SuppressWarnings("unchecked") diff --git a/aep-service/src/main/java/foundation/aep/service/InMemoryServiceCredentialStore.java b/aep-service/src/main/java/foundation/aep/service/InMemoryServiceCredentialStore.java index ff13461..e923f48 100644 --- a/aep-service/src/main/java/foundation/aep/service/InMemoryServiceCredentialStore.java +++ b/aep-service/src/main/java/foundation/aep/service/InMemoryServiceCredentialStore.java @@ -15,6 +15,7 @@ import java.util.Base64; import java.util.LinkedHashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Optional; import java.util.concurrent.CompletableFuture; @@ -22,6 +23,7 @@ final class InMemoryServiceCredentialStore implements ServiceCredentialStore { private static final int SINGLE_PRESENTATION = 1; + private static final String AUTHORIZATION = "Authorization"; private final Map records = new LinkedHashMap<>(); @Override @@ -29,11 +31,10 @@ public synchronized CompletionStage> authentica String grantType, CredentialAuthenticationInput input) { List presentations = presentations(grantType, input.headers()); if (Aep.GRANT_TYPE_API_KEY.equals(grantType)) { - records.values().stream() - .filter(record -> grantType.equals(record.grantType())) - .map(StoredRecord::header) - .distinct() - .forEach(header -> presentations.addAll(apiKeyPresentations(input.headers(), header))); + // Header names are case-insensitive on the wire (APK-CFG-003), so two records spelling + // the same header differently name one field. Deduplicating on the raw spelling harvested + // that one field twice and tipped every API key into the ambiguity branch below. + apiKeyHeaders().forEach(header -> presentations.addAll(apiKeyPresentations(input.headers(), header))); } if (presentations.size() != SINGLE_PRESENTATION) { return completed(Optional.empty()); @@ -63,10 +64,9 @@ public synchronized CompletionStage hasPresentation( if (!Aep.GRANT_TYPE_API_KEY.equals(grantType)) { return completed(!presentations(grantType, input.headers()).isEmpty()); } - boolean present = records.values().stream() - .filter(record -> grantType.equals(record.grantType())) - .map(StoredRecord::header) - .anyMatch(header -> !headerValues(input.headers(), header).isEmpty()); + Map> headers = input.headers(); + boolean present = apiKeyHeaders().stream() + .anyMatch(header -> !headerValues(headers, header).isEmpty()); return completed(present); } @@ -107,6 +107,7 @@ public synchronized CompletionStage save(ServiceCredentialRecord record) { } catch (RuntimeException exception) { return CompletableFuture.failedFuture(exception); } + // Expired and revoked identifiers remain reserved: AEP forbids reassignment. if (records.containsKey(record.credentialId())) { return CompletableFuture.failedFuture( new IllegalArgumentException("AEP credential identifier has already been issued.")); @@ -135,15 +136,17 @@ private static StoredRecord stored(ServiceCredentialRecord record) { throw new IllegalArgumentException("AEP credential store received an invalid record."); } if (credential instanceof GrantResponses.OAuthBearer bearer) { - return record(record, "Authorization", bearer.accessToken(), bearer.scopes()); + return record(record, AUTHORIZATION, bearer.accessToken(), bearer.scopes()); } if (credential instanceof GrantResponses.ApiKey apiKey) { - return record(record, apiKey.header(), apiKey.apiKey(), apiKey.scopes()); + // APK-CFG-003 asks Services to publish lowercase header names, and the wire comparison is + // case-insensitive either way, so one canonical spelling is what gets stored. + return record(record, apiKey.header().toLowerCase(Locale.ROOT), apiKey.apiKey(), apiKey.scopes()); } if (credential instanceof GrantResponses.Basic basic) { String plain = basic.username() + ':' + basic.password(); String encoded = Base64.getEncoder().encodeToString(plain.getBytes(StandardCharsets.UTF_8)); - return record(record, "Authorization", encoded, basic.scopes()); + return record(record, AUTHORIZATION, encoded, basic.scopes()); } throw new IllegalArgumentException("AEP credential store requires a built-in credential."); } @@ -161,27 +164,43 @@ private static StoredRecord record( digest(secret)); } + private synchronized List apiKeyHeaders() { + return records.values().stream() + .filter(record -> Aep.GRANT_TYPE_API_KEY.equals(record.grantType())) + .map(StoredRecord::header) + .distinct() + .toList(); + } + private static List presentations(String grantType, Map> headers) { if (Aep.GRANT_TYPE_API_KEY.equals(grantType)) return new ArrayList<>(); AuthorizationScheme expected = Aep.GRANT_TYPE_BASIC.equals(grantType) ? AuthorizationScheme.BASIC : AuthorizationScheme.BEARER; List result = new ArrayList<>(); - addAuthorizationPresentations(result, headers, "Authorization", AuthorizationCarrier.STANDARD, expected); + addAuthorizationPresentations(result, headers, AUTHORIZATION, AuthorizationCarrier.STANDARD, expected); addAuthorizationPresentations( result, headers, Aep.AUTHORIZATION_HEADER, AuthorizationCarrier.DEDICATED, expected); return result; } + // OAB-PRS-003 and BSC-PRS-003 make both carriers acceptable on a protected resource, and + // OAB-PRS-002/BSC-PRS-002 keep the field value identical between them: only the field the value + // travels in changes. Bearer and Basic records are stored under the canonical Authorization slot, + // so a presentation is filed there whichever carrier delivered it. Filing it under the request + // field name instead meant an AEP-Authorization credential matched no record and every + // dedicated-carrier request came back not_recognized. private static void addAuthorizationPresentations( List result, Map> headers, - String header, + String field, AuthorizationCarrier carrier, AuthorizationScheme expected) { - for (String value : headerValues(headers, header)) { + for (String value : headerValues(headers, field)) { try { ProtectedResourceAuthorization parsed = AepHttp.parseAuthorization(value, carrier); - if (parsed.scheme() == expected) result.add(new Presentation(header, parsed.credentials())); + if (parsed.scheme() == expected) { + result.add(new Presentation(AUTHORIZATION, parsed.credentials())); + } } catch (IllegalArgumentException ignored) { // Another authentication method owns unrecognized field values. } diff --git a/aep-service/src/main/java/foundation/aep/service/ServiceCredentialMatch.java b/aep-service/src/main/java/foundation/aep/service/ServiceCredentialMatch.java index 6c65876..76be7a6 100644 --- a/aep-service/src/main/java/foundation/aep/service/ServiceCredentialMatch.java +++ b/aep-service/src/main/java/foundation/aep/service/ServiceCredentialMatch.java @@ -6,6 +6,13 @@ public record ServiceCredentialMatch( String agentDid, String credentialId, Instant expiresAt, String grantType, List scopes) { public ServiceCredentialMatch { + // A credential store is a caller-supplied extension point. The handler guards against a + // malformed match, but it reads expiresAt before it can report one, so a null here surfaced + // as a NullPointerException instead of the intended rejection. + java.util.Objects.requireNonNull(agentDid, "agentDid"); + java.util.Objects.requireNonNull(credentialId, "credentialId"); + java.util.Objects.requireNonNull(expiresAt, "expiresAt"); + java.util.Objects.requireNonNull(grantType, "grantType"); scopes = scopes == null ? List.of() : List.copyOf(scopes); } } diff --git a/aep-service/src/main/java/foundation/aep/service/StoredCredentialGrantTypes.java b/aep-service/src/main/java/foundation/aep/service/StoredCredentialGrantTypes.java index 9fcf964..1717bd3 100644 --- a/aep-service/src/main/java/foundation/aep/service/StoredCredentialGrantTypes.java +++ b/aep-service/src/main/java/foundation/aep/service/StoredCredentialGrantTypes.java @@ -7,6 +7,7 @@ import foundation.aep.core.GrantResponses; import foundation.aep.core.InspectDocument; import foundation.aep.core.RevokeRequest; +import java.nio.charset.StandardCharsets; import java.time.Instant; import java.time.format.DateTimeParseException; import java.util.Objects; @@ -80,7 +81,9 @@ public CompletionStage grant(GrantRequest request, GrantContext con @Override public CompletionStage revoke(RevokeRequest request, RevokeContext context) { - if (request.credentialId() != null) { + // A blank identifier names no credential. Treating it as a targeted revoke quietly + // revoked nothing while the Agent was told its request had succeeded. + if (request.credentialId() != null && !request.credentialId().isBlank()) { return store.revoke(context.agentDid(), grantType, request.credentialId(), context.now()); } return store.revokeGrantType(context.agentDid(), grantType, context.now()); @@ -114,9 +117,19 @@ public CompletionStage> authenticate(Credential } private void requireIssuedCredential(T credential, Instant issuedAt) { + // AepValidation dispatches on the credential's runtime type, so it would happily accept + // an OAuth bearer response from a handler registered as api-key. Unchecked generics are + // all it takes to get there, and the mismatch would otherwise surface deep in the store. + if (!grantType.equals(credentialGrantType(credential))) { + throw new IllegalArgumentException("AEP issuer returned a credential for another Grant Type."); + } if (!AepValidation.grantResponse(credential).isEmpty()) { throw new IllegalArgumentException("AEP issuer returned an invalid Grant response."); } + if (credential instanceof GrantResponses.Basic basic && !isEncodableAsBasic(basic)) { + throw new IllegalArgumentException( + "AEP issued Basic credentials must survive RFC 7617 encoding unchanged."); + } Instant expiresAt; try { expiresAt = Instant.parse(credential.expiresAt()); @@ -135,6 +148,18 @@ private void requireIssuedCredential(T credential, Instant issuedAt) { } } + private static String credentialGrantType(GrantResponses.BuiltIn credential) { + if (credential instanceof GrantResponses.OAuthBearer) return Aep.GRANT_TYPE_OAUTH_BEARER; + if (credential instanceof GrantResponses.ApiKey) return Aep.GRANT_TYPE_API_KEY; + if (credential instanceof GrantResponses.Basic) return Aep.GRANT_TYPE_BASIC; + return ""; + } + + private static boolean isEncodableAsBasic(GrantResponses.Basic basic) { + var encoder = StandardCharsets.UTF_8.newEncoder(); + return encoder.canEncode(basic.username()) && encoder.canEncode(basic.password()); + } + private ServiceCredentialRecord credentialRecord(T credential, GrantContext context) { Instant expiresAt = Instant.parse(credential.expiresAt()); return new ServiceCredentialRecord( diff --git a/aep-service/src/test/java/foundation/aep/service/AepServiceHttpHandlerTest.java b/aep-service/src/test/java/foundation/aep/service/AepServiceHttpHandlerTest.java index d5ba7d7..08913a0 100644 --- a/aep-service/src/test/java/foundation/aep/service/AepServiceHttpHandlerTest.java +++ b/aep-service/src/test/java/foundation/aep/service/AepServiceHttpHandlerTest.java @@ -82,7 +82,7 @@ void rejectsWrongMethodsAndContentTypes() { request("POST", "/aep/enroll", Map.of("Content-Type", List.of("application/json")), new byte[0]))); assertEquals(405, method.status()); - assertEquals(List.of("GET"), method.headers().get("Allow")); + assertEquals(List.of("GET, HEAD"), method.headers().get("Allow")); assertEquals(415, contentType.status()); } @@ -158,6 +158,27 @@ void constructsPublicUrlsWithoutAllowingAuthorityReplacement() { () -> AepHttpRequest.publicUrl(URI.create("mailto:service@example.com"), "/orders/1", null)); } + @Test + void preservesRawRequestTargetsWithoutAcceptingNewUriComponents() { + URI origin = URI.create("https://[::1]:8443/"); + for (String path : List.of("/objects/a%2Fb", "/%252e%252e", "/a%3Fb%23c", "/a+b")) { + for (String query : List.of("", "filter=c%2Fd", "next=https://other.example/path?x=1")) { + URI result = AepHttpRequest.publicUrl(origin, path, query); + assertEquals("[::1]:8443", result.getRawAuthority()); + assertEquals(path, result.getRawPath()); + assertEquals(query, result.getRawQuery()); + assertNull(result.getRawFragment()); + } + } + assertNull(AepHttpRequest.publicUrl(origin, "/", null).getRawQuery()); + for (String path : List.of("//other.example/x", "/x?injected", "/x#fragment", "/%ZZ", "/has space")) { + assertThrows(IllegalArgumentException.class, () -> AepHttpRequest.publicUrl(origin, path, null)); + } + for (String query : List.of("x#fragment", "%ZZ", "x\r\nInjected: header")) { + assertThrows(IllegalArgumentException.class, () -> AepHttpRequest.publicUrl(origin, "/x", query)); + } + } + private static AepServiceHttpHandler handler() { return new AepServiceHttpHandler(service()); } diff --git a/aep-service/src/test/java/foundation/aep/service/HostedPlatformClientAssertionVerifierTest.java b/aep-service/src/test/java/foundation/aep/service/HostedPlatformClientAssertionVerifierTest.java new file mode 100644 index 0000000..ff08b89 --- /dev/null +++ b/aep-service/src/test/java/foundation/aep/service/HostedPlatformClientAssertionVerifierTest.java @@ -0,0 +1,210 @@ +package foundation.aep.service; + +import static org.junit.jupiter.api.Assertions.*; + +import foundation.aep.core.Aep; +import foundation.aep.core.AepHttpTransport; +import foundation.aep.core.AepJson; +import foundation.aep.core.AssertionOperation; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.TimeoutException; +import org.junit.jupiter.api.Test; + +final class HostedPlatformClientAssertionVerifierTest { + private static final URI ENDPOINT = URI.create("https://platform.example/verify"); + private static final String AGENT = "did:web:agent.example"; + private static final String SERVICE = "did:web:service.example"; + + @Test + void sendsBoundRequestAndStableAssertionSpecificKey() { + List requests = new ArrayList<>(); + var verifier = HostedPlatformClientAssertionVerifier.builder(ENDPOINT, request -> { + requests.add(request); + return CompletableFuture.completedFuture(response(200, success("authenticate"))); + }) + .authorization("Bearer service-token") + .build(); + String assertion = assertion("one", "authenticate"); + var claims = verifier.verify(assertion, context(AssertionOperation.AUTHENTICATE)) + .toCompletableFuture() + .join(); + verifier.verify(assertion, context(AssertionOperation.AUTHENTICATE)) + .toCompletableFuture() + .join(); + verifier.verify(assertion("two", "authenticate"), context(AssertionOperation.AUTHENTICATE)) + .toCompletableFuture() + .join(); + assertEquals(AGENT, claims.subject()); + var request = requests.get(0); + assertEquals(ENDPOINT, request.uri()); + assertEquals("POST", request.method()); + assertEquals(Duration.ofSeconds(30), request.timeout()); + assertEquals(List.of("Bearer service-token"), request.headers().get("Authorization")); + assertEquals(List.of(Aep.MEDIA_TYPE), request.headers().get("Content-Type")); + assertEquals(List.of(Aep.MEDIA_TYPE), request.headers().get("Accept")); + var body = AepJson.parsePlatformVerificationRequest(new String(request.body(), StandardCharsets.UTF_8)); + assertEquals(assertion, body.clientAssertion()); + assertEquals(SERVICE, body.serviceDid()); + assertEquals(AssertionOperation.AUTHENTICATE, body.operation()); + assertEquals("https://service.example/resource", body.resource()); + assertEquals( + request.headers().get("Idempotency-Key"), + requests.get(1).headers().get("Idempotency-Key")); + assertNotEquals( + request.headers().get("Idempotency-Key"), + requests.get(2).headers().get("Idempotency-Key")); + } + + @Test + void omitsResourceAndAuthorizationForCommand() { + var verifier = HostedPlatformClientAssertionVerifier.builder(ENDPOINT, request -> { + assertFalse(request.headers().containsKey("Authorization")); + var body = AepJson.parseObject(new String(request.body(), StandardCharsets.UTF_8), "verification"); + assertFalse(body.containsKey("resource")); + return CompletableFuture.completedFuture(response(200, success("status"))); + }) + .build(); + assertEquals( + AGENT, + verifier.verify(assertion("one", "status"), context(AssertionOperation.STATUS)) + .toCompletableFuture() + .join() + .subject()); + } + + @Test + void rejectsUnrecognizedMismatchedAndMalformedResponses() { + for (String body : List.of( + "{\"verified\":false,\"reason\":\"not_recognized\",\"service_did\":\"" + SERVICE + "\"}", + success("status").replace(AGENT, "did:web:other.example"), + success("status").replace(SERVICE, "did:web:other.example"), + success("enroll"), + "{}", + "[]", + "not json")) { + var verifier = HostedPlatformClientAssertionVerifier.builder( + ENDPOINT, request -> CompletableFuture.completedFuture(response(200, body))) + .build(); + assertThrows( + CompletionException.class, + () -> verifier.verify(assertion("one", "status"), context(AssertionOperation.STATUS)) + .toCompletableFuture() + .join()); + } + } + + @Test + void rejectsRedirectAndHttpFailureWithoutRetrying() { + for (int status : List.of(199, 302, 401, 500)) { + List requests = new ArrayList<>(); + var verifier = HostedPlatformClientAssertionVerifier.builder(ENDPOINT, request -> { + requests.add(request); + return CompletableFuture.completedFuture(response(status, success("status"))); + }) + .build(); + assertThrows( + CompletionException.class, + () -> verifier.verify(assertion("one", "status"), context(AssertionOperation.STATUS)) + .toCompletableFuture() + .join()); + assertEquals(1, requests.size()); + } + } + + @Test + void boundsWaitingAndPropagatesTransportFailure() { + CompletableFuture pending = new CompletableFuture<>(); + var verifier = HostedPlatformClientAssertionVerifier.builder(ENDPOINT, request -> pending) + .timeout(Duration.ofMillis(10)) + .build(); + var failure = assertThrows( + CompletionException.class, + () -> verifier.verify(assertion("one", "status"), context(AssertionOperation.STATUS)) + .toCompletableFuture() + .join()); + assertInstanceOf(TimeoutException.class, failure.getCause()); + assertFalse(pending.isDone()); + pending.complete(response(200, success("status"))); + var broken = HostedPlatformClientAssertionVerifier.builder( + ENDPOINT, request -> CompletableFuture.failedFuture(new IllegalStateException("offline"))) + .build(); + assertThrows( + CompletionException.class, + () -> broken.verify(assertion("one", "status"), context(AssertionOperation.STATUS)) + .toCompletableFuture() + .join()); + } + + @Test + void rejectsUnsafeConfigurationAndMalformedAssertion() { + AepHttpTransport unused = request -> { + throw new AssertionError("Must not send a request"); + }; + for (String endpoint : List.of( + "http://platform.example/v", + "https:/verify", + "/v", + "https://user:pass@platform.example/v", + "https://platform.example/v#fragment")) { + assertThrows( + IllegalArgumentException.class, + () -> HostedPlatformClientAssertionVerifier.builder(URI.create(endpoint), unused) + .build()); + } + for (Duration timeout : List.of(Duration.ZERO, Duration.ofSeconds(-1), Duration.ofNanos(1))) { + assertThrows( + IllegalArgumentException.class, + () -> HostedPlatformClientAssertionVerifier.builder(ENDPOINT, unused) + .timeout(timeout) + .build()); + } + assertThrows( + IllegalArgumentException.class, + () -> HostedPlatformClientAssertionVerifier.builder(ENDPOINT, unused) + .build() + .verify("bad", context(AssertionOperation.STATUS))); + } + + private static AssertionVerificationContext context(AssertionOperation operation) { + return new AssertionVerificationContext( + SERVICE, + operation, + operation == AssertionOperation.AUTHENTICATE ? "https://service.example/resource" : null, + "command-key", + List.of("ES256"), + Clock.systemUTC(), + Duration.ofSeconds(30), + false); + } + + private static String assertion(String id, String operation) { + long now = java.time.Instant.now().getEpochSecond(); + String payload = "{\"iss\":\"" + AGENT + "\",\"sub\":\"" + AGENT + "\",\"aud\":\"" + SERVICE + + "\",\"op\":\"" + operation + "\",\"iat\":" + now + ",\"exp\":" + (now + 300) + + ",\"jti\":\"" + id + "\"" + + (operation.equals("authenticate") ? ",\"resource\":\"https://service.example/resource\"" : "") + "}"; + return encoded("{\"alg\":\"ES256\"}") + "." + encoded(payload) + ".c2ln"; + } + + private static String encoded(String value) { + return Base64.getUrlEncoder().withoutPadding().encodeToString(value.getBytes(StandardCharsets.UTF_8)); + } + + private static String success(String operation) { + return "{\"verified\":true,\"reason\":\"verified\",\"status\":\"active\",\"agent_identity_id\":\"pai_one\"," + + "\"agent_did\":\"" + AGENT + "\",\"service_did\":\"" + SERVICE + "\",\"op\":\"" + operation + "\"}"; + } + + private static AepHttpTransport.Response response(int status, String body) { + return new AepHttpTransport.Response(status, Map.of(), body.getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/aep-service/src/test/java/foundation/aep/service/ServiceConformanceTest.java b/aep-service/src/test/java/foundation/aep/service/ServiceConformanceTest.java new file mode 100644 index 0000000..23e2742 --- /dev/null +++ b/aep-service/src/test/java/foundation/aep/service/ServiceConformanceTest.java @@ -0,0 +1,498 @@ +package foundation.aep.service; + +import static foundation.aep.service.ServiceFixtures.AGENT_DID; +import static foundation.aep.service.ServiceFixtures.API_KEY_HEADER; +import static foundation.aep.service.ServiceFixtures.AUTHORIZATION; +import static foundation.aep.service.ServiceFixtures.NOW; +import static foundation.aep.service.ServiceFixtures.RESOURCE; +import static foundation.aep.service.ServiceFixtures.authenticate; +import static foundation.aep.service.ServiceFixtures.basicCredentials; +import static foundation.aep.service.ServiceFixtures.completed; +import static foundation.aep.service.ServiceFixtures.config; +import static foundation.aep.service.ServiceFixtures.document; +import static foundation.aep.service.ServiceFixtures.enroll; +import static foundation.aep.service.ServiceFixtures.grant; +import static foundation.aep.service.ServiceFixtures.serviceBuilder; +import static foundation.aep.service.ServiceFixtures.storedCredentialService; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import foundation.aep.core.Aep; +import foundation.aep.core.AepCommand; +import foundation.aep.core.GrantResponses; +import foundation.aep.core.InspectDocument; +import foundation.aep.core.RevokeRequest; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +/** Requirement-level checks for the Service role across the AEP drafts. */ +final class ServiceConformanceTest { + private static final String AEP_AUTHORIZATION = Aep.AUTHORIZATION_HEADER; + private static final String NOT_RECOGNIZED = "not_recognized"; + private static final String CACHE_CONTROL = "Cache-Control"; + private static final String IF_NONE_MATCH = "If-None-Match"; + private static final String ALLOW = "Allow"; + private static final String SAFE_METHODS = "GET, HEAD"; + + /** OAB-PRS-003, BSC-PRS-003: both carriers authenticate on a protected resource. */ + @Test + void acceptsEitherAuthorizationCarrierForBearerAndBasicCredentials() { + AepService service = storedCredentialService(ServiceCredentialStore.inMemory()); + enroll(service); + grant(service, Aep.GRANT_TYPE_OAUTH_BEARER); + grant(service, Aep.GRANT_TYPE_BASIC); + + assertEquals("oauth-1", authenticated(service, Map.of(AUTHORIZATION, List.of("Bearer oauth-secret")))); + assertEquals( + "oauth-1", + authenticated(service, Map.of(AEP_AUTHORIZATION, List.of("Bearer oauth-secret"))), + "the dedicated carrier presents the same Bearer field value"); + assertEquals("basic-1", authenticated(service, Map.of(AUTHORIZATION, List.of("Basic " + basicCredentials())))); + assertEquals( + "basic-1", + authenticated(service, Map.of(AEP_AUTHORIZATION, List.of("Basic " + basicCredentials()))), + "the dedicated carrier presents the same Basic field value"); + } + + /** OAB-PRS-004, BSC-PRS-004: one carrier per request; two is ambiguous, not a choice. */ + @Test + void refusesAmbiguousCarrierPresentations() { + AepService service = storedCredentialService(ServiceCredentialStore.inMemory()); + enroll(service); + grant(service, Aep.GRANT_TYPE_OAUTH_BEARER); + Map> both = Map.of( + AUTHORIZATION, List.of("Bearer oauth-secret"), AEP_AUTHORIZATION, List.of("Bearer oauth-secret")); + + assertEquals(NOT_RECOGNIZED, rejected(service, both)); + assertEquals( + NOT_RECOGNIZED, + rejected(service, Map.of(AUTHORIZATION, List.of("Bearer oauth-secret", "Bearer other")))); + } + + /** APK-PRS-004: the dedicated carrier does not carry an API key. */ + @Test + void doesNotAcceptApiKeysThroughTheDedicatedCarrier() { + AepService service = storedCredentialService(ServiceCredentialStore.inMemory()); + enroll(service); + grant(service, Aep.GRANT_TYPE_API_KEY); + + assertEquals("api-1", authenticated(service, Map.of(API_KEY_HEADER, List.of("api-secret")))); + assertEquals( + NOT_RECOGNIZED, + rejected(service, Map.of(AEP_AUTHORIZATION, List.of("ApiKey api-secret"))), + "the dedicated carrier is reserved for AEP schemes, so this is a malformed presentation"); + assertEquals( + NOT_RECOGNIZED, + rejected(service, Map.of(AEP_AUTHORIZATION, List.of("Bearer api-secret"))), + "an API key is not a bearer token, whichever field carries it"); + } + + /** APK-CFG-003: header names are case-insensitive on the wire. */ + @Test + void matchesApiKeyHeadersRegardlessOfCase() { + AepService service = storedCredentialService(ServiceCredentialStore.inMemory()); + enroll(service); + grant(service, Aep.GRANT_TYPE_API_KEY); + + assertEquals("api-1", authenticated(service, Map.of("x-service-key", List.of("api-secret")))); + assertEquals("api-1", authenticated(service, Map.of("X-SERVICE-KEY", List.of("api-secret")))); + } + + /** APK-PRS-002 vs APK-PRS-003: a wrong header is no presentation; a wrong value is a bad one. */ + @Test + void separatesMissingCredentialsFromUnusableOnes() { + AepService service = storedCredentialService(ServiceCredentialStore.inMemory()); + enroll(service); + grant(service, Aep.GRANT_TYPE_API_KEY); + + assertEquals("authentication_required", rejected(service, Map.of())); + assertEquals("authentication_required", rejected(service, Map.of("X-Other", List.of("api-secret")))); + assertEquals(NOT_RECOGNIZED, rejected(service, Map.of(API_KEY_HEADER, List.of("wrong")))); + } + + /** RFC 9110 section 9.3.2: a resource that answers GET answers HEAD. */ + @Test + void answersHeadWhereverItAnswersGet() { + AepServiceHttpHandler handler = handler(); + + assertEquals(200, handle(handler, AepCommand.INSPECT, "HEAD").status()); + assertEquals(401, handle(handler, AepCommand.STATUS, "HEAD").status()); + assertEquals(200, handle(handler, AepCommand.INSPECT, "GET").status()); + var refused = handle(handler, AepCommand.INSPECT, "POST"); + assertEquals(405, refused.status()); + assertEquals(List.of(SAFE_METHODS), refused.headers().get(ALLOW)); + var unsafe = handle(handler, AepCommand.ENROLL, "HEAD"); + assertEquals(405, unsafe.status()); + assertEquals(List.of("POST"), unsafe.headers().get(ALLOW)); + } + + /** CORE-CACHE-001, CORE-CACHE-002, CORE-CACHE-004: freshness, a validator, and revalidation. */ + @Test + void sendsInspectCacheMetadataAndRevalidatesThroughNotModified() { + AepServiceHttpHandler handler = handler(); + var inspect = handle(handler, AepCommand.INSPECT, "GET"); + String entityTag = inspect.headers().get("ETag").get(0); + + assertEquals(List.of("public, max-age=300"), inspect.headers().get(CACHE_CONTROL)); + assertTrue(entityTag.startsWith("\"sha256:")); + assertEquals(304, conditional(handler, entityTag).status()); + assertEquals(304, conditional(handler, "W/" + entityTag).status(), "the comparison is weak"); + assertEquals(304, conditional(handler, "\"other\", " + entityTag).status(), "If-None-Match is a list"); + assertEquals(304, conditional(handler, "*").status()); + assertEquals(200, conditional(handler, "\"other\"").status()); + assertEquals(0, conditional(handler, entityTag).body().length); + assertEquals( + entityTag, + conditional(handler, "\"other\"").headers().get("ETag").get(0)); + } + + /** CORE-CACHE-001: the validator is derived from the document, so unlike documents differ. */ + @Test + void inspectEntityTagTracksTheAdvertisedDocument() { + AepServiceHttpHandler first = handler(); + AepServiceHttpHandler same = handler(); + AepServiceHttpHandler other = new AepServiceHttpHandler( + serviceBuilder(document(List.of(), Map.of(), List.of(Aep.AUTHENTICATION_METHOD_JWT))) + .build()); + + assertEquals(first.inspectEntityTag(), same.inspectEntityTag()); + assertNotEquals(first.inspectEntityTag(), other.inspectEntityTag()); + } + + /** Command responses carry enrollment state and credential secrets; caches must not keep them. */ + @Test + void keepsCommandResponsesOutOfCaches() { + AepServiceHttpHandler handler = handler(); + + assertEquals( + List.of("no-store"), + handle(handler, AepCommand.STATUS, "GET").headers().get(CACHE_CONTROL)); + assertEquals( + List.of("no-store"), + handle(handler, AepCommand.INSPECT, "POST").headers().get(CACHE_CONTROL)); + assertEquals( + List.of("no-store"), + handle(handler, AepCommand.ENROLL, "POST").headers().get(CACHE_CONTROL)); + } + + @Test + void preservesOptionalTargetedRevocationAdvertisement() { + for (String advertised : java.util.Arrays.asList("false", null)) { + var configuration = new InspectDocument.GrantTypeConfig(advertised); + var service = serviceBuilder(document( + List.of(Aep.GRANT_TYPE_OAUTH_BEARER), Map.of(Aep.GRANT_TYPE_OAUTH_BEARER, configuration))) + .storedCredentialGrantType(StoredCredentialGrantTypes.oauthBearer( + configuration, + (request, context) -> completed(bearer()), + ServiceCredentialStore.inMemory())) + .build(); + enroll(service); + grant(service, Aep.GRANT_TYPE_OAUTH_BEARER); + assertEquals( + 400, + service.revoke( + new RevokeRequest(Aep.GRANT_TYPE_OAUTH_BEARER, "oauth-1", null), + CommandOptions.idempotent("targeted", "targeted-key")) + .toCompletableFuture() + .join() + .status()); + assertEquals("oauth-1", authenticated(service, Map.of(AUTHORIZATION, List.of("Bearer oauth-secret")))); + assertEquals( + 200, + service.revoke( + new RevokeRequest(Aep.GRANT_TYPE_OAUTH_BEARER, null, null), + CommandOptions.idempotent("all", "all-key")) + .toCompletableFuture() + .join() + .status()); + assertEquals(NOT_RECOGNIZED, rejected(service, Map.of(AUTHORIZATION, List.of("Bearer oauth-secret")))); + } + } + + /** OAB-RVK-002: a targeted revoke leaves the Agent's other credentials of that type alone. */ + @Test + void revokesOnlyTheNamedCredential() { + ServiceCredentialStore store = ServiceCredentialStore.inMemory(); + AepService service = storedCredentialService(store); + enroll(service); + grant(service, Aep.GRANT_TYPE_OAUTH_BEARER); + grant(service, Aep.GRANT_TYPE_API_KEY); + + var response = service.revoke( + new RevokeRequest(Aep.GRANT_TYPE_OAUTH_BEARER, "oauth-1", null), + CommandOptions.idempotent("revoke", "revoke-key")) + .toCompletableFuture() + .join(); + + assertEquals(200, response.status()); + assertEquals(NOT_RECOGNIZED, rejected(service, Map.of(AUTHORIZATION, List.of("Bearer oauth-secret")))); + assertEquals("api-1", authenticated(service, Map.of(API_KEY_HEADER, List.of("api-secret")))); + } + + /** BSC-RSP-003: an issued Basic credential must survive RFC 7617 encoding unchanged. */ + @Test + void issuesAndAuthenticatesUtf8BasicCredentials() { + ServiceCredentialStore store = ServiceCredentialStore.inMemory(); + AepService service = serviceBuilder( + document(List.of(Aep.GRANT_TYPE_BASIC), Map.of(Aep.GRANT_TYPE_BASIC, config(List.of())))) + .storedCredentialGrantType(StoredCredentialGrantTypes.basic( + config(List.of()), + (request, context) -> completed(new GrantResponses.Basic( + "basic-1", + NOW.plusSeconds(600).toString(), + "paßwort-12345678901234567890", + "service", + List.of("read"), + "agént")), + store)) + .build(); + enroll(service); + + assertEquals( + 200, + service.grant( + new foundation.aep.core.GrantRequest(Aep.GRANT_TYPE_BASIC, List.of("read")), + CommandOptions.idempotent("grant", "grant-key")) + .toCompletableFuture() + .join() + .status()); + String encoded = java.util.Base64.getEncoder() + .encodeToString("agént:paßwort-12345678901234567890".getBytes(java.nio.charset.StandardCharsets.UTF_8)); + assertEquals("basic-1", authenticated(service, Map.of(AUTHORIZATION, List.of("Basic " + encoded)))); + assertEquals("basic-1", authenticated(service, Map.of(AEP_AUTHORIZATION, List.of("Basic " + encoded)))); + } + + @Test + void refusesBasicCredentialsWithMalformedUtf8OrProhibitedCharacters() { + for (String[] values : List.of( + new String[] {"\uD800", "password"}, new String[] {"agent", "\uDC00"}, + new String[] {"agent:name", "password"}, new String[] {"agent", "pass\nword"})) { + var stored = StoredCredentialGrantTypes.basic( + config(List.of()), + (request, context) -> completed(new GrantResponses.Basic( + "invalid", NOW.plusSeconds(600).toString(), values[1], null, List.of(), values[0])), + ServiceCredentialStore.inMemory()); + assertThrows( + java.util.concurrent.CompletionException.class, + () -> stored.definition() + .handler() + .grant( + new foundation.aep.core.GrantRequest(Aep.GRANT_TYPE_BASIC, List.of()), + new GrantContext(AGENT_DID, null, Aep.GRANT_TYPE_BASIC, NOW)) + .toCompletableFuture() + .join()); + } + } + + @Test + void reservesCredentialIdentifiersAfterExpiryAndRevocation() { + for (boolean revoked : List.of(false, true)) { + var store = ServiceCredentialStore.inMemory(); + var expiry = NOW.plusSeconds(600); + store.save(new ServiceCredentialRecord( + AGENT_DID, NOW, bearer(), "oauth-1", expiry, Aep.GRANT_TYPE_OAUTH_BEARER)) + .toCompletableFuture() + .join(); + if (revoked) + store.revoke(AGENT_DID, Aep.GRANT_TYPE_OAUTH_BEARER, "oauth-1", NOW) + .toCompletableFuture() + .join(); + var later = NOW.plusSeconds(7200); + var replacement = new GrantResponses.ApiKey( + "different-secret", "oauth-1", later.plusSeconds(600).toString(), API_KEY_HEADER, List.of()); + assertThrows( + java.util.concurrent.CompletionException.class, + () -> store.save(new ServiceCredentialRecord( + "did:web:other.example", + later, + replacement, + "oauth-1", + later.plusSeconds(600), + Aep.GRANT_TYPE_API_KEY)) + .toCompletableFuture() + .join()); + assertTrue(store.authenticate( + Aep.GRANT_TYPE_OAUTH_BEARER, + new CredentialAuthenticationInput( + Map.of(AUTHORIZATION, List.of("Bearer oauth-secret")), "GET", RESOURCE, later)) + .toCompletableFuture() + .join() + .isEmpty()); + } + } + + /** CORE-ERR-009: every lifecycle state maps to its fixed code and status. */ + @Test + void mapsLifecycleStatesToTheirDefinedErrorCodes() { + assertEquals("verification_pending", blockedGrant(foundation.aep.core.AgentStatus.PENDING, 403)); + assertEquals("identity_suspended", blockedGrant(foundation.aep.core.AgentStatus.SUSPENDED, 403)); + assertEquals("identity_terminated", blockedGrant(foundation.aep.core.AgentStatus.TERMINATED, 403)); + assertEquals("identity_unavailable", blockedGrant(foundation.aep.core.AgentStatus.UNAVAILABLE, 403)); + assertEquals("enrollment_failed", blockedGrant(foundation.aep.core.AgentStatus.REJECTED, 400)); + } + + /** CORE-PR-*: a rejected protected-resource request carries an AEP challenge, not a bare 401. */ + @Test + void challengesRejectedProtectedResourceRequests() { + AepService service = storedCredentialService(ServiceCredentialStore.inMemory()); + enroll(service); + grant(service, Aep.GRANT_TYPE_API_KEY); + + var response = + authenticate(service, Map.of(API_KEY_HEADER, List.of("wrong"))).response(); + String challenge = response.headers().get("WWW-Authenticate").get(0); + + assertEquals(401, response.status()); + assertTrue(challenge.startsWith("AEP ")); + assertTrue(challenge.contains("service_did=\"" + ServiceFixtures.SERVICE_DID + "\"")); + assertTrue(challenge.contains("reason=\"" + NOT_RECOGNIZED + "\"")); + assertTrue(challenge.contains("inspect=\"https://service.example" + Aep.WELL_KNOWN_PATH + "\"")); + assertFalse(response.headers().containsKey("Content-Length")); + } + + /** PLT-IDP-004 shape, CORE-IDEM: an exact retry at the retention boundary still replays. */ + @Test + void replaysIdempotentResultsForTheFullRetentionWindow() { + java.time.Instant[] instant = {NOW}; + java.time.Clock moving = new java.time.Clock() { + @Override + public java.time.ZoneId getZone() { + return java.time.ZoneOffset.UTC; + } + + @Override + public java.time.Clock withZone(java.time.ZoneId zone) { + return this; + } + + @Override + public java.time.Instant instant() { + return instant[0]; + } + }; + IdempotencyStore store = IdempotencyStore.inMemory(moving); + IdempotencyInput input = new IdempotencyInput(AGENT_DID, "key", "enroll", "hash"); + java.util.concurrent.atomic.AtomicInteger runs = new java.util.concurrent.atomic.AtomicInteger(); + java.util.function.Supplier>> operation = + () -> completed(ServiceResponse.success("run-" + runs.incrementAndGet())); + + store.execute(input, operation).toCompletableFuture().join(); + instant[0] = NOW.plus(java.time.Duration.ofHours(1)); + var boundary = store.execute(input, operation).toCompletableFuture().join(); + instant[0] = NOW.plus(java.time.Duration.ofHours(1)).plusNanos(1); + var past = store.execute(input, operation).toCompletableFuture().join(); + + assertEquals(IdempotencyResult.State.REPLAYED, boundary.state()); + assertEquals("run-1", boundary.response().body()); + assertEquals(IdempotencyResult.State.CREATED, past.state()); + assertEquals(2, runs.get()); + } + + /** Credential material reaches every authenticator, so it must not reach their logs. */ + @Test + void redactsCredentialMaterialFromTheAuthenticationInput() { + CredentialAuthenticationInput input = new CredentialAuthenticationInput( + Map.of(AUTHORIZATION, List.of("Basic " + basicCredentials())), "GET", RESOURCE, NOW); + + assertFalse(input.toString().contains(basicCredentials())); + assertTrue(input.toString().contains(AUTHORIZATION), "the field names alone carry no secret"); + } + + private static GrantResponses.OAuthBearer bearer() { + return new GrantResponses.OAuthBearer( + "oauth-secret", "oauth-1", NOW.plusSeconds(600).toString(), List.of("read"), "opaque", "Bearer"); + } + + private static String blockedGrant(foundation.aep.core.AgentStatus status, int expectedStatus) { + EnrollmentStore store = EnrollmentStore.inMemory(); + UnusedHandler handler = new UnusedHandler(); + AepService service = serviceBuilder( + document(List.of(Aep.GRANT_TYPE_API_KEY), Map.of(Aep.GRANT_TYPE_API_KEY, config(List.of())))) + .enrollmentStore(store) + .grantType(new GrantTypeDefinition(Aep.GRANT_TYPE_API_KEY, handler)) + .credentialAuthenticator(Aep.GRANT_TYPE_API_KEY, handler) + .build(); + enroll(service); + // The lifecycle states beyond the initial ones are reached by a later Service decision, not + // by an enrollment policy, so the record is moved through the store the same way. + store.save(EnrollmentRecord.builder(AGENT_DID, "enrollment-1", status, NOW) + .since(NOW) + .updatedAt(NOW) + .build()) + .toCompletableFuture() + .join(); + + var response = service.grant( + new foundation.aep.core.GrantRequest(Aep.GRANT_TYPE_API_KEY, List.of()), + CommandOptions.idempotent("grant", "grant-key")) + .toCompletableFuture() + .join(); + assertEquals(expectedStatus, response.status()); + return response.problem().code(); + } + + private static AepServiceHttpHandler handler() { + return new AepServiceHttpHandler( + serviceBuilder(document(List.of(), Map.of(), List.of())).build()); + } + + private static AepHttpResponse handle(AepServiceHttpHandler handler, AepCommand command, String method) { + Map> headers = command == AepCommand.ENROLL || command == AepCommand.GRANT + ? Map.of("Content-Type", List.of(Aep.MEDIA_TYPE)) + : Map.of(); + byte[] body = command == AepCommand.ENROLL && "POST".equals(method) + ? "{\"agent_did\":\"did:web:agent.example\"}".getBytes(java.nio.charset.StandardCharsets.UTF_8) + : new byte[0]; + return handler.handle(command, new AepHttpRequest(method, RESOURCE, headers, body)) + .toCompletableFuture() + .join(); + } + + private static AepHttpResponse conditional(AepServiceHttpHandler handler, String entityTag) { + return handler.handle( + AepCommand.INSPECT, + new AepHttpRequest("GET", RESOURCE, Map.of(IF_NONE_MATCH, List.of(entityTag)), new byte[0])) + .toCompletableFuture() + .join(); + } + + private static String authenticated(AepService service, Map> headers) { + ProtectedResourceResult result = authenticate(service, headers); + assertTrue(result.authenticated(), "expected an authenticated principal"); + return result.principal().credentialId(); + } + + private static String rejected(AepService service, Map> headers) { + ProtectedResourceResult result = authenticate(service, headers); + assertFalse(result.authenticated(), "expected a rejection"); + return result.response().problem().code(); + } + + private static final class UnusedHandler implements GrantTypeHandler, CredentialAuthenticator { + @Override + public java.util.concurrent.CompletionStage grant( + foundation.aep.core.GrantRequest request, GrantContext context) { + return completed(new GrantResult("credential-1", Map.of("credential_id", "credential-1"))); + } + + @Override + public java.util.concurrent.CompletionStage revoke(RevokeRequest request, RevokeContext context) { + return completed(null); + } + + @Override + public java.util.concurrent.CompletionStage hasPresentation(CredentialAuthenticationInput input) { + return completed(false); + } + + @Override + public java.util.concurrent.CompletionStage> authenticate( + CredentialAuthenticationInput input) { + return completed(java.util.Optional.empty()); + } + } +} diff --git a/aep-service/src/test/java/foundation/aep/service/ServiceCoverageTest.java b/aep-service/src/test/java/foundation/aep/service/ServiceCoverageTest.java new file mode 100644 index 0000000..44b56a9 --- /dev/null +++ b/aep-service/src/test/java/foundation/aep/service/ServiceCoverageTest.java @@ -0,0 +1,715 @@ +package foundation.aep.service; + +import static foundation.aep.service.ServiceFixtures.AGENT_DID; +import static foundation.aep.service.ServiceFixtures.API_KEY_HEADER; +import static foundation.aep.service.ServiceFixtures.AUTHORIZATION; +import static foundation.aep.service.ServiceFixtures.CLOCK; +import static foundation.aep.service.ServiceFixtures.NOW; +import static foundation.aep.service.ServiceFixtures.SERVICE_DID; +import static foundation.aep.service.ServiceFixtures.authenticate; +import static foundation.aep.service.ServiceFixtures.completed; +import static foundation.aep.service.ServiceFixtures.config; +import static foundation.aep.service.ServiceFixtures.document; +import static foundation.aep.service.ServiceFixtures.enroll; +import static foundation.aep.service.ServiceFixtures.grant; +import static foundation.aep.service.ServiceFixtures.serviceBuilder; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import foundation.aep.core.Aep; +import foundation.aep.core.AgentStatus; +import foundation.aep.core.AssertionOperation; +import foundation.aep.core.ClaimValues; +import foundation.aep.core.ClientAssertionClaims; +import foundation.aep.core.EnrollRequest; +import foundation.aep.core.GrantRequest; +import foundation.aep.core.GrantResponses; +import foundation.aep.core.InspectDocument; +import foundation.aep.core.RevokeRequest; +import java.net.URI; +import java.time.Duration; +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CompletionException; +import java.util.concurrent.CompletionStage; +import org.junit.jupiter.api.Test; + +/** Exercises the refusal, accessor, and store paths of the AEP Service module. */ +final class ServiceCoverageTest { + private static final String INVALID_REQUEST = "invalid_request"; + private static final String NOT_RECOGNIZED = "not_recognized"; + private static final String ENROLL_KEY = "enroll-key"; + private static final String GRANT_KEY = "grant-key"; + private static final String API_KEY = Aep.GRANT_TYPE_API_KEY; + private static final String EXPIRY = "2026-09-01T12:10:00Z"; + + @Test + void refusesCommandsWithoutAKeyOrWithAMismatchedBody() { + AepService service = apiKeyService(); + + // Each command needs its own assertion: a client assertion is single-use, so reusing one + // would be refused as a replay before the request shape is ever examined. + assertEquals(400, code(service.enroll(enrollRequest(), CommandOptions.authenticated("a1")))); + assertEquals(400, code(service.enroll(null, CommandOptions.idempotent("a2", "k1")))); + assertEquals( + 400, + code(service.enroll( + new EnrollRequest("did:web:other.example", null, null), + CommandOptions.idempotent("a3", "k2")))); + assertEquals( + 400, + code(service.enroll( + new EnrollRequest(AGENT_DID, null, "other"), CommandOptions.idempotent("a4", "k3")))); + assertEquals( + 400, code(service.enroll(new EnrollRequest(" ", null, null), CommandOptions.idempotent("a5", "k4")))); + assertEquals(400, code(service.grant(null, CommandOptions.idempotent("a6", "k5")))); + assertEquals(400, code(service.grant(grantRequest(), CommandOptions.authenticated("a7")))); + assertEquals(400, code(service.revoke(null, CommandOptions.idempotent("a8", "k6")))); + assertEquals( + 400, code(service.revoke(new RevokeRequest(API_KEY, null, null), CommandOptions.authenticated("a9")))); + assertEquals(401, code(service.enroll(enrollRequest(), CommandOptions.idempotent(" ", "k7")))); + assertEquals(401, code(service.status(null))); + assertEquals(401, code(service.grant(grantRequest(), null))); + } + + @Test + void reportsStatusForAnEnrolledAgent() { + AepService service = apiKeyService(); + enroll(service); + + var response = service.status(CommandOptions.authenticated("status")) + .toCompletableFuture() + .join(); + var body = (foundation.aep.core.StatusResponse) response.body(); + + assertEquals(200, response.status()); + assertEquals(AgentStatus.ACTIVE, body.status()); + assertEquals("2026-09-01T12:00:00Z", body.since()); + assertEquals(null, body.ownerActionRequired()); + assertEquals(401, code(freshService().status(CommandOptions.authenticated("status")))); + } + + @Test + void reportsPendingLifecycleDetailOnStatus() { + AepService service = serviceBuilder(document(List.of(), Map.of(), List.of())) + .enrollmentPolicy((request, now) -> completed(new EnrollmentDecision( + AgentStatus.PENDING, true, List.of("contact.email"), List.of("person.username")))) + .build(); + enroll(service); + + var body = (foundation.aep.core.StatusResponse) service.status(CommandOptions.authenticated("status")) + .toCompletableFuture() + .join() + .body(); + + assertEquals("true", body.ownerActionRequired()); + assertEquals(List.of("contact.email"), body.verificationPending()); + assertEquals(List.of("person.username"), body.requirementsPending()); + } + + @Test + void refusesUnsupportedGrantTypesOnBothCommands() { + AepService service = apiKeyService(); + enroll(service); + + assertEquals( + 400, + code(service.grant(new GrantRequest("other", List.of()), CommandOptions.idempotent("g1", GRANT_KEY)))); + assertEquals( + 400, + code(service.revoke(new RevokeRequest("other", null, null), CommandOptions.idempotent("r1", "r-key")))); + assertEquals( + 200, + code(service.revoke( + new RevokeRequest(API_KEY, "credential-1", null), CommandOptions.idempotent("r2", "r2-key"))), + "this Service advertises per-credential revoke"); + + AepService withoutTargetedRevoke = serviceBuilder( + document(List.of(API_KEY), Map.of(API_KEY, new InspectDocument.GrantTypeConfig("false")))) + .grantType(new GrantTypeDefinition(API_KEY, handler())) + .credentialAuthenticator(API_KEY, handler()) + .build(); + enroll(withoutTargetedRevoke); + assertEquals( + 400, + code(withoutTargetedRevoke.revoke( + new RevokeRequest(API_KEY, "credential-1", null), CommandOptions.idempotent("r3", "r3-key"))), + "per-credential revoke is refused when the Service does not advertise it"); + } + + @Test + void refusesGrantAndRevokeForAnAgentThatNeverEnrolled() { + AepService service = apiKeyService(); + + assertEquals(401, code(service.grant(grantRequest(), CommandOptions.idempotent("g1", GRANT_KEY)))); + assertEquals( + 401, + code(service.revoke(new RevokeRequest(API_KEY, null, null), CommandOptions.idempotent("r1", "r-key")))); + } + + @Test + void reportsUnmetClaimRequirementsAndOversizedClaimValues() { + InspectDocument document = InspectDocument.builder() + .version(Aep.VERSION) + .bindings(new InspectDocument.Bindings(List.of("http"))) + .claims(new InspectDocument.Claims(List.of("contact.email"), List.of(), List.of())) + .commands(new InspectDocument.Commands(List.of("enroll", "inspect", "status"), List.of(), Map.of())) + .core(new InspectDocument.Core(Aep.REQUIRED_SIGNING_ALGORITHMS)) + .http(new InspectDocument.Http("/aep", null)) + .identity(new InspectDocument.Identity(List.of("did:web"))) + .service(new InspectDocument.Service(SERVICE_DID)) + .build(); + AepService service = serviceBuilder(document).build(); + AepService limited = serviceBuilder(document) + .claimValueLimits(new ClaimValueLimits(64, 1, 1, 4)) + .build(); + + var missing = service.enroll(new EnrollRequest(AGENT_DID, null, null), CommandOptions.idempotent("e", "k")) + .toCompletableFuture() + .join(); + + assertEquals(422, missing.status()); + assertEquals(List.of("contact.email"), missing.problem().requirementsPending()); + assertEquals( + 422, + code(limited.enroll( + new EnrollRequest( + AGENT_DID, + ClaimValues.builder() + .contactEmail("a-very-long-address@example.com") + .build(), + null), + CommandOptions.idempotent("e", "k2")))); + } + + @Test + void foldsEveryCanonicalClaimIntoTheIdempotencyFingerprint() { + AepService service = + serviceBuilder(document(List.of(), Map.of(), List.of())).build(); + Map nested = new LinkedHashMap<>(); + nested.put("list", List.of(1, "two", true)); + ClaimValues first = ClaimValues.builder() + .contactAddressPrimary(new foundation.aep.core.ContactAddressPrimary( + "Springfield", "US", "Ada", "Lovelace", "1 Main St", null, null, "12345", "IL")) + .contactEmail("agent@example.com") + .contactMobile("+15550100") + .personBirthdate("1990-01-01") + .personFirstName("Ada") + .personLastName("Lovelace") + .personUsername("ada") + .additional("nested", nested) + .build(); + + assertEquals(200, code(service.enroll(new EnrollRequest(AGENT_DID, first, null), key("k1", "a1")))); + assertEquals( + 200, + code(service.enroll(new EnrollRequest(AGENT_DID, first, null), key("k1", "a2"))), + "an exact retry replays the stored result"); + assertEquals( + 409, + code(service.enroll( + new EnrollRequest( + AGENT_DID, + ClaimValues.builder().personUsername("bo").build(), + null), + key("k1", "a3")))); + } + + @Test + void refusesProtectedResourceUrlsItCannotBind() { + AepService service = apiKeyService(); + + assertThrows(CompletionException.class, () -> resource(service, URI.create("/relative"))); + assertThrows(CompletionException.class, () -> resource(service, URI.create("https://user@svc.example/x"))); + assertThrows(CompletionException.class, () -> resource(service, URI.create("https://svc.example/x#f"))); + assertThrows(CompletionException.class, () -> resource(service, URI.create("http://svc.example/x"))); + assertThrows(CompletionException.class, () -> resource(service, null)); + } + + @Test + void allowsLoopbackProtectedResourcesOnlyWhenConfigured() { + AepService loopback = serviceBuilder(document(List.of(), Map.of(), List.of())) + .allowInsecureLoopback(true) + .inspectUri(URI.create("http://127.0.0.1:8080/.well-known/aep")) + .build(); + + var rejected = loopback.authenticate( + new ProtectedResourceRequest(Map.of(), "GET", URI.create("http://localhost:8080/private"))) + .toCompletableFuture() + .join(); + + assertFalse(rejected.authenticated()); + var ipv6 = loopback.authenticate( + new ProtectedResourceRequest(Map.of(), "GET", URI.create("http://[::1]:8080/private"))) + .toCompletableFuture() + .join(); + assertFalse(ipv6.authenticated()); + assertEquals(401, ipv6.response().status()); + for (String host : List.of("192.0.2.1", "999.999.999.999")) { + assertThrows( + CompletionException.class, + () -> loopback.authenticate(new ProtectedResourceRequest( + Map.of(), "GET", URI.create("http://" + host + "/private"))) + .toCompletableFuture() + .join()); + } + assertTrue(rejected.response() + .headers() + .get("WWW-Authenticate") + .get(0) + .contains("inspect=\"http://127.0.0.1:8080/.well-known/aep\"")); + assertThrows( + CompletionException.class, + () -> loopback.authenticate(new ProtectedResourceRequest( + Map.of(), "GET", URI.create("http://example.invalid/private"))) + .toCompletableFuture() + .join()); + } + + @Test + void refusesAepAssertionsOnResourcesThatDoNotAdvertiseThem() { + AepService service = apiKeyService(); + enroll(service); + + assertEquals( + "unsupported_authentication_method", + rejected(service, Map.of(AUTHORIZATION, List.of("AEP assertion")))); + assertEquals( + "unsupported_authentication_method", + rejected(service, Map.of(AUTHORIZATION, List.of("Bearer token"))), + "the Service advertises only api-key"); + } + + @Test + void refusesAnAssertionPresentedAlongsideASessionCredential() { + AepService service = jwtAndApiKeyService(); + enroll(service); + grant(service, API_KEY); + + assertEquals( + NOT_RECOGNIZED, + rejected( + service, + Map.of(AUTHORIZATION, List.of("AEP assertion"), API_KEY_HEADER, List.of("api-secret")))); + } + + @Test + void authenticatesAepAssertionsAgainstActiveEnrollmentsOnly() { + EnrollmentStore store = EnrollmentStore.inMemory(); + AepService service = serviceBuilder(document(List.of(), Map.of(), List.of(Aep.AUTHENTICATION_METHOD_JWT))) + .enrollmentStore(store) + .build(); + + assertEquals(NOT_RECOGNIZED, rejected(service, Map.of(AUTHORIZATION, List.of("AEP assertion-1")))); + enroll(service); + var authenticated = authenticate(service, Map.of(AUTHORIZATION, List.of("AEP assertion-2"))); + assertTrue(authenticated.authenticated()); + assertEquals( + AuthenticatedPrincipal.Kind.AEP_JWT, authenticated.principal().kind()); + assertEquals(Aep.AUTHENTICATION_METHOD_JWT, authenticated.principal().authenticationMethod()); + + store.save(EnrollmentRecord.builder(AGENT_DID, "enrollment-1", AgentStatus.SUSPENDED, NOW) + .since(NOW) + .updatedAt(NOW) + .build()) + .toCompletableFuture() + .join(); + assertEquals(NOT_RECOGNIZED, rejected(service, Map.of(AUTHORIZATION, List.of("AEP assertion-3")))); + } + + @Test + void rejectsAssertionsTheVerifierCannotProduce() { + AepService throwing = AepService.builder(document(List.of(), Map.of(), List.of()), (assertion, context) -> { + throw new IllegalStateException("boom"); + }) + .clock(CLOCK) + .build(); + AepService failing = AepService.builder( + document(List.of(), Map.of(), List.of()), + (assertion, context) -> + java.util.concurrent.CompletableFuture.failedFuture(new IllegalStateException("no"))) + .clock(CLOCK) + .build(); + AepService overflowing = AepService.builder( + document(List.of(), Map.of(), List.of()), + (assertion, context) -> completed(new ClientAssertionClaims( + AGENT_DID, + AGENT_DID, + context.serviceDid(), + context.operation(), + NOW.getEpochSecond(), + Long.MAX_VALUE, + assertion, + null))) + .clock(CLOCK) + .build(); + + assertEquals(401, code(throwing.status(CommandOptions.authenticated("a")))); + assertEquals(401, code(failing.status(CommandOptions.authenticated("a")))); + assertEquals(401, code(overflowing.status(CommandOptions.authenticated("a")))); + } + + @Test + void rejectsClaimsThatDoNotBindTheRequestTheyAuthorize() { + assertEquals(401, code(claiming(AGENT_DID, "did:web:other.example", SERVICE_DID, AssertionOperation.STATUS))); + assertEquals(401, code(claiming(AGENT_DID, AGENT_DID, "did:web:elsewhere", AssertionOperation.STATUS))); + assertEquals(401, code(claiming(AGENT_DID, AGENT_DID, SERVICE_DID, AssertionOperation.ENROLL))); + assertEquals(401, code(claiming("did:key:z6Mk", "did:key:z6Mk", SERVICE_DID, AssertionOperation.STATUS))); + assertEquals(401, code(claiming(null, null, SERVICE_DID, AssertionOperation.STATUS))); + } + + @Test + void replaysAreConsumedOnce() { + AepService service = + serviceBuilder(document(List.of(), Map.of(), List.of())).build(); + enroll(service); + + assertEquals(200, code(service.status(CommandOptions.authenticated("assertion-1")))); + assertEquals(401, code(service.status(CommandOptions.authenticated("assertion-1")))); + assertEquals(200, code(service.status(CommandOptions.authenticated("assertion-2")))); + } + + @Test + void refusesUnusableServiceConfiguration() { + InspectDocument document = document(List.of(), Map.of(), List.of()); + + assertThrows( + IllegalArgumentException.class, + () -> serviceBuilder(document).clockSkew(Duration.ofSeconds(-1)).build()); + assertThrows( + IllegalArgumentException.class, + () -> serviceBuilder(document).clockSkew(Duration.ofSeconds(31)).build()); + assertThrows( + IllegalArgumentException.class, + () -> serviceBuilder(document) + .clockSkew(Duration.ofMillis(1500)) + .build()); + assertThrows( + IllegalArgumentException.class, + () -> serviceBuilder(document) + .inspectUri(URI.create("http://service.example/.well-known/aep")) + .build()); + assertThrows( + IllegalArgumentException.class, + () -> serviceBuilder(document(List.of(API_KEY), Map.of(API_KEY, config(List.of())))) + .build(), + "an advertised Grant Type needs a handler"); + assertThrows( + IllegalArgumentException.class, + () -> serviceBuilder(document(List.of(), Map.of(), List.of(API_KEY))) + .build(), + "an advertised authentication method needs a Grant Type"); + assertThrows( + CompletionException.class, + () -> serviceBuilder(document) + .identifierSupplier(() -> " ") + .build() + .enroll(enrollRequest(), CommandOptions.idempotent("e", ENROLL_KEY)) + .toCompletableFuture() + .join()); + } + + @Test + void acceptsEveryConfigurableCollaborator() { + AepService service = serviceBuilder(document(List.of(), Map.of(), List.of())) + .enrollmentStore(EnrollmentStore.inMemory()) + .enrollmentPolicy(EnrollmentPolicy.active()) + .replayStore(ReplayStore.inMemory()) + .idempotencyStore(IdempotencyStore.inMemory(CLOCK)) + .clockSkew(Duration.ofSeconds(5)) + .claimValueLimits(ClaimValueLimits.defaults()) + .inspectUri(URI.create("https://service.example/.well-known/aep")) + .build(); + + assertEquals(200, code(service.enroll(enrollRequest(), CommandOptions.idempotent("e", ENROLL_KEY)))); + var builder = serviceBuilder(document(List.of(), Map.of(), List.of())); + assertThrows(NullPointerException.class, () -> builder.enrollmentStore(null)); + assertThrows(NullPointerException.class, () -> builder.enrollmentPolicy(null)); + assertThrows(NullPointerException.class, () -> builder.replayStore(null)); + assertThrows(NullPointerException.class, () -> builder.idempotencyStore(null)); + assertThrows(NullPointerException.class, () -> builder.clockSkew(null)); + assertThrows(NullPointerException.class, () -> builder.claimValueLimits(null)); + assertThrows(NullPointerException.class, () -> builder.identifierSupplier(null)); + assertThrows(NullPointerException.class, () -> builder.clock(null)); + } + + @Test + void refusesDuplicateHandlerRegistrations() { + var builder = serviceBuilder(document(List.of(API_KEY), Map.of(API_KEY, config(List.of())))); + GrantTypeDefinition definition = new GrantTypeDefinition(API_KEY, handler()); + builder.grantType(definition); + builder.credentialAuthenticator(API_KEY, handler()); + + assertThrows(IllegalArgumentException.class, () -> builder.grantType(definition)); + assertThrows(IllegalArgumentException.class, () -> builder.credentialAuthenticator(API_KEY, handler())); + assertThrows(NullPointerException.class, () -> builder.storedCredentialGrantType(null)); + assertEquals(API_KEY, definition.grantType()); + } + + @Test + void refusesStoredCredentialConfigurationTheDocumentDoesNotAdvertise() { + var builder = serviceBuilder(document(List.of(API_KEY), Map.of(API_KEY, config(List.of())))); + StoredCredentialGrantType mismatched = StoredCredentialGrantTypes.apiKey( + config(List.of("x-other")), + (request, context) -> completed(apiKey(API_KEY_HEADER)), + ServiceCredentialStore.inMemory()); + + assertThrows(IllegalArgumentException.class, () -> builder.storedCredentialGrantType(mismatched)); + assertEquals(API_KEY, mismatched.grantType()); + assertEquals(config(List.of("x-other")), mismatched.config()); + } + + @Test + void rejectsIssuedCredentialsThatDoNotMatchTheirGrantType() { + assertThrows( + CompletionException.class, + ServiceCoverageTest::issueWrongCredentialType, + "an api-key handler does not issue a bearer token"); + assertThrows( + CompletionException.class, + () -> issue((request, context) -> completed(apiKey("Bad Header"))), + "the response still has to validate"); + assertThrows( + CompletionException.class, + () -> issue((request, context) -> + completed(new GrantResponses.ApiKey("s", "c", "whenever", API_KEY_HEADER, List.of()))), + "the expiry has to be a date-time"); + assertThrows( + CompletionException.class, + () -> issue((request, context) -> completed( + new GrantResponses.ApiKey("s", "c", "2026-09-01T11:00:00Z", API_KEY_HEADER, List.of()))), + "the expiry has to follow issuance"); + assertThrows( + CompletionException.class, + () -> issue((request, context) -> completed(apiKey("x-unadvertised"))), + "the header has to be advertised"); + } + + @Test + void rejectsCredentialStoresThatReturnAnUnusableMatch() { + assertThrows( + CompletionException.class, + () -> matched( + new ServiceCredentialMatch(" ", "credential-1", NOW.plusSeconds(60), API_KEY, List.of()))); + assertThrows( + CompletionException.class, + () -> matched(new ServiceCredentialMatch(AGENT_DID, " ", NOW.plusSeconds(60), API_KEY, List.of()))); + assertThrows( + CompletionException.class, + () -> matched(new ServiceCredentialMatch( + AGENT_DID, "credential-1", NOW.minusSeconds(1), API_KEY, List.of()))); + assertThrows( + CompletionException.class, + () -> matched(new ServiceCredentialMatch( + AGENT_DID, "credential-1", NOW.plusSeconds(60), "other", List.of()))); + assertThrows( + NullPointerException.class, + () -> new ServiceCredentialMatch(AGENT_DID, "credential-1", null, API_KEY, List.of())); + } + + @Test + void rejectsAuthenticatorsThatReturnAnUnusablePrincipal() { + AepService service = serviceBuilder(document(List.of(API_KEY), Map.of(API_KEY, config(List.of())))) + .grantType(new GrantTypeDefinition(API_KEY, handler())) + .credentialAuthenticator(API_KEY, new CredentialAuthenticator() { + @Override + public CompletionStage hasPresentation(CredentialAuthenticationInput input) { + return completed(true); + } + + @Override + public CompletionStage> authenticate( + CredentialAuthenticationInput input) { + return completed(Optional.of(new AuthenticatedPrincipal( + AGENT_DID, + API_KEY, + "credential-1", + API_KEY, + List.of(), + AuthenticatedPrincipal.Kind.AEP_JWT))); + } + }) + .build(); + enroll(service); + + assertThrows(CompletionException.class, () -> authenticate(service, Map.of(API_KEY_HEADER, List.of("x")))); + } + + @Test + void refusesSessionCredentialsForAgentsThatAreNotActive() { + EnrollmentStore store = EnrollmentStore.inMemory(); + AepService service = ServiceFixtures.storedCredentialService(ServiceCredentialStore.inMemory(), store); + enroll(service); + grant(service, API_KEY); + store.save(EnrollmentRecord.builder(AGENT_DID, "enrollment-1", AgentStatus.TERMINATED, NOW) + .since(NOW) + .updatedAt(NOW) + .build()) + .toCompletableFuture() + .join(); + + assertEquals(NOT_RECOGNIZED, rejected(service, Map.of(API_KEY_HEADER, List.of("api-secret")))); + } + + private static AepService apiKeyService() { + return serviceBuilder(document(List.of(API_KEY), Map.of(API_KEY, config(List.of())))) + .grantType(new GrantTypeDefinition(API_KEY, handler())) + .credentialAuthenticator(API_KEY, handler()) + .build(); + } + + private static AepService jwtAndApiKeyService() { + return serviceBuilder(document( + List.of(API_KEY), + Map.of(API_KEY, config(List.of(API_KEY_HEADER))), + List.of(Aep.AUTHENTICATION_METHOD_JWT, API_KEY))) + .storedCredentialGrantType(StoredCredentialGrantTypes.apiKey( + config(List.of(API_KEY_HEADER)), + (request, context) -> completed(apiKey(API_KEY_HEADER)), + ServiceCredentialStore.inMemory())) + .build(); + } + + private static AepService freshService() { + return serviceBuilder(document(List.of(), Map.of(), List.of())).build(); + } + + private static GrantResponses.ApiKey apiKey(String header) { + return new GrantResponses.ApiKey("api-secret", "api-1", EXPIRY, header, List.of("read")); + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + private static void issueWrongCredentialType() { + BuiltInCredentialIssuer raw = (BuiltInCredentialIssuer) (request, context) -> + completed(new GrantResponses.OAuthBearer("s", "c", EXPIRY, List.of(), "opaque", "Bearer")); + issue((BuiltInCredentialIssuer) raw); + } + + private static void issue(BuiltInCredentialIssuer issuer) { + AepService service = serviceBuilder( + document(List.of(API_KEY), Map.of(API_KEY, config(List.of(API_KEY_HEADER))))) + .storedCredentialGrantType(StoredCredentialGrantTypes.apiKey( + config(List.of(API_KEY_HEADER)), issuer, ServiceCredentialStore.inMemory())) + .build(); + enroll(service); + service.grant(grantRequest(), CommandOptions.idempotent("g", GRANT_KEY)) + .toCompletableFuture() + .join(); + } + + private static void matched(ServiceCredentialMatch match) { + AepService service = serviceBuilder(document(List.of(API_KEY), Map.of(API_KEY, config(List.of())))) + .storedCredentialGrantType(StoredCredentialGrantTypes.apiKey( + config(List.of()), + (request, context) -> completed(apiKey(API_KEY_HEADER)), + new MatchingStore(match))) + .build(); + enroll(service); + authenticate(service, Map.of(API_KEY_HEADER, List.of("api-secret"))); + } + + private static CompletionStage> claiming( + String issuer, String subject, String audience, AssertionOperation operation) { + AepService service = AepService.builder( + document(List.of(), Map.of(), List.of()), + (assertion, context) -> completed(new ClientAssertionClaims( + issuer, + subject, + audience, + operation, + NOW.minusSeconds(1).getEpochSecond(), + NOW.plusSeconds(60).getEpochSecond(), + assertion, + null))) + .clock(CLOCK) + .build(); + return service.status(CommandOptions.authenticated("assertion")); + } + + private static void resource(AepService service, URI url) { + service.authenticate(new ProtectedResourceRequest(Map.of(), "GET", url)) + .toCompletableFuture() + .join(); + } + + private static EnrollRequest enrollRequest() { + return new EnrollRequest(AGENT_DID, null, null); + } + + private static GrantRequest grantRequest() { + return new GrantRequest(API_KEY, List.of("read")); + } + + private static CommandOptions key(String value, String assertion) { + return CommandOptions.idempotent(assertion, value); + } + + private static int code(CompletionStage> stage) { + return stage.toCompletableFuture().join().status(); + } + + private static String rejected(AepService service, Map> headers) { + ProtectedResourceResult result = authenticate(service, headers); + assertFalse(result.authenticated(), "expected a rejection"); + return result.response().problem().code(); + } + + private static RecordingHandler handler() { + return new RecordingHandler(); + } + + private static final class RecordingHandler implements GrantTypeHandler, CredentialAuthenticator { + @Override + public CompletionStage grant(GrantRequest request, GrantContext context) { + return completed(new GrantResult("credential-1", Map.of("credential_id", "credential-1"))); + } + + @Override + public CompletionStage revoke(RevokeRequest request, RevokeContext context) { + return completed(null); + } + + @Override + public CompletionStage hasPresentation(CredentialAuthenticationInput input) { + return completed(false); + } + + @Override + public CompletionStage> authenticate(CredentialAuthenticationInput input) { + return completed(Optional.empty()); + } + } + + private record MatchingStore(ServiceCredentialMatch match) implements ServiceCredentialStore { + @Override + public CompletionStage> authenticate( + String grantType, CredentialAuthenticationInput input) { + return completed(Optional.of(match)); + } + + @Override + public CompletionStage hasPresentation(String grantType, CredentialAuthenticationInput input) { + return completed(true); + } + + @Override + public CompletionStage revoke(String agentDid, String grantType, String credentialId, Instant revokedAt) { + return completed(null); + } + + @Override + public CompletionStage revokeGrantType(String agentDid, String grantType, Instant revokedAt) { + return completed(null); + } + + @Override + public CompletionStage save(ServiceCredentialRecord record) { + return completed(null); + } + } +} diff --git a/aep-service/src/test/java/foundation/aep/service/ServiceEdgeTest.java b/aep-service/src/test/java/foundation/aep/service/ServiceEdgeTest.java new file mode 100644 index 0000000..4d132b1 --- /dev/null +++ b/aep-service/src/test/java/foundation/aep/service/ServiceEdgeTest.java @@ -0,0 +1,733 @@ +package foundation.aep.service; + +import static foundation.aep.service.ServiceFixtures.AGENT_DID; +import static foundation.aep.service.ServiceFixtures.API_KEY_HEADER; +import static foundation.aep.service.ServiceFixtures.AUTHORIZATION; +import static foundation.aep.service.ServiceFixtures.CLOCK; +import static foundation.aep.service.ServiceFixtures.NOW; +import static foundation.aep.service.ServiceFixtures.RESOURCE; +import static foundation.aep.service.ServiceFixtures.authenticate; +import static foundation.aep.service.ServiceFixtures.basicCredentials; +import static foundation.aep.service.ServiceFixtures.completed; +import static foundation.aep.service.ServiceFixtures.config; +import static foundation.aep.service.ServiceFixtures.document; +import static foundation.aep.service.ServiceFixtures.enroll; +import static foundation.aep.service.ServiceFixtures.grant; +import static foundation.aep.service.ServiceFixtures.serviceBuilder; +import static foundation.aep.service.ServiceFixtures.storedCredentialService; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import foundation.aep.core.Aep; +import foundation.aep.core.AepCommand; +import foundation.aep.core.AgentStatus; +import foundation.aep.core.GrantResponses; +import foundation.aep.core.InspectDocument; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import org.junit.jupiter.api.Test; + +/** Boundary conditions across the AEP Service HTTP binding and its reference stores. */ +final class ServiceEdgeTest { + private static final String API_KEY = Aep.GRANT_TYPE_API_KEY; + private static final String CONTENT_TYPE = "Content-Type"; + private static final String EXPIRY = "2026-09-01T12:10:00Z"; + private static final String CREDENTIAL_ID = "api-1"; + private static final byte[] ENROLL_BODY = + "{\"agent_did\":\"did:web:agent.example\"}".getBytes(StandardCharsets.UTF_8); + + @Test + void refusesRequestsTheBindingCannotRoute() { + AepServiceHttpHandler handler = handler(); + + assertEquals( + 404, + handle(handler, AepCommand.GRANT, "POST", Map.of(), new byte[0]).status()); + assertEquals( + 400, + handle(handler, AepCommand.INSPECT, "GET", Map.of(), ENROLL_BODY) + .status(), + "Inspect has no body"); + assertEquals( + 400, + handle(handler, AepCommand.STATUS, "GET", Map.of(), ENROLL_BODY).status(), + "Status has no body"); + assertThrows(NullPointerException.class, () -> handler.handle(null, request("GET", Map.of(), new byte[0]))); + assertThrows(NullPointerException.class, () -> handler.handle(AepCommand.INSPECT, null)); + assertThrows(NullPointerException.class, () -> handler.authenticate(null)); + } + + @Test + void refusesBodiesTheBindingCannotRead() { + AepServiceHttpHandler small = new AepServiceHttpHandler(commandService(), 3); + Map> aep = Map.of(CONTENT_TYPE, List.of(Aep.MEDIA_TYPE)); + + assertEquals( + 413, handle(small, AepCommand.ENROLL, "POST", aep, ENROLL_BODY).status()); + assertEquals( + 400, + handle(small, AepCommand.ENROLL, "POST", aep, new byte[] {(byte) 0xc3, (byte) 0x28, 0x20}) + .status(), + "the body has to be UTF-8"); + assertEquals( + 400, + handle(handler(), AepCommand.ENROLL, "POST", aep, "not json".getBytes(StandardCharsets.UTF_8)) + .status()); + assertEquals( + 415, + handle( + handler(), + AepCommand.ENROLL, + "POST", + Map.of(CONTENT_TYPE, List.of("application/json")), + ENROLL_BODY) + .status()); + assertEquals( + 415, + handle(handler(), AepCommand.ENROLL, "POST", Map.of(), ENROLL_BODY) + .status(), + "the media type is required"); + assertEquals( + 415, + handle( + handler(), + AepCommand.ENROLL, + "POST", + Map.of(CONTENT_TYPE, List.of(Aep.MEDIA_TYPE, Aep.MEDIA_TYPE)), + ENROLL_BODY) + .status(), + "one media type, not two"); + assertEquals( + 200, + handle( + handler(), + AepCommand.ENROLL, + "POST", + Map.of(CONTENT_TYPE, List.of("APPLICATION/AEP+JSON; charset=utf-8")), + ENROLL_BODY) + .status(), + "the essence matches case-insensitively and ignores parameters"); + } + + @Test + void routesEveryAdvertisedCommand() { + AepServiceHttpHandler handler = new AepServiceHttpHandler(commandService()); + Map> aep = Map.of(CONTENT_TYPE, List.of(Aep.MEDIA_TYPE)); + + assertEquals(5, handler.routes().size()); + assertEquals(Aep.WELL_KNOWN_PATH, handler.routes().get(AepCommand.INSPECT)); + assertEquals("/aep/enroll", handler.routes().get(AepCommand.ENROLL)); + assertEquals(AepServiceHttpHandler.DEFAULT_MAXIMUM_REQUEST_BYTES, handler.maximumRequestBytes()); + assertEquals("public, max-age=300", handler.inspectCacheControl()); + assertEquals( + 200, + handle(handler, AepCommand.ENROLL, "POST", aep, ENROLL_BODY).status()); + assertEquals( + 200, + handle( + handler, + AepCommand.GRANT, + "POST", + aep, + "{\"grant_type\":\"api-key\"}".getBytes(StandardCharsets.UTF_8)) + .status()); + assertEquals( + 200, + handle( + handler, + AepCommand.REVOKE, + "POST", + aep, + "{\"grant_type\":\"api-key\"}".getBytes(StandardCharsets.UTF_8)) + .status()); + } + + @Test + void readsTheAssertionOnlyFromASingleAepAuthorizationField() { + AepServiceHttpHandler handler = new AepServiceHttpHandler(commandService()); + Map> aep = new LinkedHashMap<>(); + aep.put(CONTENT_TYPE, List.of(Aep.MEDIA_TYPE)); + + assertEquals(401, statusWith(handler, headers(aep, AUTHORIZATION, List.of("Bearer token")))); + assertEquals(401, statusWith(handler, headers(aep, AUTHORIZATION, List.of("nonsense")))); + assertEquals(401, statusWith(handler, headers(aep, AUTHORIZATION, List.of("AEP a", "AEP b")))); + assertEquals(200, statusWith(handler, headers(aep, AUTHORIZATION, List.of("AEP assertion")))); + } + + @Test + void refusesUnusableHandlerConfiguration() { + AepService service = commandService(); + + assertThrows(NullPointerException.class, () -> new AepServiceHttpHandler(null)); + assertThrows(IllegalArgumentException.class, () -> new AepServiceHttpHandler(service, 0)); + assertThrows(IllegalArgumentException.class, () -> new AepServiceHttpHandler(service, -1)); + assertThrows(NullPointerException.class, () -> new AepServiceHttpHandler(service, 1024, null)); + assertThrows(IllegalArgumentException.class, () -> new AepServiceHttpHandler(service, 1024, " ")); + assertThrows(IllegalArgumentException.class, () -> new AepServiceHttpHandler(service, 1024, "max-age=5 ")); + assertThrows( + IllegalArgumentException.class, () -> new AepServiceHttpHandler(service, 1024, "max-age=5\r\nX: 1")); + assertEquals("no-store", new AepServiceHttpHandler(service, 1024, "no-store").inspectCacheControl()); + } + + @Test + void buildsRequestUrlsFromAPublicOrigin() { + URI origin = URI.create("https://service.example"); + + assertEquals( + URI.create("https://service.example/aep/status?x=1"), + AepHttpRequest.publicUrl(origin, "/aep/status", "x=1")); + assertEquals(List.of(), request("GET", Map.of(), new byte[0]).headerValues("Absent")); + assertThrows(IllegalArgumentException.class, () -> AepHttpRequest.publicUrl(null, "/x", null)); + assertThrows( + IllegalArgumentException.class, + () -> AepHttpRequest.publicUrl(URI.create("ftp://service.example"), "/x", null)); + assertThrows( + IllegalArgumentException.class, + () -> AepHttpRequest.publicUrl(URI.create("https://user@service.example"), "/x", null)); + assertThrows( + IllegalArgumentException.class, + () -> AepHttpRequest.publicUrl(URI.create("https://service.example/base"), "/x", null)); + assertThrows(IllegalArgumentException.class, () -> AepHttpRequest.publicUrl(origin, "relative", null)); + assertThrows(IllegalArgumentException.class, () -> AepHttpRequest.publicUrl(origin, "//evil.example", null)); + assertThrows(NullPointerException.class, () -> new AepHttpRequest(null, RESOURCE, Map.of(), new byte[0])); + assertThrows(NullPointerException.class, () -> new AepHttpRequest("GET", null, Map.of(), new byte[0])); + assertEquals(0, new AepHttpRequest("GET", RESOURCE, Map.of(), null).body().length); + } + + @Test + void reportsAuthenticationResultsAsOneOrTheOther() { + AuthenticatedPrincipal principal = new AuthenticatedPrincipal( + AGENT_DID, + API_KEY, + CREDENTIAL_ID, + API_KEY, + List.of("read"), + AuthenticatedPrincipal.Kind.SESSION_CREDENTIAL); + AepHttpResponse response = new AepHttpResponse(401, Aep.PROBLEM_MEDIA_TYPE, Map.of(), new byte[0]); + + assertTrue(AepHttpAuthenticationResult.authenticated(principal).authenticated()); + assertFalse(AepHttpAuthenticationResult.rejected(response).authenticated()); + assertThrows(IllegalArgumentException.class, () -> new AepHttpAuthenticationResult(false, principal, response)); + assertEquals( + principal, AepHttpAuthenticationResult.authenticated(principal).principal()); + } + + @Test + void authenticatesThroughTheBindingAndRejectsThroughIt() { + AepService service = storedCredentialService(ServiceCredentialStore.inMemory()); + enroll(service); + grant(service, API_KEY); + AepServiceHttpHandler handler = new AepServiceHttpHandler(service); + + AepHttpAuthenticationResult accepted = handler.authenticate( + new AepHttpRequest("GET", RESOURCE, Map.of(API_KEY_HEADER, List.of("api-secret")), new byte[0])) + .toCompletableFuture() + .join(); + AepHttpAuthenticationResult refused = handler.authenticate( + new AepHttpRequest("GET", RESOURCE, Map.of(API_KEY_HEADER, List.of("wrong")), new byte[0])) + .toCompletableFuture() + .join(); + + assertTrue(accepted.authenticated()); + assertEquals(CREDENTIAL_ID, accepted.principal().credentialId()); + assertFalse(refused.authenticated()); + assertEquals(401, refused.response().status()); + } + + @Test + void guardsGrantResultsAndTheirJsonPayloads() { + Map nested = new LinkedHashMap<>(); + nested.put("list", List.of(1, "two", true)); + nested.put("absent", null); + Map response = new LinkedHashMap<>(); + response.put("credential_id", CREDENTIAL_ID); + response.put("nested", nested); + + GrantResult result = new GrantResult(CREDENTIAL_ID, response); + + assertEquals(nested, result.response().get("nested")); + assertThrows(IllegalArgumentException.class, () -> new GrantResult(" ", Map.of())); + assertThrows( + IllegalArgumentException.class, () -> new GrantResult(CREDENTIAL_ID, Map.of("credential_id", "other"))); + assertThrows(IllegalArgumentException.class, () -> new GrantResult(CREDENTIAL_ID, null)); + assertThrows(IllegalArgumentException.class, () -> new GrantResult(CREDENTIAL_ID, Map.of("x", Double.NaN))); + assertThrows( + IllegalArgumentException.class, + () -> new GrantResult(CREDENTIAL_ID, Map.of("x", Float.POSITIVE_INFINITY))); + assertThrows(IllegalArgumentException.class, () -> new GrantResult(CREDENTIAL_ID, Map.of("x", new Object()))); + assertThrows( + IllegalArgumentException.class, + () -> new GrantResult(CREDENTIAL_ID, Map.of("x", java.util.Collections.singletonMap(1, "v")))); + assertThrows( + IllegalArgumentException.class, + () -> new GrantResult(CREDENTIAL_ID, java.util.Collections.singletonMap(null, "v"))); + assertThrows(IllegalArgumentException.class, () -> new GrantTypeDefinition(" ", null)); + } + + @Test + void guardsStoredCredentialMetadata() { + InspectDocument.GrantTypeConfig apiConfig = config(List.of(API_KEY_HEADER)); + StoredCredentialGrantType stored = StoredCredentialGrantTypes.apiKey( + apiConfig, (request, context) -> completed(apiKey()), ServiceCredentialStore.inMemory()); + + assertEquals(API_KEY, stored.grantType()); + assertEquals(apiConfig, stored.config()); + assertThrows( + IllegalArgumentException.class, + () -> new StoredCredentialGrantType("other", apiConfig, stored.definition(), stored.authenticator())); + assertThrows( + NullPointerException.class, + () -> StoredCredentialGrantTypes.apiKey(apiConfig, null, ServiceCredentialStore.inMemory())); + assertThrows( + NullPointerException.class, + () -> StoredCredentialGrantTypes.apiKey(apiConfig, (request, context) -> completed(apiKey()), null)); + } + + @Test + void exposesEnrollmentRecordState() { + EnrollmentRecord record = EnrollmentRecord.builder(AGENT_DID, "enrollment-1", AgentStatus.ACTIVE, NOW) + .claims(foundation.aep.core.ClaimValues.builder() + .contactEmail("agent@example.com") + .build()) + .since(NOW) + .updatedAt(NOW.plusSeconds(1)) + .ownerActionRequired(true) + .verificationPending(List.of("contact.email")) + .requirementsPending(List.of("person.username")) + .build(); + + assertEquals("enrollment-1", record.enrollmentId()); + assertEquals("agent@example.com", record.claims().contactEmail()); + assertEquals(NOW, record.createdAt()); + assertEquals(NOW.plusSeconds(1), record.updatedAt()); + assertEquals(NOW, record.since()); + assertTrue(record.ownerActionRequired()); + assertThrows( + IllegalArgumentException.class, + () -> EnrollmentRecord.builder(" ", "enrollment-1", AgentStatus.ACTIVE, NOW) + .build()); + assertThrows( + NullPointerException.class, + () -> EnrollmentRecord.builder(AGENT_DID, "enrollment-1", null, NOW) + .build()); + } + + @Test + void coalescesConcurrentEnrollmentCreation() { + EnrollmentStore store = EnrollmentStore.inMemory(); + CompletableFuture creation = new CompletableFuture<>(); + EnrollmentRecord record = EnrollmentRecord.builder(AGENT_DID, "enrollment-1", AgentStatus.ACTIVE, NOW) + .since(NOW) + .updatedAt(NOW) + .build(); + + var first = store.findOrCreate(AGENT_DID, () -> creation).toCompletableFuture(); + var second = store.findOrCreate(AGENT_DID, () -> creation).toCompletableFuture(); + creation.complete(record); + + assertTrue(first.join().created()); + assertFalse(second.join().created()); + assertFalse(store.findOrCreate(AGENT_DID, () -> creation) + .toCompletableFuture() + .join() + .created()); + assertThrows( + CompletionException.class, + () -> store.findOrCreate("did:web:other.example", () -> completed(record)) + .toCompletableFuture() + .join(), + "a record has to belong to the Agent it was created for"); + assertThrows( + CompletionException.class, + () -> store.findOrCreate( + "did:web:third.example", + () -> CompletableFuture.failedFuture(new IllegalStateException("boom"))) + .toCompletableFuture() + .join()); + } + + @Test + void enrollmentCreationCanRetryAfterSynchronousAndAsynchronousFailures() { + EnrollmentRecord record = EnrollmentRecord.builder(AGENT_DID, "enrollment-1", AgentStatus.PENDING, NOW) + .status(AgentStatus.ACTIVE) + .build(); + for (boolean synchronous : List.of(true, false)) { + EnrollmentStore store = EnrollmentStore.inMemory(); + IllegalStateException failure = new IllegalStateException("policy unavailable"); + var attempt = store.findOrCreate(AGENT_DID, () -> { + if (synchronous) throw failure; + return CompletableFuture.failedFuture(failure); + }) + .toCompletableFuture(); + assertEquals( + failure, + assertThrows(CompletionException.class, attempt::join).getCause()); + assertTrue(store.find(AGENT_DID).toCompletableFuture().join().isEmpty()); + var retried = store.findOrCreate(AGENT_DID, () -> completed(record)) + .toCompletableFuture() + .join(); + assertTrue(retried.created()); + assertEquals( + record, store.find(AGENT_DID).toCompletableFuture().join().orElseThrow()); + } + } + + @Test + void sharesAnInFlightIdempotentOperationAndSurfacesItsFailure() { + IdempotencyStore store = IdempotencyStore.inMemory(CLOCK); + IdempotencyInput input = new IdempotencyInput(AGENT_DID, "key", "enroll", "hash"); + CompletableFuture> pending = new CompletableFuture<>(); + + var started = store.execute(input, () -> pending).toCompletableFuture(); + var joined = store.execute(input, () -> completed(ServiceResponse.success("ignored"))) + .toCompletableFuture(); + var conflicting = store.execute( + new IdempotencyInput(AGENT_DID, "key", "grant", "hash"), + () -> completed(ServiceResponse.success("no"))) + .toCompletableFuture() + .join(); + pending.complete(ServiceResponse.success("done")); + + assertEquals("done", started.join().response().body()); + assertEquals(IdempotencyResult.State.REPLAYED, joined.join().state()); + assertEquals(IdempotencyResult.State.CONFLICT, conflicting.state()); + assertThrows( + CompletionException.class, + () -> store.execute(new IdempotencyInput(AGENT_DID, "other", "enroll", "hash"), () -> { + throw new IllegalStateException("boom"); + }) + .toCompletableFuture() + .join()); + } + + @Test + void refusesUnusableReplayRecords() { + ReplayStore store = ReplayStore.inMemory(); + + assertThrows( + CompletionException.class, + () -> store.consume(new ReplayRecord(" ", "jti", NOW.plusSeconds(60)), NOW) + .toCompletableFuture() + .join()); + assertThrows( + CompletionException.class, + () -> store.consume(new ReplayRecord(AGENT_DID, " ", NOW.plusSeconds(60)), NOW) + .toCompletableFuture() + .join()); + assertThrows( + CompletionException.class, + () -> store.consume(new ReplayRecord(AGENT_DID, "jti", NOW), NOW) + .toCompletableFuture() + .join()); + assertTrue(store.consume(new ReplayRecord(AGENT_DID, "jti", NOW.plusSeconds(60)), NOW) + .toCompletableFuture() + .join()); + assertTrue( + store.consume(new ReplayRecord(AGENT_DID, "jti", NOW.plusSeconds(120)), NOW.plusSeconds(61)) + .toCompletableFuture() + .join(), + "an expired entry stops blocking its own identifier"); + } + + @Test + void boundsClaimValuesByEveryConfiguredLimit() { + assertThrows(IllegalArgumentException.class, () -> new ClaimValueLimits(0, 1, 1, 1)); + assertThrows(IllegalArgumentException.class, () -> new ClaimValueLimits(1, 0, 1, 1)); + assertThrows(IllegalArgumentException.class, () -> new ClaimValueLimits(1, 1, 0, 1)); + assertThrows(IllegalArgumentException.class, () -> new ClaimValueLimits(1, 1, 1, 0)); + + ClaimValueLimits limits = new ClaimValueLimits(65_536, 4, 2, 20); + + assertTrue(limits.accepts(null)); + assertTrue(limits.accepts( + foundation.aep.core.ClaimValues.builder().personUsername("ada").build())); + assertFalse( + limits.accepts(foundation.aep.core.ClaimValues.builder() + .personUsername("a-username-well-past-the-limit") + .build()), + "a long string is refused"); + assertFalse( + limits.accepts(foundation.aep.core.ClaimValues.builder() + .additional("a", Map.of("b", Map.of("c", "d"))) + .build()), + "a deep object is refused"); + assertFalse( + new ClaimValueLimits(32, 8, 8, 64) + .accepts(foundation.aep.core.ClaimValues.builder() + .contactEmail("a-fairly-long-address@example.com") + .personUsername("ada") + .build()), + "an oversized encoding is refused"); + } + + @Test + void redactsStoredCredentialRecords() { + ServiceCredentialRecord record = + new ServiceCredentialRecord(AGENT_DID, NOW, apiKey(), CREDENTIAL_ID, Instant.parse(EXPIRY), API_KEY); + + assertFalse(record.toString().contains("api-secret")); + assertTrue(record.toString().contains(CREDENTIAL_ID)); + assertThrows( + NullPointerException.class, + () -> new ServiceCredentialRecord(AGENT_DID, NOW, null, CREDENTIAL_ID, Instant.parse(EXPIRY), API_KEY)); + } + + @Test + void refusesCredentialRecordsTheStoreCannotIndex() { + ServiceCredentialStore store = ServiceCredentialStore.inMemory(); + + assertThrows(CompletionException.class, () -> save(store, CREDENTIAL_ID, "other-id", EXPIRY)); + assertThrows(CompletionException.class, () -> save(store, " ", " ", EXPIRY)); + assertThrows( + CompletionException.class, + () -> save(store, CREDENTIAL_ID, CREDENTIAL_ID, "2026-09-01T11:00:00Z"), + "expiry has to follow creation"); + save(store, CREDENTIAL_ID, CREDENTIAL_ID, EXPIRY); + assertThrows( + CompletionException.class, + () -> save(store, CREDENTIAL_ID, CREDENTIAL_ID, EXPIRY), + "an identifier is issued once"); + assertThrows(CompletionException.class, () -> save(store, "api-2", "api-2", EXPIRY), "a secret is issued once"); + } + + @Test + void credentialStoreRejectsMissingAndMultiplePresentations() { + ServiceCredentialStore store = ServiceCredentialStore.inMemory(); + save(store, CREDENTIAL_ID, CREDENTIAL_ID, EXPIRY); + for (Map> headers : + List.of(Map.>of(), Map.of(API_KEY_HEADER, List.of("api-secret", "api-secret")))) { + assertTrue(store.authenticate(API_KEY, new CredentialAuthenticationInput(headers, "GET", RESOURCE, NOW)) + .toCompletableFuture() + .join() + .isEmpty()); + } + assertTrue(store.authenticate( + API_KEY, + new CredentialAuthenticationInput( + Map.of(API_KEY_HEADER, List.of("api-secret")), "GET", RESOURCE, NOW)) + .toCompletableFuture() + .join() + .isPresent()); + } + + @Test + void revokesOnlyWhatTheRequestNames() { + ServiceCredentialStore store = ServiceCredentialStore.inMemory(); + AepService service = storedCredentialService(store); + enroll(service); + grant(service, API_KEY); + Map> presented = Map.of(API_KEY_HEADER, List.of("api-secret")); + + store.revoke("did:web:someone.example", API_KEY, CREDENTIAL_ID, NOW) + .toCompletableFuture() + .join(); + store.revoke(AGENT_DID, Aep.GRANT_TYPE_BASIC, CREDENTIAL_ID, NOW) + .toCompletableFuture() + .join(); + store.revoke(AGENT_DID, API_KEY, "absent", NOW).toCompletableFuture().join(); + assertTrue(authenticate(service, presented).authenticated(), "none of those named this credential"); + + store.revokeGrantType(AGENT_DID, API_KEY, NOW).toCompletableFuture().join(); + + assertFalse(authenticate(service, presented).authenticated()); + assertThrows( + CompletionException.class, + () -> store.revoke(AGENT_DID, API_KEY, CREDENTIAL_ID, null) + .toCompletableFuture() + .join()); + assertThrows( + CompletionException.class, + () -> store.revokeGrantType(AGENT_DID, API_KEY, null) + .toCompletableFuture() + .join()); + } + + @Test + void revokesEveryCredentialOfAGrantTypeForABlankIdentifier() { + ServiceCredentialStore store = ServiceCredentialStore.inMemory(); + AepService service = storedCredentialService(store); + enroll(service); + grant(service, API_KEY); + GrantTypeHandler handler = StoredCredentialGrantTypes.apiKey( + config(List.of(API_KEY_HEADER)), (request, context) -> completed(apiKey()), store) + .definition() + .handler(); + + handler.revoke( + new foundation.aep.core.RevokeRequest(API_KEY, " ", null), + new RevokeContext(AGENT_DID, null, API_KEY, NOW)) + .toCompletableFuture() + .join(); + + assertFalse( + authenticate(service, Map.of(API_KEY_HEADER, List.of("api-secret"))) + .authenticated(), + "a blank identifier names no credential, so the whole Grant Type is revoked"); + } + + @Test + void reportsAMalformedAepPresentationAsUnrecognized() { + AepService service = storedCredentialService(ServiceCredentialStore.inMemory()); + enroll(service); + grant(service, Aep.GRANT_TYPE_OAUTH_BEARER); + + // CORE-PR-038 separates a missing credential from a malformed one. A value naming an AEP + // scheme is an AEP presentation even when the rest of it does not parse, so ignoring it and + // answering authentication_required both reported the wrong code and told the caller its + // credential had never been seen. + assertEquals("not_recognized", rejected(service, Map.of(AUTHORIZATION, List.of("AEP")))); + assertEquals("not_recognized", rejected(service, Map.of(AUTHORIZATION, List.of("Bearer")))); + assertEquals( + "not_recognized", + rejected(service, Map.of(AUTHORIZATION, List.of("aep "))), + "scheme matching is case-insensitive and whitespace does not make credentials"); + assertEquals( + "not_recognized", + rejected(service, Map.of(AUTHORIZATION, List.of("Basic not a token"))), + "credentials carrying a space are not a single credential"); + assertEquals( + "authentication_required", + rejected(service, Map.of(AUTHORIZATION, List.of("Negotiate"))), + "an unrelated scheme with no credentials is still not an AEP presentation"); + } + + @Test + void ignoresUnrelatedAuthorizationSchemesOnProtectedResources() { + AepService service = storedCredentialService(ServiceCredentialStore.inMemory()); + enroll(service); + grant(service, Aep.GRANT_TYPE_OAUTH_BEARER); + + assertEquals("authentication_required", rejected(service, Map.of(AUTHORIZATION, List.of("Negotiate abc")))); + assertTrue(authenticate( + service, + Map.of( + Aep.AUTHORIZATION_HEADER, + List.of("Bearer oauth-secret"), + AUTHORIZATION, + List.of("Payment unrelated-payment"))) + .authenticated()); + assertEquals( + "authentication_required", + rejected(service, Map.of(AUTHORIZATION, List.of("Negotiate abc", "Digest xyz"))), + "two unrelated schemes are still no AEP presentation"); + assertEquals( + "not_recognized", + rejected(service, Map.of(AUTHORIZATION, List.of("Bearer oauth-secret", "Negotiate abc"))), + "an AEP scheme alongside anything else is ambiguous"); + assertEquals( + "not_recognized", + rejected( + service, + Map.of( + Aep.AUTHORIZATION_HEADER, + List.of("Bearer oauth-secret"), + AUTHORIZATION, + List.of("Basic " + basicCredentials()))), + "the dedicated carrier does not win a race against a recognized standard one"); + } + + @Test + void doesNotResolveNamesWhenDecidingWhatIsLoopback() { + AepService service = serviceBuilder(document(List.of(), Map.of(), List.of())) + .allowInsecureLoopback(true) + .build(); + + assertThrows( + CompletionException.class, + () -> service.authenticate(new ProtectedResourceRequest( + Map.of(), "GET", URI.create("http://not-a-real-host.invalid/x"))) + .toCompletableFuture() + .join()); + assertThrows( + CompletionException.class, + () -> service.authenticate( + new ProtectedResourceRequest(Map.of(), "GET", URI.create("http://[2001:db8::1]/x"))) + .toCompletableFuture() + .join()); + assertFalse( + service.authenticate(new ProtectedResourceRequest(Map.of(), "GET", URI.create("http://[::1]:8080/x"))) + .toCompletableFuture() + .join() + .authenticated()); + } + + private static GrantResponses.ApiKey apiKey() { + return new GrantResponses.ApiKey("api-secret", CREDENTIAL_ID, EXPIRY, API_KEY_HEADER, List.of("read")); + } + + private static void save(ServiceCredentialStore store, String credentialId, String responseId, String expiry) { + store.save(new ServiceCredentialRecord( + AGENT_DID, + NOW, + new GrantResponses.ApiKey("api-secret", responseId, expiry, API_KEY_HEADER, List.of("read")), + credentialId, + Instant.parse(expiry), + API_KEY)) + .toCompletableFuture() + .join(); + } + + private static AepService commandService() { + return serviceBuilder(document(List.of(API_KEY), Map.of(API_KEY, config(List.of(API_KEY_HEADER))))) + .storedCredentialGrantType(StoredCredentialGrantTypes.apiKey( + config(List.of(API_KEY_HEADER)), + (request, context) -> completed(apiKey()), + ServiceCredentialStore.inMemory())) + .build(); + } + + private static AepServiceHttpHandler handler() { + return new AepServiceHttpHandler( + serviceBuilder(document(List.of(), Map.of(), List.of())).build()); + } + + private static Map> headers(Map> base, String name, List values) { + Map> result = new LinkedHashMap<>(base); + result.put(name, values); + return result; + } + + private static int statusWith(AepServiceHttpHandler handler, Map> headers) { + return handle(handler, AepCommand.ENROLL, "POST", headers, ENROLL_BODY).status(); + } + + private static AepHttpResponse handle( + AepServiceHttpHandler handler, + AepCommand command, + String method, + Map> headers, + byte[] body) { + return handler.handle(command, request(method, headers, body)) + .toCompletableFuture() + .join(); + } + + private static AepHttpRequest request(String method, Map> headers, byte[] body) { + Map> all = new LinkedHashMap<>(headers); + int sequence = counter(); + all.putIfAbsent(AUTHORIZATION, List.of("AEP assertion-" + sequence)); + all.putIfAbsent("Idempotency-Key", List.of("key-" + sequence)); + return new AepHttpRequest(method, RESOURCE, all, body); + } + + private static int counter() { + return COUNTER.incrementAndGet(); + } + + private static final java.util.concurrent.atomic.AtomicInteger COUNTER = + new java.util.concurrent.atomic.AtomicInteger(); + + private static String rejected(AepService service, Map> headers) { + ProtectedResourceResult result = authenticate(service, headers); + assertFalse(result.authenticated(), "expected a rejection"); + return result.response().problem().code(); + } +} diff --git a/aep-service/src/test/java/foundation/aep/service/ServiceFixtures.java b/aep-service/src/test/java/foundation/aep/service/ServiceFixtures.java new file mode 100644 index 0000000..d50ae9f --- /dev/null +++ b/aep-service/src/test/java/foundation/aep/service/ServiceFixtures.java @@ -0,0 +1,159 @@ +package foundation.aep.service; + +import foundation.aep.core.Aep; +import foundation.aep.core.AssertionOperation; +import foundation.aep.core.ClientAssertionClaims; +import foundation.aep.core.EnrollRequest; +import foundation.aep.core.GrantRequest; +import foundation.aep.core.GrantResponses; +import foundation.aep.core.InspectDocument; +import java.net.URI; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; + +/** Shared construction helpers for the AEP Service tests. */ +final class ServiceFixtures { + static final Instant NOW = Instant.parse("2026-09-01T12:00:00Z"); + static final Clock CLOCK = Clock.fixed(NOW, ZoneOffset.UTC); + static final String AGENT_DID = "did:web:agent.example"; + static final String SERVICE_DID = "did:web:service.example"; + static final String API_KEY_HEADER = "X-Service-Key"; + static final String AUTHORIZATION = "Authorization"; + static final URI RESOURCE = URI.create("https://service.example/private"); + static final List ALL_COMMANDS = List.of("enroll", "grant", "inspect", "revoke", "status"); + + private ServiceFixtures() {} + + static InspectDocument document(List grantTypes, Map configs) { + return document(grantTypes, configs, grantTypes); + } + + static InspectDocument document( + List grantTypes, + Map configs, + List authenticationMethods) { + List commands = grantTypes.isEmpty() ? List.of("enroll", "inspect", "status") : ALL_COMMANDS; + return InspectDocument.builder() + .version(Aep.VERSION) + .authentication( + authenticationMethods.isEmpty() + ? null + : new InspectDocument.Authentication(authenticationMethods)) + .bindings(new InspectDocument.Bindings(List.of("http"))) + .claims(new InspectDocument.Claims(List.of(), List.of(), List.of())) + .commands(new InspectDocument.Commands(commands, grantTypes, configs)) + .core(new InspectDocument.Core(Aep.REQUIRED_SIGNING_ALGORITHMS)) + .http(new InspectDocument.Http("/aep", null)) + .identity(new InspectDocument.Identity(List.of("did:web"))) + .service(new InspectDocument.Service(SERVICE_DID)) + .build(); + } + + static InspectDocument.GrantTypeConfig config(List headerNames) { + return new InspectDocument.GrantTypeConfig( + List.of("opaque", "jwt"), + "600", + headerNames, + "service", + List.of("read"), + "true", + "https://service.example/introspect", + "https://service.example/revoke"); + } + + static AepService.Builder serviceBuilder(InspectDocument document) { + return AepService.builder(document, verifier()).clock(CLOCK).identifierSupplier(() -> "enrollment-1"); + } + + static ClientAssertionVerifier verifier() { + return (assertion, context) -> completed(new ClientAssertionClaims( + AGENT_DID, + AGENT_DID, + context.serviceDid(), + context.operation(), + NOW.minusSeconds(1).getEpochSecond(), + NOW.plusSeconds(60).getEpochSecond(), + assertion, + context.operation() == AssertionOperation.AUTHENTICATE ? context.resource() : null)); + } + + /** A Service advertising all three built-in stored-credential Grant Types over one store. */ + static AepService storedCredentialService(ServiceCredentialStore store) { + return storedCredentialService(store, EnrollmentStore.inMemory()); + } + + static AepService storedCredentialService(ServiceCredentialStore store, EnrollmentStore enrollmentStore) { + Map configs = new LinkedHashMap<>(); + configs.put(Aep.GRANT_TYPE_OAUTH_BEARER, config(List.of())); + configs.put(Aep.GRANT_TYPE_API_KEY, config(List.of(API_KEY_HEADER))); + configs.put(Aep.GRANT_TYPE_BASIC, config(List.of())); + List grantTypes = List.of(Aep.GRANT_TYPE_OAUTH_BEARER, Aep.GRANT_TYPE_API_KEY, Aep.GRANT_TYPE_BASIC); + return serviceBuilder(document(grantTypes, configs)) + .enrollmentStore(enrollmentStore) + .storedCredentialGrantType(StoredCredentialGrantTypes.oauthBearer( + config(List.of()), + (request, context) -> completed(new GrantResponses.OAuthBearer( + "oauth-secret", + "oauth-1", + NOW.plusSeconds(600).toString(), + List.of("read"), + "opaque", + "Bearer")), + store)) + .storedCredentialGrantType(StoredCredentialGrantTypes.apiKey( + config(List.of(API_KEY_HEADER)), + (request, context) -> completed(new GrantResponses.ApiKey( + "api-secret", + "api-1", + NOW.plusSeconds(600).toString(), + API_KEY_HEADER, + List.of("read"))), + store)) + .storedCredentialGrantType(StoredCredentialGrantTypes.basic( + config(List.of()), + (request, context) -> completed(new GrantResponses.Basic( + "basic-1", + NOW.plusSeconds(600).toString(), + "basic-secret", + "service", + List.of("read"), + "agent")), + store)) + .build(); + } + + static String basicCredentials() { + return java.util.Base64.getEncoder() + .encodeToString("agent:basic-secret".getBytes(java.nio.charset.StandardCharsets.UTF_8)); + } + + static void enroll(AepService service) { + service.enroll(new EnrollRequest(AGENT_DID, null, null), CommandOptions.idempotent("enroll", "enroll-key")) + .toCompletableFuture() + .join(); + } + + static void grant(AepService service, String grantType) { + service.grant( + new GrantRequest(grantType, List.of("read")), + CommandOptions.idempotent(grantType, grantType + "-key")) + .toCompletableFuture() + .join(); + } + + static ProtectedResourceResult authenticate(AepService service, Map> headers) { + return service.authenticate(new ProtectedResourceRequest(headers, "GET", RESOURCE)) + .toCompletableFuture() + .join(); + } + + static CompletionStage completed(T value) { + return CompletableFuture.completedFuture(value); + } +} diff --git a/aep-service/src/test/java/foundation/aep/service/ServiceValidationTest.java b/aep-service/src/test/java/foundation/aep/service/ServiceValidationTest.java new file mode 100644 index 0000000..2b2ef51 --- /dev/null +++ b/aep-service/src/test/java/foundation/aep/service/ServiceValidationTest.java @@ -0,0 +1,657 @@ +package foundation.aep.service; + +import static foundation.aep.service.ServiceFixtures.AGENT_DID; +import static foundation.aep.service.ServiceFixtures.API_KEY_HEADER; +import static foundation.aep.service.ServiceFixtures.AUTHORIZATION; +import static foundation.aep.service.ServiceFixtures.CLOCK; +import static foundation.aep.service.ServiceFixtures.NOW; +import static foundation.aep.service.ServiceFixtures.SERVICE_DID; +import static foundation.aep.service.ServiceFixtures.authenticate; +import static foundation.aep.service.ServiceFixtures.completed; +import static foundation.aep.service.ServiceFixtures.config; +import static foundation.aep.service.ServiceFixtures.document; +import static foundation.aep.service.ServiceFixtures.enroll; +import static foundation.aep.service.ServiceFixtures.grant; +import static foundation.aep.service.ServiceFixtures.serviceBuilder; +import static foundation.aep.service.ServiceFixtures.storedCredentialService; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import foundation.aep.core.Aep; +import foundation.aep.core.AgentStatus; +import foundation.aep.core.ClaimValues; +import foundation.aep.core.ClientAssertionClaims; +import foundation.aep.core.EnrollRequest; +import foundation.aep.core.GrantRequest; +import foundation.aep.core.GrantResponses; +import foundation.aep.core.InspectDocument; +import foundation.aep.core.RevokeRequest; +import java.net.URI; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CompletionException; +import java.util.concurrent.CompletionStage; +import org.junit.jupiter.api.Test; + +/** Field-by-field validation of the AEP Service surface. */ +final class ServiceValidationTest { + private static final String API_KEY = Aep.GRANT_TYPE_API_KEY; + private static final String EXPIRY = "2026-09-01T12:10:00Z"; + private static final String CREDENTIAL_ID = "api-1"; + private static final String NOT_RECOGNIZED = "not_recognized"; + + @Test + void acceptsAnEchoedIdempotencyKeyInTheEnrollBody() { + AepService service = + serviceBuilder(document(List.of(), Map.of(), List.of())).build(); + + assertEquals( + 200, + status(service.enroll( + new EnrollRequest(AGENT_DID, null, "key-1"), CommandOptions.idempotent("a1", "key-1")))); + assertEquals( + 400, + status(service.enroll( + new EnrollRequest(AGENT_DID, null, "other"), CommandOptions.idempotent("a2", "key-2")))); + } + + @Test + void treatsAnAbsentClaimsAdvertisementAsNoRequirements() { + InspectDocument document = InspectDocument.builder() + .version(Aep.VERSION) + .bindings(new InspectDocument.Bindings(List.of("http"))) + .commands(new InspectDocument.Commands(List.of("enroll", "inspect", "status"), List.of(), Map.of())) + .core(new InspectDocument.Core(Aep.REQUIRED_SIGNING_ALGORITHMS)) + .http(new InspectDocument.Http("/aep", null)) + .identity(new InspectDocument.Identity(List.of("did:web"))) + .service(new InspectDocument.Service(SERVICE_DID)) + .build(); + AepService service = serviceBuilder(document).build(); + + assertEquals( + 200, + status(service.enroll(new EnrollRequest(AGENT_DID, null, null), CommandOptions.idempotent("a", "k"))), + "a Service advertising no claims requires none"); + } + + @Test + void reportsEveryRequiredClaimTheRequestOmits() { + InspectDocument document = InspectDocument.builder() + .version(Aep.VERSION) + .bindings(new InspectDocument.Bindings(List.of("http"))) + .claims(new InspectDocument.Claims(List.of("contact.email", "person.username"), List.of(), List.of())) + .commands(new InspectDocument.Commands(List.of("enroll", "inspect", "status"), List.of(), Map.of())) + .core(new InspectDocument.Core(Aep.REQUIRED_SIGNING_ALGORITHMS)) + .http(new InspectDocument.Http("/aep", null)) + .identity(new InspectDocument.Identity(List.of("did:web"))) + .service(new InspectDocument.Service(SERVICE_DID)) + .build(); + AepService service = serviceBuilder(document).build(); + + var response = service.enroll( + new EnrollRequest( + AGENT_DID, + ClaimValues.builder() + .contactEmail("agent@example.com") + .build(), + null), + CommandOptions.idempotent("a", "k")) + .toCompletableFuture() + .join(); + + assertEquals(422, response.status()); + assertEquals(List.of("person.username"), response.problem().requirementsPending()); + } + + @Test + void refusesPerCredentialRevokeWhenNoConfigurationIsAdvertised() { + AepService service = serviceBuilder(document(List.of(API_KEY), Map.of())) + .grantType(new GrantTypeDefinition(API_KEY, handler())) + .credentialAuthenticator(API_KEY, handler()) + .build(); + enroll(service); + + assertEquals( + 400, + status(service.revoke( + new RevokeRequest(API_KEY, CREDENTIAL_ID, null), CommandOptions.idempotent("r", "r-key")))); + assertEquals( + 200, + status(service.revoke( + new RevokeRequest(null, null, "true"), CommandOptions.idempotent("r2", "r2-key"))), + "an all-Grant-Types revoke needs no per-credential configuration"); + } + + @Test + void refusesAssertionsWithNoCredentialToVerify() { + AepService service = + serviceBuilder(document(List.of(), Map.of(), List.of())).build(); + + assertEquals(401, status(service.status(CommandOptions.authenticated(null)))); + assertEquals(401, status(service.status(CommandOptions.authenticated("")))); + } + + @Test + void refusesClaimsOutsideTheirValidityWindow() { + assertEquals(401, status(timed(NOW.plusSeconds(120), NOW.plusSeconds(180))), "issued in the future"); + assertEquals(401, status(timed(NOW.minusSeconds(180), NOW.minusSeconds(120))), "already expired"); + assertEquals(200, status(timed(NOW.minusSeconds(1), NOW.plusSeconds(60)))); + } + + @Test + void refusesClaimsBoundToAnotherResource() { + AepService service = serviceBuilder(document(List.of(), Map.of(), List.of(Aep.AUTHENTICATION_METHOD_JWT))) + .build(); + enroll(service); + + assertTrue(authenticate(service, Map.of(AUTHORIZATION, List.of("AEP fresh"))) + .authenticated()); + + AepService misbinding = AepService.builder( + document(List.of(), Map.of(), List.of(Aep.AUTHENTICATION_METHOD_JWT)), + (assertion, context) -> completed(new ClientAssertionClaims( + AGENT_DID, + AGENT_DID, + context.serviceDid(), + context.operation(), + NOW.minusSeconds(1).getEpochSecond(), + NOW.plusSeconds(60).getEpochSecond(), + assertion, + "https://service.example/elsewhere"))) + .clock(CLOCK) + .build(); + enroll(misbinding); + + assertFalse(authenticate(misbinding, Map.of(AUTHORIZATION, List.of("AEP a"))) + .authenticated()); + } + + @Test + void refusesVerifiersThatReturnNoClaimsAtAll() { + AepService service = AepService.builder( + document(List.of(), Map.of(), List.of()), (assertion, context) -> completed(null)) + .clock(CLOCK) + .build(); + + assertEquals(401, status(service.status(CommandOptions.authenticated("a")))); + } + + @Test + void walksPastAuthenticationMethodsThatPresentNothing() { + AepService service = serviceBuilder(document( + List.of(API_KEY), + Map.of(API_KEY, config(List.of(API_KEY_HEADER))), + List.of(Aep.AUTHENTICATION_METHOD_JWT, API_KEY))) + .storedCredentialGrantType(StoredCredentialGrantTypes.apiKey( + config(List.of(API_KEY_HEADER)), + (request, context) -> completed(apiKey()), + ServiceCredentialStore.inMemory())) + .build(); + enroll(service); + grant(service, API_KEY); + + assertEquals( + CREDENTIAL_ID, + authenticate(service, Map.of(API_KEY_HEADER, List.of("api-secret"))) + .principal() + .credentialId(), + "aep-jwt is skipped and the api-key method answers"); + assertEquals("authentication_required", rejected(service, Map.of("X-Other", List.of("api-secret")))); + } + + @Test + void rejectsPrincipalsThatDoNotDescribeTheirOwnCredential() { + assertThrows(CompletionException.class, () -> principal(null, API_KEY, API_KEY)); + assertThrows(CompletionException.class, () -> principal(" ", API_KEY, API_KEY)); + assertThrows(CompletionException.class, () -> principal(AGENT_DID, "other", API_KEY)); + assertThrows(CompletionException.class, () -> principal(AGENT_DID, API_KEY, "other")); + } + + @Test + void requiresEveryAdvertisedMethodToHaveBothHandlers() { + assertThrows( + IllegalArgumentException.class, + () -> serviceBuilder(document(List.of(API_KEY), Map.of(API_KEY, config(List.of())), List.of(API_KEY))) + .grantType(new GrantTypeDefinition(API_KEY, handler())) + .build(), + "a Grant Type without an authenticator cannot authenticate"); + assertThrows( + IllegalArgumentException.class, + () -> serviceBuilder(document(List.of(), Map.of(), List.of())) + .grantType(new GrantTypeDefinition(API_KEY, handler())) + .build(), + "an unadvertised Grant Type has no handler slot"); + } + + @Test + void recognizesOnlyAdvertisedIdentityMethods() { + AepService service = AepService.builder( + document(List.of(), Map.of(), List.of()), + (assertion, context) -> completed(new ClientAssertionClaims( + "did:example:1", + "did:example:1", + context.serviceDid(), + context.operation(), + NOW.minusSeconds(1).getEpochSecond(), + NOW.plusSeconds(60).getEpochSecond(), + assertion, + null))) + .clock(CLOCK) + .build(); + + assertEquals(401, status(service.status(CommandOptions.authenticated("a")))); + } + + @Test + void foldsEveryOptionalRequestMemberIntoTheFingerprint() { + AepService service = storedCredentialService(ServiceCredentialStore.inMemory()); + enroll(service); + + assertEquals( + 200, + status(service.grant( + new GrantRequest(Aep.GRANT_TYPE_OAUTH_BEARER, "laptop", List.of("read"), "opaque"), + CommandOptions.idempotent("g1", "g-key")))); + assertEquals( + 409, + status(service.grant( + new GrantRequest(Aep.GRANT_TYPE_OAUTH_BEARER, "phone", List.of("read"), "opaque"), + CommandOptions.idempotent("g2", "g-key"))), + "the label is material input"); + assertEquals( + 200, + status(service.revoke( + new RevokeRequest(null, null, "true"), CommandOptions.idempotent("r1", "r-key")))); + assertEquals( + 409, + status(service.revoke( + new RevokeRequest(Aep.GRANT_TYPE_OAUTH_BEARER, null, null), + CommandOptions.idempotent("r2", "r-key")))); + } + + @Test + void refusesGeneratedIdentifiersItCannotUse() { + assertThrows(CompletionException.class, () -> generated(null)); + assertThrows(CompletionException.class, () -> generated(" ")); + } + + @Test + void appliesLoopbackRulesToEveryConfiguredUri() { + var strict = serviceBuilder(document(List.of(), Map.of(), List.of())); + + assertThrows( + IllegalArgumentException.class, + () -> strict.inspectUri(URI.create("https://user@service.example/x")) + .build()); + assertThrows( + IllegalArgumentException.class, + () -> strict.inspectUri(URI.create("https://service.example/x#f")) + .build()); + assertThrows( + IllegalArgumentException.class, + () -> strict.inspectUri(URI.create("/relative")).build()); + assertEquals( + "http://localhost:9000/.well-known/aep", + serviceBuilder(document(List.of(), Map.of(), List.of())) + .allowInsecureLoopback(true) + .inspectUri(URI.create("http://localhost:9000/.well-known/aep")) + .build() + .authenticate(new ProtectedResourceRequest( + Map.of(), "GET", URI.create("https://service.example/private"))) + .toCompletableFuture() + .join() + .response() + .headers() + .get("WWW-Authenticate") + .get(0) + .replaceAll(".*inspect=\"([^\"]+)\".*", "$1")); + } + + @Test + void normalizesOptionalDecisionAndPrincipalMembers() { + EnrollmentDecision decision = new EnrollmentDecision(null, false, null, null); + + assertEquals(AgentStatus.ACTIVE, decision.status()); + assertEquals(List.of(), decision.verificationPending()); + assertEquals(List.of(), decision.requirementsPending()); + assertEquals(AgentStatus.ACTIVE, EnrollmentDecision.active().status()); + assertThrows( + IllegalArgumentException.class, + () -> new EnrollmentDecision(AgentStatus.SUSPENDED, false, List.of(), List.of())); + assertEquals( + List.of(), + new AuthenticatedPrincipal(AGENT_DID, API_KEY, null, null, null, AuthenticatedPrincipal.Kind.AEP_JWT) + .scopes()); + assertEquals(List.of(), new ServiceCredentialMatch(AGENT_DID, CREDENTIAL_ID, NOW, API_KEY, null).scopes()); + } + + @Test + void guardsHttpResponseInvariants() { + assertThrows( + IllegalArgumentException.class, () -> new AepHttpResponse(99, Aep.MEDIA_TYPE, Map.of(), new byte[0])); + assertThrows( + IllegalArgumentException.class, () -> new AepHttpResponse(600, Aep.MEDIA_TYPE, Map.of(), new byte[0])); + assertThrows(NullPointerException.class, () -> new AepHttpResponse(200, null, Map.of(), new byte[0])); + assertEquals(0, new AepHttpResponse(200, Aep.MEDIA_TYPE, null, null).body().length); + assertEquals(Map.of(), new AepHttpResponse(200, Aep.MEDIA_TYPE, null, null).headers()); + } + + @Test + void keepsAnIssuedCredentialOutOfAnotherAgentsReach() { + ServiceCredentialStore store = ServiceCredentialStore.inMemory(); + AepService service = storedCredentialService(store); + enroll(service); + grant(service, API_KEY); + + store.revokeGrantType("did:web:someone.example", API_KEY, NOW) + .toCompletableFuture() + .join(); + + assertTrue(authenticate(service, Map.of(API_KEY_HEADER, List.of("api-secret"))) + .authenticated()); + } + + @Test + void refusesExpiredCredentialsWithoutForgettingThem() { + ServiceCredentialStore store = ServiceCredentialStore.inMemory(); + AepService service = serviceBuilder( + document(List.of(API_KEY), Map.of(API_KEY, config(List.of(API_KEY_HEADER))))) + .clock(java.time.Clock.fixed(NOW.plusSeconds(3600), java.time.ZoneOffset.UTC)) + .storedCredentialGrantType(StoredCredentialGrantTypes.apiKey( + config(List.of(API_KEY_HEADER)), (request, context) -> completed(apiKey()), store)) + .build(); + store.save(new ServiceCredentialRecord(AGENT_DID, NOW, apiKey(), CREDENTIAL_ID, Instant.parse(EXPIRY), API_KEY)) + .toCompletableFuture() + .join(); + enroll(service); + + assertEquals( + NOT_RECOGNIZED, + rejected(service, Map.of(API_KEY_HEADER, List.of("api-secret"))), + "the header is still known, so this is a bad credential rather than none"); + } + + @Test + void walksEveryAuthenticationMethodBeforeReportingAnAbsentCredential() { + AepService service = serviceBuilder(document( + List.of(API_KEY), + Map.of(API_KEY, config(List.of(API_KEY_HEADER))), + List.of(Aep.AUTHENTICATION_METHOD_JWT, API_KEY))) + .grantType(new GrantTypeDefinition(API_KEY, handler())) + .credentialAuthenticator(API_KEY, new CredentialAuthenticator() { + @Override + public CompletionStage hasPresentation(CredentialAuthenticationInput input) { + return completed(!input.headers() + .getOrDefault(API_KEY_HEADER, List.of()) + .isEmpty()); + } + + @Override + public CompletionStage> authenticate( + CredentialAuthenticationInput input) { + return completed(Optional.empty()); + } + }) + .build(); + enroll(service); + + assertEquals( + "authentication_required", + rejected(service, Map.of()), + "aep-jwt is skipped and nothing else presented anything"); + assertEquals( + NOT_RECOGNIZED, + rejected(service, Map.of(API_KEY_HEADER, List.of("wrong"))), + "a presentation that no authenticator accepts is a bad credential"); + } + + @Test + void refusesSessionCredentialsForAgentsWithNoEnrollmentAtAll() { + ServiceCredentialStore store = ServiceCredentialStore.inMemory(); + AepService issuing = storedCredentialService(store); + enroll(issuing); + grant(issuing, API_KEY); + AepService fresh = storedCredentialService(store, EnrollmentStore.inMemory()); + + assertEquals(NOT_RECOGNIZED, rejected(fresh, Map.of(API_KEY_HEADER, List.of("api-secret")))); + } + + @Test + void readsIdentityMethodsOnlyFromWellFormedSubjects() { + assertEquals(401, status(subject("notadid"))); + assertEquals(401, status(subject("did:web"))); + assertEquals(401, status(subject("web:example:1"))); + } + + @Test + void refusesInspectUrisThatAreNotAbsoluteOrigins() { + var builder = serviceBuilder(document(List.of(), Map.of(), List.of())).allowInsecureLoopback(true); + + assertThrows( + IllegalArgumentException.class, + () -> builder.inspectUri(URI.create("https:/.well-known/aep")).build(), + "an authority is required"); + assertThrows( + IllegalArgumentException.class, + () -> builder.inspectUri(URI.create("http://user@127.0.0.1/x")).build()); + assertThrows( + IllegalArgumentException.class, + () -> builder.inspectUri(URI.create("http://127.0.0.1/x#f")).build()); + assertThrows( + IllegalArgumentException.class, + () -> builder.inspectUri(URI.create("http://example.com/x")).build(), + "a non-loopback host still needs HTTPS"); + } + + @Test + void refusesMoreThanOneDedicatedAuthorizationField() { + AepService service = storedCredentialService(ServiceCredentialStore.inMemory()); + enroll(service); + grant(service, Aep.GRANT_TYPE_OAUTH_BEARER); + + assertEquals( + NOT_RECOGNIZED, + rejected(service, Map.of(Aep.AUTHORIZATION_HEADER, List.of("Bearer oauth-secret", "Bearer other")))); + } + + @Test + void unwrapsNestedCompletionFailuresFromIdempotentOperations() { + AepService service = serviceBuilder(document(List.of(API_KEY), Map.of(API_KEY, config(List.of())))) + .grantType(new GrantTypeDefinition(API_KEY, new GrantTypeHandler() { + @Override + public CompletionStage grant(GrantRequest request, GrantContext context) { + return java.util.concurrent.CompletableFuture.failedFuture(new CompletionException( + new java.util.concurrent.ExecutionException(new IllegalStateException("boom")))); + } + + @Override + public CompletionStage revoke(RevokeRequest request, RevokeContext context) { + return completed(null); + } + })) + .credentialAuthenticator(API_KEY, handler()) + .build(); + enroll(service); + + CompletionException failure = assertThrows( + CompletionException.class, + () -> service.grant(new GrantRequest(API_KEY, List.of()), CommandOptions.idempotent("g", "g-key")) + .toCompletableFuture() + .join()); + + assertTrue(failure.getCause() instanceof IllegalStateException); + } + + @Test + void storesBasicCredentialsUnderTheAuthorizationSlot() { + ServiceCredentialStore store = ServiceCredentialStore.inMemory(); + AepService service = storedCredentialService(store); + enroll(service); + grant(service, Aep.GRANT_TYPE_BASIC); + + assertTrue(authenticate(service, Map.of(AUTHORIZATION, List.of("Basic " + ServiceFixtures.basicCredentials()))) + .authenticated()); + assertEquals( + "authentication_required", + rejected(service, Map.of("X-Unrelated", List.of("Basic " + ServiceFixtures.basicCredentials())))); + } + + @Test + void refusesCredentialRecordsThatDisagreeWithTheirResponse() { + ServiceCredentialStore store = ServiceCredentialStore.inMemory(); + + assertThrows( + CompletionException.class, + () -> store.save(new ServiceCredentialRecord( + AGENT_DID, + NOW, + apiKey(), + CREDENTIAL_ID, + Instant.parse("2026-09-01T12:20:00Z"), + API_KEY)) + .toCompletableFuture() + .join(), + "the record expiry has to equal the credential expiry"); + assertThrows( + CompletionException.class, + () -> store.save(new ServiceCredentialRecord( + " ", NOW, apiKey(), CREDENTIAL_ID, Instant.parse(EXPIRY), API_KEY)) + .toCompletableFuture() + .join()); + } + + @Test + void acceptsEveryJsonShapeAClaimValueMayTake() { + ClaimValueLimits limits = new ClaimValueLimits(65_536, 32, 8, 64); + + assertTrue(limits.accepts(ClaimValues.builder() + .additional("flag", true) + .additional("count", 3) + .additional("absent", null) + .additional("list", List.of("a", "b")) + .build())); + assertFalse( + new ClaimValueLimits(65_536, 1, 8, 64) + .accepts(ClaimValues.builder() + .additional("a", "1") + .additional("b", "2") + .build()), + "the member budget covers every object in the document"); + } + + private static CompletionStage> subject(String value) { + AepService service = AepService.builder( + document(List.of(), Map.of(), List.of()), + (assertion, context) -> completed(new ClientAssertionClaims( + value, + value, + context.serviceDid(), + context.operation(), + NOW.minusSeconds(1).getEpochSecond(), + NOW.plusSeconds(60).getEpochSecond(), + assertion, + null))) + .clock(CLOCK) + .build(); + return service.status(CommandOptions.authenticated("a")); + } + + private static void generated(String identifier) { + AepService service = serviceBuilder(document(List.of(), Map.of(), List.of())) + .identifierSupplier(() -> identifier) + .build(); + service.enroll(new EnrollRequest(AGENT_DID, null, null), CommandOptions.idempotent("a", "k")) + .toCompletableFuture() + .join(); + } + + private static void principal(String agentDid, String method, String grantType) { + AepService service = serviceBuilder(document(List.of(API_KEY), Map.of(API_KEY, config(List.of())))) + .grantType(new GrantTypeDefinition(API_KEY, handler())) + .credentialAuthenticator(API_KEY, new CredentialAuthenticator() { + @Override + public CompletionStage hasPresentation(CredentialAuthenticationInput input) { + return completed(true); + } + + @Override + public CompletionStage> authenticate( + CredentialAuthenticationInput input) { + return completed(Optional.of(new AuthenticatedPrincipal( + agentDid, + method, + CREDENTIAL_ID, + grantType, + List.of(), + AuthenticatedPrincipal.Kind.SESSION_CREDENTIAL))); + } + }) + .build(); + enroll(service); + authenticate(service, Map.of(API_KEY_HEADER, List.of("api-secret"))); + } + + private static CompletionStage> timed(Instant issuedAt, Instant expiresAt) { + AepService service = AepService.builder( + document(List.of(), Map.of(), List.of()), + (assertion, context) -> completed(new ClientAssertionClaims( + AGENT_DID, + AGENT_DID, + context.serviceDid(), + context.operation(), + issuedAt.getEpochSecond(), + expiresAt.getEpochSecond(), + assertion, + null))) + .clock(CLOCK) + .clockSkew(Duration.ofSeconds(30)) + .build(); + enroll(service); + return service.status(CommandOptions.authenticated("status")); + } + + private static GrantResponses.ApiKey apiKey() { + return new GrantResponses.ApiKey("api-secret", CREDENTIAL_ID, EXPIRY, API_KEY_HEADER, List.of("read")); + } + + private static int status(CompletionStage> stage) { + return stage.toCompletableFuture().join().status(); + } + + private static String rejected(AepService service, Map> headers) { + ProtectedResourceResult result = authenticate(service, headers); + assertFalse(result.authenticated(), "expected a rejection"); + return result.response().problem().code(); + } + + private static Handler handler() { + return new Handler(); + } + + private static final class Handler implements GrantTypeHandler, CredentialAuthenticator { + @Override + public CompletionStage grant(GrantRequest request, GrantContext context) { + return completed(new GrantResult(CREDENTIAL_ID, Map.of("credential_id", CREDENTIAL_ID))); + } + + @Override + public CompletionStage revoke(RevokeRequest request, RevokeContext context) { + return completed(null); + } + + @Override + public CompletionStage hasPresentation(CredentialAuthenticationInput input) { + return completed(false); + } + + @Override + public CompletionStage> authenticate(CredentialAuthenticationInput input) { + return completed(Optional.empty()); + } + } +} diff --git a/aep-servlet/src/main/java/foundation/aep/servlet/AepAuthenticationFilter.java b/aep-servlet/src/main/java/foundation/aep/servlet/AepAuthenticationFilter.java index b660d41..9ff5aeb 100644 --- a/aep-servlet/src/main/java/foundation/aep/servlet/AepAuthenticationFilter.java +++ b/aep-servlet/src/main/java/foundation/aep/servlet/AepAuthenticationFilter.java @@ -44,7 +44,7 @@ public void doFilter(ServletRequest servletRequest, ServletResponse servletRespo request.setAttribute(PRINCIPAL_ATTRIBUTE, result.principal()); chain.doFilter(request, response); } else { - AepServletSupport.write(response, result.response()); + AepServletSupport.write(request, response, result.response()); } } } diff --git a/aep-servlet/src/main/java/foundation/aep/servlet/AepServlet.java b/aep-servlet/src/main/java/foundation/aep/servlet/AepServlet.java index ceed855..eac7ba9 100644 --- a/aep-servlet/src/main/java/foundation/aep/servlet/AepServlet.java +++ b/aep-servlet/src/main/java/foundation/aep/servlet/AepServlet.java @@ -1,6 +1,9 @@ package foundation.aep.servlet; +import foundation.aep.core.Aep; import foundation.aep.core.AepCommand; +import foundation.aep.core.AepJson; +import foundation.aep.core.ProblemDetails; import foundation.aep.service.AepHttpRequest; import foundation.aep.service.AepHttpResponse; import foundation.aep.service.AepServiceHttpHandler; @@ -38,26 +41,51 @@ protected void service(HttpServletRequest request, HttpServletResponse response) String path = request.getRequestURI().substring(request.getContextPath().length()); AepCommand command = commandsByPath.get(path); if (command == null) { - response.sendError(404); + // CORE-ERR-001 makes every AEP endpoint answer with Problem Details. sendError hands the + // request to the container's error page, which is HTML on most of them. + AepServletSupport.write(request, response, notFound()); return; } AepHttpRequest input = AepServletSupport.request( request, AepServletSupport.requestUrl(request), true, serviceHandler.maximumRequestBytes()); CompletionStage stage = serviceHandler.handle(command, input); if (!request.isAsyncSupported()) { - AepServletSupport.write(response, stage.toCompletableFuture().join()); + AepServletSupport.write( + request, response, stage.toCompletableFuture().join()); return; } AsyncContext context = request.startAsync(request, response); stage.whenComplete((result, failure) -> { try { - if (failure == null) AepServletSupport.write((HttpServletResponse) context.getResponse(), result); - else ((HttpServletResponse) context.getResponse()).sendError(500); + if (failure == null) { + AepServletSupport.write(request, (HttpServletResponse) context.getResponse(), result); + } else ((HttpServletResponse) context.getResponse()).sendError(500); } catch (IOException ignored) { // The client disconnected before the asynchronous response completed. + } catch (RuntimeException exception) { + // Anything thrown while writing would otherwise escape into the completion machinery + // and leave the caller holding an open request until the container times it out. + sendErrorQuietly((HttpServletResponse) context.getResponse()); } finally { context.complete(); } }); } + + private static void sendErrorQuietly(HttpServletResponse response) { + try { + if (!response.isCommitted()) response.sendError(500); + } catch (IOException | IllegalStateException ignored) { + // The response is already on its way; the container closes it. + } + } + + private static AepHttpResponse notFound() { + return new AepHttpResponse( + 404, + Aep.PROBLEM_MEDIA_TYPE, + Map.of("Cache-Control", java.util.List.of("no-store")), + AepJson.write(ProblemDetails.of("invalid_request", "AEP route not found", 404)) + .getBytes(java.nio.charset.StandardCharsets.UTF_8)); + } } diff --git a/aep-servlet/src/main/java/foundation/aep/servlet/AepServletSupport.java b/aep-servlet/src/main/java/foundation/aep/servlet/AepServletSupport.java index 8269bd3..5125e7b 100644 --- a/aep-servlet/src/main/java/foundation/aep/servlet/AepServletSupport.java +++ b/aep-servlet/src/main/java/foundation/aep/servlet/AepServletSupport.java @@ -13,6 +13,8 @@ import java.util.Map; public final class AepServletSupport { + private static final int NOT_MODIFIED = 304; + private AepServletSupport() {} public static AepHttpRequest request( @@ -32,11 +34,26 @@ public static AepHttpRequest request( } public static void write(HttpServletResponse response, AepHttpResponse value) throws IOException { + write(null, response, value); + } + + // RFC 9110 section 9.3.2: a HEAD response repeats the header fields a GET would have sent, + // Content-Length included, and carries no content. HttpServlet normally arranges that by routing + // HEAD through doHead and a body-suppressing wrapper, but AepServlet overrides service(), so that + // never happens and the suppression has to be explicit here. + // + // RFC 9110 section 15.4.5: a 304 carries the validators a GET would have produced and no content. + // Content-Length is not among the fields it must repeat, and sending zero would describe the + // selected representation as empty to every cache on the path. + public static void write(HttpServletRequest request, HttpServletResponse response, AepHttpResponse value) + throws IOException { value.headers().forEach((name, values) -> values.forEach(header -> response.addHeader(name, header))); response.setStatus(value.status()); response.setContentType(value.contentType()); byte[] body = value.body(); + if (value.status() == NOT_MODIFIED) return; response.setContentLength(body.length); + if (request != null && "HEAD".equalsIgnoreCase(request.getMethod())) return; response.getOutputStream().write(body); } diff --git a/aep-servlet/src/test/java/foundation/aep/servlet/AepServletConformanceTest.java b/aep-servlet/src/test/java/foundation/aep/servlet/AepServletConformanceTest.java new file mode 100644 index 0000000..518f9a5 --- /dev/null +++ b/aep-servlet/src/test/java/foundation/aep/servlet/AepServletConformanceTest.java @@ -0,0 +1,432 @@ +package foundation.aep.servlet; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import foundation.aep.core.Aep; +import foundation.aep.core.AepCommand; +import foundation.aep.core.AgentStatus; +import foundation.aep.core.ClientAssertionClaims; +import foundation.aep.core.InspectDocument; +import foundation.aep.service.AepHttpRequest; +import foundation.aep.service.AepHttpResponse; +import foundation.aep.service.AepService; +import foundation.aep.service.AepServiceHttpHandler; +import foundation.aep.service.AuthenticatedPrincipal; +import foundation.aep.service.EnrollmentDecision; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockFilterChain; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +/** Checks of the Jakarta Servlet binding. */ +final class AepServletConformanceTest { + private static final Instant NOW = Instant.parse("2026-09-01T12:00:00Z"); + private static final String AGENT_DID = "did:web:agent.example"; + private static final String ORIGIN = "https://service.example"; + private static final String ENROLL_BODY = "{\"agent_did\":\"did:web:agent.example\"}"; + private static final String CONTENT_LENGTH = "Content-Length"; + + /** RFC 9110 section 9.3.2: HEAD keeps a GET's headers, Content-Length included, and no content. */ + @Test + void answersHeadWithoutContentButWithAnAccurateLength() throws Exception { + MockHttpServletResponse get = serve("GET", Aep.WELL_KNOWN_PATH); + MockHttpServletResponse head = serve("HEAD", Aep.WELL_KNOWN_PATH); + + assertEquals(200, head.getStatus()); + assertEquals("", head.getContentAsString()); + assertEquals(get.getContentAsString().length(), head.getContentLength()); + assertEquals(get.getHeader("ETag"), head.getHeader("ETag")); + assertEquals(get.getHeader("Cache-Control"), head.getHeader("Cache-Control")); + } + + @Test + void rejectsProtectedHeadRequestsWithoutWritingContent() throws Exception { + AepAuthenticationFilter filter = + new AepAuthenticationFilter(new AepServiceHttpHandler(service()), URI.create(ORIGIN)); + MockHttpServletResponse get = new MockHttpServletResponse(); + MockHttpServletResponse head = new MockHttpServletResponse(); + filter.doFilter(new MockHttpServletRequest("GET", "/orders"), get, new MockFilterChain()); + filter.doFilter(new MockHttpServletRequest("HEAD", "/orders"), head, new MockFilterChain()); + assertEquals(401, head.getStatus()); + assertEquals(0, head.getContentAsByteArray().length); + assertEquals(get.getContentAsByteArray().length, head.getContentLength()); + assertEquals(get.getHeader("WWW-Authenticate"), head.getHeader("WWW-Authenticate")); + assertEquals(get.getContentType(), head.getContentType()); + } + + /** RFC 9110 section 15.4.5: a 304 repeats the validators and describes no representation. */ + @Test + void answersNotModifiedWithNoContentAndNoLength() throws Exception { + AepServiceHttpHandler handler = new AepServiceHttpHandler(service()); + MockHttpServletRequest request = new MockHttpServletRequest("GET", Aep.WELL_KNOWN_PATH); + request.setRequestURI(Aep.WELL_KNOWN_PATH); + request.addHeader("If-None-Match", handler.inspectEntityTag()); + MockHttpServletResponse response = new MockHttpServletResponse(); + + new AepServlet(handler).service(request, response); + + assertEquals(304, response.getStatus()); + assertEquals("", response.getContentAsString()); + assertFalse(response.containsHeader(CONTENT_LENGTH), "a 304 describes no representation"); + assertEquals(handler.inspectEntityTag(), response.getHeader("ETag")); + } + + /** CORE-ERR-001: an unrouted path answers as Problem Details, not as a container error page. */ + @Test + void refusesUnroutedPathsWithAProblemDocument() throws Exception { + MockHttpServletResponse response = serve("GET", "/aep/unknown"); + + assertEquals(404, response.getStatus()); + assertEquals(Aep.PROBLEM_MEDIA_TYPE, response.getContentType()); + assertTrue(response.getContentAsString().contains("\"urn:aep:error:invalid_request\"")); + assertEquals("no-store", response.getHeader("Cache-Control")); + } + + /** The command path carries its body, media type, and idempotency key through the container. */ + @Test + void servesCommandsSynchronouslyAndAsynchronously() throws Exception { + assertEquals(200, enroll(false).getStatus()); + MockHttpServletResponse asynchronous = enroll(true); + assertEquals(200, asynchronous.getStatus()); + assertTrue(asynchronous.getContentAsString().contains("\"status\":\"active\"")); + assertEquals("no-store", asynchronous.getHeader("Cache-Control")); + } + + /** A failure after the response is handed off still completes the request rather than hanging. */ + @Test + void completesAsynchronousRequestsThatFail() throws Exception { + AepService failing = AepService.builder( + document(), (assertion, context) -> CompletableFuture.failedFuture(new IllegalStateException())) + .clock(Clock.fixed(NOW, ZoneOffset.UTC)) + .build(); + MockHttpServletRequest request = new MockHttpServletRequest("GET", Aep.WELL_KNOWN_PATH); + request.setRequestURI("/aep/status"); + request.setAsyncSupported(true); + MockHttpServletResponse response = new MockHttpServletResponse(); + + new AepServlet(new AepServiceHttpHandler(failing)).service(request, response); + + assertEquals(401, response.getStatus(), "a verifier failure is an unrecognized credential, not a crash"); + } + + /** A protected route reaches the application only once a credential has been accepted. */ + @Test + void protectsApplicationRoutes() throws Exception { + AepServiceHttpHandler handler = new AepServiceHttpHandler(service()); + enroll(handler); + AepAuthenticationFilter filter = new AepAuthenticationFilter(handler, URI.create(ORIGIN)); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/orders"); + request.setRequestURI("/orders"); + request.setQueryString("page=2"); + request.addHeader("Authorization", "AEP assertion"); + MockHttpServletResponse response = new MockHttpServletResponse(); + MockFilterChain chain = new MockFilterChain(); + + filter.doFilter(request, response, chain); + + assertEquals(200, response.getStatus()); + assertEquals( + AGENT_DID, + ((AuthenticatedPrincipal) request.getAttribute(AepAuthenticationFilter.PRINCIPAL_ATTRIBUTE)) + .agentDid()); + } + + @Test + void refusesNonHttpExchangesAndUnusableConfiguration() { + AepServiceHttpHandler handler = new AepServiceHttpHandler(service()); + AepAuthenticationFilter filter = new AepAuthenticationFilter(handler, URI.create(ORIGIN)); + + assertThrows(NullPointerException.class, () -> new AepServlet(null)); + assertThrows(NullPointerException.class, () -> new AepAuthenticationFilter(null, URI.create(ORIGIN))); + assertThrows(IllegalArgumentException.class, () -> new AepAuthenticationFilter(handler, null)); + assertThrows( + IllegalArgumentException.class, + () -> new AepAuthenticationFilter(handler, URI.create("https://service.example/base"))); + assertThrows( + ServletException.class, + () -> filter.doFilter( + stub(jakarta.servlet.ServletRequest.class), + stub(jakarta.servlet.ServletResponse.class), + new MockFilterChain()), + "AEP authentication is defined over HTTP"); + } + + @Test + void collectsHeadersAndBoundsTheBody() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/aep/enroll"); + request.setRequestURI("/aep/enroll"); + request.addHeader("X-Trace", "one"); + request.addHeader("X-Trace", "two"); + request.setContent("abcdef".getBytes(StandardCharsets.UTF_8)); + + AepHttpRequest full = AepServletSupport.request(request, URI.create(ORIGIN + "/aep/enroll"), true, 64); + AepHttpRequest none = AepServletSupport.request(request, URI.create(ORIGIN + "/aep/enroll"), false, 64); + + assertEquals(List.of("one", "two"), full.headerValues("x-trace")); + assertEquals("abcdef", new String(full.body(), StandardCharsets.UTF_8)); + assertEquals(0, none.body().length); + assertEquals("POST", full.method()); + } + + @Test + void reconstructsTheRequestUrlWithAndWithoutAQuery() { + MockHttpServletRequest plain = new MockHttpServletRequest("GET", "/aep/status"); + plain.setRequestURI("/aep/status"); + MockHttpServletRequest queried = new MockHttpServletRequest("GET", "/aep/status"); + queried.setRequestURI("/aep/status"); + queried.setQueryString("x=1"); + + assertEquals("/aep/status", AepServletSupport.requestUrl(plain).getPath()); + assertEquals("x=1", AepServletSupport.requestUrl(queried).getQuery()); + } + + @Test + void writesResponsesWithoutARequestToCompareAgainst() throws Exception { + MockHttpServletResponse response = new MockHttpServletResponse(); + + AepServletSupport.write( + response, + new AepHttpResponse( + 200, + Aep.MEDIA_TYPE, + Map.of("X-Trace", List.of("one", "two")), + "body".getBytes(StandardCharsets.UTF_8))); + + assertEquals("body", response.getContentAsString()); + assertEquals(List.of("one", "two"), response.getHeaders("X-Trace")); + assertEquals(4, response.getContentLength()); + } + + /** A failed command stage still completes the exchange instead of leaving it open. */ + @Test + void reportsAsynchronousFailuresAsServerErrors() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/aep/status"); + request.setRequestURI("/aep/status"); + request.addHeader("Authorization", "AEP assertion"); + request.setAsyncSupported(true); + MockHttpServletResponse response = new MockHttpServletResponse(); + + new AepServlet(new AepServiceHttpHandler(throwingService())).service(request, response); + + assertEquals(500, response.getStatus()); + } + + /** A failure while writing completes the exchange rather than escaping into the async machinery. */ + @Test + void reportsFailuresRaisedWhileWritingTheResponse() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("GET", Aep.WELL_KNOWN_PATH); + request.setRequestURI(Aep.WELL_KNOWN_PATH); + request.setAsyncSupported(true); + MockHttpServletResponse exploding = new MockHttpServletResponse() { + @Override + public void setContentType(String contentType) { + throw new IllegalStateException("boom"); + } + }; + + new AepServlet(new AepServiceHttpHandler(service())).service(request, exploding); + + assertEquals(500, exploding.getStatus()); + } + + /** A response that can no longer take an error is left to the container. */ + @Test + void leavesACommittedResponseAlone() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("GET", Aep.WELL_KNOWN_PATH); + request.setRequestURI(Aep.WELL_KNOWN_PATH); + request.setAsyncSupported(true); + MockHttpServletResponse stubborn = new MockHttpServletResponse() { + @Override + public void setContentType(String contentType) { + throw new IllegalStateException("boom"); + } + + @Override + public void sendError(int status) { + throw new IllegalStateException("already committed"); + } + }; + + new AepServlet(new AepServiceHttpHandler(service())).service(request, stubborn); + + assertEquals(200, stubborn.getStatus(), "nothing was written, and nothing threw out of the servlet"); + } + + /** The servlet holds a handler, so a deserialized instance would hold nothing at all. */ + @Test + void refusesToBeDeserialized() throws Exception { + java.lang.reflect.Method readObject = + AepServlet.class.getDeclaredMethod("readObject", java.io.ObjectInputStream.class); + readObject.setAccessible(true); + + var failure = assertThrows( + java.lang.reflect.InvocationTargetException.class, + () -> readObject.invoke(new AepServlet(new AepServiceHttpHandler(service())), (Object) null)); + + assertTrue(failure.getCause() instanceof java.io.InvalidObjectException); + } + + /** A container that reports no header names at all still produces a usable request. */ + @Test + void toleratesAContainerThatEnumeratesNoHeaders() throws Exception { + HttpServletRequest bare = (HttpServletRequest) java.lang.reflect.Proxy.newProxyInstance( + HttpServletRequest.class.getClassLoader(), + new Class[] {HttpServletRequest.class}, + (proxy, method, arguments) -> switch (method.getName()) { + case "getHeaderNames", "getHeaders" -> null; + case "getMethod" -> "GET"; + default -> throw new UnsupportedOperationException(method.getName()); + }); + + AepHttpRequest request = AepServletSupport.request(bare, URI.create(ORIGIN + "/x"), false, 64); + + assertEquals(Map.of(), request.headers()); + assertEquals("GET", request.method()); + } + + /** AEP authentication is defined over HTTP, so a half-HTTP exchange is refused too. */ + @Test + void refusesAHalfHttpExchange() { + AepAuthenticationFilter filter = + new AepAuthenticationFilter(new AepServiceHttpHandler(service()), URI.create(ORIGIN)); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/orders"); + request.setRequestURI("/orders"); + + assertThrows( + ServletException.class, + () -> filter.doFilter(request, stub(jakarta.servlet.ServletResponse.class), new MockFilterChain())); + } + + private static AepService throwingService() { + foundation.aep.service.EnrollmentStore failing = new foundation.aep.service.EnrollmentStore() { + @Override + public CompletableFuture> find( + String agentDid) { + return CompletableFuture.failedFuture(new IllegalStateException("boom")); + } + + @Override + public CompletableFuture findOrCreate( + String agentDid, + java.util.function.Supplier< + java.util.concurrent.CompletionStage> + create) { + return CompletableFuture.failedFuture(new IllegalStateException("boom")); + } + + @Override + public CompletableFuture save( + foundation.aep.service.EnrollmentRecord record) { + return CompletableFuture.failedFuture(new IllegalStateException("boom")); + } + }; + return AepService.builder( + document(), + (assertion, context) -> CompletableFuture.completedFuture(new ClientAssertionClaims( + AGENT_DID, + AGENT_DID, + context.serviceDid(), + context.operation(), + NOW.minusSeconds(1).getEpochSecond(), + NOW.plusSeconds(60).getEpochSecond(), + assertion, + context.resource()))) + .clock(Clock.fixed(NOW, ZoneOffset.UTC)) + .enrollmentStore(failing) + .build(); + } + + // A bare ServletRequest/ServletResponse that is not the HTTP subtype; the interfaces are wide + // and every method is unreachable for this test, so a proxy says it more plainly than a stub. + @SuppressWarnings("unchecked") + private static T stub(Class type) { + return (T) java.lang.reflect.Proxy.newProxyInstance( + type.getClassLoader(), new Class[] {type}, (proxy, method, arguments) -> { + throw new UnsupportedOperationException(method.getName()); + }); + } + + private static MockHttpServletResponse enroll(boolean asynchronous) throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/aep/enroll"); + request.setRequestURI("/aep/enroll"); + request.addHeader("Content-Type", Aep.MEDIA_TYPE); + request.addHeader("Authorization", "AEP assertion-" + asynchronous); + request.addHeader("Idempotency-Key", "key-" + asynchronous); + request.setContent(ENROLL_BODY.getBytes(StandardCharsets.UTF_8)); + request.setAsyncSupported(asynchronous); + MockHttpServletResponse response = new MockHttpServletResponse(); + new AepServlet(new AepServiceHttpHandler(service())).service(request, response); + return response; + } + + private static void enroll(AepServiceHttpHandler handler) { + handler.handle( + AepCommand.ENROLL, + new AepHttpRequest( + "POST", + URI.create(ORIGIN + "/aep/enroll"), + Map.of( + "Content-Type", + List.of(Aep.MEDIA_TYPE), + "Authorization", + List.of("AEP enrollment"), + "Idempotency-Key", + List.of("enroll-key")), + ENROLL_BODY.getBytes(StandardCharsets.UTF_8))) + .toCompletableFuture() + .join(); + } + + private static MockHttpServletResponse serve(String method, String path) throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(method, path); + request.setRequestURI(path); + MockHttpServletResponse response = new MockHttpServletResponse(); + new AepServlet(new AepServiceHttpHandler(service())).service(request, response); + return response; + } + + private static InspectDocument document() { + return InspectDocument.builder() + .version(Aep.VERSION) + .authentication(new InspectDocument.Authentication(List.of(Aep.AUTHENTICATION_METHOD_JWT))) + .bindings(new InspectDocument.Bindings(List.of("http"))) + .claims(new InspectDocument.Claims(List.of(), List.of(), List.of())) + .commands(new InspectDocument.Commands(List.of("enroll", "inspect", "status"), List.of(), Map.of())) + .core(new InspectDocument.Core(Aep.REQUIRED_SIGNING_ALGORITHMS)) + .http(new InspectDocument.Http("/aep", null)) + .identity(new InspectDocument.Identity(List.of("did:web"))) + .service(new InspectDocument.Service("did:web:service.example")) + .build(); + } + + private static AepService service() { + return AepService.builder( + document(), + (assertion, context) -> CompletableFuture.completedFuture(new ClientAssertionClaims( + AGENT_DID, + AGENT_DID, + context.serviceDid(), + context.operation(), + NOW.minusSeconds(1).getEpochSecond(), + NOW.plusSeconds(60).getEpochSecond(), + assertion, + context.resource()))) + .clock(Clock.fixed(NOW, ZoneOffset.UTC)) + .enrollmentPolicy((request, now) -> CompletableFuture.completedFuture( + new EnrollmentDecision(AgentStatus.ACTIVE, false, List.of(), List.of()))) + .build(); + } +} diff --git a/aep-spring-webmvc/README.md b/aep-spring-webmvc/README.md index c8a273c..240f9f3 100644 --- a/aep-spring-webmvc/README.md +++ b/aep-spring-webmvc/README.md @@ -38,3 +38,14 @@ RouterFunction orders = RouterFunctions.route() After successful authentication, the filter stores an `AuthenticatedPrincipal` under `AepSpringWebMvc.PRINCIPAL_ATTRIBUTE` in the `ServerRequest` attributes. + +The authentication filter waits on the request thread, including when an asynchronous store +or verifier is used. This keeps the application handler on the thread where Spring and the +application established their request context. A request thread remains occupied until +authentication completes; the application handler can still return an asynchronous response. +AEP command routes complete their responses asynchronously. + +Applications that need nonblocking authentication can build their own integration around +`AepServiceHttpHandler.authenticate()`, which returns a `CompletionStage`. That integration +must manage Spring's request context and dispatch lifecycle; moving the filter to an executor +does not preserve the request thread's context. diff --git a/aep-spring-webmvc/src/main/java/foundation/aep/spring/webmvc/AepSpringWebMvc.java b/aep-spring-webmvc/src/main/java/foundation/aep/spring/webmvc/AepSpringWebMvc.java index b70c1b7..1703284 100644 --- a/aep-spring-webmvc/src/main/java/foundation/aep/spring/webmvc/AepSpringWebMvc.java +++ b/aep-spring-webmvc/src/main/java/foundation/aep/spring/webmvc/AepSpringWebMvc.java @@ -9,9 +9,8 @@ import java.io.IOException; import java.net.URI; import java.util.Objects; -import java.util.concurrent.CompletionException; import org.springframework.web.servlet.function.HandlerFilterFunction; -import org.springframework.web.servlet.function.RequestPredicates; +import org.springframework.web.servlet.function.RequestPredicate; import org.springframework.web.servlet.function.RouterFunction; import org.springframework.web.servlet.function.RouterFunctions; import org.springframework.web.servlet.function.ServerRequest; @@ -19,15 +18,16 @@ public final class AepSpringWebMvc { public static final String PRINCIPAL_ATTRIBUTE = "foundation.aep.principal"; + private static final int NOT_MODIFIED = 304; private AepSpringWebMvc() {} public static RouterFunction routes(AepServiceHttpHandler handler) { Objects.requireNonNull(handler, "handler"); RouterFunctions.Builder routes = RouterFunctions.route(); + // Advertised command paths are literal encoded paths, not Spring path patterns. handler.routes() - .forEach((command, path) -> - routes.route(RequestPredicates.path(path), request -> handle(handler, command, request))); + .forEach((command, path) -> routes.route(exactly(path), request -> handle(handler, command, request))); return routes.build(); } @@ -35,22 +35,34 @@ public static HandlerFilterFunction protect( AepServiceHttpHandler handler, URI publicOrigin) { Objects.requireNonNull(handler, "handler"); AepHttpRequest.publicUrl(publicOrigin, "/", null); - return (request, next) -> ServerResponse.async(handler.authenticate(request( - handler, - request, - AepHttpRequest.publicUrl( - publicOrigin, - request.uri().getRawPath(), - request.uri().getRawQuery()), - false)) - .thenApply(result -> continueOrReject(result, request, next)) - .toCompletableFuture()); + return (request, next) -> { + // Application handlers must retain the request thread's Spring and application context. + AepHttpAuthenticationResult result = handler.authenticate(request( + handler, + request, + AepHttpRequest.publicUrl( + publicOrigin, + request.uri().getRawPath(), + request.uri().getRawQuery()), + false)) + .toCompletableFuture() + .join(); + if (!result.authenticated()) return rejection(result, request); + request.attributes().put(PRINCIPAL_ATTRIBUTE, result.principal()); + return next.handle(request); + }; + } + + private static RequestPredicate exactly(String path) { + return request -> + path.equals(request.requestPath().pathWithinApplication().value()); } private static ServerResponse handle(AepServiceHttpHandler handler, AepCommand command, ServerRequest request) throws IOException { + boolean head = "HEAD".equalsIgnoreCase(request.method().name()); return ServerResponse.async(handler.handle(command, request(handler, request, request.uri(), true)) - .thenApply(AepSpringWebMvc::response) + .thenApply(response -> response(response, head)) .toCompletableFuture()); } @@ -61,23 +73,23 @@ private static AepHttpRequest request( request.servletRequest(), publicUrl, includeBody, handler.maximumRequestBytes()); } - private static ServerResponse continueOrReject( - AepHttpAuthenticationResult result, - ServerRequest request, - org.springframework.web.servlet.function.HandlerFunction next) { - if (!result.authenticated()) return response(result.response()); - request.attributes().put(PRINCIPAL_ATTRIBUTE, result.principal()); - try { - return next.handle(request); - } catch (Exception exception) { - throw new CompletionException(exception); - } + private static ServerResponse rejection(AepHttpAuthenticationResult result, ServerRequest request) { + return response( + result.response(), "HEAD".equalsIgnoreCase(request.method().name())); } - private static ServerResponse response(AepHttpResponse response) { + // RFC 9110 sections 9.3.2 and 15.4.5: a HEAD response and a 304 repeat the header fields a GET + // would have sent and carry no content. Whether the servlet stack wraps a HEAD response to + // suppress its body depends on the framework and the container, so the adapter does it itself. + private static ServerResponse response(AepHttpResponse response, boolean head) { ServerResponse.BodyBuilder builder = ServerResponse.status(response.status()) .contentType(org.springframework.http.MediaType.parseMediaType(response.contentType())); response.headers().forEach((name, values) -> builder.header(name, values.toArray(String[]::new))); + if (response.status() == NOT_MODIFIED) return builder.build(); + if (head) { + builder.contentLength(response.body().length); + return builder.build(); + } return builder.body(response.body()); } } diff --git a/aep-spring-webmvc/src/test/java/foundation/aep/spring/webmvc/AepSpringWebMvcConformanceTest.java b/aep-spring-webmvc/src/test/java/foundation/aep/spring/webmvc/AepSpringWebMvcConformanceTest.java new file mode 100644 index 0000000..1f2aa8b --- /dev/null +++ b/aep-spring-webmvc/src/test/java/foundation/aep/spring/webmvc/AepSpringWebMvcConformanceTest.java @@ -0,0 +1,418 @@ +package foundation.aep.spring.webmvc; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.head; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import foundation.aep.core.Aep; +import foundation.aep.core.AepCommand; +import foundation.aep.core.AgentStatus; +import foundation.aep.core.ClientAssertionClaims; +import foundation.aep.core.InspectDocument; +import foundation.aep.service.AepHttpRequest; +import foundation.aep.service.AepService; +import foundation.aep.service.AepServiceHttpHandler; +import foundation.aep.service.EnrollmentDecision; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.servlet.function.RouterFunctions; +import org.springframework.web.servlet.function.ServerResponse; + +/** Checks of the Spring Web MVC functional binding. */ +final class AepSpringWebMvcConformanceTest { + private static final Instant NOW = Instant.parse("2026-09-01T12:00:00Z"); + private static final String AGENT_DID = "did:web:agent.example"; + private static final String ORIGIN = "https://service.example"; + private static final String ENROLL_BODY = "{\"agent_did\":\"did:web:agent.example\"}"; + + /** RFC 9110 section 9.3.2: HEAD repeats a GET's headers and carries no content. */ + @Test + void answersHeadWithoutContent() throws Exception { + MockMvc client = routed(service("/aep")); + + client.perform(head(Aep.WELL_KNOWN_PATH)) + .andExpect(status().isOk()) + .andExpect(content().string("")) + .andExpect(header().exists("ETag")) + .andExpect(header().string("Cache-Control", "public, max-age=300")); + client.perform(get(Aep.WELL_KNOWN_PATH)) + .andExpect(status().isOk()) + .andExpect(content().string(org.hamcrest.Matchers.containsString("\"aep_version\""))); + } + + /** RFC 9110 section 15.4.5: a matching validator revalidates to 304 with no content. */ + @Test + void revalidatesThroughNotModified() throws Exception { + AepServiceHttpHandler handler = new AepServiceHttpHandler(service("/aep")); + MockMvc client = + MockMvcBuilders.routerFunctions(AepSpringWebMvc.routes(handler)).build(); + + client.perform(get(Aep.WELL_KNOWN_PATH).header("If-None-Match", handler.inspectEntityTag())) + .andExpect(status().isNotModified()) + .andExpect(content().string("")) + .andExpect(header().string("ETag", handler.inspectEntityTag())); + } + + /** + * RequestPredicates.path reads its argument as a PathPattern. An endpoint_base may legally + * contain an asterisk, which would otherwise have routed AEP commands at unadvertised paths. + */ + @Test + void routesOnlyTheExactAdvertisedPath() throws Exception { + MockMvc client = routed(service("/aep*")); + + client.perform(get("/aep*/status")).andExpect(status().isUnauthorized()); + client.perform(get("/aepXYZ/status")).andExpect(status().isNotFound()); + client.perform(get("/aep/status")).andExpect(status().isNotFound()); + } + + @Test + void routesEncodedAdvertisedPathsLiterally() throws Exception { + for (String base : List.of("/aep%20v1", "/aep%3Av1", "/aep%2Fv1")) { + MockMvc client = routed(service(base)); + client.perform(get(URI.create(base + "/status"))).andExpect(status().isUnauthorized()); + client.perform(get(URI.create(base + "/status/extra"))).andExpect(status().isNotFound()); + client.perform(get("/aep/status")).andExpect(status().isNotFound()); + } + } + + /** The command path carries its body, media type, and idempotency key through Spring. */ + @Test + void servesAnEnrollCommand() throws Exception { + routed(service("/aep")) + .perform(post("/aep/enroll") + .contentType(Aep.MEDIA_TYPE) + .header("Authorization", "AEP assertion") + .header("Idempotency-Key", "key") + .content(ENROLL_BODY)) + .andExpect(status().isOk()) + .andExpect(content().string(org.hamcrest.Matchers.containsString("\"status\":\"active\""))) + .andExpect(header().string("Cache-Control", "no-store")); + } + + /** A protected route reaches the application only once a credential has been accepted. */ + @Test + void protectsApplicationRoutes() throws Exception { + AepServiceHttpHandler handler = new AepServiceHttpHandler(service("/aep")); + enroll(handler); + MockMvc client = MockMvcBuilders.routerFunctions(RouterFunctions.route() + .GET( + "/orders", + request -> ServerResponse.ok() + .body(String.valueOf( + request.attributes().get(AepSpringWebMvc.PRINCIPAL_ATTRIBUTE)))) + .filter(AepSpringWebMvc.protect(handler, URI.create(ORIGIN))) + .build()) + .build(); + + client.perform(get("/orders")) + .andExpect(status().isUnauthorized()) + .andExpect(header().exists("WWW-Authenticate")); + client.perform(get("/orders").header("Authorization", "AEP assertion")) + .andExpect(status().isOk()) + .andExpect(content().string(org.hamcrest.Matchers.containsString(AGENT_DID))); + } + + /** A downstream failure is surfaced rather than swallowed into an empty response. */ + @Test + void surfacesDownstreamFailuresFromProtectedRoutes() throws Exception { + AepServiceHttpHandler handler = new AepServiceHttpHandler(service("/aep")); + enroll(handler); + MockMvc client = MockMvcBuilders.routerFunctions(RouterFunctions.route() + .GET("/orders", request -> { + throw new IllegalStateException("downstream"); + }) + .filter(AepSpringWebMvc.protect(handler, URI.create(ORIGIN))) + .build()) + .build(); + + var thrown = Assertions.assertThrows( + Exception.class, + () -> client.perform(get("/orders").header("Authorization", "AEP assertion")) + .andReturn()); + + Assertions.assertTrue(rootCause(thrown) instanceof IllegalStateException); + } + + @Test + void refusesUnusableConfiguration() { + AepServiceHttpHandler handler = new AepServiceHttpHandler(service("/aep")); + + Assertions.assertThrows(NullPointerException.class, () -> AepSpringWebMvc.routes(null)); + Assertions.assertThrows(NullPointerException.class, () -> AepSpringWebMvc.protect(null, URI.create(ORIGIN))); + Assertions.assertThrows(IllegalArgumentException.class, () -> AepSpringWebMvc.protect(handler, null)); + Assertions.assertThrows( + IllegalArgumentException.class, + () -> AepSpringWebMvc.protect(handler, URI.create("https://service.example/base"))); + } + + @Test + void keepsRequestContextWhileAwaitingAuthentication() throws Exception { + Gate gate = new Gate(); + AepServiceHttpHandler handler = new AepServiceHttpHandler(gatedService(gate)); + enroll(handler); + gate.close(); + Thread requestThread = Thread.currentThread(); + MockMvc client = MockMvcBuilders.routerFunctions(RouterFunctions.route() + .GET("/orders", request -> { + Assertions.assertSame(requestThread, Thread.currentThread()); + Assertions.assertNotNull( + org.springframework.web.context.request.RequestContextHolder + .getRequestAttributes()); + var principal = (foundation.aep.service.AuthenticatedPrincipal) + request.attributes().get(AepSpringWebMvc.PRINCIPAL_ATTRIBUTE); + return ServerResponse.ok().body(principal.agentDid()); + }) + .filter(AepSpringWebMvc.protect(handler, URI.create(ORIGIN))) + .build()) + .build(); + CompletableFuture completion = gate.releaseAfterLookup(); + var result = client.perform(get("/orders").header("Authorization", "AEP assertion")) + .andExpect(status().isOk()) + .andExpect(content().string(AGENT_DID)) + .andReturn(); + completion.get(5, java.util.concurrent.TimeUnit.SECONDS); + Assertions.assertFalse(result.getRequest().isAsyncStarted()); + } + + @Test + void rejectsAfterAwaitingAuthentication() throws Exception { + Gate gate = new Gate(); + gate.close(); + AepServiceHttpHandler handler = new AepServiceHttpHandler(gatedService(gate)); + MockMvc client = MockMvcBuilders.routerFunctions(RouterFunctions.route() + .GET("/orders", request -> { + Assertions.fail("An unrecognized Agent must not reach the application."); + return ServerResponse.ok().build(); + }) + .filter(AepSpringWebMvc.protect(handler, URI.create(ORIGIN))) + .build()) + .build(); + CompletableFuture completion = gate.releaseAfterLookup(); + client.perform(get("/orders").header("Authorization", "AEP assertion")) + .andExpect(status().isUnauthorized()) + .andExpect(header().exists("WWW-Authenticate")); + completion.get(5, java.util.concurrent.TimeUnit.SECONDS); + } + + @Test + void permitsAsynchronousApplicationResponsesAfterAuthentication() throws Exception { + Gate gate = new Gate(); + AepServiceHttpHandler handler = new AepServiceHttpHandler(gatedService(gate)); + enroll(handler); + gate.close(); + CompletableFuture application = new CompletableFuture<>(); + MockMvc client = MockMvcBuilders.routerFunctions(RouterFunctions.route() + .GET("/orders", request -> { + Assertions.assertNotNull( + org.springframework.web.context.request.RequestContextHolder + .getRequestAttributes()); + return ServerResponse.async(application); + }) + .filter(AepSpringWebMvc.protect(handler, URI.create(ORIGIN))) + .build()) + .build(); + CompletableFuture completion = gate.releaseAfterLookup(); + var initial = client.perform(get("/orders").header("Authorization", "AEP assertion")) + .andReturn(); + completion.get(5, java.util.concurrent.TimeUnit.SECONDS); + Assertions.assertTrue(initial.getRequest().isAsyncStarted()); + application.complete(ServerResponse.ok().body("application result")); + client.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.asyncDispatch(initial)) + .andExpect(status().isOk()) + .andExpect(content().string("application result")); + } + + @Test + void surfacesPendingAuthenticationFailuresWithoutInvokingTheApplication() throws Exception { + Gate gate = new Gate(); + gate.close(); + AepServiceHttpHandler handler = new AepServiceHttpHandler(gatedService(gate)); + MockMvc client = MockMvcBuilders.routerFunctions(RouterFunctions.route() + .GET("/orders", request -> { + Assertions.fail("Failed authentication must not reach the application."); + return ServerResponse.ok().build(); + }) + .filter(AepSpringWebMvc.protect(handler, URI.create(ORIGIN))) + .build()) + .build(); + IllegalStateException failure = new IllegalStateException("store unavailable"); + CompletableFuture completion = gate.releaseAfterLookup(failure); + Exception thrown = Assertions.assertThrows( + Exception.class, () -> client.perform(get("/orders").header("Authorization", "AEP assertion"))); + completion.get(5, java.util.concurrent.TimeUnit.SECONDS); + Assertions.assertSame(failure, rootCause(thrown)); + } + + /** Holds the enrollment lookup open so protect() sees an authentication that has not settled. */ + private static final class Gate { + private volatile CompletableFuture latch = CompletableFuture.completedFuture(null); + private CompletableFuture entered = new CompletableFuture<>(); + + void close() { + latch = new CompletableFuture<>(); + entered = new CompletableFuture<>(); + } + + CompletableFuture releaseAfterLookup() { + return releaseAfterLookup(null); + } + + CompletableFuture releaseAfterLookup(Throwable failure) { + return entered.orTimeout(5, java.util.concurrent.TimeUnit.SECONDS) + .thenRunAsync( + () -> { + if (failure == null) latch.complete(null); + else latch.completeExceptionally(failure); + }, + CompletableFuture.delayedExecutor(50, java.util.concurrent.TimeUnit.MILLISECONDS)); + } + + CompletableFuture latch() { + entered.complete(null); + return latch; + } + } + + private static AepService gatedService(Gate gate) { + foundation.aep.service.EnrollmentStore delegate = foundation.aep.service.EnrollmentStore.inMemory(); + foundation.aep.service.EnrollmentStore gated = new foundation.aep.service.EnrollmentStore() { + @Override + public java.util.concurrent.CompletionStage> + find(String agentDid) { + return gate.latch().thenCompose(ignored -> delegate.find(agentDid)); + } + + @Override + public java.util.concurrent.CompletionStage findOrCreate( + String agentDid, + java.util.function.Supplier< + java.util.concurrent.CompletionStage> + create) { + return delegate.findOrCreate(agentDid, create); + } + + @Override + public java.util.concurrent.CompletionStage save( + foundation.aep.service.EnrollmentRecord record) { + return delegate.save(record); + } + }; + InspectDocument document = InspectDocument.builder() + .version(Aep.VERSION) + .authentication(new InspectDocument.Authentication(List.of(Aep.AUTHENTICATION_METHOD_JWT))) + .bindings(new InspectDocument.Bindings(List.of("http"))) + .claims(new InspectDocument.Claims(List.of(), List.of(), List.of())) + .commands(new InspectDocument.Commands(List.of("enroll", "inspect", "status"), List.of(), Map.of())) + .core(new InspectDocument.Core(Aep.REQUIRED_SIGNING_ALGORITHMS)) + .http(new InspectDocument.Http("/aep", null)) + .identity(new InspectDocument.Identity(List.of("did:web"))) + .service(new InspectDocument.Service("did:web:service.example")) + .build(); + return AepService.builder( + document, + (assertion, context) -> CompletableFuture.completedFuture(new ClientAssertionClaims( + AGENT_DID, + AGENT_DID, + context.serviceDid(), + context.operation(), + NOW.minusSeconds(1).getEpochSecond(), + NOW.plusSeconds(60).getEpochSecond(), + assertion, + context.resource()))) + .clock(Clock.fixed(NOW, ZoneOffset.UTC)) + .enrollmentStore(gated) + .enrollmentPolicy((request, now) -> CompletableFuture.completedFuture( + new EnrollmentDecision(AgentStatus.ACTIVE, false, List.of(), List.of()))) + .build(); + } + + /** A synchronous authentication keeps the downstream handler on the request thread. */ + @Test + void doesNotForceProtectedRoutesIntoAnAsyncDispatch() throws Exception { + AepServiceHttpHandler handler = new AepServiceHttpHandler(service("/aep")); + enroll(handler); + MockMvc client = MockMvcBuilders.routerFunctions(RouterFunctions.route() + .GET("/orders", request -> ServerResponse.ok().body("thread")) + .filter(AepSpringWebMvc.protect(handler, URI.create(ORIGIN))) + .build()) + .build(); + + var result = client.perform(get("/orders").header("Authorization", "AEP assertion")) + .andExpect(status().isOk()) + .andReturn(); + + Assertions.assertFalse(result.getRequest().isAsyncStarted()); + } + + private static Throwable rootCause(Throwable value) { + Throwable cause = value; + while (cause.getCause() != null && cause.getCause() != cause) cause = cause.getCause(); + return cause; + } + + private static MockMvc routed(AepService service) { + return MockMvcBuilders.routerFunctions(AepSpringWebMvc.routes(new AepServiceHttpHandler(service))) + .build(); + } + + private static void enroll(AepServiceHttpHandler handler) { + handler.handle( + AepCommand.ENROLL, + new AepHttpRequest( + "POST", + URI.create(ORIGIN + "/aep/enroll"), + Map.of( + "Content-Type", + List.of(Aep.MEDIA_TYPE), + "Authorization", + List.of("AEP enrollment"), + "Idempotency-Key", + List.of("enroll-key")), + ENROLL_BODY.getBytes(StandardCharsets.UTF_8))) + .toCompletableFuture() + .join(); + } + + private static AepService service(String endpointBase) { + InspectDocument document = InspectDocument.builder() + .version(Aep.VERSION) + .authentication(new InspectDocument.Authentication(List.of(Aep.AUTHENTICATION_METHOD_JWT))) + .bindings(new InspectDocument.Bindings(List.of("http"))) + .claims(new InspectDocument.Claims(List.of(), List.of(), List.of())) + .commands(new InspectDocument.Commands(List.of("enroll", "inspect", "status"), List.of(), Map.of())) + .core(new InspectDocument.Core(Aep.REQUIRED_SIGNING_ALGORITHMS)) + .http(new InspectDocument.Http(endpointBase, null)) + .identity(new InspectDocument.Identity(List.of("did:web"))) + .service(new InspectDocument.Service("did:web:service.example")) + .build(); + return AepService.builder( + document, + (assertion, context) -> CompletableFuture.completedFuture(new ClientAssertionClaims( + AGENT_DID, + AGENT_DID, + context.serviceDid(), + context.operation(), + NOW.minusSeconds(1).getEpochSecond(), + NOW.plusSeconds(60).getEpochSecond(), + assertion, + context.resource()))) + .clock(Clock.fixed(NOW, ZoneOffset.UTC)) + .enrollmentPolicy((request, now) -> CompletableFuture.completedFuture( + new EnrollmentDecision(AgentStatus.ACTIVE, false, List.of(), List.of()))) + .build(); + } +} diff --git a/aep-spring-webmvc/src/test/java/foundation/aep/spring/webmvc/AepSpringWebMvcTest.java b/aep-spring-webmvc/src/test/java/foundation/aep/spring/webmvc/AepSpringWebMvcTest.java index d852316..585e032 100644 --- a/aep-spring-webmvc/src/test/java/foundation/aep/spring/webmvc/AepSpringWebMvcTest.java +++ b/aep-spring-webmvc/src/test/java/foundation/aep/spring/webmvc/AepSpringWebMvcTest.java @@ -39,7 +39,7 @@ void servesInspectAndRejectsTheWrongMethod() throws Exception { .andExpect(content().string(org.hamcrest.Matchers.containsString("\"aep_version\""))); client.perform(post(Aep.WELL_KNOWN_PATH)) .andExpect(status().isMethodNotAllowed()) - .andExpect(header().string("Allow", "GET")); + .andExpect(header().string("Allow", "GET, HEAD")); } @Test diff --git a/examples/README.md b/examples/README.md index e1f8b9f..2c0fcf5 100644 --- a/examples/README.md +++ b/examples/README.md @@ -9,13 +9,20 @@ Run every example from the repository root: ./mvnw -pl examples -am verify ``` -Run one example while iterating: +To run one example while iterating, first install the reactor dependencies from the repository +root. This also runs both examples and the repository checks: ```sh -./mvnw -pl examples -am compile exec:java \ +./mvnw -pl examples -am install +``` + +Then target only the examples project: + +```sh +./mvnw -f examples/pom.xml compile exec:java \ -Dexec.mainClass=foundation.aep.examples.AgentServiceExample -./mvnw -pl examples -am compile exec:java \ +./mvnw -f examples/pom.xml compile exec:java \ -Dexec.mainClass=foundation.aep.examples.PlatformExample ``` @@ -33,7 +40,8 @@ starts an AEP Service on a random loopback port and drives it with an AEP Agent. 7. per-credential revocation and rejection of the revoked credential. [`JdkAepTransport.java`](./src/main/java/foundation/aep/examples/JdkAepTransport.java) adapts the JDK -`HttpClient` to the Agent's `AepHttpTransport` boundary. The Service uses the published +`HttpClient` to the shared `AepHttpTransport` boundary. Redirects are not followed automatically, +and each outgoing protocol request uses the timeout supplied by the SDK. The Service uses the published `aep-httpserver` adapter rather than an example-only HTTP dispatcher. Expected output identifies each completed step without printing the API key or signed assertion: diff --git a/examples/src/main/java/foundation/aep/examples/AgentServiceExample.java b/examples/src/main/java/foundation/aep/examples/AgentServiceExample.java index 8e05bb0..2906a17 100644 --- a/examples/src/main/java/foundation/aep/examples/AgentServiceExample.java +++ b/examples/src/main/java/foundation/aep/examples/AgentServiceExample.java @@ -12,6 +12,7 @@ import foundation.aep.agent.AgentAuthentication; import foundation.aep.agent.AgentIdentity; import foundation.aep.core.Aep; +import foundation.aep.core.AgentStatus; import foundation.aep.core.ClaimValues; import foundation.aep.core.ClientAssertions; import foundation.aep.core.ContactAddressPrimary; @@ -41,6 +42,7 @@ public final class AgentServiceExample { private static final String AGENT_DID = "did:web:agent.example"; private static final String API_KEY_HEADER = "X-API-Key"; private static final int HTTP_UNAUTHORIZED = 401; + private static final int HTTP_OK = 200; private AgentServiceExample() {} @@ -93,6 +95,10 @@ public static void main(String[] arguments) throws Exception { System.out.println("Inspected " + inspection.document().service().did()); // NOPMD - Example output. var enrollment = session.enroll(claims()).join(); + if (enrollment.status() != AgentStatus.ACTIVE + || session.status().join().status() != AgentStatus.ACTIVE) { + throw new IllegalStateException("The example Agent did not become active."); + } System.out.println("Enrollment status: " + enrollment.status().value()); // NOPMD - Example output. var grant = session.grant(Aep.GRANT_TYPE_API_KEY, List.of("profile:read")) @@ -102,6 +108,9 @@ public static void main(String[] arguments) throws Exception { AgentAuthentication authentication = session.authenticate(profile).join(); HttpResponse response = getProfile(transport, profile, authentication); + if (response.statusCode() != HTTP_OK || !"AEP-authenticated profile".equals(response.body())) { + throw new IllegalStateException("The example protected resource did not authenticate the Agent."); + } System.out.println( // NOPMD - Example output. "Protected profile: " + response.statusCode() + " " + response.body()); diff --git a/examples/src/main/java/foundation/aep/examples/NodeInteroperability.java b/examples/src/main/java/foundation/aep/examples/NodeInteroperability.java index 8bfe4cd..48fc01c 100644 --- a/examples/src/main/java/foundation/aep/examples/NodeInteroperability.java +++ b/examples/src/main/java/foundation/aep/examples/NodeInteroperability.java @@ -14,11 +14,14 @@ import foundation.aep.agent.AgentAuthentication; import foundation.aep.agent.PlatformIdentityProvider; import foundation.aep.core.Aep; +import foundation.aep.core.AepHttpTransport; import foundation.aep.core.AepJson; +import foundation.aep.core.AssertionOperation; import foundation.aep.core.ClaimValues; import foundation.aep.core.ClientAssertionClaims; import foundation.aep.core.ClientAssertions; import foundation.aep.core.DidWeb; +import foundation.aep.core.EnrollRequest; import foundation.aep.core.GrantResponses; import foundation.aep.core.InspectDocument; import foundation.aep.core.ManagedAgentStatus; @@ -37,7 +40,11 @@ import foundation.aep.platform.PlatformResponse; import foundation.aep.service.AepService; import foundation.aep.service.AepServiceHttpHandler; +import foundation.aep.service.AssertionVerificationContext; import foundation.aep.service.ClientAssertionVerifier; +import foundation.aep.service.CommandOptions; +import foundation.aep.service.HostedPlatformClientAssertionVerifier; +import foundation.aep.service.ProtectedResourceRequest; import foundation.aep.service.ServiceCredentialStore; import foundation.aep.service.StoredCredentialGrantTypes; import java.io.IOException; @@ -48,6 +55,9 @@ import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.LinkedHashMap; import java.util.List; @@ -65,6 +75,7 @@ public final class NodeInteroperability { private static final String HTTP_GET = "GET"; private static final String HTTP_POST = "POST"; private static final int HTTP_OK = 200; + private static final int HTTP_UNAUTHORIZED = 401; private static final String PLATFORM_AUTHORIZATION = "Bearer demo-agent"; private static final String PLATFORM_IDENTITIES = "/platform/agent-identities"; private static final int MAXIMUM_BODY_BYTES = 65_536; @@ -132,9 +143,120 @@ private static void runAgent(String... arguments) throws IOException, Interrupte result.put("revoked", revoked); result.put("service", "node"); result.put("service_did", inspection.document().service().did()); + result.put( + "hosted_verification", + verifyHosted( + identities, + platformOrigin, + serviceOrigin, + inspection.document().service().did(), + transport)); System.out.println(AepJson.write(result)); // NOPMD - Interoperability process output. } + private static boolean verifyHosted( + PlatformIdentityProvider identities, + URI platformOrigin, + URI serviceOrigin, + String serviceDid, + JdkAepTransport transport) { + var identity = identities + .getOrCreate(serviceOrigin, serviceDid) + .toCompletableFuture() + .join(); + // The test Platform runs on loopback HTTP. Only this test transport remaps the configured HTTPS endpoint. + AepHttpTransport localTransport = request -> transport.execute(new AepHttpTransport.Request( + request.method(), + platformOrigin.resolve("/v1/aep/verifications"), + request.headers(), + request.body(), + request.timeout())); + var verifier = HostedPlatformClientAssertionVerifier.builder( + URI.create("https://platform.example/v1/aep/verifications"), localTransport) + .authorization(PLATFORM_AUTHORIZATION) + .build(); + var document = InspectDocument.builder() + .version(Aep.VERSION) + .authentication(new InspectDocument.Authentication(List.of(Aep.AUTHENTICATION_METHOD_JWT))) + .bindings(new InspectDocument.Bindings(List.of("http"))) + .claims(new InspectDocument.Claims(List.of(), List.of(), List.of())) + .commands(new InspectDocument.Commands( + List.of(AssertionOperation.ENROLL.value(), "inspect", AssertionOperation.STATUS.value()), + List.of(), + Map.of())) + .core(new InspectDocument.Core(Aep.REQUIRED_SIGNING_ALGORITHMS)) + .http(new InspectDocument.Http("/aep/", null)) + .identity(new InspectDocument.Identity(List.of("did:web"))) + .service(new InspectDocument.Service(serviceDid)) + .build(); + var service = AepService.builder(document, verifier) + .allowInsecureLoopback(true) + .build(); + long now = Instant.now().getEpochSecond(); + var enrollClaims = new ClientAssertionClaims( + identity.did(), + identity.did(), + serviceDid, + AssertionOperation.ENROLL, + now, + now + 300, + UUID.randomUUID().toString(), + null); + String enroll = + identity.signer().sign(enrollClaims).toCompletableFuture().join(); + String forged = enroll.substring(0, enroll.lastIndexOf('.') + 1) + "A".repeat(86); + var rejected = service.enroll( + new EnrollRequest(identity.did(), null, null), + CommandOptions.idempotent(forged, "forged-enrollment")) + .toCompletableFuture() + .join(); + if (rejected.status() != HTTP_UNAUTHORIZED) { + throw new IllegalStateException("Hosted verification accepted a forged signature."); + } + var context = new AssertionVerificationContext( + serviceDid, + AssertionOperation.ENROLL, + null, + "hosted-enrollment", + Aep.REQUIRED_SIGNING_ALGORITHMS, + Clock.systemUTC(), + Duration.ofSeconds(30), + true); + verifier.verify(enroll, context).toCompletableFuture().join(); + // The Service retries the same verification after the first result; Platform idempotency must permit it. + var enrolled = service.enroll( + new EnrollRequest(identity.did(), null, null), + CommandOptions.idempotent(enroll, "hosted-enrollment")) + .toCompletableFuture() + .join(); + if (enrolled.status() != HTTP_OK) throw new IllegalStateException("Hosted enrollment failed."); + String resource = "https://" + serviceOrigin.getRawAuthority() + "/api/resource"; + String assertion = identity.signer() + .sign(new ClientAssertionClaims( + identity.did(), + identity.did(), + serviceDid, + AssertionOperation.AUTHENTICATE, + now, + now + 300, + UUID.randomUUID().toString(), + resource)) + .toCompletableFuture() + .join(); + var request = new ProtectedResourceRequest( + Map.of("Authorization", List.of("AEP " + assertion)), "GET", URI.create(resource)); + var wrongResource = new ProtectedResourceRequest( + request.headers(), "GET", URI.create(resource).resolve("/api/other")); + if (service.authenticate(wrongResource).toCompletableFuture().join().authenticated()) { + throw new IllegalStateException("Hosted verification accepted a mismatched resource."); + } + if (!service.authenticate(request).toCompletableFuture().join().authenticated() + || service.authenticate(request).toCompletableFuture().join().authenticated()) { + throw new IllegalStateException("Hosted authentication or replay protection failed."); + } + return true; + } + private static void runServer(String... arguments) throws IOException, InterruptedException { String listen = argument(arguments, "--listen"); int separator = listen.lastIndexOf(':'); diff --git a/scripts/run-conformance.sh b/scripts/run-conformance.sh index 24803fa..6bae634 100755 --- a/scripts/run-conformance.sh +++ b/scripts/run-conformance.sh @@ -17,7 +17,7 @@ mkdir -p "$output_dir" -Dmdep.outputFile="$PWD/.conformance/classpath" \ dependency:build-classpath -printf '%s\n' "{\"claims\":[{\"profiles\":[\"core-http\",\"claims\",\"api-key\",\"basic\",\"oauth-bearer\"],\"role\":\"agent\"},{\"profiles\":[\"platform-hosted-identity\"],\"role\":\"platform\"},{\"profiles\":[\"core-http\",\"claims\",\"api-key\",\"basic\",\"oauth-bearer\"],\"role\":\"service\"}],\"implementation\":{\"name\":\"aep-java\",\"version\":\"$implementation_version\"},\"manifest_version\":\"1\"}" > "$manifest" +printf '%s\n' "{\"claims\":[{\"profiles\":[\"core-http\",\"claims\",\"api-key\",\"basic\",\"oauth-bearer\",\"platform-hosted-identity\"],\"role\":\"agent\"},{\"profiles\":[\"platform-hosted-identity\"],\"role\":\"platform\"},{\"profiles\":[\"core-http\",\"claims\",\"api-key\",\"basic\",\"oauth-bearer\"],\"role\":\"service\"}],\"implementation\":{\"name\":\"aep-java\",\"version\":\"$implementation_version\"},\"manifest_version\":\"1\"}" > "$manifest" for role in agent platform service; do BUNDLE_GEMFILE="$specs_dir/ietf/Gemfile" bundle exec ruby "$specs_dir/ietf/scripts/run_conformance.rb" \ diff --git a/scripts/verify-node-interoperability.mjs b/scripts/verify-node-interoperability.mjs index 21c1c83..83fe9ad 100644 --- a/scripts/verify-node-interoperability.mjs +++ b/scripts/verify-node-interoperability.mjs @@ -16,6 +16,7 @@ requireEqual(javaResult.enrollment, "active", "Java Agent enrollment"); requireEqual(javaResult.credential_mode, "api-key", "Java Agent credential mode"); requireEqual(javaResult.protected_resource_status, 200, "Java Agent protected resource"); requireEqual(javaResult.revoked, true, "Java Agent credential revocation"); +requireEqual(javaResult.hosted_verification, true, "Java Service hosted verification against Node Platform"); requireEqual(nodeResult.credentialMode, "api-key", "Node Agent credential mode"); requireEqual(nodeResult.enroll?.status, "active", "Node Agent enrollment"); @@ -30,6 +31,13 @@ requireEqual(Object.keys(nodeResult.revoke ?? {}).length, 0, "Node Agent Revoke const report = { aep_version: "1.0", evidence: [ + { + service: "java", + counterpart: "node", + flow: "Hosted verification, exact retry, enrollment, protected resource, replay and resource rejection", + role: "platform", + status: "passed" + }, { agent: "java", counterpart: "node", diff --git a/tools/aep-conformance/pom.xml b/tools/aep-conformance/pom.xml index 0159282..5b4c0be 100644 --- a/tools/aep-conformance/pom.xml +++ b/tools/aep-conformance/pom.xml @@ -41,5 +41,11 @@ aep-platform ${project.version} + + org.junit.jupiter + junit-jupiter + ${junit.version} + test + diff --git a/tools/aep-conformance/src/main/java/foundation/aep/conformance/AgentConformance.java b/tools/aep-conformance/src/main/java/foundation/aep/conformance/AgentConformance.java index 2c8d535..9a84f2e 100644 --- a/tools/aep-conformance/src/main/java/foundation/aep/conformance/AgentConformance.java +++ b/tools/aep-conformance/src/main/java/foundation/aep/conformance/AgentConformance.java @@ -38,18 +38,35 @@ import java.util.concurrent.atomic.AtomicReference; final class AgentConformance { + private static final String REVOKE = "revoke"; + private static final String GET_METHOD = "GET"; + private static final String GRANT_REVOKE = "grant-revoke"; private static final int INITIAL_CALL = 1; private static final String CLAIMS = "claims"; private static final String COMMAND_TRANSPORT_PROHIBITED = "Command transport must not be called"; private static final String CONTENT_TYPE = "Content-Type"; private static final String INSPECT = "inspect"; + private static final String GRANT = "grant"; + private static final String STATUS = "status"; + private static final String CUSTOM_GRANT = "custom-grant"; + private static final String COMPACT_JWS = "compact-jws"; + private static final String ETAG = "inspect-1"; + private static final String IF_NONE_MATCH = "If-None-Match"; + private static final int SINGLE_FIELD = 1; + private static final String AGENT_DID = "did:web:agent.example"; private static final URI ORIGIN = URI.create("https://api.example.com"); private static final String SERVICE_DID = "did:web:api.example.com"; + private static final String SERVICE_ORIGIN_BINDING = "service-did-origin-binding"; + private static final String PLATFORM = "platform"; private static final Instant NOW = Instant.ofEpochSecond(1_783_958_400L); private AgentConformance() {} static boolean evaluate(AdapterRequest request) { + if (PLATFORM.equals(request.vector().category())) return AgentPlatformConformance.evaluate(request); + if (GRANT_REVOKE.equals(request.vector().category()) + && request.testCase().expected().has("authorization_scheme")) return commandWire(request); + if (SERVICE_ORIGIN_BINDING.equals(request.vector().id())) return serviceOriginBinding(request); if ("request-minimal".equals(request.vector().id()) || "request-claims-catalog".equals(request.vector().id())) { return enrollRequest(request); @@ -57,7 +74,7 @@ static boolean evaluate(AdapterRequest request) { Boolean shared = SharedConformance.evaluate(request); if (shared != null) return shared; return switch (request.vector().id()) { - case "public-discovery-cache" -> discoveryCache(); + case "public-discovery-cache" -> discoveryCache(request); case "grant-before-enroll-rejected" -> grantBeforeEnroll(request); case "command-header" -> commandIdempotency(request); case "transport-requirements" -> transport(request); @@ -74,33 +91,258 @@ static boolean evaluate(AdapterRequest request) { }; } - private static boolean discoveryCache() { - AtomicInteger calls = new AtomicInteger(); - AepHttpTransport inspect = request -> { - int call = calls.incrementAndGet(); - if (call == INITIAL_CALL) { - return completed(response( - 200, - Map.of( - "Cache-Control", - List.of("no-cache"), - CONTENT_TYPE, - List.of(Aep.MEDIA_TYPE), - "ETag", - List.of("inspect-1")), - document(List.of(INSPECT), List.of(), List.of()))); - } - boolean conditional = List.of("inspect-1").equals(request.headers().get("If-None-Match")); - return completed(response(conditional ? 304 : 500, Map.of("ETag", List.of("inspect-1")), "")); - }; - AepServiceSession session = agent(inspect, request -> failed(COMMAND_TRANSPORT_PROHIBITED)) + private static boolean commandWire(AdapterRequest vector) { + if (!Boolean.TRUE.equals(SharedConformance.evaluate(vector))) return false; + JsonNode expected = vector.testCase().expected(); + AssertionOperation operation = + AssertionOperation.fromValue(ConformanceSupport.text(expected, "client_assertion_op")); + String document = document( + List.of(GRANT, REVOKE, STATUS, INSPECT), List.of(Aep.AUTHENTICATION_METHOD_JWT), List.of()) + .replace(CUSTOM_GRANT, Aep.GRANT_TYPE_OAUTH_BEARER); + AtomicReference claims = new AtomicReference<>(); + AtomicReference captured = new AtomicReference<>(); + var session = agent(new QueueTransport(aepResponse(200, document)), outgoing -> { + if (GET_METHOD.equals(outgoing.method())) + return completed(aepResponse(200, "{\"status\":\"active\"}")); + captured.set(outgoing); + String response = operation == AssertionOperation.GRANT + ? "{\"access_token\":\"token\",\"credential_id\":\"credential-1\",\"expires_at\":\"2027-01-01T00:00:00Z\",\"token_type\":\"Bearer\",\"format\":\"opaque\"}" + : "{}"; + return completed(aepResponse(200, response)); + }) + .identityProvider((origin, did) -> completed(new AgentIdentity(AGENT_DID, value -> { + claims.set(value); + return completed(COMPACT_JWS); + }))) .build() .service(ORIGIN); + if (operation == AssertionOperation.GRANT) { + session.grant(AepJson.parseGrantRequest(vector.testCase().input().toString())) + .join(); + } else { + session.revoke(AepJson.parseRevokeRequest(vector.testCase().input().toString())) + .join(); + } + AepHttpTransport.Request sent = captured.get(); + JsonNode body = ConformanceSupport.json(new String(sent.body(), StandardCharsets.UTF_8)); + for (String missing : ConformanceSupport.strings(expected, "must_not_contain")) { + if (body.has(missing)) return false; + } + return sent.method().equals(ConformanceSupport.text(expected, "method")) + && sent.uri().getPath().equals(ConformanceSupport.text(expected, "path")) + && sent.headers().get(CONTENT_TYPE).equals(List.of(ConformanceSupport.text(expected, "content_type"))) + && sent.headers() + .get("Authorization") + .equals(List.of(ConformanceSupport.text(expected, "authorization_scheme") + " " + COMPACT_JWS)) + && claims.get().operation() == operation + && ConformanceSupport.preserves( + operation == AssertionOperation.GRANT + ? AepJson.parseGrantRequest(body.toString()) + : AepJson.parseRevokeRequest(body.toString()), + expected.get("body")); + } + + private static boolean serviceOriginBinding(AdapterRequest vector) { + JsonNode input = vector.testCase().input(); + JsonNode expected = vector.testCase().expected(); + URI origin = URI.create(ConformanceSupport.text(input, "inspect_url")).resolve("/"); + for (String field : List.of("matching_service_did", "mismatched_service_did", "unsupported_service_did")) { + String did = ConformanceSupport.text(input, field); + String inspectDocument = document(List.of(INSPECT), List.of(Aep.AUTHENTICATION_METHOD_JWT), List.of()) + .replace(SERVICE_DID, did); + AtomicInteger identities = new AtomicInteger(); + AtomicInteger assertions = new AtomicInteger(); + AtomicInteger requests = new AtomicInteger(); + var session = agent(new QueueTransport(aepResponse(200, inspectDocument)), outgoing -> { + requests.incrementAndGet(); + return failed(COMMAND_TRANSPORT_PROHIBITED); + }) + .identityProvider((serviceOrigin, serviceDid) -> { + identities.incrementAndGet(); + return completed(new AgentIdentity(AGENT_DID, claims -> { + assertions.incrementAndGet(); + return completed(COMPACT_JWS); + })); + }) + .build() + .service(origin); + try { + session.authenticate(origin.resolve("private")).join(); + if (!"accept".equals(ConformanceSupport.text(expected, field)) + || identities.get() != 1 + || assertions.get() != 1) return false; + } catch (CompletionException rejected) { + if (!(rejected.getCause() instanceof AepAgentException failure) + || !failure.code().equals(ConformanceSupport.text(expected, field)) + || identities.get() != 0 + || assertions.get() != 0 + || requests.get() != 0) return false; + } + } + return !expected.path("provision_identity").asBoolean() + && !expected.path("request_assertion").asBoolean() + && !expected.path("transmit_credentials").asBoolean(); + } + + /** + * Drives the five caching behaviours expected names: the SDK's default freshness lifetime, + * no-cache revalidation, no-store non-persistence, reuse of a 304-validated representation, and + * the cache key taken from the final safe redirect URL. + */ + private static boolean discoveryCache(AdapterRequest request) { + JsonNode expected = request.testCase().expected(); + Duration freshness = + Duration.ofSeconds(Long.parseLong(ConformanceSupport.text(expected, "default_freshness_seconds"))); + return defaultFreshness(freshness) + && "revalidate".equals(ConformanceSupport.text(expected, "no_cache")) + && revalidatesNoCache() + && "do-not-persist".equals(ConformanceSupport.text(expected, "no_store")) + && discardsNoStore() + && "reuse-validated-representation".equals(ConformanceSupport.text(expected, "status_304")) + && reusesValidated() + && "final-safe-redirect-url".equals(ConformanceSupport.text(expected, "cache_key")) + && keysOnFinalUrl(); + } + + /** + * A response carrying no Cache-Control is reusable for the SDK's own default lifetime, which is + * the one the vector publishes. The agent is built without configuring freshness so the default + * under test is the SDK's, not the vector's value handed back to itself. + */ + private static boolean defaultFreshness(Duration freshness) { + AtomicInteger calls = new AtomicInteger(); + MovableClock clock = new MovableClock(NOW); + AepServiceSession session = cachingSession( + clock, + requestValue -> + completed(counted(calls, aepResponse(200, document(List.of(INSPECT), List.of(), List.of()))))); + session.inspect().join(); + clock.advance(freshness.minusSeconds(1)); session.inspect().join(); + if (calls.get() != INITIAL_CALL) return false; + clock.advance(Duration.ofSeconds(2)); session.inspect().join(); return calls.get() == 2; } + /** CORE-DISC-014: no-cache stores the document but forces a conditional request before reuse. */ + private static boolean revalidatesNoCache() { + List seen = new ArrayList<>(); + AepServiceSession session = cachingSession(new MovableClock(NOW), requestValue -> { + seen.add(requestValue); + return completed( + seen.size() == INITIAL_CALL + ? cacheable("no-cache", document(List.of(INSPECT), List.of(), List.of())) + : response(304, Map.of("ETag", List.of(ETAG)), "")); + }); + session.inspect().join(); + session.inspect().join(); + return seen.size() == 2 && List.of(ETAG).equals(seen.get(1).headers().get(IF_NONE_MATCH)); + } + + /** no-store forbids persisting the document, so the next call cannot revalidate a stored copy. */ + private static boolean discardsNoStore() { + List seen = new ArrayList<>(); + AepServiceSession session = cachingSession(new MovableClock(NOW), requestValue -> { + seen.add(requestValue); + return completed(cacheable("no-store", document(List.of(INSPECT), List.of(), List.of()))); + }); + session.inspect().join(); + session.inspect().join(); + return seen.size() == 2 && !seen.get(1).headers().containsKey(IF_NONE_MATCH); + } + + /** A 304 revalidation reuses the stored representation rather than returning an empty document. */ + private static boolean reusesValidated() { + AtomicInteger calls = new AtomicInteger(); + AepServiceSession session = cachingSession( + new MovableClock(NOW), + requestValue -> completed( + calls.incrementAndGet() == INITIAL_CALL + ? cacheable("no-cache", document(List.of(INSPECT, STATUS), List.of(), List.of())) + : response(304, Map.of("ETag", List.of(ETAG)), ""))); + session.inspect().join(); + var revalidated = session.inspect().join(); + return SERVICE_DID.equals(revalidated.document().service().did()) + && revalidated.document().commands().supported().contains(STATUS); + } + + /** The cache is keyed on the final URL a safe redirect settled on, not the requested one. */ + private static boolean keysOnFinalUrl() { + URI finalUri = ORIGIN.resolve("/aep/inspect"); + List seen = new ArrayList<>(); + AepServiceSession session = cachingSession(new MovableClock(NOW), requestValue -> { + seen.add(requestValue); + if (seen.size() == INITIAL_CALL) { + return completed(response(308, Map.of("Location", List.of(finalUri.toString())), "")); + } + return completed(cacheable("no-cache", document(List.of(INSPECT), List.of(), List.of()))); + }); + var inspection = session.inspect().join(); + session.inspect().join(); + return finalUri.equals(inspection.documentUri()) + && seen.size() == 3 + && finalUri.equals(seen.get(2).uri()) + && List.of(ETAG).equals(seen.get(2).headers().get(IF_NONE_MATCH)); + } + + private static AepServiceSession cachingSession(Clock clock, AepHttpTransport inspect) { + AgentIdentity identity = new AgentIdentity(AGENT_DID, claims -> completed("signed")); + return AepAgent.builder() + .inspectTransport(inspect) + .commandTransport(requestValue -> failed(COMMAND_TRANSPORT_PROHIBITED)) + .identityProvider((origin, serviceDid) -> completed(identity)) + .clock(clock) + .jwtIdSupplier(() -> "jwt-id") + .build() + .service(ORIGIN); + } + + private static AepHttpTransport.Response cacheable(String cacheControl, String body) { + return response( + 200, + Map.of( + "Cache-Control", + List.of(cacheControl), + CONTENT_TYPE, + List.of(Aep.MEDIA_TYPE), + "ETag", + List.of(ETAG)), + body); + } + + private static AepHttpTransport.Response counted(AtomicInteger calls, AepHttpTransport.Response response) { + calls.incrementAndGet(); + return response; + } + + private static final class MovableClock extends Clock { + private Instant current; + + MovableClock(Instant start) { + this.current = start; + } + + void advance(Duration amount) { + current = current.plus(amount); + } + + @Override + public java.time.ZoneId getZone() { + return ZoneOffset.UTC; + } + + @Override + public Clock withZone(java.time.ZoneId zone) { + return this; + } + + @Override + public Instant instant() { + return current; + } + } + private static boolean enrollRequest(AdapterRequest request) { JsonNode input = request.testCase().input(); String agentDid = ConformanceSupport.text(input, "agent_did"); @@ -132,21 +374,22 @@ private static boolean enrollRequest(AdapterRequest request) { && List.of(idempotencyKey).equals(outgoing.headers().get("Idempotency-Key")) && List.of("AEP client-assertion").equals(outgoing.headers().get("Authorization")) && body.agentDid().equals(agentDid) + && ConformanceSupport.jsonEquals(body.claims(), input.get(CLAIMS)) && body.idempotencyKey().equals(idempotencyKey); } private static boolean grantBeforeEnroll(AdapterRequest request) { QueueTransport inspect = new QueueTransport(aepResponse( - 200, document(List.of("grant", INSPECT, "status"), List.of(Aep.AUTHENTICATION_METHOD_JWT), List.of()))); + 200, document(List.of(GRANT, INSPECT, STATUS), List.of(Aep.AUTHENTICATION_METHOD_JWT), List.of()))); QueueTransport command = new QueueTransport(problemResponse( - ConformanceSupport.required(request.testCase().expected(), "status") + ConformanceSupport.required(request.testCase().expected(), STATUS) .asInt(), ConformanceSupport.text(request.testCase().expected(), "code"))); try { agent(inspect, command) .build() .service(ORIGIN) - .grant(new GrantRequest("custom-grant", List.of())) + .grant(new GrantRequest(CUSTOM_GRANT, List.of())) .join(); return false; } catch (CompletionException exception) { @@ -156,29 +399,120 @@ private static boolean grantBeforeEnroll(AdapterRequest request) { } } + // expected.header_required is true, so every command input.commands names is issued and the + // Idempotency-Key is read off the request the Agent actually sent. private static boolean commandIdempotency(AdapterRequest request) { + if (!ConformanceSupport.required(request.testCase().expected(), "header_required") + .asBoolean()) return false; String key = ConformanceSupport.text(request.testCase().input(), "idempotency_key"); for (JsonNode command : ConformanceSupport.required(request.testCase().input(), "commands")) { AepCommand parsed = command(command.asText()); - if (AepHttp.commandPath(parsed, null).isBlank()) return false; - if (parsed == AepCommand.ENROLL - && !AepJson.parseEnrollRequest(AepJson.write(new EnrollRequest("did:web:agent.example", null, key))) - .idempotencyKey() - .equals(key)) return false; + AepHttpTransport.Request outgoing = issue(parsed, key); + if (outgoing == null || !List.of(key).equals(outgoing.headers().get("Idempotency-Key"))) return false; } return true; } + /** Runs one command through an Agent and hands back the request that reached the transport. */ + private static AepHttpTransport.Request issue(AepCommand command, String idempotencyKey) { + AtomicReference captured = new AtomicReference<>(); + QueueTransport inspect = new QueueTransport(aepResponse( + 200, + document( + List.of("enroll", GRANT, REVOKE, STATUS, INSPECT), + List.of(Aep.AUTHENTICATION_METHOD_JWT), + List.of()))); + String path = AepHttp.commandPath(command, null); + AepHttpTransport transport = outgoing -> { + // grant runs a status check first, so only the request for the command under test counts. + if (outgoing.uri().getPath().equals(path)) captured.set(outgoing); + return completed(aepResponse( + 200, commandResponse(outgoing.uri().getPath().equals(path) ? command : AepCommand.STATUS))); + }; + AepServiceSession session = agent(inspect, transport) + .idempotencyKeyProvider((serviceDid, operation, discriminator) -> idempotencyKey) + .build() + .service(ORIGIN); + try { + switch (command) { + case ENROLL -> session.enroll(null).join(); + case GRANT -> + session.grant(new GrantRequest(CUSTOM_GRANT, List.of())).join(); + case REVOKE -> + session.revoke(new foundation.aep.core.RevokeRequest(CUSTOM_GRANT, null, null)) + .join(); + default -> { + return null; + } + } + } catch (CompletionException rejectedResponse) { + // A command whose canned response the Agent rejects still produced the request under test. + assert rejectedResponse != null; + } + return captured.get(); + } + + private static AssertionOperation operationOf(AepCommand command) { + return AssertionOperation.fromValue(command.value()); + } + + private static String commandResponse(AepCommand command) { + return switch (command) { + case ENROLL, STATUS -> "{\"status\":\"active\"}"; + case GRANT -> "{\"credential_id\":\"credential-1\"}"; + default -> "{}"; + }; + } + + // Each redirect policy expected names is put to the Agent: a same-origin redirect is followed, a + // cross-origin one and a scheme downgrade are not, and the advertised media-type essence is + // accepted with its parameters and casing intact. private static boolean transport(AdapterRequest request) { JsonNode input = request.testCase().input(); + JsonNode expected = request.testCase().expected(); URI requested = URI.create(ConformanceSupport.text(input, "request_url")); URI redirected = URI.create(ConformanceSupport.text(input, "redirect_url")); - return "https".equals(requested.getScheme()) - && requested.getHost().equals(redirected.getHost()) - && requested.getPort() == redirected.getPort() - && ConformanceSupport.text(input, "content_type") - .toLowerCase(java.util.Locale.ROOT) - .startsWith(Aep.MEDIA_TYPE); + String contentType = ConformanceSupport.text(input, "content_type"); + if (!contentType + .toLowerCase(java.util.Locale.ROOT) + .startsWith(ConformanceSupport.text(expected, "media_type_essence"))) return false; + if (!inspectFollows(requested, redirected, contentType)) return false; + boolean rejectsCrossOrigin = "reject".equals(ConformanceSupport.text(expected, "cross_origin_redirect")) + && inspectRejects(requested, URI.create("https://other.example.net/discovery/aep"), contentType); + boolean rejectsDowngrade = "reject".equals(ConformanceSupport.text(expected, "scheme_downgrade")) + && inspectRejects(requested, URI.create("http://api.example.com/discovery/aep"), contentType); + return rejectsCrossOrigin && rejectsDowngrade; + } + + /** Answers a redirect to {@code location}, then the document, and reports whether Inspect completed. */ + private static boolean inspectFollows(URI from, URI location, String contentType) { + try { + redirectingSession(from, location, contentType).inspect().join(); + return true; + } catch (CompletionException exception) { + return false; + } + } + + private static boolean inspectRejects(URI from, URI location, String contentType) { + try { + redirectingSession(from, location, contentType).inspect().join(); + return false; + } catch (CompletionException exception) { + return exception.getCause() instanceof AepAgentException; + } + } + + private static AepServiceSession redirectingSession(URI from, URI location, String contentType) { + AepHttpTransport inspect = outgoing -> outgoing.uri().equals(from) + ? completed(response(302, Map.of("Location", List.of(location.toString())), "")) + : completed(response( + 200, + Map.of(CONTENT_TYPE, List.of(contentType)), + document(List.of(INSPECT), List.of(), List.of()))); + return agent(inspect, outgoing -> failed(COMMAND_TRANSPORT_PROHIBITED)) + .build() + .service(URI.create(from.getScheme() + "://" + from.getAuthority())); } private static boolean apiKeyHeader(AdapterRequest request) { @@ -202,7 +536,7 @@ private static boolean authenticateAssertion(AdapterRequest request) { AgentIdentity identity = new AgentIdentity( ConformanceSupport.text(request.testCase().expected().get(CLAIMS), "iss"), claims -> { assertion.set(claims); - return completed("compact-jws"); + return completed(COMPACT_JWS); }); QueueTransport inspect = new QueueTransport( aepResponse(200, document(List.of(INSPECT), List.of(Aep.AUTHENTICATION_METHOD_JWT), List.of()))); @@ -222,58 +556,177 @@ private static boolean authenticateAssertion(AdapterRequest request) { assertion.get(), request.testCase().expected().get(CLAIMS)); } + // Each composition in expected names the field the Agent puts its own credential in and the + // field the payment credential occupies beside it. The Agent must render into the first and + // leave the second untouched. private static boolean paymentComposition(AdapterRequest request) { - JsonNode expected = request.testCase().expected(); - Map aep = AepHttp.renderAuthorization(new ProtectedResourceAuthorization( - AuthorizationCarrier.DEDICATED, AuthorizationScheme.AEP, "compact-jws")); - return aep.get(Aep.AUTHORIZATION_HEADER) - .equals(expected.get("mpp") - .get(Aep.AUTHORIZATION_HEADER) - .asText()) - && !Aep.AUTHORIZATION_HEADER.equals("Authorization") - && expected.get("x402").has("PAYMENT-SIGNATURE"); + var compositions = request.testCase().expected().fields(); + while (compositions.hasNext()) { + JsonNode composition = compositions.next().getValue(); + if (!composition.isObject() || !composition.has(Aep.AUTHORIZATION_HEADER)) continue; + if (composition.path("ambiguous").asBoolean()) return false; + String carried = ConformanceSupport.text(composition, Aep.AUTHORIZATION_HEADER); + // The assertion is on the field, not the value: the Agent renders into the one the + // composition names for it and claims none of the fields beside it. + Map rendered = AepHttp.renderAuthorization(new ProtectedResourceAuthorization( + AuthorizationCarrier.DEDICATED, + carried.startsWith(Aep.AUTHENTICATION_SCHEME + " ") + ? AuthorizationScheme.AEP + : AuthorizationScheme.BEARER, + carried.substring(carried.indexOf(' ') + 1))); + if (!rendered.containsKey(Aep.AUTHORIZATION_HEADER)) return false; + var fields = composition.fields(); + while (fields.hasNext()) { + var field = fields.next(); + if (!field.getValue().isTextual() || Aep.AUTHORIZATION_HEADER.equals(field.getKey())) continue; + if (rendered.containsKey(field.getKey())) return false; + } + } + return true; } + // expected.allowed pairs each operation with the endpoint it may be presented at. The previous + // check asked AssertionOperation.fromValue whether its own lookup table was self-consistent; this + // one reads the operation and resource the Agent puts in the assertion it mints for each command, + // which is the binding the vector is about. private static boolean operationBinding(AdapterRequest request) { - for (JsonNode value : ConformanceSupport.required(request.testCase().input(), "operations")) { - AssertionOperation operation = AssertionOperation.fromValue(value.asText()); - boolean authenticate = operation == AssertionOperation.AUTHENTICATE; - if (authenticate != "authenticate".equals(value.asText())) return false; + String resource = ConformanceSupport.text(request.testCase().input(), "protected_resource"); + for (JsonNode pair : ConformanceSupport.required(request.testCase().expected(), "allowed")) { + String[] parts = pair.asText().split(":", 2); + AssertionOperation operation = AssertionOperation.fromValue(parts[0]); + ClientAssertionClaims minted = operation == AssertionOperation.AUTHENTICATE + ? mintedForResource(URI.create(resource)) + : mintedForCommand(command(parts[0])); + if (minted == null || minted.operation() != operation) return false; + boolean atResource = "protected-resource".equals(parts[1]); + if (atResource != resource.equals(minted.resource())) return false; + if (!atResource && minted.resource() != null) return false; } return true; } + private static ClientAssertionClaims mintedForCommand(AepCommand command) { + AtomicReference minted = new AtomicReference<>(); + QueueTransport inspect = new QueueTransport(aepResponse( + 200, + document( + List.of("enroll", GRANT, REVOKE, STATUS, INSPECT), + List.of(Aep.AUTHENTICATION_METHOD_JWT), + List.of()))); + AssertionOperation wanted = operationOf(command); + AgentIdentity identity = new AgentIdentity(AGENT_DID, claims -> { + // grant mints a status assertion first; the one under test is the one for this command. + if (claims.operation() == wanted) minted.compareAndSet(null, claims); + return completed(COMPACT_JWS); + }); + String path = AepHttp.commandPath(command, null); + AepServiceSession session = agent( + inspect, + outgoing -> completed(aepResponse( + 200, + commandResponse(outgoing.uri().getPath().equals(path) ? command : AepCommand.STATUS)))) + .identityProvider((origin, serviceDid) -> completed(identity)) + .build() + .service(ORIGIN); + try { + switch (command) { + case ENROLL -> session.enroll(null).join(); + case GRANT -> + session.grant(new GrantRequest(CUSTOM_GRANT, List.of())).join(); + case REVOKE -> + session.revoke(new foundation.aep.core.RevokeRequest(CUSTOM_GRANT, null, null)) + .join(); + case STATUS -> session.status().join(); + default -> { + return null; + } + } + } catch (CompletionException rejectedResponse) { + // The assertion is minted before the response is read, so a rejected response is fine. + assert rejectedResponse != null; + } + return minted.get(); + } + + private static ClientAssertionClaims mintedForResource(URI resource) { + AtomicReference minted = new AtomicReference<>(); + AgentIdentity identity = new AgentIdentity(AGENT_DID, claims -> { + minted.compareAndSet(null, claims); + return completed(COMPACT_JWS); + }); + QueueTransport inspect = new QueueTransport( + aepResponse(200, document(List.of(INSPECT), List.of(Aep.AUTHENTICATION_METHOD_JWT), List.of()))); + agent(inspect, outgoing -> failed(COMMAND_TRANSPORT_PROHIBITED)) + .identityProvider((origin, serviceDid) -> completed(identity)) + .build() + .service(ORIGIN) + .authenticate(resource) + .join(); + return minted.get(); + } + + // The application owns redirect handling. This checks the headers it can request from one + // Service session, not whether an application forwards or strips them on the wire. private static boolean redirectSafety(AdapterRequest request) { + JsonNode expected = request.testCase().expected(); + JsonNode sameOrigin = ConformanceSupport.required(expected, "same_origin"); + if (ConformanceSupport.required(sameOrigin, "credential_forwarded").asBoolean()) return false; URI source = URI.create(ConformanceSupport.text(request.testCase().input(), "source")); - URI sameOrigin = URI.create(ConformanceSupport.text(request.testCase().input(), "same_origin")); - URI crossOrigin = URI.create(ConformanceSupport.text(request.testCase().input(), "cross_origin")); - return source.getScheme().equals(sameOrigin.getScheme()) - && source.getAuthority().equals(sameOrigin.getAuthority()) - && !source.getAuthority().equals(crossOrigin.getAuthority()); + String target = ConformanceSupport.text(sameOrigin, "new_authenticate_assertion_resource"); + List minted = new java.util.ArrayList<>(); + AtomicInteger jwtIds = new AtomicInteger(); + var inspect = new QueueTransport( + aepResponse(200, document(List.of(INSPECT), List.of(Aep.AUTHENTICATION_METHOD_JWT), List.of()))); + JsonNode crossOrigin = ConformanceSupport.required(expected, "cross_origin"); + var session = agent(inspect, outgoing -> failed(COMMAND_TRANSPORT_PROHIBITED)) + .jwtIdSupplier(() -> "redirect-" + jwtIds.incrementAndGet()) + .identityProvider((origin, did) -> completed(new AgentIdentity(AGENT_DID, claims -> { + minted.add(claims); + return completed("signed-" + claims.jwtId()); + }))) + .build() + .service(ORIGIN); + var firstHeaders = session.authenticate(source).join().headers(); + var secondHeaders = session.authenticate( + URI.create(ConformanceSupport.text(request.testCase().input(), "same_origin"))) + .join() + .headers(); + try { + session.authenticate(URI.create( + ConformanceSupport.text(request.testCase().input(), "cross_origin"))) + .join(); + return false; + } catch (CompletionException rejected) { + if (!(rejected.getCause() instanceof AepAgentException failure) + || !"origin_mismatch".equals(failure.code())) return false; + } + return minted.size() == 2 + && inspect.requests.size() == 1 + && source.toString().equals(minted.get(0).resource()) + && target.equals(minted.get(1).resource()) + && !minted.get(0).jwtId().equals(minted.get(1).jwtId()) + && !firstHeaders.equals(secondHeaders) + && crossOrigin.path("anonymous_restart").asBoolean() + && !crossOrigin.path("assertion_forwarded").asBoolean() + && !crossOrigin.path("session_credential_forwarded").asBoolean() + && !crossOrigin.path("api_key_header_forwarded").asBoolean(); } private static boolean failClosed(AdapterRequest request) { return switch (request.vector().id()) { - case "authorization-field-safety" -> - Aep.AUTHORIZATION_HEADER.equalsIgnoreCase( - ConformanceSupport.text(request.testCase().input(), "field_name")); - case "authorization-ambiguity" -> - rejectsAuthorization("AEP first,AEP second", AuthorizationCarrier.DEDICATED); - case "unadvertised-authentication-method" -> { - QueueTransport inspect = - new QueueTransport(aepResponse(200, document(List.of(INSPECT), List.of(), List.of()))); - try { - agent(inspect, requestValue -> failed(COMMAND_TRANSPORT_PROHIBITED)) - .build() - .service(ORIGIN) - .authenticate(URI.create("https://api.example.com/private")) - .join(); - yield false; - } catch (CompletionException exception) { - yield exception.getCause() instanceof AepAgentException; - } + // The carrier utility renders this field; the application's HTTP client owns stripping + // old headers and selecting the carrier when it follows a protected-resource redirect. + case "authorization-field-safety" -> { + String carrier = + ConformanceSupport.text(request.testCase().expected(), "redirect_new_assertion_carrier"); + Map rendered = AepHttp.renderAuthorization(new ProtectedResourceAuthorization( + AuthorizationCarrier.DEDICATED, AuthorizationScheme.AEP, COMPACT_JWS)); + yield rendered.containsKey(carrier) + && carrier.equalsIgnoreCase( + ConformanceSupport.text(request.testCase().input(), "field_name")); } - default -> { + case "authorization-ambiguity" -> authorizationAmbiguity(request); + case "unadvertised-authentication-method" -> { QueueTransport inspect = new QueueTransport(aepResponse(200, document(List.of(INSPECT), List.of(), List.of()))); try { @@ -287,15 +740,95 @@ private static boolean failClosed(AdapterRequest request) { yield exception.getCause() instanceof AepAgentException; } } + default -> credentialFailures(request); }; } - private static boolean rejectsAuthorization(String value, AuthorizationCarrier carrier) { + /** + * expected maps each failure mode to the code the Agent must surface. Only the modes this SDK + * decides locally are exercised here; the rest are the Service's to report, and the Agent's job + * is to carry the Service's code through unchanged, which is what the last case checks. + */ + private static boolean credentialFailures(AdapterRequest request) { + JsonNode expected = request.testCase().expected(); + QueueTransport inspect = new QueueTransport(aepResponse(200, document(List.of(INSPECT), List.of(), List.of()))); + try { + agent(inspect, requestValue -> failed(COMMAND_TRANSPORT_PROHIBITED)) + .build() + .service(ORIGIN) + .authenticate(URI.create("https://api.example.com/private")) + .join(); + return false; + } catch (CompletionException exception) { + // The codes in this vector are the ones a Service reports. What the Agent owns locally is + // failing closed when no advertised method is usable, under its own failure identifier. + if (!(exception.getCause() instanceof AepAgentException)) return false; + } + // Every other mode is the Service's to detect; the Agent's part is to surface the code it + // was given rather than collapsing them all into one. + java.util.Set codes = new java.util.LinkedHashSet<>(); + expected.fields().forEachRemaining(mode -> codes.add(mode.getValue().asText())); + return codes.stream().allMatch(AgentConformance::surfacesCommandCode); + } + + private static boolean surfacesCommandCode(String code) { + QueueTransport inspect = new QueueTransport(aepResponse( + 200, document(List.of(STATUS, INSPECT), List.of(Aep.AUTHENTICATION_METHOD_JWT), List.of()))); + QueueTransport command = new QueueTransport(problemResponse(401, code)); + try { + agent(inspect, command).build().service(ORIGIN).status().join(); + return false; + } catch (CompletionException exception) { + return exception.getCause() instanceof AepAgentException failure && code.equals(failure.code()); + } + } + + /** + * input.cases names four ambiguous presentations and expected names one outcome for all of them: + * the published code, with no fallback to a second carrier and no credential selected. Every case + * is driven and every member of expected is read. + */ + private static boolean authorizationAmbiguity(AdapterRequest request) { + JsonNode expected = request.testCase().expected(); + if (ConformanceSupport.required(expected, "fallback").asBoolean() + || !ConformanceSupport.required(expected, "selected_credential").isNull()) return false; + String code = ConformanceSupport.text(expected, "code"); + for (JsonNode value : ConformanceSupport.required(request.testCase().input(), "cases")) { + if (!ambiguous(value.asText(), code)) return false; + } + return true; + } + + private static boolean ambiguous(String name, String code) { + return switch (name) { + // An Agent renders one presentation into one field, so it can never be the party that + // puts an AEP credential in both carriers at once. + case "both-aep-recognized" -> + AepHttp.renderAuthorization(new ProtectedResourceAuthorization( + AuthorizationCarrier.DEDICATED, AuthorizationScheme.AEP, COMPACT_JWS)) + .size() + == SINGLE_FIELD; + // A malformed dedicated field is rejected outright; the valid standard field alongside it + // is not a fallback the Agent may quietly use instead. + case "invalid-dedicated-plus-valid-standard" -> + rejects("AEP", AuthorizationCarrier.DEDICATED, code) + && AepHttp.parseAuthorization("Bearer opaque", AuthorizationCarrier.STANDARD) + .carrier() + == AuthorizationCarrier.STANDARD; + case "duplicate-dedicated-lines" -> rejects("AEP first,AEP second", AuthorizationCarrier.DEDICATED, code); + // Two schemes packed into one field value are not two credentials to choose between. + case "combined-dedicated-values" -> + rejects("AEP first Bearer second", AuthorizationCarrier.DEDICATED, code); + default -> throw new IllegalArgumentException("Unknown authorization ambiguity case: " + name); + }; + } + + private static boolean rejects(String value, AuthorizationCarrier carrier, String code) { try { AepHttp.parseAuthorization(value, carrier); return false; } catch (AepAuthorizationException exception) { - return true; + return code.equals(exception.code()); } } @@ -307,7 +840,7 @@ private static AepCommand command(String value) { } private static AepAgent.Builder agent(AepHttpTransport inspect, AepHttpTransport command) { - AgentIdentity identity = new AgentIdentity("did:web:agent.example", claims -> completed("signed")); + AgentIdentity identity = new AgentIdentity(AGENT_DID, claims -> completed("signed")); return AepAgent.builder() .inspectTransport(inspect) .commandTransport(command) @@ -317,9 +850,9 @@ private static AepAgent.Builder agent(AepHttpTransport inspect, AepHttpTransport } private static String document(List commands, List methods, List requiredClaims) { - String grants = commands.contains("grant") || commands.contains("revoke") ? "[\"custom-grant\"]" : "[]"; + String grants = commands.contains(GRANT) || commands.contains(REVOKE) ? "[\"custom-grant\"]" : "[]"; String config = - commands.contains("grant") ? "{\"custom-grant\":{\"supports_per_credential_revoke\":\"true\"}}" : "{}"; + commands.contains(GRANT) ? "{\"custom-grant\":{\"supports_per_credential_revoke\":\"true\"}}" : "{}"; String authentication = methods.isEmpty() ? "" : "\"authentication\":{\"methods\":" + AepJson.write(methods) + "},"; return "{\"aep_version\":\"1.0\"," + authentication diff --git a/tools/aep-conformance/src/main/java/foundation/aep/conformance/AgentPlatformConformance.java b/tools/aep-conformance/src/main/java/foundation/aep/conformance/AgentPlatformConformance.java new file mode 100644 index 0000000..65b8ac0 --- /dev/null +++ b/tools/aep-conformance/src/main/java/foundation/aep/conformance/AgentPlatformConformance.java @@ -0,0 +1,319 @@ +package foundation.aep.conformance; + +import com.fasterxml.jackson.databind.JsonNode; +import foundation.aep.agent.AgentIdentity; +import foundation.aep.agent.PlatformIdentityProvider; +import foundation.aep.agent.PlatformSignPendingException; +import foundation.aep.core.Aep; +import foundation.aep.core.AepHttpTransport; +import foundation.aep.core.AepJson; +import foundation.aep.core.AssertionOperation; +import foundation.aep.core.ClientAssertionClaims; +import foundation.aep.core.ClientAssertions; +import foundation.aep.core.DidWeb; +import foundation.aep.core.PlatformAgentIdentity; +import foundation.aep.core.PlatformProvisionRequest; +import foundation.aep.core.PlatformSignResponses; +import foundation.aep.platform.PlatformIdentityListQuery; +import foundation.aep.platform.PlatformRequestContext; +import foundation.aep.platform.PlatformResponse; +import java.net.URI; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.atomic.AtomicInteger; + +final class AgentPlatformConformance { + private static final String KEY_FIELD = "idempotency_key_header"; + private static final String LIST_RESPONSE = "list-response"; + private static final String SIGN_REQUEST = "sign-request"; + private static final String SERVICE_DID = "did:web:api.service.example"; + private static final String SERVICE_FIELD = "service_did"; + private static final String SIGN_RESPONSE = "sign-response"; + private static final Instant NOW = Instant.parse("2026-07-06T12:00:00Z"); + private static final String IDENTITY_PATH = "/v1/aep/agent-identities"; + private static final String IDEMPOTENCY_HEADER = "Idempotency-Key"; + private static final Map CONTEXT = Map.of("authorization_handle", "opaque-value"); + + private AgentPlatformConformance() {} + + static boolean evaluate(AdapterRequest vector) { + return switch (vector.vector().id()) { + case "discovery", LIST_RESPONSE, "provision-request", "provision-response" -> provision(vector); + case "provision-response-distinct-services" -> distinct(vector); + case SIGN_REQUEST, SIGN_RESPONSE, "sign-response-pending", "idempotency-replay-conflict" -> sign(vector); + default -> throw ConformanceSupport.unmapped(vector); + }; + } + + private static boolean provision(AdapterRequest vector) { + Exchange exchange = new Exchange(false); + JsonNode expected = vector.testCase().expected(); + String key = expected.has(KEY_FIELD) ? expected.get(KEY_FIELD).asText() : "provision"; + PlatformIdentityProvider provider = + provider(exchange).idempotencyKeys(() -> key).build(); + if (LIST_RESPONSE.equals(vector.vector().id())) exchange.provision(SERVICE_DID); + AgentIdentity identity = provider.getOrCreate(URI.create("https://api.service.example"), SERVICE_DID) + .toCompletableFuture() + .join(); + if (!identity.did().equals(exchange.identities.get(0).agentDid())) return false; + return switch (vector.vector().id()) { + case "discovery" -> + ConformanceSupport.jsonEquals( + exchange.fixture.platform().discovery().body(), expected) + && exchange.requests.get(0).headers().get("Authorization") == null; + case LIST_RESPONSE -> + ConformanceSupport.jsonEquals(exchange.listResponse, expected) + && exchange.requests.stream().noneMatch(request -> "POST".equals(request.method())); + case "provision-request" -> { + var request = exchange.requests.get(exchange.requests.size() - 1); + yield ConformanceSupport.json(new String(request.body(), StandardCharsets.UTF_8)) + .equals(vector.testCase().input()) + && request.headers().get(IDEMPOTENCY_HEADER).equals(List.of(key)); + } + default -> ConformanceSupport.jsonEquals(exchange.identities.get(0), expected); + }; + } + + private static boolean distinct(AdapterRequest vector) { + Exchange exchange = new Exchange(false); + PlatformIdentityProvider provider = provider(exchange).build(); + JsonNode input = vector.testCase().input(); + String firstDid = input.path("first_request").path(SERVICE_FIELD).asText(); + String secondDid = input.path("second_request").path(SERVICE_FIELD).asText(); + var first = provider.getOrCreate(DidWeb.documentUri(firstDid).resolve("/"), firstDid) + .toCompletableFuture() + .join(); + exchange.fixture.advanceMinute(); + var second = provider.getOrCreate(DidWeb.documentUri(secondDid).resolve("/"), secondDid) + .toCompletableFuture() + .join(); + return !first.did().equals(second.did()) + && ConformanceSupport.jsonEquals( + exchange.identities.get(0), vector.testCase().expected().get("first_response")) + && ConformanceSupport.jsonEquals( + exchange.identities.get(1), vector.testCase().expected().get("second_response")); + } + + private static boolean sign(AdapterRequest vector) { + String id = vector.vector().id(); + boolean continuation = "idempotency-replay-conflict".equals(id); + boolean pending = "sign-response-pending".equals(id); + Exchange exchange = new Exchange(pending || continuation); + exchange.provision(SERVICE_DID); + JsonNode input = vector.testCase().input(); + JsonNode expected = vector.testCase().expected(); + List keys = continuation + ? List.of( + input.path("initial_sign_key").asText(), + input.path("final_sign_key").asText()) + : List.of( + input.has(KEY_FIELD) + ? input.path(KEY_FIELD).asText() + : expected.has(KEY_FIELD) + ? expected.path(KEY_FIELD).asText() + : "sign"); + AtomicInteger index = new AtomicInteger(); + var builder = provider(exchange) + .idempotencyKeys(() -> keys.get(index.getAndIncrement())) + .platformContext((identity, claims) -> CompletableFuture.completedFuture(CONTEXT)); + if (continuation) + builder.pendingSignResolver(value -> CompletableFuture.completedFuture(value.platformContext())); + var identity = builder.build() + .getOrCreate(URI.create("https://api.service.example"), SERVICE_DID) + .toCompletableFuture() + .join(); + String jwtId = input.has("jti") ? input.path("jti").asText() : "01J0AEPASSERTION0000000001"; + var claims = new ClientAssertionClaims( + identity.did(), + identity.did(), + SERVICE_DID, + AssertionOperation.ENROLL, + NOW.getEpochSecond(), + NOW.plusSeconds(300).getEpochSecond(), + jwtId, + null); + try { + String token = identity.signer().sign(claims).toCompletableFuture().join(); + if (pending || !ClientAssertions.decodeUnverified(token).equals(claims)) return false; + var signRequests = exchange.requests.stream() + .filter(request -> request.uri().getPath().endsWith("/sign")) + .toList(); + if (!signRequests.stream() + .map(request -> request.headers().get(IDEMPOTENCY_HEADER).get(0)) + .toList() + .equals(keys)) return false; + if (SIGN_REQUEST.equals(id)) + return ConformanceSupport.json(new String(signRequests.get(0).body(), StandardCharsets.UTF_8)) + .equals(input); + if (SIGN_RESPONSE.equals(id)) { + var actual = ConformanceSupport.json(AepJson.write(exchange.signResponse)) + .deepCopy(); + ((com.fasterxml.jackson.databind.node.ObjectNode) actual) + .put( + "client_assertion", + expected.path("client_assertion").asText()); + return actual.equals(expected); + } + return signRequests.size() == 2 && !keys.get(0).equals(keys.get(1)) && replayConflict(vector); + } catch (CompletionException failure) { + if (!pending || !(failure.getCause() instanceof PlatformSignPendingException waiting)) throw failure; + return waiting.pending().retryAfterSeconds() + == Integer.parseInt( + expected.path("retry_after_seconds").asText()) + && ConformanceSupport.jsonEquals( + waiting.pending().platformContext(), expected.get("platform_context")) + && identity.did().equals(waiting.pending().identity().agentDid()); + } + } + + private static boolean replayConflict(AdapterRequest vector) { + Exchange exchange = new Exchange(false); + exchange.provision(SERVICE_DID); + String key = vector.testCase().input().path("initial_sign_key").asText(); + var identity = provider(exchange) + .idempotencyKeys(() -> key) + .build() + .getOrCreate(URI.create("https://api.service.example"), SERVICE_DID) + .toCompletableFuture() + .join(); + var claims = new ClientAssertionClaims( + identity.did(), + identity.did(), + SERVICE_DID, + AssertionOperation.ENROLL, + NOW.getEpochSecond(), + NOW.plusSeconds(300).getEpochSecond(), + "replay", + null); + String first = identity.signer().sign(claims).toCompletableFuture().join(); + String replay = identity.signer().sign(claims).toCompletableFuture().join(); + if (!first.equals(replay)) return false; + var changed = new ClientAssertionClaims( + identity.did(), + identity.did(), + SERVICE_DID, + AssertionOperation.ENROLL, + claims.issuedAt(), + claims.expiresAt(), + "different", + null); + try { + identity.signer().sign(changed).toCompletableFuture().join(); + return false; + } catch (CompletionException rejected) { + return rejected.getCause() instanceof foundation.aep.agent.AepAgentException failure + && failure.status() + == vector.testCase() + .expected() + .path("changed_input_or_operation_status") + .asInt() + && failure.code() + .equals(vector.testCase() + .expected() + .path("changed_input_or_operation_code") + .asText()); + } + } + + private static PlatformIdentityProvider.Builder provider(Exchange exchange) { + return PlatformIdentityProvider.builder(URI.create("https://p.example")) + .clock(Clock.fixed(NOW, ZoneOffset.UTC)) + .transport(exchange) + .authorization("Bearer platform-access"); + } + + private static final class Exchange implements AepHttpTransport { + private final PlatformConformance.Fixture fixture; + private final List requests = new ArrayList<>(); + private final List identities = new ArrayList<>(); + private Object listResponse; + private Object signResponse; + + Exchange(boolean pending) { + AtomicInteger stages = new AtomicInteger(); + fixture = PlatformConformance.fixture( + true, + true, + (identity, request, context) -> CompletableFuture.completedFuture( + pending && stages.getAndIncrement() == 0 + ? Optional.of(new PlatformResponse<>( + 202, + Aep.MEDIA_TYPE, + new PlatformSignResponses.Pending("pending", CONTEXT, "5"), + null, + Map.of())) + : Optional.empty())); + } + + void provision(String did) { + identities.add(fixture.platform() + .provision(new PlatformProvisionRequest(did), fixture.context("seed")) + .toCompletableFuture() + .join() + .body()); + } + + @Override + public CompletionStage execute(Request request) { + requests.add(request); + String path = request.uri().getPath(); + if (Aep.PLATFORM_WELL_KNOWN_PATH.equals(path)) + return CompletableFuture.completedFuture( + response(fixture.platform().discovery())); + if (!List.of("Bearer platform-access").equals(request.headers().get("Authorization"))) { + throw new IllegalArgumentException("Private Platform request lacks authentication"); + } + String key = request.headers() + .getOrDefault(IDEMPOTENCY_HEADER, List.of("")) + .get(0); + PlatformRequestContext context = fixture.context(key); + String body = new String(request.body(), StandardCharsets.UTF_8); + if (IDENTITY_PATH.equals(path) && "GET".equals(request.method())) { + String did = URLDecoder.decode( + request.uri().getRawQuery().split("service_did=", 2)[1], StandardCharsets.UTF_8); + return fixture.platform() + .list(new PlatformIdentityListQuery(true, 100, 0, did, null), context) + .thenApply(result -> { + listResponse = result.body(); + return response(result); + }); + } + if (IDENTITY_PATH.equals(path) && "POST".equals(request.method())) { + return fixture.platform() + .provision(AepJson.parsePlatformProvisionRequest(body), context) + .thenApply(result -> { + identities.add(result.body()); + return response(result); + }); + } + if (path.endsWith("/sign")) { + String identityId = path.substring(IDENTITY_PATH.length() + 1, path.length() - "/sign".length()); + return fixture.platform() + .sign(identityId, AepJson.parsePlatformSignRequest(body), context) + .thenApply(result -> { + signResponse = result.body(); + return response(result); + }); + } + throw new IllegalArgumentException("Unexpected Platform route: " + path); + } + + private static Response response(PlatformResponse result) { + return new Response( + result.status(), + Map.of("Content-Type", List.of(result.contentType())), + AepJson.write(result.problem() == null ? result.body() : result.problem()) + .getBytes(StandardCharsets.UTF_8)); + } + } +} diff --git a/tools/aep-conformance/src/main/java/foundation/aep/conformance/ConformanceSupport.java b/tools/aep-conformance/src/main/java/foundation/aep/conformance/ConformanceSupport.java index f002499..c5fc6d8 100644 --- a/tools/aep-conformance/src/main/java/foundation/aep/conformance/ConformanceSupport.java +++ b/tools/aep-conformance/src/main/java/foundation/aep/conformance/ConformanceSupport.java @@ -64,6 +64,35 @@ static boolean jsonEquals(Object value, JsonNode expected) { return json(AepJson.write(value)).equals(expected); } + /** + * Checks that writing a parsed value back out preserves everything the vector published. + * + * Straight equality fails on members the SDK materialises as canonical empty defaults, such as + * an absent {@code requested_scopes} becoming {@code []}. Every member the vector does publish + * must survive unchanged, and a member the round-trip added is only tolerated when it is an + * empty array or object; anything dropped, renamed, or altered fails. + */ + static boolean preserves(Object value, JsonNode source) { + return preserved(json(AepJson.write(value)), source); + } + + private static boolean preserved(JsonNode written, JsonNode source) { + if (!source.isObject()) return written.equals(source); + if (!written.isObject()) return false; + var names = source.fieldNames(); + while (names.hasNext()) { + String name = names.next(); + if (!written.has(name) || !preserved(written.get(name), source.get(name))) return false; + } + var added = written.fields(); + while (added.hasNext()) { + var entry = added.next(); + if (source.has(entry.getKey())) continue; + if (!entry.getValue().isContainerNode() || !entry.getValue().isEmpty()) return false; + } + return true; + } + static IllegalArgumentException unmapped(AdapterRequest request) { return new IllegalArgumentException("No " + request.role() + " operation maps vector " + request.vector().category() + "/" + request.vector().id()); diff --git a/tools/aep-conformance/src/main/java/foundation/aep/conformance/PlatformConformance.java b/tools/aep-conformance/src/main/java/foundation/aep/conformance/PlatformConformance.java index bc5f09d..81e49c7 100644 --- a/tools/aep-conformance/src/main/java/foundation/aep/conformance/PlatformConformance.java +++ b/tools/aep-conformance/src/main/java/foundation/aep/conformance/PlatformConformance.java @@ -36,13 +36,20 @@ import java.util.Optional; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionStage; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; final class PlatformConformance { + private static final String LIFETIME_SECONDS = "300"; + private static final String STATUS_FIELD = "status"; + private static final String CLIENT_ASSERTION = "client_assertion"; private static final String LIFECYCLE_RESPONSE = "lifecycle-response"; private static final String PROVISION_KEY = "provision"; private static final String SERVICE_DID_FIELD = "service_did"; private static final String VERIFY = "verify"; + private static final String SIGN_OPERATION = "sign"; + private static final String ASSERTION_ID = "01J0AEPASSERTION0000000001"; + private static final String IDEMPOTENCY_KEY_HEADER = "idempotency_key_header"; private static final Instant NOW = Instant.parse("2026-07-06T12:00:00Z"); private static final String PRINCIPAL = "stable-principal-123"; private static final String SERVICE_DID = "did:web:api.service.example"; @@ -51,9 +58,9 @@ private PlatformConformance() {} static boolean evaluate(AdapterRequest request) { return switch (request.vector().id()) { - case "authorization-required" -> authorization(); + case "authorization-required" -> authorization(request); case "discovery" -> discovery(request); - case "idempotency-replay-conflict" -> idempotency(); + case "idempotency-replay-conflict" -> idempotency(request); case "lifecycle-request", "lifecycle-response" -> lifecycle(request); case "list-response" -> list(request); case "provision-request", "provision-response" -> provision(request); @@ -62,21 +69,100 @@ static boolean evaluate(AdapterRequest request) { case "sign-response-pending" -> pendingSign(request); case "verification-authenticate-missing-resource" -> missingResource(request); case "verification-request" -> verificationRequest(request); - case "verification-response-recognized" -> recognizedVerification(); - case "verification-response-unrecognized" -> unrecognizedVerification(); + case "verification-response-recognized" -> recognizedVerification(request); + case "verification-response-unrecognized" -> unrecognizedVerification(request); default -> throw ConformanceSupport.unmapped(request); }; } - private static boolean authorization() { + private static boolean authorization(AdapterRequest vector) { if (!missingAuthorizerRejected()) return false; - Fixture denied = fixture(false, true, null); - PlatformResponse result = denied.platform() - .provision(new PlatformProvisionRequest(SERVICE_DID), denied.context(PROVISION_KEY)) + JsonNode expected = vector.testCase().expected(); + Fixture fixture = fixture(true, true, null); + var identity = provision(fixture, SERVICE_DID, PROVISION_KEY); + var sign = new PlatformSignRequest( + ASSERTION_ID, LIFETIME_SECONDS, AssertionOperation.ENROLL, null, null, SERVICE_DID); + var signed = fixture.platform() + .sign(identity.agentIdentityId(), sign, fixture.context("signed")) + .toCompletableFuture() + .join() + .body(); + if (!(signed instanceof PlatformSignResponses.Completed completed)) return false; + var verification = new PlatformVerificationRequest( + completed.clientAssertion(), AssertionOperation.ENROLL, null, SERVICE_DID); + fixture.authorized().set(false); + int effects = fixture.keys().effects.get(); + for (String operation : ConformanceSupport.strings(vector.testCase().input(), "private_operations")) { + int decisions = fixture.decisions().get(); + PlatformResponse denied = + switch (operation) { + case "get-identity" -> + fixture.platform() + .getIdentity(identity.agentIdentityId(), fixture.context("get")) + .toCompletableFuture() + .join(); + case "list-identities" -> + fixture.platform() + .list(PlatformIdentityListQuery.defaults(), fixture.context("list")) + .toCompletableFuture() + .join(); + case "provision-identity" -> + fixture.platform() + .provision( + new PlatformProvisionRequest(SERVICE_DID), fixture.context(PROVISION_KEY)) + .toCompletableFuture() + .join(); + case SIGN_OPERATION -> + fixture.platform() + .sign(identity.agentIdentityId(), sign, fixture.context("signed")) + .toCompletableFuture() + .join(); + case "update-identity" -> + fixture.platform() + .updateIdentity( + identity.agentIdentityId(), + new PlatformLifecycleRequest(ManagedAgentStatus.SUSPENDED), + fixture.context("update")) + .toCompletableFuture() + .join(); + case "verify" -> + fixture.platform() + .verify(verification, fixture.context(VERIFY)) + .toCompletableFuture() + .join(); + default -> throw new IllegalArgumentException("Unknown private operation: " + operation); + }; + if (fixture.decisions().get() <= decisions) return false; + if (VERIFY.equals(operation)) { + JsonNode body = ConformanceSupport.json(AepJson.write(denied.body())); + if (denied.status() != 200 + || body.path("verified").asBoolean() + != expected.path("verification_denied") + .path("verified") + .asBoolean() + || !body.path("reason") + .equals(expected.path("verification_denied").path("reason"))) return false; + } else if (denied.status() + != expected.path("management_denied_status").asInt() + || denied.problem() == null + || !denied.problem().code().equals(ConformanceSupport.text(expected, "management_denied_code"))) + return false; + } + var publicDid = fixture.platform() + .getDidDocument("4Yf7p2xQd9", new PlatformRequestContext(null, null)) + .toCompletableFuture() + .join(); + fixture.authorized().set(true); + var unchanged = fixture.platform() + .getIdentity(identity.agentIdentityId(), fixture.context("read")) .toCompletableFuture() .join(); - return result.status() == 404 - && "not_recognized".equals(result.problem().code()); + return publicDid.status() == 200 + && expected.path("did_document_public").asBoolean() + && !expected.path("side_effects").asBoolean() + && fixture.keys().effects.get() == effects + && ConformanceSupport.jsonEquals(unchanged.body(), ConformanceSupport.json(AepJson.write(identity))) + && "construction-error".equals(ConformanceSupport.text(expected, "missing_authorizer")); } private static boolean discovery(AdapterRequest request) { @@ -87,32 +173,146 @@ private static boolean discovery(AdapterRequest request) { response.body(), request.testCase().expected()); } - private static boolean idempotency() { + // Each operation in input.operations is replayed under one key and then retried with changed + // material, which must answer the conflict status and code expected names. + private static boolean idempotency(AdapterRequest request) { + JsonNode input = request.testCase().input(); + JsonNode expected = request.testCase().expected(); + int conflictStatus = ConformanceSupport.required(expected, "changed_input_or_operation_status") + .asInt(); + String conflictCode = ConformanceSupport.text(expected, "changed_input_or_operation_code"); + String signKey = ConformanceSupport.text(input, "initial_sign_key"); + for (JsonNode operation : ConformanceSupport.required(input, "operations")) { + if (!replays(operation.asText(), signKey, conflictStatus, conflictCode)) return false; + } + return "stored_http_result".equals(ConformanceSupport.text(expected, "exact_replay")) + && retains(Long.parseLong(ConformanceSupport.text(expected, "retention_seconds_minimum"))); + } + + /** + * expected.retention_seconds_minimum is how long a recorded result must stay replayable. A store + * that forgot the key earlier would provision a second identity under it instead of returning + * the first, which is exactly the duplicate the key exists to prevent. + */ + private static boolean retains(long seconds) { Fixture fixture = fixture(true, true, null); - PlatformRequestContext context = fixture.context("shared"); - PlatformResponse first = fixture.platform() - .provision(new PlatformProvisionRequest(SERVICE_DID), context) + PlatformAgentIdentity identity = provision(fixture, SERVICE_DID, PROVISION_KEY); + PlatformSignRequest signRequest = new PlatformSignRequest( + ASSERTION_ID, LIFETIME_SECONDS, AssertionOperation.ENROLL, null, null, SERVICE_DID); + // Signing is used rather than provisioning because a second provision for the same principal + // and Service returns the same identity whether or not the key was remembered, so it could + // not tell a retained result from a fresh one. A forgotten sign key mints a new assertion. + PlatformResponse recorded = fixture.platform() + .sign(identity.agentIdentityId(), signRequest, fixture.context("retained")) .toCompletableFuture() .join(); + fixture.advanceSeconds(seconds - 1); PlatformResponse replay = fixture.platform() - .provision(new PlatformProvisionRequest(SERVICE_DID), context) - .toCompletableFuture() - .join(); - PlatformResponse conflict = fixture.platform() - .provision(new PlatformProvisionRequest("did:web:other.example"), context) + .sign(identity.agentIdentityId(), signRequest, fixture.context("retained")) .toCompletableFuture() .join(); + return recorded.status() == replay.status() + && ConformanceSupport.json(AepJson.write(recorded.body())) + .equals(ConformanceSupport.json(AepJson.write(replay.body()))); + } + + /** Runs one replayable operation twice with one key, then once more with changed material. */ + private static boolean replays(String operation, String signKey, int conflictStatus, String conflictCode) { + Fixture fixture = fixture(true, true, null); + PlatformResponse first; + PlatformResponse replay; + PlatformResponse conflict; + switch (operation) { + case "provision" -> { + PlatformRequestContext context = fixture.context("shared"); + first = fixture.platform() + .provision(new PlatformProvisionRequest(SERVICE_DID), context) + .toCompletableFuture() + .join(); + replay = fixture.platform() + .provision(new PlatformProvisionRequest(SERVICE_DID), context) + .toCompletableFuture() + .join(); + conflict = fixture.platform() + .provision(new PlatformProvisionRequest("did:web:other.example"), context) + .toCompletableFuture() + .join(); + } + case SIGN_OPERATION -> { + PlatformAgentIdentity identity = provision(fixture, SERVICE_DID, PROVISION_KEY); + PlatformRequestContext context = fixture.context(signKey); + PlatformSignRequest signRequest = new PlatformSignRequest( + ASSERTION_ID, LIFETIME_SECONDS, AssertionOperation.ENROLL, null, null, SERVICE_DID); + first = fixture.platform() + .sign(identity.agentIdentityId(), signRequest, context) + .toCompletableFuture() + .join(); + replay = fixture.platform() + .sign(identity.agentIdentityId(), signRequest, context) + .toCompletableFuture() + .join(); + conflict = fixture.platform() + .sign( + identity.agentIdentityId(), + new PlatformSignRequest( + "01J0AEPASSERTION0000000002", + LIFETIME_SECONDS, + AssertionOperation.ENROLL, + null, + null, + SERVICE_DID), + context) + .toCompletableFuture() + .join(); + } + case "hosted_verification" -> { + PlatformAgentIdentity identity = provision(fixture, SERVICE_DID, PROVISION_KEY); + var response = fixture.platform() + .sign( + identity.agentIdentityId(), + new PlatformSignRequest( + ASSERTION_ID, + LIFETIME_SECONDS, + AssertionOperation.ENROLL, + null, + null, + SERVICE_DID), + fixture.context(signKey)) + .toCompletableFuture() + .join(); + if (!(response.body() instanceof PlatformSignResponses.Completed signed)) return false; + PlatformRequestContext context = fixture.context(VERIFY); + PlatformVerificationRequest verification = new PlatformVerificationRequest( + signed.clientAssertion(), AssertionOperation.ENROLL, null, SERVICE_DID); + first = fixture.platform() + .verify(verification, context) + .toCompletableFuture() + .join(); + replay = fixture.platform() + .verify(verification, context) + .toCompletableFuture() + .join(); + conflict = fixture.platform() + .verify( + new PlatformVerificationRequest( + signed.clientAssertion(), AssertionOperation.STATUS, null, SERVICE_DID), + context) + .toCompletableFuture() + .join(); + } + default -> throw new IllegalArgumentException("Unknown replayable operation: " + operation); + } return ConformanceSupport.jsonEquals(first.body(), ConformanceSupport.json(AepJson.write(replay.body()))) - && conflict.status() == 409 - && "idempotency_conflict".equals(conflict.problem().code()); + && conflict.status() == conflictStatus + && conflictCode.equals(conflict.problem().code()); } private static boolean lifecycle(AdapterRequest request) { Fixture fixture = fixture(true, true, null); PlatformAgentIdentity identity = provision(fixture, SERVICE_DID, PROVISION_KEY); - ManagedAgentStatus status = request.testCase().input().has("status") + ManagedAgentStatus status = request.testCase().input().has(STATUS_FIELD) ? ManagedAgentStatus.fromValue( - ConformanceSupport.text(request.testCase().input(), "status")) + ConformanceSupport.text(request.testCase().input(), STATUS_FIELD)) : ManagedAgentStatus.SUSPENDED; if (LIFECYCLE_RESPONSE.equals(request.vector().id())) fixture.advanceSeconds(600); PlatformResponse response = fixture.platform() @@ -135,7 +335,7 @@ private static boolean list(AdapterRequest request) { ConformanceSupport.required(queryNode, "limit").asInt(), ConformanceSupport.required(queryNode, "offset").asInt(), ConformanceSupport.text(queryNode, SERVICE_DID_FIELD), - ManagedAgentStatus.fromValue(ConformanceSupport.text(queryNode, "status"))); + ManagedAgentStatus.fromValue(ConformanceSupport.text(queryNode, STATUS_FIELD))); var response = fixture.platform() .list(query, fixture.context(null)) .toCompletableFuture() @@ -158,16 +358,10 @@ private static boolean provision(AdapterRequest request) { private static boolean distinctServices(AdapterRequest request) { Fixture fixture = fixture(true, true, null); - String firstDid = request.testCase() - .input() - .get("first_request") - .get(SERVICE_DID_FIELD) - .asText(); - String secondDid = request.testCase() - .input() - .get("second_request") - .get(SERVICE_DID_FIELD) - .asText(); + String firstDid = ConformanceSupport.text( + ConformanceSupport.required(request.testCase().input(), "first_request"), SERVICE_DID_FIELD); + String secondDid = ConformanceSupport.text( + ConformanceSupport.required(request.testCase().input(), "second_request"), SERVICE_DID_FIELD); PlatformAgentIdentity first = provision(fixture, firstDid, "first"); fixture.advanceMinute(); PlatformAgentIdentity second = provision(fixture, secondDid, "second"); @@ -178,21 +372,98 @@ private static boolean distinctServices(AdapterRequest request) { second, request.testCase().expected().get("second_response")); } + /** + * sign-request is about the request contract: PLT-SIGN_OPERATION-002 makes the Idempotency-Key header + * mandatory and PLT-IDP-005 makes an exact retry replay the stored result. sign-response is + * about the body, which is compared member by member against the one the vector publishes. + */ private static boolean sign(AdapterRequest request) { + boolean requestVector = "sign-request".equals(request.vector().id()); + PlatformSignRequest signRequest = requestVector + ? AepJson.parsePlatformSignRequest(request.testCase().input().toString()) + : signRequestFor(request.testCase().expected()); + if (requestVector) { + String key = ConformanceSupport.text(request.testCase().expected(), IDEMPOTENCY_KEY_HEADER); + return signRequiresAKey(signRequest) && signReplays(signRequest, key); + } Fixture fixture = fixture(true, true, null); PlatformAgentIdentity identity = provision(fixture, SERVICE_DID, PROVISION_KEY); - PlatformSignRequest signRequest = "sign-request".equals(request.vector().id()) - ? AepJson.parsePlatformSignRequest(request.testCase().input().toString()) - : new PlatformSignRequest( - "01J0AEPASSERTION0000000001", "300", AssertionOperation.ENROLL, null, null, SERVICE_DID); PlatformResponse response = fixture.platform() - .sign(identity.agentIdentityId(), signRequest, fixture.context("sign")) + .sign(identity.agentIdentityId(), signRequest, fixture.context(SIGN_OPERATION)) .toCompletableFuture() .join(); return response.status() == 200 && response.body() instanceof PlatformSignResponses.Completed completed - && !completed.clientAssertion().isBlank() - && signRequest.jwtId().equals(completed.jwtId()); + && matchesExpectedAssertion(completed, request.testCase().expected()); + } + + /** Rebuilds the request that produces the response the vector publishes. */ + private static PlatformSignRequest signRequestFor(JsonNode expected) { + JsonNode context = expected.get("platform_context"); + return new PlatformSignRequest( + ConformanceSupport.text(expected, "jti"), + LIFETIME_SECONDS, + AssertionOperation.ENROLL, + context == null ? null : AepJson.parseObject(context.toString(), "platform_context"), + null, + ConformanceSupport.text(expected, SERVICE_DID_FIELD)); + } + + /** + * Every member of the expected body is compared except client_assertion. + * + * That one is excluded because the token the vector publishes is illustrative and does not agree + * with the response it sits in: its `aud` is did:web:s.example where the body's service_did is + * did:web:api.service.example, and its iat/exp fall on 2026-07-07 where the body's issued_at and + * expires_at are 2026-07-06. PLT-SIGN_OPERATION-020 and PLT-SIGN_OPERATION-021 require the minted assertion to agree + * with the response carrying it, which is what {@link #boundToItsResponse} checks instead. + */ + private static boolean matchesExpectedAssertion(PlatformSignResponses.Completed completed, JsonNode expected) { + com.fasterxml.jackson.databind.node.ObjectNode actual = (com.fasterxml.jackson.databind.node.ObjectNode) + ConformanceSupport.json(AepJson.write(completed)).deepCopy(); + com.fasterxml.jackson.databind.node.ObjectNode published = + (com.fasterxml.jackson.databind.node.ObjectNode) expected.deepCopy(); + String minted = ConformanceSupport.text(actual, CLIENT_ASSERTION); + actual.remove(CLIENT_ASSERTION); + published.remove(CLIENT_ASSERTION); + return actual.equals(published) && boundToItsResponse(minted, completed); + } + + /** PLT-SIGN_OPERATION-021: iss and sub are the Agent DID, aud is the Service DID the response names. */ + private static boolean boundToItsResponse(String minted, PlatformSignResponses.Completed completed) { + ClientAssertionClaims claims = ClientAssertions.decodeUnverified(minted); + return claims.issuer().equals(completed.agentDid()) + && claims.subject().equals(completed.agentDid()) + && claims.audience().equals(completed.serviceDid()) + && claims.jwtId().equals(completed.jwtId()) + && Instant.ofEpochSecond(claims.issuedAt()).equals(Instant.parse(completed.issuedAt())) + && Instant.ofEpochSecond(claims.expiresAt()).equals(Instant.parse(completed.expiresAt())); + } + + private static boolean signRequiresAKey(PlatformSignRequest signRequest) { + Fixture fixture = fixture(true, true, null); + PlatformAgentIdentity identity = provision(fixture, SERVICE_DID, PROVISION_KEY); + var refused = fixture.platform() + .sign(identity.agentIdentityId(), signRequest, new PlatformRequestContext(PRINCIPAL, null)) + .toCompletableFuture() + .join(); + return refused.status() == 400; + } + + private static boolean signReplays(PlatformSignRequest signRequest, String key) { + Fixture fixture = fixture(true, true, null); + PlatformAgentIdentity identity = provision(fixture, SERVICE_DID, PROVISION_KEY); + PlatformRequestContext context = fixture.context(key); + var first = fixture.platform() + .sign(identity.agentIdentityId(), signRequest, context) + .toCompletableFuture() + .join(); + var replay = fixture.platform() + .sign(identity.agentIdentityId(), signRequest, context) + .toCompletableFuture() + .join(); + return first.status() == 200 + && ConformanceSupport.jsonEquals(first.body(), ConformanceSupport.json(AepJson.write(replay.body()))); } private static boolean pendingSign(AdapterRequest request) { @@ -218,7 +489,7 @@ private static boolean pendingSign(AdapterRequest request) { private static boolean missingResource(AdapterRequest request) { JsonNode input = ConformanceSupport.required(request.testCase().input(), "request"); PlatformVerificationRequest verification = new PlatformVerificationRequest( - ConformanceSupport.text(input, "client_assertion"), + ConformanceSupport.text(input, CLIENT_ASSERTION), AssertionOperation.fromValue(ConformanceSupport.text(input, "op")), null, ConformanceSupport.text(input, "service_did")); @@ -227,32 +498,69 @@ private static boolean missingResource(AdapterRequest request) { .verify(verification, new PlatformRequestContext(PRINCIPAL, VERIFY)) .toCompletableFuture() .join(); - return response.status() == 400; + // The status the Platform answers must track expected.valid, whichever polarity the vector + // publishes. + return !ConformanceSupport.valid(request) == (response.status() == 400); } + /** + * PLT-VER-002 makes the Idempotency-Key header mandatory on hosted verification. The request is + * round-tripped, refused without a key, accepted with the key the vector names, and an exact + * retry must replay the stored result. + */ private static boolean verificationRequest(AdapterRequest request) { PlatformVerificationRequest input = AepJson.parsePlatformVerificationRequest( request.testCase().input().toString()); + if (!ConformanceSupport.jsonEquals(input, request.testCase().input())) return false; + String key = ConformanceSupport.text(request.testCase().expected(), IDEMPOTENCY_KEY_HEADER); Fixture fixture = fixture(true, true, null); - var response = fixture.platform() - .verify(input, fixture.context(VERIFY)) + PlatformAgentIdentity identity = provision(fixture, input.serviceDid(), PROVISION_KEY); + var signed = fixture.platform() + .sign( + identity.agentIdentityId(), + new PlatformSignRequest( + ASSERTION_ID, + LIFETIME_SECONDS, + input.operation(), + null, + input.resource(), + input.serviceDid()), + fixture.context("sign-for-verification")) + .toCompletableFuture() + .join() + .body(); + if (!(signed instanceof PlatformSignResponses.Completed completed)) return false; + input = new PlatformVerificationRequest( + completed.clientAssertion(), input.operation(), input.resource(), input.serviceDid()); + var withoutKey = fixture.platform() + .verify(input, new PlatformRequestContext(PRINCIPAL, null)) .toCompletableFuture() .join(); - return response.status() == 200 - && !response.body().verified() - && "not_recognized".equals(response.body().reason()); + PlatformRequestContext context = fixture.context(key); + var first = + fixture.platform().verify(input, context).toCompletableFuture().join(); + var replay = + fixture.platform().verify(input, context).toCompletableFuture().join(); + return withoutKey.status() == 400 + && first.status() == 200 + && first.body().verified() + && replay.body().verified() + && ConformanceSupport.jsonEquals(first.body(), ConformanceSupport.json(AepJson.write(replay.body()))); } - private static boolean recognizedVerification() { + private static boolean recognizedVerification(AdapterRequest request) { Fixture fixture = fixture(true, true, null); PlatformAgentIdentity identity = provision(fixture, SERVICE_DID, PROVISION_KEY); PlatformSignRequest signRequest = new PlatformSignRequest("verification", null, AssertionOperation.ENROLL, null, null, SERVICE_DID); - var signed = (PlatformSignResponses.Completed) fixture.platform() - .sign(identity.agentIdentityId(), signRequest, fixture.context("sign")) + var body = fixture.platform() + .sign(identity.agentIdentityId(), signRequest, fixture.context(SIGN_OPERATION)) .toCompletableFuture() .join() .body(); + // A Pending body here raised a ClassCastException and was reported as an opaque failure + // rather than as the wrong kind of response. + if (!(body instanceof PlatformSignResponses.Completed signed)) return false; var verified = fixture.platform() .verify( new PlatformVerificationRequest( @@ -260,12 +568,11 @@ private static boolean recognizedVerification() { fixture.context(VERIFY)) .toCompletableFuture() .join(); - return verified.status() == 200 - && verified.body().verified() - && identity.agentIdentityId().equals(verified.body().agentIdentityId()); + JsonNode expected = request.testCase().expected(); + return verified.status() == 200 && ConformanceSupport.jsonEquals(verified.body(), expected); } - private static boolean unrecognizedVerification() { + private static boolean unrecognizedVerification(AdapterRequest request) { Fixture fixture = fixture(true, true, null); var response = fixture.platform() .verify( @@ -274,28 +581,36 @@ private static boolean unrecognizedVerification() { .toCompletableFuture() .join(); return response.status() == 200 - && !response.body().verified() - && "not_recognized".equals(response.body().reason()); + && ConformanceSupport.jsonEquals( + response.body(), request.testCase().expected()); } private static PlatformAgentIdentity provision(Fixture fixture, String serviceDid, String key) { - return fixture.platform() + PlatformResponse response = fixture.platform() .provision(new PlatformProvisionRequest(serviceDid), fixture.context(key)) .toCompletableFuture() - .join() - .body(); + .join(); + if (response.status() != 200 || response.body() == null) { + throw new IllegalArgumentException("Provisioning the conformance fixture returned " + response.status()); + } + return response.body(); } - private static Fixture fixture(boolean authorized, boolean hostedVerification, PlatformSignHandler handler) { + static Fixture fixture(boolean authorized, boolean hostedVerification, PlatformSignHandler handler) { try { ConformanceKeyStore keys = new ConformanceKeyStore(); AtomicInteger identity = new AtomicInteger(); AtomicInteger did = new AtomicInteger(); + AtomicBoolean permission = new AtomicBoolean(authorized); + AtomicInteger decisions = new AtomicInteger(); MutableClock clock = new MutableClock(NOW); AepPlatform.Builder builder = AepPlatform.builder( discovery(hostedVerification), "p.example", - (request, context) -> completed(authorized), + (request, context) -> { + decisions.incrementAndGet(); + return completed(permission.get()); + }, keys, (serviceDid, context) -> completed(true)) .clock(clock) @@ -306,7 +621,7 @@ private static Fixture fixture(boolean authorized, boolean hostedVerification, P () -> List.of("4Yf7p2xQd9", "9Lm2r8VnQ4").get(did.getAndIncrement())); if (hostedVerification) builder.replayStore(PlatformReplayStore.inMemory()); if (handler != null) builder.signHandler(handler); - return new Fixture(builder.build(), clock); + return new Fixture(builder.build(), clock, permission, decisions, keys); } catch (JOSEException exception) { throw new IllegalArgumentException("Unable to create Platform conformance key", exception); } @@ -343,14 +658,19 @@ private static PlatformDiscoveryDocument discovery(boolean hostedVerification) { new PlatformDiscoveryDocument.Identity( List.of(Aep.IDENTITY_METHOD_DID_WEB), "https://p.example/a/{agent_did_id}/did.json"), new PlatformDiscoveryDocument.Platform("did:web:p.example", hostedVerification, "Example Platform"), - new PlatformDiscoveryDocument.Signing(List.of("ES256"), "300")); + new PlatformDiscoveryDocument.Signing(List.of("ES256"), LIFETIME_SECONDS)); } private static CompletableFuture completed(T value) { return CompletableFuture.completedFuture(value); } - private record Fixture(AepPlatform platform, MutableClock clock) { + record Fixture( + AepPlatform platform, + MutableClock clock, + AtomicBoolean authorized, + AtomicInteger decisions, + ConformanceKeyStore keys) { PlatformRequestContext context(String key) { return new PlatformRequestContext(PRINCIPAL, key, clock.instant(), Map.of()); } @@ -394,6 +714,7 @@ public Instant instant() { private static final class ConformanceKeyStore implements PlatformKeyStore { private final ECKey key; + private final AtomicInteger effects = new AtomicInteger(); ConformanceKeyStore() throws JOSEException { key = new ECKeyGenerator(Curve.P_256).keyID("platform-key").generate(); @@ -401,6 +722,7 @@ private static final class ConformanceKeyStore implements PlatformKeyStore { @Override public CompletionStage create(PlatformIdentityRecord identity, PlatformRequestContext context) { + effects.incrementAndGet(); return completed(null); } @@ -414,6 +736,7 @@ public CompletionStage didVerificationMethod( @Override public CompletionStage sign( PlatformIdentityRecord identity, ClientAssertionClaims claims, PlatformRequestContext context) { + effects.incrementAndGet(); return completed(ClientAssertions.sign(claims, key, identity.keyId())); } diff --git a/tools/aep-conformance/src/main/java/foundation/aep/conformance/ServiceConformance.java b/tools/aep-conformance/src/main/java/foundation/aep/conformance/ServiceConformance.java index bad75d2..68d2690 100644 --- a/tools/aep-conformance/src/main/java/foundation/aep/conformance/ServiceConformance.java +++ b/tools/aep-conformance/src/main/java/foundation/aep/conformance/ServiceConformance.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.databind.JsonNode; import foundation.aep.core.Aep; +import foundation.aep.core.AepCommand; import foundation.aep.core.AepJson; import foundation.aep.core.AgentStatus; import foundation.aep.core.AssertionOperation; @@ -10,20 +11,28 @@ import foundation.aep.core.GrantRequest; import foundation.aep.core.GrantResponses; import foundation.aep.core.InspectDocument; +import foundation.aep.service.AepHttpRequest; +import foundation.aep.service.AepHttpResponse; import foundation.aep.service.AepService; +import foundation.aep.service.AepServiceHttpHandler; import foundation.aep.service.CommandOptions; import foundation.aep.service.EnrollmentDecision; import foundation.aep.service.GrantContext; import foundation.aep.service.GrantResult; import foundation.aep.service.GrantTypeDefinition; import foundation.aep.service.GrantTypeHandler; +import foundation.aep.service.IdempotencyInput; +import foundation.aep.service.IdempotencyResult; +import foundation.aep.service.IdempotencyStore; import foundation.aep.service.ProtectedResourceRequest; import foundation.aep.service.ProtectedResourceResult; import foundation.aep.service.RevokeContext; import foundation.aep.service.ServiceCredentialStore; +import foundation.aep.service.ServiceResponse; import foundation.aep.service.StoredCredentialGrantTypes; import java.net.URI; import java.time.Clock; +import java.time.Duration; import java.time.Instant; import java.time.ZoneOffset; import java.util.List; @@ -31,40 +40,200 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionStage; import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; final class ServiceConformance { + private static final String REVOKE = "revoke"; + private static final int HTTP_OK = 200; + private static final String GRANT_REVOKE = "grant-revoke"; private static final String AUTHORIZATION = "Authorization"; + private static final String SUPPORTED = "true"; + private static final String ENROLLMENT_ID = "enrollment-1"; + private static final String AUTHORIZATION_AMBIGUITY = "authorization-ambiguity"; private static final String AUTHORIZATION_FIELD_SAFETY = "authorization-field-safety"; private static final String CUSTOM_SESSION = "custom-session"; private static final String ENROLL = "enroll"; private static final String GRANT = "grant"; private static final String INSPECT = "inspect"; + private static final String CREDENTIAL_ID = "credential-1"; + private static final String GET_METHOD = "GET"; private static final Instant NOW = Instant.parse("2026-08-30T12:00:00Z"); private static final URI PROTECTED_RESOURCE = URI.create("https://service.example/private"); private static final String AGENT_DID = "did:web:agent.example"; private static final String SERVICE_DID = "did:web:service.example"; + private static final String VALIDATION_REQUIREMENTS = "validation-requirements"; + private static final String RESOURCE = "resource"; + private static final String STATUS = "status"; + private static final String CODE = "code"; private ServiceConformance() {} static boolean evaluate(AdapterRequest request) { + if (GRANT_REVOKE.equals(request.vector().category()) + && request.testCase().expected().has("authorization_scheme")) return commandWire(request); + if (VALIDATION_REQUIREMENTS.equals(request.vector().id())) return assertionValidation(request); Boolean shared = SharedConformance.evaluate(request); if (shared != null) return shared; return switch (request.vector().id()) { case "repeated-existing" -> repeatedEnrollment(); case "grant-before-enroll-rejected" -> grantBeforeEnrollment(request); - case "command-header", "command-replay-conflict", "enroll-conflict" -> idempotency(); + case "command-header" -> commandHeaders(request); + case "command-replay-conflict" -> commandReplay(request); + case "enroll-conflict" -> idempotency(request); case "transport-requirements" -> transport(request); case "api-key-wrong-header-rejected" -> apiKeyHeader(request); case "authenticate-assertion" -> authenticateAssertion(); - case "authorization-payment-composition" -> paymentComposition(); - case "operation-substitution-rejected" -> operationBinding(); - case "assertion-and-credential-failures", "authorization-ambiguity", "authorization-field-safety" -> + case "authorization-payment-composition" -> paymentComposition(request); + case "operation-substitution-rejected" -> operationBinding(request); + case "assertion-and-credential-failures", AUTHORIZATION_AMBIGUITY, AUTHORIZATION_FIELD_SAFETY -> protectedFailure(request); case "redirect-safety" -> redirectSafety(request); default -> throw ConformanceSupport.unmapped(request); }; } + private static boolean commandWire(AdapterRequest vector) { + if (!Boolean.TRUE.equals(SharedConformance.evaluate(vector))) return false; + JsonNode expected = vector.testCase().expected(); + var command = AepCommand.valueOf( + ConformanceSupport.text(expected, "client_assertion_op").toUpperCase(java.util.Locale.ROOT)); + String credentialId = vector.testCase().input().path("credential_id").asText(CREDENTIAL_ID); + var config = new InspectDocument.GrantTypeConfig(SUPPORTED); + AepService service = serviceBuilder(bearerDocument(config)) + .storedCredentialGrantType(StoredCredentialGrantTypes.oauthBearer( + config, + (request, context) -> completed(new GrantResponses.OAuthBearer( + "token", + credentialId, + NOW.plusSeconds(3600).toString(), + List.of(), + "opaque", + "Bearer")), + ServiceCredentialStore.inMemory())) + .build(); + service.enroll(new EnrollRequest(AGENT_DID, null, null), options(ENROLL, ENROLL)) + .toCompletableFuture() + .join(); + if (command == AepCommand.REVOKE) { + service.grant(new GrantRequest(Aep.GRANT_TYPE_OAUTH_BEARER, List.of()), options("seed", "seed")) + .toCompletableFuture() + .join(); + if (!service.authenticate(new ProtectedResourceRequest( + Map.of(AUTHORIZATION, List.of("Bearer token")), GET_METHOD, PROTECTED_RESOURCE)) + .toCompletableFuture() + .join() + .authenticated()) return false; + } + var request = new AepHttpRequest( + ConformanceSupport.text(expected, "method"), + URI.create("https://service.example" + ConformanceSupport.text(expected, "path")), + Map.of( + "Content-Type", + List.of(ConformanceSupport.text(expected, "content_type")), + AUTHORIZATION, + List.of(ConformanceSupport.text(expected, "authorization_scheme") + " wire"), + "Idempotency-Key", + List.of("wire")), + vector.testCase().input().toString().getBytes(java.nio.charset.StandardCharsets.UTF_8)); + var handler = new AepServiceHttpHandler(service); + if (!handler.routes().get(command).equals(request.url().getPath())) return false; + var response = handler.handle(command, request).toCompletableFuture().join(); + if (response.status() != HTTP_OK) return false; + boolean authenticated = service.authenticate(new ProtectedResourceRequest( + Map.of(AUTHORIZATION, List.of("Bearer token")), GET_METHOD, PROTECTED_RESOURCE)) + .toCompletableFuture() + .join() + .authenticated(); + return authenticated == (command == AepCommand.GRANT); + } + + private static boolean assertionValidation(AdapterRequest vector) { + JsonNode expected = vector.testCase().expected(); + try { + var key = new com.nimbusds.jose.jwk.gen.ECKeyGenerator(com.nimbusds.jose.jwk.Curve.P_256).generate(); + var verifier = foundation.aep.service.ClientAssertionVerifier.withKeyResolver( + (assertion, claims, context) -> completed(key.toPublicJWK())); + var originalClaims = (com.fasterxml.jackson.databind.node.ObjectNode) expected.get("claims"); + var originalHeader = (com.fasterxml.jackson.databind.node.ObjectNode) expected.get("header"); + var clock = + Clock.fixed(Instant.ofEpochSecond(originalClaims.path("iat").asLong()), ZoneOffset.UTC); + var context = new foundation.aep.service.AssertionVerificationContext( + originalClaims.path("aud").asText(), + AssertionOperation.ENROLL, + null, + null, + List.of("ES256"), + clock, + Duration.ZERO, + false); + String valid = signedAssertion(originalHeader, originalClaims, key); + if (!ConformanceSupport.jsonEquals( + verifier.verify(valid, context).toCompletableFuture().join(), originalClaims)) { + return false; + } + for (String rejection : ConformanceSupport.strings(expected, "reject")) { + var header = originalHeader.deepCopy(); + var claims = originalClaims.deepCopy(); + String resource = "https://api.example.com/private"; + boolean authenticate = List.of("fragmented_resource", "insecure_resource", "missing_resource") + .contains(rejection); + if (authenticate) { + claims.put("op", "authenticate"); + claims.put(RESOURCE, resource); + } + switch (rejection) { + case "excessive_lifetime" -> + claims.put("exp", claims.path("iat").asLong() + 301); + case "nonpositive_lifetime" -> + claims.put("exp", claims.path("iat").asLong()); + case "fragmented_resource" -> claims.put(RESOURCE, resource + "#fragment"); + case "insecure_resource" -> claims.put(RESOURCE, "http://api.example.com/private"); + case "missing_resource" -> claims.remove(RESOURCE); + case "unexpected_resource" -> claims.put(RESOURCE, resource); + case "mismatched_kid" -> header.put("kid", "did:web:other.example"); + case "missing_kid" -> header.remove("kid"); + case "wrong_typ" -> header.put("typ", "not-jwt"); + case "mismatched_subject" -> claims.put("sub", "did:web:other.example"); + default -> { + return false; + } + } + String assertion = signedAssertion(header, claims, key); + var rejectionContext = new foundation.aep.service.AssertionVerificationContext( + context.serviceDid(), + authenticate ? AssertionOperation.AUTHENTICATE : AssertionOperation.ENROLL, + authenticate ? resource : null, + null, + List.of("ES256"), + clock, + Duration.ZERO, + false); + try { + verifier.verify(assertion, rejectionContext) + .toCompletableFuture() + .join(); + return false; + } catch (IllegalArgumentException rejected) { + if (rejected.getMessage() == null) throw rejected; + } catch (java.util.concurrent.CompletionException rejected) { + if (!(rejected.getCause() instanceof IllegalArgumentException)) throw rejected; + } + } + return true; + } catch (com.nimbusds.jose.JOSEException | java.text.ParseException failure) { + throw new IllegalArgumentException("Unable to sign conformance assertion", failure); + } + } + + private static String signedAssertion(JsonNode header, JsonNode claims, com.nimbusds.jose.jwk.ECKey key) + throws java.text.ParseException, com.nimbusds.jose.JOSEException { + var token = new com.nimbusds.jwt.SignedJWT( + com.nimbusds.jose.JWSHeader.parse(header.toString()), + com.nimbusds.jwt.JWTClaimsSet.parse(claims.toString())); + token.sign(new com.nimbusds.jose.crypto.ECDSASigner(key)); + return token.serialize(); + } + private static boolean repeatedEnrollment() { AtomicInteger decisions = new AtomicInteger(); AepService service = serviceBuilder(document(List.of(ENROLL, INSPECT), List.of(Aep.AUTHENTICATION_METHOD_JWT))) @@ -87,21 +256,146 @@ private static boolean repeatedEnrollment() { private static boolean grantBeforeEnrollment(AdapterRequest request) { AepService service = serviceBuilder( - document(List.of(GRANT, INSPECT, "status"), List.of(Aep.AUTHENTICATION_METHOD_JWT))) + document(List.of(GRANT, INSPECT, STATUS), List.of(Aep.AUTHENTICATION_METHOD_JWT))) .grantType(new GrantTypeDefinition(CUSTOM_SESSION, new Handler())) .build(); var response = service.grant(new GrantRequest(CUSTOM_SESSION, List.of()), options("grant", "grant")) .toCompletableFuture() .join(); return response.status() - == ConformanceSupport.required(request.testCase().expected(), "status") + == ConformanceSupport.required(request.testCase().expected(), STATUS) .asInt() && response.problem() .code() - .equals(ConformanceSupport.text(request.testCase().expected(), "code")); + .equals(ConformanceSupport.text(request.testCase().expected(), CODE)); } - private static boolean idempotency() { + private static boolean commandHeaders(AdapterRequest vector) { + JsonNode expected = vector.testCase().expected(); + String key = ConformanceSupport.text(vector.testCase().input(), "idempotency_key"); + AepService service = serviceBuilder( + document(List.of(ENROLL, GRANT, REVOKE, INSPECT), List.of(Aep.AUTHENTICATION_METHOD_JWT))) + .grantType(new GrantTypeDefinition(CUSTOM_SESSION, new Handler())) + .build(); + AepServiceHttpHandler handler = new AepServiceHttpHandler(service); + for (String name : ConformanceSupport.strings(vector.testCase().input(), "commands")) { + AepCommand command = AepCommand.valueOf(name.toUpperCase(java.util.Locale.ROOT)); + Object body = command == AepCommand.ENROLL + ? new EnrollRequest(AGENT_DID, null, null) + : Map.of("grant_type", CUSTOM_SESSION); + for (List values : List.of(List.of(), List.of(""))) { + var headers = new java.util.LinkedHashMap>(); + headers.put("Content-Type", List.of(Aep.MEDIA_TYPE)); + headers.put(AUTHORIZATION, List.of("AEP " + name + values.size())); + if (!values.isEmpty()) headers.put("Idempotency-Key", values); + var result = handler.handle( + command, + new AepHttpRequest( + "POST", + URI.create("https://service.example/aep/" + name), + headers, + AepJson.write(body).getBytes(java.nio.charset.StandardCharsets.UTF_8))) + .toCompletableFuture() + .join(); + if (result.status() != expected.path("missing_or_empty_status").asInt() + || !ConformanceSupport.json(result.bodyText()) + .path(CODE) + .asText() + .equals(ConformanceSupport.text(expected, "missing_or_empty_code"))) return false; + } + } + var omitted = service.enroll(new EnrollRequest(AGENT_DID, null, null), options("omitted", key)) + .toCompletableFuture() + .join(); + var matching = service.enroll( + new EnrollRequest(AGENT_DID, null, key + "-matching"), options("matching", key + "-matching")) + .toCompletableFuture() + .join(); + var mismatched = service.enroll(new EnrollRequest(AGENT_DID, null, "different"), options("mismatch", key)) + .toCompletableFuture() + .join(); + return expected.path("header_required").asBoolean() + && "optional".equals(ConformanceSupport.text(expected, "enroll_body_key")) + && omitted.status() == 200 + && matching.status() == 200 + && mismatched.status() + == expected.path("mismatched_enroll_body_status").asInt(); + } + + private static boolean commandReplay(AdapterRequest vector) { + JsonNode input = vector.testCase().input(); + JsonNode expected = vector.testCase().expected(); + String key = ConformanceSupport.text(input, "idempotency_key"); + AepService service = serviceBuilder( + document(List.of(ENROLL, GRANT, REVOKE, INSPECT), List.of(Aep.AUTHENTICATION_METHOD_JWT))) + .grantType(new GrantTypeDefinition(CUSTOM_SESSION, new Handler())) + .build(); + service.enroll(new EnrollRequest(AGENT_DID, null, null), options(ENROLL, ENROLL)) + .toCompletableFuture() + .join(); + var grant = new GrantRequest(CUSTOM_SESSION, List.of()); + var first = service.grant(grant, options("grant-first", key)) + .toCompletableFuture() + .join(); + var replay = service.grant(grant, options("grant-replay", key)) + .toCompletableFuture() + .join(); + var changedBody = service.grant( + new GrantRequest(CUSTOM_SESSION, List.of("other")), options("grant-conflict", key)) + .toCompletableFuture() + .join(); + var changedCommand = service.revoke( + foundation.aep.core.RevokeRequest.grantType(CUSTOM_SESSION), options("revoke-conflict", key)) + .toCompletableFuture() + .join(); + if (first.status() != 200 + || replay.status() != 200 + || !ConformanceSupport.jsonEquals(first.body(), ConformanceSupport.json(AepJson.write(replay.body()))) + || !conflictMatches(changedBody, expected.path("changed_body")) + || !conflictMatches(changedCommand, expected.path("changed_command"))) return false; + + MovableClock clock = new MovableClock(NOW); + IdempotencyStore store = IdempotencyStore.inMemory(clock); + AtomicInteger executions = new AtomicInteger(); + java.util.function.Supplier>> operation = + () -> completed(ServiceResponse.success(executions.incrementAndGet())); + var original = new IdempotencyInput( + ConformanceSupport.text(input, "agent_did"), + key, + ConformanceSupport.text(input, "first_command"), + ConformanceSupport.text(input, "first_body_hash")); + var created = store.execute(original, operation).toCompletableFuture().join(); + clock.advance( + Duration.ofSeconds(expected.path("retention_seconds_minimum").asLong())); + var retained = store.execute(original, operation).toCompletableFuture().join(); + var otherAgent = store.execute( + new IdempotencyInput("did:web:other.example", key, original.command(), original.requestHash()), + operation) + .toCompletableFuture() + .join(); + var otherKey = store.execute( + new IdempotencyInput( + original.agentDid(), key + "-other", original.command(), original.requestHash()), + operation) + .toCompletableFuture() + .join(); + return created.state() == IdempotencyResult.State.CREATED + && retained.state() == IdempotencyResult.State.REPLAYED + && created.response().equals(retained.response()) + && otherAgent.state() == IdempotencyResult.State.CREATED + && otherKey.state() == IdempotencyResult.State.CREATED + && executions.get() == 3 + && ConformanceSupport.strings(expected, "scope").equals(List.of("agent_did", "idempotency_key")) + && "cached_or_equivalent_success".equals(ConformanceSupport.text(expected, "exact_retry")); + } + + private static boolean conflictMatches(ServiceResponse response, JsonNode expected) { + return response.status() == expected.path(STATUS).asInt() + && response.problem() != null + && response.problem().code().equals(ConformanceSupport.text(expected, CODE)); + } + + private static boolean idempotency(AdapterRequest vector) { AepService service = serviceBuilder(document(List.of(ENROLL, INSPECT), List.of(Aep.AUTHENTICATION_METHOD_JWT))) .build(); EnrollRequest request = new EnrollRequest(AGENT_DID, null, null); @@ -122,13 +416,43 @@ private static boolean idempotency() { .toCompletableFuture() .join(); return ConformanceSupport.jsonEquals(first.body(), ConformanceSupport.json(AepJson.write(replay.body()))) - && conflict.status() == 409 - && "idempotency_conflict".equals(conflict.problem().code()); + && conflict.status() + == vector.testCase().expected().path(STATUS).asInt() + && conflict.contentType() + .equals(ConformanceSupport.text(vector.testCase().expected(), "content_type")) + && conflictMatches(conflict, vector.testCase().expected().path("body")) + && conflict.problem() + .type() + .equals(ConformanceSupport.text( + vector.testCase().expected().path("body"), "type")) + && conflict.problem() + .title() + .equals(ConformanceSupport.text( + vector.testCase().expected().path("body"), "title")); } + // CORE-CACHE-008: the media-type essence is compared case-insensitively and valid parameters are + // ignored. The HTTP binding is driven with input.content_type verbatim, and must accept that + // field value while refusing application/json. private static boolean transport(AdapterRequest request) { - String type = ConformanceSupport.text(request.testCase().input(), "content_type"); - return type.toLowerCase(java.util.Locale.ROOT).startsWith(Aep.MEDIA_TYPE); + String contentType = ConformanceSupport.text(request.testCase().input(), "content_type"); + String essence = ConformanceSupport.text(request.testCase().expected(), "media_type_essence"); + AepServiceHttpHandler handler = new AepServiceHttpHandler( + serviceBuilder(document(List.of(ENROLL, INSPECT), List.of(Aep.AUTHENTICATION_METHOD_JWT))) + .build()); + AepHttpResponse inspect = handler.handle(AepCommand.INSPECT, get(Aep.WELL_KNOWN_PATH)) + .toCompletableFuture() + .join(); + AepHttpResponse accepted = handler.handle(AepCommand.ENROLL, enrollPost(contentType)) + .toCompletableFuture() + .join(); + AepHttpResponse refused = handler.handle(AepCommand.ENROLL, enrollPost("application/json")) + .toCompletableFuture() + .join(); + return essence.equals(inspect.contentType()) + && inspect.status() == 200 + && accepted.status() == 200 + && refused.status() == 415; } private static boolean apiKeyHeader(AdapterRequest request) { @@ -136,12 +460,12 @@ private static boolean apiKeyHeader(AdapterRequest request) { String issued = ConformanceSupport.text(input, "issued_header"); String presented = ConformanceSupport.text(input, "presented_header"); String apiKey = ConformanceSupport.text(input, "api_key"); - InspectDocument.GrantTypeConfig config = - new InspectDocument.GrantTypeConfig(null, null, List.of(issued), null, List.of(), "true", null, null); + InspectDocument.GrantTypeConfig config = new InspectDocument.GrantTypeConfig( + null, null, List.of(issued), null, List.of(), SUPPORTED, null, null); var stored = StoredCredentialGrantTypes.apiKey( config, (grant, context) -> completed(new GrantResponses.ApiKey( - apiKey, "credential-1", NOW.plusSeconds(3600).toString(), issued, List.of())), + apiKey, CREDENTIAL_ID, NOW.plusSeconds(3600).toString(), issued, List.of())), ServiceCredentialStore.inMemory()); AepService service = serviceBuilder(apiKeyDocument(config)) .storedCredentialGrantType(stored) @@ -152,12 +476,12 @@ private static boolean apiKeyHeader(AdapterRequest request) { service.grant(new GrantRequest(Aep.GRANT_TYPE_API_KEY, List.of()), options(GRANT, GRANT)) .toCompletableFuture() .join(); - ProtectedResourceResult result = service.authenticate( - new ProtectedResourceRequest(Map.of(presented, List.of(apiKey)), "GET", PROTECTED_RESOURCE)) + ProtectedResourceResult result = service.authenticate(new ProtectedResourceRequest( + Map.of(presented, List.of(apiKey)), GET_METHOD, PROTECTED_RESOURCE)) .toCompletableFuture() .join(); return !result.authenticated() - && ConformanceSupport.text(request.testCase().expected(), "code") + && ConformanceSupport.text(request.testCase().expected(), CODE) .equals(result.response().problem().code()); } @@ -168,7 +492,7 @@ private static boolean authenticateAssertion() { .toCompletableFuture() .join(); ProtectedResourceResult result = service.authenticate(new ProtectedResourceRequest( - Map.of(AUTHORIZATION, List.of("AEP authenticate")), "GET", PROTECTED_RESOURCE)) + Map.of(AUTHORIZATION, List.of("AEP authenticate")), GET_METHOD, PROTECTED_RESOURCE)) .toCompletableFuture() .join(); return result.authenticated() @@ -176,44 +500,454 @@ private static boolean authenticateAssertion() { && Aep.AUTHENTICATION_METHOD_JWT.equals(result.principal().authenticationMethod()); } - private static boolean paymentComposition() { - Map> headers = Map.of( - Aep.AUTHORIZATION_HEADER, List.of("AEP assertion"), AUTHORIZATION, List.of("Payment credential")); - return headers.size() == 2 && !Aep.AUTHORIZATION_HEADER.equals(AUTHORIZATION); + // An AEP credential on the dedicated carrier composes with an unrelated payment credential on + // Authorization without either becoming ambiguous. Each composition expected describes is + // presented to the Service, which must authenticate the Agent from it. + private static boolean paymentComposition(AdapterRequest request) { + var compositions = request.testCase().expected().fields(); + while (compositions.hasNext()) { + var composition = compositions.next(); + JsonNode value = composition.getValue(); + if (!value.isObject() || !value.has(Aep.AUTHORIZATION_HEADER)) continue; + if (value.path("ambiguous").asBoolean()) return false; + String carried = ConformanceSupport.text(value, Aep.AUTHORIZATION_HEADER); + Map> headers = new java.util.LinkedHashMap<>(); + value.fields().forEachRemaining(field -> { + if (field.getValue().isTextual()) + headers.put(field.getKey(), List.of(field.getValue().asText())); + }); + // The AEP credential on the dedicated carrier is an assertion in one composition and a + // bearer session credential in the other; the Service under test has to accept whichever + // the composition carries while leaving the payment credential beside it alone. + AepService service = + carried.startsWith(Aep.AUTHENTICATION_SCHEME + " ") ? enrolledService() : bearerService(carried); + ProtectedResourceResult result = service.authenticate( + new ProtectedResourceRequest(headers, GET_METHOD, PROTECTED_RESOURCE)) + .toCompletableFuture() + .join(); + if (!result.authenticated() || !AGENT_DID.equals(result.principal().agentDid())) return false; + } + return true; + } + + /** A Service that has issued the bearer session credential a composition presents. */ + private static AepService bearerService(String carried) { + String token = carried.substring(carried.indexOf(' ') + 1); + InspectDocument.GrantTypeConfig config = new InspectDocument.GrantTypeConfig(SUPPORTED); + AepService service = serviceBuilder(bearerDocument(config)) + .storedCredentialGrantType(StoredCredentialGrantTypes.oauthBearer( + config, + (grant, context) -> completed(new GrantResponses.OAuthBearer( + token, CREDENTIAL_ID, NOW.plusSeconds(3600).toString(), List.of(), "opaque", "Bearer")), + ServiceCredentialStore.inMemory())) + .build(); + service.enroll(new EnrollRequest(AGENT_DID, null, null), options(ENROLL, ENROLL)) + .toCompletableFuture() + .join(); + service.grant(new GrantRequest(Aep.GRANT_TYPE_OAUTH_BEARER, List.of()), options(GRANT, GRANT)) + .toCompletableFuture() + .join(); + return service; + } + + private static InspectDocument bearerDocument(InspectDocument.GrantTypeConfig config) { + return InspectDocument.builder() + .version(Aep.VERSION) + .authentication(new InspectDocument.Authentication( + List.of(Aep.AUTHENTICATION_METHOD_JWT, Aep.GRANT_TYPE_OAUTH_BEARER))) + .bindings(new InspectDocument.Bindings(List.of("http"))) + .claims(new InspectDocument.Claims(List.of(), List.of(), List.of())) + .commands(new InspectDocument.Commands( + List.of(ENROLL, GRANT, REVOKE, INSPECT), + List.of(Aep.GRANT_TYPE_OAUTH_BEARER), + Map.of(Aep.GRANT_TYPE_OAUTH_BEARER, config))) + .core(new InspectDocument.Core(Aep.REQUIRED_SIGNING_ALGORITHMS)) + .extensions(new InspectDocument.Extensions(List.of())) + .http(new InspectDocument.Http(Aep.DEFAULT_ENDPOINT_BASE, null)) + .identity(new InspectDocument.Identity(List.of(Aep.IDENTITY_METHOD_DID_WEB))) + .service(new InspectDocument.Service(SERVICE_DID)) + .build(); } - private static boolean operationBinding() { - for (AssertionOperation operation : AssertionOperation.values()) { - String resource = operation == AssertionOperation.AUTHENTICATE ? PROTECTED_RESOURCE.toString() : null; - ClientAssertionClaims claims = claims(operation, operation.value(), resource); - if (!foundation.aep.core.AepValidation.clientAssertionClaims(claims).isEmpty()) return false; + // expected.all_other_pairs is "not_recognized": only an authenticate assertion opens a protected + // resource, and only a command's own assertion reaches that command. A well-formed assertion + // carrying any other operation is substituted, and the Service must refuse it under that code. + private static boolean operationBinding(AdapterRequest request) { + String substituted = ConformanceSupport.text(request.testCase().expected(), "all_other_pairs"); + for (JsonNode value : ConformanceSupport.required(request.testCase().input(), "operations")) { + AssertionOperation minted = AssertionOperation.fromValue(value.asText()); + for (String endpoint : ConformanceSupport.strings(request.testCase().input(), "command_endpoints")) { + AepService commandService = substitutingService(minted); + CommandOptions options = options("substitution-" + endpoint, "substitution-" + endpoint); + ServiceResponse response = + switch (endpoint) { + case ENROLL -> + commandService + .enroll(new EnrollRequest(AGENT_DID, null, null), options) + .toCompletableFuture() + .join(); + case GRANT -> + commandService + .grant(new GrantRequest(CUSTOM_SESSION, List.of()), options) + .toCompletableFuture() + .join(); + case REVOKE -> + commandService + .revoke(foundation.aep.core.RevokeRequest.grantType(CUSTOM_SESSION), options) + .toCompletableFuture() + .join(); + case STATUS -> + commandService + .status(options) + .toCompletableFuture() + .join(); + default -> throw new IllegalArgumentException("Unknown command endpoint: " + endpoint); + }; + boolean allowed = ConformanceSupport.strings(request.testCase().expected(), "allowed") + .contains(minted.value() + ":" + endpoint); + if (allowed + ? response.status() != HTTP_OK + : response.problem() == null + || !substituted.equals(response.problem().code())) return false; + } + AepService service = substitutingService(minted); + ProtectedResourceResult result = service.authenticate(new ProtectedResourceRequest( + Map.of(AUTHORIZATION, List.of("AEP substituted")), GET_METHOD, PROTECTED_RESOURCE)) + .toCompletableFuture() + .join(); + if (minted == AssertionOperation.AUTHENTICATE) { + if (result.authenticated()) continue; + return false; + } + if (result.authenticated() + || !substituted.equals(result.response().problem().code())) return false; } return true; } private static boolean protectedFailure(AdapterRequest request) { - if (AUTHORIZATION_FIELD_SAFETY.equals(request.vector().id())) { - return Aep.AUTHORIZATION_HEADER.equalsIgnoreCase( - ConformanceSupport.text(request.testCase().input(), "field_name")); + if (AUTHORIZATION_FIELD_SAFETY.equals(request.vector().id())) return fieldNameSafety(request); + if (AUTHORIZATION_AMBIGUITY.equals(request.vector().id())) return authorizationAmbiguity(request); + return credentialFailures(request); + } + + /** + * expected maps each of the vector's seven failure modes to the code the Service must report. + * Every mode the SDK decides is driven against its own code, so the three distinct codes stay + * distinguishable rather than collapsing into one. + */ + private static boolean credentialFailures(AdapterRequest request) { + JsonNode expected = request.testCase().expected(); + for (JsonNode value : ConformanceSupport.required(request.testCase().input(), "cases")) { + String mode = value.asText(); + Boolean outcome = failureMode(mode, ConformanceSupport.text(expected, mode)); + // insufficient_scope is reported by the application that owns the scope, from the scopes + // the match carries; the SDK has no scope policy of its own to exercise here. + if (outcome != null && !outcome) return false; } - AepService service = serviceBuilder(document(List.of(INSPECT), List.of(Aep.AUTHENTICATION_METHOD_JWT))) + return true; + } + + private static Boolean failureMode(String mode, String code) { + return switch (mode) { + // A field value carrying a scheme and no credentials is not a presentation at all. + case "malformed_credential" -> rejected(Map.of(AUTHORIZATION, List.of("AEP")), code); + // Bearer against a document advertising only aep-jwt names a method the Service does not + // offer, which is a different answer from "the credential was not recognized". + case "unsupported_method" -> rejected(Map.of(AUTHORIZATION, List.of("Bearer opaque")), code); + case "wrong_audience" -> corrupted(claims -> withAudience(claims, "did:web:elsewhere.example"), code); + case "expired_assertion" -> corrupted(claims -> withExpiry(claims, NOW.minusSeconds(600)), code); + case "replayed_assertion" -> replayed(code); + case "expired_credential" -> expiredCredential(code); + case "insufficient_scope" -> null; + default -> throw new IllegalArgumentException("Unknown protected-resource failure mode: " + mode); + }; + } + + /** A Service whose verifier returns claims the protected-resource path must refuse to accept. */ + private static boolean corrupted(java.util.function.UnaryOperator corrupt, String code) { + AtomicReference> active = new AtomicReference<>(); + AepService service = AepService.builder( + document(List.of(ENROLL, INSPECT), List.of(Aep.AUTHENTICATION_METHOD_JWT)), + (assertion, context) -> { + ClientAssertionClaims issued = claims(context.operation(), assertion, context.resource()); + var mutate = active.get(); + return completed(mutate == null ? issued : mutate.apply(issued)); + }) + .clock(Clock.fixed(NOW, ZoneOffset.UTC)) + .identifierSupplier(() -> ENROLLMENT_ID) + .build(); + // Enrollment runs through the same verifier, so the corruption starts once the Agent is + // ACTIVE and the protected-resource path is the thing under test. + service.enroll(new EnrollRequest(AGENT_DID, null, null), options(ENROLL, ENROLL)) + .toCompletableFuture() + .join(); + active.set(corrupt); + return rejectedBy(service, Map.of(AUTHORIZATION, List.of("AEP assertion")), code); + } + + /** The same assertion presented twice: the second presentation is a replay the Service consumes once. */ + private static boolean replayed(String code) { + AepService service = enrolledService(); + Map> headers = Map.of(AUTHORIZATION, List.of("AEP replayed-assertion")); + ProtectedResourceResult first = service.authenticate( + new ProtectedResourceRequest(headers, GET_METHOD, PROTECTED_RESOURCE)) + .toCompletableFuture() + .join(); + return first.authenticated() && rejectedBy(service, headers, code); + } + + /** + * A stored session credential past its expiry no longer authenticates. The credential is minted + * with a lifetime the Service accepts and the clock is then moved past it, because issuing one + * that has already expired is refused at the Grant, which would test the wrong thing. + */ + private static boolean expiredCredential(String code) { + InspectDocument.GrantTypeConfig config = new InspectDocument.GrantTypeConfig(SUPPORTED); + MovableClock clock = new MovableClock(NOW); + AepService service = AepService.builder( + bearerDocument(config), + (assertion, context) -> completed(claims(context.operation(), assertion, context.resource()))) + .clock(clock) + .identifierSupplier(() -> ENROLLMENT_ID) + .storedCredentialGrantType(StoredCredentialGrantTypes.oauthBearer( + config, + (grant, context) -> completed(new GrantResponses.OAuthBearer( + "stale-token", + CREDENTIAL_ID, + NOW.plusSeconds(60).toString(), + List.of(), + "opaque", + "Bearer")), + ServiceCredentialStore.inMemory())) .build(); - Map> headers = - "authorization-ambiguity".equals(request.vector().id()) - ? Map.of(Aep.AUTHORIZATION_HEADER, List.of("AEP first", "AEP second")) - : Map.of(AUTHORIZATION, List.of("AEP malformed")); + service.enroll(new EnrollRequest(AGENT_DID, null, null), options(ENROLL, ENROLL)) + .toCompletableFuture() + .join(); + service.grant(new GrantRequest(Aep.GRANT_TYPE_OAUTH_BEARER, List.of()), options(GRANT, GRANT)) + .toCompletableFuture() + .join(); + clock.advance(Duration.ofSeconds(120)); + return rejectedBy(service, Map.of(AUTHORIZATION, List.of("Bearer stale-token")), code); + } + + private static final class MovableClock extends Clock { + private Instant current; + + MovableClock(Instant start) { + this.current = start; + } + + void advance(Duration amount) { + current = current.plus(amount); + } + + @Override + public java.time.ZoneId getZone() { + return ZoneOffset.UTC; + } + + @Override + public Clock withZone(java.time.ZoneId zone) { + return this; + } + + @Override + public Instant instant() { + return current; + } + } + + private static ClientAssertionClaims withAudience(ClientAssertionClaims claims, String audience) { + return new ClientAssertionClaims( + claims.issuer(), + claims.subject(), + audience, + claims.operation(), + claims.issuedAt(), + claims.expiresAt(), + claims.jwtId(), + claims.resource()); + } + + private static ClientAssertionClaims withExpiry(ClientAssertionClaims claims, Instant expiresAt) { + return new ClientAssertionClaims( + claims.issuer(), + claims.subject(), + claims.audience(), + claims.operation(), + expiresAt.minusSeconds(60).getEpochSecond(), + expiresAt.getEpochSecond(), + claims.jwtId(), + claims.resource()); + } + + /** + * input.cases names four ambiguous presentations and expected names one outcome for all of them: + * the published code, with no fallback to the standard field and no credential selected. Every + * case is driven and every member of expected is read. + */ + private static boolean authorizationAmbiguity(AdapterRequest request) { + JsonNode expected = request.testCase().expected(); + if (ConformanceSupport.required(expected, "fallback").asBoolean() + || !ConformanceSupport.required(expected, "selected_credential").isNull()) return false; + String code = ConformanceSupport.text(expected, CODE); + for (JsonNode value : ConformanceSupport.required(request.testCase().input(), "cases")) { + if (!rejected(ambiguousHeaders(value.asText()), code)) return false; + } + return true; + } + + private static Map> ambiguousHeaders(String name) { + return switch (name) { + // An AEP credential in both carriers at once names no single presentation to verify. + case "both-aep-recognized" -> + Map.of(Aep.AUTHORIZATION_HEADER, List.of("AEP dedicated"), AUTHORIZATION, List.of("AEP standard")); + // The malformed dedicated field is the presentation; the valid standard one beside it is + // not a fallback the Service may authenticate instead. + case "invalid-dedicated-plus-valid-standard" -> + Map.of(Aep.AUTHORIZATION_HEADER, List.of("AEP"), AUTHORIZATION, List.of("Bearer opaque")); + case "duplicate-dedicated-lines" -> Map.of(Aep.AUTHORIZATION_HEADER, List.of("AEP first", "AEP second")); + // Two schemes packed into one field value are not two credentials to choose between. + case "combined-dedicated-values" -> Map.of(Aep.AUTHORIZATION_HEADER, List.of("AEP first Bearer second")); + default -> throw new IllegalArgumentException("Unknown authorization ambiguity case: " + name); + }; + } + + private static boolean rejected(Map> headers, String code) { + return rejectedBy( + serviceBuilder(document(List.of(INSPECT), List.of(Aep.AUTHENTICATION_METHOD_JWT))) + .build(), + headers, + code); + } + + private static boolean rejectedBy(AepService service, Map> headers, String code) { ProtectedResourceResult result = service.authenticate( - new ProtectedResourceRequest(headers, "GET", PROTECTED_RESOURCE)) + new ProtectedResourceRequest(headers, GET_METHOD, PROTECTED_RESOURCE)) .toCompletableFuture() .join(); return !result.authenticated() - && "not_recognized".equals(result.response().problem().code()); + && code.equals(result.response().problem().code()); } + // expected.field_name_match is "case-insensitive", so the credential is presented in + // input.field_name exactly as the vector spells it and the Service must find it there. + private static boolean fieldNameSafety(AdapterRequest request) { + if (!"case-insensitive" + .equals(ConformanceSupport.text(request.testCase().expected(), "field_name_match"))) return false; + String fieldName = ConformanceSupport.text(request.testCase().input(), "field_name"); + ProtectedResourceResult result = enrolledService() + .authenticate(new ProtectedResourceRequest( + Map.of(fieldName, List.of("AEP assertion")), GET_METHOD, PROTECTED_RESOURCE)) + .toCompletableFuture() + .join(); + return result.authenticated() && AGENT_DID.equals(result.principal().agentDid()); + } + + // expected.same_origin.credential_forwarded is false: an assertion bound to one resource must not + // open another, even on the same origin. The Service is given an assertion pinned to the source + // and asked to authenticate the redirect target with it. private static boolean redirectSafety(AdapterRequest request) { + JsonNode expected = request.testCase().expected(); + if (ConformanceSupport.required(expected.get("same_origin"), "credential_forwarded") + .asBoolean()) return false; URI source = URI.create(ConformanceSupport.text(request.testCase().input(), "source")); - URI redirect = URI.create(ConformanceSupport.text(request.testCase().input(), "cross_origin")); - return !source.getAuthority().equals(redirect.getAuthority()); + URI sameOrigin = URI.create(ConformanceSupport.text(request.testCase().input(), "same_origin")); + AepService service = boundToResource(source); + ProtectedResourceResult bound = service.authenticate(new ProtectedResourceRequest( + Map.of(AUTHORIZATION, List.of("AEP assertion")), GET_METHOD, source)) + .toCompletableFuture() + .join(); + ProtectedResourceResult forwarded = service.authenticate(new ProtectedResourceRequest( + Map.of(AUTHORIZATION, List.of("AEP forwarded")), GET_METHOD, sameOrigin)) + .toCompletableFuture() + .join(); + return bound.authenticated() + && !forwarded.authenticated() + && "not_recognized".equals(forwarded.response().problem().code()); + } + + /** + * A Service holding an ACTIVE enrollment whose verifier then returns an assertion bound to + * {@code resource} however the request is addressed. Carrying that credential to another URL is + * what "credential_forwarded" means, and the Service has to refuse it. + */ + private static AepService boundToResource(URI resource) { + AtomicReference pinned = new AtomicReference<>(); + AepService service = AepService.builder( + document(List.of(ENROLL, INSPECT), List.of(Aep.AUTHENTICATION_METHOD_JWT)), + (assertion, context) -> completed(claims( + context.operation(), + assertion, + pinned.get() == null ? context.resource() : pinned.get()))) + .clock(Clock.fixed(NOW, ZoneOffset.UTC)) + .identifierSupplier(() -> ENROLLMENT_ID) + .build(); + service.enroll(new EnrollRequest(AGENT_DID, null, null), options(ENROLL, ENROLL)) + .toCompletableFuture() + .join(); + pinned.set(resource.toString()); + return service; + } + + /** A Service with one ACTIVE enrollment, ready to answer a protected-resource request. */ + private static AepService enrolledService() { + AepService service = serviceBuilder(document(List.of(ENROLL, INSPECT), List.of(Aep.AUTHENTICATION_METHOD_JWT))) + .build(); + service.enroll(new EnrollRequest(AGENT_DID, null, null), options(ENROLL, ENROLL)) + .toCompletableFuture() + .join(); + return service; + } + + /** + * A Service holding an ACTIVE enrollment whose verifier then returns an assertion minted for + * {@code minted} whatever operation the Service is checking. That is what an operation + * substitution looks like on the wire: a well-formed assertion carrying the wrong operation. + */ + private static AepService substitutingService(AssertionOperation minted) { + AtomicReference substitution = new AtomicReference<>(); + AepService service = AepService.builder( + document( + List.of(ENROLL, GRANT, REVOKE, STATUS, INSPECT), + List.of(Aep.AUTHENTICATION_METHOD_JWT)), + (assertion, context) -> { + AssertionOperation operation = substitution.get(); + if (operation == null) return completed(claims(context.operation(), assertion, null)); + String resource = + operation == AssertionOperation.AUTHENTICATE ? PROTECTED_RESOURCE.toString() : null; + return completed(claims(operation, assertion, resource)); + }) + .clock(Clock.fixed(NOW, ZoneOffset.UTC)) + .identifierSupplier(() -> ENROLLMENT_ID) + .grantType(new GrantTypeDefinition(CUSTOM_SESSION, new Handler())) + .build(); + // Enrollment runs through the same verifier, so the substitution only starts once the Agent + // is ACTIVE and the protected-resource path is the thing under test. + service.enroll(new EnrollRequest(AGENT_DID, null, null), options(ENROLL, ENROLL)) + .toCompletableFuture() + .join(); + substitution.set(minted); + return service; + } + + private static AepHttpRequest get(String path) { + return new AepHttpRequest(GET_METHOD, URI.create("https://service.example" + path), Map.of(), new byte[0]); + } + + private static AepHttpRequest enrollPost(String contentType) { + return new AepHttpRequest( + "POST", + URI.create("https://service.example/aep/enroll"), + Map.of( + "Content-Type", + List.of(contentType), + AUTHORIZATION, + List.of("AEP " + java.util.UUID.randomUUID()), + "Idempotency-Key", + List.of(java.util.UUID.randomUUID().toString())), + ("{\"agent_did\":\"" + AGENT_DID + "\"}").getBytes(java.nio.charset.StandardCharsets.UTF_8)); } private static AepService.Builder serviceBuilder(InspectDocument document) { @@ -221,7 +955,7 @@ private static AepService.Builder serviceBuilder(InspectDocument document) { document, (assertion, context) -> completed(claims(context.operation(), assertion, context.resource()))) .clock(Clock.fixed(NOW, ZoneOffset.UTC)) - .identifierSupplier(() -> "enrollment-1"); + .identifierSupplier(() -> ENROLLMENT_ID); } private static ClientAssertionClaims claims(AssertionOperation operation, String jwtId, String resource) { @@ -243,7 +977,7 @@ private static CommandOptions options(String assertion, String idempotencyKey) { private static InspectDocument document(List commands, List methods) { List grants = commands.contains(GRANT) ? List.of(CUSTOM_SESSION) : List.of(); Map config = commands.contains(GRANT) - ? Map.of(CUSTOM_SESSION, new InspectDocument.GrantTypeConfig("true")) + ? Map.of(CUSTOM_SESSION, new InspectDocument.GrantTypeConfig(SUPPORTED)) : Map.of(); return InspectDocument.builder() .version(Aep.VERSION) @@ -284,7 +1018,7 @@ private static CompletableFuture completed(T value) { private static final class Handler implements GrantTypeHandler { @Override public CompletionStage grant(GrantRequest request, GrantContext context) { - return completed(new GrantResult("credential-1", Map.of("credential_id", "credential-1"))); + return completed(new GrantResult(CREDENTIAL_ID, Map.of("credential_id", CREDENTIAL_ID))); } @Override diff --git a/tools/aep-conformance/src/main/java/foundation/aep/conformance/SharedConformance.java b/tools/aep-conformance/src/main/java/foundation/aep/conformance/SharedConformance.java index e98ab15..8cac7c0 100644 --- a/tools/aep-conformance/src/main/java/foundation/aep/conformance/SharedConformance.java +++ b/tools/aep-conformance/src/main/java/foundation/aep/conformance/SharedConformance.java @@ -29,6 +29,7 @@ import java.util.Set; final class SharedConformance { + private static final String AEP_SCHEME = "aepScheme"; private static final Set CLAIM_VALUE_VECTORS = Set.of( "forward-compatible-address", "invalid-address", @@ -213,9 +214,14 @@ private static boolean credentialResponse(AdapterRequest request) { return parsed == expectedValid; } + /** + * Parsing and discarding the result returned true for any input the parser merely tolerated. The + * parsed value is written back out and compared to the vector, so a parser that dropped or + * renamed a member now fails. + */ private static boolean parseInput(AdapterRequest request, java.util.function.Function parser) { - parser.apply(request.testCase().input().toString()); - return true; + Object parsed = parser.apply(request.testCase().input().toString()); + return ConformanceSupport.preserves(parsed, request.testCase().input()); } private static boolean enrollRequest(AdapterRequest request) { @@ -257,9 +263,16 @@ private static boolean problemValidation(AdapterRequest request) { return true; } + /** + * The expected document is the Inspect document the Service publishes. Parsing it and returning + * true accepted any document the parser did not throw on, including one it had silently emptied; + * the parsed document is validated and written back out for comparison instead. + */ private static boolean inspectExpected(AdapterRequest request) { - AepJson.parseInspectDocument(request.testCase().expected().toString()); - return true; + InspectDocument document = + AepJson.parseInspectDocument(request.testCase().expected().toString()); + return AepValidation.inspectDocument(document).isEmpty() + && ConformanceSupport.preserves(document, request.testCase().expected()); } private static boolean defaultEndpointBase(AdapterRequest request) { @@ -288,6 +301,11 @@ private static boolean didWeb(AdapterRequest request) { : ConformanceSupport.text(request.testCase().input(), "matching_service_did"); URI document = foundation.aep.core.DidWeb.documentUri(did, false); return "https".equals(document.getScheme()) + && (!request.testCase().input().has("inspect_url") + || foundation.aep.core.DidWeb.bindsOrigin( + did, + URI.create(ConformanceSupport.text( + request.testCase().input(), "inspect_url")))) && (!request.testCase().expected().has("document_url") || document.toString() .equals(ConformanceSupport.text( @@ -404,11 +422,59 @@ private static boolean openApiPath(AdapterRequest request) { } } + /** + * expected names the disposition each of the six inheritance shapes resolves to, and every one + * is put to {@link AepOpenApi#resolveSecurity} (CORE-OAS-011/013/014/015). + */ private static boolean openApiSecurity(AdapterRequest request) { OpenApiAepSecurityScheme scheme = AepJson.parseOpenApiSecurityScheme( ConformanceSupport.required(request.testCase().input(), "security_scheme") .toString()); - return Aep.AUTHENTICATION_METHOD_JWT.equals(scheme.authenticationMethod()); + if (!Aep.AUTHENTICATION_METHOD_JWT.equals(scheme.authenticationMethod())) return false; + List>> root = List.of(Map.of(AEP_SCHEME, List.of())); + List supported = List.of(AEP_SCHEME, "apiKeyScheme"); + JsonNode expected = request.testCase().expected(); + // An operation that omits `security` inherits the root value rather than becoming public. + if (!resolves(expected, "root_inherited", root, null, supported)) return false; + // An empty array at the operation replaces the root requirement and opens the operation. + if (!resolves(expected, "operation_empty_array", root, List.of(), supported)) return false; + // An empty requirement object is one alternative, so anonymous access is permitted. + if (!resolves( + expected, + "empty_requirement_alternative", + root, + List.of(Map.of(), Map.of(AEP_SCHEME, List.of())), + supported)) return false; + // Several requirement objects are alternatives, any one of which suffices. + if (!resolves( + expected, + "multiple_objects", + root, + List.of(Map.of(AEP_SCHEME, List.of()), Map.of("apiKeyScheme", List.of())), + supported)) return false; + // Several schemes in one object are compound: all of them must be presented together. + if (!resolves( + expected, + "multiple_schemes_one_object", + root, + List.of(Map.of(AEP_SCHEME, List.of(), "apiKeyScheme", List.of())), + supported)) return false; + // CORE-OAS-013: a compound requirement with an unsatisfiable member is unsupported as a + // whole, and CORE-OAS-015: it must not be reduced to the member the Agent does support. + List>> compound = List.of(Map.of(AEP_SCHEME, List.of(), "mtlsScheme", List.of())); + AepOpenApi.SecurityResolution unsupported = AepOpenApi.resolveSecurity(root, compound, supported); + return unsupported.satisfiable().isEmpty() + && unsupported.disposition().value().equals(ConformanceSupport.text(expected, "unsupported_compound")); + } + + private static boolean resolves( + JsonNode expected, + String field, + List>> root, + List>> operation, + List supported) { + AepOpenApi.SecurityResolution resolution = AepOpenApi.resolveSecurity(root, operation, supported); + return resolution.disposition().value().equals(ConformanceSupport.text(expected, field)); } private static boolean openApiUrl(AdapterRequest request) { diff --git a/tools/aep-conformance/src/test/java/foundation/aep/conformance/ConformanceFalsifiabilityTest.java b/tools/aep-conformance/src/test/java/foundation/aep/conformance/ConformanceFalsifiabilityTest.java new file mode 100644 index 0000000..76e53fe --- /dev/null +++ b/tools/aep-conformance/src/test/java/foundation/aep/conformance/ConformanceFalsifiabilityTest.java @@ -0,0 +1,631 @@ +package foundation.aep.conformance; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.function.UnaryOperator; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.Test; + +/** + * A conformance mapping that cannot fail reports every implementation as conformant, which is worse + * than having no mapping at all. Each case here runs a mapping twice: once against the vector as the + * specification publishes it, and once against the same vector with the expectation corrupted. A + * mapping that reads its expectation flips; a mapping that quietly compares two constants, or two + * fields of the vector to each other, stays true and fails this test. + */ +final class ConformanceFalsifiabilityTest { + private static final String SERVICE = "service"; + private static final String AGENT = "agent"; + private static final String PLATFORM = "platform"; + + @Test + void agentExecutesEveryHostedIdentityVector() { + Path vectors = specVectors(); + Assumptions.assumeTrue(vectors != null, "Set AEP_SPECS_DIR to run published-vector regression tests"); + for (String id : List.of( + "discovery", + "list-response", + "provision-request", + "provision-response", + "provision-response-distinct-services", + "sign-request", + "sign-response", + "sign-response-pending", + "idempotency-replay-conflict")) { + assertTrue( + evaluate( + AGENT, + "platform", + id, + read(vectors.resolve("platform").resolve(id + ".json"))), + id); + } + } + + @Test + void platformAuthorizationChecksEveryPrivateOperation() { + publishedFalsifiable( + PLATFORM, + "platform", + "authorization-required", + expected -> expected.put("management_denied_status", 200)); + } + + @Test + void grantWireMappingChecksTheActualEndpoint() { + falsifiable( + AGENT, "grant-revoke", "grant-request-oauth-bearer", expected -> expected.put("path", "/incorrect")); + } + + @Test + void serviceExecutesGrantAndEveryRevokeVariant() { + Path vectors = specVectors(); + Assumptions.assumeTrue(vectors != null, "Set AEP_SPECS_DIR to run published-vector regression tests"); + for (String id : List.of( + "grant-request-oauth-bearer", + "revoke-request-oauth-bearer", + "revoke-request-all-grant-types", + "revoke-request-targeted-oauth-bearer")) { + JsonNode vector = read(vectors.resolve("grant-revoke").resolve(id + ".json")); + assertTrue(evaluate(SERVICE, "grant-revoke", id, vector), id); + assertTrue(evaluate(AGENT, "grant-revoke", id, vector), id); + } + } + + @Test + void serviceHeaderMappingChecksMissingAndMismatchedKeys() { + falsifiable(SERVICE, "idempotency", "command-header", expected -> expected.put("missing_or_empty_status", 200)); + falsifiable( + SERVICE, + "idempotency", + "command-header", + expected -> expected.put("mismatched_enroll_body_status", 200)); + } + + @Test + void serviceReplayMappingExercisesGrantRevokeAndRetention() { + publishedFalsifiable(SERVICE, "idempotency", "command-replay-conflict", expected -> { + ((ObjectNode) expected.get("changed_command")).put("status", 200); + return expected; + }); + publishedFalsifiable( + SERVICE, + "idempotency", + "command-replay-conflict", + expected -> expected.put("retention_seconds_minimum", 3601)); + } + + @Test + void serviceAssertionMappingExercisesEveryPublishedRejection() { + publishedFalsifiable(SERVICE, "client-assertion", "validation-requirements", expected -> { + expected.putArray("reject").add("unknown-rejection"); + return expected; + }); + } + + @Test + void agentOriginMappingRejectsMismatchedIdentitiesBeforeSigning() { + publishedFalsifiable( + AGENT, "inspect", "service-did-origin-binding", expected -> expected.put("transmit_credentials", true)); + } + + @Test + void platformVerificationMappingChecksTheCompleteResponse() { + publishedFalsifiable( + PLATFORM, "platform", "verification-response-recognized", expected -> expected.put("verified", false)); + publishedFalsifiable( + PLATFORM, "platform", "verification-response-unrecognized", expected -> expected.put("verified", true)); + } + + private static void publishedFalsifiable( + String role, String category, String id, UnaryOperator corrupt) { + Path vectors = specVectors(); + Assumptions.assumeTrue(vectors != null, "Set AEP_SPECS_DIR to run published-vector regression tests"); + JsonNode vector = read(vectors.resolve(category).resolve(id + ".json")); + assertTrue(evaluate(role, category, id, vector), id); + ObjectNode changed = vector.deepCopy(); + corrupt.apply((ObjectNode) changed.get("expected")); + assertFalse(evaluate(role, category, id, changed), id); + } + + @Test + void transportRequirementsReadsTheAdvertisedMediaType() { + falsifiable( + SERVICE, + "inspect", + "transport-requirements", + expected -> expected.put("media_type_essence", "application/json")); + } + + @Test + void paymentCompositionReadsEachComposition() { + falsifiable(SERVICE, "protected-resource", "authorization-payment-composition", expected -> { + ((ObjectNode) expected.get("mpp")).put("ambiguous", true); + return expected; + }); + } + + @Test + void operationSubstitutionReadsTheRejectionCode() { + falsifiable( + SERVICE, + "protected-resource", + "operation-substitution-rejected", + expected -> expected.put("all_other_pairs", "authentication_required")); + } + + @Test + void fieldSafetyReadsTheMatchingRule() { + falsifiable( + SERVICE, + "protected-resource", + "authorization-field-safety", + expected -> expected.put("field_name_match", "case-sensitive")); + } + + @Test + void redirectSafetyReadsTheForwardingRule() { + falsifiable(SERVICE, "protected-resource", "redirect-safety", expected -> { + ((ObjectNode) expected.get("same_origin")).put("credential_forwarded", true); + return expected; + }); + } + + /** The mappings that already read their expectation keep doing so. */ + @Test + void grantBeforeEnrollmentReadsTheProblemCode() { + falsifiable( + SERVICE, + "grant-revoke", + "grant-before-enroll-rejected", + expected -> expected.put("code", "invalid_request")); + } + + @Test + void apiKeyHeaderReadsTheRejectionCode() { + falsifiable( + SERVICE, + "protected-resource", + "api-key-wrong-header-rejected", + expected -> expected.put("code", "not_recognized")); + } + + @Test + void agentTransportReadsEveryRedirectPolicy() { + falsifiable( + AGENT, + "inspect", + "transport-requirements", + expected -> expected.put("cross_origin_redirect", "follow")); + } + + @Test + void agentTransportReadsTheSchemeDowngradePolicy() { + falsifiable(AGENT, "inspect", "transport-requirements", expected -> expected.put("scheme_downgrade", "follow")); + } + + @Test + void agentCommandIdempotencyReadsTheHeaderRequirement() { + falsifiable(AGENT, "idempotency", "command-header", expected -> expected.put("header_required", false)); + } + + @Test + void agentOperationBindingReadsEachAllowedPair() { + falsifiable(AGENT, "protected-resource", "operation-substitution-rejected", expected -> { + ((com.fasterxml.jackson.databind.node.ArrayNode) expected.get("allowed")) + .set(0, expected.textNode("enroll:protected-resource")); + return expected; + }); + } + + @Test + void agentPaymentCompositionReadsEachComposition() { + falsifiable(AGENT, "protected-resource", "authorization-payment-composition", expected -> { + ((ObjectNode) expected.get("x402")).put("ambiguous", true); + return expected; + }); + } + + @Test + void agentFieldSafetyReadsTheSelectedCarrier() { + falsifiable( + AGENT, + "protected-resource", + "authorization-field-safety", + expected -> expected.put("redirect_new_assertion_carrier", "Authorization")); + } + + @Test + void agentRedirectSafetyReadsTheNewAssertionResource() { + falsifiable(AGENT, "protected-resource", "redirect-safety", expected -> { + ((ObjectNode) expected.get("same_origin")) + .put("new_authenticate_assertion_resource", "https://api.example.com/v1/orders/123"); + return expected; + }); + } + + @Test + void agentDiscoveryCacheReadsTheNoStoreRule() { + falsifiable(AGENT, "caching", "public-discovery-cache", expected -> expected.put("no_store", "reuse")); + } + + @Test + void agentDiscoveryCacheReadsTheDefaultFreshness() { + falsifiable( + AGENT, + "caching", + "public-discovery-cache", + expected -> expected.put("default_freshness_seconds", "30")); + } + + @Test + void agentDiscoveryCacheReadsTheCacheKeyRule() { + falsifiable(AGENT, "caching", "public-discovery-cache", expected -> expected.put("cache_key", "requested-url")); + } + + @Test + void agentAmbiguityReadsTheFallbackRule() { + falsifiable(AGENT, "protected-resource", "authorization-ambiguity", expected -> expected.put("fallback", true)); + } + + @Test + void serviceAmbiguityReadsTheRejectionCode() { + falsifiable( + SERVICE, + "protected-resource", + "authorization-ambiguity", + expected -> expected.put("code", "authentication_required")); + } + + @Test + void serviceCredentialFailuresReadEachDistinctCode() { + falsifiable( + SERVICE, + "protected-resource", + "assertion-and-credential-failures", + expected -> expected.put("unsupported_method", "not_recognized")); + } + + @Test + void serviceCredentialFailuresReadTheMissingCredentialCode() { + falsifiable( + SERVICE, + "protected-resource", + "assertion-and-credential-failures", + expected -> expected.put("malformed_credential", "authentication_required")); + } + + @Test + void openApiSecurityReadsTheCompoundDisposition() { + falsifiable( + AGENT, + "openapi", + "security-inheritance", + expected -> expected.put("multiple_schemes_one_object", "required")); + } + + @Test + void openApiSecurityReadsTheUnsupportedCompoundDisposition() { + falsifiable( + AGENT, "openapi", "security-inheritance", expected -> expected.put("unsupported_compound", "required")); + } + + @Test + void openApiSecurityReadsTheInheritanceDisposition() { + falsifiable(AGENT, "openapi", "security-inheritance", expected -> expected.put("root_inherited", "public")); + } + + @Test + void platformIdempotencyReadsTheConflictStatus() { + falsifiable( + PLATFORM, + "platform", + "idempotency-replay-conflict", + expected -> expected.put("changed_input_or_operation_status", 400)); + } + + @Test + void platformIdempotencyReadsTheConflictCode() { + falsifiable( + PLATFORM, + "platform", + "idempotency-replay-conflict", + expected -> expected.put("changed_input_or_operation_code", "invalid_request")); + } + + @Test + void platformRecognizedVerificationReadsTheVerdict() { + falsifiable(PLATFORM, "platform", "verification-response-recognized", expected -> expected.put("op", "status")); + } + + /** + * minimal-http publishes the Inspect document itself rather than an expectation about it, so the + * SDK's job is to parse it and reproduce it. Changing a value corrupts both sides of the + * comparison at once; adding a member the parser must carry through is what the round-trip is + * actually for, and a parser that silently drops it fails. + */ + @Test + void inspectDocumentRoundTripReadsEveryPublishedMember() { + JsonNode vector = Vectors.byId("minimal-http"); + assertTrue( + evaluate(AGENT, "inspect", "minimal-http", vector), + "the mapping rejects the Inspect document the specification publishes"); + ObjectNode corrupted = vector.deepCopy(); + ((ObjectNode) corrupted.get("expected")).put("unpreserved_member", "dropped-by-the-parser"); + assertFalse( + evaluate(AGENT, "inspect", "minimal-http", corrupted), + "the round-trip accepted a document member the parser silently dropped"); + } + + /** + * verification-request's idempotency_key_header is the key the caller sends, not a value the SDK + * produces, so corrupting it only changes the key the mapping uses and cannot falsify anything. + * What the vector does test is that the key is required and that an exact replay returns the + * stored result, which is asserted directly. + */ + @Test + void platformIdempotencyReadsTheRetentionMinimum() { + falsifiable( + PLATFORM, + "platform", + "idempotency-replay-conflict", + expected -> expected.put("retention_seconds_minimum", "36000")); + } + + @Test + void platformVerificationRequiresAKeyAndReplaysIt() { + assertTrue( + evaluate(PLATFORM, "platform", "verification-request", Vectors.byId("verification-request")), + "the Platform does not require an idempotency key or does not replay it exactly"); + } + + /** + * grant-request-oauth-bearer is an input round-trip: the vector's input is the request the SDK + * must parse and reproduce, so corrupting the expectation cannot falsify it. The input is + * corrupted instead, and a parser that drops or renames a member fails. + */ + @Test + void grantRequestRoundTripReadsItsInput() { + JsonNode vector = Vectors.byId("grant-request-oauth-bearer"); + assertTrue( + evaluate(AGENT, "grant-revoke", "grant-request-oauth-bearer", vector), + "the mapping rejects the Grant request the specification publishes"); + ObjectNode corrupted = vector.deepCopy(); + ((ObjectNode) corrupted.get("input")).put("unpreserved_member", "dropped-by-the-parser"); + assertFalse( + evaluate(AGENT, "grant-revoke", "grant-request-oauth-bearer", corrupted), + "the round-trip accepted an input member the parser silently dropped"); + } + + /** + * assertion-and-credential-failures is a pass-through: the codes in it are what a Service + * reports, and the Agent's obligation is to surface each unchanged. Corrupting one code to + * another valid code therefore cannot make a correct mapping fail — the mapping would simply + * carry the corrupted code through too. Its falsifiability is in the SDK, not the expectation, so + * it is asserted directly rather than through the corruption harness. + */ + @Test + void agentCredentialFailuresSurfaceTheServiceCode() { + assertTrue( + evaluate( + AGENT, + "protected-resource", + "assertion-and-credential-failures", + Vectors.byId("assertion-and-credential-failures")), + "the Agent does not carry the Service's Problem Details code through unchanged"); + } + + /** + * The inlined vectors are copies. If the specification moves and this checkout has it, say so + * here rather than letting the copies drift into testing a contract nobody publishes any more. + */ + @Test + void inlinedVectorsMatchThePublishedOnes() { + Path vectors = specVectors(); + Assumptions.assumeTrue( + vectors != null, "aep-specs is not checked out; set AEP_SPECS_DIR to run the drift check"); + for (Vector vector : Vectors.ALL) { + JsonNode published = read(vectors.resolve(vector.category()).resolve(vector.id() + ".json")); + assertTrue( + published.get("input").equals(vector.node().get("input")), + vector.id() + ": the inlined input no longer matches the published vector"); + assertTrue( + published.get("expected").equals(vector.node().get("expected")), + vector.id() + ": the inlined expectation no longer matches the published vector"); + } + } + + private static void falsifiable(String role, String category, String id, UnaryOperator corrupt) { + JsonNode vector = Vectors.byId(id); + assertTrue( + evaluate(role, category, id, vector), + id + ": the mapping rejects the vector the specification publishes"); + + ObjectNode corrupted = vector.deepCopy(); + corrupt.apply((ObjectNode) corrupted.get("expected")); + assertFalse( + evaluate(role, category, id, corrupted), + id + ": the mapping accepted a corrupted expectation, so nothing about the SDK can make it fail"); + } + + private static boolean evaluate(String role, String category, String id, JsonNode vector) { + AdapterRequest request = new AdapterRequest( + 1, + role, + PLATFORM.equals(role) ? "platform-hosted-identity" : "core-http", + "required", + new AdapterRequest.Vector(category, id), + new AdapterRequest.TestCase(vector.get("input"), vector.get("expected"))); + return switch (role) { + case SERVICE -> ServiceConformance.evaluate(request); + case "agent" -> AgentConformance.evaluate(request); + default -> PlatformConformance.evaluate(request); + }; + } + + private static Path specVectors() { + String configured = System.getenv("AEP_SPECS_DIR"); + List candidates = new java.util.ArrayList<>(); + if (configured != null) candidates.add(Path.of(configured)); + candidates.add(Path.of("..", "..", ".conformance", "aep-specs")); + candidates.add(Path.of("..", "..", "..", "aep-specs")); + for (Path candidate : candidates) { + Path vectors = candidate.resolve("ietf").resolve("test-vectors"); + if (Files.isDirectory(vectors)) return vectors; + } + return null; + } + + private static JsonNode read(Path path) { + try { + return ConformanceSupport.MAPPER.readTree(Files.readString(path)); + } catch (java.io.IOException exception) { + throw new IllegalStateException("Unable to read " + path, exception); + } + } + + private record Vector(String category, String id, JsonNode node) {} + + /** Copies of the published vectors, verified against the specification by the drift check. */ + private static final class Vectors { + private static final List ALL = List.of( + vector("inspect", "transport-requirements", """ + {"input": {"request_url": "https://api.example.com/.well-known/aep", + "content_type": "Application/AEP+JSON; charset=utf-8", + "redirect_url": "https://api.example.com/discovery/aep"}, + "expected": {"media_type_essence": "application/aep+json", + "redirect_policy": "same-scheme-host-effective-port", + "cross_origin_redirect": "reject", "scheme_downgrade": "reject", + "partial_document_after_bound": "reject"}}"""), + vector("protected-resource", "authorization-payment-composition", """ + {"input": {"anonymous_status": 401, "anonymous_challenge": "AEP"}, + "expected": {"mpp": {"AEP-Authorization": "AEP compact-jws", + "Authorization": "Payment mpp-credential", "ambiguous": false}, + "x402": {"AEP-Authorization": "Bearer opaque-token", + "PAYMENT-SIGNATURE": "x402-signature", "ambiguous": false}, + "payment_advertised_after_aep": true}}"""), + vector("protected-resource", "operation-substitution-rejected", """ + {"input": {"operations": ["enroll", "grant", "revoke", "status", "authenticate"], + "command_endpoints": ["enroll", "grant", "revoke", "status"], + "protected_resource": "https://api.example.com/v1/orders"}, + "expected": {"allowed": ["enroll:enroll", "grant:grant", "revoke:revoke", + "status:status", "authenticate:protected-resource"], + "all_other_pairs": "not_recognized"}}"""), + vector("protected-resource", "authorization-field-safety", """ + {"input": {"field_name": "aep-authorization", "selected_carrier": "AEP-Authorization"}, + "expected": {"field_name_match": "case-insensitive", + "redirect_new_assertion_carrier": "AEP-Authorization", + "strip_on_disallowed_redirect": ["Authorization", "AEP-Authorization", + "PAYMENT-SIGNATURE", "api-key-header"], + "sensitive_headers_redacted": true, "cache_key_excluded": true, + "idempotency_fingerprint_excluded": true}}"""), + vector("protected-resource", "redirect-safety", """ + {"input": {"source": "https://api.example.com/v1/orders/123", + "same_origin": "https://api.example.com/v1/orders/124", + "cross_origin": "https://other.example.net/v1/orders/123"}, + "expected": {"same_origin": {"credential_forwarded": false, + "new_authenticate_assertion_resource": "https://api.example.com/v1/orders/124"}, + "cross_origin": {"anonymous_restart": true, "assertion_forwarded": false, + "session_credential_forwarded": false, "api_key_header_forwarded": false}}}"""), + vector("idempotency", "command-header", """ + {"input": {"commands": ["enroll", "grant", "revoke"], + "idempotency_key": "9f8a4d2e-1c3b-4f5e-8b7a-000000000000"}, + "expected": {"enroll_body_key": "optional", "header_required": true, + "mismatched_enroll_body_status": 400, "missing_or_empty_code": "invalid_request", + "missing_or_empty_status": 400}}"""), + vector("protected-resource", "assertion-and-credential-failures", """ + {"input": {"cases": ["wrong_audience", "expired_assertion", "replayed_assertion", + "malformed_credential", "expired_credential", "unsupported_method", + "insufficient_scope"]}, + "expected": {"wrong_audience": "not_recognized", "expired_assertion": "not_recognized", + "replayed_assertion": "not_recognized", "malformed_credential": "not_recognized", + "expired_credential": "not_recognized", + "unsupported_method": "unsupported_authentication_method", + "insufficient_scope": "insufficient_scope"}}"""), + vector("grant-revoke", "grant-before-enroll-rejected", """ + {"input": {"operation": "grant", "enrollment": "unrecognized"}, + "expected": {"status": 401, "code": "not_recognized", "implicit_enrollment": false}}"""), + vector("caching", "public-discovery-cache", """ + {"input": {"metadata": ["Cache-Control", "ETag", "Last-Modified"], + "conditional_status": 304}, + "expected": {"default_freshness_seconds": "300", "no_cache": "revalidate", + "no_store": "do-not-persist", "status_304": "reuse-validated-representation", + "cache_key": "final-safe-redirect-url"}}"""), + vector("protected-resource", "authorization-ambiguity", """ + {"input": {"cases": ["both-aep-recognized", "invalid-dedicated-plus-valid-standard", + "duplicate-dedicated-lines", "combined-dedicated-values"]}, + "expected": {"code": "not_recognized", "disclosure": "none", "fallback": false, + "selected_credential": null}}"""), + vector("openapi", "security-inheritance", """ + {"input": {"security_scheme": {"type": "http", "scheme": "AEP", + "x-aep-authentication-method": "aep-jwt"}}, + "expected": {"root_inherited": "required", "operation_empty_array": "public", + "empty_requirement_alternative": "optional-anonymous", + "multiple_objects": "alternatives", + "multiple_schemes_one_object": "all-required", + "unsupported_compound": "fallback-live-challenge"}}"""), + vector("grant-revoke", "grant-request-oauth-bearer", """ + {"input": {"grant_type": "oauth-bearer"}, + "expected": {"method": "POST", "path": "/aep/grant", + "content_type": "application/aep+json", "authorization_scheme": "AEP", + "client_assertion_op": "grant", "body": {"grant_type": "oauth-bearer"}}}"""), + vector("inspect", "minimal-http", """ + {"input": {}, + "expected": {"aep_version": "1.0", + "authentication": {"methods": ["aep-jwt", "oauth-bearer", "api-key", "basic"]}, + "bindings": {"supported": ["http"]}, + "claims": {"optional": [], "preferred": [], "required": ["contact.email"]}, + "commands": {"grant_types": ["oauth-bearer", "api-key", "basic"], + "supported": ["enroll", "grant", "inspect", "revoke", "status"]}, + "core": {"signing_algorithms": ["EdDSA", "ES256"]}, + "extensions": {"supported": []}, + "http": {"endpoint_base": "/aep/", + "openapi": {"path_matching": {"trailing_slash": "strict"}, + "url": "/openapi.json"}}, + "identity": {"methods": ["did:web"]}, + "service": {"did": "did:web:api.example.com"}}}"""), + vector("platform", "idempotency-replay-conflict", """ + {"input": {"principal": "stable-principal-123", + "initial_sign_key": "01J0AEPSIGNINITIAL0000000001", + "final_sign_key": "01J0AEPSIGNFINAL000000000002", + "operations": ["provision", "sign", "hosted_verification"]}, + "expected": {"retention_seconds_minimum": "3600", + "exact_replay": "stored_http_result", + "changed_input_or_operation_status": 409, + "changed_input_or_operation_code": "idempotency_conflict"}}"""), + vector("platform", "verification-request", """ + {"input": {"client_assertion": "eyJhbGciOiJFUzI1NiIsImtpZCI6ImRpZDp3ZWI6cC5leGFtcGxlOmE6NFlmN3AyeFFkOSJ9.eyJhdWQiOiJkaWQ6d2ViOmFwaS5zZXJ2aWNlLmV4YW1wbGUiLCJleHAiOjE3ODM0MjU5MDAsImlhdCI6MTc4MzQyNTYwMCwiaXNzIjoiZGlkOndlYjpwLmV4YW1wbGU6YTo0WWY3cDJ4UWQ5IiwianRpIjoiMDFKMEFFUEFTU0VSVElPTjAwMDAwMDAwMDEiLCJvcCI6ImVucm9sbCIsInN1YiI6ImRpZDp3ZWI6cC5leGFtcGxlOmE6NFlmN3AyeFFkOSJ9.signature", + "op": "enroll", "service_did": "did:web:api.service.example"}, + "expected": {"idempotency_key_header": "01J0AEPVERIFY0000000000001"}}"""), + vector("platform", "verification-response-recognized", """ + {"input": {}, + "expected": {"agent_did": "did:web:p.example:a:4Yf7p2xQd9", + "agent_identity_id": "pai_01J0AEPPLATFORM000000000001", "op": "enroll", + "reason": "verified", "service_did": "did:web:api.service.example", + "status": "active", "verified": true}}"""), + vector("protected-resource", "api-key-wrong-header-rejected", """ + {"input": {"issued_header": "x-api-key", "presented_header": "service-api-key", + "api_key": "opaque-api-key"}, + "expected": {"accepted": false, "code": "authentication_required"}}""")); + + private Vectors() {} + + static JsonNode byId(String id) { + return ALL.stream() + .filter(vector -> vector.id().equals(id)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("No inlined vector: " + id)) + .node(); + } + + private static Vector vector(String category, String id, String json) { + return new Vector(category, id, ConformanceSupport.json(json)); + } + } +}