From a66543a51ae426f8a79d965567fa7b3b486865df Mon Sep 17 00:00:00 2001 From: Nas Kavian Date: Sat, 31 Oct 2026 11:15:35 -0700 Subject: [PATCH] feat: support service documents and repeated enrollment --- README.md | 4 + aep-agent/README.md | 36 +- .../aep/agent/AepAgentException.java | 16 + .../aep/agent/AepDocumentReader.java | 154 ++++++ .../aep/agent/AepServiceSession.java | 33 +- .../java/foundation/aep/agent/AgentHttp.java | 3 +- .../aep/agent/AepDocumentReaderTest.java | 271 +++++++++++ .../aep/agent/AepServiceSessionTest.java | 86 +++- aep-core/README.md | 21 + .../java/foundation/aep/core/AepJson.java | 34 +- .../java/foundation/aep/core/AepRawJson.java | 3 +- .../foundation/aep/core/AepValidation.java | 24 +- .../foundation/aep/core/EnrollRequest.java | 12 +- .../foundation/aep/core/EnrollResponse.java | 12 +- .../foundation/aep/core/InspectDocument.java | 14 + .../aep/core/PortableServiceDocument.java | 34 ++ .../foundation/aep/core/ProblemDetails.java | 27 +- .../foundation/aep/core/ServiceDocument.java | 17 + .../foundation/aep/core/ServiceDocuments.java | 183 +++++++ .../aep/core/ServiceDocumentsTest.java | 161 +++++++ .../jackson2/ServiceDocumentsJsonTest.java | 86 ++++ .../jackson3/ServiceDocumentsJsonTest.java | 86 ++++ aep-service/README.md | 44 +- .../foundation/aep/service/AepService.java | 145 +++++- .../aep/service/EnrollmentDecision.java | 31 +- .../aep/service/EnrollmentRecord.java | 24 + .../aep/service/AepServiceTest.java | 260 +++++++++- .../aep/service/ServiceValidationTest.java | 15 +- scripts/run-conformance.sh | 2 +- .../aep/conformance/DocumentConformance.java | 453 ++++++++++++++++++ .../aep/conformance/ServiceConformance.java | 74 ++- .../ConformanceFalsifiabilityTest.java | 34 ++ 32 files changed, 2328 insertions(+), 71 deletions(-) create mode 100644 aep-agent/src/main/java/foundation/aep/agent/AepDocumentReader.java create mode 100644 aep-agent/src/test/java/foundation/aep/agent/AepDocumentReaderTest.java create mode 100644 aep-core/src/main/java/foundation/aep/core/PortableServiceDocument.java create mode 100644 aep-core/src/main/java/foundation/aep/core/ServiceDocument.java create mode 100644 aep-core/src/main/java/foundation/aep/core/ServiceDocuments.java create mode 100644 aep-core/src/test/java/foundation/aep/core/ServiceDocumentsTest.java create mode 100644 aep-json-jackson2/src/test/java/foundation/aep/json/jackson2/ServiceDocumentsJsonTest.java create mode 100644 aep-json-jackson3/src/test/java/foundation/aep/json/jackson3/ServiceDocumentsJsonTest.java create mode 100644 tools/aep-conformance/src/main/java/foundation/aep/conformance/DocumentConformance.java diff --git a/README.md b/README.md index 3592497..1b7d55a 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/`. +Service reports include Service Documents parsing, structural limits, repeated enrollment, +credential preservation, and unmet-requirements handling. Agent reports do not claim the Service +Documents presentation profile. + 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). diff --git a/aep-agent/README.md b/aep-agent/README.md index a4c2697..8cbbd64 100644 --- a/aep-agent/README.md +++ b/aep-agent/README.md @@ -116,7 +116,8 @@ if (enrolled.status() == AgentStatus.PENDING) { AgentGrantResult grant = service.grant("api-key", List.of("catalog:read")).join(); ``` -`enroll` rejects locally when required advertised claims are missing. `waitForActive` is intended +`enroll` validates supplied claims; the Service decides whether required claims are satisfied by +the request and its stored enrollment. `waitForActive` is intended for a Service that returns a pending enrollment; an immediately active enrollment can proceed directly to Grant. @@ -127,6 +128,39 @@ 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`. +### Service documents + +`inspection.document().serviceDocuments()` exposes document IDs, titles, open types, enrollment +requirements, and representation links. Present required documents to the Owner before supplying +their explicit acknowledgement: + +```java +ServiceDocument descriptor = inspection.document().serviceDocuments().get(0); +AepDocumentReader.Result reading = AepDocumentReader.read(descriptor).join(); +// Present reading.document() when available; otherwise offer reading.webUrl(). + +EnrollResponse enrolled = service.enroll(claims, List.of("general-terms", "privacy")).join(); +``` + +`AepDocumentReader` uses a separate anonymous JDK HTTP client without authentication or cookies. +It accepts HTTPS with normal certificate and hostname verification, or HTTP on literal +`localhost`, `127.0.0.1`, and `[::1]` hosts for local development. The caller owns destination +trust; the reader does not inspect DNS answers or connected addresses. It refuses redirects, +limits responses to one mebibyte, and validates UTF-8, media type, portable structure, and document ID. +The default ten-second timeout covers the complete download, including the body; use +`read(descriptor, Duration)` to set a positive timeout representable in nanoseconds. Cancel the returned future +to cancel the download. Missing or unusable portable content returns the HTML link with reason +`not_advertised` or `unavailable_or_unsupported`; cancellation remains cancellation, and invalid +descriptors or timeout arguments throw. A successful result has a document and no reason. + +Only send IDs the Owner accepted. A null list omits the declaration; an empty list declares no +accepted documents. The Service must advertise the extension, and a successful response must echo +the exact declaration. Inspecting or reading a document does not acknowledge it. + +Explicit repeated Enroll uses the same Agent identity and reaches the Service. Grant errors retain +the Service's `requirementsPending` and `requiredDocumentIds` in `AepAgentException.problem()`. Present those +requirements so the caller can choose to repeat Enroll and then Grant; the SDK does not chain them. + ## Authenticate and revoke Authentication selects the first advertised method that the Agent can satisfy. The returned diff --git a/aep-agent/src/main/java/foundation/aep/agent/AepAgentException.java b/aep-agent/src/main/java/foundation/aep/agent/AepAgentException.java index 4838f5c..2d973a0 100644 --- a/aep-agent/src/main/java/foundation/aep/agent/AepAgentException.java +++ b/aep-agent/src/main/java/foundation/aep/agent/AepAgentException.java @@ -1,10 +1,13 @@ package foundation.aep.agent; +import foundation.aep.core.ProblemDetails; + public final class AepAgentException extends RuntimeException { private static final long serialVersionUID = 1L; private final String errorCode; private final int responseStatus; + private final transient ProblemDetails responseProblem; public AepAgentException(String code, String message) { this(code, message, 0, null); @@ -19,9 +22,18 @@ public AepAgentException(String code, String message, int status) { } private AepAgentException(String code, String message, int status, Throwable cause) { + this(code, message, status, cause, null); + } + + AepAgentException(ProblemDetails problem, int status) { + this(problem.code(), problem.detail() == null ? problem.title() : problem.detail(), status, null, problem); + } + + private AepAgentException(String code, String message, int status, Throwable cause, ProblemDetails problem) { super(message, cause); errorCode = code; responseStatus = status; + responseProblem = problem; } public String code() { @@ -31,4 +43,8 @@ public String code() { public int status() { return responseStatus; } + + public ProblemDetails problem() { + return responseProblem; + } } diff --git a/aep-agent/src/main/java/foundation/aep/agent/AepDocumentReader.java b/aep-agent/src/main/java/foundation/aep/agent/AepDocumentReader.java new file mode 100644 index 0000000..9f74a75 --- /dev/null +++ b/aep-agent/src/main/java/foundation/aep/agent/AepDocumentReader.java @@ -0,0 +1,154 @@ +package foundation.aep.agent; + +import foundation.aep.core.AepJson; +import foundation.aep.core.AepValidationException; +import foundation.aep.core.PortableServiceDocument; +import foundation.aep.core.ServiceDocument; +import foundation.aep.core.ServiceDocuments; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.ByteBuffer; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.Flow; +import java.util.concurrent.TimeUnit; + +public final class AepDocumentReader { + private static final Duration DEFAULT_TIMEOUT = Duration.ofSeconds(10); + private static final HttpClient CLIENT = + HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NEVER).build(); + + private AepDocumentReader() {} + + public record Result(ServiceDocument descriptor, URI webUrl, PortableServiceDocument document, String reason) {} + + public static CompletableFuture read(ServiceDocument descriptor) { + return read(descriptor, DEFAULT_TIMEOUT); + } + + public static CompletableFuture read(ServiceDocument descriptor, Duration timeout) { + var issues = ServiceDocuments.descriptor(descriptor); + if (!issues.isEmpty()) throw new AepValidationException("Service document", issues); + Objects.requireNonNull(timeout, "timeout"); + if (timeout.isNegative() || timeout.isZero()) { + throw new IllegalArgumentException("Document timeout must be positive"); + } + long timeoutNanos = timeout.toNanos(); + URI webUrl = URI.create(descriptor.representations().stream() + .filter(item -> "text/html".equals(item.mediaType())) + .findFirst() + .orElseThrow() + .url()); + var portable = descriptor.representations().stream() + .filter(item -> "application/json".equals(item.mediaType())) + .findFirst(); + if (portable.isEmpty()) { + return CompletableFuture.completedFuture(new Result(descriptor, webUrl, null, "not_advertised")); + } + Result fallback = new Result(descriptor, webUrl, null, "unavailable_or_unsupported"); + HttpRequest request = HttpRequest.newBuilder( + URI.create(portable.orElseThrow().url())) + .timeout(timeout) + .header("Accept", "application/json") + .header("Accept-Encoding", "identity") + .GET() + .build(); + var exchange = CLIENT.sendAsync(request, info -> { + requireResponse(info); + return new LimitedBodySubscriber(); + }); + // Keep caller cancellation separate from HttpClient's cancellation-propagating futures. + CompletableFuture result = new CompletableFuture<>(); + result.whenComplete((value, failure) -> exchange.cancel(true)); + exchange.whenComplete((response, failure) -> { + if (result.isDone()) return; + if (failure != null) { + result.complete(fallback); + return; + } + try { + String json = StandardCharsets.UTF_8 + .newDecoder() + .decode(ByteBuffer.wrap(response.body())) + .toString(); + var document = AepJson.parsePortableServiceDocument(json, descriptor.id()); + result.complete(new Result(descriptor, webUrl, document, null)); + } catch (CharacterCodingException | RuntimeException exception) { + result.complete(fallback); + } + }) + .exceptionally(failure -> { + result.completeExceptionally(failure); + return null; + }); + return result.completeOnTimeout(fallback, timeoutNanos, TimeUnit.NANOSECONDS); + } + + private static void requireResponse(HttpResponse.ResponseInfo info) { + String contentType = info.headers().firstValue("Content-Type").orElse(""); + String encoding = info.headers().firstValue("Content-Encoding").orElse("identity"); + String[] parts = contentType.split(";", -1); + if (info.statusCode() < 200 + || info.statusCode() >= 300 + || !"application/json".equalsIgnoreCase(parts[0].trim()) + || !"identity".equalsIgnoreCase(encoding)) { + throw new IllegalArgumentException("Unsupported document response"); + } + for (int index = 1; index < parts.length; index++) { + String[] parameter = parts[index].trim().split("=", 2); + if ("charset".equalsIgnoreCase(parameter[0].trim()) + && (parameter.length != 2 + || !("utf-8".equalsIgnoreCase(parameter[1].trim()) + || "\"utf-8\"".equalsIgnoreCase(parameter[1].trim())))) { + throw new IllegalArgumentException("Document must use UTF-8"); + } + } + } + + private static final class LimitedBodySubscriber implements HttpResponse.BodySubscriber { + private final HttpResponse.BodySubscriber delegate = HttpResponse.BodySubscribers.ofByteArray(); + private Flow.Subscription subscription; + private int size; + + @Override + public CompletionStage getBody() { + return delegate.getBody(); + } + + @Override + public void onSubscribe(Flow.Subscription value) { + subscription = value; + delegate.onSubscribe(value); + } + + @Override + public void onNext(List buffers) { + for (ByteBuffer buffer : buffers) { + if (buffer.remaining() > ServiceDocuments.MAX_BYTES - size) { + subscription.cancel(); + delegate.onError(new IllegalArgumentException("Document exceeds one MiB")); + return; + } + size += buffer.remaining(); + } + delegate.onNext(buffers); + } + + @Override + public void onError(Throwable failure) { + delegate.onError(failure); + } + + @Override + public void onComplete() { + delegate.onComplete(); + } + } +} 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 5636a5c..612a98d 100644 --- a/aep-agent/src/main/java/foundation/aep/agent/AepServiceSession.java +++ b/aep-agent/src/main/java/foundation/aep/agent/AepServiceSession.java @@ -18,6 +18,7 @@ import foundation.aep.core.InspectDocument; import foundation.aep.core.RevokeRequest; import foundation.aep.core.RevokeResponse; +import foundation.aep.core.ServiceDocuments; import foundation.aep.core.StatusResponse; import foundation.aep.core.ValidationIssue; import java.net.URI; @@ -62,13 +63,29 @@ public synchronized CompletableFuture inspect() { } public CompletableFuture enroll(ClaimValues claims) { + return enroll(claims, null); + } + + public CompletableFuture enroll(ClaimValues claims, List acknowledgedDocumentIds) { + List declared = acknowledgedDocumentIds == null ? null : List.copyOf(acknowledgedDocumentIds); return commandContext(AepCommand.ENROLL).thenCompose(context -> { - requireClaims(context.inspection.document(), claims); + InspectDocument document = context.inspection.document(); + if (declared != null + && (document.extensions() == null + || !document.extensions().supported().contains(ServiceDocuments.EXTENSION))) { + return failed("service_documents_unavailable", "AEP Service does not advertise Service Documents"); + } String key = idempotencyKey(context, AepCommand.ENROLL, ""); - EnrollRequest request = new EnrollRequest(context.identity.did(), claims, key); + EnrollRequest request = new EnrollRequest(context.identity.did(), claims, key, declared); requireValid("Enroll request", AepValidation.enrollRequest(request)); return execute(context, AepCommand.ENROLL, "POST", request, key, AepJson::parseEnrollResponse) .thenApply(response -> { + if (response.status() != AgentStatus.REJECTED + && !Objects.equals(declared, response.acknowledgedDocumentIds())) { + throw new AepAgentException( + "document_acknowledgement_mismatch", + "AEP enrollment document acknowledgement does not match the request"); + } recordEnrollment(context, response.status()); return response; }); @@ -475,18 +492,6 @@ private void pollStatus( }); } - private static void requireClaims(InspectDocument document, ClaimValues claims) { - List required = - document.claims() == null ? List.of() : document.claims().required(); - List missing = required.stream() - .filter(name -> claims == null || !claims.contains(name)) - .toList(); - if (!missing.isEmpty()) { - throw new AepAgentException( - "requirements_unmet", "Required Service claims are unavailable: " + String.join(", ", missing)); - } - } - private static void requirePerCredentialRevoke(InspectDocument document, RevokeRequest request) { if (request.credentialId() == null) { return; 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 1ef6a7b..09be934 100644 --- a/aep-agent/src/main/java/foundation/aep/agent/AgentHttp.java +++ b/aep-agent/src/main/java/foundation/aep/agent/AgentHttp.java @@ -111,8 +111,7 @@ static AepAgentException commandError(AepHttpTransport.Response response) { try { requireMediaType(response, Aep.PROBLEM_MEDIA_TYPE); ProblemDetails problem = AepJson.parseProblemDetails(body(response)); - return new AepAgentException( - problem.code(), problem.detail() == null ? problem.title() : problem.detail(), response.status()); + return new AepAgentException(problem, response.status()); } catch (RuntimeException ignored) { return new AepAgentException("command_failed", "AEP command failed", response.status()); } diff --git a/aep-agent/src/test/java/foundation/aep/agent/AepDocumentReaderTest.java b/aep-agent/src/test/java/foundation/aep/agent/AepDocumentReaderTest.java new file mode 100644 index 0000000..004ab9a --- /dev/null +++ b/aep-agent/src/test/java/foundation/aep/agent/AepDocumentReaderTest.java @@ -0,0 +1,271 @@ +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.assertNotNull; +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.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import foundation.aep.core.AepValidationException; +import foundation.aep.core.ServiceDocument; +import foundation.aep.core.ServiceDocuments; +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.List; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +@Timeout(10) +final class AepDocumentReaderTest { + private static final String JSON = """ + {"format":"aep-service-document","version":"1.0","id":"terms", + "summary":[{"type":"paragraph","text":"Service-authored summary"}], + "content":[{"type":"paragraph","text":"Full café terms"}]} + """; + private static final URI WEB_URL = URI.create("https://service.example/terms"); + private HttpServer server; + private String origin; + + @BeforeEach + void startServer() throws IOException { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + origin = "http://localhost:" + server.getAddress().getPort(); + server.start(); + } + + @AfterEach + void stopServer() { + server.stop(0); + } + + @Test + void readsPortableContentAnonymouslyAndDoesNotRetainCookies() { + AtomicReference> authorization = new AtomicReference<>(); + AtomicReference> cookies = new AtomicReference<>(); + AtomicReference accept = new AtomicReference<>(); + AtomicInteger requests = new AtomicInteger(); + server.createContext("/terms", exchange -> { + requests.incrementAndGet(); + authorization.set(exchange.getRequestHeaders().get("Authorization")); + cookies.set(exchange.getRequestHeaders().get("Cookie")); + accept.set(exchange.getRequestHeaders().getFirst("Accept")); + exchange.getResponseHeaders().add("Set-Cookie", "identity=private; Path=/"); + respond(exchange, 200, "application/json; charset=\"UTF-8\"", JSON.getBytes(StandardCharsets.UTF_8), false); + }); + ServiceDocument descriptor = descriptor(origin + "/terms"); + for (int index = 0; index < 2; index++) { + var result = AepDocumentReader.read(descriptor).join(); + assertEquals(descriptor, result.descriptor()); + assertEquals(WEB_URL, result.webUrl()); + assertNull(result.reason()); + assertEquals("Full café terms", result.document().content().get(0).text()); + assertEquals( + "Service-authored summary", + result.document().summary().get(0).text()); + } + assertEquals(2, requests.get()); + assertNull(authorization.get()); + assertNull(cookies.get()); + assertEquals("application/json", accept.get()); + } + + @Test + void offersWebLinkWithoutFetchingWhenPortableIsAbsent() { + var descriptor = new ServiceDocument( + "terms", + "terms_of_service", + "Terms", + "required", + List.of(new ServiceDocument.Representation("text/html", WEB_URL.toString()))); + var result = AepDocumentReader.read(descriptor).join(); + assertEquals("not_advertised", result.reason()); + assertNull(result.document()); + assertEquals(WEB_URL, result.webUrl()); + } + + @Test + void refusesRedirectsWithoutContactingTheirDestination() { + AtomicInteger destinationRequests = new AtomicInteger(); + server.createContext("/target", exchange -> { + destinationRequests.incrementAndGet(); + respond(exchange, 200, "application/json", JSON.getBytes(StandardCharsets.UTF_8), false); + }); + for (int status : List.of(301, 302, 303, 307, 308)) { + String path = "/redirect-" + status; + server.createContext(path, exchange -> { + exchange.getResponseHeaders().add("Location", origin + "/target"); + respond(exchange, status, "application/json", new byte[0], false); + }); + assertFallback(AepDocumentReader.read(descriptor(origin + path)).join()); + } + assertEquals(0, destinationRequests.get()); + } + + @Test + void rejectsErrorStatusWrongMediaEncodingAndCharset() { + server.createContext("/missing", exchange -> respond(exchange, 404, "application/json", new byte[0], false)); + assertFallback(AepDocumentReader.read(descriptor(origin + "/missing")).join()); + int index = 0; + for (String media : List.of("text/html", "application/json; charset=iso-8859-1", "application/json; charset")) { + String path = "/media-" + index++; + server.createContext( + path, exchange -> respond(exchange, 200, media, JSON.getBytes(StandardCharsets.UTF_8), false)); + assertFallback(AepDocumentReader.read(descriptor(origin + path)).join()); + } + server.createContext("/encoding", exchange -> { + exchange.getResponseHeaders().add("Content-Encoding", "gzip"); + respond(exchange, 200, "application/json", JSON.getBytes(StandardCharsets.UTF_8), false); + }); + assertFallback(AepDocumentReader.read(descriptor(origin + "/encoding")).join()); + } + + @Test + void rejectsMalformedUtf8JsonMismatchedIdsAndUnsupportedBlocks() { + List bodies = List.of( + new byte[] {(byte) 0xc3, 0x28}, + "{".getBytes(StandardCharsets.UTF_8), + JSON.replace("\"id\":\"terms\"", "\"id\":\"other\"").getBytes(StandardCharsets.UTF_8), + JSON.replace("paragraph", "script").getBytes(StandardCharsets.UTF_8)); + for (int index = 0; index < bodies.size(); index++) { + String path = "/body-" + index; + byte[] body = bodies.get(index); + server.createContext(path, exchange -> respond(exchange, 200, "application/json", body, false)); + assertFallback(AepDocumentReader.read(descriptor(origin + path)).join()); + } + } + + @Test + void boundsBothDeclaredAndChunkedBodiesAndAcceptsExactLimit() { + byte[] exact = (JSON + " ".repeat(ServiceDocuments.MAX_BYTES - JSON.getBytes(StandardCharsets.UTF_8).length)) + .getBytes(StandardCharsets.UTF_8); + byte[] excessive = new byte[ServiceDocuments.MAX_BYTES + 1]; + server.createContext("/exact", exchange -> respond(exchange, 200, "application/json", exact, true)); + server.createContext("/large", exchange -> respond(exchange, 200, "application/json", excessive, false)); + server.createContext("/chunked", exchange -> respond(exchange, 200, "application/json", excessive, true)); + assertNotNull( + AepDocumentReader.read(descriptor(origin + "/exact")).join().document()); + assertFallback(AepDocumentReader.read(descriptor(origin + "/large")).join()); + assertFallback(AepDocumentReader.read(descriptor(origin + "/chunked")).join()); + } + + @Test + void timeoutCoversStalledBodyNotOnlyResponseHeaders() throws InterruptedException { + CountDownLatch headersSent = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + server.createContext("/stall", exchange -> stall(exchange, headersSent, release)); + try { + var future = AepDocumentReader.read(descriptor(origin + "/stall"), Duration.ofSeconds(1)); + assertTrue(headersSent.await(2, TimeUnit.SECONDS)); + assertFallback(future.join()); + } finally { + release.countDown(); + } + } + + @Test + void callerCancellationRemainsCancellation() throws InterruptedException { + CountDownLatch headersSent = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + server.createContext("/cancel", exchange -> stall(exchange, headersSent, release)); + try { + var future = AepDocumentReader.read(descriptor(origin + "/cancel")); + assertTrue(headersSent.await(2, TimeUnit.SECONDS)); + assertFalse(future.isDone()); + assertTrue(future.cancel(true)); + assertThrows(CancellationException.class, future::join); + } finally { + release.countDown(); + } + } + + @Test + void invalidDescriptorsAndTimeoutsFailBeforeNetworkAccess() { + assertThrows(AepValidationException.class, () -> AepDocumentReader.read(null)); + for (String url : List.of( + "http://service.example/terms", "http://localhost.example/terms", "https://user@localhost/terms")) { + assertThrows(AepValidationException.class, () -> AepDocumentReader.read(descriptor(url))); + } + for (Duration timeout : List.of(Duration.ZERO, Duration.ofSeconds(-1))) { + assertThrows( + IllegalArgumentException.class, + () -> AepDocumentReader.read(descriptor(origin + "/terms"), timeout)); + } + assertThrows(NullPointerException.class, () -> AepDocumentReader.read(descriptor(origin + "/terms"), null)); + assertThrows( + ArithmeticException.class, + () -> AepDocumentReader.read(descriptor(origin + "/terms"), Duration.ofSeconds(Long.MAX_VALUE))); + } + + @Test + void connectionFailureOffersHtml() { + server.stop(0); + assertFallback(AepDocumentReader.read(descriptor(origin + "/terms")).join()); + } + + @Test + void truncatedResponseDoesNotProducePartialDocument() { + server.createContext("/truncated", exchange -> { + try (exchange) { + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, 5000); + exchange.getResponseBody().write(JSON.getBytes(StandardCharsets.UTF_8)); + } + }); + assertFallback(AepDocumentReader.read(descriptor(origin + "/truncated")).join()); + } + + private static ServiceDocument descriptor(String portableUrl) { + return new ServiceDocument( + "terms", + "terms_of_service", + "Terms", + "required", + List.of( + new ServiceDocument.Representation("text/html", WEB_URL.toString()), + new ServiceDocument.Representation("application/json", portableUrl))); + } + + private static void assertFallback(AepDocumentReader.Result result) { + assertNull(result.document()); + assertEquals("unavailable_or_unsupported", result.reason()); + assertEquals(WEB_URL, result.webUrl()); + } + + private static void respond(HttpExchange exchange, int status, String media, byte[] body, boolean chunked) + throws IOException { + try (exchange) { + exchange.getResponseHeaders().set("Content-Type", media); + exchange.sendResponseHeaders(status, body.length == 0 ? -1 : chunked ? 0 : body.length); + exchange.getResponseBody().write(body); + } + } + + private static void stall(HttpExchange exchange, CountDownLatch headersSent, CountDownLatch release) + throws IOException { + try (exchange) { + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, 0); + exchange.getResponseBody().write('{'); + exchange.getResponseBody().flush(); + headersSent.countDown(); + try { + release.await(5, TimeUnit.SECONDS); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + } + } + } +} 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 10afed8..8ed1045 100644 --- a/aep-agent/src/test/java/foundation/aep/agent/AepServiceSessionTest.java +++ b/aep-agent/src/test/java/foundation/aep/agent/AepServiceSessionTest.java @@ -146,16 +146,59 @@ void inspectRejectsMalformedParametersAndAcceptsValidQuotedParameters() { } @Test - void enrollRejectsMissingRequiredClaimsBeforeCommandRequest() { + void enrollLetsTheServiceResolveMissingRequiredClaimsFromStoredEnrollment() { QueueTransport inspect = new QueueTransport( aepResponse(200, document(List.of("enroll", "inspect"), List.of(), List.of("contact.email")))); - QueueTransport command = new QueueTransport(); + QueueTransport command = new QueueTransport(aepResponse(200, "{\"status\":\"active\"}")); AepServiceSession session = agent(inspect, command).build().service(ORIGIN); - AepAgentException error = - agentError(session.enroll(ClaimValues.builder().build())); + assertEquals( + AgentStatus.ACTIVE, + session.enroll(ClaimValues.builder().build()).join().status()); + assertEquals(1, command.requests.size()); + } - assertEquals("requirements_unmet", error.code()); + @Test + void enrollmentDocumentDeclarationsRequireAdvertisementAndExactEchoes() { + String inspection = document(List.of("enroll", "inspect"), List.of(), List.of()) + .replace( + "\"extensions\":{\"supported\":[]}", + "\"extensions\":{\"supported\":[\"" + foundation.aep.core.ServiceDocuments.EXTENSION + "\"]}"); + for (List ids : java.util.Arrays.asList(null, List.of(), List.of("Terms", "privacy"))) { + String suffix = ids == null ? "" : ",\"acknowledged_document_ids\":" + array(ids); + QueueTransport command = new QueueTransport(aepResponse(200, "{\"status\":\"active\"" + suffix + "}")); + var session = agent(new QueueTransport(aepResponse(200, inspection)), command) + .build() + .service(ORIGIN); + assertEquals(ids, session.enroll(null, ids).join().acknowledgedDocumentIds()); + var sent = foundation.aep.core.AepJson.parseEnrollRequest( + new String(command.requests.get(0).body(), StandardCharsets.UTF_8)); + assertEquals(ids, sent.acknowledgedDocumentIds()); + assertEquals(1, command.requests.size()); + } + for (String response : List.of( + "{\"status\":\"active\"}", + "{\"status\":\"active\",\"acknowledged_document_ids\":[\"terms\"]}", + "{\"status\":\"active\",\"acknowledged_document_ids\":[\"Terms\",\"extra\"]}")) { + QueueTransport command = new QueueTransport(aepResponse(200, response)); + var session = agent(new QueueTransport(aepResponse(200, inspection)), command) + .build() + .service(ORIGIN); + assertEquals( + "document_acknowledgement_mismatch", + agentError(session.enroll(null, List.of("Terms"))).code()); + assertEquals(1, command.requests.size()); + } + QueueTransport command = new QueueTransport(); + var session = agent( + new QueueTransport( + aepResponse(200, document(List.of("enroll", "inspect"), List.of(), List.of()))), + command) + .build() + .service(ORIGIN); + assertEquals( + "service_documents_unavailable", + agentError(session.enroll(null, List.of())).code()); assertTrue(command.requests.isEmpty()); } @@ -238,6 +281,39 @@ void authenticationUsesAdvertisedMethodOrderAndNeverLeaksAcrossOrigins() { assertEquals("origin_mismatch", crossOrigin.code()); } + @Test + void grantPreservesDocumentAndClaimRequirementsWithoutStartingEnrollment() { + QueueTransport inspect = new QueueTransport(aepResponse( + 200, + document( + List.of("enroll", "grant", "inspect", "status"), + List.of(Aep.AUTHENTICATION_METHOD_JWT), + List.of()))); + QueueTransport command = new QueueTransport( + aepResponse(200, "{\"status\":\"active\"}"), + response(422, Map.of("Content-Type", List.of(Aep.PROBLEM_MEDIA_TYPE)), """ + {"type":"urn:aep:error:requirements_unmet","title":"Enrollment update required", + "status":422,"code":"requirements_unmet","owner_action_required":"true", + "requirements_pending":["contact.email","service_documents"], + "required_document_ids":["terms"]} + """)); + MemoryCredentialStore store = new MemoryCredentialStore(); + AepServiceSession session = + agent(inspect, command).credentialStore(store).build().service(ORIGIN); + + AepAgentException error = agentError(session.grant("custom-grant", List.of())); + + assertEquals("requirements_unmet", error.code()); + assertEquals(422, error.status()); + assertEquals(List.of("terms"), error.problem().requiredDocumentIds()); + assertEquals( + List.of("contact.email", "service_documents"), error.problem().requirementsPending()); + assertEquals(2, command.requests.size()); + assertTrue(command.requests.get(0).uri().getPath().endsWith("/status")); + assertTrue(command.requests.get(1).uri().getPath().endsWith("/grant")); + assertTrue(store.credential.isEmpty()); + } + @Test void grantRequiresActiveEnrollmentAndPersistsParsedCredential() { QueueTransport inspect = new QueueTransport(aepResponse( diff --git a/aep-core/README.md b/aep-core/README.md index 78fb4d9..2ee6d56 100644 --- a/aep-core/README.md +++ b/aep-core/README.md @@ -52,6 +52,27 @@ Use `AepValidation` when the application already has a typed value: List issues = AepValidation.enrollRequest(request); ``` +## Service documents + +`ServiceDocument` represents Inspect metadata and `PortableServiceDocument` represents full text +with an optional Service-authored summary. Document types are open strings; +`ServiceDocuments.preferredTypes()` supplies the alphabetized conventional names. + +```java +ServiceDocument descriptor = AepJson.parseServiceDocument(descriptorJson); +PortableServiceDocument content = AepJson.parsePortableServiceDocument(contentJson, descriptor.id()); +``` + +Parsing validates the matching ID, supported format and blocks, Unicode text limits, nesting, +total block count, and 1 MiB portable payload bound. It does not fetch URLs or acknowledge documents. +Applications must preserve Service-authored wording and structure when rendering blocks, neutralize +terminal controls, and keep source links and agent instructions outside fenced untrusted text. +Unsupported or unavailable portable content uses the descriptor's HTML link instead of partial text. + +Acknowledgement lists preserve omission versus an explicit empty list during parsing and writing. +`required_document_ids` is restricted to document-related HTTP 422 `requirements_unmet` errors; +unrecognized Agents must not receive these requirements. + ## Compose HTTP paths and authorization `AepHttp` composes command paths from `http.endpoint_base` and renders or parses the standard and 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 5466f11..3af03c2 100644 --- a/aep-core/src/main/java/foundation/aep/core/AepJson.java +++ b/aep-core/src/main/java/foundation/aep/core/AepJson.java @@ -1,5 +1,6 @@ package foundation.aep.core; +import java.nio.charset.StandardCharsets; import java.util.List; import java.util.Map; import java.util.ServiceLoader; @@ -22,6 +23,7 @@ public final class AepJson { private static final String OAUTH_RESPONSE = "OAuth Bearer Grant response"; private static final String OPENAPI_SCHEME = "OpenAPI AEP security scheme"; private static final String PROBLEM_DETAILS = "Problem Details"; + private static final String PORTABLE_DOCUMENT = "Portable document"; private static final String PROTECTED_AUTHORIZATION = "protected-resource authorization"; private static final String RESOURCE = "resource"; private static final String SERVICE_DID = "service_did"; @@ -67,7 +69,8 @@ public static ClaimValues parseClaimValues(String json) { 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"); + AepRawJson.rejectNullPaths( + value, ENROLL_REQUEST, AGENT_DID, "claims", "idempotency_key", "acknowledged_document_ids"); if (value.get("claims") instanceof Map claims) { AepRawJson.claimValues(claims); } @@ -83,7 +86,8 @@ public static EnrollResponse parseEnrollResponse(String json) { STATUS, "owner_action_required", "verification_pending", - "requirements_pending"); + "requirements_pending", + "acknowledged_document_ids"); return parse(json, EnrollResponse.class, ENROLL_RESPONSE, AepValidation::enrollResponse); } @@ -151,7 +155,8 @@ public static ProblemDetails parseProblemDetails(String json) { "code", "owner_action_required", "requirements_pending", - "verification_pending"); + "verification_pending", + "required_document_ids"); return parse(json, ProblemDetails.class, PROBLEM_DETAILS, AepValidation::problemDetails); } @@ -381,13 +386,31 @@ public static Map parseObject(String json, String documentType) return Copies.jsonMap(object(json, documentType)); } + public static ServiceDocument parseServiceDocument(String json) { + return parse(json, ServiceDocument.class, "Service document", ServiceDocuments::descriptor); + } + + public static PortableServiceDocument parsePortableServiceDocument(String json, String expectedId) { + if (json == null || json.getBytes(StandardCharsets.UTF_8).length > ServiceDocuments.MAX_BYTES) { + throw new AepValidationException( + PORTABLE_DOCUMENT, List.of(new ValidationIssue("$", "Document exceeds one MiB or is absent."))); + } + AepRawJson.rejectNullPaths(object(json, PORTABLE_DOCUMENT), PORTABLE_DOCUMENT, "summary"); + return parse( + json, + PortableServiceDocument.class, + PORTABLE_DOCUMENT, + value -> ServiceDocuments.portable(value, expectedId)); + } + private static Object canonicalValue(Object value) { if (value instanceof EnrollResponse response) { return new EnrollResponse( response.status(), canonicalOwnerAction(response.ownerActionRequired()), canonicalPending(response.verificationPending()), - canonicalPending(response.requirementsPending())); + canonicalPending(response.requirementsPending()), + response.acknowledgedDocumentIds()); } if (value instanceof StatusResponse response) { return new StatusResponse( @@ -407,7 +430,8 @@ private static Object canonicalValue(Object value) { problem.code(), canonicalOwnerAction(problem.ownerActionRequired()), canonicalPending(problem.requirementsPending()), - canonicalPending(problem.verificationPending())); + canonicalPending(problem.verificationPending()), + problem.requiredDocumentIds()); } return value; } 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 778d89a..3b0a0c3 100644 --- a/aep-core/src/main/java/foundation/aep/core/AepRawJson.java +++ b/aep-core/src/main/java/foundation/aep/core/AepRawJson.java @@ -71,7 +71,8 @@ static void inspectDocument(Map value) { "extensions", "http", "identity", - "service"); + "service", + "service_documents"); closedObject(value.get("authentication"), "$.authentication", Set.of("methods")); rejectKnownNulls(value.get("bindings"), "$.bindings", "supported"); rejectKnownNulls(value.get("claims"), "$.claims", "required", "preferred", "optional"); 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 c1b17bc..2a9e718 100644 --- a/aep-core/src/main/java/foundation/aep/core/AepValidation.java +++ b/aep-core/src/main/java/foundation/aep/core/AepValidation.java @@ -58,6 +58,12 @@ public static List inspectDocument(InspectDocument value) { validateCommands(value.commands(), issues); validateCore(value.core(), issues); validateExtensions(value.extensions(), issues); + issues.prefix("$.service_documents", ServiceDocuments.catalog(value.serviceDocuments())); + if (value.serviceDocuments() != null + && (value.extensions() == null + || !value.extensions().supported().contains(ServiceDocuments.EXTENSION))) { + issues.add("$.service_documents", "Service Documents extension must be advertised."); + } validateHttp(value.http(), issues); validateIdentity(value.identity(), value.commands(), issues); if (value.service() == null @@ -131,6 +137,7 @@ public static List enrollRequest(EnrollRequest value) { issues.prefix("$.claims", claimValues(value.claims())); } optionalNonEmpty(value.idempotencyKey(), "$.idempotency_key", issues); + issues.prefix("$.acknowledged_document_ids", ServiceDocuments.identifiers(value.acknowledgedDocumentIds())); return issues.values(); } @@ -142,6 +149,7 @@ public static List enrollResponse(EnrollResponse value) { if (value.status() == null) { issues.add(STATUS_PATH, "Expected a registered Agent status."); } + issues.prefix("$.acknowledged_document_ids", ServiceDocuments.identifiers(value.acknowledgedDocumentIds())); lifecycleMetadata( value.ownerActionRequired(), value.verificationPending(), value.requirementsPending(), issues); return issues.values(); @@ -259,6 +267,20 @@ public static List problemDetails(ProblemDetails value) { if (value == null) { return issues.required("$", JSON_OBJECT).values(); } + boolean documentsPending = value.requirementsPending() != null + && value.requirementsPending().contains("service_documents"); + if (value.requiredDocumentIds() != null || documentsPending) { + issues.prefix("$.required_document_ids", ServiceDocuments.identifiers(value.requiredDocumentIds())); + if (value.requiredDocumentIds() == null + || value.requiredDocumentIds().isEmpty() + || !documentsPending + || !"requirements_unmet".equals(value.code()) + || value.status() != 422) { + issues.add( + "$.required_document_ids", + "Document requirements require identifiers, service_documents, requirements_unmet, and status 422."); + } + } requiredNonEmpty(value.code(), "$.code", issues); requiredNonEmpty(value.title(), "$.title", issues); if (value.code() != null && !ERROR_CODE.matcher(value.code()).matches()) { @@ -956,7 +978,7 @@ private static boolean nonEmpty(String value) { * {@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) { + 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. diff --git a/aep-core/src/main/java/foundation/aep/core/EnrollRequest.java b/aep-core/src/main/java/foundation/aep/core/EnrollRequest.java index ff4ca35..e17907a 100644 --- a/aep-core/src/main/java/foundation/aep/core/EnrollRequest.java +++ b/aep-core/src/main/java/foundation/aep/core/EnrollRequest.java @@ -1,8 +1,18 @@ package foundation.aep.core; import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; public record EnrollRequest( @JsonProperty("agent_did") String agentDid, ClaimValues claims, - @JsonProperty("idempotency_key") String idempotencyKey) {} + @JsonProperty("idempotency_key") String idempotencyKey, + @JsonProperty("acknowledged_document_ids") List acknowledgedDocumentIds) { + public EnrollRequest { + acknowledgedDocumentIds = Copies.nullableList(acknowledgedDocumentIds); + } + + public EnrollRequest(String agentDid, ClaimValues claims, String idempotencyKey) { + this(agentDid, claims, idempotencyKey, null); + } +} diff --git a/aep-core/src/main/java/foundation/aep/core/EnrollResponse.java b/aep-core/src/main/java/foundation/aep/core/EnrollResponse.java index 49206f7..cbe6339 100644 --- a/aep-core/src/main/java/foundation/aep/core/EnrollResponse.java +++ b/aep-core/src/main/java/foundation/aep/core/EnrollResponse.java @@ -7,9 +7,19 @@ public record EnrollResponse( AgentStatus status, @JsonProperty("owner_action_required") String ownerActionRequired, @JsonProperty("verification_pending") List verificationPending, - @JsonProperty("requirements_pending") List requirementsPending) { + @JsonProperty("requirements_pending") List requirementsPending, + @JsonProperty("acknowledged_document_ids") List acknowledgedDocumentIds) { public EnrollResponse { verificationPending = Copies.nullableList(verificationPending); requirementsPending = Copies.nullableList(requirementsPending); + acknowledgedDocumentIds = Copies.nullableList(acknowledgedDocumentIds); + } + + public EnrollResponse( + AgentStatus status, + String ownerActionRequired, + List verificationPending, + List requirementsPending) { + this(status, ownerActionRequired, verificationPending, requirementsPending, null); } } diff --git a/aep-core/src/main/java/foundation/aep/core/InspectDocument.java b/aep-core/src/main/java/foundation/aep/core/InspectDocument.java index 01c92f1..e7e9918 100644 --- a/aep-core/src/main/java/foundation/aep/core/InspectDocument.java +++ b/aep-core/src/main/java/foundation/aep/core/InspectDocument.java @@ -15,6 +15,7 @@ public final class InspectDocument { private final Http documentHttp; private final Identity documentIdentity; private final Service documentService; + private final List documentServiceDocuments; private InspectDocument(Builder builder) { documentVersion = builder.configuredVersion; @@ -27,6 +28,7 @@ private InspectDocument(Builder builder) { documentHttp = builder.configuredHttp; documentIdentity = builder.configuredIdentity; documentService = builder.configuredService; + documentServiceDocuments = Copies.nullableList(builder.configuredServiceDocuments); } public static Builder builder() { @@ -83,6 +85,11 @@ public Service service() { return documentService; } + @JsonProperty("service_documents") + public List serviceDocuments() { + return documentServiceDocuments == null ? null : List.copyOf(documentServiceDocuments); + } + public record Authentication(List methods) { public Authentication { methods = Copies.list(methods); @@ -173,6 +180,7 @@ public static final class Builder { private Http configuredHttp; private Identity configuredIdentity; private Service configuredService; + private List configuredServiceDocuments; private Builder() {} @@ -227,6 +235,12 @@ public Builder service(Service value) { return this; } + @JsonProperty("service_documents") + public Builder serviceDocuments(List value) { + configuredServiceDocuments = Copies.nullableList(value); + return this; + } + public InspectDocument build() { return new InspectDocument(this); } diff --git a/aep-core/src/main/java/foundation/aep/core/PortableServiceDocument.java b/aep-core/src/main/java/foundation/aep/core/PortableServiceDocument.java new file mode 100644 index 0000000..c97f1f3 --- /dev/null +++ b/aep-core/src/main/java/foundation/aep/core/PortableServiceDocument.java @@ -0,0 +1,34 @@ +package foundation.aep.core; + +import java.util.List; + +public record PortableServiceDocument( + String format, String version, String id, List summary, List content) { + public PortableServiceDocument { + summary = Copies.nullableList(summary); + content = Copies.nullableList(content); + } + + public List summary() { + return summary == null ? null : List.copyOf(summary); + } + + public List content() { + return content == null ? null : List.copyOf(content); + } + + public record Block(String type, String text, String title, List blocks, List items) { + public Block { + blocks = Copies.nullableList(blocks); + items = Copies.nullableList(items); + } + + public List blocks() { + return blocks == null ? null : List.copyOf(blocks); + } + + public List items() { + return items == null ? null : List.copyOf(items); + } + } +} diff --git a/aep-core/src/main/java/foundation/aep/core/ProblemDetails.java b/aep-core/src/main/java/foundation/aep/core/ProblemDetails.java index 04874d7..a4b28e7 100644 --- a/aep-core/src/main/java/foundation/aep/core/ProblemDetails.java +++ b/aep-core/src/main/java/foundation/aep/core/ProblemDetails.java @@ -12,10 +12,35 @@ public record ProblemDetails( String code, @JsonProperty("owner_action_required") String ownerActionRequired, @JsonProperty("requirements_pending") List requirementsPending, - @JsonProperty("verification_pending") List verificationPending) { + @JsonProperty("verification_pending") List verificationPending, + @JsonProperty("required_document_ids") List requiredDocumentIds) { public ProblemDetails { requirementsPending = Copies.nullableList(requirementsPending); verificationPending = Copies.nullableList(verificationPending); + requiredDocumentIds = Copies.nullableList(requiredDocumentIds); + } + + public ProblemDetails( + String type, + String title, + int status, + String detail, + String instance, + String code, + String ownerActionRequired, + List requirementsPending, + List verificationPending) { + this( + type, + title, + status, + detail, + instance, + code, + ownerActionRequired, + requirementsPending, + verificationPending, + null); } public static ProblemDetails of(String code, String title, int status) { diff --git a/aep-core/src/main/java/foundation/aep/core/ServiceDocument.java b/aep-core/src/main/java/foundation/aep/core/ServiceDocument.java new file mode 100644 index 0000000..ee8e8b6 --- /dev/null +++ b/aep-core/src/main/java/foundation/aep/core/ServiceDocument.java @@ -0,0 +1,17 @@ +package foundation.aep.core; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; + +public record ServiceDocument( + String id, String type, String title, String enrollment, List representations) { + public ServiceDocument { + representations = Copies.nullableList(representations); + } + + public List representations() { + return representations == null ? null : List.copyOf(representations); + } + + public record Representation(@JsonProperty("media_type") String mediaType, String url) {} +} diff --git a/aep-core/src/main/java/foundation/aep/core/ServiceDocuments.java b/aep-core/src/main/java/foundation/aep/core/ServiceDocuments.java new file mode 100644 index 0000000..728da55 --- /dev/null +++ b/aep-core/src/main/java/foundation/aep/core/ServiceDocuments.java @@ -0,0 +1,183 @@ +package foundation.aep.core; + +import java.net.URI; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.regex.Pattern; + +public final class ServiceDocuments { + public static final String EXTENSION = "urn:aep:ext:aep-foundation:service-documents#v=1.0.0"; + public static final int MAX_BYTES = 1 << 20; + private static final int MAX_DOCUMENTS = 7; + private static final int MAX_BLOCKS = 1024; + private static final Pattern ID = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]{0,31}"); + private static final Pattern TYPE = Pattern.compile("[a-z][a-z0-9_]{0,63}"); + private static final Pattern MEDIA = Pattern.compile("[a-z0-9!#$&^_.+-]+/[a-z0-9!#$&^_.+-]+"); + + private ServiceDocuments() {} + + public static List preferredTypes() { + return List.of( + "acceptable_use_policy", + "age_or_parental_consent", + "ai_data_use_policy", + "community_guidelines", + "cookie_policy", + "data_processing_agreement", + "data_retention_notice", + "electronic_signature_consent", + "privacy_policy", + "security_policy", + "subscription_terms", + "supplemental_terms", + "terms_of_service"); + } + + public static List identifiers(List ids) { + List issues = new ArrayList<>(); + if (ids == null) return issues; + if (ids.size() > MAX_DOCUMENTS) issues.add(issue("$", "At most seven document identifiers are allowed.")); + Set seen = new HashSet<>(); + for (String id : ids) { + if (!matches(ID, id) || !seen.add(id)) + issues.add(issue("$", "Expected unique valid document identifiers.")); + } + return issues; + } + + public static List catalog(List documents) { + List issues = new ArrayList<>(); + if (documents == null) return issues; + if (documents.isEmpty() || documents.size() > MAX_DOCUMENTS) + issues.add(issue("$", "Expected one through seven documents.")); + List ids = new ArrayList<>(); + for (ServiceDocument document : documents) { + issues.addAll(descriptor(document)); + if (document != null) ids.add(document.id()); + } + issues.addAll(identifiers(ids)); + return issues; + } + + public static List descriptor(ServiceDocument document) { + List issues = new ArrayList<>(); + if (document == null) return List.of(issue("$", "Expected a document descriptor.")); + if (!matches(ID, document.id())) issues.add(issue("$.id", "Invalid document identifier.")); + if (!matches(TYPE, document.type())) issues.add(issue("$.type", "Invalid document type.")); + if (!text(document.title(), 256)) issues.add(issue("$.title", "Invalid document title.")); + if (!"required".equals(document.enrollment()) && !"not-required".equals(document.enrollment())) { + issues.add(issue("$.enrollment", "Expected required or not-required.")); + } + if (document.representations() == null + || document.representations().isEmpty() + || document.representations().size() > 8) { + issues.add(issue("$.representations", "Expected one through eight representations.")); + return issues; + } + Set media = new HashSet<>(); + for (ServiceDocument.Representation representation : document.representations()) { + if (representation == null + || !matches(MEDIA, representation.mediaType()) + || !media.add(representation.mediaType()) + || !validUrl(representation.url())) { + issues.add(issue( + "$.representations", "Expected unique lowercase media types and HTTPS or loopback HTTP URLs.")); + } + } + if (!media.contains("text/html")) issues.add(issue("$.representations", "An HTML representation is required.")); + return issues; + } + + public static boolean validUrl(String value) { + if (value == null + || value.length() > 2048 + || value.chars().anyMatch(character -> character <= 32 || character > 127)) { + return false; + } + try { + URI uri = URI.create(value); + return uri.getHost() != null + && ("https".equals(uri.getScheme()) + || "http".equals(uri.getScheme()) && AepValidation.isLoopbackHost(uri.getHost())) + && uri.getUserInfo() == null; + } catch (IllegalArgumentException exception) { + return false; + } + } + + public static List portable(PortableServiceDocument document, String expectedId) { + List issues = new ArrayList<>(); + if (document == null) return List.of(issue("$", "Expected a portable document.")); + if (!"aep-service-document".equals(document.format()) || !"1.0".equals(document.version())) { + issues.add(issue("$", "Unsupported portable document format or version.")); + } + if (!matches(ID, document.id()) || !document.id().equals(expectedId)) { + issues.add(issue("$.id", "Document identifier must match its descriptor.")); + } + if (document.content() == null + || document.content().isEmpty() + || document.summary() != null && document.summary().isEmpty()) { + issues.add(issue("$", "Full content and any supplied summary must be nonempty.")); + } + int count = blocks(document.summary(), 0, 0, issues); + blocks(document.content(), 0, count, issues); + return issues; + } + + private static int blocks( + List blocks, int depth, int previous, List issues) { + int count = previous; + if (blocks == null) return count; + for (PortableServiceDocument.Block block : blocks) { + count++; + if (count > MAX_BLOCKS) { + issues.add(issue("$", "Document exceeds 1024 blocks.")); + return count; + } + if (block == null || block.type() == null) { + issues.add(issue("$", "Expected a typed content block.")); + continue; + } + switch (block.type()) { + case "paragraph" -> { + if (!text(block.text(), 16384)) issues.add(issue("$.text", "Invalid paragraph text.")); + } + case "section" -> { + if (depth >= 8 + || !text(block.title(), 256) + || block.blocks() == null + || block.blocks().isEmpty()) { + issues.add(issue("$.blocks", "Invalid section or section depth.")); + } else count = blocks(block.blocks(), depth + 1, count, issues); + } + case "numbered_list", "bulleted_list" -> { + if (block.items() == null + || block.items().isEmpty() + || block.items().size() > 128 + || block.items().stream().anyMatch(item -> !text(item, 16384))) { + issues.add(issue("$.items", "Invalid list items.")); + } + } + default -> issues.add(issue("$.type", "Unsupported content block.")); + } + } + return count; + } + + private static boolean text(String value, int maximum) { + return value != null + && !value.isEmpty() + && value.codePointCount(0, value.length()) <= maximum + && value.codePoints().noneMatch(character -> character >= 0xd800 && character <= 0xdfff); + } + + private static boolean matches(Pattern pattern, String value) { + return value != null && pattern.matcher(value).matches(); + } + + private static ValidationIssue issue(String path, String message) { + return new ValidationIssue(path, message); + } +} diff --git a/aep-core/src/test/java/foundation/aep/core/ServiceDocumentsTest.java b/aep-core/src/test/java/foundation/aep/core/ServiceDocumentsTest.java new file mode 100644 index 0000000..eb0577b --- /dev/null +++ b/aep-core/src/test/java/foundation/aep/core/ServiceDocumentsTest.java @@ -0,0 +1,161 @@ +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.assertTrue; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.IntStream; +import org.junit.jupiter.api.Test; + +final class ServiceDocumentsTest { + private static ServiceDocument descriptor(String id) { + return new ServiceDocument( + id, + "custom_terms", + "Terms", + "required", + List.of(new ServiceDocument.Representation("text/html", "https://example.com/terms"))); + } + + private static PortableServiceDocument.Block paragraph(String text) { + return new PortableServiceDocument.Block("paragraph", text, null, null, null); + } + + private static PortableServiceDocument portable(List content) { + return new PortableServiceDocument("aep-service-document", "1.0", "terms", null, content); + } + + @Test + void validatesBoundedCaseSensitiveIdsAndOpenTypes() { + assertTrue(ServiceDocuments.identifiers(null).isEmpty()); + assertTrue(ServiceDocuments.identifiers(List.of()).isEmpty()); + assertTrue(ServiceDocuments.identifiers(List.of("Terms", "terms", "a".repeat(32))) + .isEmpty()); + for (List ids : List.of( + List.of(""), + List.of("a".repeat(33)), + List.of("é"), + List.of("a\n"), + List.of("a", "a"), + IntStream.range(0, 8).mapToObj(Integer::toString).toList())) { + assertFalse(ServiceDocuments.identifiers(ids).isEmpty(), ids.toString()); + } + assertTrue(ServiceDocuments.catalog(IntStream.range(0, 7) + .mapToObj(i -> descriptor("id-" + i)) + .toList()) + .isEmpty()); + assertFalse(ServiceDocuments.catalog(IntStream.range(0, 8) + .mapToObj(i -> descriptor("id-" + i)) + .toList()) + .isEmpty()); + assertFalse(ServiceDocuments.catalog(List.of()).isEmpty()); + assertFalse(ServiceDocuments.catalog(List.of(descriptor("terms"), descriptor("terms"))) + .isEmpty()); + assertTrue(ServiceDocuments.catalog(null).isEmpty()); + assertFalse(ServiceDocuments.descriptor(null).isEmpty()); + assertEquals(ServiceDocuments.preferredTypes().stream().sorted().toList(), ServiceDocuments.preferredTypes()); + } + + @Test + void rejectsUnsafeUrlsAndInvalidDescriptors() { + for (String url : List.of( + "http://example.com", + "http://localhost.example.com", + "http://192.168.1.10", + "http://user@localhost", + "https://user@example.com", + "/relative", + "https://example.com/é", + "https://example.com/a b", + "https://example.com\\@localhost/", + "https://example.com/" + "x".repeat(2048))) { + assertFalse(ServiceDocuments.validUrl(url), url); + } + assertFalse(ServiceDocuments.validUrl(null)); + for (String url : List.of( + "http://localhost:8080/terms", + "http://127.0.0.1/terms", + "http://[::1]/terms", + "https://192.168.1.10/terms")) { + assertTrue(ServiceDocuments.validUrl(url), url); + } + assertTrue(ServiceDocuments.validUrl("https://example.com/" + "x".repeat(2028))); + ServiceDocument original = descriptor("terms"); + for (ServiceDocument value : List.of( + new ServiceDocument("bad id", "custom_terms", "Terms", "required", original.representations()), + new ServiceDocument("terms", "bad-type", "Terms", "required", original.representations()), + new ServiceDocument("terms", "custom_terms", "x".repeat(257), "required", original.representations()), + new ServiceDocument("terms", "custom_terms", "Terms", "optional", original.representations()), + new ServiceDocument("terms", "custom_terms", "Terms", "required", List.of()), + new ServiceDocument("terms", "custom_terms", "Terms", "required", null), + new ServiceDocument( + "terms", + "custom_terms", + "Terms", + "required", + List.of( + original.representations().get(0), + original.representations().get(0))), + new ServiceDocument( + "terms", + "custom_terms", + "Terms", + "required", + List.of(new ServiceDocument.Representation( + "application/json", "https://example.com/legal"))))) { + assertFalse(ServiceDocuments.descriptor(value).isEmpty()); + } + assertTrue(ServiceDocuments.descriptor(new ServiceDocument( + "terms", "custom_terms", "😀".repeat(256), "not-required", original.representations())) + .isEmpty()); + } + + @Test + void enforcesPortableStructureAndBoundariesAcrossSummaryAndContent() { + var text = paragraph("Full terms"); + assertTrue(ServiceDocuments.portable(portable(List.of(text)), "terms").isEmpty()); + assertFalse(ServiceDocuments.portable(portable(List.of(text)), "other").isEmpty()); + assertFalse(ServiceDocuments.portable(null, "terms").isEmpty()); + assertFalse(ServiceDocuments.portable(portable(null), "terms").isEmpty()); + assertFalse(ServiceDocuments.portable(portable(List.of()), "terms").isEmpty()); + for (String value : List.of("", "x".repeat(16385), "\ud800")) { + assertFalse(ServiceDocuments.portable(portable(List.of(paragraph(value))), "terms") + .isEmpty()); + } + assertTrue(ServiceDocuments.portable(portable(List.of(paragraph("x".repeat(16384)))), "terms") + .isEmpty()); + List blocks = new ArrayList<>(); + for (int index = 0; index < 1024; index++) blocks.add(text); + assertTrue(ServiceDocuments.portable(portable(blocks), "terms").isEmpty()); + assertFalse(ServiceDocuments.portable( + new PortableServiceDocument("aep-service-document", "1.0", "terms", List.of(text), blocks), + "terms") + .isEmpty()); + List nested = List.of(text); + for (int depth = 1; depth <= 9; depth++) { + nested = List.of(new PortableServiceDocument.Block("section", null, "Section", nested, null)); + assertEquals( + depth <= 8, + ServiceDocuments.portable(portable(nested), "terms").isEmpty()); + } + for (String type : List.of("numbered_list", "bulleted_list")) { + for (int size : List.of(0, 128, 129)) { + var block = new PortableServiceDocument.Block( + type, null, null, null, java.util.Collections.nCopies(size, "Item")); + assertEquals( + size == 128, + ServiceDocuments.portable(portable(List.of(block)), "terms") + .isEmpty()); + } + } + assertFalse(ServiceDocuments.portable( + portable(List.of(new PortableServiceDocument.Block("table", null, null, null, null))), "terms") + .isEmpty()); + assertFalse(ServiceDocuments.portable( + new PortableServiceDocument("aep-service-document", "2.0", "terms", null, List.of(text)), + "terms") + .isEmpty()); + } +} diff --git a/aep-json-jackson2/src/test/java/foundation/aep/json/jackson2/ServiceDocumentsJsonTest.java b/aep-json-jackson2/src/test/java/foundation/aep/json/jackson2/ServiceDocumentsJsonTest.java new file mode 100644 index 0000000..fe237ca --- /dev/null +++ b/aep-json-jackson2/src/test/java/foundation/aep/json/jackson2/ServiceDocumentsJsonTest.java @@ -0,0 +1,86 @@ +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; +import java.util.List; +import org.junit.jupiter.api.Test; + +final class ServiceDocumentsJsonTest { + private static final String PORTABLE = """ + {"format":"aep-service-document","version":"1.0","id":"terms", + "content":[{"type":"paragraph","text":"Full terms"}],"future":true} + """; + + @Test + void retainsAbsentVersusEmptyAndRejectsMalformedAcknowledgements() { + String request = "{\"agent_did\":\"did:example:agent\""; + assertNull(AepJson.parseEnrollRequest(request + "}").acknowledgedDocumentIds()); + assertEquals( + List.of(), + AepJson.parseEnrollRequest(request + ",\"acknowledged_document_ids\":[]}") + .acknowledgedDocumentIds()); + var response = AepJson.parseEnrollResponse("{\"status\":\"active\",\"acknowledged_document_ids\":[]}"); + assertEquals( + List.of(), AepJson.parseEnrollResponse(AepJson.write(response)).acknowledgedDocumentIds()); + for (String invalid : List.of("null", "42", "[42]", "\"terms\"", "[\"terms\",\"terms\"]")) { + assertThrows( + AepValidationException.class, + () -> AepJson.parseEnrollRequest(request + ",\"acknowledged_document_ids\":" + invalid + "}")); + } + } + + @Test + void requiresDocumentIdsInScopedRequirementErrorsAndPreservesThemOnWrite() { + String problem = """ + {"type":"urn:aep:error:requirements_unmet","title":"Required","code":"requirements_unmet", + "status":422,"requirements_pending":["service_documents"],"required_document_ids":["terms"]} + """; + var parsed = AepJson.parseProblemDetails(problem); + assertEquals( + List.of("terms"), + AepJson.parseProblemDetails(AepJson.write(parsed)).requiredDocumentIds()); + for (String invalid : List.of( + problem.replace(",\"required_document_ids\":[\"terms\"]", ""), + problem.replace("[\"terms\"]", "[]"), + problem.replace("[\"terms\"]", "null"), + problem.replace("requirements_unmet", "not_recognized"), + problem.replace("422", "400"), + problem.replace("[\"service_documents\"]", "[\"contact.email\"]"))) { + assertThrows(AepValidationException.class, () -> AepJson.parseProblemDetails(invalid)); + } + } + + @Test + void validatesPortableContentWithoutCoercionOrPartialAcceptance() { + assertEquals( + "Full terms", + AepJson.parsePortableServiceDocument(PORTABLE, "terms") + .content() + .get(0) + .text()); + for (String invalid : List.of( + PORTABLE.replace("\"Full terms\"", "42"), + PORTABLE.replace("\"paragraph\"", "\"table\""), + PORTABLE.replace("\"future\":true", "\"summary\":null"), + PORTABLE.replace("\"future\":true", "\"summary\":[]"), + PORTABLE + "{}", + " ".repeat(1048577))) { + assertThrows(AepValidationException.class, () -> AepJson.parsePortableServiceDocument(invalid, "terms")); + } + assertThrows(AepValidationException.class, () -> AepJson.parsePortableServiceDocument(PORTABLE, "other")); + String descriptor = """ + {"id":"terms","type":"custom_terms","title":"Terms","enrollment":"required", + "representations":[{"media_type":"text/html","url":"https://example.com/terms"}],"future":true} + """; + assertEquals("custom_terms", AepJson.parseServiceDocument(descriptor).type()); + assertThrows( + AepValidationException.class, + () -> AepJson.parseServiceDocument(descriptor.replace("\"Terms\"", "42"))); + assertTrue(AepJson.write(AepJson.parseServiceDocument(descriptor)).contains("\"media_type\"")); + } +} diff --git a/aep-json-jackson3/src/test/java/foundation/aep/json/jackson3/ServiceDocumentsJsonTest.java b/aep-json-jackson3/src/test/java/foundation/aep/json/jackson3/ServiceDocumentsJsonTest.java new file mode 100644 index 0000000..52ab933 --- /dev/null +++ b/aep-json-jackson3/src/test/java/foundation/aep/json/jackson3/ServiceDocumentsJsonTest.java @@ -0,0 +1,86 @@ +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; +import java.util.List; +import org.junit.jupiter.api.Test; + +final class ServiceDocumentsJsonTest { + private static final String PORTABLE = """ + {"format":"aep-service-document","version":"1.0","id":"terms", + "content":[{"type":"paragraph","text":"Full terms"}],"future":true} + """; + + @Test + void retainsAbsentVersusEmptyAndRejectsMalformedAcknowledgements() { + String request = "{\"agent_did\":\"did:example:agent\""; + assertNull(AepJson.parseEnrollRequest(request + "}").acknowledgedDocumentIds()); + assertEquals( + List.of(), + AepJson.parseEnrollRequest(request + ",\"acknowledged_document_ids\":[]}") + .acknowledgedDocumentIds()); + var response = AepJson.parseEnrollResponse("{\"status\":\"active\",\"acknowledged_document_ids\":[]}"); + assertEquals( + List.of(), AepJson.parseEnrollResponse(AepJson.write(response)).acknowledgedDocumentIds()); + for (String invalid : List.of("null", "42", "[42]", "\"terms\"", "[\"terms\",\"terms\"]")) { + assertThrows( + AepValidationException.class, + () -> AepJson.parseEnrollRequest(request + ",\"acknowledged_document_ids\":" + invalid + "}")); + } + } + + @Test + void requiresDocumentIdsInScopedRequirementErrorsAndPreservesThemOnWrite() { + String problem = """ + {"type":"urn:aep:error:requirements_unmet","title":"Required","code":"requirements_unmet", + "status":422,"requirements_pending":["service_documents"],"required_document_ids":["terms"]} + """; + var parsed = AepJson.parseProblemDetails(problem); + assertEquals( + List.of("terms"), + AepJson.parseProblemDetails(AepJson.write(parsed)).requiredDocumentIds()); + for (String invalid : List.of( + problem.replace(",\"required_document_ids\":[\"terms\"]", ""), + problem.replace("[\"terms\"]", "[]"), + problem.replace("[\"terms\"]", "null"), + problem.replace("requirements_unmet", "not_recognized"), + problem.replace("422", "400"), + problem.replace("[\"service_documents\"]", "[\"contact.email\"]"))) { + assertThrows(AepValidationException.class, () -> AepJson.parseProblemDetails(invalid)); + } + } + + @Test + void validatesPortableContentWithoutCoercionOrPartialAcceptance() { + assertEquals( + "Full terms", + AepJson.parsePortableServiceDocument(PORTABLE, "terms") + .content() + .get(0) + .text()); + for (String invalid : List.of( + PORTABLE.replace("\"Full terms\"", "42"), + PORTABLE.replace("\"paragraph\"", "\"table\""), + PORTABLE.replace("\"future\":true", "\"summary\":null"), + PORTABLE.replace("\"future\":true", "\"summary\":[]"), + PORTABLE + "{}", + " ".repeat(1048577))) { + assertThrows(AepValidationException.class, () -> AepJson.parsePortableServiceDocument(invalid, "terms")); + } + assertThrows(AepValidationException.class, () -> AepJson.parsePortableServiceDocument(PORTABLE, "other")); + String descriptor = """ + {"id":"terms","type":"custom_terms","title":"Terms","enrollment":"required", + "representations":[{"media_type":"text/html","url":"https://example.com/terms"}],"future":true} + """; + assertEquals("custom_terms", AepJson.parseServiceDocument(descriptor).type()); + assertThrows( + AepValidationException.class, + () -> AepJson.parseServiceDocument(descriptor.replace("\"Terms\"", "42"))); + assertTrue(AepJson.write(AepJson.parseServiceDocument(descriptor)).contains("\"media_type\"")); + } +} diff --git a/aep-service/README.md b/aep-service/README.md index af17083..f75bd72 100644 --- a/aep-service/README.md +++ b/aep-service/README.md @@ -126,6 +126,47 @@ Production stores implement `ServiceCredentialStore` with durable, atomic identi authentication, expiry, and revocation. Custom Grant Types continue to use `GrantTypeDefinition` and `CredentialAuthenticator` directly. +## Repeated enrollment decisions + +Valid Enroll requests evaluate the enrollment policy, including requests for an existing Agent. +Policies consult their own authoritative storage when deciding whether to accept an update. +Accepted updates merge supplied claims while preserving identity, creation time, lifecycle status, +and issued credentials. A rejected decision leaves the stored enrollment unchanged; +`updateExisting=false` returns it unchanged. + +In `EnrollmentDecision`, `ownerActionRequired`, `requirementsPending`, and `verificationPending` +are optional: `null` preserves the stored value on a repeat, and defaults to `false` or an empty +list on initial enrollment. An explicit boolean or list replaces the value, including `false` +and an empty list. Stored enrollment records retain concrete values. + +## Service documents + +Include `ServiceDocuments.EXTENSION` in `InspectDocument.Extensions.supported` and supply the +document list through `InspectDocument.Builder.serviceDocuments`: + +```java +.extensions(new InspectDocument.Extensions(List.of(ServiceDocuments.EXTENSION))) +.serviceDocuments(List.of(new ServiceDocument( + "general-terms", "terms_of_service", "General terms", "required", + List.of(new ServiceDocument.Representation("text/html", "https://service.example/legal/terms"))))) +``` + +The catalog permits seven documents with IDs of at most 32 ASCII characters. Each descriptor +requires an HTTPS HTML link. Document types are open; `ServiceDocuments.preferredTypes()` exposes +the conventional names. Omit the catalog when there are no documents. Extension advertisement +without a catalog still supports acknowledgement declarations. + +Durable enrollment stores must persist `acknowledgedDocumentIds` and `requiredDocumentIds`. +The latter lets the Service request renewed acceptance of an existing ID. Repeated Enroll merges +accepted IDs without clearing earlier acknowledgements by omission or an empty declaration. +Unknown IDs do not satisfy another requirement. Responses echo only the current declaration. + +The Service accepts otherwise valid partial declarations without adding IDs to the Owner's decision. +Grant checks required claims and document acknowledgements before issuing credentials. Unmet document +requirements return HTTP 422 with `requirements_unmet`, `service_documents` in `requirements_pending`, +and the missing `required_document_ids`. Same-key retries replay their original result; a separate +explicit update uses a new key. The Service owns its notification and document-change policy. + ## Advertise and enforce claims The three claim lists in the Inspect document communicate how the Service uses Agent-provided @@ -135,7 +176,8 @@ values: - `preferred` values improve the Service experience but do not block enrollment; - `optional` values are accepted but are not needed for enrollment. -`AepService` checks required advertised values before invoking the enrollment policy. The policy +Initial enrollment checks required advertised values before invoking the enrollment policy. +Accepted repeated enrollment checks the combined stored and supplied claims. The policy can return additional `requirementsPending` or `verificationPending` values when an application workflow needs owner action or independent verification. Applications should persist only claims needed for their stated purpose and apply their own retention controls. 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 b4ac916..5b40900 100644 --- a/aep-service/src/main/java/foundation/aep/service/AepService.java +++ b/aep-service/src/main/java/foundation/aep/service/AepService.java @@ -8,6 +8,7 @@ import foundation.aep.core.AssertionOperation; import foundation.aep.core.AuthorizationCarrier; import foundation.aep.core.AuthorizationScheme; +import foundation.aep.core.ClaimValues; import foundation.aep.core.ClientAssertionClaims; import foundation.aep.core.EnrollRequest; import foundation.aep.core.EnrollResponse; @@ -17,6 +18,7 @@ import foundation.aep.core.ProtectedResourceAuthorization; import foundation.aep.core.RevokeRequest; import foundation.aep.core.RevokeResponse; +import foundation.aep.core.ServiceDocuments; import foundation.aep.core.StatusResponse; import java.net.URI; import java.nio.charset.StandardCharsets; @@ -106,7 +108,16 @@ public CompletionStage> enroll(EnrollRequest req if (!claimValueLimits.accepts(request.claims())) { return completed(problem("requirements_unmet", "Requirements unmet", 422)); } - return idempotent(claims.subject(), "enroll", options.idempotencyKey(), request, () -> enrollNew(request)); + return idempotent( + claims.subject(), + "enroll", + options.idempotencyKey(), + request, + () -> enrollNew( + supportsDocuments() + ? request + : new EnrollRequest( + request.agentDid(), request.claims(), request.idempotencyKey()))); }); } @@ -203,15 +214,18 @@ public CompletionStage authenticate(ProtectedResourceRe private CompletionStage> enrollNew(EnrollRequest request) { return enrollmentStore.find(request.agentDid()).thenCompose(existing -> { if (existing.isPresent()) { - return completed(ServiceResponse.success(enrollResponse(existing.get()))); + return updateEnrollment(request, existing.get()); } List missing = missingRequiredClaims(request); - if (!missing.isEmpty()) { - return completed(problem("requirements_unmet", "Requirements unmet", 422, missing)); + List missingDocuments = + request.acknowledgedDocumentIds() == null ? missingDocumentIds(null) : List.of(); + if (!missing.isEmpty() || !missingDocuments.isEmpty()) { + return completed(documentRequirements(missing, missingDocuments)); } return enrollmentStore .findOrCreate(request.agentDid(), () -> createEnrollment(request)) - .thenApply(selection -> ServiceResponse.success(enrollResponse(selection.record()))); + .thenApply(selection -> ServiceResponse.success( + enrollResponse(selection.record(), request.acknowledgedDocumentIds()))); }); } @@ -222,12 +236,69 @@ private CompletionStage createEnrollment(EnrollRequest request .thenApply(decision -> EnrollmentRecord.builder( request.agentDid(), requireIdentifier(), decision.status(), now) .claims(request.claims()) - .ownerActionRequired(decision.ownerActionRequired()) - .verificationPending(decision.verificationPending()) - .requirementsPending(decision.requirementsPending()) + .acknowledgedDocumentIds( + decision.status() == AgentStatus.REJECTED + ? List.of() + : acceptedDocumentIds(List.of(), request.acknowledgedDocumentIds())) + .ownerActionRequired(Boolean.TRUE.equals(decision.ownerActionRequired())) + .verificationPending( + decision.verificationPending() == null ? List.of() : decision.verificationPending()) + .requirementsPending( + decision.requirementsPending() == null ? List.of() : decision.requirementsPending()) .build()); } + private CompletionStage> updateEnrollment( + EnrollRequest request, EnrollmentRecord existing) { + Instant now = clock.instant(); + return enrollmentPolicy.decide(request, now).thenCompose(decision -> { + if (decision.status() == AgentStatus.REJECTED) { + return completed(problem("enrollment_failed", "Enrollment failed", 403)); + } + if (!decision.updateExisting()) { + return completed(ServiceResponse.success(enrollResponse(existing, request.acknowledgedDocumentIds()))); + } + Map merged = new LinkedHashMap<>(); + if (existing.claims() != null) merged.putAll(canonicalClaims(existing.claims())); + if (request.claims() != null) merged.putAll(canonicalClaims(request.claims())); + ClaimValues claims = AepJson.parseClaimValues(AepJson.write(merged)); + List missing = missingRequiredClaims(new EnrollRequest(request.agentDid(), claims, null)); + List missingDocuments = + request.acknowledgedDocumentIds() == null ? missingDocumentIds(existing) : List.of(); + if (!missing.isEmpty() || !missingDocuments.isEmpty()) { + return completed(documentRequirements(missing, missingDocuments)); + } + EnrollmentRecord updated = EnrollmentRecord.builder( + existing.agentDid(), existing.enrollmentId(), existing.status(), existing.createdAt()) + .claims(claims) + .acknowledgedDocumentIds( + acceptedDocumentIds(existing.acknowledgedDocumentIds(), request.acknowledgedDocumentIds())) + .requiredDocumentIds(existing.requiredDocumentIds().stream() + .filter(id -> request.acknowledgedDocumentIds() == null + || !request.acknowledgedDocumentIds().contains(id)) + .toList()) + .since(existing.since()) + .updatedAt(now) + .ownerActionRequired( + decision.ownerActionRequired() == null + ? existing.ownerActionRequired() + : decision.ownerActionRequired()) + .requirementsPending( + decision.requirementsPending() == null + ? existing.requirementsPending() + : decision.requirementsPending()) + .verificationPending( + decision.verificationPending() == null + ? existing.verificationPending() + : decision.verificationPending()) + .build(); + return enrollmentStore + .save(updated) + .thenApply(record -> + ServiceResponse.success(enrollResponse(record, request.acknowledgedDocumentIds()))); + }); + } + private List missingRequiredClaims(EnrollRequest request) { List missing = new ArrayList<>(); InspectDocument.Claims claims = document.claims(); @@ -239,6 +310,52 @@ private List missingRequiredClaims(EnrollRequest request) { return missing; } + private boolean supportsDocuments() { + return document.extensions() != null + && document.extensions().supported().contains(ServiceDocuments.EXTENSION); + } + + private List missingDocumentIds(EnrollmentRecord record) { + if (document.serviceDocuments() == null) return List.of(); + return document.serviceDocuments().stream() + .filter(item -> "required".equals(item.enrollment())) + .filter(item -> record == null + || !record.acknowledgedDocumentIds().contains(item.id()) + || record.requiredDocumentIds().contains(item.id())) + .map(foundation.aep.core.ServiceDocument::id) + .toList(); + } + + private List acceptedDocumentIds(List existing, List declared) { + List accepted = new ArrayList<>(existing); + if (declared != null && document.serviceDocuments() != null) { + for (String id : declared) { + if (!accepted.contains(id) + && document.serviceDocuments().stream() + .anyMatch(item -> item.id().equals(id))) accepted.add(id); + } + } + return accepted; + } + + private static ServiceResponse documentRequirements(List claims, List ids) { + if (ids.isEmpty()) return problem("requirements_unmet", "Requirements unmet", 422, claims); + List pending = new ArrayList<>(claims); + pending.add("service_documents"); + ProblemDetails failure = new ProblemDetails( + "urn:aep:error:requirements_unmet", + "Requirements unmet", + 422, + null, + null, + "requirements_unmet", + STRING_TRUE, + pending, + null, + ids); + return new ServiceResponse<>(422, Aep.PROBLEM_MEDIA_TYPE, null, failure, Map.of()); + } + private CompletionStage>> grantNew(String agentDid, GrantRequest request) { GrantTypeDefinition definition = grantTypes.get(request.grantType()); if (definition == null) return completed(problem("unsupported_grant_type", "Unsupported Grant Type", 400)); @@ -246,6 +363,11 @@ private CompletionStage>> grantNew(String ag if (enrollment.isEmpty()) return completed(problem(ERROR_NOT_RECOGNIZED, TITLE_NOT_RECOGNIZED, 401)); ServiceResponse> lifecycle = blockedGrant(enrollment.get()); if (lifecycle != null) return completed(lifecycle); + List missing = missingRequiredClaims( + new EnrollRequest(agentDid, enrollment.get().claims(), null)); + List missingDocuments = missingDocumentIds(enrollment.get()); + if (!missing.isEmpty() || !missingDocuments.isEmpty()) + return completed(documentRequirements(missing, missingDocuments)); GrantContext context = new GrantContext(agentDid, enrollment.get(), request.grantType(), clock.instant()); return definition .handler() @@ -496,6 +618,8 @@ private static Object canonicalRequest(Object request) { value.put("agent_did", enroll.agentDid()); if (enroll.claims() != null) value.put("claims", canonicalClaims(enroll.claims())); if (enroll.idempotencyKey() != null) value.put("idempotency_key", enroll.idempotencyKey()); + if (enroll.acknowledgedDocumentIds() != null) + value.put("acknowledged_document_ids", enroll.acknowledgedDocumentIds()); return value; } if (request instanceof GrantRequest grant) { @@ -541,12 +665,13 @@ private static Object canonicalJsonValue(Object value) { return value; } - private static EnrollResponse enrollResponse(EnrollmentRecord record) { + private static EnrollResponse enrollResponse(EnrollmentRecord record, List declared) { return new EnrollResponse( record.status(), record.ownerActionRequired() ? STRING_TRUE : null, emptyToNull(record.verificationPending()), - emptyToNull(record.requirementsPending())); + emptyToNull(record.requirementsPending()), + record.status() == AgentStatus.REJECTED ? null : declared); } private static StatusResponse statusResponse(EnrollmentRecord record) { diff --git a/aep-service/src/main/java/foundation/aep/service/EnrollmentDecision.java b/aep-service/src/main/java/foundation/aep/service/EnrollmentDecision.java index f4e103b..32a25ca 100644 --- a/aep-service/src/main/java/foundation/aep/service/EnrollmentDecision.java +++ b/aep-service/src/main/java/foundation/aep/service/EnrollmentDecision.java @@ -5,19 +5,40 @@ public record EnrollmentDecision( AgentStatus status, - boolean ownerActionRequired, + Boolean ownerActionRequired, List verificationPending, - List requirementsPending) { + List requirementsPending, + boolean updateExisting) { + public EnrollmentDecision( + AgentStatus status, + Boolean ownerActionRequired, + List verificationPending, + List requirementsPending) { + this(status, ownerActionRequired, verificationPending, requirementsPending, true); + } + public EnrollmentDecision { status = status == null ? AgentStatus.ACTIVE : status; - verificationPending = verificationPending == null ? List.of() : List.copyOf(verificationPending); - requirementsPending = requirementsPending == null ? List.of() : List.copyOf(requirementsPending); + if (verificationPending != null) { + verificationPending = List.copyOf(verificationPending); + } + if (requirementsPending != null) { + requirementsPending = List.copyOf(requirementsPending); + } if (status != AgentStatus.ACTIVE && status != AgentStatus.PENDING && status != AgentStatus.REJECTED) { throw new IllegalArgumentException("Initial enrollment status must be active, pending, or rejected."); } } public static EnrollmentDecision active() { - return new EnrollmentDecision(AgentStatus.ACTIVE, false, List.of(), List.of()); + return new EnrollmentDecision(AgentStatus.ACTIVE, null, null, null); + } + + public List verificationPending() { + return verificationPending == null ? null : List.copyOf(verificationPending); + } + + public List requirementsPending() { + return requirementsPending == null ? null : List.copyOf(requirementsPending); } } diff --git a/aep-service/src/main/java/foundation/aep/service/EnrollmentRecord.java b/aep-service/src/main/java/foundation/aep/service/EnrollmentRecord.java index 3c4f4b5..05d2f49 100644 --- a/aep-service/src/main/java/foundation/aep/service/EnrollmentRecord.java +++ b/aep-service/src/main/java/foundation/aep/service/EnrollmentRecord.java @@ -17,6 +17,8 @@ public final class EnrollmentRecord { private final boolean recordOwnerActionRequired; private final List recordVerificationPending; private final List recordRequirementsPending; + private final List recordAcknowledgedDocumentIds; + private final List recordRequiredDocumentIds; private EnrollmentRecord(Builder builder) { recordAgentDid = requireText(builder.configuredAgentDid, "agentDid"); @@ -29,6 +31,8 @@ private EnrollmentRecord(Builder builder) { recordOwnerActionRequired = builder.configuredOwnerActionRequired; recordVerificationPending = List.copyOf(builder.configuredVerificationPending); recordRequirementsPending = List.copyOf(builder.configuredRequirementsPending); + recordAcknowledgedDocumentIds = List.copyOf(builder.configuredAcknowledgedDocumentIds); + recordRequiredDocumentIds = List.copyOf(builder.configuredRequiredDocumentIds); } public static Builder builder(String agentDid, String enrollmentId, AgentStatus status, Instant createdAt) { @@ -75,6 +79,14 @@ public List requirementsPending() { return recordRequirementsPending; } + public List acknowledgedDocumentIds() { + return recordAcknowledgedDocumentIds; + } + + public List requiredDocumentIds() { + return recordRequiredDocumentIds; + } + public static final class Builder { private final String configuredAgentDid; private final String configuredEnrollmentId; @@ -86,6 +98,8 @@ public static final class Builder { private boolean configuredOwnerActionRequired; private List configuredVerificationPending = List.of(); private List configuredRequirementsPending = List.of(); + private List configuredAcknowledgedDocumentIds = List.of(); + private List configuredRequiredDocumentIds = List.of(); private Builder(String agentDid, String enrollmentId, AgentStatus status, Instant createdAt) { configuredAgentDid = agentDid; @@ -134,6 +148,16 @@ public Builder requirementsPending(List value) { public EnrollmentRecord build() { return new EnrollmentRecord(this); } + + public Builder acknowledgedDocumentIds(List value) { + configuredAcknowledgedDocumentIds = List.copyOf(value); + return this; + } + + public Builder requiredDocumentIds(List value) { + configuredRequiredDocumentIds = List.copyOf(value); + return this; + } } private static String requireText(String value, String name) { diff --git a/aep-service/src/test/java/foundation/aep/service/AepServiceTest.java b/aep-service/src/test/java/foundation/aep/service/AepServiceTest.java index e61d488..2eb9c72 100644 --- a/aep-service/src/test/java/foundation/aep/service/AepServiceTest.java +++ b/aep-service/src/test/java/foundation/aep/service/AepServiceTest.java @@ -10,6 +10,7 @@ import com.nimbusds.jose.jwk.JWK; import com.nimbusds.jose.jwk.gen.ECKeyGenerator; import foundation.aep.core.Aep; +import foundation.aep.core.AepJson; import foundation.aep.core.AgentStatus; import foundation.aep.core.ClaimValues; import foundation.aep.core.ClientAssertionClaims; @@ -18,6 +19,8 @@ import foundation.aep.core.GrantRequest; import foundation.aep.core.InspectDocument; import foundation.aep.core.RevokeRequest; +import foundation.aep.core.ServiceDocument; +import foundation.aep.core.ServiceDocuments; import java.net.URI; import java.time.Clock; import java.time.Instant; @@ -36,7 +39,181 @@ final class AepServiceTest { private static final String AGENT_DID = "did:web:agent.example"; @Test - void enrollsOnceAndReturnsExistingLifecycleForANewKey() { + void appliesOnlyDeclaredDocumentsAndBlocksGrantUntilRequirementsAreMet() { + EnrollmentStore store = EnrollmentStore.inMemory(); + AtomicInteger decisions = new AtomicInteger(); + RecordingGrantHandler handler = new RecordingGrantHandler(); + AepService service = documentService( + store, + (request, now) -> { + decisions.incrementAndGet(); + return completed(EnrollmentDecision.active()); + }, + handler); + var absent = service.enroll(requestWithRequiredClaims(), idempotent("absent", "absent")) + .toCompletableFuture() + .join(); + assertEquals(422, absent.status()); + assertEquals(List.of("terms", "privacy"), absent.problem().requiredDocumentIds()); + assertEquals(0, decisions.get()); + EnrollRequest partial = + new EnrollRequest(AGENT_DID, requestWithRequiredClaims().claims(), null, List.of("terms")); + var first = service.enroll(partial, idempotent("first", "same-key")) + .toCompletableFuture() + .join(); + assertEquals(200, first.status()); + assertEquals(List.of("terms"), first.body().acknowledgedDocumentIds()); + assertEquals( + first, + service.enroll(partial, idempotent("replay", "same-key")) + .toCompletableFuture() + .join()); + assertEquals(1, decisions.get()); + EnrollRequest changed = new EnrollRequest(AGENT_DID, null, null, List.of("privacy")); + assertEquals( + 409, + service.enroll(changed, idempotent("conflict", "same-key")) + .toCompletableFuture() + .join() + .status()); + var failedGrant = service.grant(new GrantRequest("api-key", List.of()), idempotent("grant", "grant")) + .toCompletableFuture() + .join(); + assertEquals(422, failedGrant.status()); + assertEquals(List.of("privacy"), failedGrant.problem().requiredDocumentIds()); + assertEquals(0, handler.issues.get()); + EnrollmentRecord original = + store.find(AGENT_DID).toCompletableFuture().join().orElseThrow(); + var empty = service.enroll(new EnrollRequest(AGENT_DID, null, null, List.of()), idempotent("empty", "empty")) + .toCompletableFuture() + .join(); + assertEquals(List.of(), empty.body().acknowledgedDocumentIds()); + assertEquals( + List.of("terms"), + store.find(AGENT_DID).toCompletableFuture().join().orElseThrow().acknowledgedDocumentIds()); + var update = service.enroll(changed, idempotent("update", "update")) + .toCompletableFuture() + .join(); + assertEquals(List.of("privacy"), update.body().acknowledgedDocumentIds()); + EnrollmentRecord saved = + store.find(AGENT_DID).toCompletableFuture().join().orElseThrow(); + assertEquals(List.of("terms", "privacy"), saved.acknowledgedDocumentIds()); + assertEquals(original.enrollmentId(), saved.enrollmentId()); + assertEquals(original.createdAt(), saved.createdAt()); + assertEquals(original.since(), saved.since()); + assertEquals(original.claims().contactEmail(), saved.claims().contactEmail()); + assertEquals( + 200, + service.grant(new GrantRequest("api-key", List.of()), idempotent("ready", "ready")) + .toCompletableFuture() + .join() + .status()); + assertEquals(1, handler.issues.get()); + assertEquals( + failedGrant, + service.grant(new GrantRequest("api-key", List.of()), idempotent("retry-grant", "grant")) + .toCompletableFuture() + .join()); + assertEquals(1, handler.issues.get()); + } + + @Test + void repeatedDocumentAcknowledgementsPreserveLifecycleAndHonorPolicy() { + for (AgentStatus status : + List.of(AgentStatus.ACTIVE, AgentStatus.SUSPENDED, AgentStatus.TERMINATED, AgentStatus.UNAVAILABLE)) { + EnrollmentStore store = EnrollmentStore.inMemory(); + EnrollmentRecord original = EnrollmentRecord.builder(AGENT_DID, "original", status, NOW.minusSeconds(3600)) + .claims(requestWithRequiredClaims().claims()) + .acknowledgedDocumentIds(List.of("terms", "privacy")) + .requiredDocumentIds(List.of("terms")) + .build(); + store.save(original).toCompletableFuture().join(); + var policy = new java.util.concurrent.atomic.AtomicReference<>(EnrollmentDecision.active()); + AepService service = + documentService(store, (request, now) -> completed(policy.get()), new RecordingGrantHandler()); + EnrollRequest request = new EnrollRequest(AGENT_DID, null, null, List.of("terms", "retired")); + policy.set(new EnrollmentDecision(AgentStatus.ACTIVE, null, null, null, false)); + assertEquals( + 200, + service.enroll(request, idempotent("unchanged", "unchanged")) + .toCompletableFuture() + .join() + .status()); + assertEquals( + original, store.find(AGENT_DID).toCompletableFuture().join().orElseThrow()); + policy.set(new EnrollmentDecision(AgentStatus.REJECTED, null, null, null)); + assertEquals( + 403, + service.enroll(request, idempotent("reject", "reject")) + .toCompletableFuture() + .join() + .status()); + assertEquals( + original, store.find(AGENT_DID).toCompletableFuture().join().orElseThrow()); + policy.set(EnrollmentDecision.active()); + var result = service.enroll(request, idempotent("accept", "accept")) + .toCompletableFuture() + .join(); + assertEquals(List.of("terms", "retired"), result.body().acknowledgedDocumentIds()); + EnrollmentRecord saved = + store.find(AGENT_DID).toCompletableFuture().join().orElseThrow(); + assertEquals(status, saved.status()); + assertEquals(original.since(), saved.since()); + assertEquals(original.createdAt(), saved.createdAt()); + assertEquals(original.enrollmentId(), saved.enrollmentId()); + assertEquals(List.of(), saved.requiredDocumentIds()); + assertEquals(List.of("terms", "privacy"), saved.acknowledgedDocumentIds()); + } + } + + @Test + void documentGrantReportsClaimsWithoutDisclosingUnrecognizedEnrollment() { + EnrollmentStore store = EnrollmentStore.inMemory(); + RecordingGrantHandler handler = new RecordingGrantHandler(); + AepService service = documentService(store, EnrollmentPolicy.active(), handler); + var unknown = service.grant(new GrantRequest("api-key", List.of()), idempotent("unknown", "unknown")) + .toCompletableFuture() + .join(); + assertEquals(401, unknown.status()); + org.junit.jupiter.api.Assertions.assertNull(unknown.problem().requiredDocumentIds()); + store.save(EnrollmentRecord.builder(AGENT_DID, "existing", AgentStatus.ACTIVE, NOW) + .build()) + .toCompletableFuture() + .join(); + var failure = service.grant(new GrantRequest("api-key", List.of()), idempotent("missing", "missing")) + .toCompletableFuture() + .join(); + assertEquals(422, failure.status()); + assertEquals( + List.of("contact.email", "service_documents"), failure.problem().requirementsPending()); + assertEquals(List.of("terms", "privacy"), failure.problem().requiredDocumentIds()); + assertEquals(0, handler.issues.get()); + } + + private static AepService documentService( + EnrollmentStore store, EnrollmentPolicy policy, RecordingGrantHandler handler) { + Map wire = new LinkedHashMap<>(AepJson.parseObject(AepJson.write(document(true)), "Inspect")); + wire.put("extensions", Map.of("supported", List.of(ServiceDocuments.EXTENSION))); + wire.put( + "service_documents", + List.of("terms", "privacy").stream() + .map(id -> new ServiceDocument( + id, + "terms_of_service", + id, + "required", + List.of(new ServiceDocument.Representation( + "text/html", "https://service.example/" + id)))) + .toList()); + return serviceBuilder(AepJson.parseInspectDocument(AepJson.write(wire)), policy) + .enrollmentStore(store) + .grantType(new GrantTypeDefinition("api-key", handler)) + .credentialAuthenticator("api-key", new EmptyAuthenticator()) + .build(); + } + + @Test + void reevaluatesPolicyAndPreservesExistingLifecycleForANewKey() { AtomicInteger decisions = new AtomicInteger(); EnrollmentPolicy policy = (request, now) -> { decisions.incrementAndGet(); @@ -56,7 +233,7 @@ void enrollsOnceAndReturnsExistingLifecycleForANewKey() { assertEquals(200, first.status()); assertEquals(AgentStatus.PENDING, ((foundation.aep.core.EnrollResponse) first.body()).status()); assertEquals("true", ((foundation.aep.core.EnrollResponse) second.body()).ownerActionRequired()); - assertEquals(1, decisions.get()); + assertEquals(2, decisions.get()); } @Test @@ -82,6 +259,83 @@ void cachesRequirementsUnmetAndDetectsIdempotencyConflict() { assertEquals("idempotency_conflict", conflict.problem().code()); } + @Test + void distinguishesOmittedAndExplicitPolicyMetadata() { + for (boolean initial : new boolean[] {true, false}) { + for (Boolean value : new Boolean[] {null, false, true}) { + EnrollmentStore store = EnrollmentStore.inMemory(); + EnrollmentRecord original = EnrollmentRecord.builder( + AGENT_DID, "existing", AgentStatus.SUSPENDED, NOW.minusSeconds(3600)) + .claims(requestWithRequiredClaims().claims()) + .ownerActionRequired(true) + .requirementsPending(List.of("review")) + .verificationPending(List.of("contact.email")) + .build(); + if (!initial) store.save(original).toCompletableFuture().join(); + EnrollmentDecision decision = new EnrollmentDecision( + AgentStatus.ACTIVE, value, value == null ? null : List.of(), value == null ? null : List.of()); + AepService service = serviceBuilder(document(false), (request, now) -> completed(decision)) + .enrollmentStore(store) + .build(); + ServiceResponse result = service.enroll( + requestWithRequiredClaims(), idempotent("metadata", "metadata")) + .toCompletableFuture() + .join(); + assertEquals(200, result.status()); + EnrollmentRecord saved = + store.find(AGENT_DID).toCompletableFuture().join().orElseThrow(); + assertEquals(value == null ? !initial : value, saved.ownerActionRequired()); + assertEquals(!initial && value == null ? List.of("review") : List.of(), saved.requirementsPending()); + assertEquals( + !initial && value == null ? List.of("contact.email") : List.of(), saved.verificationPending()); + if (!initial) { + assertEquals(original.enrollmentId(), saved.enrollmentId()); + assertEquals(original.createdAt(), saved.createdAt()); + assertEquals(original.since(), saved.since()); + assertEquals(AgentStatus.SUSPENDED, saved.status()); + } + } + } + } + + @Test + void repeatedEnrollmentMergesClaimsAndAllowsPolicyToLeaveRecordUntouched() { + for (EnrollmentDecision decision : List.of( + new EnrollmentDecision(AgentStatus.ACTIVE, true, List.of("verification"), List.of("review")), + new EnrollmentDecision(AgentStatus.REJECTED, null, null, null), + new EnrollmentDecision(AgentStatus.ACTIVE, null, null, null, false))) { + EnrollmentStore store = EnrollmentStore.inMemory(); + EnrollmentRecord original = EnrollmentRecord.builder( + AGENT_DID, "existing", AgentStatus.PENDING, NOW.minusSeconds(3600)) + .claims(requestWithRequiredClaims().claims()) + .build(); + store.save(original).toCompletableFuture().join(); + AepService service = serviceBuilder(document(false), (request, now) -> completed(decision)) + .enrollmentStore(store) + .build(); + EnrollRequest request = new EnrollRequest( + AGENT_DID, ClaimValues.builder().personFirstName("Owner").build(), null); + ServiceResponse result = service.enroll(request, idempotent("repeat", "repeat")) + .toCompletableFuture() + .join(); + EnrollmentRecord saved = + store.find(AGENT_DID).toCompletableFuture().join().orElseThrow(); + if (decision.status() == AgentStatus.REJECTED || !decision.updateExisting()) { + assertEquals(decision.status() == AgentStatus.REJECTED ? 403 : 200, result.status()); + assertEquals(original.updatedAt(), saved.updatedAt()); + assertEquals(null, saved.claims().personFirstName()); + } else { + assertEquals(200, result.status()); + assertEquals("agent@example.com", saved.claims().contactEmail()); + assertEquals("Owner", saved.claims().personFirstName()); + assertTrue(saved.ownerActionRequired()); + assertEquals(List.of("verification"), saved.verificationPending()); + assertEquals(List.of("review"), saved.requirementsPending()); + assertEquals(AgentStatus.PENDING, saved.status()); + } + } + } + @Test void rejectsReplayedAssertionWithoutDisclosingTheCause() { AepService service = @@ -483,9 +737,11 @@ private static CompletionStage completed(T value) { private static final class RecordingGrantHandler implements GrantTypeHandler { private final AtomicInteger revocations = new AtomicInteger(); + private final AtomicInteger issues = new AtomicInteger(); @Override public CompletionStage grant(GrantRequest request, GrantContext context) { + issues.incrementAndGet(); return completed( new GrantResult("credential-1", Map.of("credential_id", "credential-1", "api_key", "secret"))); } diff --git a/aep-service/src/test/java/foundation/aep/service/ServiceValidationTest.java b/aep-service/src/test/java/foundation/aep/service/ServiceValidationTest.java index 2b2ef51..f332fc2 100644 --- a/aep-service/src/test/java/foundation/aep/service/ServiceValidationTest.java +++ b/aep-service/src/test/java/foundation/aep/service/ServiceValidationTest.java @@ -317,9 +317,20 @@ 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(null, decision.verificationPending()); + assertEquals(null, decision.requirementsPending()); assertEquals(AgentStatus.ACTIVE, EnrollmentDecision.active().status()); + List pending = new java.util.ArrayList<>(List.of("review")); + EnrollmentDecision populated = new EnrollmentDecision(AgentStatus.ACTIVE, true, pending, pending); + pending.clear(); + assertEquals(List.of("review"), populated.verificationPending()); + assertEquals(List.of("review"), populated.requirementsPending()); + assertThrows( + UnsupportedOperationException.class, + () -> populated.verificationPending().clear()); + assertThrows( + UnsupportedOperationException.class, + () -> populated.requirementsPending().clear()); assertThrows( IllegalArgumentException.class, () -> new EnrollmentDecision(AgentStatus.SUSPENDED, false, List.of(), List.of())); diff --git a/scripts/run-conformance.sh b/scripts/run-conformance.sh index 6bae634..8d29e86 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\",\"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" +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\",\"service-documents\"],\"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/tools/aep-conformance/src/main/java/foundation/aep/conformance/DocumentConformance.java b/tools/aep-conformance/src/main/java/foundation/aep/conformance/DocumentConformance.java new file mode 100644 index 0000000..2944bd8 --- /dev/null +++ b/tools/aep-conformance/src/main/java/foundation/aep/conformance/DocumentConformance.java @@ -0,0 +1,453 @@ +package foundation.aep.conformance; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import foundation.aep.core.Aep; +import foundation.aep.core.AepJson; +import foundation.aep.core.AepValidationException; +import foundation.aep.core.AgentStatus; +import foundation.aep.core.ClientAssertionClaims; +import foundation.aep.core.EnrollRequest; +import foundation.aep.core.EnrollResponse; +import foundation.aep.core.GrantRequest; +import foundation.aep.core.GrantResponses; +import foundation.aep.core.InspectDocument; +import foundation.aep.core.ServiceDocument; +import foundation.aep.core.ServiceDocuments; +import foundation.aep.service.AepService; +import foundation.aep.service.CommandOptions; +import foundation.aep.service.CredentialAuthenticationInput; +import foundation.aep.service.EnrollmentDecision; +import foundation.aep.service.EnrollmentRecord; +import foundation.aep.service.EnrollmentStore; +import foundation.aep.service.ServiceCredentialRecord; +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.Instant; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicInteger; + +final class DocumentConformance { + private static final String CONTENT = "content"; + private static final String TITLE = "title"; + private static final String ACKNOWLEDGED = "acknowledged_document_ids"; + private static final String REQUIRED_IDS = "required_document_ids"; + private static final String HTTP_STATUS = "http_status"; + private static final String OPERATIONS = "operations"; + private static final String NAME = "name"; + private static final String CLAIMS = "claims"; + private static final String ENROLL_REQUESTS = "enroll_requests"; + private static final String CREDENTIAL_IDS = "credential_ids"; + private static final String AUTHORIZATION = "Authorization"; + private static final String SERVICE_DID = "did:example:service"; + private static final String ENROLLMENT_LIFECYCLE = "enrollment-lifecycle"; + private static final String GRANT_GUIDANCE = "grant-guidance"; + private static final String UPDATE = "update"; + private static final String MISSING_CLAIMS = "missing_claims"; + private static final int HTTP_OK = 200; + private static final Instant NOW = Instant.parse("2026-10-02T10:00:00Z"); + + private DocumentConformance() {} + + static boolean evaluate(AdapterRequest request) { + if (ENROLLMENT_LIFECYCLE.equals(request.vector().id())) return lifecycle(request); + if (GRANT_GUIDANCE.equals(request.vector().id())) return guidance(request); + JsonNode input = request.testCase().input(); + JsonNode cases = ConformanceSupport.required(input, "cases"); + JsonNode expected = ConformanceSupport.required(request.testCase().expected(), "cases"); + if (!cases.isArray() || cases.isEmpty() || cases.size() != expected.size()) return false; + for (int index = 0; index < cases.size(); index++) { + JsonNode item = cases.get(index); + boolean valid = + switch (request.vector().id()) { + case "wire-shapes" -> + wire(ConformanceSupport.text(item, "schema"), ConformanceSupport.required(item, "value")); + case "structural-limits" -> structural(input, item); + default -> throw new IllegalArgumentException("Unsupported document vector"); + }; + if (!item.get("name").equals(expected.get(index).get("name")) + || valid + != ConformanceSupport.required(expected.get(index), "valid") + .asBoolean()) return false; + } + return true; + } + + private static boolean lifecycle(AdapterRequest request) { + JsonNode input = request.testCase().input(); + JsonNode initial = ConformanceSupport.required(input, "existing"); + Fixture fixture = new Fixture( + ConformanceSupport.text(initial, "agent_did"), + ConformanceSupport.strings(input, "current_required_ids"), + List.of()); + EnrollmentRecord original = EnrollmentRecord.builder( + fixture.agentDid, + ConformanceSupport.text(initial, "enrollment_id"), + AgentStatus.fromValue(ConformanceSupport.text(initial, "status")), + Instant.parse(ConformanceSupport.text(initial, "created"))) + .since(Instant.parse(ConformanceSupport.text(initial, "since"))) + .claims(AepJson.parseClaimValues(initial.get(CLAIMS).toString())) + .acknowledgedDocumentIds(ConformanceSupport.strings(initial, ACKNOWLEDGED)) + .build(); + fixture.save(original); + List credentialIds = ConformanceSupport.strings(initial, CREDENTIAL_IDS); + for (String id : credentialIds) + fixture.credentials + .save(new ServiceCredentialRecord( + fixture.agentDid, + NOW, + credential(id), + id, + NOW.plusSeconds(3600), + Aep.GRANT_TYPE_OAUTH_BEARER)) + .toCompletableFuture() + .join(); + ObjectNode actual = ConformanceSupport.MAPPER.createObjectNode(); + ArrayNode results = actual.putArray(OPERATIONS); + for (JsonNode operation : ConformanceSupport.required(input, OPERATIONS)) { + if (operation.has("mark_required")) + fixture.save(copy(fixture.record()) + .requiredDocumentIds(ConformanceSupport.strings(operation, "mark_required")) + .build()); + EnrollmentRecord before = fixture.record(); + int effects = fixture.policyEffects.get(); + ServiceResponse response = + fixture.enroll(operation, ConformanceSupport.text(operation, "key")); + EnrollmentRecord after = fixture.record(); + ObjectNode outcome = response(response).put(NAME, ConformanceSupport.text(operation, NAME)); + outcome.set("stored_ids", ConformanceSupport.MAPPER.valueToTree(after.acknowledgedDocumentIds())); + outcome.put("policy_effects_repeated", fixture.policyEffects.get() != effects); + outcome.put("mutated", !after.equals(before)); + outcome.put("requirement_satisfied", after.requiredDocumentIds().isEmpty()); + results.add(outcome); + if (UPDATE.equals(ConformanceSupport.text(operation, NAME))) { + actual.set( + "outstanding_ids_after_update", + ConformanceSupport.MAPPER.valueToTree( + fixture.grant().problem().requiredDocumentIds())); + } + } + EnrollmentRecord updated = fixture.record(); + actual.put( + "identity_preserved", + original.agentDid().equals(updated.agentDid()) + && original.enrollmentId().equals(updated.enrollmentId())); + actual.put("creation_preserved", original.createdAt().equals(updated.createdAt())); + actual.put("unchanged_state_timestamp_preserved", original.since().equals(updated.since())); + actual.set(CLAIMS, ConformanceSupport.json(AepJson.write(updated.claims()))); + ArrayNode retained = actual.putArray(CREDENTIAL_IDS); + for (String id : credentialIds) + fixture.credentials + .authenticate( + Aep.GRANT_TYPE_OAUTH_BEARER, + new CredentialAuthenticationInput( + Map.of(AUTHORIZATION, List.of("Bearer " + id)), + "GET", + URI.create("https://service.example/private"), + NOW)) + .toCompletableFuture() + .join() + .ifPresent(match -> retained.add(match.credentialId())); + boolean preserved = true; + for (String status : ConformanceSupport.strings(input, "lifecycle_states")) { + AgentStatus state = AgentStatus.fromValue(status); + fixture.save(copy(updated).status(state).build()); + ObjectNode empty = ConformanceSupport.MAPPER.createObjectNode(); + empty.putArray(ACKNOWLEDGED); + ServiceResponse result = fixture.enroll(empty, "state-" + status); + preserved &= result.status() == HTTP_OK + && fixture.record().status() == state + && updated.since().equals(fixture.record().since()); + } + actual.put("unrelated_lifecycle_states_preserved", preserved); + return matches(actual, request.testCase().expected()); + } + + private static boolean guidance(AdapterRequest request) { + JsonNode input = request.testCase().input(); + Fixture fixture = new Fixture( + "did:example:agent", + ConformanceSupport.strings(input, REQUIRED_IDS), + ConformanceSupport.strings(input, MISSING_CLAIMS)); + if (input.path("recognized").asBoolean()) + fixture.save(EnrollmentRecord.builder(fixture.agentDid, "existing", AgentStatus.ACTIVE, NOW) + .build()); + var grant = fixture.grant(); + ObjectNode actual = ConformanceSupport.MAPPER + .createObjectNode() + .put("grant_status", grant.status()) + .put("credential_issued", fixture.issued.get() != 0); + if (grant.problem() == null + || !matches( + ConformanceSupport.json(AepJson.write(grant.problem())), + ConformanceSupport.required(input, "problem"), + TITLE)) return false; + Fixture unknown = new Fixture( + fixture.agentDid, + ConformanceSupport.strings(input, REQUIRED_IDS), + ConformanceSupport.strings(input, MISSING_CLAIMS)); + var unknownGrant = unknown.grant(); + actual.set( + "unrecognized", + response(unknownGrant) + .put( + "requirements_disclosed", + unknownGrant.problem().requirementsPending() != null + || unknownGrant.problem().requiredDocumentIds() != null)); + ArrayNode enrollments = actual.putArray(ENROLL_REQUESTS); + for (JsonNode value : ConformanceSupport.required(input, ENROLL_REQUESTS)) { + Fixture separate = new Fixture( + fixture.agentDid, + ConformanceSupport.strings(input, REQUIRED_IDS), + ConformanceSupport.strings(input, MISSING_CLAIMS)); + var enrolled = separate.enroll(value, "enroll"); + ObjectNode observed = response(enrolled).put(NAME, ConformanceSupport.text(value, NAME)); + if (enrolled.status() == HTTP_OK) + observed.put( + "missing_documents_accepted", + !separate.record().acknowledgedDocumentIds().isEmpty()); + enrollments.add(observed); + } + return matches(actual, request.testCase().expected(), "agent"); + } + + private static EnrollmentRecord.Builder copy(EnrollmentRecord value) { + return EnrollmentRecord.builder(value.agentDid(), value.enrollmentId(), value.status(), value.createdAt()) + .claims(value.claims()) + .since(value.since()) + .updatedAt(value.updatedAt()) + .ownerActionRequired(value.ownerActionRequired()) + .requirementsPending(value.requirementsPending()) + .verificationPending(value.verificationPending()) + .acknowledgedDocumentIds(value.acknowledgedDocumentIds()) + .requiredDocumentIds(value.requiredDocumentIds()); + } + + private static ObjectNode response(ServiceResponse response) { + ObjectNode result = ConformanceSupport.MAPPER.createObjectNode().put(HTTP_STATUS, response.status()); + if (response.problem() != null) result.put("code", response.problem().code()); + if (response.body() instanceof EnrollResponse enrolled) + result.set(ACKNOWLEDGED, ConformanceSupport.MAPPER.valueToTree(enrolled.acknowledgedDocumentIds())); + return result; + } + + private static boolean matches(JsonNode actual, JsonNode expected, String... ignored) { + if (actual == null) return false; + if (expected.isObject()) { + var fields = expected.fields(); + while (fields.hasNext()) { + var field = fields.next(); + if (!List.of(ignored).contains(field.getKey()) + && !matches(actual.get(field.getKey()), field.getValue())) return false; + } + return true; + } + if (expected.isArray()) { + if (!actual.isArray() || actual.size() != expected.size()) return false; + for (int index = 0; index < expected.size(); index++) + if (!matches(actual.get(index), expected.get(index))) return false; + return true; + } + return actual.equals(expected); + } + + private static GrantResponses.OAuthBearer credential(String id) { + return new GrantResponses.OAuthBearer(id, id, NOW.plusSeconds(3600).toString(), List.of(), "opaque", "Bearer"); + } + + private static final class Fixture { + private final String agentDid; + private final EnrollmentStore enrollments = EnrollmentStore.inMemory(); + private final ServiceCredentialStore credentials = ServiceCredentialStore.inMemory(); + private final AtomicInteger policyEffects = new AtomicInteger(); + private final AtomicInteger issued = new AtomicInteger(); + private final AtomicInteger assertions = new AtomicInteger(); + private final AepService service; + + private Fixture(String did, List required, List claims) { + agentDid = did; + var config = 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", "enroll", "grant"), + List.of(Aep.GRANT_TYPE_OAUTH_BEARER), + Map.of(Aep.GRANT_TYPE_OAUTH_BEARER, config))) + .authentication(new InspectDocument.Authentication(List.of(Aep.GRANT_TYPE_OAUTH_BEARER))) + .claims(new InspectDocument.Claims(claims, List.of(), List.of())) + .core(new InspectDocument.Core(Aep.REQUIRED_SIGNING_ALGORITHMS)) + .extensions(new InspectDocument.Extensions(List.of(ServiceDocuments.EXTENSION))) + .http(new InspectDocument.Http("/aep", null)) + .identity(new InspectDocument.Identity(List.of("did:example"))) + .service(new InspectDocument.Service(SERVICE_DID)) + .serviceDocuments(required.stream() + .map(id -> new ServiceDocument( + id, + "terms_of_service", + id, + "required", + List.of(new ServiceDocument.Representation( + "text/html", "https://service.example/" + id)))) + .toList()) + .build(); + service = AepService.builder( + document, + (assertion, context) -> CompletableFuture.completedFuture(new ClientAssertionClaims( + did, + did, + context.serviceDid(), + context.operation(), + NOW.minusSeconds(1).getEpochSecond(), + NOW.plusSeconds(60).getEpochSecond(), + assertion, + null))) + .clock(Clock.fixed(NOW, ZoneOffset.UTC)) + .enrollmentStore(enrollments) + .enrollmentPolicy((request, now) -> { + policyEffects.incrementAndGet(); + return CompletableFuture.completedFuture(EnrollmentDecision.active()); + }) + .storedCredentialGrantType(StoredCredentialGrantTypes.oauthBearer( + config, + (request, context) -> { + issued.incrementAndGet(); + return CompletableFuture.completedFuture(credential("issued")); + }, + credentials)) + .build(); + } + + private CommandOptions options(String key) { + return CommandOptions.idempotent("assertion-" + assertions.incrementAndGet(), key); + } + + private void save(EnrollmentRecord record) { + enrollments.save(record).toCompletableFuture().join(); + } + + private EnrollmentRecord record() { + return enrollments.find(agentDid).toCompletableFuture().join().orElseThrow(); + } + + private ServiceResponse enroll(JsonNode input, String key) { + EnrollRequest request = new EnrollRequest( + agentDid, + input.has(CLAIMS) + ? AepJson.parseClaimValues(input.get(CLAIMS).toString()) + : null, + key, + input.has(ACKNOWLEDGED) ? ConformanceSupport.strings(input, ACKNOWLEDGED) : null); + return service.enroll(request, options(key)).toCompletableFuture().join(); + } + + private ServiceResponse> grant() { + return service.grant( + new GrantRequest(Aep.GRANT_TYPE_OAUTH_BEARER, List.of()), + options("grant-" + assertions.get())) + .toCompletableFuture() + .join(); + } + } + + private static boolean wire(String schema, JsonNode value) { + try { + switch (schema) { + case "service-documents.schema.json" -> + AepJson.parseInspectDocument(inspect(value).toString()); + case "inspect-document.schema.json" -> AepJson.parseInspectDocument(value.toString()); + case "enroll-request.schema.json" -> AepJson.parseEnrollRequest(value.toString()); + case "enroll-response.schema.json" -> AepJson.parseEnrollResponse(value.toString()); + case "problem.schema.json" -> AepJson.parseProblemDetails(value.toString()); + case "portable-service-document.schema.json" -> + AepJson.parsePortableServiceDocument( + value.toString(), value.path("id").asText()); + default -> throw new IllegalArgumentException("Unsupported document schema"); + } + return true; + } catch (AepValidationException exception) { + return false; + } + } + + private static ObjectNode inspect(JsonNode fields) { + ObjectNode base = (ObjectNode) ConformanceSupport.json(""" + {"aep_version":"1.0","bindings":{"supported":["http"]},"commands":{"supported":["inspect"]}, + "core":{"signing_algorithms":["EdDSA","ES256"]},"http":{"endpoint_base":"/aep"}, + "identity":{"methods":[]},"service":{"did":"did:example:service"}} + """); + if (!fields.isObject()) throw new IllegalArgumentException("Expected Inspect members"); + fields.fields().forEachRemaining(field -> base.set(field.getKey(), field.getValue())); + return base; + } + + private static boolean structural(JsonNode input, JsonNode item) { + ObjectNode descriptor = ConformanceSupport.required(input, "descriptor").deepCopy(); + ObjectNode document = ConformanceSupport.required(input, "document").deepCopy(); + ArrayNode catalog = ConformanceSupport.MAPPER.createArrayNode(); + String kind = ConformanceSupport.text(item, "kind"); + switch (kind) { + case "catalog" -> { + for (int index = 0; index < item.path("count").asInt(); index++) + catalog.add(descriptor.deepCopy().put("id", "doc-" + index)); + } + case "duplicate-ids" -> catalog.add(descriptor).add(descriptor); + case "duplicate-media" -> { + ((ArrayNode) descriptor.get("representations")) + .add(descriptor.get("representations").get(0)); + catalog.add(descriptor); + } + case "blocks" -> { + JsonNode block = document.get(CONTENT).get(0); + for (String field : java.util.List.of(CONTENT, "summary")) { + ArrayNode blocks = document.putArray(field); + for (int index = 0; index < item.path(field + "_count").asInt(); index++) blocks.add(block); + if ("summary".equals(field) && blocks.isEmpty()) document.remove(field); + } + } + case "sections" -> { + for (int index = 0; index < item.path("depth").asInt(); index++) { + ObjectNode section = ConformanceSupport.MAPPER + .createObjectNode() + .put("type", "section") + .put(TITLE, "Section"); + section.set("blocks", document.get(CONTENT)); + document.putArray(CONTENT).add(section); + } + } + case "text" -> + ((ObjectNode) document.get(CONTENT).get(0)) + .put("text", "a".repeat(item.path("length").asInt())); + case "list" -> { + ObjectNode list = document.putArray(CONTENT).addObject().put("type", "bulleted_list"); + ArrayNode items = list.putArray("items"); + for (int index = 0; index < item.path("count").asInt(); index++) items.add("Item"); + } + case TITLE -> + catalog.add(descriptor.put(TITLE, "a".repeat(item.path("length").asInt()))); + case "url" -> { + String prefix = "https://example.com/"; + ((ObjectNode) descriptor.get("representations").get(0)) + .put("url", prefix + "a".repeat(item.path("length").asInt() - prefix.length())); + catalog.add(descriptor); + } + default -> throw new IllegalArgumentException("Unsupported structural bound"); + } + if (catalog.isEmpty()) return wire("portable-service-document.schema.json", document); + try { + var values = new ArrayList(); + for (JsonNode entry : catalog) values.add(AepJson.parseServiceDocument(entry.toString())); + return ServiceDocuments.catalog(values).isEmpty(); + } catch (AepValidationException exception) { + return false; + } + } +} 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 68d2690..d534286 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 @@ -17,6 +17,8 @@ import foundation.aep.service.AepServiceHttpHandler; import foundation.aep.service.CommandOptions; import foundation.aep.service.EnrollmentDecision; +import foundation.aep.service.EnrollmentRecord; +import foundation.aep.service.EnrollmentStore; import foundation.aep.service.GrantContext; import foundation.aep.service.GrantResult; import foundation.aep.service.GrantTypeDefinition; @@ -65,17 +67,21 @@ final class ServiceConformance { private static final String RESOURCE = "resource"; private static final String STATUS = "status"; private static final String CODE = "code"; + private static final String CONTENT_TYPE = "content_type"; + private static final String BODY = "body"; + private static final String SERVICE_DOCUMENTS = "service-documents"; private ServiceConformance() {} static boolean evaluate(AdapterRequest request) { + if (SERVICE_DOCUMENTS.equals(request.vector().category())) return DocumentConformance.evaluate(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 "repeated-existing" -> repeatedEnrollment(request); case "grant-before-enroll-rejected" -> grantBeforeEnrollment(request); case "command-header" -> commandHeaders(request); case "command-replay-conflict" -> commandReplay(request); @@ -129,7 +135,7 @@ private static boolean commandWire(AdapterRequest vector) { URI.create("https://service.example" + ConformanceSupport.text(expected, "path")), Map.of( "Content-Type", - List.of(ConformanceSupport.text(expected, "content_type")), + List.of(ConformanceSupport.text(expected, CONTENT_TYPE)), AUTHORIZATION, List.of(ConformanceSupport.text(expected, "authorization_scheme") + " wire"), "Idempotency-Key", @@ -234,24 +240,56 @@ private static String signedAssertion(JsonNode header, JsonNode claims, com.nimb return token.serialize(); } - private static boolean repeatedEnrollment() { + private static boolean repeatedEnrollment(AdapterRequest vector) { + JsonNode input = vector.testCase().input(); + JsonNode expected = vector.testCase().expected(); + JsonNode existing = ConformanceSupport.required(input, "existing"); + String agentDid = ConformanceSupport.text(existing, "agent_did"); + Instant since = Instant.parse(ConformanceSupport.text(existing, "since")); + AgentStatus status = + AgentStatus.valueOf(ConformanceSupport.text(existing, "status").toUpperCase(java.util.Locale.ROOT)); + EnrollmentStore store = EnrollmentStore.inMemory(); + EnrollmentRecord original = + EnrollmentRecord.builder(agentDid, ENROLLMENT_ID, status, since).build(); + store.save(original).toCompletableFuture().join(); AtomicInteger decisions = new AtomicInteger(); - AepService service = serviceBuilder(document(List.of(ENROLL, INSPECT), List.of(Aep.AUTHENTICATION_METHOD_JWT))) + AepService service = AepService.builder( + document(List.of(ENROLL, INSPECT), List.of(Aep.AUTHENTICATION_METHOD_JWT)), + (assertion, context) -> completed(new ClientAssertionClaims( + agentDid, + agentDid, + context.serviceDid(), + context.operation(), + NOW.getEpochSecond(), + NOW.plusSeconds(60).getEpochSecond(), + assertion, + null))) + .clock(Clock.fixed(NOW, ZoneOffset.UTC)) + .enrollmentStore(store) .enrollmentPolicy((request, now) -> { decisions.incrementAndGet(); - return completed(EnrollmentDecision.active()); + return completed(new EnrollmentDecision(AgentStatus.ACTIVE, null, null, null, false)); }) .build(); - EnrollRequest request = new EnrollRequest(AGENT_DID, null, null); - var first = service.enroll(request, options("first", "first")) - .toCompletableFuture() - .join(); - var second = service.enroll(request, options("second", "second")) + EnrollRequest request = AepJson.parseEnrollRequest( + ConformanceSupport.required(input, "request").toString()); + var response = service.enroll(request, options("repeat", request.idempotencyKey())) .toCompletableFuture() .join(); - return first.body().status() == AgentStatus.ACTIVE - && second.body().status() == AgentStatus.ACTIVE - && decisions.get() == 1; + EnrollmentRecord saved = + store.find(agentDid).toCompletableFuture().join().orElseThrow(); + JsonNode expectedResponse = ConformanceSupport.required(expected, "response"); + return response.status() + == ConformanceSupport.required(expectedResponse, "status") + .asInt() + && response.contentType().equals(ConformanceSupport.text(expectedResponse, CONTENT_TYPE)) + && ConformanceSupport.jsonEquals(response.body(), ConformanceSupport.required(expectedResponse, BODY)) + && (decisions.get() == 1) + == ConformanceSupport.required(expected, "policy_evaluated") + .asBoolean() + && !saved.equals(original) + == ConformanceSupport.required(expected, "record_replaced") + .asBoolean(); } private static boolean grantBeforeEnrollment(AdapterRequest request) { @@ -419,23 +457,23 @@ private static boolean idempotency(AdapterRequest vector) { && 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")) + .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")) + vector.testCase().expected().path(BODY), "type")) && conflict.problem() .title() .equals(ConformanceSupport.text( - vector.testCase().expected().path("body"), "title")); + 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 contentType = ConformanceSupport.text(request.testCase().input(), "content_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))) 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 index 76e53fe..6790ad8 100644 --- a/tools/aep-conformance/src/test/java/foundation/aep/conformance/ConformanceFalsifiabilityTest.java +++ b/tools/aep-conformance/src/test/java/foundation/aep/conformance/ConformanceFalsifiabilityTest.java @@ -24,6 +24,40 @@ final class ConformanceFalsifiabilityTest { private static final String AGENT = "agent"; private static final String PLATFORM = "platform"; + @Test + void documentLifecycleAndGrantGuidanceExerciseServiceState() { + publishedFalsifiable( + SERVICE, + "service-documents", + "enrollment-lifecycle", + expected -> expected.put("identity_preserved", false)); + publishedFalsifiable(SERVICE, "service-documents", "enrollment-lifecycle", expected -> { + expected.putArray("credential_ids"); + return expected; + }); + publishedFalsifiable(SERVICE, "service-documents", "enrollment-lifecycle", expected -> { + ((ObjectNode) expected.get("operations").get(1)).put("policy_effects_repeated", true); + return expected; + }); + publishedFalsifiable( + SERVICE, "service-documents", "grant-guidance", expected -> expected.put("credential_issued", true)); + publishedFalsifiable(SERVICE, "service-documents", "grant-guidance", expected -> { + ((ObjectNode) expected.get("unrecognized")).put("requirements_disclosed", true); + return expected; + }); + } + + @Test + void documentParsersExercisePublishedWireAndStructuralBoundaries() { + for (String id : List.of("wire-shapes", "structural-limits")) { + publishedFalsifiable(SERVICE, "service-documents", id, expected -> { + ObjectNode first = (ObjectNode) expected.get("cases").get(0); + first.put("valid", !first.get("valid").asBoolean()); + return expected; + }); + } + } + @Test void agentExecutesEveryHostedIdentityVector() { Path vectors = specVectors();