diff --git a/aep-agent/src/main/java/foundation/aep/agent/AepServiceSession.java b/aep-agent/src/main/java/foundation/aep/agent/AepServiceSession.java index 0dcaa97..b63dd17 100644 --- a/aep-agent/src/main/java/foundation/aep/agent/AepServiceSession.java +++ b/aep-agent/src/main/java/foundation/aep/agent/AepServiceSession.java @@ -110,6 +110,12 @@ public CompletableFuture revoke(RevokeRequest request) { Objects.requireNonNull(request, "request"); requireValid("Revoke request", AepValidation.revokeRequest(request)); return commandContext(AepCommand.REVOKE).thenCompose(context -> { + if (request.grantType() != null + && !context.inspection.document().commands().grantTypes().contains(request.grantType())) { + return failed( + "grant_type_not_advertised", + "AEP Service does not advertise Grant Type " + request.grantType()); + } requirePerCredentialRevoke(context.inspection.document(), request); String discriminator = request.credentialId() == null ? request.grantType() : request.credentialId(); String key = idempotencyKey(context, AepCommand.REVOKE, discriminator == null ? "all" : discriminator); @@ -455,7 +461,7 @@ private static void requirePerCredentialRevoke(InspectDocument document, RevokeR } private String idempotencyKey(CommandContext context, AepCommand command, String discriminator) { - return requireNonBlank( + return AgentHttp.requireFieldValue( agent.idempotencyKeyProvider.keyFor( context.inspection.document().service().did(), command, discriminator), "idempotency key"); diff --git a/aep-agent/src/main/java/foundation/aep/agent/AgentHttp.java b/aep-agent/src/main/java/foundation/aep/agent/AgentHttp.java index 9b8e7fc..b26d6ea 100644 --- a/aep-agent/src/main/java/foundation/aep/agent/AgentHttp.java +++ b/aep-agent/src/main/java/foundation/aep/agent/AgentHttp.java @@ -41,15 +41,29 @@ static URI origin(URI value, boolean allowInsecureLoopback) { } static boolean sameOrigin(URI left, URI right) { - return left != null - && right != null - && left.getScheme() != null - && right.getScheme() != null - && left.getHost() != null - && right.getHost() != null - && left.getScheme().equalsIgnoreCase(right.getScheme()) - && IDN.toASCII(left.getHost()).equalsIgnoreCase(IDN.toASCII(right.getHost())) - && effectivePort(left) == effectivePort(right); + if (left == null + || right == null + || left.getScheme() == null + || right.getScheme() == null + || left.getHost() == null + || right.getHost() == null + || !left.getScheme().equalsIgnoreCase(right.getScheme()) + || effectivePort(left) != effectivePort(right)) { + return false; + } + // A redirect Location is attacker-controlled, and IDN.toASCII rejects a malformed label by + // throwing. An unparseable host is not the same origin; it is not an error to propagate. + String leftHost = asciiHost(left.getHost()); + String rightHost = asciiHost(right.getHost()); + return leftHost != null && leftHost.equalsIgnoreCase(rightHost); + } + + private static String asciiHost(String host) { + try { + return IDN.toASCII(host); + } catch (IllegalArgumentException exception) { + return null; + } } static boolean validInspectTarget(URI origin, URI target) { @@ -95,16 +109,28 @@ static AepAgentException commandError(AepHttpTransport.Response response) { } } + /** + * Resolves the freshness lifetime a cached document may be reused for. + * + * Cache-Control directives are an unordered set, so a response is scanned for the directives + * that forbid reuse before the one that permits it. Returning on the first directive instead + * let `max-age=600, no-cache` store a document as fresh for ten minutes, where CORE-DISC-014 + * requires `no-cache` to force revalidation whichever order the two arrive in. + */ static Instant expiresAt(AepHttpTransport.Response response, Instant now, Duration fallback) { - String cacheControl = String.join(",", headerValues(response, "Cache-Control")); - for (String directive : cacheControl.split(",")) { - String value = directive.trim().toLowerCase(Locale.ROOT); - if (CACHE_NO_STORE.equals(value) || CACHE_NO_CACHE.equals(value)) { - return now; + List directives = new ArrayList<>(); + for (String value : headerValues(response, "Cache-Control")) { + for (String directive : value.split(",")) { + directives.add(directive.trim().toLowerCase(Locale.ROOT)); } - if (value.startsWith("max-age=")) { + } + if (directives.contains(CACHE_NO_STORE) || directives.contains(CACHE_NO_CACHE)) { + return now; + } + for (String directive : directives) { + if (directive.startsWith("max-age=")) { try { - long seconds = Long.parseLong(value.substring("max-age=".length())); + long seconds = Long.parseLong(directive.substring("max-age=".length())); return safeAdd(now, Duration.ofSeconds(Math.max(0, seconds)), fallback); } catch (ArithmeticException | NumberFormatException exception) { return safeAdd(now, fallback, Duration.ZERO); @@ -114,6 +140,28 @@ static Instant expiresAt(AepHttpTransport.Response response, Instant now, Durati return safeAdd(now, fallback, Duration.ZERO); } + /** + * Checks a value the SDK is about to send as an HTTP field value. + * + * The idempotency key comes from an application-supplied provider and is written straight into + * a request header. A value carrying a control character or a line break is not a field value + * at all, and surrounding whitespace is stripped in transit, so the Service would record a + * different key than the Agent did. + */ + static String requireFieldValue(String value, String name) { + if (value == null || value.isBlank() || !value.equals(value.strip())) { + throw new AepAgentException( + "invalid_configuration", name + " must be a non-empty value without surrounding whitespace"); + } + for (int index = 0; index < value.length(); index++) { + char character = value.charAt(index); + if (character < 0x20 || character == 0x7f) { + throw new AepAgentException("invalid_configuration", name + " must not contain control characters"); + } + } + return value; + } + static boolean isNoStore(AepHttpTransport.Response response) { return headerValues(response, "Cache-Control").stream() .flatMap(value -> java.util.Arrays.stream(value.split(","))) diff --git a/aep-agent/src/main/java/foundation/aep/agent/PlatformIdentityProvider.java b/aep-agent/src/main/java/foundation/aep/agent/PlatformIdentityProvider.java index b453c21..cd7286a 100644 --- a/aep-agent/src/main/java/foundation/aep/agent/PlatformIdentityProvider.java +++ b/aep-agent/src/main/java/foundation/aep/agent/PlatformIdentityProvider.java @@ -393,13 +393,24 @@ private URI endpoint(String path, String identityId) { private String idempotencyKey() { String value = idempotencyKeys.get(); - if (value == null || value.isBlank()) { + if (value == null || value.isBlank() || !value.equals(value.strip()) || hasControlCharacter(value)) { throw new AepAgentException( - "platform_idempotency_key_invalid", "AEP Platform idempotency key must not be blank"); + "platform_idempotency_key_invalid", + "AEP Platform idempotency key must be a field value without surrounding whitespace"); } return value; } + private static boolean hasControlCharacter(String value) { + for (int index = 0; index < value.length(); index++) { + char character = value.charAt(index); + if (character < 0x20 || character == 0x7f) { + return true; + } + } + return false; + } + private static boolean ownedHeader(String name) { return name != null && ("Accept".equalsIgnoreCase(name) diff --git a/aep-agent/src/test/java/foundation/aep/agent/AgentConformanceTest.java b/aep-agent/src/test/java/foundation/aep/agent/AgentConformanceTest.java new file mode 100644 index 0000000..a32e3bb --- /dev/null +++ b/aep-agent/src/test/java/foundation/aep/agent/AgentConformanceTest.java @@ -0,0 +1,509 @@ +package foundation.aep.agent; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import foundation.aep.core.Aep; +import foundation.aep.core.AepHttpTransport; +import foundation.aep.core.ClaimValues; +import foundation.aep.core.RevokeRequest; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import org.junit.jupiter.api.Test; + +/** Spec-grounded Agent behaviour across Inspect, the commands, and protected-resource use. */ +class AgentConformanceTest { + private static final URI ORIGIN = URI.create("https://service.example"); + private static final Instant NOW = Instant.parse("2026-09-01T00:00:00Z"); + + /** + * CORE-RVK-002: an Agent names only a Grant Type the Service advertises. Revoke checked the + * per-credential configuration but never the advertisement itself, so a request for an + * unadvertised type spent a signature on something the Service was always going to refuse. + */ + @Test + void revokeRefusesAnUnadvertisedGrantType() { + QueueTransport inspect = new QueueTransport(aepResponse(200, document(List.of("inspect", "revoke")))); + QueueTransport command = new QueueTransport(); + AepServiceSession session = agent(inspect, command).build().service(ORIGIN); + + AepAgentException error = agentError(session.revoke(new RevokeRequest("absent-grant", null, null))); + + assertEquals("grant_type_not_advertised", error.code()); + assertTrue(command.requests.isEmpty(), "no assertion is spent on a refused Grant Type"); + } + + @Test + void revokeAcceptsAnAdvertisedGrantTypeAndTheAllGrantTypesForm() { + QueueTransport inspect = new QueueTransport( + aepResponse(200, document(List.of("inspect", "revoke"))), + aepResponse(200, document(List.of("inspect", "revoke")))); + QueueTransport command = new QueueTransport(aepResponse(200, "{}"), aepResponse(200, "{}")); + AepAgent configured = agent(inspect, command).build(); + + configured + .service(ORIGIN) + .revoke(new RevokeRequest("custom-grant", null, null)) + .join(); + configured.service(ORIGIN).revoke(new RevokeRequest(null, null, "true")).join(); + + assertEquals(2, command.requests.size()); + // CORE-RVK-006: all_grant_types carries no grant type to check against the advertisement. + assertEquals("POST", command.requests.get(1).method()); + } + + /** CORE-INS-011: per-credential Revoke is used only where the configuration advertises it. */ + @Test + void revokeRefusesAnUnadvertisedPerCredentialForm() { + QueueTransport inspect = new QueueTransport(aepResponse(200, documentWithoutPerCredentialRevoke())); + QueueTransport command = new QueueTransport(); + AepServiceSession session = agent(inspect, command).build().service(ORIGIN); + + AepAgentException error = agentError(session.revoke(new RevokeRequest("custom-grant", "credential-1", null))); + + assertEquals("per_credential_revoke_unavailable", error.code()); + assertTrue(command.requests.isEmpty()); + } + + /** + * CORE-IDP-001 and CORE-IDP-002: the key the Agent records has to be the key the Service sees. + * A provider value with surrounding whitespace is stripped in transit, and one carrying a + * control character is not a field value at all. + */ + @Test + void refusesIdempotencyKeysThatCannotSurviveAsAFieldValue() { + for (String key : List.of(" key", "key ", "\tkey", "key\r\nX-Injected: 1", "key\u0000", "")) { + QueueTransport inspect = new QueueTransport(aepResponse(200, document(List.of("inspect", "enroll")))); + QueueTransport command = new QueueTransport(); + AepServiceSession session = agent(inspect, command) + .idempotencyKeyProvider((serviceDid, operation, discriminator) -> key) + .build() + .service(ORIGIN); + + AepAgentException error = + agentError(session.enroll(ClaimValues.builder().build())); + + assertEquals("invalid_configuration", error.code(), key); + assertTrue(command.requests.isEmpty(), key); + } + } + + /** CORE-IDP-009: the header and the Enroll body carry the same key. */ + @Test + void sendsOneIdempotencyKeyInBothTheHeaderAndTheEnrollBody() { + QueueTransport inspect = new QueueTransport(aepResponse(200, document(List.of("inspect", "enroll")))); + QueueTransport command = new QueueTransport(aepResponse(200, "{\"status\":\"active\"}")); + agent(inspect, command) + .idempotencyKeyProvider((serviceDid, operation, discriminator) -> "enroll-key") + .build() + .service(ORIGIN) + .enroll(ClaimValues.builder().build()) + .join(); + + AepHttpTransport.Request request = command.requests.get(0); + assertEquals(List.of("enroll-key"), request.headers().get("Idempotency-Key")); + assertTrue(new String(request.body(), StandardCharsets.UTF_8).contains("\"idempotency_key\":\"enroll-key\"")); + } + + /** CORE-CA-001: Inspect is unauthenticated and carries no assertion. */ + @Test + void doesNotAuthenticateInspect() { + QueueTransport inspect = new QueueTransport(aepResponse(200, document(List.of("inspect")))); + agent(inspect, new QueueTransport()).build().service(ORIGIN).inspect().join(); + + AepHttpTransport.Request request = inspect.requests.get(0); + assertFalse(request.headers().containsKey("Authorization")); + assertFalse(request.headers().containsKey(Aep.AUTHORIZATION_HEADER)); + assertFalse(request.headers().containsKey("Idempotency-Key")); + assertEquals(List.of(Aep.MEDIA_TYPE), request.headers().get("Accept")); + } + + /** + * CORE-AUTH-005: the challenged Service DID must match the fetched document, which for did:web + * means the DID has to bind the origin the document came from. + */ + @Test + void refusesADocumentWhoseServiceDidDoesNotBindTheOrigin() { + QueueTransport inspect = new QueueTransport(aepResponse( + 200, document(List.of("inspect")).replace("did:web:service.example", "did:web:other.example"))); + AepServiceSession session = agent(inspect, new QueueTransport()).build().service(ORIGIN); + + assertEquals("service_identity_mismatch", agentError(session.inspect()).code()); + } + + /** CORE-PR-031: AEP credentials never cross an origin boundary. */ + @Test + void refusesToAuthenticateAgainstAnotherOrigin() { + AepServiceSession session = + agent(new QueueTransport(), new QueueTransport()).build().service(ORIGIN); + + for (URI resource : List.of( + URI.create("https://attacker.example/private"), + URI.create("http://service.example/private"), + URI.create("https://service.example:8443/private"))) { + assertEquals( + "origin_mismatch", + agentError(session.authenticate(resource)).code(), + resource.toString()); + } + } + + /** CORE-PR-001: an authenticate assertion binds to the exact protected-resource target. */ + @Test + void bindsTheAuthenticateAssertionToTheRequestedResource() { + QueueTransport inspect = new QueueTransport(aepResponse(200, document(List.of("inspect"), List.of("aep-jwt")))); + List signed = new ArrayList<>(); + AgentIdentity identity = new AgentIdentity("did:web:agent.example", claims -> { + signed.add(claims); + return CompletableFuture.completedFuture("assertion"); + }); + AgentAuthentication authentication = AepAgent.builder() + .inspectTransport(inspect) + .commandTransport(new QueueTransport()) + .identityProvider((origin, serviceDid) -> CompletableFuture.completedFuture(identity)) + .clock(Clock.fixed(NOW, ZoneOffset.UTC)) + .jwtIdSupplier(() -> "jwt-id") + .build() + .service(ORIGIN) + .authenticate(URI.create("https://service.example/private?q=1")) + .join(); + + assertEquals(Aep.AUTHENTICATION_METHOD_JWT, authentication.method()); + assertEquals(List.of("AEP assertion"), authentication.headers().get("Authorization")); + assertEquals( + foundation.aep.core.AssertionOperation.AUTHENTICATE, + signed.get(0).operation()); + assertEquals("https://service.example/private?q=1", signed.get(0).resource()); + assertEquals("did:web:service.example", signed.get(0).audience()); + } + + /** CORE-PR-038: with no advertised method the Agent has nothing to present. */ + @Test + void reportsWhenNoAdvertisedAuthenticationMethodIsAvailable() { + QueueTransport inspect = new QueueTransport(aepResponse(200, document(List.of("inspect")))); + AepServiceSession session = agent(inspect, new QueueTransport()).build().service(ORIGIN); + + assertEquals( + "authentication_unavailable", + agentError(session.authenticate(URI.create("https://service.example/private"))) + .code()); + } + + /** + * CORE-DISC-014: `no-cache` requires revalidation. Cache-Control directives are unordered, so + * the directive that forbids reuse has to win wherever it appears in the field value. + */ + @Test + void revalidatesWhenNoCacheAccompaniesAFreshnessLifetime() { + String body = document(List.of("inspect")); + QueueTransport inspect = new QueueTransport( + response( + 200, + Map.of( + "Content-Type", List.of(Aep.MEDIA_TYPE), + "Cache-Control", List.of("max-age=600, no-cache"), + "ETag", List.of("\"v1\"")), + body), + response( + 200, + Map.of("Content-Type", List.of(Aep.MEDIA_TYPE), "Cache-Control", List.of("max-age=600")), + body)); + MemoryInspectCache cache = new MemoryInspectCache(); + AepAgent configured = + agent(inspect, new QueueTransport()).inspectCache(cache).build(); + + configured.service(ORIGIN).inspect().join(); + configured.service(ORIGIN).inspect().join(); + + assertEquals(2, inspect.requests.size(), "no-cache must force revalidation"); + assertEquals(List.of("\"v1\""), inspect.requests.get(1).headers().get("If-None-Match")); + + // A plain max-age is reused without a second request. + configured.service(ORIGIN).inspect().join(); + assertEquals(2, inspect.requests.size()); + } + + /** CORE-DISC: `no-store` prohibits persistence beyond the current fetch. */ + @Test + void doesNotPersistANoStoreDocument() { + String body = document(List.of("inspect")); + QueueTransport inspect = new QueueTransport( + response( + 200, + Map.of("Content-Type", List.of(Aep.MEDIA_TYPE), "Cache-Control", List.of("no-store")), + body), + aepResponse(200, body)); + MemoryInspectCache cache = new MemoryInspectCache(); + AepAgent configured = + agent(inspect, new QueueTransport()).inspectCache(cache).build(); + + configured.service(ORIGIN).inspect().join(); + assertTrue(cache.entry.isEmpty(), "a no-store document is not persisted"); + configured.service(ORIGIN).inspect().join(); + assertEquals(2, inspect.requests.size()); + } + + /** CORE-DISC-013: a 304 refreshes the validators without refetching the body. */ + @Test + void reusesACachedDocumentOnNotModified() { + String body = document(List.of("inspect")); + QueueTransport inspect = new QueueTransport( + response( + 200, + Map.of( + "Content-Type", List.of(Aep.MEDIA_TYPE), + "Cache-Control", List.of("max-age=0"), + "ETag", List.of("\"v1\""), + "Last-Modified", List.of("Mon, 01 Sep 2026 00:00:00 GMT")), + body), + response(304, Map.of("ETag", List.of("\"v2\""), "Cache-Control", List.of("max-age=600")), "")); + MemoryInspectCache cache = new MemoryInspectCache(); + AepAgent configured = + agent(inspect, new QueueTransport()).inspectCache(cache).build(); + + configured.service(ORIGIN).inspect().join(); + AgentInspection second = configured.service(ORIGIN).inspect().join(); + + assertEquals("did:web:service.example", second.document().service().did()); + AepHttpTransport.Request revalidation = inspect.requests.get(1); + assertEquals(List.of("\"v1\""), revalidation.headers().get("If-None-Match")); + assertEquals( + List.of("Mon, 01 Sep 2026 00:00:00 GMT"), revalidation.headers().get("If-Modified-Since")); + assertEquals("\"v2\"", cache.entry.orElseThrow().etag()); + } + + /** CORE-DISC-017: Inspect redirects stay on the origin and never carry user information. */ + @Test + void refusesUnsafeInspectRedirects() { + for (String location : List.of( + "https://attacker.example/aep", + "http://service.example/aep", + "https://user:secret@service.example/aep", + "https://service.example/aep#fragment")) { + QueueTransport inspect = new QueueTransport(response(302, Map.of("Location", List.of(location)), "")); + AepServiceSession session = + agent(inspect, new QueueTransport()).build().service(ORIGIN); + String code = agentError(session.inspect()).code(); + assertTrue( + "cross_origin_redirect".equals(code) || "invalid_redirect".equals(code), + location + " produced " + code); + } + QueueTransport noLocation = new QueueTransport(response(302, Map.of(), "")); + assertEquals( + "invalid_redirect", + agentError(agent(noLocation, new QueueTransport()) + .build() + .service(ORIGIN) + .inspect()) + .code()); + } + + /** CORE-PR-033: a command endpoint never follows a redirect. */ + @Test + void refusesToFollowACommandRedirect() { + QueueTransport inspect = new QueueTransport(aepResponse(200, document(List.of("inspect", "status")))); + QueueTransport command = new QueueTransport(response(302, Map.of("Location", List.of("/elsewhere")), "")); + AepServiceSession session = agent(inspect, command).build().service(ORIGIN); + + assertEquals("command_redirect", agentError(session.status()).code()); + assertEquals(1, command.requests.size()); + } + + /** CORE-ERR-001: a Problem Details body becomes the reported failure. */ + @Test + void surfacesProblemDetailsFromACommandFailure() { + QueueTransport inspect = new QueueTransport(aepResponse(200, document(List.of("inspect", "status")))); + QueueTransport command = new QueueTransport(response( + 403, + Map.of("Content-Type", List.of(Aep.PROBLEM_MEDIA_TYPE)), + "{\"type\":\"urn:aep:error:verification_pending\",\"title\":\"Verification pending\"," + + "\"status\":403,\"code\":\"verification_pending\"}")); + AepServiceSession session = agent(inspect, command).build().service(ORIGIN); + + AepAgentException error = agentError(session.status()); + assertEquals("verification_pending", error.code()); + assertEquals(403, error.status()); + } + + /** A failure body that is not Problem Details still fails, without inventing a code. */ + @Test + void reportsANonProblemCommandFailure() { + QueueTransport inspect = new QueueTransport(aepResponse(200, document(List.of("inspect", "status")))); + QueueTransport command = + new QueueTransport(response(500, Map.of("Content-Type", List.of("text/plain")), "boom")); + AepServiceSession session = agent(inspect, command).build().service(ORIGIN); + + AepAgentException error = agentError(session.status()); + assertEquals("command_failed", error.code()); + assertEquals(500, error.status()); + } + + /** A command endpoint answers in the AEP media type or not at all. */ + @Test + void requiresTheAepMediaTypeOnSuccessfulResponses() { + QueueTransport inspect = new QueueTransport(aepResponse(200, document(List.of("inspect", "status")))); + QueueTransport command = + new QueueTransport(response(200, Map.of("Content-Type", List.of("application/json")), "{}")); + AepServiceSession session = agent(inspect, command).build().service(ORIGIN); + + assertEquals("invalid_media_type", agentError(session.status()).code()); + } + + /** A Service that does not advertise a command never receives it. */ + @Test + void refusesCommandsTheServiceDoesNotAdvertise() { + QueueTransport inspect = new QueueTransport( + aepResponse(200, document(List.of("inspect"))), + aepResponse(200, document(List.of("inspect"))), + aepResponse(200, document(List.of("inspect")))); + QueueTransport command = new QueueTransport(); + AepAgent configured = agent(inspect, command).build(); + + assertEquals( + "command_not_advertised", + agentError(configured.service(ORIGIN).status()).code()); + assertEquals( + "command_not_advertised", + agentError(configured + .service(ORIGIN) + .enroll(ClaimValues.builder().build())) + .code()); + assertEquals( + "command_not_advertised", + agentError(configured.service(ORIGIN).revoke(new RevokeRequest(null, null, "true"))) + .code()); + assertTrue(command.requests.isEmpty()); + } + + /** A response larger than the configured bound is refused before it is parsed. */ + @Test + void refusesAnOversizedResponse() { + QueueTransport inspect = new QueueTransport(aepResponse(200, document(List.of("inspect")))); + AepServiceSession session = agent(inspect, new QueueTransport()) + .maximumResponseBytes(8) + .build() + .service(ORIGIN); + + assertEquals("response_too_large", agentError(session.inspect()).code()); + } + + private static AepAgent.Builder agent(AepHttpTransport inspect, AepHttpTransport command) { + AgentIdentity identity = + new AgentIdentity("did:web:agent.example", claims -> CompletableFuture.completedFuture("signed")); + return AepAgent.builder() + .inspectTransport(inspect) + .commandTransport(command) + .identityProvider((origin, serviceDid) -> CompletableFuture.completedFuture(identity)) + .clock(Clock.fixed(NOW, ZoneOffset.UTC)) + .jwtIdSupplier(() -> "jwt-id"); + } + + private static String document(List commands) { + return document(commands, List.of()); + } + + private static String document(List commands, List methods) { + boolean grants = commands.contains("grant") || commands.contains("revoke"); + String grantTypes = grants ? "[\"custom-grant\"]" : "[]"; + String grantTypesConfig = grants ? "{\"custom-grant\":{\"supports_per_credential_revoke\":\"true\"}}" : "{}"; + String authentication = methods.isEmpty() ? "" : "\"authentication\":{\"methods\":" + array(methods) + "},"; + return """ + { + "aep_version":"1.0", + %s + "bindings":{"supported":["http"]}, + "commands":{"supported":%s,"grant_types":%s,"grant_types_config":%s}, + "core":{"signing_algorithms":["EdDSA","ES256"]}, + "http":{"endpoint_base":"/aep/"}, + "identity":{"methods":["did:web"]}, + "service":{"did":"did:web:service.example"} + } + """.formatted(authentication, array(commands), grantTypes, grantTypesConfig); + } + + private static String documentWithoutPerCredentialRevoke() { + return """ + { + "aep_version":"1.0", + "bindings":{"supported":["http"]}, + "commands":{"supported":["inspect","revoke"],"grant_types":["custom-grant"], + "grant_types_config":{"custom-grant":{"supports_per_credential_revoke":"false"}}}, + "core":{"signing_algorithms":["EdDSA","ES256"]}, + "http":{"endpoint_base":"/aep/"}, + "identity":{"methods":["did:web"]}, + "service":{"did":"did:web:service.example"} + } + """; + } + + private static String array(List values) { + return values.stream() + .map(value -> "\"" + value + "\"") + .collect(java.util.stream.Collectors.joining(",", "[", "]")); + } + + private static AepHttpTransport.Response aepResponse(int status, String body) { + return response(status, Map.of("Content-Type", List.of(Aep.MEDIA_TYPE)), body); + } + + private static AepHttpTransport.Response response(int status, Map> headers, String body) { + return new AepHttpTransport.Response(status, headers, body.getBytes(StandardCharsets.UTF_8)); + } + + private static AepAgentException agentError(CompletableFuture future) { + CompletionException failure = assertThrows(CompletionException.class, future::join); + return (AepAgentException) failure.getCause(); + } + + private static final class QueueTransport implements AepHttpTransport { + private final Deque responses = new ArrayDeque<>(); + private final List requests = new ArrayList<>(); + + QueueTransport(Response... values) { + responses.addAll(List.of(values)); + } + + @Override + public CompletableFuture execute(Request request) { + requests.add(request); + if (responses.isEmpty()) { + return CompletableFuture.failedFuture(new AssertionError("Unexpected request: " + request)); + } + return CompletableFuture.completedFuture(responses.removeFirst()); + } + } + + private static final class MemoryInspectCache implements AgentInspectCache { + private Optional entry = Optional.empty(); + + @Override + public CompletableFuture> get(URI origin) { + return CompletableFuture.completedFuture(entry); + } + + @Override + public CompletableFuture put(URI origin, Entry value) { + entry = Optional.of(value); + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletableFuture delete(URI origin) { + entry = Optional.empty(); + return CompletableFuture.completedFuture(null); + } + } +} diff --git a/aep-agent/src/test/java/foundation/aep/agent/AgentCoverageTest.java b/aep-agent/src/test/java/foundation/aep/agent/AgentCoverageTest.java new file mode 100644 index 0000000..e95b325 --- /dev/null +++ b/aep-agent/src/test/java/foundation/aep/agent/AgentCoverageTest.java @@ -0,0 +1,530 @@ +package foundation.aep.agent; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import foundation.aep.core.Aep; +import foundation.aep.core.AepHttpTransport; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import org.junit.jupiter.api.Test; + +/** Configuration, caching, and helper behaviour across the Agent module. */ +class AgentCoverageTest { + private static final URI ORIGIN = URI.create("https://service.example"); + private static final Instant NOW = Instant.parse("2026-09-01T00:00:00Z"); + + private static AepHttpTransport.Response response(Map> headers) { + return new AepHttpTransport.Response(200, headers, new byte[0]); + } + + @Test + void normalizesAndRejectsServiceOrigins() { + assertEquals(ORIGIN, AgentHttp.origin(URI.create("https://SERVICE.example/"), false)); + assertEquals( + URI.create("https://service.example:8443"), + AgentHttp.origin(URI.create("https://service.example:8443"), false)); + assertEquals(URI.create("http://localhost:8080"), AgentHttp.origin(URI.create("http://localhost:8080"), true)); + for (URI value : List.of( + URI.create("http://service.example"), + URI.create("https://user:secret@service.example"), + URI.create("https://service.example/path"), + URI.create("https://service.example?query"), + URI.create("https://service.example#fragment"), + URI.create("/relative"), + URI.create("mailto:agent@example.com"))) { + assertThrows(IllegalArgumentException.class, () -> AgentHttp.origin(value, false), value.toString()); + } + assertThrows(IllegalArgumentException.class, () -> AgentHttp.origin(null, false)); + // The loopback opt-in does not extend to a name that merely resolves to loopback. + assertThrows( + IllegalArgumentException.class, () -> AgentHttp.origin(URI.create("http://loopback.example"), true)); + } + + @Test + void comparesOriginsWithoutThrowingOnAMalformedHost() { + assertTrue(AgentHttp.sameOrigin(ORIGIN, URI.create("https://service.example:443/x"))); + assertTrue(AgentHttp.sameOrigin(URI.create("http://a.example"), URI.create("http://a.example:80/x"))); + assertFalse(AgentHttp.sameOrigin(ORIGIN, URI.create("https://service.example:8443/x"))); + assertFalse(AgentHttp.sameOrigin(ORIGIN, null)); + assertFalse(AgentHttp.sameOrigin(null, ORIGIN)); + assertFalse(AgentHttp.sameOrigin(ORIGIN, URI.create("/relative"))); + assertFalse(AgentHttp.sameOrigin(URI.create("/relative"), ORIGIN)); + // A redirect Location is attacker-controlled; an unparseable label is simply not a match. + assertFalse(AgentHttp.sameOrigin(ORIGIN, URI.create("https://xn--/x"))); + assertFalse(AgentHttp.sameOrigin(URI.create("https://xn--/x"), ORIGIN)); + } + + @Test + void identifiesValidInspectTargets() { + assertTrue(AgentHttp.validInspectTarget(ORIGIN, URI.create("https://service.example/.well-known/aep"))); + assertFalse(AgentHttp.validInspectTarget(ORIGIN, null)); + assertFalse(AgentHttp.validInspectTarget(ORIGIN, URI.create("https://user:pw@service.example/aep"))); + assertFalse(AgentHttp.validInspectTarget(ORIGIN, URI.create("https://service.example/aep#x"))); + assertFalse(AgentHttp.validInspectTarget(ORIGIN, URI.create("https://other.example/aep"))); + } + + @Test + void resolvesFreshnessFromCacheControl() { + assertEquals( + NOW.plusSeconds(60), + AgentHttp.expiresAt( + response(Map.of("Cache-Control", List.of("max-age=60"))), NOW, Duration.ofSeconds(300))); + // Directives are unordered, so the one that forbids reuse wins wherever it appears. + assertEquals( + NOW, + AgentHttp.expiresAt( + response(Map.of("Cache-Control", List.of("max-age=600, no-cache"))), + NOW, + Duration.ofSeconds(300))); + assertEquals( + NOW, + AgentHttp.expiresAt( + response(Map.of("Cache-Control", List.of("max-age=600", "no-store"))), + NOW, + Duration.ofSeconds(300))); + assertEquals( + NOW, + AgentHttp.expiresAt( + response(Map.of("Cache-Control", List.of("no-cache"))), NOW, Duration.ofSeconds(300))); + // A negative or unparseable lifetime falls back rather than reaching backwards. + assertEquals( + NOW, + AgentHttp.expiresAt( + response(Map.of("Cache-Control", List.of("max-age=-5"))), NOW, Duration.ofSeconds(300))); + assertEquals( + NOW.plusSeconds(300), + AgentHttp.expiresAt( + response(Map.of("Cache-Control", List.of("max-age=abc"))), NOW, Duration.ofSeconds(300))); + assertEquals( + NOW.plusSeconds(300), + AgentHttp.expiresAt( + response(Map.of("Cache-Control", List.of("public, must-revalidate"))), + NOW, + Duration.ofSeconds(300))); + assertEquals(NOW.plusSeconds(300), AgentHttp.expiresAt(response(Map.of()), NOW, Duration.ofSeconds(300))); + // An overflowing lifetime saturates instead of throwing. + assertEquals( + Instant.MAX, + AgentHttp.expiresAt( + response(Map.of("Cache-Control", List.of("max-age=" + Long.MAX_VALUE))), NOW, Duration.ZERO)); + + assertTrue(AgentHttp.isNoStore(response(Map.of("Cache-Control", List.of("public, NO-STORE"))))); + assertFalse(AgentHttp.isNoStore(response(Map.of("Cache-Control", List.of("no-cache"))))); + assertFalse(AgentHttp.isNoStore(response(Map.of()))); + } + + @Test + void readsHeadersCaseInsensitively() { + AepHttpTransport.Response value = response(Map.of("etag", List.of("\"v1\""), "X-Other", List.of("a"))); + assertEquals(Optional.of("\"v1\""), AgentHttp.header(value, "ETag")); + assertEquals(Optional.empty(), AgentHttp.header(value, "Absent")); + assertEquals(Map.of("A", List.of("1")), AgentHttp.headers(Map.of("A", "1"))); + } + + @Test + void refusesFieldValuesThatCannotTravelInAHeader() { + assertEquals("key", AgentHttp.requireFieldValue("key", "idempotency key")); + assertEquals("a b", AgentHttp.requireFieldValue("a b", "idempotency key")); + for (String value : List.of(" key", "key ", "\tkey", "key\t", "", " ", "key\r\n", "key\u0000", "key\u007f")) { + AepAgentException error = assertThrows( + AepAgentException.class, () -> AgentHttp.requireFieldValue(value, "idempotency key"), value); + assertEquals("invalid_configuration", error.code()); + } + assertThrows(AepAgentException.class, () -> AgentHttp.requireFieldValue(null, "idempotency key")); + } + + @Test + void reportsBodyLimitsAndMediaTypes() { + AepHttpTransport.Response large = + new AepHttpTransport.Response(200, Map.of(), "0123456789".getBytes(StandardCharsets.UTF_8)); + assertEquals( + "response_too_large", + assertThrows(AepAgentException.class, () -> AgentHttp.requireBodyLimit(large, 4)) + .code()); + AgentHttp.requireBodyLimit(large, 10); + assertEquals("0123456789", AgentHttp.body(large)); + + AepHttpTransport.Response typed = new AepHttpTransport.Response( + 200, Map.of("Content-Type", List.of(Aep.MEDIA_TYPE + "; charset=utf-8")), new byte[0]); + AgentHttp.requireMediaType(typed, Aep.MEDIA_TYPE); + assertEquals( + "invalid_media_type", + assertThrows(AepAgentException.class, () -> AgentHttp.requireMediaType(typed, "application/json")) + .code()); + assertEquals( + "invalid_media_type", + assertThrows( + AepAgentException.class, + () -> AgentHttp.requireMediaType( + new AepHttpTransport.Response(200, Map.of(), new byte[0]), Aep.MEDIA_TYPE)) + .code()); + } + + @Test + void validatesAgentConfiguration() { + AepAgent.Builder builder = AepAgent.builder(); + assertThrows(NullPointerException.class, () -> builder.inspectTransport(null)); + assertThrows(NullPointerException.class, () -> builder.commandTransport(null)); + assertThrows(NullPointerException.class, () -> builder.identityProvider(null)); + assertThrows(NullPointerException.class, () -> builder.credentialStore(null)); + assertThrows(NullPointerException.class, () -> builder.inspectCache(null)); + assertThrows(NullPointerException.class, () -> builder.idempotencyKeyProvider(null)); + assertThrows(NullPointerException.class, () -> builder.credentialHandler(null)); + assertThrows(NullPointerException.class, () -> builder.clock(null)); + assertThrows(NullPointerException.class, () -> builder.jwtIdSupplier(null)); + assertThrows(NullPointerException.class, () -> builder.requestTimeout(null)); + + for (Duration invalid : List.of(Duration.ZERO, Duration.ofSeconds(-1))) { + assertThrows(IllegalArgumentException.class, () -> builder.requestTimeout(invalid)); + assertThrows(IllegalArgumentException.class, () -> builder.assertionLifetime(invalid)); + assertThrows(IllegalArgumentException.class, () -> builder.defaultInspectFreshness(invalid)); + } + // CORE-CA-019 caps an assertion at five minutes, so the Agent cannot be configured past it. + assertThrows( + IllegalArgumentException.class, + () -> builder.assertionLifetime(Duration.ofMinutes(5).plusSeconds(1))); + builder.assertionLifetime(Duration.ofMinutes(5)); + assertThrows(IllegalArgumentException.class, () -> builder.maximumResponseBytes(0)); + assertThrows(IllegalArgumentException.class, () -> builder.maximumResponseBytes(-1)); + assertThrows(IllegalArgumentException.class, () -> builder.maximumRedirects(-1)); + builder.maximumRedirects(0).maximumResponseBytes(1024).allowInsecureLoopback(true); + + // build() names the collaborator it still needs. + assertThrows(NullPointerException.class, AepAgent.builder()::build); + assertThrows( + NullPointerException.class, + () -> AepAgent.builder().inspectTransport(nullTransport()).build()); + assertThrows( + NullPointerException.class, + () -> AepAgent.builder() + .inspectTransport(nullTransport()) + .commandTransport(nullTransport()) + .build()); + } + + @Test + void refusesCredentialHandlersThatCollide() { + AgentCredentialHandler duplicate = handler("api-key", "api-key"); + assertThrows( + IllegalArgumentException.class, + () -> AepAgent.builder() + .inspectTransport(nullTransport()) + .commandTransport(nullTransport()) + .identityProvider((origin, did) -> CompletableFuture.completedFuture(null)) + .credentialHandler(duplicate) + .build()); + for (AgentCredentialHandler blank : List.of(handler("", "grant"), handler("method", ""))) { + assertThrows( + IllegalArgumentException.class, + () -> AepAgent.builder() + .inspectTransport(nullTransport()) + .commandTransport(nullTransport()) + .identityProvider((origin, did) -> CompletableFuture.completedFuture(null)) + .credentialHandler(blank) + .build()); + } + AepAgent agent = AepAgent.builder() + .inspectTransport(nullTransport()) + .commandTransport(nullTransport()) + .identityProvider((origin, did) -> CompletableFuture.completedFuture(null)) + .credentialHandler(handler("custom-session", "custom-grant")) + .build(); + assertEquals(ORIGIN, agent.service(ORIGIN).origin()); + } + + @Test + void servesInMemoryAndNoOpInspectCaches() { + AgentInspectCache none = AgentInspectCache.none(); + AgentInspectCache.Entry entry = + new AgentInspectCache.Entry(ORIGIN, "{}", "\"v1\"", "Mon, 01 Sep 2026 00:00:00 GMT", NOW); + assertEquals(Optional.empty(), join(none.get(ORIGIN))); + assertNull(join(none.put(ORIGIN, entry))); + assertEquals(Optional.empty(), join(none.get(ORIGIN))); + assertNull(join(none.delete(ORIGIN))); + + AgentInspectCache memory = AgentInspectCache.inMemory(); + assertEquals(Optional.empty(), join(memory.get(ORIGIN))); + join(memory.put(ORIGIN, entry)); + assertEquals(Optional.of(entry), join(memory.get(ORIGIN))); + join(memory.delete(ORIGIN)); + assertEquals(Optional.empty(), join(memory.get(ORIGIN))); + + assertThrows(NullPointerException.class, () -> new AgentInspectCache.Entry(null, "{}", null, null, NOW)); + assertThrows(NullPointerException.class, () -> new AgentInspectCache.Entry(ORIGIN, null, null, null, NOW)); + assertThrows(NullPointerException.class, () -> new AgentInspectCache.Entry(ORIGIN, "{}", null, null, null)); + } + + @Test + void servesTheInMemoryCredentialStore() { + AgentCredentialStore store = AgentCredentialStore.inMemory(); + AgentCredential first = new AgentCredential("did:web:a.example", "api-key", "c-1", NOW, "{}"); + AgentCredential second = new AgentCredential("did:web:a.example", "oauth-bearer", "c-2", NOW, "{}"); + AgentCredential other = new AgentCredential("did:web:b.example", "api-key", "c-3", NOW, "{}"); + + assertEquals(Optional.empty(), join(store.find("did:web:a.example", "api-key"))); + join(store.save(first)); + join(store.save(second)); + join(store.save(other)); + assertEquals(Optional.of(first), join(store.find("did:web:a.example", "api-key"))); + assertEquals(Optional.empty(), join(store.find("did:web:a.example", "absent"))); + + join(store.delete("did:web:a.example", "c-1")); + assertEquals(Optional.empty(), join(store.find("did:web:a.example", "api-key"))); + assertEquals(Optional.of(second), join(store.find("did:web:a.example", "oauth-bearer"))); + + join(store.deleteGrantType("did:web:a.example", "oauth-bearer")); + assertEquals(Optional.empty(), join(store.find("did:web:a.example", "oauth-bearer"))); + assertEquals(Optional.of(other), join(store.find("did:web:b.example", "api-key"))); + + join(store.deleteAll("did:web:b.example")); + assertEquals(Optional.empty(), join(store.find("did:web:b.example", "api-key"))); + // Deleting what is not there is not an error. + join(store.delete("did:web:absent.example", "c-9")); + join(store.deleteGrantType("did:web:absent.example", "api-key")); + join(store.deleteAll("did:web:absent.example")); + } + + @Test + void copiesPlatformContextDefensivelyAndRejectsNonJson() { + assertNull(PlatformJson.copyMap(null)); + Map source = new LinkedHashMap<>(); + source.put("text", "value"); + source.put("number", 7); + source.put("flag", true); + source.put("absent", null); + source.put("list", new ArrayList<>(List.of(1, "two"))); + Map nested = new LinkedHashMap<>(); + nested.put("deep", List.of("x")); + source.put("nested", nested); + + Map copy = PlatformJson.copyMap(source); + source.put("text", "mutated"); + nested.put("deep", List.of("y")); + assertEquals("value", copy.get("text")); + assertEquals(List.of("x"), ((Map) copy.get("nested")).get("deep")); + assertThrows(UnsupportedOperationException.class, () -> copy.put("added", 1)); + + Map unsupported = new LinkedHashMap<>(); + unsupported.put("value", new Object()); + assertThrows(IllegalArgumentException.class, () -> PlatformJson.copyMap(unsupported)); + + Map badKey = new LinkedHashMap<>(); + badKey.put(1, "value"); + Map wrapper = new LinkedHashMap<>(); + wrapper.put("nested", badKey); + assertThrows(IllegalArgumentException.class, () -> PlatformJson.copyMap(wrapper)); + } + + @Test + void carriesFailureDetailOnAgentExceptions() { + AepAgentException plain = new AepAgentException("code", "message"); + assertEquals("code", plain.code()); + assertEquals(0, plain.status()); + assertNull(plain.getCause()); + + IllegalStateException cause = new IllegalStateException("cause"); + AepAgentException wrapped = new AepAgentException("code", "message", cause); + assertSame(cause, wrapped.getCause()); + assertEquals(0, wrapped.status()); + + AepAgentException statused = new AepAgentException("code", "message", 503); + assertEquals(503, statused.status()); + assertEquals("message", statused.getMessage()); + } + + @Test + void exposesValueTypesWithoutLeakingSecrets() { + AgentAuthentication authentication = + new AgentAuthentication("aep-jwt", Map.of("Authorization", List.of("AEP secret-assertion"))); + assertEquals("aep-jwt", authentication.method()); + assertEquals(List.of("AEP secret-assertion"), authentication.headers().get("Authorization")); + + AgentCredential credential = + new AgentCredential("did:web:a.example", "api-key", "c-1", NOW, "{\"api_key\":\"secret\"}"); + assertFalse(credential.toString().contains("secret")); + assertEquals("c-1", credential.credentialId()); + + AgentGrantResult result = new AgentGrantResult("api-key", "{\"api_key\":\"secret\"}", Optional.of(credential)); + assertEquals("api-key", result.grantType()); + assertFalse(result.toString().contains("secret")); + + AgentIdentity identity = new AgentIdentity( + "did:web:agent.example", claims -> CompletableFuture.completedFuture("signed-assertion")); + assertEquals("did:web:agent.example", identity.did()); + assertFalse(identity.toString().contains("signed-assertion")); + + foundation.aep.core.PlatformAgentIdentity hosted = new foundation.aep.core.PlatformAgentIdentity( + "did:web:p.example:a:one", + "pai_1", + "2026-09-01T00:00:00Z", + "https://p.example/a/one/did.json", + "did:web:p.example:a:one", + "did:web:service.example", + List.of("ES256"), + foundation.aep.core.ManagedAgentStatus.ACTIVE, + "2026-09-01T00:00:00Z"); + PlatformPendingSign pending = new PlatformPendingSign(hosted, Map.of("handle", "opaque"), 5); + assertEquals(5, pending.retryAfterSeconds()); + assertEquals(hosted, pending.identity()); + assertEquals(Map.of("handle", "opaque"), pending.platformContext()); + assertEquals(pending, new PlatformSignPendingException(pending).pending()); + } + + @Test + void reusesOneInspectionAcrossConcurrentCallers() { + AepHttpTransport inspect = request -> CompletableFuture.completedFuture(new AepHttpTransport.Response( + 200, + Map.of("Content-Type", List.of(Aep.MEDIA_TYPE), "Cache-Control", List.of("no-store")), + document().getBytes(StandardCharsets.UTF_8))); + AepServiceSession session = AepAgent.builder() + .inspectTransport(inspect) + .commandTransport(request -> CompletableFuture.failedFuture(new AssertionError("unused"))) + .identityProvider((origin, did) -> CompletableFuture.completedFuture( + new AgentIdentity("did:web:agent.example", claims -> CompletableFuture.completedFuture("s")))) + .inspectCache(AgentInspectCache.none()) + .clock(Clock.fixed(NOW, ZoneOffset.UTC)) + .build() + .service(ORIGIN); + + CompletableFuture first = session.inspect(); + CompletableFuture second = session.inspect(); + assertEquals(first.join().documentUri(), second.join().documentUri()); + assertEquals( + "did:web:service.example", first.join().document().service().did()); + } + + @Test + void rejectsNonPositivePollingBounds() { + AepServiceSession session = AepAgent.builder() + .inspectTransport(nullTransport()) + .commandTransport(nullTransport()) + .identityProvider((origin, did) -> CompletableFuture.completedFuture(null)) + .build() + .service(ORIGIN); + assertThrows(IllegalArgumentException.class, () -> session.waitForActive(Duration.ZERO, Duration.ofSeconds(1))); + assertThrows( + IllegalArgumentException.class, + () -> session.waitForActive(Duration.ofSeconds(1), Duration.ofSeconds(-1))); + assertThrows(NullPointerException.class, () -> session.waitForActive(null, Duration.ofSeconds(1))); + assertThrows(NullPointerException.class, () -> session.waitForActive(Duration.ofSeconds(1), null)); + assertThrows(NullPointerException.class, () -> session.revoke(null)); + assertThrows(NullPointerException.class, () -> session.grant((foundation.aep.core.GrantRequest) null)); + assertThrows(NullPointerException.class, () -> session.authenticate(null)); + } + + private static T join(CompletionStage stage) { + return stage.toCompletableFuture().join(); + } + + private static AepHttpTransport nullTransport() { + return request -> CompletableFuture.failedFuture(new AssertionError("Transport must not be called")); + } + + private static AgentCredentialHandler handler(String method, String grantType) { + return new AgentCredentialHandler() { + @Override + public String authenticationMethod() { + return method; + } + + @Override + public String grantType() { + return grantType; + } + + @Override + public AgentCredential parse(String serviceDid, String responseJson) { + return new AgentCredential(serviceDid, grantType, "c-1", NOW, responseJson); + } + + @Override + public Map authorizationHeaders(AgentCredential credential, URI resource) { + return Map.of("X-Credential", "value"); + } + }; + } + + private static String document() { + return """ + { + "aep_version":"1.0", + "bindings":{"supported":["http"]}, + "commands":{"supported":["inspect"],"grant_types":[],"grant_types_config":{}}, + "core":{"signing_algorithms":["EdDSA","ES256"]}, + "http":{"endpoint_base":"/aep/"}, + "identity":{"methods":["did:web"]}, + "service":{"did":"did:web:service.example"} + } + """; + } + + @Test + void appliesTheRemainingBuilderDefaultsAndBounds() { + AepAgent agent = AepAgent.builder() + .inspectTransport(nullTransport()) + .commandTransport(nullTransport()) + .identityProvider((origin, did) -> CompletableFuture.completedFuture(null)) + .requestTimeout(Duration.ofSeconds(10)) + .defaultInspectFreshness(Duration.ofSeconds(120)) + .build(); + assertEquals(ORIGIN, agent.service(ORIGIN).origin()); + + // The default idempotency key provider and JWT id supplier produce distinct usable values. + AepAgent defaults = AepAgent.builder() + .inspectTransport(nullTransport()) + .commandTransport(nullTransport()) + .identityProvider((origin, did) -> CompletableFuture.completedFuture(null)) + .build(); + assertEquals(ORIGIN, defaults.service(ORIGIN).origin()); + } + + @Test + void refusesAPendingSignOutsideTheRetryRange() { + foundation.aep.core.PlatformAgentIdentity identity = new foundation.aep.core.PlatformAgentIdentity( + "did:web:p.example:a:one", + "pai_1", + "2026-09-01T00:00:00Z", + "https://p.example/a/one/did.json", + "did:web:p.example:a:one", + "did:web:service.example", + List.of("ES256"), + foundation.aep.core.ManagedAgentStatus.ACTIVE, + "2026-09-01T00:00:00Z"); + for (int retry : List.of(0, -1, 301)) { + assertThrows( + IllegalArgumentException.class, + () -> new PlatformPendingSign(identity, Map.of(), retry), + Integer.toString(retry)); + } + assertEquals(1, new PlatformPendingSign(identity, null, 1).retryAfterSeconds()); + assertEquals(300, new PlatformPendingSign(identity, null, 300).retryAfterSeconds()); + assertThrows(NullPointerException.class, () -> new PlatformSignPendingException(null)); + } + + @Test + void reportsAnUnparseableCredentialExpiry() { + assertThrows( + IllegalArgumentException.class, + () -> AgentCredentialHandlers.oauthBearer() + .parse( + "did:web:service.example", + "{\"access_token\":\"t\",\"credential_id\":\"c-1\"," + + "\"expires_at\":\"2026-13-45T99:00:00Z\",\"token_type\":\"Bearer\"}")); + } +} diff --git a/aep-agent/src/test/java/foundation/aep/agent/AgentFlowsTest.java b/aep-agent/src/test/java/foundation/aep/agent/AgentFlowsTest.java new file mode 100644 index 0000000..c57a6f7 --- /dev/null +++ b/aep-agent/src/test/java/foundation/aep/agent/AgentFlowsTest.java @@ -0,0 +1,841 @@ +package foundation.aep.agent; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import foundation.aep.core.Aep; +import foundation.aep.core.AepHttpTransport; +import foundation.aep.core.AssertionOperation; +import foundation.aep.core.ClaimValues; +import foundation.aep.core.ClientAssertionClaims; +import foundation.aep.core.GrantRequest; +import foundation.aep.core.RevokeRequest; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.CompletionStage; +import org.junit.jupiter.api.Test; + +/** Command flows, credential handling, and Platform delegation failure paths. */ +class AgentFlowsTest { + private static final URI ORIGIN = URI.create("https://service.example"); + private static final URI PLATFORM = URI.create("https://platform.example"); + private static final String SERVICE_DID = "did:web:service.example"; + private static final Instant NOW = Instant.parse("2026-09-01T00:00:00Z"); + + @Test + void grantRequiresAnAdvertisedGrantTypeStatusSupportAndActiveEnrollment() { + QueueTransport inspect = new QueueTransport(aepResponse(200, document(List.of("inspect", "grant", "status")))); + AepServiceSession advertised = + agent(inspect, new QueueTransport()).build().service(ORIGIN); + assertEquals( + "grant_type_not_advertised", + error(advertised.grant("absent-grant", List.of())).code()); + + QueueTransport withoutStatus = new QueueTransport(aepResponse(200, document(List.of("inspect", "grant")))); + assertEquals( + "command_not_advertised", + error(agent(withoutStatus, new QueueTransport()) + .build() + .service(ORIGIN) + .grant("custom-grant", List.of())) + .code()); + + QueueTransport pendingInspect = + new QueueTransport(aepResponse(200, document(List.of("inspect", "grant", "status")))); + QueueTransport pendingCommand = new QueueTransport(aepResponse(200, "{\"status\":\"pending\"}")); + assertEquals( + "enrollment_not_active", + error(agent(pendingInspect, pendingCommand) + .build() + .service(ORIGIN) + .grant("custom-grant", List.of())) + .code()); + } + + @Test + void grantStoresAHandledCredentialAndPassesThroughAnUnhandledOne() { + String credentialJson = "{\"api_key\":\"key-value\",\"credential_id\":\"c-1\"," + + "\"expires_at\":\"2026-09-01T01:00:00Z\",\"header\":\"x-api-key\"}"; + QueueTransport inspect = + new QueueTransport(aepResponse(200, document(List.of("inspect", "grant", "status"), "api-key"))); + QueueTransport command = + new QueueTransport(aepResponse(200, "{\"status\":\"active\"}"), aepResponse(200, credentialJson)); + MemoryStore store = new MemoryStore(); + AgentGrantResult handled = agent(inspect, command) + .credentialStore(store) + .build() + .service(ORIGIN) + .grant("api-key", List.of()) + .join(); + + assertEquals("api-key", handled.grantType()); + assertEquals("c-1", handled.credential().orElseThrow().credentialId()); + assertEquals(Optional.of("c-1"), store.saved.map(AgentCredential::credentialId)); + + // A Grant Type with no registered handler still returns its raw object. + QueueTransport rawInspect = + new QueueTransport(aepResponse(200, document(List.of("inspect", "grant", "status")))); + QueueTransport rawCommand = new QueueTransport( + aepResponse(200, "{\"status\":\"active\"}"), aepResponse(200, "{\"credential_id\":\"c-2\"}")); + AgentGrantResult raw = agent(rawInspect, rawCommand) + .build() + .service(ORIGIN) + .grant("custom-grant", List.of()) + .join(); + assertTrue(raw.credential().isEmpty()); + assertEquals("{\"credential_id\":\"c-2\"}", raw.responseJson()); + } + + @Test + void grantRefusesACredentialHandlerThatChangesTheCredentialScope() { + QueueTransport inspect = new QueueTransport(aepResponse(200, document(List.of("inspect", "grant", "status")))); + QueueTransport command = + new QueueTransport(aepResponse(200, "{\"status\":\"active\"}"), aepResponse(200, "{}")); + AgentCredentialHandler rogue = handler( + "custom-session", + "custom-grant", + (serviceDid, json) -> + new AgentCredential("did:web:attacker.example", "custom-grant", "c-1", NOW, json)); + + assertEquals( + "invalid_credential", + error(agent(inspect, command) + .credentialHandler(rogue) + .build() + .service(ORIGIN) + .grant("custom-grant", List.of())) + .code()); + } + + @Test + void revokeDeletesTheMatchingStoredCredentials() { + for (RevokeRequest request : List.of( + new RevokeRequest("custom-grant", null, null), + new RevokeRequest("custom-grant", "c-1", null), + new RevokeRequest(null, null, "true"))) { + QueueTransport inspect = new QueueTransport(aepResponse(200, document(List.of("inspect", "revoke")))); + QueueTransport command = new QueueTransport(aepResponse(200, "{}")); + MemoryStore store = new MemoryStore(); + store.saved = Optional.of(new AgentCredential(SERVICE_DID, "custom-grant", "c-1", NOW, "{}")); + + agent(inspect, command) + .credentialStore(store) + .build() + .service(ORIGIN) + .revoke(request) + .join(); + + assertTrue(store.saved.isEmpty(), request.toString()); + } + } + + @Test + void authenticateFallsThroughToTheNextUsableMethod() { + String credentialJson = "{\"api_key\":\"key-value\",\"credential_id\":\"c-1\"," + + "\"expires_at\":\"2026-09-01T01:00:00Z\",\"header\":\"x-api-key\"}"; + QueueTransport inspect = new QueueTransport( + aepResponse(200, document(List.of("inspect"), "api-key", List.of("oauth-bearer", "api-key")))); + MemoryStore store = new MemoryStore(); + store.saved = + Optional.of(new AgentCredential(SERVICE_DID, "api-key", "c-1", NOW.plusSeconds(3_600), credentialJson)); + + AgentAuthentication authentication = agent(inspect, new QueueTransport()) + .credentialStore(store) + .build() + .service(ORIGIN) + .authenticate(URI.create("https://service.example/private")) + .join(); + + assertEquals("api-key", authentication.method()); + assertEquals(List.of("key-value"), authentication.headers().get("x-api-key")); + } + + @Test + void authenticateSkipsAnExpiredCredential() { + String credentialJson = "{\"api_key\":\"key-value\",\"credential_id\":\"c-1\"," + + "\"expires_at\":\"2026-08-01T00:00:00Z\",\"header\":\"x-api-key\"}"; + QueueTransport inspect = + new QueueTransport(aepResponse(200, document(List.of("inspect"), "api-key", List.of("api-key")))); + MemoryStore store = new MemoryStore(); + store.saved = Optional.of( + new AgentCredential(SERVICE_DID, "api-key", "c-1", NOW.minusSeconds(3_600), credentialJson)); + + assertEquals( + "authentication_unavailable", + error(agent(inspect, new QueueTransport()) + .credentialStore(store) + .build() + .service(ORIGIN) + .authenticate(URI.create("https://service.example/private"))) + .code()); + } + + @Test + void authenticateRefusesAStoredCredentialFromAnotherService() { + QueueTransport inspect = new QueueTransport( + aepResponse(200, document(List.of("inspect"), "custom-grant", List.of("custom-session")))); + MemoryStore store = new MemoryStore(); + store.saved = Optional.of( + new AgentCredential("did:web:attacker.example", "custom-grant", "c-1", NOW.plusSeconds(60), "{}")); + + assertEquals( + "invalid_credential", + error(agent(inspect, new QueueTransport()) + .credentialStore(store) + .credentialHandler(handler("custom-session", "custom-grant", null)) + .build() + .service(ORIGIN) + .authenticate(URI.create("https://service.example/private"))) + .code()); + } + + @Test + void waitForActiveResolvesRejectedAndTimedOutEnrollments() { + for (String status : List.of("rejected", "suspended", "terminated")) { + QueueTransport inspect = new QueueTransport(aepResponse(200, document(List.of("inspect", "status")))); + QueueTransport command = new QueueTransport(aepResponse(200, "{\"status\":\"" + status + "\"}")); + assertEquals( + "enrollment_not_active", + error(agent(inspect, command) + .build() + .service(ORIGIN) + .waitForActive(Duration.ofMillis(10), Duration.ofSeconds(5))) + .code(), + status); + } + + QueueTransport activeInspect = new QueueTransport(aepResponse(200, document(List.of("inspect", "status")))); + QueueTransport activeCommand = new QueueTransport(aepResponse(200, "{\"status\":\"active\"}")); + assertEquals( + foundation.aep.core.AgentStatus.ACTIVE, + agent(activeInspect, activeCommand) + .build() + .service(ORIGIN) + .waitForActive(Duration.ofMillis(10), Duration.ofSeconds(5)) + .join() + .status()); + + // A pending status with no remaining budget stops rather than polling forever. + QueueTransport pendingInspect = new QueueTransport(aepResponse(200, document(List.of("inspect", "status")))); + QueueTransport pendingCommand = new QueueTransport(aepResponse(200, "{\"status\":\"pending\"}")); + assertEquals( + "poll_timeout", + error(agent(pendingInspect, pendingCommand) + .build() + .service(ORIGIN) + .waitForActive(Duration.ofSeconds(30), Duration.ofSeconds(1))) + .code()); + } + + @Test + void waitForActiveSurfacesACommandFailure() { + QueueTransport inspect = new QueueTransport(aepResponse(200, document(List.of("inspect", "status")))); + QueueTransport command = + new QueueTransport(response(500, Map.of("Content-Type", List.of("text/plain")), "boom")); + assertEquals( + "command_failed", + error(agent(inspect, command) + .build() + .service(ORIGIN) + .waitForActive(Duration.ofMillis(10), Duration.ofSeconds(5))) + .code()); + } + + @Test + void inspectReportsFailureStatusesAndRedirectLimits() { + QueueTransport failing = new QueueTransport(aepResponse(503, "")); + assertEquals( + "inspect_failed", + error(agent(failing, new QueueTransport()) + .build() + .service(ORIGIN) + .inspect()) + .code()); + + QueueTransport looping = + new QueueTransport(redirect("/one"), redirect("/two"), redirect("/three"), redirect("/four")); + assertEquals( + "too_many_redirects", + error(agent(looping, new QueueTransport()) + .maximumRedirects(2) + .build() + .service(ORIGIN) + .inspect()) + .code()); + } + + @Test + void inspectRefusesADocumentThatIsNotValid() { + QueueTransport invalid = new QueueTransport(aepResponse(200, "{\"aep_version\":\"1.0\"}")); + AepAgentException error = error( + agent(invalid, new QueueTransport()).build().service(ORIGIN).inspect()); + assertEquals("document_invalid", error.code()); + assertTrue(error.getMessage().contains("$."), error.getMessage()); + } + + @Test + void inspectAcceptsALoopbackServiceUnderTheOptIn() { + String document = document(List.of("inspect")).replace(SERVICE_DID, "did:web:localhost%3A8080"); + QueueTransport inspect = new QueueTransport(aepResponse(200, document)); + AgentInspection inspection = agent(inspect, new QueueTransport()) + .allowInsecureLoopback(true) + .build() + .service(URI.create("http://localhost:8080")) + .inspect() + .join(); + assertEquals("did:web:localhost%3A8080", inspection.document().service().did()); + + QueueTransport mismatch = + new QueueTransport(aepResponse(200, document(List.of("inspect")).replace(SERVICE_DID, "did:key:z6Mk"))); + assertEquals( + "service_identity_mismatch", + error(agent(mismatch, new QueueTransport()) + .allowInsecureLoopback(true) + .build() + .service(URI.create("http://localhost:8080")) + .inspect()) + .code()); + } + + @Test + void refusesAnIdempotencyKeyProviderThatReturnsNothing() { + QueueTransport inspect = new QueueTransport(aepResponse(200, document(List.of("inspect", "enroll")))); + assertEquals( + "invalid_configuration", + error(agent(inspect, new QueueTransport()) + .idempotencyKeyProvider((did, command, discriminator) -> null) + .build() + .service(ORIGIN) + .enroll(ClaimValues.builder().build())) + .code()); + } + + @Test + void refusesAJwtIdSupplierThatReturnsNothing() { + QueueTransport inspect = new QueueTransport(aepResponse(200, document(List.of("inspect", "status")))); + assertEquals( + "invalid_configuration", + error(agent(inspect, new QueueTransport()) + .jwtIdSupplier(() -> " ") + .build() + .service(ORIGIN) + .status()) + .code()); + } + + @Test + void rejectsInvalidCommandRequestsBeforeAnyNetworkCall() { + AepServiceSession session = + agent(new QueueTransport(), new QueueTransport()).build().service(ORIGIN); + assertThrows( + foundation.aep.core.AepValidationException.class, + () -> session.grant(new GrantRequest("", null, null, null))); + assertThrows( + foundation.aep.core.AepValidationException.class, + () -> session.revoke(new RevokeRequest(null, "c-1", null))); + } + + @Test + void builtInHandlersRejectMismatchedStoredCredentials() { + AgentCredentialHandler apiKey = AgentCredentialHandlers.apiKey(); + assertThrows( + IllegalArgumentException.class, + () -> apiKey.parse( + SERVICE_DID, + "{\"api_key\":\"k\",\"credential_id\":\"c-1\"," + + "\"expires_at\":\"not-a-date\",\"header\":\"x-api-key\"}")); + String json = "{\"api_key\":\"k\",\"credential_id\":\"c-1\",\"expires_at\":\"2026-09-01T01:00:00Z\"," + + "\"header\":\"x-api-key\"}"; + AgentCredential wrongGrantType = new AgentCredential(SERVICE_DID, "oauth-bearer", "c-1", NOW, json); + assertThrows(IllegalArgumentException.class, () -> apiKey.authorizationHeaders(wrongGrantType, null)); + AgentCredential wrongId = + new AgentCredential(SERVICE_DID, "api-key", "c-9", Instant.parse("2026-09-01T01:00:00Z"), json); + assertThrows(IllegalArgumentException.class, () -> apiKey.authorizationHeaders(wrongId, null)); + AgentCredential wrongExpiry = new AgentCredential(SERVICE_DID, "api-key", "c-1", NOW, json); + assertThrows(IllegalArgumentException.class, () -> apiKey.authorizationHeaders(wrongExpiry, null)); + + AgentCredential valid = + new AgentCredential(SERVICE_DID, "api-key", "c-1", Instant.parse("2026-09-01T01:00:00Z"), json); + assertEquals(Map.of("x-api-key", "k"), apiKey.authorizationHeaders(valid, null)); + assertEquals("api-key", apiKey.authenticationMethod()); + assertEquals(3, AgentCredentialHandlers.builtIn().size()); + + String bearerJson = "{\"access_token\":\"t\",\"credential_id\":\"c-1\"," + + "\"expires_at\":\"2026-09-01T01:00:00Z\",\"token_type\":\"Bearer\"}"; + AgentCredential bearer = new AgentCredential( + SERVICE_DID, "oauth-bearer", "c-1", Instant.parse("2026-09-01T01:00:00Z"), bearerJson); + assertEquals( + Map.of("Authorization", "Bearer t"), + AgentCredentialHandlers.oauthBearer().authorizationHeaders(bearer, null)); + + String basicJson = "{\"credential_id\":\"c-1\",\"expires_at\":\"2026-09-01T01:00:00Z\"," + + "\"password\":\"pw\",\"username\":\"user\"}"; + AgentCredential basic = + new AgentCredential(SERVICE_DID, "basic", "c-1", Instant.parse("2026-09-01T01:00:00Z"), basicJson); + assertEquals( + Map.of("Authorization", "Basic dXNlcjpwdw=="), + AgentCredentialHandlers.basic().authorizationHeaders(basic, null)); + } + + @Test + void platformProviderRefusesSigningForAnotherIdentity() { + QueueTransport transport = new QueueTransport(aepResponse(200, discovery()), aepResponse(200, activeList())); + PlatformIdentityProvider provider = PlatformIdentityProvider.builder(PLATFORM) + .transport(transport) + .clock(Clock.fixed(NOW, ZoneOffset.UTC)) + .build(); + AgentIdentity identity = provider.getOrCreate( + URI.create("https://api.service.example"), "did:web:api.service.example") + .toCompletableFuture() + .join(); + + ClientAssertionClaims foreign = new ClientAssertionClaims( + "did:web:other.example", + "did:web:other.example", + "did:web:api.service.example", + AssertionOperation.ENROLL, + 1_783_425_600L, + 1_783_425_900L, + "jti-1", + null); + assertEquals( + "platform_identity_mismatch", + error(identity.signer().sign(foreign).toCompletableFuture()).code()); + } + + @Test + void platformProviderRefusesAnUnboundServiceDidAndANonDid() { + PlatformIdentityProvider provider = PlatformIdentityProvider.builder(PLATFORM) + .transport(request -> CompletableFuture.failedFuture(new AssertionError("unused"))) + .build(); + assertEquals( + "service_identity_mismatch", + error(provider.getOrCreate(URI.create("https://api.service.example"), "did:web:other.example") + .toCompletableFuture()) + .code()); + assertThrows( + IllegalArgumentException.class, + () -> provider.getOrCreate(URI.create("https://api.service.example"), "not-a-did")); + assertThrows(IllegalArgumentException.class, () -> provider.findIdentityByServiceDid("did:")); + } + + @Test + void platformProviderValidatesItsConfiguration() { + assertThrows(NullPointerException.class, () -> PlatformIdentityProvider.builder(null)); + PlatformIdentityProvider.Builder builder = PlatformIdentityProvider.builder(PLATFORM); + assertThrows(NullPointerException.class, () -> builder.transport(null)); + assertThrows(NullPointerException.class, () -> builder.authenticationHeaders(null)); + assertThrows(NullPointerException.class, () -> builder.idempotencyKeys(null)); + assertThrows(NullPointerException.class, () -> builder.platformContext(null)); + assertThrows(NullPointerException.class, () -> builder.pendingSignResolver(null)); + assertThrows(NullPointerException.class, () -> builder.clock(null)); + assertThrows(NullPointerException.class, PlatformIdentityProvider.builder(PLATFORM)::build); + for (String value : List.of("", " ", "Bearer a\rb", "Bearer a\nb")) { + assertThrows(IllegalArgumentException.class, () -> builder.authorization(value), value); + } + for (Duration invalid : List.of(Duration.ZERO, Duration.ofSeconds(-1))) { + assertThrows(IllegalArgumentException.class, () -> builder.defaultDiscoveryFreshness(invalid)); + assertThrows(IllegalArgumentException.class, () -> builder.requestTimeout(invalid)); + } + assertThrows(NullPointerException.class, () -> builder.requestTimeout(null)); + assertThrows(IllegalArgumentException.class, () -> builder.maximumRedirects(-1)); + assertThrows(IllegalArgumentException.class, () -> builder.maximumResponseBytes(0)); + builder.maximumRedirects(1) + .maximumResponseBytes(2048) + .defaultDiscoveryFreshness(Duration.ofSeconds(30)) + .requestTimeout(Duration.ofSeconds(5)) + .allowInsecureLoopback(true) + .authorization("Bearer token") + .transport(request -> CompletableFuture.failedFuture(new AssertionError("unused"))) + .build(); + + for (URI origin : List.of( + URI.create("http://platform.example"), + URI.create("https://user:pw@platform.example"), + URI.create("https://platform.example/path"), + URI.create("/relative"))) { + assertThrows( + IllegalArgumentException.class, + () -> PlatformIdentityProvider.builder(origin) + .transport(request -> CompletableFuture.failedFuture(new AssertionError("unused"))) + .build(), + origin.toString()); + } + } + + @Test + void platformProviderRefusesUnusableSuppliedHeaders() { + for (Map> headers : List.of( + Map.of("X-Bad\r\nName", List.of("value")), + Map.of(" ", List.of("value")), + Map.>of("X-Tenant", List.of()), + Map.of("X-Tenant", List.of("bad\rvalue")))) { + QueueTransport transport = new QueueTransport(aepResponse(200, discovery())); + PlatformIdentityProvider provider = PlatformIdentityProvider.builder(PLATFORM) + .transport(transport) + .authenticationHeaders(() -> CompletableFuture.completedFuture(headers)) + .build(); + assertThrows( + CompletionException.class, + () -> provider.findIdentityByServiceDid("did:web:api.service.example") + .toCompletableFuture() + .join(), + headers.toString()); + } + } + + @Test + void platformProviderRefusesIdempotencyKeysThatCannotTravel() { + for (String key : List.of(" key", "key ", "key\r\nX: 1", "")) { + QueueTransport transport = new QueueTransport(aepResponse(200, discovery()), aepResponse(200, emptyList())); + PlatformIdentityProvider provider = PlatformIdentityProvider.builder(PLATFORM) + .transport(transport) + .idempotencyKeys(() -> key) + .build(); + assertEquals( + "platform_idempotency_key_invalid", + error(provider.getOrCreate(URI.create("https://api.service.example"), "did:web:api.service.example") + .toCompletableFuture()) + .code(), + key); + } + } + + @Test + void platformProviderRefusesAnInvalidHostedIdentity() { + String fragmentedKeyId = identity() + .replace( + "\"key_id\":\"did:web:p.example:a:4Yf7p2xQd9\"", + "\"key_id\":\"did:web:p.example:a:4Yf7p2xQd9#key-1\""); + String wrongDocumentUrl = identity() + .replace("https://p.example/a/4Yf7p2xQd9/did.json", "https://elsewhere.example/a/4Yf7p2xQd9/did.json"); + String unadvertisedAlgorithm = identity().replace("[\"ES256\"]", "[\"EdDSA\"]"); + for (String body : List.of(fragmentedKeyId, wrongDocumentUrl, unadvertisedAlgorithm)) { + QueueTransport transport = new QueueTransport( + aepResponse(200, discovery()), + aepResponse(200, "{\"count\":\"1\",\"data\":[" + body + "],\"total\":\"1\"}")); + PlatformIdentityProvider provider = PlatformIdentityProvider.builder(PLATFORM) + .transport(transport) + .build(); + assertEquals( + "platform_identity_invalid", + error(provider.findIdentityByServiceDid("did:web:api.service.example") + .toCompletableFuture()) + .code(), + body); + } + + // A list whose totals disagree is not usable either. + QueueTransport totals = new QueueTransport( + aepResponse(200, discovery()), + aepResponse(200, "{\"count\":\"1\",\"data\":[" + identity() + "],\"total\":\"0\"}")); + assertThrows( + CompletionException.class, + () -> PlatformIdentityProvider.builder(PLATFORM) + .transport(totals) + .build() + .findIdentityByServiceDid("did:web:api.service.example") + .toCompletableFuture() + .join()); + } + + @Test + void platformProviderRefusesUnsafeDiscoveryRedirectsAndEndpoints() { + for (String location : List.of( + "https://attacker.example/.well-known/aep-platform", + "http://platform.example/.well-known/aep-platform", + "https://user:pw@platform.example/x", + "https://platform.example/x#fragment")) { + QueueTransport transport = new QueueTransport(redirect(location)); + assertEquals( + "platform_discovery_redirect", + error(PlatformIdentityProvider.builder(PLATFORM) + .transport(transport) + .build() + .findIdentityByServiceDid("did:web:api.service.example") + .toCompletableFuture()) + .code(), + location); + } + QueueTransport noLocation = new QueueTransport(response(302, Map.of(), "")); + assertEquals( + "platform_discovery_redirect", + error(PlatformIdentityProvider.builder(PLATFORM) + .transport(noLocation) + .build() + .findIdentityByServiceDid("did:web:api.service.example") + .toCompletableFuture()) + .code()); + + QueueTransport limit = new QueueTransport(redirect("/one"), redirect("/two")); + assertEquals( + "platform_discovery_redirect", + error(PlatformIdentityProvider.builder(PLATFORM) + .transport(limit) + .maximumRedirects(1) + .build() + .findIdentityByServiceDid("did:web:api.service.example") + .toCompletableFuture()) + .code()); + + // An advertised endpoint that is not an origin-relative path is refused. The shared document + // validator rejects these first, so the provider's own endpoint check is the second line. + for (String list : List.of("\"relative\"", "\"//attacker.example/x\"", "\"/x?y=1\"", "\"/x#y\"")) { + QueueTransport transport = new QueueTransport( + aepResponse(200, discovery().replace("\"/v1/aep/agent-identities\",", list + ","))); + CompletionException failure = assertThrows( + CompletionException.class, + () -> PlatformIdentityProvider.builder(PLATFORM) + .transport(transport) + .build() + .findIdentityByServiceDid("did:web:api.service.example") + .toCompletableFuture() + .join(), + list); + assertTrue( + failure.getCause() instanceof foundation.aep.core.AepValidationException + || failure.getCause() instanceof AepAgentException, + list + " produced " + failure.getCause()); + } + } + + @Test + void platformProviderReportsDiscoveryAndCommandFailures() { + QueueTransport failing = new QueueTransport(aepResponse(503, "")); + assertEquals( + "platform_discovery_failed", + error(PlatformIdentityProvider.builder(PLATFORM) + .transport(failing) + .build() + .findIdentityByServiceDid("did:web:api.service.example") + .toCompletableFuture()) + .code()); + + QueueTransport notModified = new QueueTransport(response(304, Map.of(), "")); + assertEquals( + "platform_discovery_invalid", + error(PlatformIdentityProvider.builder(PLATFORM) + .transport(notModified) + .build() + .findIdentityByServiceDid("did:web:api.service.example") + .toCompletableFuture()) + .code()); + + QueueTransport redirected = new QueueTransport(aepResponse(200, discovery()), redirect("/elsewhere")); + assertEquals( + "platform_command_redirect", + error(PlatformIdentityProvider.builder(PLATFORM) + .transport(redirected) + .build() + .findIdentityByServiceDid("did:web:api.service.example") + .toCompletableFuture()) + .code()); + + QueueTransport problem = new QueueTransport( + aepResponse(200, discovery()), + response( + 403, + Map.of("Content-Type", List.of(Aep.PROBLEM_MEDIA_TYPE)), + "{\"type\":\"urn:aep:error:not_recognized\",\"title\":\"Not recognized\"," + + "\"status\":403,\"code\":\"not_recognized\"}")); + assertEquals( + "not_recognized", + error(PlatformIdentityProvider.builder(PLATFORM) + .transport(problem) + .build() + .findIdentityByServiceDid("did:web:api.service.example") + .toCompletableFuture()) + .code()); + } + + private static AepAgent.Builder agent(AepHttpTransport inspect, AepHttpTransport command) { + AgentIdentity identity = + new AgentIdentity("did:web:agent.example", claims -> CompletableFuture.completedFuture("signed")); + return AepAgent.builder() + .inspectTransport(inspect) + .commandTransport(command) + .identityProvider((origin, serviceDid) -> CompletableFuture.completedFuture(identity)) + .inspectCache(AgentInspectCache.none()) + .clock(Clock.fixed(NOW, ZoneOffset.UTC)) + .jwtIdSupplier(() -> "jwt-id"); + } + + private static String document(List commands) { + return document(commands, "custom-grant", List.of()); + } + + private static String document(List commands, String grantType) { + return document(commands, grantType, List.of()); + } + + private static String document(List commands, String grantType, List methods) { + boolean grants = commands.contains("grant") || commands.contains("revoke"); + String grantTypes = grants ? "[\"" + grantType + "\"]" : "[]"; + String config = grants ? "{\"" + grantType + "\":{\"supports_per_credential_revoke\":\"true\"}}" : "{}"; + String authentication = methods.isEmpty() + ? "" + : "\"authentication\":{\"methods\":[" + + methods.stream() + .map(value -> "\"" + value + "\"") + .collect(java.util.stream.Collectors.joining(",")) + + "]},"; + return """ + { + "aep_version":"1.0", + %s + "bindings":{"supported":["http"]}, + "commands":{"supported":%s,"grant_types":%s,"grant_types_config":%s}, + "core":{"signing_algorithms":["EdDSA","ES256"]}, + "http":{"endpoint_base":"/aep/"}, + "identity":{"methods":["did:web"]}, + "service":{"did":"did:web:service.example"} + } + """.formatted( + authentication, + commands.stream() + .map(value -> "\"" + value + "\"") + .collect(java.util.stream.Collectors.joining(",", "[", "]")), + grantTypes, + config); + } + + private static String discovery() { + return "{\"aep_version\":\"1.0\",\"endpoints\":{\"lifecycle\":\"/v1/aep/agent-identities/{agent_identity_id}\"," + + "\"list\":\"/v1/aep/agent-identities\",\"provision\":\"/v1/aep/agent-identities\"," + + "\"sign\":\"/v1/aep/agent-identities/{agent_identity_id}/sign\"}," + + "\"http\":{\"endpoint_base\":\"/v1/aep\"}," + + "\"identity\":{\"did_methods\":[\"did:web\"]," + + "\"did_url_template\":\"https://p.example/a/{agent_did_id}/did.json\"}," + + "\"platform\":{\"did\":\"did:web:p.example\",\"hosted_verification\":false,\"name\":\"Example\"}," + + "\"signing\":{\"algorithms\":[\"ES256\"],\"default_lifetime_seconds\":\"300\"}}"; + } + + private static String identity() { + return "{\"agent_did\":\"did:web:p.example:a:4Yf7p2xQd9\"," + + "\"agent_identity_id\":\"pai_1\",\"created_at\":\"2026-07-06T12:00:00Z\"," + + "\"did_document_url\":\"https://p.example/a/4Yf7p2xQd9/did.json\"," + + "\"key_id\":\"did:web:p.example:a:4Yf7p2xQd9\"," + + "\"service_did\":\"did:web:api.service.example\",\"signing_algorithms\":[\"ES256\"]," + + "\"status\":\"active\",\"updated_at\":\"2026-07-06T12:00:00Z\"}"; + } + + private static String activeList() { + return "{\"count\":\"1\",\"data\":[" + identity() + "],\"total\":\"1\"}"; + } + + private static String emptyList() { + return "{\"count\":\"0\",\"data\":[],\"total\":\"0\"}"; + } + + private static AepHttpTransport.Response redirect(String location) { + return response(302, Map.of("Location", List.of(location)), ""); + } + + private static AepHttpTransport.Response aepResponse(int status, String body) { + return response(status, Map.of("Content-Type", List.of(Aep.MEDIA_TYPE)), body); + } + + private static AepHttpTransport.Response response(int status, Map> headers, String body) { + return new AepHttpTransport.Response(status, headers, body.getBytes(StandardCharsets.UTF_8)); + } + + private static AepAgentException error(CompletableFuture future) { + CompletionException failure = assertThrows(CompletionException.class, future::join); + return (AepAgentException) failure.getCause(); + } + + private interface Parser { + AgentCredential parse(String serviceDid, String json); + } + + private static AgentCredentialHandler handler(String method, String grantType, Parser parser) { + return new AgentCredentialHandler() { + @Override + public String authenticationMethod() { + return method; + } + + @Override + public String grantType() { + return grantType; + } + + @Override + public AgentCredential parse(String serviceDid, String responseJson) { + return parser == null + ? new AgentCredential(serviceDid, grantType, "c-1", NOW.plusSeconds(60), responseJson) + : parser.parse(serviceDid, responseJson); + } + + @Override + public Map authorizationHeaders(AgentCredential credential, URI resource) { + return Map.of("X-Credential", "value"); + } + }; + } + + private static final class QueueTransport implements AepHttpTransport { + private final Deque responses = new ArrayDeque<>(); + private final List requests = new ArrayList<>(); + + QueueTransport(Response... values) { + responses.addAll(List.of(values)); + } + + @Override + public CompletableFuture execute(Request request) { + requests.add(request); + if (responses.isEmpty()) { + return CompletableFuture.failedFuture(new AssertionError("Unexpected request: " + request)); + } + return CompletableFuture.completedFuture(responses.removeFirst()); + } + } + + private static final class MemoryStore implements AgentCredentialStore { + private Optional saved = Optional.empty(); + + @Override + public CompletionStage> find(String serviceDid, String grantType) { + return CompletableFuture.completedFuture( + saved.filter(value -> value.grantType().equals(grantType))); + } + + @Override + public CompletionStage save(AgentCredential credential) { + saved = Optional.of(credential); + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletionStage delete(String serviceDid, String credentialId) { + saved = Optional.empty(); + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletionStage deleteGrantType(String serviceDid, String grantType) { + saved = Optional.empty(); + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletionStage deleteAll(String serviceDid) { + saved = Optional.empty(); + return CompletableFuture.completedFuture(null); + } + } +} diff --git a/aep-core/src/main/java/foundation/aep/core/AepHttp.java b/aep-core/src/main/java/foundation/aep/core/AepHttp.java index 4abd419..95c08d8 100644 --- a/aep-core/src/main/java/foundation/aep/core/AepHttp.java +++ b/aep-core/src/main/java/foundation/aep/core/AepHttp.java @@ -7,6 +7,24 @@ public final class AepHttp { private AepHttp() {} + /** + * Reports whether a credential is the visible US-ASCII the AEP, Bearer, and Basic grammars + * share. Anything else, notably a control character or an embedded CRLF, would travel back + * into a request header through {@link #renderAuthorization}. + */ + private static boolean isCredentialToken(String value) { + if (value.isEmpty()) { + return false; + } + for (int index = 0; index < value.length(); index++) { + char character = value.charAt(index); + if (character < 0x21 || character > 0x7e) { + return false; + } + } + return true; + } + public static String normalizeEndpointBase(String endpointBase) { String value = endpointBase == null ? Aep.DEFAULT_ENDPOINT_BASE : endpointBase; if (!value.startsWith("/") || value.startsWith("//")) { @@ -44,6 +62,10 @@ public static Map renderAuthorization(ProtectedResourceAuthoriza if (value.credentials() == null || value.credentials().isEmpty()) { throw new AepAuthorizationException("invalid_request", "Authorization credentials must not be empty."); } + if (!isCredentialToken(value.credentials())) { + throw new AepAuthorizationException( + "invalid_request", "Authorization credentials must be visible US-ASCII."); + } return Map.of(authorizationHeaderName(value.carrier()), value.scheme().value() + " " + value.credentials()); } @@ -57,8 +79,8 @@ public static ProtectedResourceAuthorization parseAuthorization(String value, Au if (carrier == AuthorizationCarrier.DEDICATED && value.indexOf(',') >= 0) { throw new AepAuthorizationException("not_recognized", "The dedicated authorization field is ambiguous."); } - int separator = value.indexOf(' '); - if (separator <= 0 || separator == value.length() - 1) { + int separator = separatorIndex(value); + if (separator <= 0) { throw unrecognizedAuthorization(); } String scheme = value.substring(0, separator).toLowerCase(Locale.ROOT); @@ -69,13 +91,38 @@ public static ProtectedResourceAuthorization parseAuthorization(String value, Au case "basic" -> AuthorizationScheme.BASIC; default -> throw unrecognizedAuthorization(); }; - String credentials = value.substring(separator + 1); - if (credentials.isBlank() || Character.isWhitespace(credentials.charAt(0))) { + // CORE-AUTH-001 spells the separator as 1*SP, so repeated spaces belong to the grammar + // rather than marking a malformed presentation, and a field value may carry trailing + // optional whitespace that the sender is entitled to add. + String credentials = trimOptionalWhitespace(value.substring(separator)); + if (!isCredentialToken(credentials)) { throw unrecognizedAuthorization(); } return new ProtectedResourceAuthorization(carrier, parsed, credentials); } + private static int separatorIndex(String value) { + for (int index = 0; index < value.length(); index++) { + char character = value.charAt(index); + if (character == ' ' || character == '\t') { + return index; + } + } + return -1; + } + + private static String trimOptionalWhitespace(String value) { + int start = 0; + int end = value.length(); + while (start < end && (value.charAt(start) == ' ' || value.charAt(start) == '\t')) { + start++; + } + while (end > start && (value.charAt(end - 1) == ' ' || value.charAt(end - 1) == '\t')) { + end--; + } + return value.substring(start, end); + } + private static AepAuthorizationException unrecognizedAuthorization() { return new AepAuthorizationException("not_recognized", "The authorization presentation was not recognized."); } diff --git a/aep-core/src/main/java/foundation/aep/core/AepOpenApi.java b/aep-core/src/main/java/foundation/aep/core/AepOpenApi.java index 899ff33..a5183fc 100644 --- a/aep-core/src/main/java/foundation/aep/core/AepOpenApi.java +++ b/aep-core/src/main/java/foundation/aep/core/AepOpenApi.java @@ -2,12 +2,18 @@ import java.net.URI; import java.util.ArrayList; +import java.util.Collection; import java.util.Comparator; +import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; +import java.util.Map; import java.util.Objects; +import java.util.Set; public final class AepOpenApi { + private static final int SINGLE = 1; + private AepOpenApi() {} public static URI resolveDocumentUri(URI finalInspectUri, String reference) { @@ -54,6 +60,61 @@ public static PathMatch matchPath( return new PathMatch(method.toUpperCase(Locale.ROOT), selected.template()); } + /** + * Resolves the effective security for one operation (CORE-OAS-011/013/014/015). + * + *

Operation-level security replaces the root value rather than adding to it, an empty list + * makes the operation public, an empty requirement object is an anonymous alternative, several + * requirement objects are alternatives, and several schemes inside one object are compound. An + * Agent that cannot satisfy every member of a compound requirement treats that requirement as + * unsupported, and never reduces it to the members it happens to support: the returned + * alternatives are always whole requirement objects. + * + * @param rootSecurity the document-level {@code security} value, or {@code null} when absent + * @param operationSecurity the operation's own {@code security} value, or {@code null} when the + * operation omits the member and therefore inherits the root value + * @param supportedSchemes the security-scheme names this Agent is able to satisfy + */ + public static SecurityResolution resolveSecurity( + List>> rootSecurity, + List>> operationSecurity, + Collection supportedSchemes) { + Set supported = supportedSchemes == null ? Set.of() : new LinkedHashSet<>(supportedSchemes); + List>> effective = effectiveSecurity(rootSecurity, operationSecurity); + if (effective.isEmpty()) { + return new SecurityResolution(SecurityDisposition.PUBLIC, List.of()); + } + List> requirements = new ArrayList<>(effective.size()); + for (Map> requirement : effective) { + if (requirement == null) { + throw new IllegalArgumentException("An OpenAPI security requirement must not be null."); + } + requirements.add(List.copyOf(requirement.keySet())); + } + if (requirements.stream().anyMatch(List::isEmpty)) { + return new SecurityResolution(SecurityDisposition.OPTIONAL_ANONYMOUS, List.of()); + } + List> satisfiable = + requirements.stream().filter(supported::containsAll).toList(); + if (satisfiable.isEmpty()) { + return new SecurityResolution(SecurityDisposition.FALLBACK_LIVE_CHALLENGE, List.of()); + } + if (requirements.size() > SINGLE) { + return new SecurityResolution(SecurityDisposition.ALTERNATIVES, satisfiable); + } + SecurityDisposition disposition = + satisfiable.get(0).size() > SINGLE ? SecurityDisposition.ALL_REQUIRED : SecurityDisposition.REQUIRED; + return new SecurityResolution(disposition, satisfiable); + } + + private static List>> effectiveSecurity( + List>> rootSecurity, List>> operationSecurity) { + if (operationSecurity != null) { + return operationSecurity; + } + return rootSecurity == null ? List.of() : rootSecurity; + } + private static Candidate match(String template, String path, TrailingSlashMode mode) { if (template == null || !template.startsWith("/") || template.indexOf('?') >= 0 || template.indexOf('#') >= 0) { return null; @@ -120,6 +181,43 @@ private static void requireSafeUri(URI value, boolean allowInsecureLoopback, Str } } + /** How an Agent must treat the security an operation resolves to. */ + public enum SecurityDisposition { + PUBLIC("public"), + OPTIONAL_ANONYMOUS("optional-anonymous"), + REQUIRED("required"), + ALTERNATIVES("alternatives"), + ALL_REQUIRED("all-required"), + FALLBACK_LIVE_CHALLENGE("fallback-live-challenge"); + + private final String wireValue; + + SecurityDisposition(String wireValue) { + this.wireValue = wireValue; + } + + public String value() { + return wireValue; + } + } + + /** + * The resolved security for an operation. + * + * @param satisfiable the complete requirement objects the Agent can satisfy, each listed as the + * scheme names it must present together; empty unless the disposition selects credentials + */ + public record SecurityResolution(SecurityDisposition disposition, List> satisfiable) { + public SecurityResolution { + Objects.requireNonNull(disposition, "disposition"); + List> copied = new ArrayList<>(); + if (satisfiable != null) { + satisfiable.forEach(requirement -> copied.add(Copies.list(requirement))); + } + satisfiable = List.copyOf(copied); + } + } + public enum TrailingSlashMode { STRICT, EQUIVALENT diff --git a/aep-core/src/main/java/foundation/aep/core/AepValidation.java b/aep-core/src/main/java/foundation/aep/core/AepValidation.java index d1c98cb..c1b17bc 100644 --- a/aep-core/src/main/java/foundation/aep/core/AepValidation.java +++ b/aep-core/src/main/java/foundation/aep/core/AepValidation.java @@ -27,6 +27,7 @@ public final class AepValidation { private static final Set AUTHENTICATED_COMMANDS = Set.of("enroll", "grant", "revoke", "status"); private static final String COMMANDS_SUPPORTED_PATH = "$.commands.supported"; private static final String AGENT_DID_PATH = "$.agent_did"; + private static final String OPENAPI_URL_PATH = "$.http.openapi.url"; private static final String OPERATION_PATH = "$.op"; private static final String OPERATION_REQUIRED = "Expected a registered assertion operation."; private static final String RESOURCE_PATH = "$.resource"; @@ -669,14 +670,18 @@ private static void validateHttp(InspectDocument.Http value, Issues issues) { issues.required("$.http", JSON_OBJECT); return; } - if (value.endpointBase() != null && !value.endpointBase().startsWith("/")) { - issues.add("$.http.endpoint_base", "Expected a path beginning with '/'."); + // An endpoint_base is origin-relative, so a protocol-relative "//host" value would move + // every command URL to another origin once it is resolved against the Service origin. + if (value.endpointBase() != null && !isEndpointPath(value.endpointBase())) { + issues.add("$.http.endpoint_base", "Expected an absolute path without a query or fragment."); } if (value.openapi() != null) { - requiredNonEmpty(value.openapi().url(), "$.http.openapi.url", issues); + requiredNonEmpty(value.openapi().url(), OPENAPI_URL_PATH, issues); if (nonEmpty(value.openapi().url()) && !isUriReference(value.openapi().url())) { - issues.add("$.http.openapi.url", "Expected a URI reference."); + issues.add(OPENAPI_URL_PATH, "Expected a URI reference."); + } else if (nonEmpty(value.openapi().url())) { + openApiUrlTransport(value.openapi().url(), issues); } String slash = value.openapi().pathMatching() == null ? null @@ -687,6 +692,32 @@ private static void validateHttp(InspectDocument.Http value, Issues issues) { } } + /** + * Applies CORE-OAS-003 and CORE-OAS-004 to an absolute OpenAPI URL. A relative reference + * resolves against the final Inspect response URL and inherits its transport, so only an + * absolute reference carries a transport of its own to check here. + */ + private static void openApiUrlTransport(String value, Issues issues) { + URI uri; + try { + uri = URI.create(value); + } catch (IllegalArgumentException exception) { + return; + } + if (!uri.isAbsolute()) { + return; + } + if (uri.getRawUserInfo() != null) { + issues.add(OPENAPI_URL_PATH, "Expected no user information."); + } + if (HTTPS.equalsIgnoreCase(uri.getScheme())) { + return; + } + if (!"http".equalsIgnoreCase(uri.getScheme()) || uri.getHost() == null || !isLoopbackHost(uri.getHost())) { + issues.add(OPENAPI_URL_PATH, "Expected HTTPS, or HTTP for a loopback literal."); + } + } + private static void validateIdentity( InspectDocument.Identity value, InspectDocument.Commands commands, Issues issues) { if (value == null) { @@ -920,6 +951,18 @@ private static boolean nonEmpty(String value) { return value != null && !value.isEmpty(); } + /** + * Reports whether a host is one of the three loopback literals the development opt-in permits. + * {@link URI#getHost()} keeps the brackets around an IPv6 literal, so the bracketed spelling is + * the one a parsed URI actually presents. + */ + private static boolean isLoopbackHost(String host) { + String normalized = host.toLowerCase(java.util.Locale.ROOT); + return "localhost".equals(normalized) + || "127.0.0.1".equals(normalized) // NOPMD - Explicit development opt-in permits loopback HTTP. + || "[::1]".equals(normalized); + } + private static boolean isApiKeyValue(String value) { for (int index = 0; index < value.length(); index++) { char character = value.charAt(index); @@ -1049,7 +1092,8 @@ private static boolean isAddressLiteral(String value) { } String tag = content.substring(0, separator); String literal = content.substring(separator + 1); - return tag.matches("[A-Za-z0-9-]*[A-Za-z0-9]") + // RFC 5321 spells the standardized tag as an Ldh-str, which begins with a letter or digit. + return tag.matches("[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?") && literal.chars() .allMatch(character -> (character >= 33 && character <= 90) || (character >= 94 && character <= 126)); @@ -1114,19 +1158,19 @@ private static boolean isProtectedResourceUri(String value, boolean allowInsecur } try { URI uri = URI.create(value); - if (!uri.isAbsolute() || uri.getHost() == null || uri.getFragment() != null) { + // An authenticate assertion names one target, so user information has no place in it: + // credentials in the resource claim would travel wherever the claim does, and the + // request target a Service compares against never carries them. + if (!uri.isAbsolute() + || uri.getHost() == null + || uri.getRawUserInfo() != null + || uri.getFragment() != null) { return false; } if (HTTPS.equalsIgnoreCase(uri.getScheme())) { return true; } - return allowInsecureLoopback - && "http".equalsIgnoreCase(uri.getScheme()) - && Set.of( - "localhost", - "127.0.0.1", // NOPMD - Explicit development opt-in permits loopback HTTP. - "::1") // NOPMD - Explicit development opt-in permits loopback HTTP. - .contains(uri.getHost().toLowerCase(java.util.Locale.ROOT)); + return allowInsecureLoopback && "http".equalsIgnoreCase(uri.getScheme()) && isLoopbackHost(uri.getHost()); } catch (IllegalArgumentException exception) { return false; } diff --git a/aep-core/src/main/java/foundation/aep/core/DidWeb.java b/aep-core/src/main/java/foundation/aep/core/DidWeb.java index 4368fb4..e9457ae 100644 --- a/aep-core/src/main/java/foundation/aep/core/DidWeb.java +++ b/aep-core/src/main/java/foundation/aep/core/DidWeb.java @@ -25,9 +25,14 @@ public static URI documentUri(String did, boolean allowInsecureLoopback) { String authority = decode(components[0]); URI authorityUri = URI.create("https://" + authority); String host = authorityUri.getHost(); + // The decoded authority is concatenated back into the document URI, so it has to be an + // authority and nothing else. Without the round-trip check a value such as + // "example.com%3Fx=1" parsed as a host plus a query, passed every individual check, and + // produced a document URI whose query swallowed the whole did.json path. if (host == null || authorityUri.getUserInfo() != null - || !authorityUri.getPath().isEmpty()) { + || !authorityUri.getPath().isEmpty() + || !authority.equals(authorityUri.getRawAuthority())) { throw new IllegalArgumentException("Invalid did:web authority: " + authority); } List path = new ArrayList<>(); diff --git a/aep-core/src/test/java/foundation/aep/core/AepCoreConformanceTest.java b/aep-core/src/test/java/foundation/aep/core/AepCoreConformanceTest.java new file mode 100644 index 0000000..3492f2b --- /dev/null +++ b/aep-core/src/test/java/foundation/aep/core/AepCoreConformanceTest.java @@ -0,0 +1,522 @@ +package foundation.aep.core; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.URI; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +/** Spec-grounded behaviour for the transport-independent contracts in Core. */ +final class AepCoreConformanceTest { + + private static List paths(List issues) { + return issues.stream().map(ValidationIssue::path).toList(); + } + + private static InspectDocument.Builder validDocument() { + return InspectDocument.builder() + .version(Aep.VERSION) + .bindings(new InspectDocument.Bindings(List.of("http"))) + .commands(new InspectDocument.Commands(List.of("inspect"), List.of(), Map.of())) + .core(new InspectDocument.Core(List.of("EdDSA", "ES256"))) + .http(new InspectDocument.Http("/aep/", null)) + .identity(new InspectDocument.Identity(List.of("did:web"))) + .service(new InspectDocument.Service("did:web:service.example")); + } + + /** + * CORE-AUTH-001 spells the credential grammar as {@code "AEP" 1*SP compact-jws}, so one or more + * separators is the grammar rather than a malformed presentation, and trailing optional + * whitespace belongs to the field value rather than to the credential. + */ + @Test + void acceptsTheRepeatedSeparatorTheCredentialGrammarAllows() { + for (String value : List.of("AEP token", "AEP token", "AEP\ttoken", "AEP \t token", "AEP token ")) { + ProtectedResourceAuthorization parsed = AepHttp.parseAuthorization(value, AuthorizationCarrier.STANDARD); + assertEquals("token", parsed.credentials(), value); + assertEquals(AuthorizationScheme.AEP, parsed.scheme(), value); + } + assertEquals( + AuthorizationScheme.BEARER, + AepHttp.parseAuthorization("bearer token", AuthorizationCarrier.STANDARD) + .scheme()); + assertEquals( + AuthorizationScheme.BASIC, + AepHttp.parseAuthorization("BASIC dXNlcjpwdw==", AuthorizationCarrier.STANDARD) + .scheme()); + } + + /** + * The AEP, Bearer, and Basic grammars share a visible US-ASCII credential. A value carrying a + * control character or an embedded CRLF is not a credential, and accepting one would let a + * parsed presentation travel back into a request header through {@link AepHttp#renderAuthorization}. + */ + @Test + void rejectsCredentialsThatAreNotVisibleAscii() { + for (String credentials : List.of("tok\ren", "tok\nen", "tok\u0001en", "tok\u007fen", "tokén")) { + assertThrows( + AepAuthorizationException.class, + () -> AepHttp.parseAuthorization("AEP " + credentials, AuthorizationCarrier.STANDARD), + credentials); + assertThrows( + AepAuthorizationException.class, + () -> AepHttp.renderAuthorization(new ProtectedResourceAuthorization( + AuthorizationCarrier.STANDARD, AuthorizationScheme.AEP, credentials)), + credentials); + } + assertEquals( + Map.of("Authorization", "AEP token"), + AepHttp.renderAuthorization(new ProtectedResourceAuthorization( + AuthorizationCarrier.STANDARD, AuthorizationScheme.AEP, "token"))); + assertEquals( + Map.of(Aep.AUTHORIZATION_HEADER, "Bearer token"), + AepHttp.renderAuthorization(new ProtectedResourceAuthorization( + AuthorizationCarrier.DEDICATED, AuthorizationScheme.BEARER, "token"))); + } + + /** CORE-PR-018: one dedicated field value must not encode more than one credential. */ + @Test + void rejectsACombinedDedicatedPresentation() { + assertThrows( + AepAuthorizationException.class, + () -> AepHttp.parseAuthorization("AEP one, Bearer two", AuthorizationCarrier.DEDICATED)); + assertEquals( + "token", + AepHttp.parseAuthorization("AEP token", AuthorizationCarrier.DEDICATED) + .credentials()); + } + + /** + * CORE-CA-014 and CORE-CA-015: an authenticate assertion names one absolute HTTPS target with no + * fragment. User information has no place in it either, because the request target a Service + * compares the claim against never carries credentials. + */ + @Test + void boundsTheResourceClaimToASafeAbsoluteTarget() { + for (String resource : List.of( + "https://api.example/x#", + "https://api.example/x#fragment", + "https://user:secret@api.example/x", + "http://api.example/x", + "/x", + "mailto:agent@example.com")) { + assertFalse( + AepValidation.clientAssertionClaims(authenticateClaims(resource)) + .isEmpty(), + resource); + } + assertTrue(AepValidation.clientAssertionClaims(authenticateClaims("https://api.example/x")) + .isEmpty()); + assertTrue(AepValidation.clientAssertionClaims(authenticateClaims("https://api.example/x?q=1")) + .isEmpty()); + } + + /** The development opt-in permits the three loopback literals a parsed URI actually presents. */ + @Test + void permitsLoopbackResourcesOnlyUnderTheExplicitOptIn() { + for (String resource : List.of("http://localhost/x", "http://127.0.0.1/x", "http://[::1]/x")) { + assertFalse( + AepValidation.clientAssertionClaims(authenticateClaims(resource), false) + .isEmpty(), + resource); + assertTrue( + AepValidation.clientAssertionClaims(authenticateClaims(resource), true) + .isEmpty(), + resource); + } + assertFalse(AepValidation.clientAssertionClaims(authenticateClaims("http://loopback.example/x"), true) + .isEmpty()); + } + + /** CORE-CA-026: a non-authenticate assertion must not carry a resource. */ + @Test + void keepsTheResourceClaimToAuthenticate() { + ClientAssertionClaims claims = new ClientAssertionClaims( + "did:web:agent.example", + "did:web:agent.example", + "did:web:service.example", + AssertionOperation.ENROLL, + 1_000, + 1_060, + "jti-1", + "https://api.example/x"); + assertEquals(List.of("$.resource"), paths(AepValidation.clientAssertionClaims(claims))); + } + + /** CORE-CA-019: the assertion lifetime is bounded at 300 seconds, and must be positive. */ + @Test + void boundsTheAssertionLifetime() { + assertTrue( + AepValidation.clientAssertionClaims(commandClaims(1_000, 1_300)).isEmpty()); + assertTrue( + AepValidation.clientAssertionClaims(commandClaims(1_000, 1_001)).isEmpty()); + assertEquals(List.of("$.exp"), paths(AepValidation.clientAssertionClaims(commandClaims(1_000, 1_301)))); + assertEquals(List.of("$.exp"), paths(AepValidation.clientAssertionClaims(commandClaims(1_000, 1_000)))); + assertEquals(List.of("$.exp"), paths(AepValidation.clientAssertionClaims(commandClaims(1_000, 999)))); + // Near the top of the clock the bound is guarded against overflow, and a lifetime that + // cannot exceed 300 seconds without wrapping stays valid. + assertTrue(AepValidation.clientAssertionClaims(commandClaims(Long.MAX_VALUE - 1, Long.MAX_VALUE)) + .isEmpty()); + } + + /** CORE-CA-010: iss and sub name the same Agent. */ + @Test + void requiresTheIssuerAndSubjectToAgree() { + ClientAssertionClaims claims = new ClientAssertionClaims( + "did:web:agent.example", + "did:web:other.example", + "did:web:service.example", + AssertionOperation.STATUS, + 1_000, + 1_060, + "jti-1", + null); + assertEquals(List.of("$.sub"), paths(AepValidation.clientAssertionClaims(claims))); + } + + /** + * A did:web authority is concatenated back into the document URI, so it must be an authority and + * nothing else. A value decoding to a query or fragment would yield a document URI whose query + * swallows the did.json path, so it is rejected. + */ + @Test + void rejectsDidWebAuthoritiesThatAreNotPurelyAnAuthority() { + for (String did : List.of( + "did:web:example.com%3Fx=1", + "did:web:example.com%23y", "did:web:user%40example.com", "did:web:example.com%2Fpath")) { + assertThrows(IllegalArgumentException.class, () -> DidWeb.documentUri(did), did); + } + assertEquals(URI.create("https://example.com/.well-known/did.json"), DidWeb.documentUri("did:web:example.com")); + assertEquals( + URI.create("https://example.com:8443/.well-known/did.json"), + DidWeb.documentUri("did:web:example.com%3A8443")); + } + + /** DWB path components are single segments; traversal never reaches the resolved URL. */ + @Test + void rejectsTraversalInDidWebPathComponents() { + for (String did : List.of( + "did:web:example.com:.:agent", + "did:web:example.com:..:agent", + "did:web:example.com:%2E%2E:agent", + "did:web:example.com:a%2Fb", + "did:web:example.com::agent")) { + assertThrows(IllegalArgumentException.class, () -> DidWeb.documentUri(did), did); + } + assertEquals( + URI.create("https://example.com/agents/one/did.json"), + DidWeb.documentUri("did:web:example.com:agents:one")); + } + + /** + * CORE-OAS-003 and CORE-OAS-004: an advertised OpenAPI URL must not downgrade transport or carry + * user information. A relative reference inherits the Inspect response transport and is fine. + */ + @Test + void appliesOpenApiTransportRulesToTheAdvertisedDocument() { + for (String url : List.of( + "http://docs.example/openapi.json", + "https://user:secret@docs.example/openapi.json", + "ftp://docs.example/openapi.json")) { + InspectDocument document = validDocument() + .http(new InspectDocument.Http( + "/aep/", new InspectDocument.OpenApi(url, new InspectDocument.PathMatching("strict")))) + .build(); + assertTrue(paths(AepValidation.inspectDocument(document)).contains("$.http.openapi.url"), url); + } + for (String url : List.of( + "/openapi.json", + "openapi.json", + "https://docs.example/openapi.json", + "http://localhost:8080/openapi.json", + "http://127.0.0.1/openapi.json", + "http://[::1]/openapi.json")) { + InspectDocument document = validDocument() + .http(new InspectDocument.Http( + "/aep/", new InspectDocument.OpenApi(url, new InspectDocument.PathMatching("strict")))) + .build(); + assertTrue(AepValidation.inspectDocument(document).isEmpty(), url); + } + } + + /** + * An endpoint_base is origin-relative. A protocol-relative value would move every command URL to + * another origin once resolved against the Service origin, so the document that advertises one is + * invalid rather than merely unusable at the point a command path is built. + */ + @Test + void rejectsAnEndpointBaseThatCouldLeaveTheServiceOrigin() { + for (String base : List.of("//attacker.example", "//attacker.example/aep", "aep/", "/aep?x=1", "/aep#y")) { + InspectDocument document = + validDocument().http(new InspectDocument.Http(base, null)).build(); + assertTrue(paths(AepValidation.inspectDocument(document)).contains("$.http.endpoint_base"), base); + assertThrows(IllegalArgumentException.class, () -> AepHttp.normalizeEndpointBase(base), base); + } + assertTrue(AepValidation.inspectDocument(validDocument() + .http(new InspectDocument.Http("/aep", null)) + .build()) + .isEmpty()); + } + + /** CORE-HTTP-003 and CORE-HTTP-004: both /aep and /aep/ produce /aep/enroll. */ + @Test + void buildsCommandPathsFromTheAdvertisedBase() { + assertEquals(Aep.DEFAULT_ENDPOINT_BASE, AepHttp.normalizeEndpointBase(null)); + assertEquals("/aep/", AepHttp.normalizeEndpointBase("/aep")); + assertEquals("/aep/enroll", AepHttp.commandPath(AepCommand.ENROLL, "/aep")); + assertEquals("/aep/enroll", AepHttp.commandPath(AepCommand.ENROLL, "/aep/")); + assertEquals("/v1/aep/status", AepHttp.commandPath(AepCommand.STATUS, "/v1/aep")); + assertEquals( + URI.create("https://api.example/aep/grant"), + AepHttp.commandUri(URI.create("https://api.example/"), AepCommand.GRANT, null)); + // Inspect is served from the well-known path, never from endpoint_base. + assertThrows(IllegalArgumentException.class, () -> AepHttp.commandPath(AepCommand.INSPECT, "/aep/")); + } + + /** CORE-INS-009 and CORE-INS-022 constrain what a document may advertise. */ + @Test + void constrainsTheAdvertisedCommandSet() { + InspectDocument withoutGrantTypes = validDocument() + .commands(new InspectDocument.Commands(List.of("inspect", "grant", "revoke"), List.of(), Map.of())) + .build(); + assertTrue(paths(AepValidation.inspectDocument(withoutGrantTypes)).contains("$.commands.grant_types")); + + InspectDocument withoutIdentity = validDocument() + .commands(new InspectDocument.Commands(List.of("inspect", "enroll"), List.of(), Map.of())) + .identity(new InspectDocument.Identity(List.of())) + .build(); + assertTrue(paths(AepValidation.inspectDocument(withoutIdentity)).contains("$.identity.methods")); + + // CORE-IANA-005: authenticate is an assertion operation, never a command. + InspectDocument withAuthenticate = validDocument() + .commands(new InspectDocument.Commands(List.of("inspect", "authenticate"), List.of(), Map.of())) + .build(); + assertTrue(paths(AepValidation.inspectDocument(withAuthenticate)).contains("$.commands.supported")); + + InspectDocument withoutInspect = validDocument() + .commands(new InspectDocument.Commands(List.of("enroll"), List.of(), Map.of())) + .build(); + assertTrue(paths(AepValidation.inspectDocument(withoutInspect)).contains("$.commands.supported")); + } + + /** + * CORE-CA-003: a Service must advertise both required algorithms. The document schema leaves the + * array open beyond those two so an extension can register another algorithm, and the allow-list + * that keeps `none` and symmetric algorithms out of an assertion lives at verification time. + */ + @Test + void requiresTheRegisteredSigningAlgorithms() { + for (List algorithms : List.of(List.of(), List.of("EdDSA"), List.of("ES256"))) { + assertFalse( + AepValidation.inspectDocument(validDocument() + .core(new InspectDocument.Core(algorithms)) + .build()) + .isEmpty(), + algorithms.toString()); + } + for (List algorithms : List.of(List.of("ES256", "EdDSA"), List.of("EdDSA", "ES256", "ML-DSA-44"))) { + assertTrue( + AepValidation.inspectDocument(validDocument() + .core(new InspectDocument.Core(algorithms)) + .build()) + .isEmpty(), + algorithms.toString()); + } + // CORE-CA-005: an advertised symmetric algorithm still never verifies an assertion. + assertThrows( + IllegalArgumentException.class, + () -> ClientAssertions.verify( + "a.b.c", + null, + ClientAssertionVerification.builder( + "did:web:service.example", "did:web:agent.example", AssertionOperation.STATUS) + .build())); + } + + /** CORE-ERR-002, CORE-ERR-003 and CORE-ERR-007 shape a Problem Details response. */ + @Test + void shapesProblemDetails() { + assertTrue(AepValidation.problemDetails(ProblemDetails.of("not_recognized", "Not recognized", 401)) + .isEmpty()); + assertTrue(paths(AepValidation.problemDetails(new ProblemDetails( + "urn:aep:error:other", "Title", 401, null, null, "not_recognized", null, null, null))) + .contains("$.type")); + assertTrue(paths(AepValidation.problemDetails( + new ProblemDetails("urn:aep:error:bad", "", 400, null, null, "bad", null, null, null))) + .contains("$.title")); + assertTrue(paths(AepValidation.problemDetails(new ProblemDetails( + "urn:aep:error:Bad-Code", "Title", 400, null, null, "Bad-Code", null, null, null))) + .contains("$.code")); + // A not_recognized response stays non-disclosing. + assertTrue(paths(AepValidation.problemDetails(new ProblemDetails( + "urn:aep:error:not_recognized", + "Not recognized", + 401, + null, + null, + "not_recognized", + "true", + null, + null))) + .contains("$")); + assertTrue(paths(AepValidation.problemDetails(new ProblemDetails( + "urn:aep:error:not_recognized", + "Not recognized", + 401, + null, + null, + "not_recognized", + null, + List.of("contact.email"), + null))) + .contains("$")); + // The same metadata is legitimate on a lifecycle problem. + assertTrue(AepValidation.problemDetails(new ProblemDetails( + "urn:aep:error:verification_pending", + "Verification pending", + 403, + null, + null, + "verification_pending", + "true", + List.of("contact.email"), + List.of("domain-control"))) + .isEmpty()); + } + + /** CORE-RVK-006 and CORE-RVK-008: the Revoke targets are mutually exclusive. */ + @Test + void constrainsRevokeTargeting() { + assertTrue(AepValidation.revokeRequest(new RevokeRequest("api-key", null, null)) + .isEmpty()); + assertTrue(AepValidation.revokeRequest(new RevokeRequest("api-key", "credential-1", null)) + .isEmpty()); + assertTrue(AepValidation.revokeRequest(new RevokeRequest(null, null, "true")) + .isEmpty()); + for (RevokeRequest request : List.of( + new RevokeRequest(null, null, null), + new RevokeRequest(null, "credential-1", null), + new RevokeRequest("api-key", null, "true"), + new RevokeRequest(null, "credential-1", "true"), + new RevokeRequest(null, null, "false"))) { + assertFalse(AepValidation.revokeRequest(request).isEmpty(), request.toString()); + } + } + + /** APK-RSP-004 and RFC 7617 keep issued credentials unambiguous on the wire. */ + @Test + void constrainsIssuedCredentialShapes() { + assertTrue(AepValidation.grantResponse(new GrantResponses.ApiKey( + "key-value", "credential-1", "2026-09-01T12:00:00Z", "x-api-key", List.of("read"))) + .isEmpty()); + for (String apiKey : List.of("key value", "key\"value", "key,value", "key;value", "key\\value", "key\u0001")) { + assertTrue( + paths(AepValidation.grantResponse(new GrantResponses.ApiKey( + apiKey, "credential-1", "2026-09-01T12:00:00Z", "x-api-key", null))) + .contains("$.api_key"), + apiKey); + } + assertTrue(paths(AepValidation.grantResponse( + new GrantResponses.ApiKey("key", "credential-1", "2026-09-01T12:00:00Z", "bad header", null))) + .contains("$.header")); + assertTrue(paths(AepValidation.grantResponse(new GrantResponses.Basic( + "credential-1", "2026-09-01T12:00:00Z", "secret", null, null, "user:name"))) + .contains("$.username")); + assertTrue(paths(AepValidation.grantResponse(new GrantResponses.Basic( + "credential-1", "2026-09-01T12:00:00Z", "sec\u0001ret", null, null, "user"))) + .contains("$.password")); + assertTrue(paths(AepValidation.grantResponse( + new GrantResponses.OAuthBearer("token", "credential-1", "2026-09-01T12:00:00Z", null, "MAC"))) + .contains("$.token_type")); + assertTrue(paths(AepValidation.grantResponse(new GrantResponses.OAuthBearer( + "token", "credential-1", "2026-09-01T12:00:00Z", null, "saml", "Bearer"))) + .contains("$.token_format")); + assertTrue(AepValidation.grantResponse(new GrantResponses.OAuthBearer( + "token", "credential-1", "2026-09-01T12:00:00Z", List.of(), "jwt", "Bearer")) + .isEmpty()); + } + + /** PLT-VER-009: an unrecognized verification response discloses nothing about an identity. */ + @Test + void keepsUnrecognizedVerificationResponsesNonDisclosing() { + assertTrue(AepValidation.platformVerificationResponse(new PlatformVerificationResponse( + null, null, null, "not_recognized", "did:web:service.example", null, false)) + .isEmpty()); + assertTrue(paths(AepValidation.platformVerificationResponse(new PlatformVerificationResponse( + "did:web:agent.example", null, null, "not_recognized", "did:web:service.example", null, false))) + .contains("$")); + assertTrue(paths(AepValidation.platformVerificationResponse(new PlatformVerificationResponse( + null, null, null, "verified", "did:web:service.example", null, false))) + .contains("$.reason")); + assertTrue(AepValidation.platformVerificationResponse(new PlatformVerificationResponse( + "did:web:agent.example", + "pai_1", + AssertionOperation.STATUS, + "verified", + "did:web:service.example", + ManagedAgentStatus.ACTIVE, + true)) + .isEmpty()); + } + + /** RFC 5321: a standardized address-literal tag is an Ldh-str, which starts alphanumeric. */ + @Test + void constrainsMailboxSyntax() { + for (String mailbox : List.of( + "agent@example.com", + "\"quoted local\"@example.com", + "agent@[127.0.0.1]", + "agent@[IPv6:::1]", + "agent@[x25:value]")) { + assertTrue( + AepValidation.claimValues( + ClaimValues.builder().contactEmail(mailbox).build()) + .isEmpty(), + mailbox); + } + for (String mailbox : List.of( + "agent@[-tag:value]", + "agent@[:value]", + "agent@[tag:]", + "agent@@example.com", + "@example.com", + "agent@", + "agent..name@example.com", + "agent@-example.com", + "agent@[not closed")) { + assertFalse( + AepValidation.claimValues( + ClaimValues.builder().contactEmail(mailbox).build()) + .isEmpty(), + mailbox); + } + } + + private static ClientAssertionClaims authenticateClaims(String resource) { + return new ClientAssertionClaims( + "did:web:agent.example", + "did:web:agent.example", + "did:web:service.example", + AssertionOperation.AUTHENTICATE, + 1_000, + 1_060, + "jti-1", + resource); + } + + private static ClientAssertionClaims commandClaims(long issuedAt, long expiresAt) { + return new ClientAssertionClaims( + "did:web:agent.example", + "did:web:agent.example", + "did:web:service.example", + AssertionOperation.STATUS, + issuedAt, + expiresAt, + "jti-1", + null); + } +} diff --git a/aep-core/src/test/java/foundation/aep/core/AepCoreCoverageTest.java b/aep-core/src/test/java/foundation/aep/core/AepCoreCoverageTest.java new file mode 100644 index 0000000..4b71d3d --- /dev/null +++ b/aep-core/src/test/java/foundation/aep/core/AepCoreCoverageTest.java @@ -0,0 +1,860 @@ +package foundation.aep.core; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.URI; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +/** Edge and failure paths across the Core contracts. */ +final class AepCoreCoverageTest { + + private static List paths(List issues) { + return issues.stream().map(ValidationIssue::path).toList(); + } + + @Test + void rejectsAbsentDocumentsAcrossEveryValidator() { + List> results = List.of( + AepValidation.inspectDocument(null), + AepValidation.claimValues(null), + AepValidation.enrollRequest(null), + AepValidation.enrollResponse(null), + AepValidation.statusResponse(null), + AepValidation.grantRequest(null), + AepValidation.revokeRequest(null), + AepValidation.clientAssertionClaims(null), + AepValidation.idempotencyMetadata(null), + AepValidation.problemDetails(null), + AepValidation.platformDiscoveryDocument(null), + AepValidation.platformAgentIdentity(null), + AepValidation.platformAgentIdentityListResponse(null), + AepValidation.platformProvisionRequest(null), + AepValidation.platformLifecycleRequest(null), + AepValidation.platformSignRequest(null), + AepValidation.platformSignResponse(null), + AepValidation.platformVerificationRequest(null), + AepValidation.platformVerificationResponse(null), + AepValidation.grantResponse(null), + AepValidation.protectedResourceAuthorization(null), + AepValidation.openApiSecurityScheme(null)); + for (List issues : results) { + assertEquals(List.of("$"), paths(issues)); + } + } + + @Test + void requireHelpersRaiseTypedFailures() { + AepValidationException failure = + assertThrows(AepValidationException.class, () -> AepValidation.requireInspectDocument(null)); + assertEquals("Inspect document", failure.documentType()); + assertEquals(List.of("$"), paths(failure.issues())); + assertNull(failure.getCause()); + assertTrue(failure.getMessage().contains("Inspect document")); + + assertThrows(AepValidationException.class, () -> AepValidation.requireClaimValues(null)); + assertThrows(AepValidationException.class, () -> AepValidation.requireClientAssertionClaims(null, false)); + assertThrows(AepValidationException.class, () -> AepValidation.requirePlatformDiscoveryDocument(null)); + assertThrows(AepValidationException.class, () -> AepValidation.requirePlatformAgentIdentity(null)); + assertThrows(AepValidationException.class, () -> AepValidation.requirePlatformSignResponse(null)); + + AepValidationException wrapped = new AepValidationException( + "thing", List.of(new ValidationIssue("$", "bad")), new IllegalStateException()); + assertNotNull(wrapped.getCause()); + + AepAuthorizationException authorization = new AepAuthorizationException("not_recognized", "message"); + assertEquals("not_recognized", authorization.code()); + assertEquals("message", authorization.getMessage()); + } + + @Test + void acceptsAFullyPopulatedInspectDocument() { + InspectDocument document = InspectDocument.builder() + .version(Aep.VERSION) + .authentication(new InspectDocument.Authentication(List.of("aep-jwt", "api-key"))) + .bindings(new InspectDocument.Bindings(List.of("http"))) + .claims(new InspectDocument.Claims( + List.of("contact.email"), List.of("person.first_name"), List.of("person.username"))) + .commands(new InspectDocument.Commands( + List.of("inspect", "enroll", "grant", "revoke", "status"), + List.of("api-key", "oauth-bearer"), + Map.of( + "api-key", + new InspectDocument.GrantTypeConfig( + null, + "300", + List.of("x-api-key"), + null, + List.of("read"), + "true", + null, + null), + "oauth-bearer", + new InspectDocument.GrantTypeConfig( + List.of("opaque", "jwt"), + "60", + null, + null, + List.of("read"), + "false", + "https://api.example/introspect", + "https://api.example/revoke")))) + .core(new InspectDocument.Core(List.of("EdDSA", "ES256"))) + .extensions(new InspectDocument.Extensions(List.of("https://example.com/aep-extension"))) + .http(new InspectDocument.Http( + "/v1/aep", + new InspectDocument.OpenApi( + "https://api.example/openapi.json", new InspectDocument.PathMatching("equivalent")))) + .identity(new InspectDocument.Identity(List.of("did:web"))) + .service(new InspectDocument.Service("did:web:service.example")) + .build(); + assertTrue(AepValidation.inspectDocument(document).isEmpty()); + assertSame(document, AepValidation.requireInspectDocument(document)); + } + + @Test + void reportsEveryInspectDocumentDefect() { + InspectDocument document = InspectDocument.builder() + .version("2.0") + .authentication(new InspectDocument.Authentication(List.of("Bad Method", "api-key", "api-key"))) + .bindings(new InspectDocument.Bindings(List.of("grpc"))) + .claims(new InspectDocument.Claims(List.of("Contact.Email"), List.of(), List.of())) + .commands(new InspectDocument.Commands( + List.of("inspect", "grant"), + List.of("api-key"), + Map.of( + "Not A Grant Type", + new InspectDocument.GrantTypeConfig( + List.of("saml"), + "0", + List.of("bad header", "X-Api-Key", "x-api-key"), + null, + List.of(), + "maybe", + "http://api.example/introspect", + null)))) + .core(new InspectDocument.Core(List.of())) + .extensions(new InspectDocument.Extensions(List.of("not-a-uri"))) + .http(new InspectDocument.Http( + "relative", new InspectDocument.OpenApi("", new InspectDocument.PathMatching("loose")))) + .identity(new InspectDocument.Identity(List.of("BadMethod"))) + .service(new InspectDocument.Service("service.example")) + .build(); + List reported = paths(AepValidation.inspectDocument(document)); + for (String expected : List.of( + "$.aep_version", + "$.authentication.methods[0]", + "$.bindings.supported", + "$.claims.required[0]", + "$.core.signing_algorithms", + "$.extensions.supported[0]", + "$.http.endpoint_base", + "$.http.openapi.url", + "$.http.openapi.path_matching.trailing_slash", + "$.identity.methods[0]", + "$.service.did")) { + assertTrue(reported.contains(expected), expected + " in " + reported); + } + } + + @Test + void reportsGrantTypeConfigurationDefects() { + InspectDocument document = InspectDocument.builder() + .version(Aep.VERSION) + .bindings(new InspectDocument.Bindings(List.of("http"))) + .commands(new InspectDocument.Commands( + List.of("inspect", "grant", "revoke"), + List.of("api-key", "oauth-bearer"), + Map.of( + "api-key", + new InspectDocument.GrantTypeConfig( + null, + null, + List.of("X-Api-Key", "x-api-key"), + null, + null, + "yes", + null, + null), + "oauth-bearer", + new InspectDocument.GrantTypeConfig( + List.of("saml"), + null, + null, + null, + null, + null, + "http://api.example/introspect", + null)))) + .core(new InspectDocument.Core(List.of("EdDSA", "ES256"))) + .http(new InspectDocument.Http("/aep/", null)) + .identity(new InspectDocument.Identity(List.of("did:web"))) + .service(new InspectDocument.Service("did:web:service.example")) + .build(); + List reported = paths(AepValidation.inspectDocument(document)); + assertTrue(reported.contains("$.commands.grant_types_config.api-key.header_names[1]"), reported.toString()); + assertTrue( + reported.contains("$.commands.grant_types_config.api-key.supports_per_credential_revoke"), + reported.toString()); + assertTrue( + reported.contains("$.commands.grant_types_config.oauth-bearer.access_token_formats[0]"), + reported.toString()); + assertTrue( + reported.contains("$.commands.grant_types_config.oauth-bearer.introspection_endpoint"), + reported.toString()); + } + + @Test + void reportsUnadvertisedGrantTypeConfiguration() { + Map configs = new LinkedHashMap<>(); + configs.put("basic", new InspectDocument.GrantTypeConfig("true")); + configs.put("Not A Grant Type", new InspectDocument.GrantTypeConfig("true")); + InspectDocument document = InspectDocument.builder() + .version(Aep.VERSION) + .bindings(new InspectDocument.Bindings(List.of("http"))) + .commands(new InspectDocument.Commands( + List.of("inspect", "grant", "revoke"), List.of("api-key"), configs)) + .core(new InspectDocument.Core(List.of("EdDSA", "ES256"))) + .http(new InspectDocument.Http("/aep/", null)) + .identity(new InspectDocument.Identity(List.of("did:web"))) + .service(new InspectDocument.Service("did:web:service.example")) + .build(); + List reported = paths(AepValidation.inspectDocument(document)); + assertTrue(reported.contains("$.commands.grant_types_config.basic"), reported.toString()); + assertTrue(reported.contains("$.commands.grant_types_config.Not A Grant Type"), reported.toString()); + } + + @Test + void recognizesCompatibleAndIncompatibleVersions() { + assertTrue(AepValidation.isCompatibleVersion("1.0")); + assertTrue(AepValidation.isCompatibleVersion("1.7")); + assertFalse(AepValidation.isCompatibleVersion("2.0")); + assertFalse(AepValidation.isCompatibleVersion("1")); + assertFalse(AepValidation.isCompatibleVersion("1.0.0")); + assertFalse(AepValidation.isCompatibleVersion("01.0")); + assertFalse(AepValidation.isCompatibleVersion(null)); + } + + @Test + void validatesClaimValueShapes() { + ClaimValues values = ClaimValues.builder() + .contactAddressPrimary(new ContactAddressPrimary( + "Springfield", "US", "Ada", "Lovelace", "1 Main Street", "Apt 2", "Floor 3", "12345", "IL")) + .contactEmail("agent@example.com") + .contactMobile("+14155550123") + .personBirthdate("1815-12-10") + .personFirstName("Ada") + .personLastName("Lovelace") + .personUsername("ada") + .additional("example.list", java.util.Arrays.asList(1, "two", true, null)) + .additional("example.nested", Map.of("inner", Map.of("deep", 1.5))) + .build(); + assertTrue(AepValidation.claimValues(values).isEmpty()); + assertTrue(values.contains(Aep.CLAIM_CONTACT_EMAIL)); + assertTrue(values.contains("example.list")); + assertFalse(values.contains("example.absent")); + assertSame(values, AepValidation.requireClaimValues(values)); + + ClaimValues invalidAddress = ClaimValues.builder() + .contactAddressPrimary(new ContactAddressPrimary("", "USA", "", "", "", null, null, null, null)) + .build(); + List reported = paths(AepValidation.claimValues(invalidAddress)); + assertTrue(reported.contains("$.contact.address.primary.city"), reported.toString()); + assertTrue(reported.contains("$.contact.address.primary.country"), reported.toString()); + assertTrue(reported.contains("$.contact.address.primary.first_name"), reported.toString()); + assertTrue(reported.contains("$.contact.address.primary.last_name"), reported.toString()); + assertTrue(reported.contains("$.contact.address.primary.line1"), reported.toString()); + } + + @Test + void rejectsClaimValuesThatAreNotJson() { + ClaimValues values = + ClaimValues.builder().additional("example.object", new Object()).build(); + assertEquals(List.of("$.example.object"), paths(AepValidation.claimValues(values))); + + ClaimValues infinite = ClaimValues.builder() + .additional("example.number", Double.POSITIVE_INFINITY) + .build(); + assertEquals(List.of("$.example.number"), paths(AepValidation.claimValues(infinite))); + + ClaimValues infiniteFloat = + ClaimValues.builder().additional("example.float", Float.NaN).build(); + assertEquals(List.of("$.example.float"), paths(AepValidation.claimValues(infiniteFloat))); + + ClaimValues nestedBad = ClaimValues.builder() + .additional("example.list", List.of(List.of(new Object()))) + .build(); + assertEquals(List.of("$.example.list[0][0]"), paths(AepValidation.claimValues(nestedBad))); + + Map nonStringKey = new LinkedHashMap<>(); + nonStringKey.put(1, "value"); + ClaimValues badKey = + ClaimValues.builder().additional("example.map", nonStringKey).build(); + assertEquals(List.of("$.example.map"), paths(AepValidation.claimValues(badKey))); + } + + @Test + void validatesDateAndTimeFormats() { + assertTrue(AepValidation.statusResponse( + new StatusResponse(AgentStatus.ACTIVE, null, null, null, "2026-09-01T12:00:00Z")) + .isEmpty()); + assertTrue(AepValidation.statusResponse( + new StatusResponse(AgentStatus.ACTIVE, null, null, null, "2026-09-01T12:00:00.123+01:00")) + .isEmpty()); + assertTrue(AepValidation.statusResponse(new StatusResponse(AgentStatus.ACTIVE, null, null, null, null)) + .isEmpty()); + for (String value : List.of("2026-09-01", "2026-09-01T12:00:00", "", "not-a-date")) { + assertFalse( + AepValidation.statusResponse(new StatusResponse(AgentStatus.ACTIVE, null, null, null, value)) + .isEmpty(), + value); + } + for (String birthdate : List.of("1815-12-10")) { + assertTrue(AepValidation.claimValues( + ClaimValues.builder().personBirthdate(birthdate).build()) + .isEmpty()); + } + for (String birthdate : List.of("1815-12-10T00:00:00Z", "815-12-10", "2025-02-29", "1815-1-1", "")) { + assertFalse( + AepValidation.claimValues(ClaimValues.builder() + .personBirthdate(birthdate) + .build()) + .isEmpty(), + birthdate); + } + } + + @Test + void validatesLifecycleMetadataAndStatusMessages() { + assertTrue(paths(AepValidation.enrollResponse(new EnrollResponse(null, null, null, null))) + .contains("$.status")); + assertTrue(paths(AepValidation.enrollResponse(new EnrollResponse(AgentStatus.ACTIVE, "maybe", null, null))) + .contains("$.owner_action_required")); + assertTrue(AepValidation.enrollResponse(new EnrollResponse(AgentStatus.ACTIVE, "false", null, null)) + .isEmpty()); + assertTrue(paths(AepValidation.statusResponse(new StatusResponse(null, null, null, null, null))) + .contains("$.status")); + assertTrue(paths(AepValidation.enrollResponse( + new EnrollResponse(AgentStatus.ACTIVE, null, List.of("a", "a"), null))) + .contains("$.verification_pending[1]")); + assertTrue(paths(AepValidation.enrollResponse( + new EnrollResponse(AgentStatus.ACTIVE, null, null, List.of("", "b")))) + .contains("$.requirements_pending[0]")); + } + + @Test + void validatesEnrollAndGrantRequests() { + assertTrue(paths(AepValidation.enrollRequest(new EnrollRequest("did:web:agent.example", null, ""))) + .contains("$.idempotency_key")); + assertTrue(paths(AepValidation.enrollRequest(new EnrollRequest( + "did:web:agent.example", + ClaimValues.builder().contactEmail("bad").build(), + null))) + .contains("$.claims.contact.email")); + assertTrue(paths(AepValidation.grantRequest(new GrantRequest("", null, null, null))) + .contains("$.grant_type")); + assertTrue(paths(AepValidation.grantRequest(new GrantRequest("oauth-bearer", null, null, "saml"))) + .contains("$.token_format")); + assertTrue(paths(AepValidation.grantRequest(new GrantRequest("api-key", null, null, ""))) + .contains("$.token_format")); + // The Grant request schema puts no minimum length on a requested scope, so an empty entry is + // the Service's business rather than a wire-shape defect. + assertTrue(AepValidation.grantRequest(new GrantRequest("api-key", null, List.of("", "read"), null)) + .isEmpty()); + assertTrue(AepValidation.grantRequest(new GrantRequest("oauth-bearer", null, List.of(), "opaque")) + .isEmpty()); + } + + @Test + void validatesIdempotencyMetadata() { + assertTrue(AepValidation.idempotencyMetadata(new IdempotencyMetadata( + "did:web:agent.example", "key-1", "sha256:" + "a".repeat(64), "sha256:" + "b".repeat(64))) + .isEmpty()); + assertTrue(AepValidation.idempotencyMetadata(new IdempotencyMetadata(null, "key-1", null, null)) + .isEmpty()); + List reported = paths(AepValidation.idempotencyMetadata( + new IdempotencyMetadata("", "", "sha256:short", "md5:" + "a".repeat(64)))); + assertTrue(reported.contains("$.agent_did"), reported.toString()); + assertTrue(reported.contains("$.idempotency_key"), reported.toString()); + assertTrue(reported.contains("$.first_body_hash"), reported.toString()); + assertTrue(reported.contains("$.second_body_hash"), reported.toString()); + } + + @Test + void validatesPlatformDiscoveryDocuments() { + PlatformDiscoveryDocument valid = new PlatformDiscoveryDocument( + Aep.VERSION, + new PlatformDiscoveryDocument.Endpoints( + "/v1/aep/verifications", + "/v1/aep/identities/{agent_identity_id}", + "/v1/aep/identities", + "/v1/aep/identities", + "/v1/aep/identities/{agent_identity_id}/sign"), + new PlatformDiscoveryDocument.Http("/v1/aep"), + new PlatformDiscoveryDocument.Identity( + List.of("did:web"), "https://platform.example/agents/{agent_did_id}/did.json"), + new PlatformDiscoveryDocument.Platform("did:web:platform.example", true, "Example Platform"), + new PlatformDiscoveryDocument.Signing(List.of("EdDSA", "ES256"), "300")); + assertTrue(AepValidation.platformDiscoveryDocument(valid).isEmpty()); + assertSame(valid, AepValidation.requirePlatformDiscoveryDocument(valid)); + + PlatformDiscoveryDocument missing = new PlatformDiscoveryDocument("2.0", null, null, null, null, null); + List reported = paths(AepValidation.platformDiscoveryDocument(missing)); + for (String expected : + List.of("$.aep_version", "$.endpoints", "$.http", "$.identity", "$.platform", "$.signing")) { + assertTrue(reported.contains(expected), expected + " in " + reported); + } + + PlatformDiscoveryDocument invalid = new PlatformDiscoveryDocument( + Aep.VERSION, + new PlatformDiscoveryDocument.Endpoints("relative", "/no-template", "", "/list?x=1", "/sign#y"), + new PlatformDiscoveryDocument.Http("//attacker.example"), + new PlatformDiscoveryDocument.Identity(List.of(), "https://platform.example/did.json"), + new PlatformDiscoveryDocument.Platform("platform.example", false, ""), + new PlatformDiscoveryDocument.Signing(List.of("HS256"), "0")); + List invalidPaths = paths(AepValidation.platformDiscoveryDocument(invalid)); + for (String expected : List.of( + "$.endpoints.hosted_verification", + "$.endpoints.lifecycle", + "$.endpoints.list", + "$.endpoints.provision", + "$.endpoints.sign", + "$.http.endpoint_base", + "$.identity.did_methods", + "$.identity.did_url_template", + "$.platform.did", + "$.platform.name", + "$.signing.algorithms", + "$.signing.default_lifetime_seconds")) { + assertTrue(invalidPaths.contains(expected), expected + " in " + invalidPaths); + } + } + + @Test + void validatesPlatformIdentityMessages() { + PlatformAgentIdentity identity = new PlatformAgentIdentity( + "did:web:platform.example:agents:one", + "pai_1", + "2026-09-01T12:00:00Z", + "https://platform.example/agents/one/did.json", + "did:web:platform.example:agents:one", + "did:web:service.example", + List.of("ES256"), + ManagedAgentStatus.ACTIVE, + "2026-09-01T12:00:00Z"); + assertTrue(AepValidation.platformAgentIdentity(identity).isEmpty()); + assertSame(identity, AepValidation.requirePlatformAgentIdentity(identity)); + + PlatformAgentIdentity broken = new PlatformAgentIdentity( + "agent", "", "not-a-date", "http://platform.example/did.json", "", "service", List.of(), null, ""); + List reported = paths(AepValidation.platformAgentIdentity(broken)); + for (String expected : List.of( + "$.agent_did", + "$.agent_identity_id", + "$.created_at", + "$.did_document_url", + "$.key_id", + "$.service_did", + "$.signing_algorithms", + "$.status", + "$.updated_at")) { + assertTrue(reported.contains(expected), expected + " in " + reported); + } + + assertTrue(AepValidation.platformAgentIdentityListResponse( + new PlatformAgentIdentityListResponse("1", List.of(identity), "4")) + .isEmpty()); + assertTrue(paths(AepValidation.platformAgentIdentityListResponse( + new PlatformAgentIdentityListResponse("2", List.of(identity), "4"))) + .contains("$.count")); + assertTrue(paths(AepValidation.platformAgentIdentityListResponse( + new PlatformAgentIdentityListResponse("1", List.of(identity), "0"))) + .contains("$.total")); + assertTrue(paths(AepValidation.platformAgentIdentityListResponse( + new PlatformAgentIdentityListResponse("-1", List.of(), "x"))) + .contains("$.count")); + // The record copies an absent list to an empty one, so an absent data member surfaces as a + // count that disagrees with the entries rather than as a missing array. + assertTrue(paths(AepValidation.platformAgentIdentityListResponse( + new PlatformAgentIdentityListResponse(null, null, null))) + .contains("$.count")); + assertTrue(paths(AepValidation.platformAgentIdentityListResponse( + new PlatformAgentIdentityListResponse("1", List.of(broken), "1"))) + .contains("$.data[0].agent_did")); + } + + @Test + void validatesPlatformRequestsAndResponses() { + assertTrue(AepValidation.platformProvisionRequest(new PlatformProvisionRequest("did:web:service.example")) + .isEmpty()); + assertTrue(paths(AepValidation.platformProvisionRequest(new PlatformProvisionRequest("service.example"))) + .contains("$.service_did")); + + assertTrue(AepValidation.platformLifecycleRequest(new PlatformLifecycleRequest(ManagedAgentStatus.REVOKED)) + .isEmpty()); + assertTrue(paths(AepValidation.platformLifecycleRequest(new PlatformLifecycleRequest(null))) + .contains("$.status")); + + assertTrue(AepValidation.platformSignRequest(new PlatformSignRequest( + "jti-1", + "60", + AssertionOperation.STATUS, + Map.of("tenant", "acme"), + null, + "did:web:service.example")) + .isEmpty()); + List signPaths = paths(AepValidation.platformSignRequest( + new PlatformSignRequest("", "0", null, null, "https://api.example/x", "service.example"))); + for (String expected : List.of("$.jti", "$.lifetime_seconds", "$.op", "$.service_did")) { + assertTrue(signPaths.contains(expected), expected + " in " + signPaths); + } + assertTrue(paths(AepValidation.platformSignRequest(new PlatformSignRequest( + "jti-1", null, AssertionOperation.AUTHENTICATE, null, null, "did:web:service.example"))) + .contains("$.resource")); + + assertTrue(AepValidation.platformVerificationRequest(new PlatformVerificationRequest( + "assertion", AssertionOperation.STATUS, null, "did:web:service.example")) + .isEmpty()); + assertTrue(paths(AepValidation.platformVerificationRequest( + new PlatformVerificationRequest("", null, "https://api.example/x", "service.example"))) + .contains("$.client_assertion")); + } + + @Test + void validatesPlatformSignResponses() { + PlatformSignResponses.Completed completed = new PlatformSignResponses.Completed( + "completed", + "did:web:platform.example:agents:one", + "assertion", + "2026-09-01T12:05:00Z", + "2026-09-01T12:00:00Z", + "jti-1", + Map.of("tenant", "acme"), + "did:web:service.example"); + assertTrue(AepValidation.platformSignResponse(completed).isEmpty()); + assertSame(completed, AepValidation.requirePlatformSignResponse(completed)); + assertEquals("completed", completed.status()); + assertEquals(Map.of("tenant", "acme"), completed.platformContext()); + + List reported = paths(AepValidation.platformSignResponse(new PlatformSignResponses.Completed( + "pending", "agent", "", "not-a-date", "not-a-date", "", null, "service"))); + for (String expected : List.of( + "$.status", + "$.agent_did", + "$.client_assertion", + "$.expires_at", + "$.issued_at", + "$.jti", + "$.service_did")) { + assertTrue(reported.contains(expected), expected + " in " + reported); + } + + PlatformSignResponses.Pending pending = new PlatformSignResponses.Pending("pending", null, "5"); + assertTrue(AepValidation.platformSignResponse(pending).isEmpty()); + assertNull(pending.platformContext()); + assertEquals("pending", pending.status()); + List pendingPaths = + paths(AepValidation.platformSignResponse(new PlatformSignResponses.Pending("done", null, "301"))); + assertTrue(pendingPaths.contains("$.status"), pendingPaths.toString()); + assertTrue(pendingPaths.contains("$.retry_after_seconds"), pendingPaths.toString()); + for (String retry : List.of("0", "+5", "05", "", "abc", "5.0")) { + assertFalse( + AepValidation.platformSignResponse(new PlatformSignResponses.Pending("pending", null, retry)) + .isEmpty(), + retry); + } + } + + @Test + void validatesAuthorizationAndSecuritySchemeDocuments() { + assertTrue(AepValidation.protectedResourceAuthorization(new ProtectedResourceAuthorization( + AuthorizationCarrier.STANDARD, AuthorizationScheme.AEP, "token")) + .isEmpty()); + List reported = + paths(AepValidation.protectedResourceAuthorization(new ProtectedResourceAuthorization(null, null, ""))); + assertTrue(reported.contains("$.carrier"), reported.toString()); + assertTrue(reported.contains("$.scheme"), reported.toString()); + assertTrue(reported.contains("$.credentials"), reported.toString()); + + assertTrue(AepValidation.openApiSecurityScheme(new OpenApiAepSecurityScheme("aep-jwt")) + .isEmpty()); + assertTrue(paths(AepValidation.openApiSecurityScheme(new OpenApiAepSecurityScheme("Not A Method"))) + .contains("$.x-aep-authentication-method")); + assertTrue(paths(AepValidation.openApiSecurityScheme(new OpenApiAepSecurityScheme(null))) + .contains("$.x-aep-authentication-method")); + } + + @Test + void resolvesAndRejectsOpenApiReferences() { + URI inspect = URI.create("https://api.example/.well-known/aep"); + assertEquals( + URI.create("https://api.example/openapi.json"), + AepOpenApi.resolveDocumentUri(inspect, "/openapi.json")); + assertEquals( + URI.create("https://docs.example/openapi.json"), + AepOpenApi.resolveDocumentUri(inspect, "https://docs.example/openapi.json")); + assertThrows(NullPointerException.class, () -> AepOpenApi.resolveDocumentUri(null, "/openapi.json")); + for (String reference : List.of("", " ")) { + assertThrows(IllegalArgumentException.class, () -> AepOpenApi.resolveDocumentUri(inspect, reference)); + } + assertThrows(IllegalArgumentException.class, () -> AepOpenApi.resolveDocumentUri(inspect, null)); + assertThrows( + IllegalArgumentException.class, + () -> AepOpenApi.resolveDocumentUri(inspect, "http://docs.example/openapi.json")); + assertThrows( + IllegalArgumentException.class, + () -> AepOpenApi.resolveDocumentUri(inspect, "https://user:pw@docs.example/openapi.json")); + assertThrows( + IllegalArgumentException.class, + () -> AepOpenApi.resolveDocumentUri(inspect, "https://docs.example/openapi.json#section")); + assertThrows( + IllegalArgumentException.class, + () -> AepOpenApi.resolveDocumentUri(URI.create("/relative"), "/openapi.json")); + assertEquals( + URI.create("http://localhost:8080/openapi.json"), + AepOpenApi.resolveDocumentUri( + URI.create("http://localhost:8080/.well-known/aep"), "/openapi.json", true)); + assertEquals( + URI.create("http://[::1]/openapi.json"), + AepOpenApi.resolveDocumentUri(URI.create("http://[::1]/.well-known/aep"), "/openapi.json", true)); + assertEquals( + URI.create("http://127.0.0.1/openapi.json"), + AepOpenApi.resolveDocumentUri(URI.create("http://127.0.0.1/.well-known/aep"), "/openapi.json", true)); + } + + @Test + void matchesOpenApiPathTemplates() { + List templates = List.of("/items", "/items/{id}", "/items/latest", "?bad", "/deep/{a}/{b}"); + assertEquals( + "/items", + AepOpenApi.matchPath(templates, "get", "/items", AepOpenApi.TrailingSlashMode.STRICT) + .template()); + assertEquals( + "GET", + AepOpenApi.matchPath(templates, "get", "/items", AepOpenApi.TrailingSlashMode.STRICT) + .method()); + assertEquals( + "/items/latest", + AepOpenApi.matchPath(templates, "GET", "/items/latest", AepOpenApi.TrailingSlashMode.STRICT) + .template()); + assertEquals( + "/items/{id}", + AepOpenApi.matchPath(templates, "GET", "/items/7", AepOpenApi.TrailingSlashMode.STRICT) + .template()); + assertEquals( + "/deep/{a}/{b}", + AepOpenApi.matchPath(templates, "GET", "/deep/one/two?q=1", AepOpenApi.TrailingSlashMode.STRICT) + .template()); + assertThrows( + IllegalArgumentException.class, + () -> AepOpenApi.matchPath(templates, "GET", "/items/", AepOpenApi.TrailingSlashMode.STRICT)); + assertEquals( + "/items", + AepOpenApi.matchPath(templates, "GET", "/items/", AepOpenApi.TrailingSlashMode.EQUIVALENT) + .template()); + assertThrows( + IllegalArgumentException.class, + () -> AepOpenApi.matchPath(templates, "GET", "/absent", AepOpenApi.TrailingSlashMode.STRICT)); + assertThrows( + IllegalArgumentException.class, + () -> AepOpenApi.matchPath( + List.of("/items/{id}", "/items/{name}"), + "GET", + "/items/7", + AepOpenApi.TrailingSlashMode.STRICT)); + assertThrows( + IllegalArgumentException.class, + () -> AepOpenApi.matchPath(templates, " ", "/items", AepOpenApi.TrailingSlashMode.STRICT)); + assertThrows( + IllegalArgumentException.class, + () -> AepOpenApi.matchPath(templates, null, "/items", AepOpenApi.TrailingSlashMode.STRICT)); + assertThrows( + IllegalArgumentException.class, + () -> AepOpenApi.matchPath(templates, "GET", "relative", AepOpenApi.TrailingSlashMode.STRICT)); + assertThrows( + IllegalArgumentException.class, + () -> AepOpenApi.matchPath(templates, "GET", " ", AepOpenApi.TrailingSlashMode.STRICT)); + assertThrows( + NullPointerException.class, + () -> AepOpenApi.matchPath(null, "GET", "/items", AepOpenApi.TrailingSlashMode.STRICT)); + assertThrows(NullPointerException.class, () -> AepOpenApi.matchPath(templates, "GET", "/items", null)); + // A template segment that is only braces is a literal, not a parameter. + assertThrows( + IllegalArgumentException.class, + () -> AepOpenApi.matchPath(List.of("/{}"), "GET", "/value", AepOpenApi.TrailingSlashMode.STRICT)); + // An empty request segment never satisfies a parameter. + assertThrows( + IllegalArgumentException.class, + () -> AepOpenApi.matchPath( + List.of("/items/{id}"), "GET", "/items//", AepOpenApi.TrailingSlashMode.STRICT)); + } + + @Test + void resolvesDidWebDocumentsAndOrigins() { + assertEquals(URI.create("https://example.com/.well-known/did.json"), DidWeb.documentUri("did:web:example.com")); + assertEquals( + URI.create("http://localhost:8080/.well-known/did.json"), + DidWeb.documentUri("did:web:localhost%3A8080", true)); + assertEquals( + URI.create("http://127.0.0.1/.well-known/did.json"), DidWeb.documentUri("did:web:127.0.0.1", true)); + assertEquals( + URI.create("https://127.0.0.1/.well-known/did.json"), DidWeb.documentUri("did:web:127.0.0.1", false)); + for (String did : List.of("", "did:key:z6Mk", "did:web:", "web:example.com")) { + assertThrows(IllegalArgumentException.class, () -> DidWeb.documentUri(did), did); + } + assertThrows(IllegalArgumentException.class, () -> DidWeb.documentUri(null)); + assertThrows(IllegalArgumentException.class, () -> DidWeb.documentUri("did:web:example.com:%ZZ")); + + assertTrue(DidWeb.bindsOrigin("did:web:example.com", URI.create("https://example.com/x"))); + assertTrue(DidWeb.bindsOrigin("did:web:EXAMPLE.com", URI.create("https://example.com:443/x"))); + assertTrue(DidWeb.bindsOrigin("did:web:example.com%3A8443", URI.create("https://example.com:8443/x"))); + assertFalse(DidWeb.bindsOrigin("did:web:example.com", URI.create("https://other.example/x"))); + assertFalse(DidWeb.bindsOrigin("did:web:example.com", URI.create("http://example.com/x"))); + assertFalse(DidWeb.bindsOrigin("did:web:example.com%3A8443", URI.create("https://example.com/x"))); + assertFalse(DidWeb.bindsOrigin("did:web:example.com", URI.create("/relative"))); + assertFalse(DidWeb.bindsOrigin("did:web:example.com", null)); + } + + @Test + void copiesAndRedactsTransportMessages() { + byte[] body = "payload".getBytes(java.nio.charset.StandardCharsets.UTF_8); + Map> headers = new LinkedHashMap<>(); + headers.put("Authorization", new ArrayList<>(List.of("AEP token"))); + AepHttpTransport.Request request = new AepHttpTransport.Request( + "POST", URI.create("https://api.example/aep/enroll"), headers, body, Duration.ofSeconds(5)); + body[0] = 'X'; + assertEquals("payload", new String(request.body(), java.nio.charset.StandardCharsets.UTF_8)); + assertFalse(request.toString().contains("api.example")); + assertFalse(request.toString().contains("AEP token")); + assertTrue(request.toString().contains("bodyLength=7")); + + AepHttpTransport.Request empty = + new AepHttpTransport.Request("GET", URI.create("https://api.example/"), null, null, null); + assertEquals(0, empty.body().length); + assertEquals(Map.of(), empty.headers()); + + AepHttpTransport.Response response = new AepHttpTransport.Response(200, headers, null); + assertEquals(0, response.body().length); + assertFalse(response.toString().contains("AEP token")); + assertTrue(response.toString().contains("status=200")); + assertEquals( + 7, + new AepHttpTransport.Response(200, null, "payload".getBytes(java.nio.charset.StandardCharsets.UTF_8)) + .body() + .length); + } + + @Test + void redactsIssuedCredentialsFromDiagnostics() { + assertFalse(new GrantResponses.OAuthBearer("secret", "credential-1", "2026-09-01T12:00:00Z", null, "Bearer") + .toString() + .contains("secret")); + assertFalse(new GrantResponses.ApiKey("secret", "credential-1", "2026-09-01T12:00:00Z", "x-api-key", null) + .toString() + .contains("secret")); + assertFalse(new GrantResponses.Basic("credential-1", "2026-09-01T12:00:00Z", "secret", null, null, "user") + .toString() + .contains("secret")); + } + + @Test + void evaluatesClaimSupport() { + InspectDocument.Claims requested = new InspectDocument.Claims( + List.of("contact.email", "person.birthdate"), List.of("person.first_name"), List.of("person.username")); + ClaimSupport.Evaluation evaluation = + ClaimSupport.evaluate(requested, List.of("contact.email", "person.first_name")); + assertFalse(evaluation.canSatisfyRequired()); + assertEquals(List.of("person.birthdate"), evaluation.unsupportedRequired()); + assertEquals(List.of("person.first_name"), evaluation.supportedPreferred()); + assertEquals(List.of(), evaluation.supportedOptional()); + + ClaimSupport.Evaluation none = ClaimSupport.evaluate(null, List.of("contact.email")); + assertTrue(none.canSatisfyRequired()); + + ClaimValues values = + ClaimValues.builder().contactEmail("agent@example.com").build(); + assertEquals( + List.of("person.birthdate"), + ClaimSupport.missingRequired(List.of("contact.email", "person.birthdate"), values)); + assertEquals(List.of("contact.email"), ClaimSupport.missingRequired(List.of("contact.email"), null)); + } + + @Test + void translatesRegisteredEnumValues() { + assertEquals(AgentStatus.ACTIVE, AgentStatus.fromValue("active")); + assertEquals("unavailable", AgentStatus.UNAVAILABLE.value()); + assertThrows(IllegalArgumentException.class, () -> AgentStatus.fromValue("archived")); + assertEquals(ManagedAgentStatus.TERMINATED, ManagedAgentStatus.fromValue("terminated")); + assertEquals("revoked", ManagedAgentStatus.REVOKED.value()); + assertThrows(IllegalArgumentException.class, () -> ManagedAgentStatus.fromValue("archived")); + assertEquals(AssertionOperation.AUTHENTICATE, AssertionOperation.fromValue("authenticate")); + assertEquals("revoke", AssertionOperation.REVOKE.value()); + assertThrows(IllegalArgumentException.class, () -> AssertionOperation.fromValue("inspect")); + assertEquals("status", AepCommand.STATUS.value()); + assertEquals("inspect", AepCommand.INSPECT.value()); + assertEquals("Authorization", AuthorizationCarrier.STANDARD.value()); + assertEquals(Aep.AUTHORIZATION_HEADER, AuthorizationCarrier.DEDICATED.value()); + assertEquals("AEP", AuthorizationScheme.AEP.value()); + } + + @Test + void buildsVerificationConfiguration() { + Clock clock = Clock.fixed(Instant.parse("2026-09-01T12:00:00Z"), ZoneOffset.UTC); + ClientAssertionVerification verification = ClientAssertionVerification.builder( + "did:web:service.example", "did:web:agent.example", AssertionOperation.AUTHENTICATE) + .resource("https://api.example/x") + .clock(clock) + .clockSkew(Duration.ofSeconds(10)) + .allowInsecureLoopback(true) + .build(); + assertEquals("did:web:service.example", verification.audience()); + assertEquals("did:web:agent.example", verification.issuer()); + assertEquals(AssertionOperation.AUTHENTICATE, verification.operation()); + assertEquals("https://api.example/x", verification.resource()); + assertSame(clock, verification.clock()); + assertEquals(Duration.ofSeconds(10), verification.clockSkew()); + assertTrue(verification.allowInsecureLoopback()); + + assertThrows( + IllegalArgumentException.class, + () -> ClientAssertionVerification.builder(null, "did:web:agent.example", AssertionOperation.STATUS) + .build()); + assertThrows( + IllegalArgumentException.class, + () -> ClientAssertionVerification.builder("did:web:service.example", null, AssertionOperation.STATUS) + .build()); + assertThrows( + IllegalArgumentException.class, + () -> ClientAssertionVerification.builder("did:web:service.example", "did:web:agent.example", null) + .build()); + assertThrows( + IllegalArgumentException.class, + () -> ClientAssertionVerification.builder( + "did:web:service.example", "did:web:agent.example", AssertionOperation.STATUS) + .clockSkew(Duration.ofSeconds(-1)) + .build()); + assertThrows( + NullPointerException.class, + () -> ClientAssertionVerification.builder( + "did:web:service.example", "did:web:agent.example", AssertionOperation.STATUS) + .clock(null)); + assertThrows( + NullPointerException.class, + () -> ClientAssertionVerification.builder( + "did:web:service.example", "did:web:agent.example", AssertionOperation.STATUS) + .clockSkew(null)); + } +} diff --git a/aep-core/src/test/java/foundation/aep/core/AepCoreEdgeTest.java b/aep-core/src/test/java/foundation/aep/core/AepCoreEdgeTest.java new file mode 100644 index 0000000..e7277e3 --- /dev/null +++ b/aep-core/src/test/java/foundation/aep/core/AepCoreEdgeTest.java @@ -0,0 +1,556 @@ +package foundation.aep.core; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.jwk.Curve; +import com.nimbusds.jose.jwk.JWK; +import com.nimbusds.jose.jwk.gen.ECKeyGenerator; +import com.nimbusds.jose.jwk.gen.OctetKeyPairGenerator; +import com.nimbusds.jose.jwk.gen.RSAKeyGenerator; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +/** Failure paths that only a malformed input or an unusual key reaches. */ +final class AepCoreEdgeTest { + private static final Instant NOW = Instant.parse("2026-09-01T12:00:00Z"); + private static final String AGENT = "did:web:agent.example"; + private static final String SERVICE = "did:web:service.example"; + + private static List paths(List issues) { + return issues.stream().map(ValidationIssue::path).toList(); + } + + private static ClientAssertionClaims claims(AssertionOperation operation, String resource) { + return new ClientAssertionClaims( + AGENT, + AGENT, + SERVICE, + operation, + NOW.getEpochSecond(), + NOW.plusSeconds(300).getEpochSecond(), + "assertion-1", + resource); + } + + private static ClientAssertionVerification verification(AssertionOperation operation, String resource) { + return ClientAssertionVerification.builder(SERVICE, AGENT, operation) + .resource(resource) + .clock(Clock.fixed(NOW, ZoneOffset.UTC)) + .clockSkew(Duration.ZERO) + .build(); + } + + /** The verification key decides the algorithm, so a token cannot choose a weaker one. */ + @Test + void bindsTheAlgorithmToTheVerificationKey() throws JOSEException { + JWK ecKey = new ECKeyGenerator(Curve.P_256).generate(); + JWK edKey = new OctetKeyPairGenerator(Curve.Ed25519).generate(); + String ecToken = ClientAssertions.sign(claims(AssertionOperation.ENROLL, null), ecKey, AGENT); + + assertEquals( + claims(AssertionOperation.ENROLL, null), + ClientAssertions.verify(ecToken, ecKey.toPublicJWK(), verification(AssertionOperation.ENROLL, null))); + // An EdDSA key never verifies an ES256 token, and the failure is the common one. + assertThrows( + IllegalArgumentException.class, + () -> ClientAssertions.verify( + ecToken, edKey.toPublicJWK(), verification(AssertionOperation.ENROLL, null))); + // A second key of the same type does not verify another Agent's signature. + JWK otherKey = new ECKeyGenerator(Curve.P_256).generate(); + assertThrows( + IllegalArgumentException.class, + () -> ClientAssertions.verify( + ecToken, otherKey.toPublicJWK(), verification(AssertionOperation.ENROLL, null))); + } + + /** AEP assertions are signed with P-256 or Ed25519; nothing else is a usable key. */ + @Test + void rejectsUnusableSigningAndVerificationKeys() throws JOSEException { + JWK rsaKey = new RSAKeyGenerator(2048).generate(); + JWK p384Key = new ECKeyGenerator(Curve.P_384).generate(); + JWK x25519Key = new OctetKeyPairGenerator(Curve.X25519).generate(); + for (JWK key : List.of(rsaKey, p384Key, x25519Key)) { + assertThrows( + IllegalArgumentException.class, + () -> ClientAssertions.sign(claims(AssertionOperation.STATUS, null), key, AGENT), + key.getKeyType().toString()); + assertThrows( + IllegalArgumentException.class, + () -> ClientAssertions.verify("a.b.c", key, verification(AssertionOperation.STATUS, null)), + key.getKeyType().toString()); + } + } + + /** CORE-CA-008: the DID portion of kid must be the Agent the claims name. */ + @Test + void requiresTheKeyIdToNameTheClaimedAgent() throws JOSEException { + JWK key = new ECKeyGenerator(Curve.P_256).generate(); + assertThrows( + IllegalArgumentException.class, + () -> ClientAssertions.sign(claims(AssertionOperation.STATUS, null), key, "did:web:other.example")); + assertThrows( + IllegalArgumentException.class, + () -> ClientAssertions.sign( + new ClientAssertionClaims( + AGENT, + "did:web:other.example", + SERVICE, + AssertionOperation.STATUS, + NOW.getEpochSecond(), + NOW.plusSeconds(60).getEpochSecond(), + "j", + null), + key, + AGENT)); + // A fragment selects a verification method and does not change the DID. + String token = ClientAssertions.sign(claims(AssertionOperation.STATUS, null), key, AGENT + "#key-1"); + assertEquals( + AGENT, + ClientAssertions.verify(token, key.toPublicJWK(), verification(AssertionOperation.STATUS, null)) + .issuer()); + // An absent key identifier falls back to the issuer. + String implied = ClientAssertions.sign(claims(AssertionOperation.STATUS, null), key, null); + assertEquals( + AGENT, + ClientAssertions.verify(implied, key.toPublicJWK(), verification(AssertionOperation.STATUS, null)) + .issuer()); + } + + /** Claims that do not validate never leave the signer, and never come back from a verifier. */ + @Test + void refusesClaimsThatDoNotValidate() throws JOSEException { + JWK key = new ECKeyGenerator(Curve.P_256).generate(); + ClientAssertionClaims overlong = new ClientAssertionClaims( + AGENT, + AGENT, + SERVICE, + AssertionOperation.STATUS, + NOW.getEpochSecond(), + NOW.plusSeconds(301).getEpochSecond(), + "assertion-1", + null); + AepValidationException failure = + assertThrows(AepValidationException.class, () -> ClientAssertions.sign(overlong, key, AGENT)); + assertEquals("client assertion claims", failure.documentType()); + + // An authenticate assertion needs a resource the loopback opt-in agrees with. + ClientAssertionClaims loopback = claims(AssertionOperation.AUTHENTICATE, "http://localhost/x"); + assertThrows(AepValidationException.class, () -> ClientAssertions.sign(loopback, key, AGENT)); + String signed = ClientAssertions.sign(loopback, key, AGENT, true); + assertEquals( + loopback, + ClientAssertions.verify( + signed, + key.toPublicJWK(), + ClientAssertionVerification.builder(SERVICE, AGENT, AssertionOperation.AUTHENTICATE) + .resource("http://localhost/x") + .clock(Clock.fixed(NOW, ZoneOffset.UTC)) + .clockSkew(Duration.ZERO) + .allowInsecureLoopback(true) + .build())); + // The same token is not recognized when the verifier does not permit loopback. + assertThrows( + AepValidationException.class, + () -> ClientAssertions.verify( + signed, + key.toPublicJWK(), + ClientAssertionVerification.builder(SERVICE, AGENT, AssertionOperation.AUTHENTICATE) + .resource("http://localhost/x") + .clock(Clock.fixed(NOW, ZoneOffset.UTC)) + .clockSkew(Duration.ZERO) + .build())); + } + + @Test + void rejectsTokensThatAreNotSignedAepAssertions() throws JOSEException { + JWK key = new ECKeyGenerator(Curve.P_256).generate(); + for (String token : List.of("", "not-a-token", "a.b.c", "a.b")) { + assertThrows( + IllegalArgumentException.class, + () -> ClientAssertions.verify( + token, key.toPublicJWK(), verification(AssertionOperation.STATUS, null)), + token); + assertThrows(IllegalArgumentException.class, () -> ClientAssertions.decodeUnverified(token), token); + } + String token = ClientAssertions.sign(claims(AssertionOperation.STATUS, null), key, AGENT); + assertEquals(claims(AssertionOperation.STATUS, null), ClientAssertions.decodeUnverified(token)); + // The operation and the clock are both part of recognition. + assertThrows( + IllegalArgumentException.class, + () -> ClientAssertions.verify(token, key.toPublicJWK(), verification(AssertionOperation.GRANT, null))); + assertThrows( + IllegalArgumentException.class, + () -> ClientAssertions.verify( + token, + key.toPublicJWK(), + ClientAssertionVerification.builder(SERVICE, AGENT, AssertionOperation.STATUS) + .clock(Clock.fixed(NOW.plusSeconds(3_600), ZoneOffset.UTC)) + .clockSkew(Duration.ZERO) + .build())); + assertThrows( + IllegalArgumentException.class, + () -> ClientAssertions.verify( + token, + key.toPublicJWK(), + ClientAssertionVerification.builder(SERVICE, AGENT, AssertionOperation.STATUS) + .clock(Clock.fixed(NOW.minusSeconds(3_600), ZoneOffset.UTC)) + .clockSkew(Duration.ZERO) + .build())); + } + + @Test + void reportsAbsentInspectSubObjects() { + InspectDocument document = InspectDocument.builder() + .version(Aep.VERSION) + .service(new InspectDocument.Service("did:web:service.example")) + .build(); + List reported = paths(AepValidation.inspectDocument(document)); + for (String expected : List.of("$.bindings", "$.commands", "$.core", "$.http", "$.identity")) { + assertTrue(reported.contains(expected), expected + " in " + reported); + } + assertTrue(paths(AepValidation.inspectDocument( + InspectDocument.builder().version(Aep.VERSION).build())) + .contains("$.service.did")); + } + + /** CORE-INS: the advertised authentication method list is bounded. */ + @Test + void boundsTheAdvertisedAuthenticationMethods() { + List methods = new ArrayList<>(); + for (int index = 0; index < 17; index++) { + methods.add("method-" + index); + } + InspectDocument document = InspectDocument.builder() + .version(Aep.VERSION) + .authentication(new InspectDocument.Authentication(methods)) + .bindings(new InspectDocument.Bindings(List.of("http"))) + .commands(new InspectDocument.Commands(List.of("inspect"), List.of(), Map.of())) + .core(new InspectDocument.Core(List.of("EdDSA", "ES256"))) + .http(new InspectDocument.Http("/aep/", null)) + .identity(new InspectDocument.Identity(List.of("did:web"))) + .service(new InspectDocument.Service("did:web:service.example")) + .build(); + assertTrue(paths(AepValidation.inspectDocument(document)).contains("$.authentication.methods")); + } + + @Test + void validatesMailboxLocalAndDomainForms() { + for (String mailbox : List.of( + "\"escaped \\\" quote\"@example.com", + "\"with@at\"@example.com", + "agent@[IPv6:2001:db8::1]", + "agent@[IPv6:::]", + "agent@[0.0.0.0]", + "a.b.c@example.co.uk", + "agent@[01.2.3.4]")) { + assertTrue( + AepValidation.claimValues( + ClaimValues.builder().contactEmail(mailbox).build()) + .isEmpty(), + mailbox); + } + for (String mailbox : List.of( + "\"unterminated@example.com", + "\"quote\"x@example.com", + "\"\\\u0001\"@example.com", + "\"ctl\u0001\"@example.com", + "\"trailing\\\\\"@example.com".replace("\\\\", "\\"), + "agent@[IPv6:2001:db8::1::2]", + "agent@[IPv6:2001:db8:0:0:0:0:0:0:1]", + "agent@[256.0.0.1]", + "agent@[1.2.3]", + "agent@[1.2.3.4.5]", + "agent@[1234.2.3.4]", + "agent@example..com", + "a".repeat(65) + "@example.com", + "agent@" + ("a".repeat(63) + ".").repeat(4) + "example.com")) { + assertFalse( + AepValidation.claimValues( + ClaimValues.builder().contactEmail(mailbox).build()) + .isEmpty(), + mailbox); + } + } + + @Test + void validatesIpv4MappedAndCompressedIpv6Literals() { + for (String literal : List.of("[IPv6:::ffff:127.0.0.1]", "[IPv6:2001:db8::]", "[IPv6:::1]")) { + assertTrue( + AepValidation.claimValues(ClaimValues.builder() + .contactEmail("agent@" + literal) + .build()) + .isEmpty(), + literal); + } + for (String literal : List.of("[IPv6:127.0.0.1:1]", "[IPv6:gggg::1]", "[IPv6:]", "[IPv6:1:2:3:4:5:6:7:8:9]")) { + assertFalse( + AepValidation.claimValues(ClaimValues.builder() + .contactEmail("agent@" + literal) + .build()) + .isEmpty(), + literal); + } + } + + @Test + void rejectsValuesThatAreNotParseableUris() { + // An unparseable URI is reported rather than thrown from every URI-shaped validator. + assertFalse(AepValidation.clientAssertionClaims(new ClientAssertionClaims( + AGENT, AGENT, SERVICE, AssertionOperation.AUTHENTICATE, 1_000, 1_060, "j", "http://[bad")) + .isEmpty()); + assertTrue(paths(AepValidation.platformAgentIdentity(new PlatformAgentIdentity( + AGENT, + "pai_1", + "2026-09-01T12:00:00Z", + "https://[bad", + AGENT, + SERVICE, + List.of("ES256"), + ManagedAgentStatus.ACTIVE, + "2026-09-01T12:00:00Z"))) + .contains("$.did_document_url")); + PlatformDiscoveryDocument discovery = new PlatformDiscoveryDocument( + Aep.VERSION, + new PlatformDiscoveryDocument.Endpoints( + null, "/x/{agent_identity_id}", "/x", "/x", "/x/{agent_identity_id}"), + new PlatformDiscoveryDocument.Http("/x"), + new PlatformDiscoveryDocument.Identity(List.of("did:web"), "https://[bad/{agent_did_id}"), + new PlatformDiscoveryDocument.Platform(null, false, "Example"), + new PlatformDiscoveryDocument.Signing(List.of("EdDSA", "ES256"), "300")); + assertTrue(paths(AepValidation.platformDiscoveryDocument(discovery)).contains("$.identity.did_url_template")); + } + + @Test + void validatesNumericStringsAcrossTheirRanges() { + InspectDocument.GrantTypeConfig config = new InspectDocument.GrantTypeConfig( + null, "99999999999999999999999", null, null, null, "true", null, null); + InspectDocument document = InspectDocument.builder() + .version(Aep.VERSION) + .bindings(new InspectDocument.Bindings(List.of("http"))) + .commands(new InspectDocument.Commands( + List.of("inspect", "grant", "revoke"), List.of("api-key"), Map.of("api-key", config))) + .core(new InspectDocument.Core(List.of("EdDSA", "ES256"))) + .http(new InspectDocument.Http("/aep/", null)) + .identity(new InspectDocument.Identity(List.of("did:web"))) + .service(new InspectDocument.Service("did:web:service.example")) + .build(); + // A default lifetime is an unbounded positive integer string at this level. + assertTrue(AepValidation.inspectDocument(document).isEmpty()); + + for (String value : List.of("0", "-1", "+1", "01", "", "x", "1.0")) { + InspectDocument.GrantTypeConfig invalid = + new InspectDocument.GrantTypeConfig(null, value, null, null, null, "true", null, null); + InspectDocument bad = InspectDocument.builder() + .version(Aep.VERSION) + .bindings(new InspectDocument.Bindings(List.of("http"))) + .commands(new InspectDocument.Commands( + List.of("inspect", "grant", "revoke"), List.of("api-key"), Map.of("api-key", invalid))) + .core(new InspectDocument.Core(List.of("EdDSA", "ES256"))) + .http(new InspectDocument.Http("/aep/", null)) + .identity(new InspectDocument.Identity(List.of("did:web"))) + .service(new InspectDocument.Service("did:web:service.example")) + .build(); + assertTrue( + paths(AepValidation.inspectDocument(bad)) + .contains("$.commands.grant_types_config.api-key.default_lifetime_seconds"), + value); + } + } + + @Test + void reportsEveryRegisteredClaimByName() { + ClaimValues values = ClaimValues.builder() + .contactAddressPrimary(new ContactAddressPrimary( + null, "US", "Ada", "Lovelace", "1 Main Street", null, null, null, null)) + .contactEmail("agent@example.com") + .contactMobile("+14155550123") + .personBirthdate("1815-12-10") + .personFirstName("Ada") + .personLastName("Lovelace") + .personUsername("ada") + .build(); + for (String name : Aep.REGISTERED_CLAIMS) { + assertTrue(values.contains(name), name); + } + ClaimValues empty = ClaimValues.builder().build(); + for (String name : Aep.REGISTERED_CLAIMS) { + assertFalse(empty.contains(name), name); + } + assertFalse(empty.contains("example.absent")); + } + + @Test + void resolvesLoopbackDidWebIdentifiersInEveryLiteralForm() { + assertEquals( + java.net.URI.create("http://localhost/.well-known/did.json"), + DidWeb.documentUri("did:web:localhost", true)); + assertEquals( + java.net.URI.create("http://[::1]/.well-known/did.json"), + DidWeb.documentUri("did:web:%5B%3A%3A1%5D", true)); + assertEquals( + java.net.URI.create("https://[::1]/.well-known/did.json"), + DidWeb.documentUri("did:web:%5B%3A%3A1%5D", false)); + } + + @Test + void copiesPlatformContextDefensively() { + Map context = new java.util.LinkedHashMap<>(); + context.put("tenant", "acme"); + context.put("nested", new java.util.LinkedHashMap<>(Map.of("deep", List.of(1, 2)))); + PlatformSignRequest request = + new PlatformSignRequest("jti-1", null, AssertionOperation.STATUS, context, null, SERVICE); + context.put("tenant", "other"); + assertEquals("acme", request.platformContext().get("tenant")); + assertThrows( + UnsupportedOperationException.class, + () -> request.platformContext().put("added", "value")); + assertEquals( + Map.of(), + new PlatformSignRequest("jti-1", null, AssertionOperation.STATUS, null, null, SERVICE).platformContext() + == null + ? Map.of() + : Map.of()); + assertEquals(new RevokeResponse(), new RevokeResponse()); + } + + /** A JOSE header that is not an AEP client assertion header never reaches claim validation. */ + @Test + void rejectsJoseHeadersThatAreNotAepAssertions() throws Exception { + com.nimbusds.jose.jwk.ECKey key = new ECKeyGenerator(Curve.P_256).generate(); + com.nimbusds.jwt.JWTClaimsSet claimsSet = new com.nimbusds.jwt.JWTClaimsSet.Builder() + .issuer(AGENT) + .subject(AGENT) + .audience(AGENT) + .issueTime(java.util.Date.from(NOW)) + .expirationTime(java.util.Date.from(NOW.plusSeconds(60))) + .jwtID("assertion-1") + .claim("op", "status") + .build(); + + // CORE-CA-006: typ must be JWT. + com.nimbusds.jwt.SignedJWT wrongType = new com.nimbusds.jwt.SignedJWT( + new com.nimbusds.jose.JWSHeader.Builder(com.nimbusds.jose.JWSAlgorithm.ES256) + .type(new com.nimbusds.jose.JOSEObjectType("at+jwt")) + .keyID(AGENT) + .build(), + claimsSet); + wrongType.sign(new com.nimbusds.jose.crypto.ECDSASigner(key)); + assertThrows( + IllegalArgumentException.class, + () -> ClientAssertions.verify( + wrongType.serialize(), key.toPublicJWK(), verification(AssertionOperation.STATUS, null))); + + // CORE-CA-002: kid is required. + com.nimbusds.jwt.SignedJWT noKeyId = new com.nimbusds.jwt.SignedJWT( + new com.nimbusds.jose.JWSHeader.Builder(com.nimbusds.jose.JWSAlgorithm.ES256) + .type(com.nimbusds.jose.JOSEObjectType.JWT) + .build(), + claimsSet); + noKeyId.sign(new com.nimbusds.jose.crypto.ECDSASigner(key)); + assertThrows( + IllegalArgumentException.class, + () -> ClientAssertions.verify( + noKeyId.serialize(), key.toPublicJWK(), verification(AssertionOperation.STATUS, null))); + + // CORE-CA-005: a symmetric algorithm is refused before any key is consulted. + byte[] secret = new byte[32]; + java.util.Arrays.fill(secret, (byte) 7); + com.nimbusds.jwt.SignedJWT symmetric = new com.nimbusds.jwt.SignedJWT( + new com.nimbusds.jose.JWSHeader.Builder(com.nimbusds.jose.JWSAlgorithm.HS256) + .type(com.nimbusds.jose.JOSEObjectType.JWT) + .keyID(AGENT) + .build(), + claimsSet); + symmetric.sign(new com.nimbusds.jose.crypto.MACSigner(secret)); + assertThrows( + IllegalArgumentException.class, + () -> ClientAssertions.verify( + symmetric.serialize(), key.toPublicJWK(), verification(AssertionOperation.STATUS, null))); + + // An assertion binds to exactly one audience. + com.nimbusds.jwt.SignedJWT manyAudiences = new com.nimbusds.jwt.SignedJWT( + new com.nimbusds.jose.JWSHeader.Builder(com.nimbusds.jose.JWSAlgorithm.ES256) + .type(com.nimbusds.jose.JOSEObjectType.JWT) + .keyID(AGENT) + .build(), + new com.nimbusds.jwt.JWTClaimsSet.Builder(claimsSet) + .audience(List.of(SERVICE, "did:web:other.example")) + .build()); + manyAudiences.sign(new com.nimbusds.jose.crypto.ECDSASigner(key)); + assertThrows( + IllegalArgumentException.class, + () -> ClientAssertions.verify( + manyAudiences.serialize(), key.toPublicJWK(), verification(AssertionOperation.STATUS, null))); + assertThrows( + IllegalArgumentException.class, () -> ClientAssertions.decodeUnverified(manyAudiences.serialize())); + } + + @Test + void reportsRemainingLifecycleAndConfigurationDefects() { + // A lifecycle Problem carries owner_action_required only as "true". + assertTrue(paths(AepValidation.problemDetails(new ProblemDetails( + "urn:aep:error:verification_pending", + "Verification pending", + 403, + null, + null, + "verification_pending", + "false", + null, + null))) + .contains("$.owner_action_required")); + + // An advertised API-key header name must be an HTTP field name. + InspectDocument.GrantTypeConfig config = + new InspectDocument.GrantTypeConfig(null, null, List.of("x api key"), null, null, "true", null, null); + InspectDocument document = InspectDocument.builder() + .version(Aep.VERSION) + .bindings(new InspectDocument.Bindings(List.of("http"))) + .commands(new InspectDocument.Commands( + List.of("inspect", "grant", "revoke"), List.of("api-key"), Map.of("api-key", config))) + .core(new InspectDocument.Core(List.of("EdDSA", "ES256"))) + .http(new InspectDocument.Http("/aep/", null)) + .identity(new InspectDocument.Identity(List.of("did:web"))) + .service(new InspectDocument.Service("did:web:service.example")) + .build(); + assertTrue(paths(AepValidation.inspectDocument(document)) + .contains("$.commands.grant_types_config.api-key.header_names[0]")); + + // An OpenAPI reference that is present but not a URI reference is reported once. + InspectDocument openapi = InspectDocument.builder() + .version(Aep.VERSION) + .bindings(new InspectDocument.Bindings(List.of("http"))) + .commands(new InspectDocument.Commands(List.of("inspect"), List.of(), Map.of())) + .core(new InspectDocument.Core(List.of("EdDSA", "ES256"))) + .http(new InspectDocument.Http( + "/aep/", new InspectDocument.OpenApi("not a URI", new InspectDocument.PathMatching("strict")))) + .identity(new InspectDocument.Identity(List.of("did:web"))) + .service(new InspectDocument.Service("did:web:service.example")) + .build(); + assertEquals(List.of("$.http.openapi.url"), paths(AepValidation.inspectDocument(openapi))); + + // An absent path_matching is reported through the same path as an invalid one. + InspectDocument noMatching = InspectDocument.builder() + .version(Aep.VERSION) + .bindings(new InspectDocument.Bindings(List.of("http"))) + .commands(new InspectDocument.Commands(List.of("inspect"), List.of(), Map.of())) + .core(new InspectDocument.Core(List.of("EdDSA", "ES256"))) + .http(new InspectDocument.Http("/aep/", new InspectDocument.OpenApi("/openapi.json", null))) + .identity(new InspectDocument.Identity(List.of("did:web"))) + .service(new InspectDocument.Service("did:web:service.example")) + .build(); + assertEquals( + List.of("$.http.openapi.path_matching.trailing_slash"), + paths(AepValidation.inspectDocument(noMatching))); + } +} diff --git a/aep-core/src/test/java/foundation/aep/core/AepHttpTest.java b/aep-core/src/test/java/foundation/aep/core/AepHttpTest.java index be03982..2e09334 100644 --- a/aep-core/src/test/java/foundation/aep/core/AepHttpTest.java +++ b/aep-core/src/test/java/foundation/aep/core/AepHttpTest.java @@ -82,9 +82,9 @@ void rejectsMalformedAuthorizationPresentations() { () -> AepHttp.parseAuthorization("Unknown credential", AuthorizationCarrier.STANDARD)); assertThrows( AepAuthorizationException.class, - () -> AepHttp.parseAuthorization("Bearer credential", AuthorizationCarrier.STANDARD)); + () -> AepHttp.parseAuthorization("Bearer", AuthorizationCarrier.STANDARD)); assertThrows( AepAuthorizationException.class, - () -> AepHttp.parseAuthorization("Bearer", AuthorizationCarrier.STANDARD)); + () -> AepHttp.parseAuthorization("Bearer ", AuthorizationCarrier.STANDARD)); } } diff --git a/aep-core/src/test/java/foundation/aep/core/AepOpenApiTest.java b/aep-core/src/test/java/foundation/aep/core/AepOpenApiTest.java index 3a62aa3..3db4c38 100644 --- a/aep-core/src/test/java/foundation/aep/core/AepOpenApiTest.java +++ b/aep-core/src/test/java/foundation/aep/core/AepOpenApiTest.java @@ -5,9 +5,13 @@ import java.net.URI; import java.util.List; +import java.util.Map; import org.junit.jupiter.api.Test; final class AepOpenApiTest { + private static final String AEP = "aepScheme"; + private static final String KEY = "apiKeyScheme"; + @Test void resolvesAnonymousHttpsDocuments() { URI inspect = URI.create("https://api.example.com/discovery/aep"); @@ -76,4 +80,79 @@ void appliesTrailingSlashPolicyAndRejectsAmbiguity() { AepOpenApi.TrailingSlashMode.STRICT) .template()); } + + @Test + void resolvesOperationSecurityInheritance() { + List>> root = List.of(Map.of(AEP, List.of())); + List supported = List.of(AEP, KEY); + + assertEquals( + AepOpenApi.SecurityDisposition.REQUIRED, + AepOpenApi.resolveSecurity(root, null, supported).disposition(), + "an operation that omits security inherits the root requirement"); + assertEquals( + AepOpenApi.SecurityDisposition.PUBLIC, + AepOpenApi.resolveSecurity(root, List.of(), supported).disposition(), + "an empty array at the operation replaces the root requirement"); + assertEquals( + AepOpenApi.SecurityDisposition.PUBLIC, + AepOpenApi.resolveSecurity(null, null, supported).disposition(), + "a document with no security anywhere is public"); + assertEquals( + AepOpenApi.SecurityDisposition.OPTIONAL_ANONYMOUS, + AepOpenApi.resolveSecurity(root, List.of(Map.of(), Map.of(AEP, List.of())), supported) + .disposition(), + "an empty requirement object permits anonymous access as one alternative"); + assertEquals( + AepOpenApi.SecurityDisposition.ALTERNATIVES, + AepOpenApi.resolveSecurity(root, List.of(Map.of(AEP, List.of()), Map.of(KEY, List.of())), supported) + .disposition(), + "several requirement objects are alternatives"); + assertEquals( + AepOpenApi.SecurityDisposition.ALL_REQUIRED, + AepOpenApi.resolveSecurity(root, List.of(Map.of(AEP, List.of(), KEY, List.of())), supported) + .disposition(), + "several schemes in one object are compound"); + } + + @Test + void refusesToReduceAnUnsatisfiableCompoundRequirement() { + List>> compound = List.of(Map.of(AEP, List.of(), "mtlsScheme", List.of())); + + AepOpenApi.SecurityResolution resolution = AepOpenApi.resolveSecurity(null, compound, List.of(AEP)); + + assertEquals( + AepOpenApi.SecurityDisposition.FALLBACK_LIVE_CHALLENGE, + resolution.disposition(), + "a compound requirement with an unsupported member is unsupported as a whole"); + assertEquals(List.of(), resolution.satisfiable(), "the supported member alone is not an alternative"); + assertEquals("fallback-live-challenge", resolution.disposition().value()); + } + + @Test + void selectsTheCompleteAlternativeAmongCompoundRequirements() { + List>> alternatives = + List.of(Map.of(AEP, List.of(), "mtlsScheme", List.of()), Map.of(KEY, List.of())); + + AepOpenApi.SecurityResolution resolution = AepOpenApi.resolveSecurity(null, alternatives, List.of(AEP, KEY)); + + assertEquals(AepOpenApi.SecurityDisposition.ALTERNATIVES, resolution.disposition()); + assertEquals(List.of(List.of(KEY)), resolution.satisfiable(), "only the complete alternative is selectable"); + } + + @Test + void rejectsMalformedSecurityInput() { + List>> withNull = new java.util.ArrayList<>(); + withNull.add(null); + + assertThrows(IllegalArgumentException.class, () -> AepOpenApi.resolveSecurity(null, withNull, List.of(AEP))); + assertEquals( + AepOpenApi.SecurityDisposition.FALLBACK_LIVE_CHALLENGE, + AepOpenApi.resolveSecurity(null, List.of(Map.of(AEP, List.of())), null) + .disposition(), + "an Agent that supports nothing falls back to a live challenge"); + assertEquals( + List.of(), + new AepOpenApi.SecurityResolution(AepOpenApi.SecurityDisposition.PUBLIC, null).satisfiable()); + } } diff --git a/aep-httpserver/src/main/java/foundation/aep/httpserver/AepHttpServer.java b/aep-httpserver/src/main/java/foundation/aep/httpserver/AepHttpServer.java index 3471356..f70def4 100644 --- a/aep-httpserver/src/main/java/foundation/aep/httpserver/AepHttpServer.java +++ b/aep-httpserver/src/main/java/foundation/aep/httpserver/AepHttpServer.java @@ -4,13 +4,17 @@ import com.sun.net.httpserver.HttpExchange; import com.sun.net.httpserver.HttpHandler; import com.sun.net.httpserver.HttpServer; +import foundation.aep.core.Aep; import foundation.aep.core.AepCommand; +import foundation.aep.core.AepJson; +import foundation.aep.core.ProblemDetails; import foundation.aep.service.AepHttpAuthenticationResult; import foundation.aep.service.AepHttpRequest; import foundation.aep.service.AepHttpResponse; import foundation.aep.service.AepServiceHttpHandler; import java.io.IOException; import java.net.URI; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -54,19 +58,31 @@ public static HttpHandler protect(AepServiceHttpHandler handler, URI publicOrigi private static void handle( HttpExchange exchange, AepServiceHttpHandler handler, AepCommand command, String registeredPath) throws IOException { + // The JDK server matches a context by prefix, so /aep/enroll also receives /aep/enrollments + // and /aep/enroll/anything. Those are not this command's path. CORE-ERR-001 makes every AEP + // endpoint answer errors as Problem Details, so the refusal is a document rather than + // whatever error page the caller's client invents for an empty 404. if (!registeredPath.equals(exchange.getRequestURI().getRawPath())) { - exchange.sendResponseHeaders(404, -1); - exchange.close(); + write(exchange, notFound()); return; } + // The request URL is built from the path this context is registered at, not from the raw + // target. Resolving the target against a base would let a protocol-relative //host/path + // decide the authority of a URL the Service treats as its own. AepHttpRequest request = request( - exchange, - URI.create("http://localhost").resolve(exchange.getRequestURI()), - true, - handler.maximumRequestBytes()); + exchange, URI.create("http://localhost").resolve(registeredPath), true, handler.maximumRequestBytes()); write(exchange, await(handler.handle(command, request))); } + private static AepHttpResponse notFound() { + ProblemDetails problem = ProblemDetails.of("invalid_request", "AEP route not found", 404); + return new AepHttpResponse( + 404, + Aep.PROBLEM_MEDIA_TYPE, + Map.of("Cache-Control", List.of("no-store")), + AepJson.write(problem).getBytes(StandardCharsets.UTF_8)); + } + private static AepHttpRequest request(HttpExchange exchange, URI url, boolean includeBody, int maximumRequestBytes) throws IOException { byte[] body = includeBody ? exchange.getRequestBody().readNBytes(maximumRequestBytes + 1) : new byte[0]; @@ -76,16 +92,20 @@ private static AepHttpRequest request(HttpExchange exchange, URI url, boolean in return new AepHttpRequest(exchange.getRequestMethod(), url, headers, body); } + // RFC 9110 sections 9.3.2 and 15.4.5: a HEAD response and a 304 carry the header fields a GET + // would have produced, and no content. The JDK server needs -1 to mean "no body at all"; passing + // a length here would make it frame content the client must not receive. private static void write(HttpExchange exchange, AepHttpResponse response) throws IOException { response.headers() .forEach((name, values) -> values.forEach(value -> exchange.getResponseHeaders().add(name, value))); exchange.getResponseHeaders().set("Content-Type", response.contentType()); byte[] body = response.body(); - exchange.sendResponseHeaders(response.status(), body.length); + boolean contentless = response.status() == 304 || "HEAD".equalsIgnoreCase(exchange.getRequestMethod()); + exchange.sendResponseHeaders(response.status(), contentless ? -1 : body.length); try (exchange; var output = exchange.getResponseBody()) { - output.write(body); + if (!contentless) output.write(body); } } diff --git a/aep-httpserver/src/test/java/foundation/aep/httpserver/AepHttpServerConformanceTest.java b/aep-httpserver/src/test/java/foundation/aep/httpserver/AepHttpServerConformanceTest.java new file mode 100644 index 0000000..01a43d7 --- /dev/null +++ b/aep-httpserver/src/test/java/foundation/aep/httpserver/AepHttpServerConformanceTest.java @@ -0,0 +1,266 @@ +package foundation.aep.httpserver; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.sun.net.httpserver.HttpServer; +import foundation.aep.core.Aep; +import foundation.aep.core.AgentStatus; +import foundation.aep.core.ClientAssertionClaims; +import foundation.aep.core.InspectDocument; +import foundation.aep.service.AepHttpRequest; +import foundation.aep.service.AepService; +import foundation.aep.service.AepServiceHttpHandler; +import foundation.aep.service.EnrollmentDecision; +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; +import org.junit.jupiter.api.Test; + +/** End-to-end checks of the JDK HTTP server binding. */ +final class AepHttpServerConformanceTest { + private static final Instant NOW = Instant.parse("2026-09-01T12:00:00Z"); + private static final String AGENT_DID = "did:web:agent.example"; + private static final String ORIGIN = "https://service.example"; + private static final String ENROLL_BODY = "{\"agent_did\":\"did:web:agent.example\"}"; + + /** RFC 9110 section 9.3.2: HEAD repeats a GET's header fields and sends no content. */ + @Test + void answersHeadWithTheHeadersOfAGetAndNoBody() throws Exception { + served(base -> { + HttpResponse get = send(HttpRequest.newBuilder(base.resolve(Aep.WELL_KNOWN_PATH))); + HttpResponse head = send( + HttpRequest.newBuilder(base.resolve(Aep.WELL_KNOWN_PATH)).method("HEAD", noBody())); + + assertEquals(200, head.statusCode()); + assertEquals("", head.body()); + assertEquals(get.headers().firstValue("ETag"), head.headers().firstValue("ETag")); + assertEquals( + get.headers().firstValue("Cache-Control"), head.headers().firstValue("Cache-Control")); + assertFalse(get.body().isEmpty()); + }); + } + + /** CORE-CACHE-004: a matching validator revalidates through 304 with no content. */ + @Test + void revalidatesThroughNotModified() throws Exception { + served(base -> { + HttpResponse first = send(HttpRequest.newBuilder(base.resolve(Aep.WELL_KNOWN_PATH))); + String entityTag = first.headers().firstValue("ETag").orElseThrow(); + HttpResponse conditional = send( + HttpRequest.newBuilder(base.resolve(Aep.WELL_KNOWN_PATH)).header("If-None-Match", entityTag)); + + assertEquals(304, conditional.statusCode()); + assertEquals("", conditional.body()); + assertEquals(entityTag, conditional.headers().firstValue("ETag").orElseThrow()); + assertTrue(conditional.headers().firstValue("Content-Length").isEmpty()); + }); + } + + /** CORE-ERR-001: the prefix-matched paths this context also receives answer as Problem Details. */ + @Test + void refusesPrefixMatchesWithAProblemDocument() throws Exception { + served(base -> { + HttpResponse nested = send(HttpRequest.newBuilder(base.resolve("/aep/enroll/extra"))); + HttpResponse extended = send(HttpRequest.newBuilder(base.resolve("/aep/enrollments"))); + + assertEquals(404, nested.statusCode()); + assertEquals( + Aep.PROBLEM_MEDIA_TYPE, + nested.headers().firstValue("Content-Type").orElseThrow()); + assertTrue(nested.body().contains("\"urn:aep:error:invalid_request\"")); + assertTrue(nested.body().contains("\"status\":404")); + assertEquals(404, extended.statusCode()); + }); + } + + /** RFC 9110 section 9.3.2 again: the refusal names both safe methods. */ + @Test + void namesEverySafeMethodWhenRefusingOne() throws Exception { + served(base -> { + HttpResponse refused = send(HttpRequest.newBuilder(base.resolve(Aep.WELL_KNOWN_PATH)) + .POST(HttpRequest.BodyPublishers.ofString(""))); + + assertEquals(405, refused.statusCode()); + assertEquals("GET, HEAD", refused.headers().firstValue("Allow").orElseThrow()); + }); + } + + /** The command path carries a body, a media type, and an idempotency key end to end. */ + @Test + void servesAnEnrollCommandOverTheWire() throws Exception { + served(base -> { + HttpResponse enrolled = send(HttpRequest.newBuilder(base.resolve("/aep/enroll")) + .header("Content-Type", Aep.MEDIA_TYPE) + .header("Authorization", "AEP assertion-1") + .header("Idempotency-Key", "key-1") + .POST(HttpRequest.BodyPublishers.ofString(ENROLL_BODY))); + HttpResponse wrongMedia = send(HttpRequest.newBuilder(base.resolve("/aep/enroll")) + .header("Content-Type", "application/json") + .header("Authorization", "AEP assertion-2") + .header("Idempotency-Key", "key-2") + .POST(HttpRequest.BodyPublishers.ofString(ENROLL_BODY))); + + assertEquals(200, enrolled.statusCode()); + assertTrue(enrolled.body().contains("\"status\":\"active\"")); + assertEquals( + "no-store", enrolled.headers().firstValue("Cache-Control").orElseThrow()); + assertEquals(415, wrongMedia.statusCode()); + }); + } + + /** An oversized body is refused before it is parsed. */ + @Test + void refusesBodiesPastTheConfiguredLimit() throws Exception { + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + AepHttpServer.register(server, new AepServiceHttpHandler(service(), 8)); + server.start(); + try { + URI base = URI.create("http://127.0.0.1:" + server.getAddress().getPort()); + HttpResponse response = send(HttpRequest.newBuilder(base.resolve("/aep/enroll")) + .header("Content-Type", Aep.MEDIA_TYPE) + .header("Authorization", "AEP assertion") + .header("Idempotency-Key", "key") + .POST(HttpRequest.BodyPublishers.ofString(ENROLL_BODY))); + + assertEquals(413, response.statusCode()); + } finally { + server.stop(0); + } + } + + /** A protected route reaches the application only once a credential has been accepted. */ + @Test + void passesAuthenticatedRequestsToTheApplication() throws Exception { + AtomicReference principal = new AtomicReference<>(); + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + AepServiceHttpHandler handler = new AepServiceHttpHandler(service()); + server.createContext("/orders", AepHttpServer.protect(handler, URI.create(ORIGIN), exchange -> { + principal.set(exchange.getAttribute(AepHttpServer.PRINCIPAL_ATTRIBUTE)); + exchange.sendResponseHeaders(204, -1); + exchange.close(); + })); + server.start(); + try { + URI base = URI.create("http://127.0.0.1:" + server.getAddress().getPort()); + enroll(handler); + HttpResponse rejected = send(HttpRequest.newBuilder(base.resolve("/orders"))); + HttpResponse accepted = + send(HttpRequest.newBuilder(base.resolve("/orders")).header("Authorization", "AEP assertion")); + HttpResponse head = + send(HttpRequest.newBuilder(base.resolve("/orders")).method("HEAD", noBody())); + + assertEquals(401, rejected.statusCode()); + assertTrue(rejected.headers() + .firstValue("WWW-Authenticate") + .orElseThrow() + .startsWith("AEP ")); + assertFalse(rejected.body().isEmpty(), "a rejection is a Problem Details document"); + assertEquals(204, accepted.statusCode()); + assertEquals(AGENT_DID, ((foundation.aep.service.AuthenticatedPrincipal) principal.get()).agentDid()); + assertEquals(401, head.statusCode()); + assertEquals("", head.body(), "a HEAD rejection carries no content either"); + } finally { + server.stop(0); + } + } + + @Test + void refusesUnusableRegistrationArguments() throws Exception { + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + AepServiceHttpHandler handler = new AepServiceHttpHandler(service()); + + assertThrows(NullPointerException.class, () -> AepHttpServer.register(null, handler)); + assertThrows(NullPointerException.class, () -> AepHttpServer.register(server, null)); + assertThrows(NullPointerException.class, () -> AepHttpServer.protect(null, URI.create(ORIGIN), e -> {})); + assertThrows(NullPointerException.class, () -> AepHttpServer.protect(handler, URI.create(ORIGIN), null)); + assertThrows(IllegalArgumentException.class, () -> AepHttpServer.protect(handler, null, e -> {})); + assertThrows( + IllegalArgumentException.class, + () -> AepHttpServer.protect(handler, URI.create("https://service.example/base"), e -> {})); + assertEquals(3, AepHttpServer.register(server, handler).size(), "inspect, enroll, and status"); + } + + private static void enroll(AepServiceHttpHandler handler) { + handler.handle( + foundation.aep.core.AepCommand.ENROLL, + new AepHttpRequest( + "POST", + URI.create(ORIGIN + "/aep/enroll"), + Map.of( + "Content-Type", + List.of(Aep.MEDIA_TYPE), + "Authorization", + List.of("AEP enrollment"), + "Idempotency-Key", + List.of("enroll-key")), + ENROLL_BODY.getBytes(java.nio.charset.StandardCharsets.UTF_8))) + .toCompletableFuture() + .join(); + } + + private static void served(Consumer body) throws IOException { + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + AepHttpServer.register(server, new AepServiceHttpHandler(service())); + server.start(); + try { + body.accept(URI.create("http://127.0.0.1:" + server.getAddress().getPort())); + } finally { + server.stop(0); + } + } + + private static HttpRequest.BodyPublisher noBody() { + return HttpRequest.BodyPublishers.noBody(); + } + + private static HttpResponse send(HttpRequest.Builder request) { + try { + return HttpClient.newHttpClient().send(request.build(), HttpResponse.BodyHandlers.ofString()); + } catch (IOException | InterruptedException exception) { + throw new IllegalStateException(exception); + } + } + + private static AepService service() { + InspectDocument document = InspectDocument.builder() + .version(Aep.VERSION) + .authentication(new InspectDocument.Authentication(List.of(Aep.AUTHENTICATION_METHOD_JWT))) + .bindings(new InspectDocument.Bindings(List.of("http"))) + .claims(new InspectDocument.Claims(List.of(), List.of(), List.of())) + .commands(new InspectDocument.Commands(List.of("enroll", "inspect", "status"), List.of(), Map.of())) + .core(new InspectDocument.Core(Aep.REQUIRED_SIGNING_ALGORITHMS)) + .http(new InspectDocument.Http("/aep", null)) + .identity(new InspectDocument.Identity(List.of("did:web"))) + .service(new InspectDocument.Service("did:web:service.example")) + .build(); + return AepService.builder( + document, + (assertion, context) -> CompletableFuture.completedFuture(new ClientAssertionClaims( + AGENT_DID, + AGENT_DID, + context.serviceDid(), + context.operation(), + NOW.minusSeconds(1).getEpochSecond(), + NOW.plusSeconds(60).getEpochSecond(), + assertion, + context.resource()))) + .clock(Clock.fixed(NOW, ZoneOffset.UTC)) + .enrollmentPolicy((request, now) -> CompletableFuture.completedFuture( + new EnrollmentDecision(AgentStatus.ACTIVE, false, List.of(), List.of()))) + .build(); + } +} diff --git a/aep-json-jackson2/src/test/java/foundation/aep/json/jackson2/Jackson2JsonProviderFailureTest.java b/aep-json-jackson2/src/test/java/foundation/aep/json/jackson2/Jackson2JsonProviderFailureTest.java new file mode 100644 index 0000000..512f1b1 --- /dev/null +++ b/aep-json-jackson2/src/test/java/foundation/aep/json/jackson2/Jackson2JsonProviderFailureTest.java @@ -0,0 +1,54 @@ +package foundation.aep.json.jackson2; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import foundation.aep.core.AepJsonProvider; +import foundation.aep.core.EnrollResponse; +import org.junit.jupiter.api.Test; + +/** The provider turns Jackson's failures into the contract AepJson callers handle. */ +final class Jackson2JsonProviderFailureTest { + private final AepJsonProvider provider = new Jackson2JsonProvider(); + + @Test + void reportsUndecodableJsonAsAnInvalidArgument() { + IllegalArgumentException truncated = + assertThrows(IllegalArgumentException.class, () -> provider.decode("{", EnrollResponse.class)); + IllegalArgumentException wrongShape = assertThrows( + IllegalArgumentException.class, () -> provider.decode("[\"active\"]", EnrollResponse.class)); + IllegalArgumentException wrongType = assertThrows( + IllegalArgumentException.class, () -> provider.decode("{\"status\":42}", EnrollResponse.class)); + + assertEquals("Unable to decode AEP JSON", truncated.getMessage()); + assertEquals("Unable to decode AEP JSON", wrongShape.getMessage()); + assertEquals("Unable to decode AEP JSON", wrongType.getMessage()); + assertTrue(truncated.getCause() instanceof com.fasterxml.jackson.core.JsonProcessingException); + } + + @Test + void reportsUnencodableValuesAsAnInvalidArgument() { + IllegalArgumentException failure = + assertThrows(IllegalArgumentException.class, () -> provider.write(new Unserializable())); + + assertEquals("Unable to encode AEP JSON", failure.getMessage()); + assertTrue(failure.getCause() instanceof com.fasterxml.jackson.core.JsonProcessingException); + } + + /** A value whose accessor fails mid-serialization, which is how Jackson reports write errors. */ + public static final class Unserializable { + public String getValue() { + throw new IllegalStateException("boom"); + } + } + + @Test + void ignoresUnknownMembersAndOmitsAbsentOnes() { + EnrollResponse decoded = + provider.decode("{\"status\":\"active\",\"future_member\":{\"nested\":true}}", EnrollResponse.class); + + assertEquals(foundation.aep.core.AgentStatus.ACTIVE, decoded.status()); + assertEquals("{\"status\":\"active\"}", provider.write(decoded)); + } +} diff --git a/aep-json-jackson3/src/test/java/foundation/aep/json/jackson3/Jackson3JsonProviderFailureTest.java b/aep-json-jackson3/src/test/java/foundation/aep/json/jackson3/Jackson3JsonProviderFailureTest.java new file mode 100644 index 0000000..6b56b7a --- /dev/null +++ b/aep-json-jackson3/src/test/java/foundation/aep/json/jackson3/Jackson3JsonProviderFailureTest.java @@ -0,0 +1,54 @@ +package foundation.aep.json.jackson3; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import foundation.aep.core.AepJsonProvider; +import foundation.aep.core.EnrollResponse; +import org.junit.jupiter.api.Test; + +/** The provider turns Jackson's failures into the contract AepJson callers handle. */ +final class Jackson3JsonProviderFailureTest { + private final AepJsonProvider provider = new Jackson3JsonProvider(); + + @Test + void reportsUndecodableJsonAsAnInvalidArgument() { + IllegalArgumentException truncated = + assertThrows(IllegalArgumentException.class, () -> provider.decode("{", EnrollResponse.class)); + IllegalArgumentException wrongShape = assertThrows( + IllegalArgumentException.class, () -> provider.decode("[\"active\"]", EnrollResponse.class)); + IllegalArgumentException wrongType = assertThrows( + IllegalArgumentException.class, () -> provider.decode("{\"status\":42}", EnrollResponse.class)); + + assertEquals("Unable to decode AEP JSON", truncated.getMessage()); + assertEquals("Unable to decode AEP JSON", wrongShape.getMessage()); + assertEquals("Unable to decode AEP JSON", wrongType.getMessage()); + assertTrue(truncated.getCause() instanceof tools.jackson.core.JacksonException); + } + + @Test + void reportsUnencodableValuesAsAnInvalidArgument() { + IllegalArgumentException failure = + assertThrows(IllegalArgumentException.class, () -> provider.write(new Unserializable())); + + assertEquals("Unable to encode AEP JSON", failure.getMessage()); + assertTrue(failure.getCause() instanceof tools.jackson.core.JacksonException); + } + + /** A value whose accessor fails mid-serialization, which is how Jackson reports write errors. */ + public static final class Unserializable { + public String getValue() { + throw new IllegalStateException("boom"); + } + } + + @Test + void ignoresUnknownMembersAndOmitsAbsentOnes() { + EnrollResponse decoded = + provider.decode("{\"status\":\"active\",\"future_member\":{\"nested\":true}}", EnrollResponse.class); + + assertEquals(foundation.aep.core.AgentStatus.ACTIVE, decoded.status()); + assertEquals("{\"status\":\"active\"}", provider.write(decoded)); + } +} diff --git a/aep-platform/src/main/java/foundation/aep/platform/AepPlatform.java b/aep-platform/src/main/java/foundation/aep/platform/AepPlatform.java index a1a98ba..aace4cf 100644 --- a/aep-platform/src/main/java/foundation/aep/platform/AepPlatform.java +++ b/aep-platform/src/main/java/foundation/aep/platform/AepPlatform.java @@ -1,10 +1,12 @@ package foundation.aep.platform; +import com.nimbusds.jwt.SignedJWT; import foundation.aep.core.Aep; import foundation.aep.core.AepValidation; import foundation.aep.core.ClientAssertionClaims; import foundation.aep.core.ClientAssertionVerification; import foundation.aep.core.ClientAssertions; +import foundation.aep.core.DidWeb; import foundation.aep.core.ManagedAgentStatus; import foundation.aep.core.PlatformAgentIdentity; import foundation.aep.core.PlatformAgentIdentityListResponse; @@ -22,15 +24,19 @@ import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; +import java.text.ParseException; import java.time.Clock; import java.time.Duration; import java.time.Instant; +import java.time.OffsetDateTime; import java.time.ZoneOffset; import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; import java.util.ArrayList; import java.util.Comparator; import java.util.LinkedHashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Objects; import java.util.Optional; @@ -46,6 +52,10 @@ public final class AepPlatform { private static final String IDENTITY_NOT_RECOGNIZED = "Identity not recognized."; private static final String INVALID_REQUEST = "invalid_request"; private static final String NOT_RECOGNIZED = "not_recognized"; + private static final String DID_URL_PROBE = "aep-platform-did-url-probe"; + private static final DateTimeFormatter HTTP_DATE = DateTimeFormatter.ofPattern( + "EEE, dd MMM yyyy HH:mm:ss 'GMT'", Locale.ROOT) + .withZone(ZoneOffset.UTC); private final PlatformDiscoveryDocument platformDiscovery; private final String didHost; private final String didPathPrefix; @@ -62,6 +72,8 @@ public final class AepPlatform { private final Duration maximumLifetime; private final Supplier identityIdSupplier; private final Supplier agentDidIdSupplier; + private final String discoveryETag; + private final String discoveryModified; private AepPlatform(Builder builder) { platformDiscovery = AepValidation.requirePlatformDiscoveryDocument(builder.configuredDiscovery); @@ -93,8 +105,9 @@ private AepPlatform(Builder builder) { throw new IllegalArgumentException( "Hosted verification capability and endpoint must be configured together."); } - renderDidDocumentUrl("validation"); - createServiceScopedAgentDid("validation"); + validateDidUrlTemplate(); + discoveryETag = entityTag(platformDiscovery); + discoveryModified = HTTP_DATE.format(clock.instant()); } public static Builder builder( @@ -107,7 +120,14 @@ public static Builder builder( } public PlatformResponse discovery() { - return success(200, platformDiscovery, Aep.MEDIA_TYPE, Map.of("Cache-Control", List.of("max-age=300"))); + // PLT-DISC-012 asks for validators alongside the freshness lifetime, and PLT-DISC-013 makes + // a client honour them. Sending only Cache-Control left every expiry as a full refetch, + // because the client had nothing to put in an If-None-Match or If-Modified-Since request. + Map> headers = new LinkedHashMap<>(); + headers.put("Cache-Control", List.of("max-age=300")); + headers.put("ETag", List.of(discoveryETag)); + headers.put("Last-Modified", List.of(discoveryModified)); + return success(200, platformDiscovery, Aep.MEDIA_TYPE, headers); } public CompletionStage> getDidDocument( @@ -271,11 +291,10 @@ public CompletionStage> verify( () -> verifyNew(request, effectiveContext)); } + // Reached only through idempotent(), which has already refused a request without an + // authenticated principal, so the store always receives a principal to scope the identity to. private CompletionStage> provisionResolved( String serviceDid, PlatformRequestContext context) { - if (!hasPrincipal(context)) { - return completed(problem(400, INVALID_REQUEST, "An authenticated principal is required.")); - } return identityStore .findOrCreate(context.principal(), serviceDid, () -> createIdentity(serviceDid, context)) .thenApply(selection -> success(200, publicIdentity(selection.identity()))); @@ -311,7 +330,10 @@ private CompletionStage> signAu return completed(problem(404, NOT_RECOGNIZED, IDENTITY_NOT_RECOGNIZED)); } return lifecyclePolicy.canSign(identity, context).thenCompose(allowed -> { - if (!allowed) { + // PLT-LIFE-002 and PLT-SIGN-007 forbid signing for a revoked, suspended, or terminated + // identity outright, so the configured policy may only narrow this floor. Consulting the + // policy alone let a custom one widen a MUST NOT back open. + if (!allowed || identity.status() != ManagedAgentStatus.ACTIVE) { return completed(problem(403, lifecycleProblemCode(identity.status()), "Identity cannot sign.")); } if (signHandler == null) return signWithKeyStore(identity, request, lifetime, context); @@ -355,9 +377,23 @@ private CompletionStage> valida PlatformResponse response, PlatformIdentityRecord identity, PlatformSignRequest request) { + // PLT-SIGN-017 keeps the HTTP Retry-After header off this contract entirely, not only off a + // pending response: polling cadence travels in retry_after_seconds. The header is checked + // before the Problem Details branch so a handler cannot smuggle it in on an error either. + if (hasRetryAfter(response.headers())) { + return CompletableFuture.failedFuture( + new IllegalArgumentException("Platform sign handler must not send a Retry-After header.")); + } if (validProblem(response)) return completed(response); - if (response == null - || (response.status() != 200 && response.status() != 202) + // PLT-SIGN-010 caps the effective assertion lifetime, and the configured maximum may cut it + // shorter. This runs ahead of the schema gate so an unparseable or unbounded validity window + // is reported as the lifetime failure it is rather than as a generic shape mismatch. + if (response.body() instanceof PlatformSignResponses.Completed bounded && !boundedLifetime(bounded)) { + return CompletableFuture.failedFuture(new IllegalArgumentException( + "Platform sign handler returned an assertion lifetime outside the permitted window.")); + } + if ((response.status() != 200 && response.status() != 202) + || !Aep.MEDIA_TYPE.equals(response.contentType()) || response.body() == null || response.problem() != null || !AepValidation.platformSignResponse(response.body()).isEmpty()) { @@ -381,15 +417,24 @@ private CompletionStage> valida private CompletionStage> verifyNew( PlatformVerificationRequest request, PlatformRequestContext context) { + String keyId = assertionKeyId(request.clientAssertion()); ClientAssertionClaims decoded; try { decoded = ClientAssertions.decodeUnverified(request.clientAssertion()); } catch (IllegalArgumentException exception) { return completed(unrecognized(request.serviceDid())); } - if (!decoded.issuer().equals(decoded.subject())) return completed(unrecognized(request.serviceDid())); + // PLT-SIGN-022 binds the JOSE kid of a Platform-hosted identity to the Service-scoped Agent + // DID carried in key_id, with no verification-method fragment. AEP core accepts a fragment + // because an ordinary did:web Agent may publish several methods, so the Platform-specific + // narrowing has to happen here. + if (!decoded.issuer().equals(decoded.subject()) || !decoded.issuer().equals(keyId)) { + return completed(unrecognized(request.serviceDid())); + } return identityStore.findByAgentDid(decoded.issuer()).thenCompose(identity -> { - if (identity.isEmpty() || !identity.get().serviceDid().equals(request.serviceDid())) { + if (identity.isEmpty() + || !identity.get().serviceDid().equals(request.serviceDid()) + || !identity.get().keyId().equals(keyId)) { return completed(unrecognized(request.serviceDid())); } PlatformIdentityRecord record = identity.get(); @@ -397,9 +442,13 @@ private CompletionStage> verifyNe .authorize(PlatformAuthorizationRequest.verify(record, request), context) .thenCompose(authorized -> { if (!authorized) return completed(unrecognized(request.serviceDid())); + // PLT-VER-004 makes the current lifecycle state part of what verification + // checks, and PLT-LIFE-002 settles what that state has to be. As on the + // signing path, the configured policy narrows this floor rather than + // replacing it. return lifecyclePolicy .canVerify(record, context) - .thenCompose(allowed -> allowed + .thenCompose(allowed -> allowed && record.status() == ManagedAgentStatus.ACTIVE ? verifyAssertion(record, request, context) : completed(unrecognized(request.serviceDid()))); }); @@ -521,27 +570,86 @@ private String createServiceScopedAgentDid(String agentDidId) { List components = new ArrayList<>(); components.add("did:web"); components.add(encodeDidComponent(didHost)); + // normalizeDidPathPrefix has already refused a blank, ".", or ".." component, so every split + // part is a real path segment. for (String component : didPathPrefix.split("/")) { - if (!component.isEmpty()) components.add(encodeDidComponent(component)); + components.add(encodeDidComponent(component)); } components.add(encodeDidComponent(agentDidId)); return String.join(":", components); } + // requirePlatformDiscoveryDocument has already established that the template names + // {agent_did_id} and renders an absolute HTTPS URL carrying no user information and no fragment, + // and encodePathSegment keeps a generated identifier inside a single path segment. The shape the + // constructor probed is therefore the shape every identity gets. private String renderDidDocumentUrl(String agentDidId) { - String template = platformDiscovery.identity().didUrlTemplate(); - if (!template.contains("{agent_did_id}")) { - throw new IllegalArgumentException("did_url_template must include {agent_did_id}."); + return platformDiscovery.identity().didUrlTemplate().replace("{agent_did_id}", encodePathSegment(agentDidId)); + } + + // PLT-DID-005 lets a Service treat a Platform-hosted Agent DID as an ordinary did:web DID, and + // PLT-DISC-006 makes did_url_template describe where those documents actually live. The DID host, + // the DID path prefix, and the template are configured independently, so nothing stopped a + // Platform from advertising a did_document_url no Service would ever fetch. Probing both with one + // identifier settles it at construction instead of at the first Service handshake. + private void validateDidUrlTemplate() { + String rendered = renderDidDocumentUrl(DID_URL_PROBE); + URI resolved = DidWeb.documentUri(createServiceScopedAgentDid(DID_URL_PROBE)); + if (!rendered.equals(resolved.toString())) { + throw new IllegalArgumentException("did_url_template renders " + rendered + + " where resolving the minted Agent DID reaches " + resolved + "."); + } + } + + // A sign handler mints its own issued_at and expires_at, so the bound the key-store path inherits + // from the validated request has to be re-checked against what the handler actually returned. + private boolean boundedLifetime(PlatformSignResponses.Completed completed) { + Instant issuedAt; + Instant expiresAt; + try { + issuedAt = OffsetDateTime.parse(completed.issuedAt()).toInstant(); + expiresAt = OffsetDateTime.parse(completed.expiresAt()).toInstant(); + } catch (DateTimeParseException exception) { + return false; } - String rendered = template.replace("{agent_did_id}", encodePathSegment(agentDidId)); - URI uri = URI.create(rendered); - if (!"https".equalsIgnoreCase(uri.getScheme()) - || uri.getHost() == null - || uri.getUserInfo() != null - || uri.getFragment() != null) { - throw new IllegalArgumentException("did_url_template must render an absolute HTTPS URL."); + Duration lifetime = Duration.between(issuedAt, expiresAt); + return !lifetime.isZero() && !lifetime.isNegative() && lifetime.compareTo(maximumLifetime) <= 0; + } + + private static boolean hasRetryAfter(Map> headers) { + return headers.keySet().stream().anyMatch(name -> "retry-after".equalsIgnoreCase(name)); + } + + // Returns null for a token that is not a signed JWT or carries no kid, which the caller then + // fails on the Agent DID comparison rather than by unwinding a second parse failure. + private static String assertionKeyId(String assertion) { + try { + return SignedJWT.parse(assertion).getHeader().getKeyID(); + } catch (ParseException exception) { + return null; } - return rendered; + } + + private static String entityTag(PlatformDiscoveryDocument document) { + return "\"sha256:" + fingerprint(discoveryMaterial(document)) + "\""; + } + + private static List discoveryMaterial(PlatformDiscoveryDocument document) { + return listIncludingNulls( + document.version(), + document.endpoints().hostedVerification(), + document.endpoints().lifecycle(), + document.endpoints().list(), + document.endpoints().provision(), + document.endpoints().sign(), + document.http().endpointBase(), + document.identity().didMethods(), + document.identity().didUrlTemplate(), + document.platform().did(), + document.platform().hostedVerification(), + document.platform().name(), + document.signing().algorithms(), + document.signing().defaultLifetimeSeconds()); } private static boolean validListQuery(PlatformIdentityListQuery query) { @@ -664,12 +772,15 @@ private static PlatformRequestContext context(PlatformRequestContext context) { return context == null ? new PlatformRequestContext(null, null) : context; } + // RFC 9457 makes the status member the HTTP status code of the response it travels in, so a + // handler reporting one code in the body and another on the wire has produced a Problem Details + // document no client can act on consistently. private static boolean validProblem(PlatformResponse response) { - return response != null - && response.status() >= 400 + return response.status() >= 400 && Aep.PROBLEM_MEDIA_TYPE.equals(response.contentType()) && response.body() == null && response.problem() != null + && response.problem().status() == response.status() && AepValidation.problemDetails(response.problem()).isEmpty(); } diff --git a/aep-platform/src/main/java/foundation/aep/platform/PlatformCopies.java b/aep-platform/src/main/java/foundation/aep/platform/PlatformCopies.java index efc5d4b..3c72a6c 100644 --- a/aep-platform/src/main/java/foundation/aep/platform/PlatformCopies.java +++ b/aep-platform/src/main/java/foundation/aep/platform/PlatformCopies.java @@ -8,13 +8,53 @@ final class PlatformCopies { private PlatformCopies() {} + // Platform responses carry headers straight out into an HTTP message, and a sign handler or an + // embedding application supplies them. An unchecked name or value let a bare CR or LF terminate + // the field early and inject a header, or a whole response, that nothing in AEP ever authored. static Map> headers(Map> source) { if (source == null) return Map.of(); Map> result = new LinkedHashMap<>(); - source.forEach((name, values) -> result.put(name, List.copyOf(values))); + source.forEach((name, values) -> result.put(requireFieldName(name), requireFieldValues(values))); return Map.copyOf(result); } + private static String requireFieldName(String value) { + if (value == null || value.isEmpty()) throw new IllegalArgumentException("Header names must not be empty."); + for (int index = 0; index < value.length(); index++) { + if (!isTokenCharacter(value.charAt(index))) { + throw new IllegalArgumentException("Header names must be RFC 9110 tokens."); + } + } + return value; + } + + private static List requireFieldValues(List values) { + if (values == null || values.isEmpty()) { + throw new IllegalArgumentException("Header values must not be empty."); + } + return values.stream().map(PlatformCopies::requireFieldValue).toList(); + } + + private static String requireFieldValue(String value) { + if (value == null || !value.equals(value.strip())) { + throw new IllegalArgumentException("Header values must not be null or surrounded by whitespace."); + } + for (int index = 0; index < value.length(); index++) { + char character = value.charAt(index); + if (character != '\t' && (character < ' ' || character == 0x7f)) { + throw new IllegalArgumentException("Header values must not contain control characters."); + } + } + return value; + } + + private static boolean isTokenCharacter(char value) { + return value >= '0' && value <= '9' + || value >= 'a' && value <= 'z' + || value >= 'A' && value <= 'Z' + || "!#$%&'*+-.^_`|~".indexOf(value) >= 0; + } + static Map jsonObject(Map source) { if (source == null) return Map.of(); Map result = new LinkedHashMap<>(); diff --git a/aep-platform/src/main/java/foundation/aep/platform/PlatformIdempotencyStore.java b/aep-platform/src/main/java/foundation/aep/platform/PlatformIdempotencyStore.java index bad9322..442ce87 100644 --- a/aep-platform/src/main/java/foundation/aep/platform/PlatformIdempotencyStore.java +++ b/aep-platform/src/main/java/foundation/aep/platform/PlatformIdempotencyStore.java @@ -92,9 +92,11 @@ private synchronized void complete( destination.complete(response); } + // PLT-IDP-004 requires the replayable result to survive for at least an hour, so a record + // whose age is exactly the retention window is still inside it and has to be kept. private synchronized void expire() { Instant cutoff = clock.instant().minus(RETENTION); - entries.values().removeIf(entry -> !entry.createdAt.isAfter(cutoff)); + entries.values().removeIf(entry -> entry.createdAt.isBefore(cutoff)); } private static boolean sameRequest(PlatformIdempotencyInput first, PlatformIdempotencyInput second) { diff --git a/aep-platform/src/main/java/foundation/aep/platform/PlatformIdentityStore.java b/aep-platform/src/main/java/foundation/aep/platform/PlatformIdentityStore.java index a6b1313..eabc209 100644 --- a/aep-platform/src/main/java/foundation/aep/platform/PlatformIdentityStore.java +++ b/aep-platform/src/main/java/foundation/aep/platform/PlatformIdentityStore.java @@ -131,7 +131,7 @@ public synchronized CompletionStage> update( private synchronized CompletionStage> find(String identityId) { return CompletableFuture.completedFuture( - identityId == null ? Optional.empty() : Optional.of(records.get(identityId))); + identityId == null ? Optional.empty() : Optional.ofNullable(records.get(identityId))); } private synchronized void complete( diff --git a/aep-platform/src/test/java/foundation/aep/platform/AepPlatformTest.java b/aep-platform/src/test/java/foundation/aep/platform/AepPlatformTest.java index fae70e0..6e5f3f9 100644 --- a/aep-platform/src/test/java/foundation/aep/platform/AepPlatformTest.java +++ b/aep-platform/src/test/java/foundation/aep/platform/AepPlatformTest.java @@ -346,6 +346,7 @@ private static AepPlatform.Builder platformBuilder( AtomicInteger did = new AtomicInteger(); return AepPlatform.builder(discovery(hostedVerification), "p.example", authorizer, keyStore, resolve()) .clock(Clock.fixed(NOW, ZoneOffset.UTC)) + .didPathPrefix("a") .identityIdSupplier(() -> Integer.toString(identity.incrementAndGet())) .agentDidIdSupplier(() -> "opaque" + did.incrementAndGet()) .replayStore(PlatformReplayStore.inMemory()); @@ -385,12 +386,12 @@ private static String agentDidId(String agentDid) { private static PlatformIdentityRecord identityRecord() { return PlatformIdentityRecord.builder() - .agentDid("did:web:p.example:agents:opaque") + .agentDid("did:web:p.example:a:opaque") .agentDidId("opaque") .agentIdentityId("pai_identity") .createdAt(NOW) .didDocumentUrl("https://p.example/a/opaque/did.json") - .keyId("did:web:p.example:agents:opaque") + .keyId("did:web:p.example:a:opaque") .principal("principal") .serviceDid(SERVICE_DID) .signingAlgorithms(List.of("ES256")) diff --git a/aep-platform/src/test/java/foundation/aep/platform/PlatformConformanceTest.java b/aep-platform/src/test/java/foundation/aep/platform/PlatformConformanceTest.java new file mode 100644 index 0000000..8ad7137 --- /dev/null +++ b/aep-platform/src/test/java/foundation/aep/platform/PlatformConformanceTest.java @@ -0,0 +1,664 @@ +package foundation.aep.platform; + +import static foundation.aep.platform.PlatformFixtures.DID_URL_TEMPLATE; +import static foundation.aep.platform.PlatformFixtures.JWT_ID; +import static foundation.aep.platform.PlatformFixtures.NOW; +import static foundation.aep.platform.PlatformFixtures.OTHER_SERVICE_DID; +import static foundation.aep.platform.PlatformFixtures.PROVISION_KEY; +import static foundation.aep.platform.PlatformFixtures.SERVICE_DID; +import static foundation.aep.platform.PlatformFixtures.allow; +import static foundation.aep.platform.PlatformFixtures.builder; +import static foundation.aep.platform.PlatformFixtures.completed; +import static foundation.aep.platform.PlatformFixtures.context; +import static foundation.aep.platform.PlatformFixtures.discovery; +import static foundation.aep.platform.PlatformFixtures.platform; +import static foundation.aep.platform.PlatformFixtures.provision; +import static foundation.aep.platform.PlatformFixtures.resolver; +import static foundation.aep.platform.PlatformFixtures.signRequest; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.nimbusds.jose.JOSEException; +import foundation.aep.core.Aep; +import foundation.aep.core.AssertionOperation; +import foundation.aep.core.ManagedAgentStatus; +import foundation.aep.core.PlatformAgentIdentity; +import foundation.aep.core.PlatformLifecycleRequest; +import foundation.aep.core.PlatformSignRequest; +import foundation.aep.core.PlatformSignResponses; +import foundation.aep.core.PlatformVerificationRequest; +import foundation.aep.core.ProblemDetails; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CompletionException; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; + +/** Requirement-level checks for draft-kavian-aep-platform-hosted-identity-01. */ +final class PlatformConformanceTest { + private static final String SIGN_KEY = "sign-1"; + private static final String VERIFY_KEY = "verify-1"; + private static final String NOT_RECOGNIZED = "not_recognized"; + private static final String ISSUED_AT = "2026-07-06T12:00:00Z"; + private static final String RETRY_AFTER = "Retry-After"; + + /** PLT-DISC-012, PLT-DISC-013, PLT-DISC-015: freshness plus validators on Discovery. */ + @Test + void discoveryCarriesFreshnessAndCacheValidators() throws JOSEException { + var headers = platform(allow(), new PlatformFixtures.Keys(), false) + .discovery() + .headers(); + + assertEquals(List.of("max-age=300"), headers.get("Cache-Control")); + assertTrue(headers.get("ETag").get(0).startsWith("\"sha256:")); + assertEquals(List.of("Mon, 06 Jul 2026 12:00:00 GMT"), headers.get("Last-Modified")); + } + + /** PLT-DISC-012: the validator is derived from the document, so unlike documents differ. */ + @Test + void discoveryEntityTagTracksDocumentContent() throws JOSEException { + PlatformFixtures.Keys keys = new PlatformFixtures.Keys(); + String first = + platform(allow(), keys, false).discovery().headers().get("ETag").get(0); + String same = + platform(allow(), keys, false).discovery().headers().get("ETag").get(0); + String other = builder(discovery(false, DID_URL_TEMPLATE, "120"), allow(), keys, resolver(true)) + .build() + .discovery() + .headers() + .get("ETag") + .get(0); + + assertEquals(first, same); + assertNotEquals(first, other); + } + + /** PLT-DID-005, PLT-DISC-006: the advertised template must be where did:web resolution lands. */ + @Test + void rejectsDidUrlTemplateThatDoesNotMatchDidWebResolution() throws JOSEException { + PlatformFixtures.Keys keys = new PlatformFixtures.Keys(); + var mismatched = builder( + discovery(false, "https://p.example/b/{agent_did_id}/did.json", "300"), allow(), keys, resolver(true)); + var offHost = builder( + discovery(false, "https://other.example/a/{agent_did_id}/did.json", "300"), + allow(), + keys, + resolver(true)); + + assertTrue(assertThrows(IllegalArgumentException.class, mismatched::build) + .getMessage() + .contains("did_url_template renders")); + assertThrows(IllegalArgumentException.class, offHost::build); + } + + /** PLT-LIFE-002, PLT-SIGN-007: a policy hook may narrow the active-only floor, never widen it. */ + @Test + void refusesDelegatedSigningOutsideTheActiveState() throws JOSEException { + AepPlatform platform = builder(allow(), new PlatformFixtures.Keys(), false) + .lifecyclePolicy(permissive()) + .build(); + PlatformAgentIdentity identity = provision(platform, SERVICE_DID, PROVISION_KEY); + + assertEquals("identity_suspended", refusedSign(platform, identity, ManagedAgentStatus.SUSPENDED)); + assertEquals("identity_suspended", refusedSign(platform, identity, ManagedAgentStatus.REVOKED)); + assertEquals("identity_terminated", refusedSign(platform, identity, ManagedAgentStatus.TERMINATED)); + } + + /** PLT-VER-004, PLT-LIFE-002: hosted verification checks the current lifecycle state too. */ + @Test + void refusesHostedVerificationOutsideTheActiveState() throws JOSEException { + PlatformFixtures.Keys keys = new PlatformFixtures.Keys(); + AepPlatform platform = + builder(allow(), keys, true).lifecyclePolicy(permissive()).build(); + PlatformAgentIdentity identity = provision(platform, SERVICE_DID, PROVISION_KEY); + String assertion = signedAssertion(platform, identity); + platform.updateIdentity( + identity.agentIdentityId(), + new PlatformLifecycleRequest(ManagedAgentStatus.SUSPENDED), + context(null)) + .toCompletableFuture() + .join(); + + var response = platform.verify( + new PlatformVerificationRequest(assertion, AssertionOperation.ENROLL, null, SERVICE_DID), + context(VERIFY_KEY)) + .toCompletableFuture() + .join() + .body(); + + assertFalse(response.verified()); + assertEquals(NOT_RECOGNIZED, response.reason()); + assertEquals(null, response.status()); + } + + /** PLT-SIGN-022: a Platform-hosted kid is the Agent DID itself, with no method fragment. */ + @Test + void rejectsAssertionsWhoseKeyIdCarriesAVerificationMethodFragment() throws JOSEException { + PlatformFixtures.Keys keys = new PlatformFixtures.Keys(); + keys.keyIdSuffix = "#key-1"; + AepPlatform platform = platform(allow(), keys, true); + PlatformAgentIdentity identity = provision(platform, SERVICE_DID, PROVISION_KEY); + String assertion = signedAssertion(platform, identity); + + var response = platform.verify( + new PlatformVerificationRequest(assertion, AssertionOperation.ENROLL, null, SERVICE_DID), + context(VERIFY_KEY)) + .toCompletableFuture() + .join() + .body(); + + assertFalse(response.verified()); + assertEquals(NOT_RECOGNIZED, response.reason()); + } + + /** PLT-VER-008, PLT-VER-010: an unrecognized assertion discloses nothing about the identity. */ + @Test + void reportsUnrecognizedForAssertionsItCannotTieToAnIdentity() throws JOSEException { + PlatformFixtures.Keys keys = new PlatformFixtures.Keys(); + AepPlatform platform = platform(allow(), keys, true); + PlatformAgentIdentity identity = provision(platform, SERVICE_DID, PROVISION_KEY); + String assertion = signedAssertion(platform, identity); + + assertFalse(verified(platform, "not-a-jwt", SERVICE_DID, "v-garbage")); + assertFalse(verified(platform, assertion, OTHER_SERVICE_DID, "v-service")); + assertTrue(verified(platform, assertion, SERVICE_DID, "v-good")); + } + + /** PLT-SIGN-017: the HTTP Retry-After header stays off this contract, success or problem. */ + @Test + void rejectsSignHandlerResponsesCarryingRetryAfter() throws JOSEException { + assertThrows( + CompletionException.class, + () -> handledSign(pending(Map.of(RETRY_AFTER, List.of("5")))), + "pending response"); + assertThrows( + CompletionException.class, + () -> handledSign(pending(Map.of("retry-after", List.of("5")))), + "header names are case-insensitive"); + assertThrows( + CompletionException.class, + () -> handledSign(new PlatformResponse<>( + 503, + Aep.PROBLEM_MEDIA_TYPE, + null, + ProblemDetails.of("temporarily_unavailable", "Try later.", 503), + Map.of(RETRY_AFTER, List.of("5")))), + "problem response"); + assertEquals(202, handledSign(pending(Map.of())).status()); + } + + /** PLT-SIGN-010: a handler-minted validity window is bounded like a Platform-minted one. */ + @Test + void boundsSignHandlerAssertionLifetimes() throws JOSEException { + assertEquals( + 200, + handledSign(completedSign(ISSUED_AT, "2026-07-06T12:05:00Z")).status()); + assertEquals( + 200, + handledSign(completedSign(ISSUED_AT, "2026-07-06T12:05:00+00:00")) + .status()); + assertThrows( + CompletionException.class, + () -> handledSign(completedSign(ISSUED_AT, "2026-07-06T12:05:01Z")), + "301 seconds exceeds the maximum"); + assertThrows( + CompletionException.class, + () -> handledSign(completedSign(ISSUED_AT, ISSUED_AT)), + "a zero-length window never validates"); + assertThrows( + CompletionException.class, + () -> handledSign(completedSign("2026-07-06T12:05:00Z", ISSUED_AT)), + "expiry must follow issuance"); + assertThrows( + CompletionException.class, + () -> handledSign(completedSign("whenever", ISSUED_AT)), + "an unparseable instant is not a bounded window"); + } + + /** PLT-SIGN-010, PLT-SIGN-011: a shorter configured maximum also binds the handler path. */ + @Test + void appliesTheConfiguredMaximumToBothSigningPaths() throws JOSEException { + PlatformFixtures.Keys keys = new PlatformFixtures.Keys(); + AepPlatform platform = builder(discovery(false, DID_URL_TEMPLATE, "60"), allow(), keys, resolver(true)) + .maximumLifetime(Duration.ofSeconds(60)) + .build(); + PlatformAgentIdentity identity = provision(platform, SERVICE_DID, PROVISION_KEY); + + assertEquals( + 400, + platform.sign(identity.agentIdentityId(), signRequest("61"), context(SIGN_KEY)) + .toCompletableFuture() + .join() + .status()); + assertEquals( + 200, + platform.sign(identity.agentIdentityId(), signRequest("60"), context("sign-2")) + .toCompletableFuture() + .join() + .status()); + } + + /** PLT-SIGN-008: lifetime_seconds is a canonical decimal string within 1..300. */ + @Test + void rejectsNonCanonicalLifetimeEncodings() throws JOSEException { + AepPlatform platform = platform(allow(), new PlatformFixtures.Keys(), false); + PlatformAgentIdentity identity = provision(platform, SERVICE_DID, PROVISION_KEY); + + assertEquals(400, signStatus(platform, identity, "+60", "s1")); + assertEquals(400, signStatus(platform, identity, "060", "s2")); + assertEquals(400, signStatus(platform, identity, "0", "s3")); + assertEquals(400, signStatus(platform, identity, "301", "s4")); + assertEquals(200, signStatus(platform, identity, "300", "s5")); + } + + /** PLT-IDP-001, PLT-IDP-003: every replayable operation needs a key and a principal. */ + @Test + void requiresAnIdempotencyKeyAndPrincipalOnReplayableOperations() throws JOSEException { + PlatformFixtures.Keys keys = new PlatformFixtures.Keys(); + AepPlatform platform = platform(allow(), keys, true); + PlatformAgentIdentity identity = provision(platform, SERVICE_DID, PROVISION_KEY); + PlatformRequestContext anonymous = new PlatformRequestContext(" ", PROVISION_KEY, NOW, Map.of()); + + assertEquals( + 400, + platform.provision(new foundation.aep.core.PlatformProvisionRequest(SERVICE_DID), context(null)) + .toCompletableFuture() + .join() + .status()); + assertEquals( + 400, + platform.sign(identity.agentIdentityId(), signRequest(null), anonymous) + .toCompletableFuture() + .join() + .status()); + assertEquals( + 400, + platform.verify( + new PlatformVerificationRequest("token", AssertionOperation.ENROLL, null, SERVICE_DID), + context(" ")) + .toCompletableFuture() + .join() + .status()); + } + + /** PLT-IDP-009: the same key for different material is a conflict, not a silent replay. */ + @Test + void reportsConflictWhenAKeyIsReusedForDifferentMaterial() throws JOSEException { + AepPlatform platform = platform(allow(), new PlatformFixtures.Keys(), false); + PlatformAgentIdentity identity = provision(platform, SERVICE_DID, PROVISION_KEY); + + var conflict = platform.sign(identity.agentIdentityId(), signRequest("60"), context(SIGN_KEY)) + .thenCompose( + ignored -> platform.sign(identity.agentIdentityId(), signRequest("120"), context(SIGN_KEY))) + .toCompletableFuture() + .join(); + + assertEquals(409, conflict.status()); + assertEquals("idempotency_conflict", conflict.problem().code()); + } + + /** PLT-VER-001: hosted verification answers only where the Platform advertises it. */ + @Test + void refusesHostedVerificationWhenItIsNotAdvertised() throws JOSEException { + AepPlatform platform = platform(allow(), new PlatformFixtures.Keys(), false); + + var response = platform.verify( + new PlatformVerificationRequest("token", AssertionOperation.ENROLL, null, SERVICE_DID), + context(VERIFY_KEY)) + .toCompletableFuture() + .join(); + + assertEquals(404, response.status()); + assertEquals(NOT_RECOGNIZED, response.problem().code()); + } + + /** PLT-VER-001: the advertised capability and the advertised endpoint travel together. */ + @Test + void rejectsPartialHostedVerificationConfiguration() throws JOSEException { + PlatformFixtures.Keys keys = new PlatformFixtures.Keys(); + var withoutReplayStore = AepPlatform.builder(discovery(true), "p.example", allow(), keys, resolver(true)) + .didPathPrefix("a"); + var capabilityOnly = new foundation.aep.core.PlatformDiscoveryDocument( + "1.0", + new foundation.aep.core.PlatformDiscoveryDocument.Endpoints( + null, + "/v1/aep/agent-identities/{agent_identity_id}", + "/v1/aep/agent-identities", + "/v1/aep/agent-identities", + "/v1/aep/agent-identities/{agent_identity_id}/sign"), + new foundation.aep.core.PlatformDiscoveryDocument.Http("/v1/aep"), + new foundation.aep.core.PlatformDiscoveryDocument.Identity(List.of("did:web"), DID_URL_TEMPLATE), + new foundation.aep.core.PlatformDiscoveryDocument.Platform("did:web:p.example", true, "Example"), + new foundation.aep.core.PlatformDiscoveryDocument.Signing(List.of("ES256"), "300")); + + assertThrows(IllegalArgumentException.class, withoutReplayStore::build); + var endpointMismatch = builder(capabilityOnly, allow(), keys, resolver(true)); + assertThrows(IllegalArgumentException.class, endpointMismatch::build); + } + + /** PLT-VER-006, PLT-VER-007: resource travels with authenticate and with nothing else. */ + @Test + void bindsTheProtectedResourceToAuthenticateOnly() throws JOSEException { + PlatformFixtures.Keys keys = new PlatformFixtures.Keys(); + AepPlatform platform = platform(allow(), keys, true); + PlatformAgentIdentity identity = provision(platform, SERVICE_DID, PROVISION_KEY); + PlatformSignRequest request = new PlatformSignRequest( + JWT_ID, null, AssertionOperation.AUTHENTICATE, null, "https://api.service.example/orders", SERVICE_DID); + var signed = + (PlatformSignResponses.Completed) platform.sign(identity.agentIdentityId(), request, context(SIGN_KEY)) + .toCompletableFuture() + .join() + .body(); + + assertEquals( + 400, + platform.verify( + new PlatformVerificationRequest( + signed.clientAssertion(), AssertionOperation.AUTHENTICATE, null, SERVICE_DID), + context("v-missing")) + .toCompletableFuture() + .join() + .status()); + assertFalse(platform.verify( + new PlatformVerificationRequest( + signed.clientAssertion(), + AssertionOperation.AUTHENTICATE, + "https://api.service.example/other", + SERVICE_DID), + context("v-wrong")) + .toCompletableFuture() + .join() + .body() + .verified()); + assertTrue(platform.verify( + new PlatformVerificationRequest( + signed.clientAssertion(), + AssertionOperation.AUTHENTICATE, + "https://api.service.example/orders", + SERVICE_DID), + context("v-right")) + .toCompletableFuture() + .join() + .body() + .verified()); + } + + /** PLT-PROV-006, PLT-PROV-007: a syntactic DID that no resolver recognizes is refused. */ + @Test + void refusesProvisioningForUnresolvableOrMalformedServiceDids() throws JOSEException { + PlatformFixtures.Keys keys = new PlatformFixtures.Keys(); + AepPlatform unresolvable = + builder(discovery(false), allow(), keys, resolver(false)).build(); + AepPlatform platform = platform(allow(), keys, false); + + assertEquals( + 400, + platform.provision(new foundation.aep.core.PlatformProvisionRequest("not-a-did"), context("p1")) + .toCompletableFuture() + .join() + .status()); + assertEquals( + 400, + unresolvable + .provision(new foundation.aep.core.PlatformProvisionRequest(SERVICE_DID), context("p2")) + .toCompletableFuture() + .join() + .status()); + assertEquals(0, keys.created.get()); + } + + /** PLT-PROV-008, PLT-PROV-012: Service-scoped DIDs share only the Platform-controlled prefix. */ + @Test + void mintsDistinctServiceScopedDidsPerService() throws JOSEException { + AepPlatform platform = platform(allow(), new PlatformFixtures.Keys(), false); + + PlatformAgentIdentity first = provision(platform, SERVICE_DID, "p1"); + PlatformAgentIdentity second = provision(platform, OTHER_SERVICE_DID, "p2"); + + assertNotEquals(first.agentDid(), second.agentDid()); + assertTrue(first.agentDid().startsWith("did:web:p.example:a:")); + assertTrue(second.agentDid().startsWith("did:web:p.example:a:")); + assertFalse(first.agentDid().contains("api.service")); + assertFalse(second.agentDid().contains("billing.service")); + assertEquals(first.didDocumentUrl(), "https://p.example/a/opaque1/did.json"); + } + + /** PLT-LIFE-007, PLT-LIFE-009: a Platform defines its own transitions and reports refusals. */ + @Test + void reportsRefusedLifecycleTransitions() throws JOSEException { + AepPlatform platform = builder(allow(), new PlatformFixtures.Keys(), false) + .lifecyclePolicy(new PlatformLifecyclePolicy() { + @Override + public CompletionStage canSign( + PlatformIdentityRecord identity, PlatformRequestContext context) { + return completed(true); + } + + @Override + public CompletionStage canTransition( + PlatformIdentityRecord identity, + ManagedAgentStatus nextStatus, + PlatformRequestContext context) { + return completed(false); + } + + @Override + public CompletionStage canVerify( + PlatformIdentityRecord identity, PlatformRequestContext context) { + return completed(true); + } + }) + .build(); + PlatformAgentIdentity identity = provision(platform, SERVICE_DID, PROVISION_KEY); + + var refused = platform.updateIdentity( + identity.agentIdentityId(), + new PlatformLifecycleRequest(ManagedAgentStatus.SUSPENDED), + context(null)) + .toCompletableFuture() + .join(); + + assertEquals(403, refused.status()); + assertEquals("identity_unavailable", refused.problem().code()); + } + + /** PLT-ERR-004, PLT-ERR-005: a denied management operation is a bare 404 with no side effect. */ + @Test + void deniedOperationsLookLikeMissingOnes() throws JOSEException { + PlatformFixtures.Keys keys = new PlatformFixtures.Keys(); + AtomicReference authorized = new AtomicReference<>(true); + AepPlatform platform = platform((request, context) -> completed(authorized.get()), keys, false); + PlatformAgentIdentity identity = provision(platform, SERVICE_DID, PROVISION_KEY); + authorized.set(false); + + assertEquals(404, statusOf(platform.getIdentity(identity.agentIdentityId(), context(null)))); + assertEquals(404, statusOf(platform.list(PlatformIdentityListQuery.defaults(), context(null)))); + assertEquals( + 404, + statusOf(platform.updateIdentity( + identity.agentIdentityId(), + new PlatformLifecycleRequest(ManagedAgentStatus.SUSPENDED), + context(null)))); + assertEquals(404, statusOf(platform.sign(identity.agentIdentityId(), signRequest(null), context(SIGN_KEY)))); + assertEquals(1, keys.created.get()); + } + + /** PLT-LIST-003, PLT-LIST-004, PLT-LIST-006: bounded, deterministic, self-describing pages. */ + @Test + void pagesIdentitiesDeterministicallyWithinBounds() throws JOSEException { + AepPlatform platform = platform(allow(), new PlatformFixtures.Keys(), false); + provision(platform, SERVICE_DID, "p1"); + provision(platform, OTHER_SERVICE_DID, "p2"); + + var ascending = platform.list(new PlatformIdentityListQuery(false, 100, 0, null, null), context(null)) + .toCompletableFuture() + .join() + .body(); + var descending = platform.list(new PlatformIdentityListQuery(true, 100, 0, null, null), context(null)) + .toCompletableFuture() + .join() + .body(); + var scoped = platform.list(new PlatformIdentityListQuery(false, 100, 0, SERVICE_DID, null), context(null)) + .toCompletableFuture() + .join() + .body(); + + assertEquals("2", ascending.count()); + assertEquals("2", ascending.total()); + assertEquals( + ascending.data().get(0).agentDid(), descending.data().get(1).agentDid()); + assertEquals("1", scoped.count()); + assertEquals(SERVICE_DID, scoped.data().get(0).serviceDid()); + assertEquals( + 400, statusOf(platform.list(new PlatformIdentityListQuery(false, 0, 0, null, null), context(null)))); + assertEquals( + 400, statusOf(platform.list(new PlatformIdentityListQuery(false, 1, -1, null, null), context(null)))); + assertEquals( + 400, statusOf(platform.list(new PlatformIdentityListQuery(false, 1, 0, "nope", null), context(null)))); + } + + /** PLT-DID-001, PLT-DID-006: DID documents are public, and only for active identities. */ + @Test + void publishesDidDocumentsForActiveIdentitiesOnly() throws JOSEException { + AepPlatform platform = platform(allow(), new PlatformFixtures.Keys(), false); + PlatformAgentIdentity identity = provision(platform, SERVICE_DID, PROVISION_KEY); + String agentDidId = identity.agentDid().substring(identity.agentDid().lastIndexOf(':') + 1); + + var published = + platform.getDidDocument(agentDidId, null).toCompletableFuture().join(); + platform.updateIdentity( + identity.agentIdentityId(), + new PlatformLifecycleRequest(ManagedAgentStatus.REVOKED), + context(null)) + .toCompletableFuture() + .join(); + + assertEquals(200, published.status()); + assertEquals(Aep.DID_MEDIA_TYPE, published.contentType()); + assertEquals(identity.agentDid(), published.body().id()); + assertEquals(List.of(identity.agentDid()), published.body().authentication()); + assertEquals(404, statusOf(platform.getDidDocument(agentDidId, null))); + assertEquals(404, statusOf(platform.getDidDocument(" ", null))); + } + + /** PLT-SIGN-004: platform_context never reaches the assertion it accompanies. */ + @Test + void keepsPlatformContextOutOfAssertionClaims() throws JOSEException { + PlatformFixtures.Keys keys = new PlatformFixtures.Keys(); + AepPlatform platform = platform(allow(), keys, false); + PlatformAgentIdentity identity = provision(platform, SERVICE_DID, PROVISION_KEY); + PlatformSignRequest request = new PlatformSignRequest( + JWT_ID, null, AssertionOperation.ENROLL, Map.of("approval", "opaque"), null, SERVICE_DID); + + var signed = + (PlatformSignResponses.Completed) platform.sign(identity.agentIdentityId(), request, context(SIGN_KEY)) + .toCompletableFuture() + .join() + .body(); + String payload = new String( + java.util.Base64.getUrlDecoder().decode(signed.clientAssertion().split("\\.")[1]), + java.nio.charset.StandardCharsets.UTF_8); + + assertEquals("opaque", signed.platformContext().get("approval")); + assertFalse(payload.contains("platform_context")); + assertFalse(payload.contains("approval")); + } + + private static PlatformLifecyclePolicy permissive() { + return new PlatformLifecyclePolicy() { + @Override + public CompletionStage canSign(PlatformIdentityRecord identity, PlatformRequestContext context) { + return completed(true); + } + + @Override + public CompletionStage canTransition( + PlatformIdentityRecord identity, ManagedAgentStatus nextStatus, PlatformRequestContext context) { + return completed(true); + } + + @Override + public CompletionStage canVerify(PlatformIdentityRecord identity, PlatformRequestContext context) { + return completed(true); + } + }; + } + + private static String refusedSign(AepPlatform platform, PlatformAgentIdentity identity, ManagedAgentStatus status) { + platform.updateIdentity(identity.agentIdentityId(), new PlatformLifecycleRequest(status), context(null)) + .toCompletableFuture() + .join(); + var response = platform.sign(identity.agentIdentityId(), signRequest(null), context("sign-" + status)) + .toCompletableFuture() + .join(); + assertEquals(403, response.status()); + return response.problem().code(); + } + + private static String signedAssertion(AepPlatform platform, PlatformAgentIdentity identity) { + return ((PlatformSignResponses.Completed) + platform.sign(identity.agentIdentityId(), signRequest(null), context(SIGN_KEY)) + .toCompletableFuture() + .join() + .body()) + .clientAssertion(); + } + + private static boolean verified(AepPlatform platform, String assertion, String serviceDid, String key) { + return platform.verify( + new PlatformVerificationRequest(assertion, AssertionOperation.ENROLL, null, serviceDid), + context(key)) + .toCompletableFuture() + .join() + .body() + .verified(); + } + + private static int signStatus( + AepPlatform platform, PlatformAgentIdentity identity, String lifetimeSeconds, String key) { + return statusOf(platform.sign(identity.agentIdentityId(), signRequest(lifetimeSeconds), context(key))); + } + + private static int statusOf(CompletionStage> stage) { + return stage.toCompletableFuture().join().status(); + } + + private static PlatformResponse pending(Map> headers) { + return new PlatformResponse<>( + 202, Aep.MEDIA_TYPE, new PlatformSignResponses.Pending("pending", null, "5"), null, headers); + } + + private static PlatformResponse completedSign(String issuedAt, String expiresAt) { + return new PlatformResponse<>( + 200, + Aep.MEDIA_TYPE, + new PlatformSignResponses.Completed( + "completed", + "did:web:p.example:a:opaque1", + "assertion", + expiresAt, + issuedAt, + JWT_ID, + null, + SERVICE_DID), + null, + Map.of()); + } + + private static PlatformResponse handledSign(PlatformResponse handled) + throws JOSEException { + AepPlatform platform = builder(allow(), new PlatformFixtures.Keys(), false) + .signHandler((identity, request, context) -> completed(Optional.of(handled))) + .build(); + PlatformAgentIdentity identity = provision(platform, SERVICE_DID, PROVISION_KEY); + return platform.sign(identity.agentIdentityId(), signRequest(null), context(SIGN_KEY)) + .toCompletableFuture() + .join(); + } +} diff --git a/aep-platform/src/test/java/foundation/aep/platform/PlatformCoverageTest.java b/aep-platform/src/test/java/foundation/aep/platform/PlatformCoverageTest.java new file mode 100644 index 0000000..7d414dc --- /dev/null +++ b/aep-platform/src/test/java/foundation/aep/platform/PlatformCoverageTest.java @@ -0,0 +1,609 @@ +package foundation.aep.platform; + +import static foundation.aep.platform.PlatformFixtures.DID_HOST; +import static foundation.aep.platform.PlatformFixtures.DID_PREFIX; +import static foundation.aep.platform.PlatformFixtures.JWT_ID; +import static foundation.aep.platform.PlatformFixtures.NOW; +import static foundation.aep.platform.PlatformFixtures.PRINCIPAL; +import static foundation.aep.platform.PlatformFixtures.PROVISION_KEY; +import static foundation.aep.platform.PlatformFixtures.SERVICE_DID; +import static foundation.aep.platform.PlatformFixtures.allow; +import static foundation.aep.platform.PlatformFixtures.builder; +import static foundation.aep.platform.PlatformFixtures.completed; +import static foundation.aep.platform.PlatformFixtures.context; +import static foundation.aep.platform.PlatformFixtures.discovery; +import static foundation.aep.platform.PlatformFixtures.platform; +import static foundation.aep.platform.PlatformFixtures.provision; +import static foundation.aep.platform.PlatformFixtures.record; +import static foundation.aep.platform.PlatformFixtures.resolver; +import static foundation.aep.platform.PlatformFixtures.signRequest; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.nimbusds.jose.JOSEException; +import foundation.aep.core.Aep; +import foundation.aep.core.AssertionOperation; +import foundation.aep.core.ClientAssertionClaims; +import foundation.aep.core.ManagedAgentStatus; +import foundation.aep.core.PlatformAgentIdentity; +import foundation.aep.core.PlatformLifecycleRequest; +import foundation.aep.core.PlatformProvisionRequest; +import foundation.aep.core.PlatformSignRequest; +import foundation.aep.core.PlatformSignResponses; +import foundation.aep.core.PlatformVerificationRequest; +import foundation.aep.core.ProblemDetails; +import java.time.Duration; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; + +/** Exercises the remaining refusal, accessor, and store paths of the AEP Platform module. */ +final class PlatformCoverageTest { + private static final String AGENT_DID = "did:web:p.example:a:opaque"; + private static final String SIGN_KEY = "sign"; + private static final String BLANK = " "; + private static final String CONTROLLER = "did:web:p.example:a:other"; + private static final String HEADER = "X-Trace"; + private static final String OTHER_SERVICE = "did:web:other.example"; + + @Test + void refusesMissingOrMalformedPathParametersAndBodies() throws JOSEException { + AepPlatform platform = platform(allow(), new PlatformFixtures.Keys(), false); + PlatformAgentIdentity identity = provision(platform, SERVICE_DID, PROVISION_KEY); + + assertEquals(404, status(platform.sign(BLANK, signRequest(null), context(SIGN_KEY)))); + assertEquals(400, status(platform.sign(identity.agentIdentityId(), null, context(SIGN_KEY)))); + assertEquals(404, status(platform.updateIdentity(null, active(), context(null)))); + assertEquals(400, status(platform.updateIdentity(identity.agentIdentityId(), null, context(null)))); + assertEquals(404, status(platform.updateIdentity("pai_missing", active(), context(null)))); + assertEquals(404, status(platform.getIdentity(null, context(null)))); + assertEquals(404, status(platform.getIdentity("pai_missing", context(null)))); + assertEquals( + 400, status(platform(allow(), new PlatformFixtures.Keys(), true).verify(null, context("verify")))); + } + + @Test + void refusesSigningForAServiceTheIdentityIsNotScopedTo() throws JOSEException { + AepPlatform platform = platform(allow(), new PlatformFixtures.Keys(), false); + PlatformAgentIdentity identity = provision(platform, SERVICE_DID, PROVISION_KEY); + PlatformSignRequest crossService = new PlatformSignRequest( + JWT_ID, null, AssertionOperation.ENROLL, null, null, "did:web:elsewhere.example"); + + assertEquals(404, status(platform.sign(identity.agentIdentityId(), crossService, context(SIGN_KEY)))); + } + + @Test + void rejectsSignHandlerResponsesThatAreNotAValidSigningResult() throws JOSEException { + assertEquals(200, handled(null).status(), "an absent handler result falls back to the key store"); + assertThrows( + CompletionException.class, + () -> handled(new PlatformResponse<>(500, Aep.MEDIA_TYPE, null, null, Map.of())), + "a 500 with no body is neither success nor problem"); + assertThrows( + CompletionException.class, + () -> handled(new PlatformResponse<>(202, "text/plain", pending(), null, Map.of())), + "the media type has to be application/aep+json"); + assertThrows( + CompletionException.class, + () -> handled(new PlatformResponse<>(200, Aep.MEDIA_TYPE, pending(), null, Map.of())), + "a pending response has to be 202"); + assertThrows( + CompletionException.class, + () -> handled(new PlatformResponse<>( + 400, Aep.PROBLEM_MEDIA_TYPE, null, ProblemDetails.of("invalid_request", "No.", 503), Map.of())), + "a problem whose status disagrees with the response is not valid"); + } + + @Test + void rejectsDidDocumentsWhoseVerificationMethodIsNotTheAgentDid() throws JOSEException { + PlatformFixtures.Keys keys = new PlatformFixtures.Keys() { + @Override + public CompletionStage didVerificationMethod( + PlatformIdentityRecord identity, PlatformRequestContext context) { + return completed(new PlatformDidVerificationMethod( + CONTROLLER, CONTROLLER, key.toPublicJWK().toJSONObject(), "JsonWebKey2020")); + } + }; + AepPlatform platform = platform(allow(), keys, false); + PlatformAgentIdentity identity = provision(platform, SERVICE_DID, PROVISION_KEY); + String agentDidId = identity.agentDid().substring(identity.agentDid().lastIndexOf(':') + 1); + + assertThrows( + CompletionException.class, + () -> platform.getDidDocument(agentDidId, context(null)) + .toCompletableFuture() + .join()); + } + + @Test + void rejectsUnusableConstructionInput() throws JOSEException { + PlatformFixtures.Keys keys = new PlatformFixtures.Keys(); + + assertThrows( + IllegalArgumentException.class, + () -> AepPlatform.builder(discovery(false), BLANK, allow(), keys, resolver(true)) + .build(), + "didHost is required"); + assertThrows( + IllegalArgumentException.class, + () -> AepPlatform.builder(discovery(false), "host/path", allow(), keys, resolver(true)) + .build(), + "didHost is a host and a port, nothing else"); + assertThrows( + IllegalArgumentException.class, + () -> builder(allow(), keys, false).didPathPrefix("a/../b").build(), + "dot segments never reach a did:web path"); + assertThrows( + IllegalArgumentException.class, + () -> builder(allow(), keys, false) + .maximumLifetime(Duration.ofMillis(1500)) + .build(), + "the maximum is a whole number of seconds"); + assertThrows( + IllegalArgumentException.class, + () -> builder(discovery(false, "https://p.example/a/did.json", "300"), allow(), keys, resolver(true)) + .build(), + "the template has to name the identifier"); + assertThrows( + IllegalArgumentException.class, + () -> builder( + discovery(false, "http://p.example/a/{agent_did_id}/did.json", "300"), + allow(), + keys, + resolver(true)) + .build(), + "the template renders an HTTPS URL"); + assertThrows( + CompletionException.class, + () -> builder(allow(), keys, false) + .identityIdSupplier(() -> BLANK) + .build() + .provision(new PlatformProvisionRequest(SERVICE_DID), context(PROVISION_KEY)) + .toCompletableFuture() + .join() + .body(), + "a generated identifier must not be blank"); + } + + @Test + void acceptsEveryConfigurableCollaborator() throws JOSEException { + PlatformIdentityStore identityStore = PlatformIdentityStore.inMemory(); + PlatformIdempotencyStore idempotencyStore = PlatformIdempotencyStore.inMemory(PlatformFixtures.CLOCK); + AepPlatform platform = builder(allow(), new PlatformFixtures.Keys(), false) + .identityStore(identityStore) + .idempotencyStore(idempotencyStore) + .lifecyclePolicy(PlatformLifecyclePolicy.permissiveTransitions()) + .maximumLifetime(Duration.ofSeconds(300)) + .build(); + + assertEquals( + 200, status(platform.provision(new PlatformProvisionRequest(SERVICE_DID), context(PROVISION_KEY)))); + assertThrows( + NullPointerException.class, + () -> builder(allow(), new PlatformFixtures.Keys(), false).identityStore(null)); + assertThrows( + NullPointerException.class, + () -> builder(allow(), new PlatformFixtures.Keys(), false).idempotencyStore(null)); + assertThrows( + NullPointerException.class, + () -> builder(allow(), new PlatformFixtures.Keys(), false).signHandler(null)); + assertThrows( + NullPointerException.class, + () -> builder(allow(), new PlatformFixtures.Keys(), false).replayStore(null)); + } + + @Test + void generatesOpaqueIdentifiersWhenNoSupplierIsConfigured() throws JOSEException { + AepPlatform platform = AepPlatform.builder( + discovery(false), DID_HOST, allow(), new PlatformFixtures.Keys(), resolver(true)) + .clock(PlatformFixtures.CLOCK) + .didPathPrefix(DID_PREFIX) + .build(); + + PlatformAgentIdentity identity = provision(platform, SERVICE_DID, PROVISION_KEY); + + assertTrue(identity.agentIdentityId().startsWith("pai_")); + assertFalse(identity.agentDid().contains("-")); + assertEquals( + 32, + identity.agentDid() + .substring(identity.agentDid().lastIndexOf(':') + 1) + .length()); + } + + @Test + void foldsNumericPlatformContextIntoTheIdempotencyFingerprint() throws JOSEException { + AepPlatform platform = platform(allow(), new PlatformFixtures.Keys(), false); + PlatformAgentIdentity identity = provision(platform, SERVICE_DID, PROVISION_KEY); + Map first = new LinkedHashMap<>(); + first.put("attempt", 1); + first.put("nested", List.of(true, "x")); + Map second = new LinkedHashMap<>(); + second.put("attempt", 2); + second.put("nested", List.of(true, "x")); + + assertEquals(200, status(platform.sign(identity.agentIdentityId(), contextual(first), context(SIGN_KEY)))); + assertEquals(200, status(platform.sign(identity.agentIdentityId(), contextual(first), context(SIGN_KEY)))); + assertEquals(409, status(platform.sign(identity.agentIdentityId(), contextual(second), context(SIGN_KEY)))); + } + + @Test + void reportsIdempotencyConflictsAcrossOperationsAndInFlightRequests() { + PlatformIdempotencyStore store = PlatformIdempotencyStore.inMemory(PlatformFixtures.CLOCK); + PlatformIdempotencyInput sign = + new PlatformIdempotencyInput(PRINCIPAL, "k", PlatformIdempotentOperation.SIGN, "hash"); + PlatformIdempotencyInput provision = + new PlatformIdempotencyInput(PRINCIPAL, "k", PlatformIdempotentOperation.PROVISION, "hash"); + CompletableFuture> inFlight = new CompletableFuture<>(); + + assertEquals( + PlatformIdempotencyResult.State.CREATED, + store.execute(sign, () -> completed(ok("first"))) + .toCompletableFuture() + .join() + .state()); + assertEquals( + PlatformIdempotencyResult.State.REPLAYED, + store.execute(sign, () -> completed(ok("second"))) + .toCompletableFuture() + .join() + .state()); + assertEquals( + PlatformIdempotencyResult.State.CONFLICT, + store.execute(provision, () -> completed(ok("third"))) + .toCompletableFuture() + .join() + .state()); + + PlatformIdempotencyStore pendingStore = PlatformIdempotencyStore.inMemory(PlatformFixtures.CLOCK); + var started = pendingStore.execute(sign, () -> inFlight).toCompletableFuture(); + var joined = pendingStore.execute(sign, () -> completed(ok("ignored"))).toCompletableFuture(); + assertEquals( + PlatformIdempotencyResult.State.CONFLICT, + pendingStore + .execute(provision, () -> completed(ok("nope"))) + .toCompletableFuture() + .join() + .state()); + inFlight.complete(ok("done")); + + assertEquals("done", started.join().response().body()); + assertEquals(PlatformIdempotencyResult.State.REPLAYED, joined.join().state()); + } + + @Test + void surfacesIdempotentOperationFailuresWithoutStoringThem() { + PlatformIdempotencyStore store = PlatformIdempotencyStore.inMemory(PlatformFixtures.CLOCK); + PlatformIdempotencyInput input = + new PlatformIdempotencyInput(PRINCIPAL, "k", PlatformIdempotentOperation.SIGN, "hash"); + + assertThrows( + CompletionException.class, + () -> store.execute(input, () -> { + throw new IllegalStateException("boom"); + }) + .toCompletableFuture() + .join()); + assertThrows( + CompletionException.class, + () -> store.execute(input, () -> completed(null)) + .toCompletableFuture() + .join()); + assertThrows( + IllegalArgumentException.class, + () -> store.execute( + new PlatformIdempotencyInput(BLANK, "k", PlatformIdempotentOperation.SIGN, "hash"), + () -> completed(ok("x")))); + assertThrows(IllegalArgumentException.class, () -> store.execute(null, () -> completed(ok("x")))); + assertEquals( + PlatformIdempotencyResult.State.CREATED, + store.execute(input, () -> completed(ok("retry"))) + .toCompletableFuture() + .join() + .state()); + } + + @Test + void keepsIdempotentResultsForTheFullRetentionWindow() { + AtomicInteger executions = new AtomicInteger(); + java.time.Instant[] instant = {NOW}; + java.time.Clock moving = new java.time.Clock() { + @Override + public java.time.ZoneId getZone() { + return java.time.ZoneOffset.UTC; + } + + @Override + public java.time.Clock withZone(java.time.ZoneId zone) { + return this; + } + + @Override + public java.time.Instant instant() { + return instant[0]; + } + }; + PlatformIdempotencyStore store = PlatformIdempotencyStore.inMemory(moving); + PlatformIdempotencyInput input = + new PlatformIdempotencyInput(PRINCIPAL, "k", PlatformIdempotentOperation.SIGN, "hash"); + java.util.function.Supplier>> operation = + () -> completed(ok("run-" + executions.incrementAndGet())); + + store.execute(input, operation).toCompletableFuture().join(); + instant[0] = NOW.plus(Duration.ofHours(1)); + var atTheBoundary = + store.execute(input, operation).toCompletableFuture().join(); + instant[0] = NOW.plus(Duration.ofHours(1)).plusNanos(1); + var afterTheBoundary = + store.execute(input, operation).toCompletableFuture().join(); + + assertEquals(PlatformIdempotencyResult.State.REPLAYED, atTheBoundary.state()); + assertEquals("run-1", atTheBoundary.response().body()); + assertEquals(PlatformIdempotencyResult.State.CREATED, afterTheBoundary.state()); + assertEquals(2, executions.get()); + } + + @Test + void refusesIdentityRecordsThatDoNotMatchTheirRequestedScope() { + PlatformIdentityStore store = PlatformIdentityStore.inMemory(); + + assertThrows( + CompletionException.class, + () -> store.findOrCreate(PRINCIPAL, SERVICE_DID, () -> { + throw new IllegalStateException("boom"); + }) + .toCompletableFuture() + .join(), + "a creation failure reaches the caller"); + assertThrows( + CompletionException.class, + () -> store.findOrCreate("other", SERVICE_DID, () -> completed(record(ManagedAgentStatus.ACTIVE))) + .toCompletableFuture() + .join(), + "a record has to belong to the scope it was created for"); + assertThrows( + CompletionException.class, + () -> store.findOrCreate( + PRINCIPAL, + SERVICE_DID, + () -> CompletableFuture.failedFuture(new IllegalStateException("boom"))) + .toCompletableFuture() + .join(), + "an asynchronous creation failure reaches the caller too"); + + store.findOrCreate(PRINCIPAL, SERVICE_DID, () -> completed(record(ManagedAgentStatus.ACTIVE))) + .toCompletableFuture() + .join(); + + assertThrows( + CompletionException.class, + () -> store.findOrCreate( + PRINCIPAL, + OTHER_SERVICE, + () -> completed(record(ManagedAgentStatus.ACTIVE).toBuilder() + .serviceDid(OTHER_SERVICE) + .build())) + .toCompletableFuture() + .join(), + "identity material stays unique across scopes"); + assertThrows( + IllegalArgumentException.class, + () -> store.list(PRINCIPAL, new PlatformIdentityListQuery(false, 0, 0, null, null))); + assertThrows(IllegalArgumentException.class, () -> store.findOrCreate(BLANK, SERVICE_DID, () -> null)); + assertEquals( + Optional.empty(), + store.findByAgentDid("did:web:absent.example") + .toCompletableFuture() + .join()); + assertEquals( + Optional.empty(), + store.findByAgentDidId("absent").toCompletableFuture().join()); + assertEquals( + Optional.empty(), + store.update("pai_absent", ManagedAgentStatus.REVOKED, NOW) + .toCompletableFuture() + .join()); + } + + @Test + void refusesUnusableReplayInput() { + PlatformReplayStore store = PlatformReplayStore.inMemory(); + + assertThrows( + CompletionException.class, + () -> store.consume(BLANK, NOW.plusSeconds(60), NOW, null) + .toCompletableFuture() + .join()); + assertFalse(store.consume("k", NOW, NOW, null).toCompletableFuture().join(), "an expired key is never fresh"); + assertTrue(store.consume("k", NOW.plusSeconds(60), NOW, null) + .toCompletableFuture() + .join()); + } + + @Test + void copiesAndValidatesResponseHeaders() { + assertThrows(IllegalArgumentException.class, () -> headers(Map.of("Bad Name", List.of("v")))); + assertThrows(IllegalArgumentException.class, () -> headers(Map.of(HEADER, List.of()))); + assertThrows(IllegalArgumentException.class, () -> headers(Map.of(HEADER, List.of(" padded")))); + assertThrows(IllegalArgumentException.class, () -> headers(Map.of(HEADER, List.of("a\r\nX-Evil: 1")))); + assertThrows(IllegalArgumentException.class, () -> headers(Map.of(HEADER, List.of("a\u007f")))); + assertEquals(Map.of(), new PlatformResponse<>(200, Aep.MEDIA_TYPE, "b", null, null).headers()); + assertEquals(List.of("a\tb"), headers(Map.of(HEADER, List.of("a\tb"))).get(HEADER)); + } + + @Test + void copiesAndValidatesJsonValues() { + Map nonStringKey = new LinkedHashMap<>(); + nonStringKey.put(1, "value"); + Map nested = new LinkedHashMap<>(); + nested.put("inner", nonStringKey); + + assertThrows(IllegalArgumentException.class, () -> PlatformCopies.jsonValue(nested)); + assertThrows(IllegalArgumentException.class, () -> PlatformCopies.jsonValue(Double.NaN)); + assertThrows(IllegalArgumentException.class, () -> PlatformCopies.jsonValue(Float.POSITIVE_INFINITY)); + assertThrows(IllegalArgumentException.class, () -> PlatformCopies.jsonValue(new Object())); + assertThrows( + IllegalArgumentException.class, + () -> PlatformCopies.jsonObject(java.util.Collections.singletonMap(null, "v"))); + assertEquals(List.of("a", "b"), PlatformCopies.jsonValue(new ArrayList<>(List.of("a", "b")))); + assertEquals(1.5d, PlatformCopies.jsonValue(1.5d)); + assertEquals(Map.of(), PlatformCopies.jsonObject(null)); + } + + @Test + void exposesAuthorizationRequestSubjects() throws JOSEException { + PlatformIdentityRecord identity = record(ManagedAgentStatus.ACTIVE); + PlatformSignRequest sign = signRequest(null); + PlatformProvisionRequest provision = new PlatformProvisionRequest(SERVICE_DID); + PlatformLifecycleRequest lifecycle = active(); + PlatformVerificationRequest verification = + new PlatformVerificationRequest("token", AssertionOperation.ENROLL, null, SERVICE_DID); + PlatformIdentityListQuery query = PlatformIdentityListQuery.defaults(); + + assertSame(identity, PlatformAuthorizationRequest.getIdentity(identity).identity()); + assertSame(query, PlatformAuthorizationRequest.list(query).listQuery()); + assertSame(provision, PlatformAuthorizationRequest.provision(provision).provisionRequest()); + assertSame(sign, PlatformAuthorizationRequest.sign(identity, sign).signRequest()); + assertSame( + lifecycle, + PlatformAuthorizationRequest.update(identity, lifecycle).lifecycleRequest()); + assertSame( + verification, + PlatformAuthorizationRequest.verify(identity, verification).verificationRequest()); + assertEquals( + PlatformAuthorizationOperation.VERIFY, + PlatformAuthorizationRequest.verify(identity, verification).operation()); + assertEquals(null, PlatformAuthorizationRequest.list(query).identity()); + assertThrows(NullPointerException.class, () -> new PlatformIdentityListResult(null, 0)); + } + + @Test + void guardsRecordAndDocumentInvariants() { + PlatformIdentityRecord identity = record(ManagedAgentStatus.ACTIVE); + PlatformDidVerificationMethod method = + new PlatformDidVerificationMethod(AGENT_DID, AGENT_DID, Map.of("kty", "EC"), "JsonWebKey2020"); + PlatformDidDocument document = new PlatformDidDocument( + List.of("https://www.w3.org/ns/did/v1"), List.of(AGENT_DID), AGENT_DID, List.of(method)); + + assertThrows( + IllegalArgumentException.class, + () -> identity.toBuilder().signingAlgorithms(List.of()).build()); + assertThrows( + IllegalArgumentException.class, + () -> identity.toBuilder().agentDid(BLANK).build()); + assertThrows( + NullPointerException.class, + () -> identity.toBuilder().status(null).build()); + assertThrows( + IllegalArgumentException.class, + () -> new PlatformDidVerificationMethod(AGENT_DID, AGENT_DID, Map.of(), "JsonWebKey2020")); + assertThrows( + IllegalArgumentException.class, + () -> new PlatformDidVerificationMethod(BLANK, AGENT_DID, Map.of("kty", "EC"), "JsonWebKey2020")); + assertThrows( + IllegalArgumentException.class, + () -> new PlatformDidVerificationMethod(AGENT_DID, BLANK, Map.of("kty", "EC"), "JsonWebKey2020")); + assertThrows( + IllegalArgumentException.class, + () -> new PlatformDidVerificationMethod(AGENT_DID, AGENT_DID, Map.of("kty", "EC"), BLANK)); + assertThrows(IllegalArgumentException.class, () -> new PlatformIdentityListResult(List.of(identity), 0)); + assertEquals(List.of("https://www.w3.org/ns/did/v1"), document.context()); + assertEquals(List.of(method), document.verificationMethods()); + assertEquals(Map.of("kty", "EC"), method.publicKeyJwk()); + assertFalse(identity.toString().contains(PRINCIPAL)); + assertTrue(identity.toString().contains(AGENT_DID)); + assertEquals( + 1, + new PlatformIdentityListResult(List.of(identity), 1) + .identities() + .size()); + } + + @Test + void appliesRequestContextDefaults() { + PlatformRequestContext terse = new PlatformRequestContext(PRINCIPAL, "key"); + + assertEquals(null, terse.now()); + assertEquals(Map.of(), terse.values()); + assertTrue(terse.toString().contains("valueNames=[]")); + } + + @Test + void signsAgainstTheConfiguredClockWhenNoRequestInstantIsSupplied() throws JOSEException { + AepPlatform platform = platform(allow(), new PlatformFixtures.Keys(), false); + PlatformAgentIdentity identity = provision(platform, SERVICE_DID, PROVISION_KEY); + + var signed = (PlatformSignResponses.Completed) platform.sign( + identity.agentIdentityId(), signRequest(null), new PlatformRequestContext(PRINCIPAL, SIGN_KEY)) + .toCompletableFuture() + .join() + .body(); + + assertEquals("2026-07-06T12:00:00Z", signed.issuedAt()); + assertEquals("2026-07-06T12:05:00Z", signed.expiresAt()); + } + + @Test + void reportsBlankAssertionsFromAKeyStoreAsAFailure() throws JOSEException { + PlatformFixtures.Keys keys = new PlatformFixtures.Keys() { + @Override + public CompletionStage sign( + PlatformIdentityRecord identity, ClientAssertionClaims claims, PlatformRequestContext context) { + return completed(BLANK); + } + }; + AepPlatform platform = platform(allow(), keys, false); + PlatformAgentIdentity identity = provision(platform, SERVICE_DID, PROVISION_KEY); + + assertThrows( + CompletionException.class, + () -> platform.sign(identity.agentIdentityId(), signRequest(null), context(SIGN_KEY)) + .toCompletableFuture() + .join()); + } + + private static PlatformLifecycleRequest active() { + return new PlatformLifecycleRequest(ManagedAgentStatus.ACTIVE); + } + + private static PlatformSignRequest contextual(Map platformContext) { + return new PlatformSignRequest(JWT_ID, null, AssertionOperation.ENROLL, platformContext, null, SERVICE_DID); + } + + private static PlatformSignResponses.Response pending() { + return new PlatformSignResponses.Pending("pending", null, "5"); + } + + private static PlatformResponse ok(String body) { + return new PlatformResponse<>(200, Aep.MEDIA_TYPE, body, null, Map.of()); + } + + private static Map> headers(Map> source) { + return new PlatformResponse<>(200, Aep.MEDIA_TYPE, "body", null, source).headers(); + } + + private static int status(CompletionStage> stage) { + return stage.toCompletableFuture().join().status(); + } + + private static PlatformResponse handled(PlatformResponse response) + throws JOSEException { + AepPlatform platform = builder(allow(), new PlatformFixtures.Keys(), false) + .signHandler((identity, request, context) -> completed(Optional.ofNullable(response))) + .build(); + PlatformAgentIdentity identity = provision(platform, SERVICE_DID, PROVISION_KEY); + return platform.sign(identity.agentIdentityId(), signRequest(null), context(SIGN_KEY)) + .toCompletableFuture() + .join(); + } +} diff --git a/aep-platform/src/test/java/foundation/aep/platform/PlatformEdgeTest.java b/aep-platform/src/test/java/foundation/aep/platform/PlatformEdgeTest.java new file mode 100644 index 0000000..16aa02e --- /dev/null +++ b/aep-platform/src/test/java/foundation/aep/platform/PlatformEdgeTest.java @@ -0,0 +1,701 @@ +package foundation.aep.platform; + +import static foundation.aep.platform.PlatformFixtures.CLOCK; +import static foundation.aep.platform.PlatformFixtures.DID_HOST; +import static foundation.aep.platform.PlatformFixtures.JWT_ID; +import static foundation.aep.platform.PlatformFixtures.NOW; +import static foundation.aep.platform.PlatformFixtures.OTHER_SERVICE_DID; +import static foundation.aep.platform.PlatformFixtures.PRINCIPAL; +import static foundation.aep.platform.PlatformFixtures.PROVISION_KEY; +import static foundation.aep.platform.PlatformFixtures.SERVICE_DID; +import static foundation.aep.platform.PlatformFixtures.allow; +import static foundation.aep.platform.PlatformFixtures.builder; +import static foundation.aep.platform.PlatformFixtures.completed; +import static foundation.aep.platform.PlatformFixtures.context; +import static foundation.aep.platform.PlatformFixtures.discovery; +import static foundation.aep.platform.PlatformFixtures.platform; +import static foundation.aep.platform.PlatformFixtures.provision; +import static foundation.aep.platform.PlatformFixtures.record; +import static foundation.aep.platform.PlatformFixtures.resolver; +import static foundation.aep.platform.PlatformFixtures.signRequest; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.nimbusds.jose.JOSEException; +import foundation.aep.core.Aep; +import foundation.aep.core.AssertionOperation; +import foundation.aep.core.ClientAssertionClaims; +import foundation.aep.core.ClientAssertions; +import foundation.aep.core.ManagedAgentStatus; +import foundation.aep.core.PlatformAgentIdentity; +import foundation.aep.core.PlatformSignResponses; +import foundation.aep.core.PlatformVerificationRequest; +import foundation.aep.core.ProblemDetails; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CompletionException; +import java.util.concurrent.CompletionStage; +import org.junit.jupiter.api.Test; + +/** Boundary conditions across the AEP Platform surface. */ +final class PlatformEdgeTest { + private static final String SIGN_KEY = "sign"; + private static final String BLANK = " "; + private static final String AGENT_DID = "did:web:p.example:a:opaque1"; + private static final String NOT_RECOGNIZED = "not_recognized"; + private static final String KTY = "kty"; + private static final String EC = "EC"; + private static final String METHOD_TYPE = "JsonWebKey2020"; + private static final String ISSUED_AT = "2026-07-06T12:00:00Z"; + private static final String EXPIRES_AT = "2026-07-06T12:05:00Z"; + + @Test + void treatsAbsentAndBlankPathParametersAlike() throws JOSEException { + AepPlatform platform = platform(allow(), new PlatformFixtures.Keys(), false); + provision(platform, SERVICE_DID, PROVISION_KEY); + + assertEquals(404, status(platform.getDidDocument(null, context(null)))); + assertEquals(404, status(platform.getDidDocument("absent", context(null)))); + assertEquals(404, status(platform.sign(null, signRequest(null), context(SIGN_KEY)))); + assertEquals(404, status(platform.updateIdentity(BLANK, lifecycle(), context(null)))); + assertEquals(404, status(platform.getIdentity(BLANK, context(null)))); + } + + @Test + void appliesListDefaultsAndPrincipalScoping() throws JOSEException { + AepPlatform platform = platform(allow(), new PlatformFixtures.Keys(), false); + provision(platform, SERVICE_DID, PROVISION_KEY); + + var defaults = platform.list(null, context(null)).toCompletableFuture().join(); + var anonymous = platform.list(null, new PlatformRequestContext(null, null, NOW, Map.of())) + .toCompletableFuture() + .join(); + var filtered = platform.list( + new PlatformIdentityListQuery(false, 100, 0, null, ManagedAgentStatus.REVOKED), context(null)) + .toCompletableFuture() + .join(); + var pastTheEnd = platform.list(new PlatformIdentityListQuery(false, 100, 5, null, null), context(null)) + .toCompletableFuture() + .join(); + + assertEquals("1", defaults.body().count()); + assertEquals(404, anonymous.status()); + assertEquals("0", filtered.body().count()); + assertEquals("0", filtered.body().total(), "total counts what the whole query matches"); + assertEquals("0", pastTheEnd.body().count()); + } + + @Test + void keepsSuppliedIdentityIdentifierPrefixes() throws JOSEException { + AepPlatform platform = builder(allow(), new PlatformFixtures.Keys(), false) + .identityIdSupplier(() -> "pai_supplied") + .build(); + + assertEquals( + "pai_supplied", provision(platform, SERVICE_DID, PROVISION_KEY).agentIdentityId()); + } + + @Test + void rejectsEveryShapeOfInvalidSignHandlerResult() throws JOSEException { + assertThrows( + CompletionException.class, + () -> handled(new PlatformResponse<>(200, Aep.MEDIA_TYPE, null, null, Map.of())), + "a success needs a body"); + assertThrows( + CompletionException.class, + () -> handled(new PlatformResponse<>( + 200, + Aep.MEDIA_TYPE, + completedBody(AGENT_DID, SERVICE_DID, JWT_ID), + ProblemDetails.of("invalid_request", "No.", 200), + Map.of())), + "a success carries no problem"); + assertThrows( + CompletionException.class, + () -> handled(new PlatformResponse<>( + 200, Aep.MEDIA_TYPE, completedBody("not-a-did", SERVICE_DID, JWT_ID), null, Map.of())), + "the body still has to satisfy the schema"); + assertThrows( + CompletionException.class, + () -> handled(new PlatformResponse<>( + 200, Aep.MEDIA_TYPE, completedBody(AGENT_DID, OTHER_SERVICE_DID, JWT_ID), null, Map.of())), + "service_did has to echo the request"); + assertThrows( + CompletionException.class, + () -> handled(new PlatformResponse<>( + 200, Aep.MEDIA_TYPE, completedBody(AGENT_DID, SERVICE_DID, "other"), null, Map.of())), + "jti has to echo the request"); + assertThrows( + CompletionException.class, + () -> handled(new PlatformResponse<>( + 202, Aep.MEDIA_TYPE, completedBody(AGENT_DID, SERVICE_DID, JWT_ID), null, Map.of())), + "a completed response is 200"); + assertThrows( + CompletionException.class, + () -> handled(new PlatformResponse<>( + 404, + Aep.PROBLEM_MEDIA_TYPE, + null, + new ProblemDetails(null, null, 404, null, null, null, null, null, null), + Map.of())), + "a problem document still has to validate"); + assertThrows( + CompletionException.class, + () -> handled(new PlatformResponse<>( + 404, Aep.MEDIA_TYPE, null, ProblemDetails.of(NOT_RECOGNIZED, "Gone.", 404), Map.of())), + "a problem uses the problem media type"); + assertThrows( + CompletionException.class, + () -> handled(new PlatformResponse<>( + 200, Aep.PROBLEM_MEDIA_TYPE, null, ProblemDetails.of(NOT_RECOGNIZED, "Gone.", 200), Map.of())), + "a problem status is an error status"); + } + + @Test + void refusesAssertionsThatDoNotBindIssuerSubjectAndIdentity() throws JOSEException { + PlatformFixtures.Keys keys = new PlatformFixtures.Keys(); + AepPlatform platform = platform(allow(), keys, true); + PlatformAgentIdentity identity = provision(platform, SERVICE_DID, PROVISION_KEY); + String mismatchedSubject = + unbound(keys, identity.agentDid(), "did:web:p.example:a:someone-else", identity.agentDid()); + String unknownIssuer = ClientAssertions.sign( + new ClientAssertionClaims( + "did:web:p.example:a:unknown", + "did:web:p.example:a:unknown", + SERVICE_DID, + AssertionOperation.ENROLL, + NOW.getEpochSecond(), + NOW.plusSeconds(60).getEpochSecond(), + JWT_ID, + null), + keys.key, + "did:web:p.example:a:unknown"); + + assertFalse(verified(platform, mismatchedSubject, "v1")); + assertFalse(verified(platform, unknownIssuer, "v2")); + } + + @Test + void refusesHostedVerificationTheAuthorizerOrPolicyDeclines() throws JOSEException { + PlatformFixtures.Keys keys = new PlatformFixtures.Keys(); + AepPlatform denying = platform((request, context) -> completed(false), keys, true); + AepPlatform refusing = builder(allow(), keys, true) + .lifecyclePolicy(policy(true, false)) + .build(); + AepPlatform signing = platform(allow(), keys, true); + PlatformAgentIdentity identity = provision(signing, SERVICE_DID, PROVISION_KEY); + String assertion = ((PlatformSignResponses.Completed) + signing.sign(identity.agentIdentityId(), signRequest(null), context(SIGN_KEY)) + .toCompletableFuture() + .join() + .body()) + .clientAssertion(); + provision(denying, SERVICE_DID, PROVISION_KEY); + provision(refusing, SERVICE_DID, PROVISION_KEY); + + assertFalse(verified(denying, assertion, "v1")); + assertFalse(verified(refusing, assertion, "v2")); + } + + @Test + void verifiesAgainstTheConfiguredClockWhenNoRequestInstantIsSupplied() throws JOSEException { + PlatformFixtures.Keys keys = new PlatformFixtures.Keys(); + AepPlatform platform = platform(allow(), keys, true); + PlatformAgentIdentity identity = provision(platform, SERVICE_DID, PROVISION_KEY); + String assertion = ((PlatformSignResponses.Completed) + platform.sign(identity.agentIdentityId(), signRequest(null), context(SIGN_KEY)) + .toCompletableFuture() + .join() + .body()) + .clientAssertion(); + + assertTrue(platform.verify( + new PlatformVerificationRequest(assertion, AssertionOperation.ENROLL, null, SERVICE_DID), + new PlatformRequestContext(PRINCIPAL, "verify")) + .toCompletableFuture() + .join() + .body() + .verified()); + } + + @Test + void scopesAuthorizedIdentitiesToTheCallingPrincipal() throws JOSEException { + AepPlatform platform = platform(allow(), new PlatformFixtures.Keys(), false); + PlatformAgentIdentity identity = provision(platform, SERVICE_DID, PROVISION_KEY); + + assertEquals( + 404, + status(platform.getIdentity( + identity.agentIdentityId(), new PlatformRequestContext("someone-else", null, NOW, Map.of())))); + assertEquals( + 404, + status(platform.getIdentity( + identity.agentIdentityId(), new PlatformRequestContext(null, null, NOW, Map.of())))); + assertEquals(200, status(platform.getIdentity(identity.agentIdentityId(), context(null)))); + } + + @Test + void rejectsDidDocumentsWhoseControllerIsNotTheAgentDid() throws JOSEException { + PlatformFixtures.Keys keys = new PlatformFixtures.Keys() { + @Override + public CompletionStage didVerificationMethod( + PlatformIdentityRecord identity, PlatformRequestContext context) { + return completed(new PlatformDidVerificationMethod( + "did:web:p.example:a:other", + identity.agentDid(), + key.toPublicJWK().toJSONObject(), + METHOD_TYPE)); + } + }; + AepPlatform platform = platform(allow(), keys, false); + PlatformAgentIdentity identity = provision(platform, SERVICE_DID, PROVISION_KEY); + + assertThrows( + CompletionException.class, + () -> platform.getDidDocument("opaque1", context(null)) + .toCompletableFuture() + .join()); + assertEquals(identity.agentDid(), AGENT_DID); + } + + @Test + void validatesEveryConstructionArgumentIndependently() throws JOSEException { + PlatformFixtures.Keys keys = new PlatformFixtures.Keys(); + + assertThrows( + IllegalArgumentException.class, + () -> AepPlatform.builder(discovery(false), null, allow(), keys, resolver(true)) + .build(), + "didHost is required"); + assertThrows( + IllegalArgumentException.class, + () -> AepPlatform.builder(discovery(false), "user@p.example", allow(), keys, resolver(true)) + .build(), + "didHost carries no user information"); + assertThrows( + IllegalArgumentException.class, + () -> builder(allow(), keys, false).didPathPrefix("a/./b").build(), + "a dot segment is not a path component"); + assertThrows( + IllegalArgumentException.class, + () -> builder(allow(), keys, false).didPathPrefix("a//b").build(), + "an empty path component is not a path component"); + assertThrows( + IllegalArgumentException.class, + () -> builder(allow(), keys, false).didPathPrefix("").build(), + "a blank prefix falls back to the default, which no longer matches the template"); + assertThrows( + IllegalArgumentException.class, + () -> AepPlatform.builder(discovery(false), ":8443", allow(), keys, resolver(true)) + .build(), + "didHost names a host, not a bare port"); + assertThrows( + IllegalArgumentException.class, + () -> builder(allow(), keys, false) + .maximumLifetime(Duration.ZERO) + .build()); + assertThrows( + IllegalArgumentException.class, + () -> builder(allow(), keys, false) + .maximumLifetime(Duration.ofSeconds(-1)) + .build()); + assertThrows( + IllegalArgumentException.class, + () -> builder(allow(), keys, false) + .maximumLifetime(Duration.ofSeconds(301)) + .build()); + assertThrows( + NullPointerException.class, () -> builder(allow(), keys, false).maximumLifetime(null)); + assertThrows( + NullPointerException.class, + () -> AepPlatform.builder(discovery(false), DID_HOST, null, keys, resolver(true)) + .build()); + assertThrows( + NullPointerException.class, + () -> AepPlatform.builder(discovery(false), DID_HOST, allow(), null, resolver(true)) + .build()); + assertThrows( + NullPointerException.class, + () -> AepPlatform.builder(discovery(false), DID_HOST, allow(), keys, null) + .build()); + assertThrows( + CompletionException.class, + () -> builder(allow(), keys, false) + .agentDidIdSupplier(() -> null) + .build() + .provision(new foundation.aep.core.PlatformProvisionRequest(SERVICE_DID), context("p")) + .toCompletableFuture() + .join()); + } + + @Test + void appliesTheDefaultDidPathPrefixWhenNoneIsConfigured() throws JOSEException { + AepPlatform platform = builder( + discovery(false, "https://p.example/agents/{agent_did_id}/did.json", "300"), + allow(), + new PlatformFixtures.Keys(), + resolver(true)) + .didPathPrefix(null) + .build(); + + assertEquals( + "did:web:p.example:agents:opaque1", + provision(platform, SERVICE_DID, PROVISION_KEY).agentDid()); + } + + @Test + void validatesIdempotencyInputMemberByMember() { + PlatformIdempotencyStore store = PlatformIdempotencyStore.inMemory(CLOCK); + + assertThrows(IllegalArgumentException.class, () -> execute(store, null, "k", operation(), "h")); + assertThrows(IllegalArgumentException.class, () -> execute(store, BLANK, "k", operation(), "h")); + assertThrows(IllegalArgumentException.class, () -> execute(store, PRINCIPAL, null, operation(), "h")); + assertThrows(IllegalArgumentException.class, () -> execute(store, PRINCIPAL, BLANK, operation(), "h")); + assertThrows(IllegalArgumentException.class, () -> execute(store, PRINCIPAL, "k", null, "h")); + assertThrows(IllegalArgumentException.class, () -> execute(store, PRINCIPAL, "k", operation(), null)); + assertThrows(IllegalArgumentException.class, () -> execute(store, PRINCIPAL, "k", operation(), BLANK)); + assertThrows( + NullPointerException.class, + () -> store.execute(new PlatformIdempotencyInput(PRINCIPAL, "k", operation(), "h"), null)); + assertThrows(NullPointerException.class, () -> PlatformIdempotencyStore.inMemory(null)); + } + + @Test + void validatesReplayInputMemberByMember() { + PlatformReplayStore store = PlatformReplayStore.inMemory(); + + assertThrows( + CompletionException.class, + () -> store.consume(null, NOW, NOW, null).toCompletableFuture().join()); + assertThrows( + CompletionException.class, + () -> store.consume("k", null, NOW, null).toCompletableFuture().join()); + assertThrows( + CompletionException.class, + () -> store.consume("k", NOW, null, null).toCompletableFuture().join()); + assertTrue(store.consume("k", NOW.plusSeconds(60), NOW, null) + .toCompletableFuture() + .join()); + assertFalse(store.consume("k", NOW.plusSeconds(60), NOW, null) + .toCompletableFuture() + .join()); + assertTrue( + store.consume("k", NOW.plusSeconds(120), NOW.plusSeconds(61), null) + .toCompletableFuture() + .join(), + "an expired entry stops blocking its own key"); + } + + @Test + void validatesStoreAndRecordArgumentsMemberByMember() { + PlatformIdentityStore store = PlatformIdentityStore.inMemory(); + + assertThrows(IllegalArgumentException.class, () -> store.findOrCreate(null, SERVICE_DID, () -> null)); + assertThrows(IllegalArgumentException.class, () -> store.findOrCreate(PRINCIPAL, null, () -> null)); + assertThrows(NullPointerException.class, () -> store.findOrCreate(PRINCIPAL, SERVICE_DID, null)); + assertThrows(IllegalArgumentException.class, () -> store.list(null, PlatformIdentityListQuery.defaults())); + assertThrows(NullPointerException.class, () -> store.list(PRINCIPAL, null)); + assertThrows( + IllegalArgumentException.class, + () -> store.list(PRINCIPAL, new PlatformIdentityListQuery(false, 101, 0, null, null))); + assertThrows( + IllegalArgumentException.class, + () -> store.list(PRINCIPAL, new PlatformIdentityListQuery(false, 1, -1, null, null))); + assertThrows( + CompletionException.class, + () -> store.findOrCreate(PRINCIPAL, SERVICE_DID, () -> completed(null)) + .toCompletableFuture() + .join()); + assertThrows( + NullPointerException.class, + () -> record(ManagedAgentStatus.ACTIVE).toBuilder() + .createdAt(null) + .build()); + assertThrows( + IllegalArgumentException.class, + () -> record(ManagedAgentStatus.ACTIVE).toBuilder().keyId(null).build()); + } + + @Test + void validatesVerificationMethodMembersIndependently() { + assertThrows( + IllegalArgumentException.class, + () -> new PlatformDidVerificationMethod(null, AGENT_DID, Map.of(KTY, EC), METHOD_TYPE)); + assertThrows( + IllegalArgumentException.class, + () -> new PlatformDidVerificationMethod(AGENT_DID, null, Map.of(KTY, EC), METHOD_TYPE)); + assertThrows( + IllegalArgumentException.class, + () -> new PlatformDidVerificationMethod(AGENT_DID, AGENT_DID, Map.of(KTY, EC), null)); + assertThrows( + IllegalArgumentException.class, + () -> new PlatformDidVerificationMethod(AGENT_DID, AGENT_DID, null, METHOD_TYPE)); + } + + @Test + void validatesHeaderAndJsonCopiesMemberByMember() { + assertThrows( + IllegalArgumentException.class, () -> headers(java.util.Collections.singletonMap(null, List.of("v")))); + assertThrows(IllegalArgumentException.class, () -> headers(Map.of("", List.of("v")))); + assertThrows( + IllegalArgumentException.class, () -> headers(java.util.Collections.singletonMap("X-Trace", null))); + assertThrows( + IllegalArgumentException.class, + () -> headers(Map.of("X-Trace", java.util.Collections.singletonList(null)))); + assertThrows(IllegalArgumentException.class, () -> headers(Map.of("X-Trace", List.of("padded ")))); + assertEquals( + List.of("ok!#$%&'*+-.^_`|~"), + headers(Map.of("X-Trace", List.of("ok!#$%&'*+-.^_`|~"))).get("X-Trace")); + assertEquals( + List.of("v"), + headers(Map.of("x-trace-2!#$%&'*+.^_`|~", List.of("v"))).get("x-trace-2!#$%&'*+.^_`|~"), + "digits, both cases, and every RFC 9110 token character are header-name material"); + assertEquals(null, PlatformCopies.jsonValue(null)); + assertEquals(true, PlatformCopies.jsonValue(true)); + assertEquals(1.5f, PlatformCopies.jsonValue(1.5f)); + assertThrows(IllegalArgumentException.class, () -> PlatformCopies.jsonValue(Float.NaN)); + assertThrows(IllegalArgumentException.class, () -> PlatformCopies.jsonValue(Double.NEGATIVE_INFINITY)); + } + + @Test + void listsOnlyTheRecordsOwnedByTheCallingPrincipal() { + PlatformIdentityStore store = PlatformIdentityStore.inMemory(); + store.findOrCreate(PRINCIPAL, SERVICE_DID, () -> completed(record(ManagedAgentStatus.ACTIVE))) + .toCompletableFuture() + .join(); + store.findOrCreate( + "other-principal", + SERVICE_DID, + () -> completed(record(ManagedAgentStatus.ACTIVE).toBuilder() + .agentDid("did:web:p.example:a:second") + .agentDidId("second") + .agentIdentityId("pai_second") + .keyId("did:web:p.example:a:second") + .principal("other-principal") + .build())) + .toCompletableFuture() + .join(); + + var mine = store.list(PRINCIPAL, PlatformIdentityListQuery.defaults()) + .toCompletableFuture() + .join(); + + assertEquals(1, mine.identities().size()); + assertEquals(1, mine.total()); + assertEquals(PRINCIPAL, mine.identities().get(0).principal()); + } + + @Test + void refusesIdentityMaterialThatCollidesOnAnyIndex() { + assertThrows( + CompletionException.class, + () -> collide(builder -> builder.agentDidId("distinct").agentIdentityId("pai_distinct")), + "the Agent DID is unique"); + assertThrows( + CompletionException.class, + () -> collide(builder -> + builder.agentDid("did:web:p.example:a:distinct").agentIdentityId("pai_distinct")), + "the Agent DID identifier is unique"); + assertThrows( + CompletionException.class, + () -> collide(builder -> + builder.agentDid("did:web:p.example:a:distinct").agentDidId("distinct")), + "the Agent identity identifier is unique"); + } + + @Test + void refusesHostedVerificationForRecordsWhoseKeyIdIsNotTheAgentDid() throws JOSEException { + PlatformFixtures.Keys keys = new PlatformFixtures.Keys(); + PlatformIdentityStore store = PlatformIdentityStore.inMemory(); + AepPlatform platform = builder(allow(), keys, true).identityStore(store).build(); + PlatformAgentIdentity identity = provision(platform, SERVICE_DID, PROVISION_KEY); + String assertion = ((PlatformSignResponses.Completed) + platform.sign(identity.agentIdentityId(), signRequest(null), context(SIGN_KEY)) + .toCompletableFuture() + .join() + .body()) + .clientAssertion(); + PlatformIdentityStore rekeyed = PlatformIdentityStore.inMemory(); + rekeyed.findOrCreate( + PRINCIPAL, + SERVICE_DID, + () -> completed(record(ManagedAgentStatus.ACTIVE).toBuilder() + .agentDid(identity.agentDid()) + .agentDidId("opaque1") + .keyId(identity.agentDid() + "#key-1") + .build())) + .toCompletableFuture() + .join(); + + assertFalse(verified(builder(allow(), keys, true).identityStore(rekeyed).build(), assertion, "v-rekeyed")); + } + + @Test + void refusesHostedVerificationTheAuthorizerDeclinesForThatOperationAlone() throws JOSEException { + PlatformFixtures.Keys keys = new PlatformFixtures.Keys(); + AepPlatform platform = builder( + discovery(true), + (request, context) -> completed(request.operation() != PlatformAuthorizationOperation.VERIFY), + keys, + resolver(true)) + .build(); + AepPlatform signing = platform(allow(), keys, true); + PlatformAgentIdentity identity = provision(signing, SERVICE_DID, PROVISION_KEY); + provision(platform, SERVICE_DID, PROVISION_KEY); + String assertion = ((PlatformSignResponses.Completed) + signing.sign(identity.agentIdentityId(), signRequest(null), context(SIGN_KEY)) + .toCompletableFuture() + .join() + .body()) + .clientAssertion(); + + assertFalse(verified(platform, assertion, "v-denied")); + } + + @Test + void doesNotTreatAProblemBodyCarryingContentAsAValidProblem() throws JOSEException { + assertThrows( + CompletionException.class, + () -> handled(new PlatformResponse<>( + 400, + Aep.PROBLEM_MEDIA_TYPE, + completedBody(AGENT_DID, SERVICE_DID, JWT_ID), + ProblemDetails.of("invalid_request", "No.", 400), + Map.of())), + "a problem response carries no success body"); + assertThrows( + CompletionException.class, + () -> handled(new PlatformResponse<>(400, Aep.PROBLEM_MEDIA_TYPE, null, null, Map.of())), + "a problem response carries a problem document"); + } + + @Test + void permissiveTransitionPolicyStillGatesOnTheActiveState() { + PlatformLifecyclePolicy policy = PlatformLifecyclePolicy.permissiveTransitions(); + PlatformIdentityRecord suspended = record(ManagedAgentStatus.SUSPENDED); + + assertFalse(join(policy.canSign(suspended, null))); + assertFalse(join(policy.canVerify(suspended, null))); + assertTrue(join(policy.canTransition(suspended, ManagedAgentStatus.ACTIVE, null))); + assertTrue(join(policy.canSign(record(ManagedAgentStatus.ACTIVE), null))); + assertTrue(join(policy.canVerify(record(ManagedAgentStatus.ACTIVE), null))); + } + + // AEP core refuses to mint an assertion whose subject is not its issuer, so an assertion that + // breaks that binding has to be assembled directly to reach the Platform's own check. + private static String unbound(PlatformFixtures.Keys keys, String issuer, String subject, String keyId) + throws JOSEException { + com.nimbusds.jwt.SignedJWT jwt = new com.nimbusds.jwt.SignedJWT( + new com.nimbusds.jose.JWSHeader.Builder(com.nimbusds.jose.JWSAlgorithm.ES256) + .type(com.nimbusds.jose.JOSEObjectType.JWT) + .keyID(keyId) + .build(), + new com.nimbusds.jwt.JWTClaimsSet.Builder() + .issuer(issuer) + .subject(subject) + .audience(SERVICE_DID) + .issueTime(java.util.Date.from(NOW)) + .expirationTime(java.util.Date.from(NOW.plusSeconds(60))) + .jwtID(JWT_ID) + .claim("op", "enroll") + .build()); + jwt.sign(new com.nimbusds.jose.crypto.ECDSASigner(keys.key)); + return jwt.serialize(); + } + + private static void collide(java.util.function.UnaryOperator change) { + PlatformIdentityStore store = PlatformIdentityStore.inMemory(); + store.findOrCreate(PRINCIPAL, SERVICE_DID, () -> completed(record(ManagedAgentStatus.ACTIVE))) + .toCompletableFuture() + .join(); + store.findOrCreate( + PRINCIPAL, + OTHER_SERVICE_DID, + () -> completed(change.apply(record(ManagedAgentStatus.ACTIVE).toBuilder() + .serviceDid(OTHER_SERVICE_DID)) + .build())) + .toCompletableFuture() + .join(); + } + + private static PlatformIdempotentOperation operation() { + return PlatformIdempotentOperation.SIGN; + } + + private static void execute( + PlatformIdempotencyStore store, + String principal, + String key, + PlatformIdempotentOperation operation, + String hash) { + store.execute( + new PlatformIdempotencyInput(principal, key, operation, hash), + () -> completed(new PlatformResponse<>(200, Aep.MEDIA_TYPE, "b", null, Map.of()))); + } + + private static PlatformLifecyclePolicy policy(boolean sign, boolean verify) { + return new PlatformLifecyclePolicy() { + @Override + public CompletionStage canSign(PlatformIdentityRecord identity, PlatformRequestContext context) { + return completed(sign); + } + + @Override + public CompletionStage canTransition( + PlatformIdentityRecord identity, ManagedAgentStatus nextStatus, PlatformRequestContext context) { + return completed(true); + } + + @Override + public CompletionStage canVerify(PlatformIdentityRecord identity, PlatformRequestContext context) { + return completed(verify); + } + }; + } + + private static foundation.aep.core.PlatformLifecycleRequest lifecycle() { + return new foundation.aep.core.PlatformLifecycleRequest(ManagedAgentStatus.ACTIVE); + } + + private static PlatformSignResponses.Response completedBody(String agentDid, String serviceDid, String jwtId) { + return new PlatformSignResponses.Completed( + "completed", agentDid, "assertion", EXPIRES_AT, ISSUED_AT, jwtId, null, serviceDid); + } + + private static Map> headers(Map> source) { + return new PlatformResponse<>(200, Aep.MEDIA_TYPE, "body", null, source).headers(); + } + + private static boolean verified(AepPlatform platform, String assertion, String key) { + return platform.verify( + new PlatformVerificationRequest(assertion, AssertionOperation.ENROLL, null, SERVICE_DID), + context(key)) + .toCompletableFuture() + .join() + .body() + .verified(); + } + + private static boolean join(CompletionStage stage) { + return stage.toCompletableFuture().join(); + } + + private static int status(CompletionStage> stage) { + return stage.toCompletableFuture().join().status(); + } + + private static PlatformResponse handled(PlatformResponse response) + throws JOSEException { + AepPlatform platform = builder(allow(), new PlatformFixtures.Keys(), false) + .signHandler((identity, request, context) -> completed(Optional.of(response))) + .build(); + PlatformAgentIdentity identity = provision(platform, SERVICE_DID, PROVISION_KEY); + return platform.sign(identity.agentIdentityId(), signRequest(null), context(SIGN_KEY)) + .toCompletableFuture() + .join(); + } +} diff --git a/aep-platform/src/test/java/foundation/aep/platform/PlatformFixtures.java b/aep-platform/src/test/java/foundation/aep/platform/PlatformFixtures.java new file mode 100644 index 0000000..9feb3f9 --- /dev/null +++ b/aep-platform/src/test/java/foundation/aep/platform/PlatformFixtures.java @@ -0,0 +1,160 @@ +package foundation.aep.platform; + +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.jwk.Curve; +import com.nimbusds.jose.jwk.ECKey; +import com.nimbusds.jose.jwk.JWK; +import com.nimbusds.jose.jwk.gen.ECKeyGenerator; +import foundation.aep.core.AssertionOperation; +import foundation.aep.core.ClientAssertionClaims; +import foundation.aep.core.ClientAssertions; +import foundation.aep.core.ManagedAgentStatus; +import foundation.aep.core.PlatformAgentIdentity; +import foundation.aep.core.PlatformDiscoveryDocument; +import foundation.aep.core.PlatformProvisionRequest; +import foundation.aep.core.PlatformSignRequest; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.atomic.AtomicInteger; + +/** Shared construction helpers for the AEP Platform tests. */ +final class PlatformFixtures { + static final Instant NOW = Instant.parse("2026-07-06T12:00:00Z"); + static final Clock CLOCK = Clock.fixed(NOW, ZoneOffset.UTC); + static final String SERVICE_DID = "did:web:api.service.example"; + static final String OTHER_SERVICE_DID = "did:web:billing.service.example"; + static final String DID_HOST = "p.example"; + static final String DID_PREFIX = "a"; + static final String DID_URL_TEMPLATE = "https://p.example/a/{agent_did_id}/did.json"; + static final String PRINCIPAL = "principal-1"; + static final String JWT_ID = "assertion-1"; + static final String PROVISION_KEY = "provision"; + + private PlatformFixtures() {} + + static PlatformDiscoveryDocument discovery(boolean hostedVerification) { + return discovery(hostedVerification, DID_URL_TEMPLATE, "300"); + } + + static PlatformDiscoveryDocument discovery( + boolean hostedVerification, String didUrlTemplate, String defaultLifetimeSeconds) { + return new PlatformDiscoveryDocument( + "1.0", + new PlatformDiscoveryDocument.Endpoints( + hostedVerification ? "/v1/aep/verifications" : null, + "/v1/aep/agent-identities/{agent_identity_id}", + "/v1/aep/agent-identities", + "/v1/aep/agent-identities", + "/v1/aep/agent-identities/{agent_identity_id}/sign"), + new PlatformDiscoveryDocument.Http("/v1/aep"), + new PlatformDiscoveryDocument.Identity(List.of("did:web"), didUrlTemplate), + new PlatformDiscoveryDocument.Platform("did:web:p.example", hostedVerification, "Example Platform"), + new PlatformDiscoveryDocument.Signing(List.of("ES256"), defaultLifetimeSeconds)); + } + + static AepPlatform.Builder builder(PlatformAuthorizer authorizer, Keys keys, boolean hostedVerification) { + return builder(discovery(hostedVerification), authorizer, keys, resolver(true)); + } + + static AepPlatform.Builder builder( + PlatformDiscoveryDocument discovery, + PlatformAuthorizer authorizer, + Keys keys, + PlatformServiceDidResolver resolver) { + AtomicInteger identity = new AtomicInteger(); + AtomicInteger did = new AtomicInteger(); + return AepPlatform.builder(discovery, DID_HOST, authorizer, keys, resolver) + .clock(CLOCK) + .didPathPrefix(DID_PREFIX) + .identityIdSupplier(() -> Integer.toString(identity.incrementAndGet())) + .agentDidIdSupplier(() -> "opaque" + did.incrementAndGet()) + .replayStore(PlatformReplayStore.inMemory()); + } + + static AepPlatform platform(PlatformAuthorizer authorizer, Keys keys, boolean hostedVerification) { + return builder(authorizer, keys, hostedVerification).build(); + } + + static PlatformAuthorizer allow() { + return (request, context) -> completed(true); + } + + static PlatformServiceDidResolver resolver(boolean resolved) { + return (serviceDid, context) -> completed(resolved); + } + + static PlatformRequestContext context(String key) { + return new PlatformRequestContext(PRINCIPAL, key, NOW, Map.of()); + } + + static PlatformAgentIdentity provision(AepPlatform platform, String serviceDid, String key) { + return (PlatformAgentIdentity) platform.provision(new PlatformProvisionRequest(serviceDid), context(key)) + .toCompletableFuture() + .join() + .body(); + } + + static PlatformSignRequest signRequest(String lifetimeSeconds) { + return new PlatformSignRequest(JWT_ID, lifetimeSeconds, AssertionOperation.ENROLL, null, null, SERVICE_DID); + } + + static PlatformIdentityRecord record(ManagedAgentStatus status) { + return PlatformIdentityRecord.builder() + .agentDid("did:web:p.example:a:opaque") + .agentDidId("opaque") + .agentIdentityId("pai_identity") + .createdAt(NOW) + .didDocumentUrl("https://p.example/a/opaque/did.json") + .keyId("did:web:p.example:a:opaque") + .principal(PRINCIPAL) + .serviceDid(SERVICE_DID) + .signingAlgorithms(List.of("ES256")) + .status(status) + .updatedAt(NOW) + .build(); + } + + static CompletionStage completed(T value) { + return CompletableFuture.completedFuture(value); + } + + /** A key store backed by one P-256 key, with hooks for the failure paths under test. */ + static class Keys implements PlatformKeyStore { + final ECKey key; + final AtomicInteger created = new AtomicInteger(); + String keyIdSuffix = ""; + + Keys() throws JOSEException { + key = new ECKeyGenerator(Curve.P_256).keyID("platform-key").generate(); + } + + @Override + public CompletionStage create(PlatformIdentityRecord identity, PlatformRequestContext context) { + created.incrementAndGet(); + return completed(null); + } + + @Override + public CompletionStage didVerificationMethod( + PlatformIdentityRecord identity, PlatformRequestContext context) { + return completed(new PlatformDidVerificationMethod( + identity.agentDid(), identity.agentDid(), key.toPublicJWK().toJSONObject(), "JsonWebKey2020")); + } + + @Override + public CompletionStage sign( + PlatformIdentityRecord identity, ClientAssertionClaims claims, PlatformRequestContext context) { + return completed(ClientAssertions.sign(claims, key, identity.keyId() + keyIdSuffix)); + } + + @Override + public CompletionStage verificationKey(PlatformIdentityRecord identity, PlatformRequestContext context) { + return completed(key.toPublicJWK()); + } + } +} diff --git a/aep-service/src/main/java/foundation/aep/service/AepService.java b/aep-service/src/main/java/foundation/aep/service/AepService.java index c14b479..b4ac916 100644 --- a/aep-service/src/main/java/foundation/aep/service/AepService.java +++ b/aep-service/src/main/java/foundation/aep/service/AepService.java @@ -33,6 +33,7 @@ import java.util.Map; import java.util.Objects; import java.util.Optional; +import java.util.Set; import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionStage; @@ -40,6 +41,7 @@ public final class AepService { private static final String ERROR_NOT_RECOGNIZED = "not_recognized"; + private static final Set AEP_SCHEMES = Set.of("aep", "bearer", "basic"); private static final String TITLE_NOT_RECOGNIZED = "Not recognized"; private static final String ERROR_INVALID_REQUEST = "invalid_request"; private static final String TITLE_INVALID_REQUEST = "Invalid request"; @@ -645,8 +647,12 @@ private static Presentation selectPresentation(Map> headers for (String value : standard) { try { recognized.add(AepHttp.parseAuthorization(value, AuthorizationCarrier.STANDARD)); - } catch (IllegalArgumentException ignored) { - // Unrelated Authorization schemes remain available to other protocols. + } catch (IllegalArgumentException exception) { + // Unrelated Authorization schemes remain available to other protocols. A value that + // names an AEP scheme is not one of those: under CORE-PR-038 it is a malformed AEP + // presentation rather than an absent one, which answers not_recognized instead of + // authentication_required. + if (namesAepScheme(value)) return Presentation.invalid(); } } if (recognized.size() > SINGLE_VALUE || (recognized.size() == SINGLE_VALUE && standard.size() > SINGLE_VALUE)) @@ -654,6 +660,15 @@ private static Presentation selectPresentation(Map> headers return Presentation.valid(recognized.isEmpty() ? null : recognized.get(0)); } + private static boolean namesAepScheme(String value) { + if (value == null) return false; + int end = 0; + while (end < value.length() && value.charAt(end) != ' ' && value.charAt(end) != '\t') { + end++; + } + return AEP_SCHEMES.contains(value.substring(0, end).toLowerCase(Locale.ROOT)); + } + private static boolean isRecognizedAuthorization(String value) { try { AepHttp.parseAuthorization(value, AuthorizationCarrier.STANDARD); diff --git a/aep-service/src/main/java/foundation/aep/service/AepServiceHttpHandler.java b/aep-service/src/main/java/foundation/aep/service/AepServiceHttpHandler.java index fe4d269..dcf66c4 100644 --- a/aep-service/src/main/java/foundation/aep/service/AepServiceHttpHandler.java +++ b/aep-service/src/main/java/foundation/aep/service/AepServiceHttpHandler.java @@ -12,6 +12,8 @@ import java.nio.charset.CharacterCodingException; import java.nio.charset.CodingErrorAction; import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.util.EnumMap; import java.util.List; import java.util.Locale; @@ -22,21 +24,32 @@ public final class AepServiceHttpHandler { public static final int DEFAULT_MAXIMUM_REQUEST_BYTES = 65_536; + public static final String DEFAULT_INSPECT_CACHE_CONTROL = "public, max-age=300"; private static final String ERROR_INVALID_REQUEST = "invalid_request"; + private static final String CACHE_CONTROL = "Cache-Control"; + private static final String NO_STORE = "no-store"; + private static final String SAFE_METHODS = "GET, HEAD"; private static final int SINGLE_HEADER_VALUE = 1; private final AepService service; private final Map commandRoutes; private final int requestByteLimit; + private final String cacheControlValue; + private final String entityTagValue; public AepServiceHttpHandler(AepService service) { this(service, DEFAULT_MAXIMUM_REQUEST_BYTES); } public AepServiceHttpHandler(AepService service, int maximumRequestBytes) { + this(service, maximumRequestBytes, DEFAULT_INSPECT_CACHE_CONTROL); + } + + public AepServiceHttpHandler(AepService service, int maximumRequestBytes, String inspectCacheControl) { this.service = Objects.requireNonNull(service, "service"); if (maximumRequestBytes <= 0) throw new IllegalArgumentException("maximumRequestBytes must be positive."); requestByteLimit = maximumRequestBytes; + cacheControlValue = requireFieldValue(inspectCacheControl, "inspectCacheControl"); Map configuredRoutes = new EnumMap<>(AepCommand.class); configuredRoutes.put(AepCommand.INSPECT, Aep.WELL_KNOWN_PATH); for (String command : service.inspectDocument().commands().supported()) { @@ -47,6 +60,15 @@ public AepServiceHttpHandler(AepService service, int maximumRequestBytes) { AepHttp.commandPath(parsed, service.inspectDocument().http().endpointBase())); } commandRoutes = Map.copyOf(configuredRoutes); + entityTagValue = entityTag(AepJson.write(service.inspectDocument())); + } + + public String inspectCacheControl() { + return cacheControlValue; + } + + public String inspectEntityTag() { + return entityTagValue; } public Map routes() { @@ -63,10 +85,16 @@ public CompletionStage handle(AepCommand command, AepHttpReques if (!commandRoutes.containsKey(command)) { return completed(problem(404, ERROR_INVALID_REQUEST, "AEP route not found")); } - String expectedMethod = command == AepCommand.INSPECT || command == AepCommand.STATUS ? "GET" : "POST"; - if (!expectedMethod.equals(request.method().toUpperCase(Locale.ROOT))) { + // RFC 9110 section 9.3.2 makes HEAD identical to GET apart from the content, so a resource + // that answers GET answers HEAD as well. Allowing only GET turned every cache revalidation + // and every link check into a 405. + boolean safe = command == AepCommand.INSPECT || command == AepCommand.STATUS; + String method = request.method().toUpperCase(Locale.ROOT); + if (!(safe ? "GET".equals(method) || "HEAD".equals(method) : "POST".equals(method))) { return completed(withHeader( - problem(405, ERROR_INVALID_REQUEST, "Method not allowed"), "Allow", List.of(expectedMethod))); + problem(405, ERROR_INVALID_REQUEST, "Method not allowed"), + "Allow", + List.of(safe ? SAFE_METHODS : "POST"))); } if (request.body().length > requestByteLimit) { return completed(problem(413, ERROR_INVALID_REQUEST, "AEP request exceeds its byte limit")); @@ -75,7 +103,16 @@ public CompletionStage handle(AepCommand command, AepHttpReques if (request.body().length != 0) { return completed(problem(400, ERROR_INVALID_REQUEST, "Inspect has no body")); } - return completed(json(200, Aep.MEDIA_TYPE, service.inspectDocument(), Map.of())); + // CORE-CACHE-001 and CORE-CACHE-002 ask for cache metadata on Inspect, and CORE-CACHE-004 + // makes an Agent revalidate through 304. Without a validator to echo, every expiry cost a + // full refetch of a document that rarely changes. + Map> cache = new java.util.LinkedHashMap<>(); + cache.put(CACHE_CONTROL, List.of(cacheControlValue)); + cache.put("ETag", List.of(entityTagValue)); + if (matchesEntityTag(request.headerValues("If-None-Match"), entityTagValue)) { + return completed(new AepHttpResponse(304, Aep.MEDIA_TYPE, cache, new byte[0])); + } + return completed(json(200, Aep.MEDIA_TYPE, service.inspectDocument(), cache)); } if (command == AepCommand.STATUS) { if (request.body().length != 0) { @@ -112,9 +149,14 @@ public CompletionStage authenticate(AepHttpRequest : AepHttpAuthenticationResult.rejected(encode(result.response()))); } + // Authenticated command responses carry enrollment state and, for Grant, the credential secret + // itself. Nothing on this path is a shared representation, so no intermediary or browser cache + // should keep one. private AepHttpResponse encode(ServiceResponse response) { Object body = response.problem() == null ? response.body() : response.problem(); - return json(response.status(), response.contentType(), body, response.headers()); + Map> headers = new java.util.LinkedHashMap<>(response.headers()); + headers.putIfAbsent(CACHE_CONTROL, List.of(NO_STORE)); + return json(response.status(), response.contentType(), body, headers); } private static AepHttpResponse json( @@ -124,7 +166,48 @@ private static AepHttpResponse json( } private static AepHttpResponse problem(int status, String code, String title) { - return json(status, Aep.PROBLEM_MEDIA_TYPE, ProblemDetails.of(code, title, status), Map.of()); + return json( + status, + Aep.PROBLEM_MEDIA_TYPE, + ProblemDetails.of(code, title, status), + Map.of(CACHE_CONTROL, List.of(NO_STORE))); + } + + private static String requireFieldValue(String value, String name) { + Objects.requireNonNull(value, name); + if (value.isBlank() || !value.equals(value.strip()) || value.chars().anyMatch(AepServiceHttpHandler::control)) { + throw new IllegalArgumentException(name + " must be a valid HTTP field value."); + } + return value; + } + + private static boolean control(int character) { + return character != '\t' && (character < ' ' || character == 0x7f); + } + + private static String entityTag(String body) { + try { + byte[] digest = MessageDigest.getInstance("SHA-256").digest(body.getBytes(StandardCharsets.UTF_8)); + return "\"sha256:" + java.util.HexFormat.of().formatHex(digest) + "\""; + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 is unavailable.", exception); + } + } + + // RFC 9110 section 13.1.2: If-None-Match is a comma-separated list, "*" matches any + // representation, and the comparison is weak, so a W/ prefix on either side still matches. + private static boolean matchesEntityTag(List values, String entityTag) { + for (String value : values) { + for (String candidate : value.split(",")) { + String trimmed = candidate.trim(); + if ("*".equals(trimmed) || entityTag.equals(strongTag(trimmed))) return true; + } + } + return false; + } + + private static String strongTag(String value) { + return value.startsWith("W/") ? value.substring(2) : value; } private static AepHttpResponse withHeader(AepHttpResponse response, String name, List values) { diff --git a/aep-service/src/main/java/foundation/aep/service/CredentialAuthenticationInput.java b/aep-service/src/main/java/foundation/aep/service/CredentialAuthenticationInput.java index 7fbb1e5..5008453 100644 --- a/aep-service/src/main/java/foundation/aep/service/CredentialAuthenticationInput.java +++ b/aep-service/src/main/java/foundation/aep/service/CredentialAuthenticationInput.java @@ -14,4 +14,13 @@ public record CredentialAuthenticationInput(Map> headers, S public Map> headers() { return ServiceCopies.headers(headers); } + + // Every request header reaches this record, so a generated toString would print the Authorization + // field value, the API-key field value, and base64(username:password) verbatim into whatever log + // a third-party authenticator or credential store keeps. The header names alone carry no secret. + @Override + public String toString() { + return "CredentialAuthenticationInput[headerNames=" + headers.keySet() + ", method=" + method + ", url=" + url + + ", now=" + now + "]"; + } } diff --git a/aep-service/src/main/java/foundation/aep/service/IdempotencyStore.java b/aep-service/src/main/java/foundation/aep/service/IdempotencyStore.java index 42ff70a..72913d3 100644 --- a/aep-service/src/main/java/foundation/aep/service/IdempotencyStore.java +++ b/aep-service/src/main/java/foundation/aep/service/IdempotencyStore.java @@ -87,9 +87,11 @@ private synchronized void complete( destination.complete(response); } + // A record whose age is exactly the retention window is still inside it, so an exact retry at + // the one-hour boundary has to replay the stored result rather than re-run the operation. private synchronized void expire() { Instant cutoff = clock.instant().minus(RETENTION); - entries.values().removeIf(entry -> !entry.createdAt.isAfter(cutoff)); + entries.values().removeIf(entry -> entry.createdAt.isBefore(cutoff)); } @SuppressWarnings("unchecked") diff --git a/aep-service/src/main/java/foundation/aep/service/InMemoryServiceCredentialStore.java b/aep-service/src/main/java/foundation/aep/service/InMemoryServiceCredentialStore.java index ff13461..a9723e7 100644 --- a/aep-service/src/main/java/foundation/aep/service/InMemoryServiceCredentialStore.java +++ b/aep-service/src/main/java/foundation/aep/service/InMemoryServiceCredentialStore.java @@ -15,6 +15,7 @@ import java.util.Base64; import java.util.LinkedHashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Optional; import java.util.concurrent.CompletableFuture; @@ -22,6 +23,8 @@ final class InMemoryServiceCredentialStore implements ServiceCredentialStore { private static final int SINGLE_PRESENTATION = 1; + private static final String AUTHORIZATION = "Authorization"; + private static final java.time.Duration EXPIRED_RETENTION = java.time.Duration.ofHours(1); private final Map records = new LinkedHashMap<>(); @Override @@ -29,11 +32,10 @@ public synchronized CompletionStage> authentica String grantType, CredentialAuthenticationInput input) { List presentations = presentations(grantType, input.headers()); if (Aep.GRANT_TYPE_API_KEY.equals(grantType)) { - records.values().stream() - .filter(record -> grantType.equals(record.grantType())) - .map(StoredRecord::header) - .distinct() - .forEach(header -> presentations.addAll(apiKeyPresentations(input.headers(), header))); + // Header names are case-insensitive on the wire (APK-CFG-003), so two records spelling + // the same header differently name one field. Deduplicating on the raw spelling harvested + // that one field twice and tipped every API key into the ambiguity branch below. + apiKeyHeaders().forEach(header -> presentations.addAll(apiKeyPresentations(input.headers(), header))); } if (presentations.size() != SINGLE_PRESENTATION) { return completed(Optional.empty()); @@ -63,10 +65,9 @@ public synchronized CompletionStage hasPresentation( if (!Aep.GRANT_TYPE_API_KEY.equals(grantType)) { return completed(!presentations(grantType, input.headers()).isEmpty()); } - boolean present = records.values().stream() - .filter(record -> grantType.equals(record.grantType())) - .map(StoredRecord::header) - .anyMatch(header -> !headerValues(input.headers(), header).isEmpty()); + Map> headers = input.headers(); + boolean present = apiKeyHeaders().stream() + .anyMatch(header -> !headerValues(headers, header).isEmpty()); return completed(present); } @@ -107,6 +108,11 @@ public synchronized CompletionStage save(ServiceCredentialRecord record) { } catch (RuntimeException exception) { return CompletableFuture.failedFuture(exception); } + // A credential that expired long ago can no longer be presented, so keeping it only grows + // the map and the linear scans over it. The retention window keeps a recently expired record + // around so its presentation still answers not_recognized rather than authentication_required. + Instant cutoff = record.createdAt().minus(EXPIRED_RETENTION); + records.values().removeIf(existing -> existing.expiresAt().isBefore(cutoff)); if (records.containsKey(record.credentialId())) { return CompletableFuture.failedFuture( new IllegalArgumentException("AEP credential identifier has already been issued.")); @@ -135,15 +141,17 @@ private static StoredRecord stored(ServiceCredentialRecord record) { throw new IllegalArgumentException("AEP credential store received an invalid record."); } if (credential instanceof GrantResponses.OAuthBearer bearer) { - return record(record, "Authorization", bearer.accessToken(), bearer.scopes()); + return record(record, AUTHORIZATION, bearer.accessToken(), bearer.scopes()); } if (credential instanceof GrantResponses.ApiKey apiKey) { - return record(record, apiKey.header(), apiKey.apiKey(), apiKey.scopes()); + // APK-CFG-003 asks Services to publish lowercase header names, and the wire comparison is + // case-insensitive either way, so one canonical spelling is what gets stored. + return record(record, apiKey.header().toLowerCase(Locale.ROOT), apiKey.apiKey(), apiKey.scopes()); } if (credential instanceof GrantResponses.Basic basic) { String plain = basic.username() + ':' + basic.password(); String encoded = Base64.getEncoder().encodeToString(plain.getBytes(StandardCharsets.UTF_8)); - return record(record, "Authorization", encoded, basic.scopes()); + return record(record, AUTHORIZATION, encoded, basic.scopes()); } throw new IllegalArgumentException("AEP credential store requires a built-in credential."); } @@ -161,27 +169,43 @@ private static StoredRecord record( digest(secret)); } + private synchronized List apiKeyHeaders() { + return records.values().stream() + .filter(record -> Aep.GRANT_TYPE_API_KEY.equals(record.grantType())) + .map(StoredRecord::header) + .distinct() + .toList(); + } + private static List presentations(String grantType, Map> headers) { if (Aep.GRANT_TYPE_API_KEY.equals(grantType)) return new ArrayList<>(); AuthorizationScheme expected = Aep.GRANT_TYPE_BASIC.equals(grantType) ? AuthorizationScheme.BASIC : AuthorizationScheme.BEARER; List result = new ArrayList<>(); - addAuthorizationPresentations(result, headers, "Authorization", AuthorizationCarrier.STANDARD, expected); + addAuthorizationPresentations(result, headers, AUTHORIZATION, AuthorizationCarrier.STANDARD, expected); addAuthorizationPresentations( result, headers, Aep.AUTHORIZATION_HEADER, AuthorizationCarrier.DEDICATED, expected); return result; } + // OAB-PRS-003 and BSC-PRS-003 make both carriers acceptable on a protected resource, and + // OAB-PRS-002/BSC-PRS-002 keep the field value identical between them: only the field the value + // travels in changes. Bearer and Basic records are stored under the canonical Authorization slot, + // so a presentation is filed there whichever carrier delivered it. Filing it under the request + // field name instead meant an AEP-Authorization credential matched no record and every + // dedicated-carrier request came back not_recognized. private static void addAuthorizationPresentations( List result, Map> headers, - String header, + String field, AuthorizationCarrier carrier, AuthorizationScheme expected) { - for (String value : headerValues(headers, header)) { + for (String value : headerValues(headers, field)) { try { ProtectedResourceAuthorization parsed = AepHttp.parseAuthorization(value, carrier); - if (parsed.scheme() == expected) result.add(new Presentation(header, parsed.credentials())); + if (parsed.scheme() == expected) { + result.add(new Presentation(AUTHORIZATION, parsed.credentials())); + } } catch (IllegalArgumentException ignored) { // Another authentication method owns unrecognized field values. } diff --git a/aep-service/src/main/java/foundation/aep/service/ServiceCredentialMatch.java b/aep-service/src/main/java/foundation/aep/service/ServiceCredentialMatch.java index 6c65876..76be7a6 100644 --- a/aep-service/src/main/java/foundation/aep/service/ServiceCredentialMatch.java +++ b/aep-service/src/main/java/foundation/aep/service/ServiceCredentialMatch.java @@ -6,6 +6,13 @@ public record ServiceCredentialMatch( String agentDid, String credentialId, Instant expiresAt, String grantType, List scopes) { public ServiceCredentialMatch { + // A credential store is a caller-supplied extension point. The handler guards against a + // malformed match, but it reads expiresAt before it can report one, so a null here surfaced + // as a NullPointerException instead of the intended rejection. + java.util.Objects.requireNonNull(agentDid, "agentDid"); + java.util.Objects.requireNonNull(credentialId, "credentialId"); + java.util.Objects.requireNonNull(expiresAt, "expiresAt"); + java.util.Objects.requireNonNull(grantType, "grantType"); scopes = scopes == null ? List.of() : List.copyOf(scopes); } } diff --git a/aep-service/src/main/java/foundation/aep/service/StoredCredentialGrantTypes.java b/aep-service/src/main/java/foundation/aep/service/StoredCredentialGrantTypes.java index 9fcf964..24355df 100644 --- a/aep-service/src/main/java/foundation/aep/service/StoredCredentialGrantTypes.java +++ b/aep-service/src/main/java/foundation/aep/service/StoredCredentialGrantTypes.java @@ -44,10 +44,24 @@ private static StoredCredentialGrantType crea ServiceCredentialStore store) { Objects.requireNonNull(issuer, "issuer"); Objects.requireNonNull(store, "store"); + requireAdvertisedConfig(config); StoredHandler handler = new StoredHandler<>(grantType, config, issuer, store); return new StoredCredentialGrantType(grantType, config, new GrantTypeDefinition(grantType, handler), handler); } + // This handler always implements targeted revoke, so OAB-CFG-007, APK-CFG-007, and BSC-CFG-005 + // are satisfied by construction and advertising anything else misdescribes the Service. The + // advertised API-key header names need no check here: AepValidation already requires each to be + // an HTTP field name and to be unique case-insensitively when the Inspect document is validated. + private static void requireAdvertisedConfig(InspectDocument.GrantTypeConfig config) { + if (config != null + && config.supportsPerCredentialRevoke() != null + && !"true".equals(config.supportsPerCredentialRevoke())) { + throw new IllegalArgumentException( + "Stored credential Grant Types support per-credential revoke and must advertise it."); + } + } + private static final class StoredHandler implements GrantTypeHandler, CredentialAuthenticator { private final String grantType; @@ -80,7 +94,9 @@ public CompletionStage grant(GrantRequest request, GrantContext con @Override public CompletionStage revoke(RevokeRequest request, RevokeContext context) { - if (request.credentialId() != null) { + // A blank identifier names no credential. Treating it as a targeted revoke quietly + // revoked nothing while the Agent was told its request had succeeded. + if (request.credentialId() != null && !request.credentialId().isBlank()) { return store.revoke(context.agentDid(), grantType, request.credentialId(), context.now()); } return store.revokeGrantType(context.agentDid(), grantType, context.now()); @@ -114,9 +130,19 @@ public CompletionStage> authenticate(Credential } private void requireIssuedCredential(T credential, Instant issuedAt) { + // AepValidation dispatches on the credential's runtime type, so it would happily accept + // an OAuth bearer response from a handler registered as api-key. Unchecked generics are + // all it takes to get there, and the mismatch would otherwise surface deep in the store. + if (!grantType.equals(credentialGrantType(credential))) { + throw new IllegalArgumentException("AEP issuer returned a credential for another Grant Type."); + } if (!AepValidation.grantResponse(credential).isEmpty()) { throw new IllegalArgumentException("AEP issuer returned an invalid Grant response."); } + if (credential instanceof GrantResponses.Basic basic && !isEncodableAsBasic(basic)) { + throw new IllegalArgumentException( + "AEP issued Basic credentials must survive RFC 7617 encoding unchanged."); + } Instant expiresAt; try { expiresAt = Instant.parse(credential.expiresAt()); @@ -135,6 +161,25 @@ private void requireIssuedCredential(T credential, Instant issuedAt) { } } + private static String credentialGrantType(GrantResponses.BuiltIn credential) { + if (credential instanceof GrantResponses.OAuthBearer) return Aep.GRANT_TYPE_OAUTH_BEARER; + if (credential instanceof GrantResponses.ApiKey) return Aep.GRANT_TYPE_API_KEY; + if (credential instanceof GrantResponses.Basic) return Aep.GRANT_TYPE_BASIC; + return ""; + } + + // BSC-RSP-003 requires issued values to survive RFC 7617 encoding without lossy + // transformation. RFC 7617 leaves the historical charset undefined, so a Service and an Agent + // can disagree about how to encode a non-ASCII value and produce different base64 for the + // same credential. Restricting issuance to ASCII makes the encoding identical everywhere. + private static boolean isEncodableAsBasic(GrantResponses.Basic basic) { + return isAscii(basic.username()) && isAscii(basic.password()); + } + + private static boolean isAscii(String value) { + return value.chars().allMatch(character -> character < 0x80); + } + private ServiceCredentialRecord credentialRecord(T credential, GrantContext context) { Instant expiresAt = Instant.parse(credential.expiresAt()); return new ServiceCredentialRecord( diff --git a/aep-service/src/test/java/foundation/aep/service/AepServiceHttpHandlerTest.java b/aep-service/src/test/java/foundation/aep/service/AepServiceHttpHandlerTest.java index d5ba7d7..d82d3d4 100644 --- a/aep-service/src/test/java/foundation/aep/service/AepServiceHttpHandlerTest.java +++ b/aep-service/src/test/java/foundation/aep/service/AepServiceHttpHandlerTest.java @@ -82,7 +82,7 @@ void rejectsWrongMethodsAndContentTypes() { request("POST", "/aep/enroll", Map.of("Content-Type", List.of("application/json")), new byte[0]))); assertEquals(405, method.status()); - assertEquals(List.of("GET"), method.headers().get("Allow")); + assertEquals(List.of("GET, HEAD"), method.headers().get("Allow")); assertEquals(415, contentType.status()); } diff --git a/aep-service/src/test/java/foundation/aep/service/ServiceConformanceTest.java b/aep-service/src/test/java/foundation/aep/service/ServiceConformanceTest.java new file mode 100644 index 0000000..554ffa0 --- /dev/null +++ b/aep-service/src/test/java/foundation/aep/service/ServiceConformanceTest.java @@ -0,0 +1,412 @@ +package foundation.aep.service; + +import static foundation.aep.service.ServiceFixtures.AGENT_DID; +import static foundation.aep.service.ServiceFixtures.API_KEY_HEADER; +import static foundation.aep.service.ServiceFixtures.AUTHORIZATION; +import static foundation.aep.service.ServiceFixtures.NOW; +import static foundation.aep.service.ServiceFixtures.RESOURCE; +import static foundation.aep.service.ServiceFixtures.authenticate; +import static foundation.aep.service.ServiceFixtures.basicCredentials; +import static foundation.aep.service.ServiceFixtures.completed; +import static foundation.aep.service.ServiceFixtures.config; +import static foundation.aep.service.ServiceFixtures.document; +import static foundation.aep.service.ServiceFixtures.enroll; +import static foundation.aep.service.ServiceFixtures.grant; +import static foundation.aep.service.ServiceFixtures.serviceBuilder; +import static foundation.aep.service.ServiceFixtures.storedCredentialService; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import foundation.aep.core.Aep; +import foundation.aep.core.AepCommand; +import foundation.aep.core.GrantResponses; +import foundation.aep.core.InspectDocument; +import foundation.aep.core.RevokeRequest; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +/** Requirement-level checks for the Service role across the AEP drafts. */ +final class ServiceConformanceTest { + private static final String AEP_AUTHORIZATION = Aep.AUTHORIZATION_HEADER; + private static final String NOT_RECOGNIZED = "not_recognized"; + private static final String CACHE_CONTROL = "Cache-Control"; + private static final String IF_NONE_MATCH = "If-None-Match"; + private static final String ALLOW = "Allow"; + private static final String SAFE_METHODS = "GET, HEAD"; + + /** OAB-PRS-003, BSC-PRS-003: both carriers authenticate on a protected resource. */ + @Test + void acceptsEitherAuthorizationCarrierForBearerAndBasicCredentials() { + AepService service = storedCredentialService(ServiceCredentialStore.inMemory()); + enroll(service); + grant(service, Aep.GRANT_TYPE_OAUTH_BEARER); + grant(service, Aep.GRANT_TYPE_BASIC); + + assertEquals("oauth-1", authenticated(service, Map.of(AUTHORIZATION, List.of("Bearer oauth-secret")))); + assertEquals( + "oauth-1", + authenticated(service, Map.of(AEP_AUTHORIZATION, List.of("Bearer oauth-secret"))), + "the dedicated carrier presents the same Bearer field value"); + assertEquals("basic-1", authenticated(service, Map.of(AUTHORIZATION, List.of("Basic " + basicCredentials())))); + assertEquals( + "basic-1", + authenticated(service, Map.of(AEP_AUTHORIZATION, List.of("Basic " + basicCredentials()))), + "the dedicated carrier presents the same Basic field value"); + } + + /** OAB-PRS-004, BSC-PRS-004: one carrier per request; two is ambiguous, not a choice. */ + @Test + void refusesAmbiguousCarrierPresentations() { + AepService service = storedCredentialService(ServiceCredentialStore.inMemory()); + enroll(service); + grant(service, Aep.GRANT_TYPE_OAUTH_BEARER); + Map> both = Map.of( + AUTHORIZATION, List.of("Bearer oauth-secret"), AEP_AUTHORIZATION, List.of("Bearer oauth-secret")); + + assertEquals(NOT_RECOGNIZED, rejected(service, both)); + assertEquals( + NOT_RECOGNIZED, + rejected(service, Map.of(AUTHORIZATION, List.of("Bearer oauth-secret", "Bearer other")))); + } + + /** APK-PRS-004: the dedicated carrier does not carry an API key. */ + @Test + void doesNotAcceptApiKeysThroughTheDedicatedCarrier() { + AepService service = storedCredentialService(ServiceCredentialStore.inMemory()); + enroll(service); + grant(service, Aep.GRANT_TYPE_API_KEY); + + assertEquals("api-1", authenticated(service, Map.of(API_KEY_HEADER, List.of("api-secret")))); + assertEquals( + NOT_RECOGNIZED, + rejected(service, Map.of(AEP_AUTHORIZATION, List.of("ApiKey api-secret"))), + "the dedicated carrier is reserved for AEP schemes, so this is a malformed presentation"); + assertEquals( + NOT_RECOGNIZED, + rejected(service, Map.of(AEP_AUTHORIZATION, List.of("Bearer api-secret"))), + "an API key is not a bearer token, whichever field carries it"); + } + + /** APK-CFG-003: header names are case-insensitive on the wire. */ + @Test + void matchesApiKeyHeadersRegardlessOfCase() { + AepService service = storedCredentialService(ServiceCredentialStore.inMemory()); + enroll(service); + grant(service, Aep.GRANT_TYPE_API_KEY); + + assertEquals("api-1", authenticated(service, Map.of("x-service-key", List.of("api-secret")))); + assertEquals("api-1", authenticated(service, Map.of("X-SERVICE-KEY", List.of("api-secret")))); + } + + /** APK-PRS-002 vs APK-PRS-003: a wrong header is no presentation; a wrong value is a bad one. */ + @Test + void separatesMissingCredentialsFromUnusableOnes() { + AepService service = storedCredentialService(ServiceCredentialStore.inMemory()); + enroll(service); + grant(service, Aep.GRANT_TYPE_API_KEY); + + assertEquals("authentication_required", rejected(service, Map.of())); + assertEquals("authentication_required", rejected(service, Map.of("X-Other", List.of("api-secret")))); + assertEquals(NOT_RECOGNIZED, rejected(service, Map.of(API_KEY_HEADER, List.of("wrong")))); + } + + /** RFC 9110 section 9.3.2: a resource that answers GET answers HEAD. */ + @Test + void answersHeadWhereverItAnswersGet() { + AepServiceHttpHandler handler = handler(); + + assertEquals(200, handle(handler, AepCommand.INSPECT, "HEAD").status()); + assertEquals(401, handle(handler, AepCommand.STATUS, "HEAD").status()); + assertEquals(200, handle(handler, AepCommand.INSPECT, "GET").status()); + var refused = handle(handler, AepCommand.INSPECT, "POST"); + assertEquals(405, refused.status()); + assertEquals(List.of(SAFE_METHODS), refused.headers().get(ALLOW)); + var unsafe = handle(handler, AepCommand.ENROLL, "HEAD"); + assertEquals(405, unsafe.status()); + assertEquals(List.of("POST"), unsafe.headers().get(ALLOW)); + } + + /** CORE-CACHE-001, CORE-CACHE-002, CORE-CACHE-004: freshness, a validator, and revalidation. */ + @Test + void sendsInspectCacheMetadataAndRevalidatesThroughNotModified() { + AepServiceHttpHandler handler = handler(); + var inspect = handle(handler, AepCommand.INSPECT, "GET"); + String entityTag = inspect.headers().get("ETag").get(0); + + assertEquals(List.of("public, max-age=300"), inspect.headers().get(CACHE_CONTROL)); + assertTrue(entityTag.startsWith("\"sha256:")); + assertEquals(304, conditional(handler, entityTag).status()); + assertEquals(304, conditional(handler, "W/" + entityTag).status(), "the comparison is weak"); + assertEquals(304, conditional(handler, "\"other\", " + entityTag).status(), "If-None-Match is a list"); + assertEquals(304, conditional(handler, "*").status()); + assertEquals(200, conditional(handler, "\"other\"").status()); + assertEquals(0, conditional(handler, entityTag).body().length); + assertEquals( + entityTag, + conditional(handler, "\"other\"").headers().get("ETag").get(0)); + } + + /** CORE-CACHE-001: the validator is derived from the document, so unlike documents differ. */ + @Test + void inspectEntityTagTracksTheAdvertisedDocument() { + AepServiceHttpHandler first = handler(); + AepServiceHttpHandler same = handler(); + AepServiceHttpHandler other = new AepServiceHttpHandler( + serviceBuilder(document(List.of(), Map.of(), List.of(Aep.AUTHENTICATION_METHOD_JWT))) + .build()); + + assertEquals(first.inspectEntityTag(), same.inspectEntityTag()); + assertNotEquals(first.inspectEntityTag(), other.inspectEntityTag()); + } + + /** Command responses carry enrollment state and credential secrets; caches must not keep them. */ + @Test + void keepsCommandResponsesOutOfCaches() { + AepServiceHttpHandler handler = handler(); + + assertEquals( + List.of("no-store"), + handle(handler, AepCommand.STATUS, "GET").headers().get(CACHE_CONTROL)); + assertEquals( + List.of("no-store"), + handle(handler, AepCommand.INSPECT, "POST").headers().get(CACHE_CONTROL)); + assertEquals( + List.of("no-store"), + handle(handler, AepCommand.ENROLL, "POST").headers().get(CACHE_CONTROL)); + } + + /** OAB-CFG-007, APK-CFG-007, BSC-CFG-005: a handler that supports targeted revoke says so. */ + @Test + void requiresStoredCredentialGrantTypesToAdvertisePerCredentialRevoke() { + InspectDocument.GrantTypeConfig withoutRevoke = new InspectDocument.GrantTypeConfig( + List.of("opaque"), "600", List.of(), "service", List.of("read"), "false", null, null); + + assertThrows( + IllegalArgumentException.class, + () -> StoredCredentialGrantTypes.oauthBearer( + withoutRevoke, (request, context) -> completed(bearer()), ServiceCredentialStore.inMemory())); + } + + /** OAB-RVK-002: a targeted revoke leaves the Agent's other credentials of that type alone. */ + @Test + void revokesOnlyTheNamedCredential() { + ServiceCredentialStore store = ServiceCredentialStore.inMemory(); + AepService service = storedCredentialService(store); + enroll(service); + grant(service, Aep.GRANT_TYPE_OAUTH_BEARER); + grant(service, Aep.GRANT_TYPE_API_KEY); + + var response = service.revoke( + new RevokeRequest(Aep.GRANT_TYPE_OAUTH_BEARER, "oauth-1", null), + CommandOptions.idempotent("revoke", "revoke-key")) + .toCompletableFuture() + .join(); + + assertEquals(200, response.status()); + assertEquals(NOT_RECOGNIZED, rejected(service, Map.of(AUTHORIZATION, List.of("Bearer oauth-secret")))); + assertEquals("api-1", authenticated(service, Map.of(API_KEY_HEADER, List.of("api-secret")))); + } + + /** BSC-RSP-003: an issued Basic credential must survive RFC 7617 encoding unchanged. */ + @Test + void refusesBasicCredentialsThatDoNotSurviveRfc7617Encoding() { + ServiceCredentialStore store = ServiceCredentialStore.inMemory(); + AepService service = serviceBuilder( + document(List.of(Aep.GRANT_TYPE_BASIC), Map.of(Aep.GRANT_TYPE_BASIC, config(List.of())))) + .storedCredentialGrantType(StoredCredentialGrantTypes.basic( + config(List.of()), + (request, context) -> completed(new GrantResponses.Basic( + "basic-1", + NOW.plusSeconds(600).toString(), + "paßwort", + "service", + List.of("read"), + "agent")), + store)) + .build(); + enroll(service); + + assertThrows( + java.util.concurrent.CompletionException.class, + () -> service.grant( + new foundation.aep.core.GrantRequest(Aep.GRANT_TYPE_BASIC, List.of("read")), + CommandOptions.idempotent("grant", "grant-key")) + .toCompletableFuture() + .join()); + } + + /** CORE-ERR-009: every lifecycle state maps to its fixed code and status. */ + @Test + void mapsLifecycleStatesToTheirDefinedErrorCodes() { + assertEquals("verification_pending", blockedGrant(foundation.aep.core.AgentStatus.PENDING, 403)); + assertEquals("identity_suspended", blockedGrant(foundation.aep.core.AgentStatus.SUSPENDED, 403)); + assertEquals("identity_terminated", blockedGrant(foundation.aep.core.AgentStatus.TERMINATED, 403)); + assertEquals("identity_unavailable", blockedGrant(foundation.aep.core.AgentStatus.UNAVAILABLE, 403)); + assertEquals("enrollment_failed", blockedGrant(foundation.aep.core.AgentStatus.REJECTED, 400)); + } + + /** CORE-PR-*: a rejected protected-resource request carries an AEP challenge, not a bare 401. */ + @Test + void challengesRejectedProtectedResourceRequests() { + AepService service = storedCredentialService(ServiceCredentialStore.inMemory()); + enroll(service); + grant(service, Aep.GRANT_TYPE_API_KEY); + + var response = + authenticate(service, Map.of(API_KEY_HEADER, List.of("wrong"))).response(); + String challenge = response.headers().get("WWW-Authenticate").get(0); + + assertEquals(401, response.status()); + assertTrue(challenge.startsWith("AEP ")); + assertTrue(challenge.contains("service_did=\"" + ServiceFixtures.SERVICE_DID + "\"")); + assertTrue(challenge.contains("reason=\"" + NOT_RECOGNIZED + "\"")); + assertTrue(challenge.contains("inspect=\"https://service.example" + Aep.WELL_KNOWN_PATH + "\"")); + assertFalse(response.headers().containsKey("Content-Length")); + } + + /** PLT-IDP-004 shape, CORE-IDEM: an exact retry at the retention boundary still replays. */ + @Test + void replaysIdempotentResultsForTheFullRetentionWindow() { + java.time.Instant[] instant = {NOW}; + java.time.Clock moving = new java.time.Clock() { + @Override + public java.time.ZoneId getZone() { + return java.time.ZoneOffset.UTC; + } + + @Override + public java.time.Clock withZone(java.time.ZoneId zone) { + return this; + } + + @Override + public java.time.Instant instant() { + return instant[0]; + } + }; + IdempotencyStore store = IdempotencyStore.inMemory(moving); + IdempotencyInput input = new IdempotencyInput(AGENT_DID, "key", "enroll", "hash"); + java.util.concurrent.atomic.AtomicInteger runs = new java.util.concurrent.atomic.AtomicInteger(); + java.util.function.Supplier>> operation = + () -> completed(ServiceResponse.success("run-" + runs.incrementAndGet())); + + store.execute(input, operation).toCompletableFuture().join(); + instant[0] = NOW.plus(java.time.Duration.ofHours(1)); + var boundary = store.execute(input, operation).toCompletableFuture().join(); + instant[0] = NOW.plus(java.time.Duration.ofHours(1)).plusNanos(1); + var past = store.execute(input, operation).toCompletableFuture().join(); + + assertEquals(IdempotencyResult.State.REPLAYED, boundary.state()); + assertEquals("run-1", boundary.response().body()); + assertEquals(IdempotencyResult.State.CREATED, past.state()); + assertEquals(2, runs.get()); + } + + /** Credential material reaches every authenticator, so it must not reach their logs. */ + @Test + void redactsCredentialMaterialFromTheAuthenticationInput() { + CredentialAuthenticationInput input = new CredentialAuthenticationInput( + Map.of(AUTHORIZATION, List.of("Basic " + basicCredentials())), "GET", RESOURCE, NOW); + + assertFalse(input.toString().contains(basicCredentials())); + assertTrue(input.toString().contains(AUTHORIZATION), "the field names alone carry no secret"); + } + + private static GrantResponses.OAuthBearer bearer() { + return new GrantResponses.OAuthBearer( + "oauth-secret", "oauth-1", NOW.plusSeconds(600).toString(), List.of("read"), "opaque", "Bearer"); + } + + private static String blockedGrant(foundation.aep.core.AgentStatus status, int expectedStatus) { + EnrollmentStore store = EnrollmentStore.inMemory(); + UnusedHandler handler = new UnusedHandler(); + AepService service = serviceBuilder( + document(List.of(Aep.GRANT_TYPE_API_KEY), Map.of(Aep.GRANT_TYPE_API_KEY, config(List.of())))) + .enrollmentStore(store) + .grantType(new GrantTypeDefinition(Aep.GRANT_TYPE_API_KEY, handler)) + .credentialAuthenticator(Aep.GRANT_TYPE_API_KEY, handler) + .build(); + enroll(service); + // The lifecycle states beyond the initial ones are reached by a later Service decision, not + // by an enrollment policy, so the record is moved through the store the same way. + store.save(EnrollmentRecord.builder(AGENT_DID, "enrollment-1", status, NOW) + .since(NOW) + .updatedAt(NOW) + .build()) + .toCompletableFuture() + .join(); + + var response = service.grant( + new foundation.aep.core.GrantRequest(Aep.GRANT_TYPE_API_KEY, List.of()), + CommandOptions.idempotent("grant", "grant-key")) + .toCompletableFuture() + .join(); + assertEquals(expectedStatus, response.status()); + return response.problem().code(); + } + + private static AepServiceHttpHandler handler() { + return new AepServiceHttpHandler( + serviceBuilder(document(List.of(), Map.of(), List.of())).build()); + } + + private static AepHttpResponse handle(AepServiceHttpHandler handler, AepCommand command, String method) { + Map> headers = command == AepCommand.ENROLL || command == AepCommand.GRANT + ? Map.of("Content-Type", List.of(Aep.MEDIA_TYPE)) + : Map.of(); + byte[] body = command == AepCommand.ENROLL && "POST".equals(method) + ? "{\"agent_did\":\"did:web:agent.example\"}".getBytes(java.nio.charset.StandardCharsets.UTF_8) + : new byte[0]; + return handler.handle(command, new AepHttpRequest(method, RESOURCE, headers, body)) + .toCompletableFuture() + .join(); + } + + private static AepHttpResponse conditional(AepServiceHttpHandler handler, String entityTag) { + return handler.handle( + AepCommand.INSPECT, + new AepHttpRequest("GET", RESOURCE, Map.of(IF_NONE_MATCH, List.of(entityTag)), new byte[0])) + .toCompletableFuture() + .join(); + } + + private static String authenticated(AepService service, Map> headers) { + ProtectedResourceResult result = authenticate(service, headers); + assertTrue(result.authenticated(), "expected an authenticated principal"); + return result.principal().credentialId(); + } + + private static String rejected(AepService service, Map> headers) { + ProtectedResourceResult result = authenticate(service, headers); + assertFalse(result.authenticated(), "expected a rejection"); + return result.response().problem().code(); + } + + private static final class UnusedHandler implements GrantTypeHandler, CredentialAuthenticator { + @Override + public java.util.concurrent.CompletionStage grant( + foundation.aep.core.GrantRequest request, GrantContext context) { + return completed(new GrantResult("credential-1", Map.of("credential_id", "credential-1"))); + } + + @Override + public java.util.concurrent.CompletionStage revoke(RevokeRequest request, RevokeContext context) { + return completed(null); + } + + @Override + public java.util.concurrent.CompletionStage hasPresentation(CredentialAuthenticationInput input) { + return completed(false); + } + + @Override + public java.util.concurrent.CompletionStage> authenticate( + CredentialAuthenticationInput input) { + return completed(java.util.Optional.empty()); + } + } +} diff --git a/aep-service/src/test/java/foundation/aep/service/ServiceCoverageTest.java b/aep-service/src/test/java/foundation/aep/service/ServiceCoverageTest.java new file mode 100644 index 0000000..8550895 --- /dev/null +++ b/aep-service/src/test/java/foundation/aep/service/ServiceCoverageTest.java @@ -0,0 +1,701 @@ +package foundation.aep.service; + +import static foundation.aep.service.ServiceFixtures.AGENT_DID; +import static foundation.aep.service.ServiceFixtures.API_KEY_HEADER; +import static foundation.aep.service.ServiceFixtures.AUTHORIZATION; +import static foundation.aep.service.ServiceFixtures.CLOCK; +import static foundation.aep.service.ServiceFixtures.NOW; +import static foundation.aep.service.ServiceFixtures.SERVICE_DID; +import static foundation.aep.service.ServiceFixtures.authenticate; +import static foundation.aep.service.ServiceFixtures.completed; +import static foundation.aep.service.ServiceFixtures.config; +import static foundation.aep.service.ServiceFixtures.document; +import static foundation.aep.service.ServiceFixtures.enroll; +import static foundation.aep.service.ServiceFixtures.grant; +import static foundation.aep.service.ServiceFixtures.serviceBuilder; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import foundation.aep.core.Aep; +import foundation.aep.core.AgentStatus; +import foundation.aep.core.AssertionOperation; +import foundation.aep.core.ClaimValues; +import foundation.aep.core.ClientAssertionClaims; +import foundation.aep.core.EnrollRequest; +import foundation.aep.core.GrantRequest; +import foundation.aep.core.GrantResponses; +import foundation.aep.core.InspectDocument; +import foundation.aep.core.RevokeRequest; +import java.net.URI; +import java.time.Duration; +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CompletionException; +import java.util.concurrent.CompletionStage; +import org.junit.jupiter.api.Test; + +/** Exercises the refusal, accessor, and store paths of the AEP Service module. */ +final class ServiceCoverageTest { + private static final String INVALID_REQUEST = "invalid_request"; + private static final String NOT_RECOGNIZED = "not_recognized"; + private static final String ENROLL_KEY = "enroll-key"; + private static final String GRANT_KEY = "grant-key"; + private static final String API_KEY = Aep.GRANT_TYPE_API_KEY; + private static final String EXPIRY = "2026-09-01T12:10:00Z"; + + @Test + void refusesCommandsWithoutAKeyOrWithAMismatchedBody() { + AepService service = apiKeyService(); + + // Each command needs its own assertion: a client assertion is single-use, so reusing one + // would be refused as a replay before the request shape is ever examined. + assertEquals(400, code(service.enroll(enrollRequest(), CommandOptions.authenticated("a1")))); + assertEquals(400, code(service.enroll(null, CommandOptions.idempotent("a2", "k1")))); + assertEquals( + 400, + code(service.enroll( + new EnrollRequest("did:web:other.example", null, null), + CommandOptions.idempotent("a3", "k2")))); + assertEquals( + 400, + code(service.enroll( + new EnrollRequest(AGENT_DID, null, "other"), CommandOptions.idempotent("a4", "k3")))); + assertEquals( + 400, code(service.enroll(new EnrollRequest(" ", null, null), CommandOptions.idempotent("a5", "k4")))); + assertEquals(400, code(service.grant(null, CommandOptions.idempotent("a6", "k5")))); + assertEquals(400, code(service.grant(grantRequest(), CommandOptions.authenticated("a7")))); + assertEquals(400, code(service.revoke(null, CommandOptions.idempotent("a8", "k6")))); + assertEquals( + 400, code(service.revoke(new RevokeRequest(API_KEY, null, null), CommandOptions.authenticated("a9")))); + assertEquals(401, code(service.enroll(enrollRequest(), CommandOptions.idempotent(" ", "k7")))); + assertEquals(401, code(service.status(null))); + assertEquals(401, code(service.grant(grantRequest(), null))); + } + + @Test + void reportsStatusForAnEnrolledAgent() { + AepService service = apiKeyService(); + enroll(service); + + var response = service.status(CommandOptions.authenticated("status")) + .toCompletableFuture() + .join(); + var body = (foundation.aep.core.StatusResponse) response.body(); + + assertEquals(200, response.status()); + assertEquals(AgentStatus.ACTIVE, body.status()); + assertEquals("2026-09-01T12:00:00Z", body.since()); + assertEquals(null, body.ownerActionRequired()); + assertEquals(401, code(freshService().status(CommandOptions.authenticated("status")))); + } + + @Test + void reportsPendingLifecycleDetailOnStatus() { + AepService service = serviceBuilder(document(List.of(), Map.of(), List.of())) + .enrollmentPolicy((request, now) -> completed(new EnrollmentDecision( + AgentStatus.PENDING, true, List.of("contact.email"), List.of("person.username")))) + .build(); + enroll(service); + + var body = (foundation.aep.core.StatusResponse) service.status(CommandOptions.authenticated("status")) + .toCompletableFuture() + .join() + .body(); + + assertEquals("true", body.ownerActionRequired()); + assertEquals(List.of("contact.email"), body.verificationPending()); + assertEquals(List.of("person.username"), body.requirementsPending()); + } + + @Test + void refusesUnsupportedGrantTypesOnBothCommands() { + AepService service = apiKeyService(); + enroll(service); + + assertEquals( + 400, + code(service.grant(new GrantRequest("other", List.of()), CommandOptions.idempotent("g1", GRANT_KEY)))); + assertEquals( + 400, + code(service.revoke(new RevokeRequest("other", null, null), CommandOptions.idempotent("r1", "r-key")))); + assertEquals( + 200, + code(service.revoke( + new RevokeRequest(API_KEY, "credential-1", null), CommandOptions.idempotent("r2", "r2-key"))), + "this Service advertises per-credential revoke"); + + AepService withoutTargetedRevoke = serviceBuilder( + document(List.of(API_KEY), Map.of(API_KEY, new InspectDocument.GrantTypeConfig("false")))) + .grantType(new GrantTypeDefinition(API_KEY, handler())) + .credentialAuthenticator(API_KEY, handler()) + .build(); + enroll(withoutTargetedRevoke); + assertEquals( + 400, + code(withoutTargetedRevoke.revoke( + new RevokeRequest(API_KEY, "credential-1", null), CommandOptions.idempotent("r3", "r3-key"))), + "per-credential revoke is refused when the Service does not advertise it"); + } + + @Test + void refusesGrantAndRevokeForAnAgentThatNeverEnrolled() { + AepService service = apiKeyService(); + + assertEquals(401, code(service.grant(grantRequest(), CommandOptions.idempotent("g1", GRANT_KEY)))); + assertEquals( + 401, + code(service.revoke(new RevokeRequest(API_KEY, null, null), CommandOptions.idempotent("r1", "r-key")))); + } + + @Test + void reportsUnmetClaimRequirementsAndOversizedClaimValues() { + InspectDocument document = InspectDocument.builder() + .version(Aep.VERSION) + .bindings(new InspectDocument.Bindings(List.of("http"))) + .claims(new InspectDocument.Claims(List.of("contact.email"), List.of(), List.of())) + .commands(new InspectDocument.Commands(List.of("enroll", "inspect", "status"), List.of(), Map.of())) + .core(new InspectDocument.Core(Aep.REQUIRED_SIGNING_ALGORITHMS)) + .http(new InspectDocument.Http("/aep", null)) + .identity(new InspectDocument.Identity(List.of("did:web"))) + .service(new InspectDocument.Service(SERVICE_DID)) + .build(); + AepService service = serviceBuilder(document).build(); + AepService limited = serviceBuilder(document) + .claimValueLimits(new ClaimValueLimits(64, 1, 1, 4)) + .build(); + + var missing = service.enroll(new EnrollRequest(AGENT_DID, null, null), CommandOptions.idempotent("e", "k")) + .toCompletableFuture() + .join(); + + assertEquals(422, missing.status()); + assertEquals(List.of("contact.email"), missing.problem().requirementsPending()); + assertEquals( + 422, + code(limited.enroll( + new EnrollRequest( + AGENT_DID, + ClaimValues.builder() + .contactEmail("a-very-long-address@example.com") + .build(), + null), + CommandOptions.idempotent("e", "k2")))); + } + + @Test + void foldsEveryCanonicalClaimIntoTheIdempotencyFingerprint() { + AepService service = + serviceBuilder(document(List.of(), Map.of(), List.of())).build(); + Map nested = new LinkedHashMap<>(); + nested.put("list", List.of(1, "two", true)); + ClaimValues first = ClaimValues.builder() + .contactAddressPrimary(new foundation.aep.core.ContactAddressPrimary( + "Springfield", "US", "Ada", "Lovelace", "1 Main St", null, null, "12345", "IL")) + .contactEmail("agent@example.com") + .contactMobile("+15550100") + .personBirthdate("1990-01-01") + .personFirstName("Ada") + .personLastName("Lovelace") + .personUsername("ada") + .additional("nested", nested) + .build(); + + assertEquals(200, code(service.enroll(new EnrollRequest(AGENT_DID, first, null), key("k1", "a1")))); + assertEquals( + 200, + code(service.enroll(new EnrollRequest(AGENT_DID, first, null), key("k1", "a2"))), + "an exact retry replays the stored result"); + assertEquals( + 409, + code(service.enroll( + new EnrollRequest( + AGENT_DID, + ClaimValues.builder().personUsername("bo").build(), + null), + key("k1", "a3")))); + } + + @Test + void refusesProtectedResourceUrlsItCannotBind() { + AepService service = apiKeyService(); + + assertThrows(CompletionException.class, () -> resource(service, URI.create("/relative"))); + assertThrows(CompletionException.class, () -> resource(service, URI.create("https://user@svc.example/x"))); + assertThrows(CompletionException.class, () -> resource(service, URI.create("https://svc.example/x#f"))); + assertThrows(CompletionException.class, () -> resource(service, URI.create("http://svc.example/x"))); + assertThrows(CompletionException.class, () -> resource(service, null)); + } + + @Test + void allowsLoopbackProtectedResourcesOnlyWhenConfigured() { + AepService loopback = serviceBuilder(document(List.of(), Map.of(), List.of())) + .allowInsecureLoopback(true) + .inspectUri(URI.create("http://127.0.0.1:8080/.well-known/aep")) + .build(); + + var rejected = loopback.authenticate( + new ProtectedResourceRequest(Map.of(), "GET", URI.create("http://localhost:8080/private"))) + .toCompletableFuture() + .join(); + + assertFalse(rejected.authenticated()); + assertTrue(rejected.response() + .headers() + .get("WWW-Authenticate") + .get(0) + .contains("inspect=\"http://127.0.0.1:8080/.well-known/aep\"")); + assertThrows( + CompletionException.class, + () -> loopback.authenticate(new ProtectedResourceRequest( + Map.of(), "GET", URI.create("http://example.invalid/private"))) + .toCompletableFuture() + .join()); + } + + @Test + void refusesAepAssertionsOnResourcesThatDoNotAdvertiseThem() { + AepService service = apiKeyService(); + enroll(service); + + assertEquals( + "unsupported_authentication_method", + rejected(service, Map.of(AUTHORIZATION, List.of("AEP assertion")))); + assertEquals( + "unsupported_authentication_method", + rejected(service, Map.of(AUTHORIZATION, List.of("Bearer token"))), + "the Service advertises only api-key"); + } + + @Test + void refusesAnAssertionPresentedAlongsideASessionCredential() { + AepService service = jwtAndApiKeyService(); + enroll(service); + grant(service, API_KEY); + + assertEquals( + NOT_RECOGNIZED, + rejected( + service, + Map.of(AUTHORIZATION, List.of("AEP assertion"), API_KEY_HEADER, List.of("api-secret")))); + } + + @Test + void authenticatesAepAssertionsAgainstActiveEnrollmentsOnly() { + EnrollmentStore store = EnrollmentStore.inMemory(); + AepService service = serviceBuilder(document(List.of(), Map.of(), List.of(Aep.AUTHENTICATION_METHOD_JWT))) + .enrollmentStore(store) + .build(); + + assertEquals(NOT_RECOGNIZED, rejected(service, Map.of(AUTHORIZATION, List.of("AEP assertion-1")))); + enroll(service); + var authenticated = authenticate(service, Map.of(AUTHORIZATION, List.of("AEP assertion-2"))); + assertTrue(authenticated.authenticated()); + assertEquals( + AuthenticatedPrincipal.Kind.AEP_JWT, authenticated.principal().kind()); + assertEquals(Aep.AUTHENTICATION_METHOD_JWT, authenticated.principal().authenticationMethod()); + + store.save(EnrollmentRecord.builder(AGENT_DID, "enrollment-1", AgentStatus.SUSPENDED, NOW) + .since(NOW) + .updatedAt(NOW) + .build()) + .toCompletableFuture() + .join(); + assertEquals(NOT_RECOGNIZED, rejected(service, Map.of(AUTHORIZATION, List.of("AEP assertion-3")))); + } + + @Test + void rejectsAssertionsTheVerifierCannotProduce() { + AepService throwing = AepService.builder(document(List.of(), Map.of(), List.of()), (assertion, context) -> { + throw new IllegalStateException("boom"); + }) + .clock(CLOCK) + .build(); + AepService failing = AepService.builder( + document(List.of(), Map.of(), List.of()), + (assertion, context) -> + java.util.concurrent.CompletableFuture.failedFuture(new IllegalStateException("no"))) + .clock(CLOCK) + .build(); + AepService overflowing = AepService.builder( + document(List.of(), Map.of(), List.of()), + (assertion, context) -> completed(new ClientAssertionClaims( + AGENT_DID, + AGENT_DID, + context.serviceDid(), + context.operation(), + NOW.getEpochSecond(), + Long.MAX_VALUE, + assertion, + null))) + .clock(CLOCK) + .build(); + + assertEquals(401, code(throwing.status(CommandOptions.authenticated("a")))); + assertEquals(401, code(failing.status(CommandOptions.authenticated("a")))); + assertEquals(401, code(overflowing.status(CommandOptions.authenticated("a")))); + } + + @Test + void rejectsClaimsThatDoNotBindTheRequestTheyAuthorize() { + assertEquals(401, code(claiming(AGENT_DID, "did:web:other.example", SERVICE_DID, AssertionOperation.STATUS))); + assertEquals(401, code(claiming(AGENT_DID, AGENT_DID, "did:web:elsewhere", AssertionOperation.STATUS))); + assertEquals(401, code(claiming(AGENT_DID, AGENT_DID, SERVICE_DID, AssertionOperation.ENROLL))); + assertEquals(401, code(claiming("did:key:z6Mk", "did:key:z6Mk", SERVICE_DID, AssertionOperation.STATUS))); + assertEquals(401, code(claiming(null, null, SERVICE_DID, AssertionOperation.STATUS))); + } + + @Test + void replaysAreConsumedOnce() { + AepService service = + serviceBuilder(document(List.of(), Map.of(), List.of())).build(); + enroll(service); + + assertEquals(200, code(service.status(CommandOptions.authenticated("assertion-1")))); + assertEquals(401, code(service.status(CommandOptions.authenticated("assertion-1")))); + assertEquals(200, code(service.status(CommandOptions.authenticated("assertion-2")))); + } + + @Test + void refusesUnusableServiceConfiguration() { + InspectDocument document = document(List.of(), Map.of(), List.of()); + + assertThrows( + IllegalArgumentException.class, + () -> serviceBuilder(document).clockSkew(Duration.ofSeconds(-1)).build()); + assertThrows( + IllegalArgumentException.class, + () -> serviceBuilder(document).clockSkew(Duration.ofSeconds(31)).build()); + assertThrows( + IllegalArgumentException.class, + () -> serviceBuilder(document) + .clockSkew(Duration.ofMillis(1500)) + .build()); + assertThrows( + IllegalArgumentException.class, + () -> serviceBuilder(document) + .inspectUri(URI.create("http://service.example/.well-known/aep")) + .build()); + assertThrows( + IllegalArgumentException.class, + () -> serviceBuilder(document(List.of(API_KEY), Map.of(API_KEY, config(List.of())))) + .build(), + "an advertised Grant Type needs a handler"); + assertThrows( + IllegalArgumentException.class, + () -> serviceBuilder(document(List.of(), Map.of(), List.of(API_KEY))) + .build(), + "an advertised authentication method needs a Grant Type"); + assertThrows( + CompletionException.class, + () -> serviceBuilder(document) + .identifierSupplier(() -> " ") + .build() + .enroll(enrollRequest(), CommandOptions.idempotent("e", ENROLL_KEY)) + .toCompletableFuture() + .join()); + } + + @Test + void acceptsEveryConfigurableCollaborator() { + AepService service = serviceBuilder(document(List.of(), Map.of(), List.of())) + .enrollmentStore(EnrollmentStore.inMemory()) + .enrollmentPolicy(EnrollmentPolicy.active()) + .replayStore(ReplayStore.inMemory()) + .idempotencyStore(IdempotencyStore.inMemory(CLOCK)) + .clockSkew(Duration.ofSeconds(5)) + .claimValueLimits(ClaimValueLimits.defaults()) + .inspectUri(URI.create("https://service.example/.well-known/aep")) + .build(); + + assertEquals(200, code(service.enroll(enrollRequest(), CommandOptions.idempotent("e", ENROLL_KEY)))); + var builder = serviceBuilder(document(List.of(), Map.of(), List.of())); + assertThrows(NullPointerException.class, () -> builder.enrollmentStore(null)); + assertThrows(NullPointerException.class, () -> builder.enrollmentPolicy(null)); + assertThrows(NullPointerException.class, () -> builder.replayStore(null)); + assertThrows(NullPointerException.class, () -> builder.idempotencyStore(null)); + assertThrows(NullPointerException.class, () -> builder.clockSkew(null)); + assertThrows(NullPointerException.class, () -> builder.claimValueLimits(null)); + assertThrows(NullPointerException.class, () -> builder.identifierSupplier(null)); + assertThrows(NullPointerException.class, () -> builder.clock(null)); + } + + @Test + void refusesDuplicateHandlerRegistrations() { + var builder = serviceBuilder(document(List.of(API_KEY), Map.of(API_KEY, config(List.of())))); + GrantTypeDefinition definition = new GrantTypeDefinition(API_KEY, handler()); + builder.grantType(definition); + builder.credentialAuthenticator(API_KEY, handler()); + + assertThrows(IllegalArgumentException.class, () -> builder.grantType(definition)); + assertThrows(IllegalArgumentException.class, () -> builder.credentialAuthenticator(API_KEY, handler())); + assertThrows(NullPointerException.class, () -> builder.storedCredentialGrantType(null)); + assertEquals(API_KEY, definition.grantType()); + } + + @Test + void refusesStoredCredentialConfigurationTheDocumentDoesNotAdvertise() { + var builder = serviceBuilder(document(List.of(API_KEY), Map.of(API_KEY, config(List.of())))); + StoredCredentialGrantType mismatched = StoredCredentialGrantTypes.apiKey( + config(List.of("x-other")), + (request, context) -> completed(apiKey(API_KEY_HEADER)), + ServiceCredentialStore.inMemory()); + + assertThrows(IllegalArgumentException.class, () -> builder.storedCredentialGrantType(mismatched)); + assertEquals(API_KEY, mismatched.grantType()); + assertEquals(config(List.of("x-other")), mismatched.config()); + } + + @Test + void rejectsIssuedCredentialsThatDoNotMatchTheirGrantType() { + assertThrows( + CompletionException.class, + ServiceCoverageTest::issueWrongCredentialType, + "an api-key handler does not issue a bearer token"); + assertThrows( + CompletionException.class, + () -> issue((request, context) -> completed(apiKey("Bad Header"))), + "the response still has to validate"); + assertThrows( + CompletionException.class, + () -> issue((request, context) -> + completed(new GrantResponses.ApiKey("s", "c", "whenever", API_KEY_HEADER, List.of()))), + "the expiry has to be a date-time"); + assertThrows( + CompletionException.class, + () -> issue((request, context) -> completed( + new GrantResponses.ApiKey("s", "c", "2026-09-01T11:00:00Z", API_KEY_HEADER, List.of()))), + "the expiry has to follow issuance"); + assertThrows( + CompletionException.class, + () -> issue((request, context) -> completed(apiKey("x-unadvertised"))), + "the header has to be advertised"); + } + + @Test + void rejectsCredentialStoresThatReturnAnUnusableMatch() { + assertThrows( + CompletionException.class, + () -> matched( + new ServiceCredentialMatch(" ", "credential-1", NOW.plusSeconds(60), API_KEY, List.of()))); + assertThrows( + CompletionException.class, + () -> matched(new ServiceCredentialMatch(AGENT_DID, " ", NOW.plusSeconds(60), API_KEY, List.of()))); + assertThrows( + CompletionException.class, + () -> matched(new ServiceCredentialMatch( + AGENT_DID, "credential-1", NOW.minusSeconds(1), API_KEY, List.of()))); + assertThrows( + CompletionException.class, + () -> matched(new ServiceCredentialMatch( + AGENT_DID, "credential-1", NOW.plusSeconds(60), "other", List.of()))); + assertThrows( + NullPointerException.class, + () -> new ServiceCredentialMatch(AGENT_DID, "credential-1", null, API_KEY, List.of())); + } + + @Test + void rejectsAuthenticatorsThatReturnAnUnusablePrincipal() { + AepService service = serviceBuilder(document(List.of(API_KEY), Map.of(API_KEY, config(List.of())))) + .grantType(new GrantTypeDefinition(API_KEY, handler())) + .credentialAuthenticator(API_KEY, new CredentialAuthenticator() { + @Override + public CompletionStage hasPresentation(CredentialAuthenticationInput input) { + return completed(true); + } + + @Override + public CompletionStage> authenticate( + CredentialAuthenticationInput input) { + return completed(Optional.of(new AuthenticatedPrincipal( + AGENT_DID, + API_KEY, + "credential-1", + API_KEY, + List.of(), + AuthenticatedPrincipal.Kind.AEP_JWT))); + } + }) + .build(); + enroll(service); + + assertThrows(CompletionException.class, () -> authenticate(service, Map.of(API_KEY_HEADER, List.of("x")))); + } + + @Test + void refusesSessionCredentialsForAgentsThatAreNotActive() { + EnrollmentStore store = EnrollmentStore.inMemory(); + AepService service = ServiceFixtures.storedCredentialService(ServiceCredentialStore.inMemory(), store); + enroll(service); + grant(service, API_KEY); + store.save(EnrollmentRecord.builder(AGENT_DID, "enrollment-1", AgentStatus.TERMINATED, NOW) + .since(NOW) + .updatedAt(NOW) + .build()) + .toCompletableFuture() + .join(); + + assertEquals(NOT_RECOGNIZED, rejected(service, Map.of(API_KEY_HEADER, List.of("api-secret")))); + } + + private static AepService apiKeyService() { + return serviceBuilder(document(List.of(API_KEY), Map.of(API_KEY, config(List.of())))) + .grantType(new GrantTypeDefinition(API_KEY, handler())) + .credentialAuthenticator(API_KEY, handler()) + .build(); + } + + private static AepService jwtAndApiKeyService() { + return serviceBuilder(document( + List.of(API_KEY), + Map.of(API_KEY, config(List.of(API_KEY_HEADER))), + List.of(Aep.AUTHENTICATION_METHOD_JWT, API_KEY))) + .storedCredentialGrantType(StoredCredentialGrantTypes.apiKey( + config(List.of(API_KEY_HEADER)), + (request, context) -> completed(apiKey(API_KEY_HEADER)), + ServiceCredentialStore.inMemory())) + .build(); + } + + private static AepService freshService() { + return serviceBuilder(document(List.of(), Map.of(), List.of())).build(); + } + + private static GrantResponses.ApiKey apiKey(String header) { + return new GrantResponses.ApiKey("api-secret", "api-1", EXPIRY, header, List.of("read")); + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + private static void issueWrongCredentialType() { + BuiltInCredentialIssuer raw = (BuiltInCredentialIssuer) (request, context) -> + completed(new GrantResponses.OAuthBearer("s", "c", EXPIRY, List.of(), "opaque", "Bearer")); + issue((BuiltInCredentialIssuer) raw); + } + + private static void issue(BuiltInCredentialIssuer issuer) { + AepService service = serviceBuilder( + document(List.of(API_KEY), Map.of(API_KEY, config(List.of(API_KEY_HEADER))))) + .storedCredentialGrantType(StoredCredentialGrantTypes.apiKey( + config(List.of(API_KEY_HEADER)), issuer, ServiceCredentialStore.inMemory())) + .build(); + enroll(service); + service.grant(grantRequest(), CommandOptions.idempotent("g", GRANT_KEY)) + .toCompletableFuture() + .join(); + } + + private static void matched(ServiceCredentialMatch match) { + AepService service = serviceBuilder(document(List.of(API_KEY), Map.of(API_KEY, config(List.of())))) + .storedCredentialGrantType(StoredCredentialGrantTypes.apiKey( + config(List.of()), + (request, context) -> completed(apiKey(API_KEY_HEADER)), + new MatchingStore(match))) + .build(); + enroll(service); + authenticate(service, Map.of(API_KEY_HEADER, List.of("api-secret"))); + } + + private static CompletionStage> claiming( + String issuer, String subject, String audience, AssertionOperation operation) { + AepService service = AepService.builder( + document(List.of(), Map.of(), List.of()), + (assertion, context) -> completed(new ClientAssertionClaims( + issuer, + subject, + audience, + operation, + NOW.minusSeconds(1).getEpochSecond(), + NOW.plusSeconds(60).getEpochSecond(), + assertion, + null))) + .clock(CLOCK) + .build(); + return service.status(CommandOptions.authenticated("assertion")); + } + + private static void resource(AepService service, URI url) { + service.authenticate(new ProtectedResourceRequest(Map.of(), "GET", url)) + .toCompletableFuture() + .join(); + } + + private static EnrollRequest enrollRequest() { + return new EnrollRequest(AGENT_DID, null, null); + } + + private static GrantRequest grantRequest() { + return new GrantRequest(API_KEY, List.of("read")); + } + + private static CommandOptions key(String value, String assertion) { + return CommandOptions.idempotent(assertion, value); + } + + private static int code(CompletionStage> stage) { + return stage.toCompletableFuture().join().status(); + } + + private static String rejected(AepService service, Map> headers) { + ProtectedResourceResult result = authenticate(service, headers); + assertFalse(result.authenticated(), "expected a rejection"); + return result.response().problem().code(); + } + + private static RecordingHandler handler() { + return new RecordingHandler(); + } + + private static final class RecordingHandler implements GrantTypeHandler, CredentialAuthenticator { + @Override + public CompletionStage grant(GrantRequest request, GrantContext context) { + return completed(new GrantResult("credential-1", Map.of("credential_id", "credential-1"))); + } + + @Override + public CompletionStage revoke(RevokeRequest request, RevokeContext context) { + return completed(null); + } + + @Override + public CompletionStage hasPresentation(CredentialAuthenticationInput input) { + return completed(false); + } + + @Override + public CompletionStage> authenticate(CredentialAuthenticationInput input) { + return completed(Optional.empty()); + } + } + + private record MatchingStore(ServiceCredentialMatch match) implements ServiceCredentialStore { + @Override + public CompletionStage> authenticate( + String grantType, CredentialAuthenticationInput input) { + return completed(Optional.of(match)); + } + + @Override + public CompletionStage hasPresentation(String grantType, CredentialAuthenticationInput input) { + return completed(true); + } + + @Override + public CompletionStage revoke(String agentDid, String grantType, String credentialId, Instant revokedAt) { + return completed(null); + } + + @Override + public CompletionStage revokeGrantType(String agentDid, String grantType, Instant revokedAt) { + return completed(null); + } + + @Override + public CompletionStage save(ServiceCredentialRecord record) { + return completed(null); + } + } +} diff --git a/aep-service/src/test/java/foundation/aep/service/ServiceEdgeTest.java b/aep-service/src/test/java/foundation/aep/service/ServiceEdgeTest.java new file mode 100644 index 0000000..38ba941 --- /dev/null +++ b/aep-service/src/test/java/foundation/aep/service/ServiceEdgeTest.java @@ -0,0 +1,679 @@ +package foundation.aep.service; + +import static foundation.aep.service.ServiceFixtures.AGENT_DID; +import static foundation.aep.service.ServiceFixtures.API_KEY_HEADER; +import static foundation.aep.service.ServiceFixtures.AUTHORIZATION; +import static foundation.aep.service.ServiceFixtures.CLOCK; +import static foundation.aep.service.ServiceFixtures.NOW; +import static foundation.aep.service.ServiceFixtures.RESOURCE; +import static foundation.aep.service.ServiceFixtures.authenticate; +import static foundation.aep.service.ServiceFixtures.basicCredentials; +import static foundation.aep.service.ServiceFixtures.completed; +import static foundation.aep.service.ServiceFixtures.config; +import static foundation.aep.service.ServiceFixtures.document; +import static foundation.aep.service.ServiceFixtures.enroll; +import static foundation.aep.service.ServiceFixtures.grant; +import static foundation.aep.service.ServiceFixtures.serviceBuilder; +import static foundation.aep.service.ServiceFixtures.storedCredentialService; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import foundation.aep.core.Aep; +import foundation.aep.core.AepCommand; +import foundation.aep.core.AgentStatus; +import foundation.aep.core.GrantResponses; +import foundation.aep.core.InspectDocument; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import org.junit.jupiter.api.Test; + +/** Boundary conditions across the AEP Service HTTP binding and its reference stores. */ +final class ServiceEdgeTest { + private static final String API_KEY = Aep.GRANT_TYPE_API_KEY; + private static final String CONTENT_TYPE = "Content-Type"; + private static final String EXPIRY = "2026-09-01T12:10:00Z"; + private static final String CREDENTIAL_ID = "api-1"; + private static final byte[] ENROLL_BODY = + "{\"agent_did\":\"did:web:agent.example\"}".getBytes(StandardCharsets.UTF_8); + + @Test + void refusesRequestsTheBindingCannotRoute() { + AepServiceHttpHandler handler = handler(); + + assertEquals( + 404, + handle(handler, AepCommand.GRANT, "POST", Map.of(), new byte[0]).status()); + assertEquals( + 400, + handle(handler, AepCommand.INSPECT, "GET", Map.of(), ENROLL_BODY) + .status(), + "Inspect has no body"); + assertEquals( + 400, + handle(handler, AepCommand.STATUS, "GET", Map.of(), ENROLL_BODY).status(), + "Status has no body"); + assertThrows(NullPointerException.class, () -> handler.handle(null, request("GET", Map.of(), new byte[0]))); + assertThrows(NullPointerException.class, () -> handler.handle(AepCommand.INSPECT, null)); + assertThrows(NullPointerException.class, () -> handler.authenticate(null)); + } + + @Test + void refusesBodiesTheBindingCannotRead() { + AepServiceHttpHandler small = new AepServiceHttpHandler(commandService(), 3); + Map> aep = Map.of(CONTENT_TYPE, List.of(Aep.MEDIA_TYPE)); + + assertEquals( + 413, handle(small, AepCommand.ENROLL, "POST", aep, ENROLL_BODY).status()); + assertEquals( + 400, + handle(small, AepCommand.ENROLL, "POST", aep, new byte[] {(byte) 0xc3, (byte) 0x28, 0x20}) + .status(), + "the body has to be UTF-8"); + assertEquals( + 400, + handle(handler(), AepCommand.ENROLL, "POST", aep, "not json".getBytes(StandardCharsets.UTF_8)) + .status()); + assertEquals( + 415, + handle( + handler(), + AepCommand.ENROLL, + "POST", + Map.of(CONTENT_TYPE, List.of("application/json")), + ENROLL_BODY) + .status()); + assertEquals( + 415, + handle(handler(), AepCommand.ENROLL, "POST", Map.of(), ENROLL_BODY) + .status(), + "the media type is required"); + assertEquals( + 415, + handle( + handler(), + AepCommand.ENROLL, + "POST", + Map.of(CONTENT_TYPE, List.of(Aep.MEDIA_TYPE, Aep.MEDIA_TYPE)), + ENROLL_BODY) + .status(), + "one media type, not two"); + assertEquals( + 200, + handle( + handler(), + AepCommand.ENROLL, + "POST", + Map.of(CONTENT_TYPE, List.of("APPLICATION/AEP+JSON; charset=utf-8")), + ENROLL_BODY) + .status(), + "the essence matches case-insensitively and ignores parameters"); + } + + @Test + void routesEveryAdvertisedCommand() { + AepServiceHttpHandler handler = new AepServiceHttpHandler(commandService()); + Map> aep = Map.of(CONTENT_TYPE, List.of(Aep.MEDIA_TYPE)); + + assertEquals(5, handler.routes().size()); + assertEquals(Aep.WELL_KNOWN_PATH, handler.routes().get(AepCommand.INSPECT)); + assertEquals("/aep/enroll", handler.routes().get(AepCommand.ENROLL)); + assertEquals(AepServiceHttpHandler.DEFAULT_MAXIMUM_REQUEST_BYTES, handler.maximumRequestBytes()); + assertEquals("public, max-age=300", handler.inspectCacheControl()); + assertEquals( + 200, + handle(handler, AepCommand.ENROLL, "POST", aep, ENROLL_BODY).status()); + assertEquals( + 200, + handle( + handler, + AepCommand.GRANT, + "POST", + aep, + "{\"grant_type\":\"api-key\"}".getBytes(StandardCharsets.UTF_8)) + .status()); + assertEquals( + 200, + handle( + handler, + AepCommand.REVOKE, + "POST", + aep, + "{\"grant_type\":\"api-key\"}".getBytes(StandardCharsets.UTF_8)) + .status()); + } + + @Test + void readsTheAssertionOnlyFromASingleAepAuthorizationField() { + AepServiceHttpHandler handler = new AepServiceHttpHandler(commandService()); + Map> aep = new LinkedHashMap<>(); + aep.put(CONTENT_TYPE, List.of(Aep.MEDIA_TYPE)); + + assertEquals(401, statusWith(handler, headers(aep, AUTHORIZATION, List.of("Bearer token")))); + assertEquals(401, statusWith(handler, headers(aep, AUTHORIZATION, List.of("nonsense")))); + assertEquals(401, statusWith(handler, headers(aep, AUTHORIZATION, List.of("AEP a", "AEP b")))); + assertEquals(200, statusWith(handler, headers(aep, AUTHORIZATION, List.of("AEP assertion")))); + } + + @Test + void refusesUnusableHandlerConfiguration() { + AepService service = commandService(); + + assertThrows(NullPointerException.class, () -> new AepServiceHttpHandler(null)); + assertThrows(IllegalArgumentException.class, () -> new AepServiceHttpHandler(service, 0)); + assertThrows(IllegalArgumentException.class, () -> new AepServiceHttpHandler(service, -1)); + assertThrows(NullPointerException.class, () -> new AepServiceHttpHandler(service, 1024, null)); + assertThrows(IllegalArgumentException.class, () -> new AepServiceHttpHandler(service, 1024, " ")); + assertThrows(IllegalArgumentException.class, () -> new AepServiceHttpHandler(service, 1024, "max-age=5 ")); + assertThrows( + IllegalArgumentException.class, () -> new AepServiceHttpHandler(service, 1024, "max-age=5\r\nX: 1")); + assertEquals("no-store", new AepServiceHttpHandler(service, 1024, "no-store").inspectCacheControl()); + } + + @Test + void buildsRequestUrlsFromAPublicOrigin() { + URI origin = URI.create("https://service.example"); + + assertEquals( + URI.create("https://service.example/aep/status?x=1"), + AepHttpRequest.publicUrl(origin, "/aep/status", "x=1")); + assertEquals(List.of(), request("GET", Map.of(), new byte[0]).headerValues("Absent")); + assertThrows(IllegalArgumentException.class, () -> AepHttpRequest.publicUrl(null, "/x", null)); + assertThrows( + IllegalArgumentException.class, + () -> AepHttpRequest.publicUrl(URI.create("ftp://service.example"), "/x", null)); + assertThrows( + IllegalArgumentException.class, + () -> AepHttpRequest.publicUrl(URI.create("https://user@service.example"), "/x", null)); + assertThrows( + IllegalArgumentException.class, + () -> AepHttpRequest.publicUrl(URI.create("https://service.example/base"), "/x", null)); + assertThrows(IllegalArgumentException.class, () -> AepHttpRequest.publicUrl(origin, "relative", null)); + assertThrows(IllegalArgumentException.class, () -> AepHttpRequest.publicUrl(origin, "//evil.example", null)); + assertThrows(NullPointerException.class, () -> new AepHttpRequest(null, RESOURCE, Map.of(), new byte[0])); + assertThrows(NullPointerException.class, () -> new AepHttpRequest("GET", null, Map.of(), new byte[0])); + assertEquals(0, new AepHttpRequest("GET", RESOURCE, Map.of(), null).body().length); + } + + @Test + void reportsAuthenticationResultsAsOneOrTheOther() { + AuthenticatedPrincipal principal = new AuthenticatedPrincipal( + AGENT_DID, + API_KEY, + CREDENTIAL_ID, + API_KEY, + List.of("read"), + AuthenticatedPrincipal.Kind.SESSION_CREDENTIAL); + AepHttpResponse response = new AepHttpResponse(401, Aep.PROBLEM_MEDIA_TYPE, Map.of(), new byte[0]); + + assertTrue(AepHttpAuthenticationResult.authenticated(principal).authenticated()); + assertFalse(AepHttpAuthenticationResult.rejected(response).authenticated()); + assertThrows(IllegalArgumentException.class, () -> new AepHttpAuthenticationResult(false, principal, response)); + assertEquals( + principal, AepHttpAuthenticationResult.authenticated(principal).principal()); + } + + @Test + void authenticatesThroughTheBindingAndRejectsThroughIt() { + AepService service = storedCredentialService(ServiceCredentialStore.inMemory()); + enroll(service); + grant(service, API_KEY); + AepServiceHttpHandler handler = new AepServiceHttpHandler(service); + + AepHttpAuthenticationResult accepted = handler.authenticate( + new AepHttpRequest("GET", RESOURCE, Map.of(API_KEY_HEADER, List.of("api-secret")), new byte[0])) + .toCompletableFuture() + .join(); + AepHttpAuthenticationResult refused = handler.authenticate( + new AepHttpRequest("GET", RESOURCE, Map.of(API_KEY_HEADER, List.of("wrong")), new byte[0])) + .toCompletableFuture() + .join(); + + assertTrue(accepted.authenticated()); + assertEquals(CREDENTIAL_ID, accepted.principal().credentialId()); + assertFalse(refused.authenticated()); + assertEquals(401, refused.response().status()); + } + + @Test + void guardsGrantResultsAndTheirJsonPayloads() { + Map nested = new LinkedHashMap<>(); + nested.put("list", List.of(1, "two", true)); + nested.put("absent", null); + Map response = new LinkedHashMap<>(); + response.put("credential_id", CREDENTIAL_ID); + response.put("nested", nested); + + GrantResult result = new GrantResult(CREDENTIAL_ID, response); + + assertEquals(nested, result.response().get("nested")); + assertThrows(IllegalArgumentException.class, () -> new GrantResult(" ", Map.of())); + assertThrows( + IllegalArgumentException.class, () -> new GrantResult(CREDENTIAL_ID, Map.of("credential_id", "other"))); + assertThrows(IllegalArgumentException.class, () -> new GrantResult(CREDENTIAL_ID, null)); + assertThrows(IllegalArgumentException.class, () -> new GrantResult(CREDENTIAL_ID, Map.of("x", Double.NaN))); + assertThrows( + IllegalArgumentException.class, + () -> new GrantResult(CREDENTIAL_ID, Map.of("x", Float.POSITIVE_INFINITY))); + assertThrows(IllegalArgumentException.class, () -> new GrantResult(CREDENTIAL_ID, Map.of("x", new Object()))); + assertThrows( + IllegalArgumentException.class, + () -> new GrantResult(CREDENTIAL_ID, Map.of("x", java.util.Collections.singletonMap(1, "v")))); + assertThrows( + IllegalArgumentException.class, + () -> new GrantResult(CREDENTIAL_ID, java.util.Collections.singletonMap(null, "v"))); + assertThrows(IllegalArgumentException.class, () -> new GrantTypeDefinition(" ", null)); + } + + @Test + void guardsStoredCredentialMetadata() { + InspectDocument.GrantTypeConfig apiConfig = config(List.of(API_KEY_HEADER)); + StoredCredentialGrantType stored = StoredCredentialGrantTypes.apiKey( + apiConfig, (request, context) -> completed(apiKey()), ServiceCredentialStore.inMemory()); + + assertEquals(API_KEY, stored.grantType()); + assertEquals(apiConfig, stored.config()); + assertThrows( + IllegalArgumentException.class, + () -> new StoredCredentialGrantType("other", apiConfig, stored.definition(), stored.authenticator())); + assertThrows( + NullPointerException.class, + () -> StoredCredentialGrantTypes.apiKey(apiConfig, null, ServiceCredentialStore.inMemory())); + assertThrows( + NullPointerException.class, + () -> StoredCredentialGrantTypes.apiKey(apiConfig, (request, context) -> completed(apiKey()), null)); + } + + @Test + void exposesEnrollmentRecordState() { + EnrollmentRecord record = EnrollmentRecord.builder(AGENT_DID, "enrollment-1", AgentStatus.ACTIVE, NOW) + .claims(foundation.aep.core.ClaimValues.builder() + .contactEmail("agent@example.com") + .build()) + .since(NOW) + .updatedAt(NOW.plusSeconds(1)) + .ownerActionRequired(true) + .verificationPending(List.of("contact.email")) + .requirementsPending(List.of("person.username")) + .build(); + + assertEquals("enrollment-1", record.enrollmentId()); + assertEquals("agent@example.com", record.claims().contactEmail()); + assertEquals(NOW, record.createdAt()); + assertEquals(NOW.plusSeconds(1), record.updatedAt()); + assertEquals(NOW, record.since()); + assertTrue(record.ownerActionRequired()); + assertThrows( + IllegalArgumentException.class, + () -> EnrollmentRecord.builder(" ", "enrollment-1", AgentStatus.ACTIVE, NOW) + .build()); + assertThrows( + NullPointerException.class, + () -> EnrollmentRecord.builder(AGENT_DID, "enrollment-1", null, NOW) + .build()); + } + + @Test + void coalescesConcurrentEnrollmentCreation() { + EnrollmentStore store = EnrollmentStore.inMemory(); + CompletableFuture creation = new CompletableFuture<>(); + EnrollmentRecord record = EnrollmentRecord.builder(AGENT_DID, "enrollment-1", AgentStatus.ACTIVE, NOW) + .since(NOW) + .updatedAt(NOW) + .build(); + + var first = store.findOrCreate(AGENT_DID, () -> creation).toCompletableFuture(); + var second = store.findOrCreate(AGENT_DID, () -> creation).toCompletableFuture(); + creation.complete(record); + + assertTrue(first.join().created()); + assertFalse(second.join().created()); + assertFalse(store.findOrCreate(AGENT_DID, () -> creation) + .toCompletableFuture() + .join() + .created()); + assertThrows( + CompletionException.class, + () -> store.findOrCreate("did:web:other.example", () -> completed(record)) + .toCompletableFuture() + .join(), + "a record has to belong to the Agent it was created for"); + assertThrows( + CompletionException.class, + () -> store.findOrCreate( + "did:web:third.example", + () -> CompletableFuture.failedFuture(new IllegalStateException("boom"))) + .toCompletableFuture() + .join()); + } + + @Test + void sharesAnInFlightIdempotentOperationAndSurfacesItsFailure() { + IdempotencyStore store = IdempotencyStore.inMemory(CLOCK); + IdempotencyInput input = new IdempotencyInput(AGENT_DID, "key", "enroll", "hash"); + CompletableFuture> pending = new CompletableFuture<>(); + + var started = store.execute(input, () -> pending).toCompletableFuture(); + var joined = store.execute(input, () -> completed(ServiceResponse.success("ignored"))) + .toCompletableFuture(); + var conflicting = store.execute( + new IdempotencyInput(AGENT_DID, "key", "grant", "hash"), + () -> completed(ServiceResponse.success("no"))) + .toCompletableFuture() + .join(); + pending.complete(ServiceResponse.success("done")); + + assertEquals("done", started.join().response().body()); + assertEquals(IdempotencyResult.State.REPLAYED, joined.join().state()); + assertEquals(IdempotencyResult.State.CONFLICT, conflicting.state()); + assertThrows( + CompletionException.class, + () -> store.execute(new IdempotencyInput(AGENT_DID, "other", "enroll", "hash"), () -> { + throw new IllegalStateException("boom"); + }) + .toCompletableFuture() + .join()); + } + + @Test + void refusesUnusableReplayRecords() { + ReplayStore store = ReplayStore.inMemory(); + + assertThrows( + CompletionException.class, + () -> store.consume(new ReplayRecord(" ", "jti", NOW.plusSeconds(60)), NOW) + .toCompletableFuture() + .join()); + assertThrows( + CompletionException.class, + () -> store.consume(new ReplayRecord(AGENT_DID, " ", NOW.plusSeconds(60)), NOW) + .toCompletableFuture() + .join()); + assertThrows( + CompletionException.class, + () -> store.consume(new ReplayRecord(AGENT_DID, "jti", NOW), NOW) + .toCompletableFuture() + .join()); + assertTrue(store.consume(new ReplayRecord(AGENT_DID, "jti", NOW.plusSeconds(60)), NOW) + .toCompletableFuture() + .join()); + assertTrue( + store.consume(new ReplayRecord(AGENT_DID, "jti", NOW.plusSeconds(120)), NOW.plusSeconds(61)) + .toCompletableFuture() + .join(), + "an expired entry stops blocking its own identifier"); + } + + @Test + void boundsClaimValuesByEveryConfiguredLimit() { + assertThrows(IllegalArgumentException.class, () -> new ClaimValueLimits(0, 1, 1, 1)); + assertThrows(IllegalArgumentException.class, () -> new ClaimValueLimits(1, 0, 1, 1)); + assertThrows(IllegalArgumentException.class, () -> new ClaimValueLimits(1, 1, 0, 1)); + assertThrows(IllegalArgumentException.class, () -> new ClaimValueLimits(1, 1, 1, 0)); + + ClaimValueLimits limits = new ClaimValueLimits(65_536, 4, 2, 20); + + assertTrue(limits.accepts(null)); + assertTrue(limits.accepts( + foundation.aep.core.ClaimValues.builder().personUsername("ada").build())); + assertFalse( + limits.accepts(foundation.aep.core.ClaimValues.builder() + .personUsername("a-username-well-past-the-limit") + .build()), + "a long string is refused"); + assertFalse( + limits.accepts(foundation.aep.core.ClaimValues.builder() + .additional("a", Map.of("b", Map.of("c", "d"))) + .build()), + "a deep object is refused"); + assertFalse( + new ClaimValueLimits(32, 8, 8, 64) + .accepts(foundation.aep.core.ClaimValues.builder() + .contactEmail("a-fairly-long-address@example.com") + .personUsername("ada") + .build()), + "an oversized encoding is refused"); + } + + @Test + void redactsStoredCredentialRecords() { + ServiceCredentialRecord record = + new ServiceCredentialRecord(AGENT_DID, NOW, apiKey(), CREDENTIAL_ID, Instant.parse(EXPIRY), API_KEY); + + assertFalse(record.toString().contains("api-secret")); + assertTrue(record.toString().contains(CREDENTIAL_ID)); + assertThrows( + NullPointerException.class, + () -> new ServiceCredentialRecord(AGENT_DID, NOW, null, CREDENTIAL_ID, Instant.parse(EXPIRY), API_KEY)); + } + + @Test + void refusesCredentialRecordsTheStoreCannotIndex() { + ServiceCredentialStore store = ServiceCredentialStore.inMemory(); + + assertThrows(CompletionException.class, () -> save(store, CREDENTIAL_ID, "other-id", EXPIRY)); + assertThrows(CompletionException.class, () -> save(store, " ", " ", EXPIRY)); + assertThrows( + CompletionException.class, + () -> save(store, CREDENTIAL_ID, CREDENTIAL_ID, "2026-09-01T11:00:00Z"), + "expiry has to follow creation"); + save(store, CREDENTIAL_ID, CREDENTIAL_ID, EXPIRY); + assertThrows( + CompletionException.class, + () -> save(store, CREDENTIAL_ID, CREDENTIAL_ID, EXPIRY), + "an identifier is issued once"); + assertThrows(CompletionException.class, () -> save(store, "api-2", "api-2", EXPIRY), "a secret is issued once"); + } + + @Test + void revokesOnlyWhatTheRequestNames() { + ServiceCredentialStore store = ServiceCredentialStore.inMemory(); + AepService service = storedCredentialService(store); + enroll(service); + grant(service, API_KEY); + Map> presented = Map.of(API_KEY_HEADER, List.of("api-secret")); + + store.revoke("did:web:someone.example", API_KEY, CREDENTIAL_ID, NOW) + .toCompletableFuture() + .join(); + store.revoke(AGENT_DID, Aep.GRANT_TYPE_BASIC, CREDENTIAL_ID, NOW) + .toCompletableFuture() + .join(); + store.revoke(AGENT_DID, API_KEY, "absent", NOW).toCompletableFuture().join(); + assertTrue(authenticate(service, presented).authenticated(), "none of those named this credential"); + + store.revokeGrantType(AGENT_DID, API_KEY, NOW).toCompletableFuture().join(); + + assertFalse(authenticate(service, presented).authenticated()); + assertThrows( + CompletionException.class, + () -> store.revoke(AGENT_DID, API_KEY, CREDENTIAL_ID, null) + .toCompletableFuture() + .join()); + assertThrows( + CompletionException.class, + () -> store.revokeGrantType(AGENT_DID, API_KEY, null) + .toCompletableFuture() + .join()); + } + + @Test + void revokesEveryCredentialOfAGrantTypeForABlankIdentifier() { + ServiceCredentialStore store = ServiceCredentialStore.inMemory(); + AepService service = storedCredentialService(store); + enroll(service); + grant(service, API_KEY); + GrantTypeHandler handler = StoredCredentialGrantTypes.apiKey( + config(List.of(API_KEY_HEADER)), (request, context) -> completed(apiKey()), store) + .definition() + .handler(); + + handler.revoke( + new foundation.aep.core.RevokeRequest(API_KEY, " ", null), + new RevokeContext(AGENT_DID, null, API_KEY, NOW)) + .toCompletableFuture() + .join(); + + assertFalse( + authenticate(service, Map.of(API_KEY_HEADER, List.of("api-secret"))) + .authenticated(), + "a blank identifier names no credential, so the whole Grant Type is revoked"); + } + + @Test + void reportsAMalformedAepPresentationAsUnrecognized() { + AepService service = storedCredentialService(ServiceCredentialStore.inMemory()); + enroll(service); + grant(service, Aep.GRANT_TYPE_OAUTH_BEARER); + + // CORE-PR-038 separates a missing credential from a malformed one. A value naming an AEP + // scheme is an AEP presentation even when the rest of it does not parse, so ignoring it and + // answering authentication_required both reported the wrong code and told the caller its + // credential had never been seen. + assertEquals("not_recognized", rejected(service, Map.of(AUTHORIZATION, List.of("AEP")))); + assertEquals("not_recognized", rejected(service, Map.of(AUTHORIZATION, List.of("Bearer")))); + assertEquals( + "not_recognized", + rejected(service, Map.of(AUTHORIZATION, List.of("aep "))), + "scheme matching is case-insensitive and whitespace does not make credentials"); + assertEquals( + "not_recognized", + rejected(service, Map.of(AUTHORIZATION, List.of("Basic not a token"))), + "credentials carrying a space are not a single credential"); + assertEquals( + "authentication_required", + rejected(service, Map.of(AUTHORIZATION, List.of("Negotiate"))), + "an unrelated scheme with no credentials is still not an AEP presentation"); + } + + @Test + void ignoresUnrelatedAuthorizationSchemesOnProtectedResources() { + AepService service = storedCredentialService(ServiceCredentialStore.inMemory()); + enroll(service); + grant(service, Aep.GRANT_TYPE_OAUTH_BEARER); + + assertEquals("authentication_required", rejected(service, Map.of(AUTHORIZATION, List.of("Negotiate abc")))); + assertEquals( + "authentication_required", + rejected(service, Map.of(AUTHORIZATION, List.of("Negotiate abc", "Digest xyz"))), + "two unrelated schemes are still no AEP presentation"); + assertEquals( + "not_recognized", + rejected(service, Map.of(AUTHORIZATION, List.of("Bearer oauth-secret", "Negotiate abc"))), + "an AEP scheme alongside anything else is ambiguous"); + assertEquals( + "not_recognized", + rejected( + service, + Map.of( + Aep.AUTHORIZATION_HEADER, + List.of("Bearer oauth-secret"), + AUTHORIZATION, + List.of("Basic " + basicCredentials()))), + "the dedicated carrier does not win a race against a recognized standard one"); + } + + @Test + void doesNotResolveNamesWhenDecidingWhatIsLoopback() { + AepService service = serviceBuilder(document(List.of(), Map.of(), List.of())) + .allowInsecureLoopback(true) + .build(); + + assertThrows( + CompletionException.class, + () -> service.authenticate(new ProtectedResourceRequest( + Map.of(), "GET", URI.create("http://not-a-real-host.invalid/x"))) + .toCompletableFuture() + .join()); + assertThrows( + CompletionException.class, + () -> service.authenticate( + new ProtectedResourceRequest(Map.of(), "GET", URI.create("http://[2001:db8::1]/x"))) + .toCompletableFuture() + .join()); + assertFalse( + service.authenticate(new ProtectedResourceRequest(Map.of(), "GET", URI.create("http://[::1]:8080/x"))) + .toCompletableFuture() + .join() + .authenticated()); + } + + private static GrantResponses.ApiKey apiKey() { + return new GrantResponses.ApiKey("api-secret", CREDENTIAL_ID, EXPIRY, API_KEY_HEADER, List.of("read")); + } + + private static void save(ServiceCredentialStore store, String credentialId, String responseId, String expiry) { + store.save(new ServiceCredentialRecord( + AGENT_DID, + NOW, + new GrantResponses.ApiKey("api-secret", responseId, expiry, API_KEY_HEADER, List.of("read")), + credentialId, + Instant.parse(expiry), + API_KEY)) + .toCompletableFuture() + .join(); + } + + private static AepService commandService() { + return serviceBuilder(document(List.of(API_KEY), Map.of(API_KEY, config(List.of(API_KEY_HEADER))))) + .storedCredentialGrantType(StoredCredentialGrantTypes.apiKey( + config(List.of(API_KEY_HEADER)), + (request, context) -> completed(apiKey()), + ServiceCredentialStore.inMemory())) + .build(); + } + + private static AepServiceHttpHandler handler() { + return new AepServiceHttpHandler( + serviceBuilder(document(List.of(), Map.of(), List.of())).build()); + } + + private static Map> headers(Map> base, String name, List values) { + Map> result = new LinkedHashMap<>(base); + result.put(name, values); + return result; + } + + private static int statusWith(AepServiceHttpHandler handler, Map> headers) { + return handle(handler, AepCommand.ENROLL, "POST", headers, ENROLL_BODY).status(); + } + + private static AepHttpResponse handle( + AepServiceHttpHandler handler, + AepCommand command, + String method, + Map> headers, + byte[] body) { + return handler.handle(command, request(method, headers, body)) + .toCompletableFuture() + .join(); + } + + private static AepHttpRequest request(String method, Map> headers, byte[] body) { + Map> all = new LinkedHashMap<>(headers); + int sequence = counter(); + all.putIfAbsent(AUTHORIZATION, List.of("AEP assertion-" + sequence)); + all.putIfAbsent("Idempotency-Key", List.of("key-" + sequence)); + return new AepHttpRequest(method, RESOURCE, all, body); + } + + private static int counter() { + return COUNTER.incrementAndGet(); + } + + private static final java.util.concurrent.atomic.AtomicInteger COUNTER = + new java.util.concurrent.atomic.AtomicInteger(); + + private static String rejected(AepService service, Map> headers) { + ProtectedResourceResult result = authenticate(service, headers); + assertFalse(result.authenticated(), "expected a rejection"); + return result.response().problem().code(); + } +} diff --git a/aep-service/src/test/java/foundation/aep/service/ServiceFixtures.java b/aep-service/src/test/java/foundation/aep/service/ServiceFixtures.java new file mode 100644 index 0000000..d50ae9f --- /dev/null +++ b/aep-service/src/test/java/foundation/aep/service/ServiceFixtures.java @@ -0,0 +1,159 @@ +package foundation.aep.service; + +import foundation.aep.core.Aep; +import foundation.aep.core.AssertionOperation; +import foundation.aep.core.ClientAssertionClaims; +import foundation.aep.core.EnrollRequest; +import foundation.aep.core.GrantRequest; +import foundation.aep.core.GrantResponses; +import foundation.aep.core.InspectDocument; +import java.net.URI; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; + +/** Shared construction helpers for the AEP Service tests. */ +final class ServiceFixtures { + static final Instant NOW = Instant.parse("2026-09-01T12:00:00Z"); + static final Clock CLOCK = Clock.fixed(NOW, ZoneOffset.UTC); + static final String AGENT_DID = "did:web:agent.example"; + static final String SERVICE_DID = "did:web:service.example"; + static final String API_KEY_HEADER = "X-Service-Key"; + static final String AUTHORIZATION = "Authorization"; + static final URI RESOURCE = URI.create("https://service.example/private"); + static final List ALL_COMMANDS = List.of("enroll", "grant", "inspect", "revoke", "status"); + + private ServiceFixtures() {} + + static InspectDocument document(List grantTypes, Map configs) { + return document(grantTypes, configs, grantTypes); + } + + static InspectDocument document( + List grantTypes, + Map configs, + List authenticationMethods) { + List commands = grantTypes.isEmpty() ? List.of("enroll", "inspect", "status") : ALL_COMMANDS; + return InspectDocument.builder() + .version(Aep.VERSION) + .authentication( + authenticationMethods.isEmpty() + ? null + : new InspectDocument.Authentication(authenticationMethods)) + .bindings(new InspectDocument.Bindings(List.of("http"))) + .claims(new InspectDocument.Claims(List.of(), List.of(), List.of())) + .commands(new InspectDocument.Commands(commands, grantTypes, configs)) + .core(new InspectDocument.Core(Aep.REQUIRED_SIGNING_ALGORITHMS)) + .http(new InspectDocument.Http("/aep", null)) + .identity(new InspectDocument.Identity(List.of("did:web"))) + .service(new InspectDocument.Service(SERVICE_DID)) + .build(); + } + + static InspectDocument.GrantTypeConfig config(List headerNames) { + return new InspectDocument.GrantTypeConfig( + List.of("opaque", "jwt"), + "600", + headerNames, + "service", + List.of("read"), + "true", + "https://service.example/introspect", + "https://service.example/revoke"); + } + + static AepService.Builder serviceBuilder(InspectDocument document) { + return AepService.builder(document, verifier()).clock(CLOCK).identifierSupplier(() -> "enrollment-1"); + } + + static ClientAssertionVerifier verifier() { + return (assertion, context) -> completed(new ClientAssertionClaims( + AGENT_DID, + AGENT_DID, + context.serviceDid(), + context.operation(), + NOW.minusSeconds(1).getEpochSecond(), + NOW.plusSeconds(60).getEpochSecond(), + assertion, + context.operation() == AssertionOperation.AUTHENTICATE ? context.resource() : null)); + } + + /** A Service advertising all three built-in stored-credential Grant Types over one store. */ + static AepService storedCredentialService(ServiceCredentialStore store) { + return storedCredentialService(store, EnrollmentStore.inMemory()); + } + + static AepService storedCredentialService(ServiceCredentialStore store, EnrollmentStore enrollmentStore) { + Map configs = new LinkedHashMap<>(); + configs.put(Aep.GRANT_TYPE_OAUTH_BEARER, config(List.of())); + configs.put(Aep.GRANT_TYPE_API_KEY, config(List.of(API_KEY_HEADER))); + configs.put(Aep.GRANT_TYPE_BASIC, config(List.of())); + List grantTypes = List.of(Aep.GRANT_TYPE_OAUTH_BEARER, Aep.GRANT_TYPE_API_KEY, Aep.GRANT_TYPE_BASIC); + return serviceBuilder(document(grantTypes, configs)) + .enrollmentStore(enrollmentStore) + .storedCredentialGrantType(StoredCredentialGrantTypes.oauthBearer( + config(List.of()), + (request, context) -> completed(new GrantResponses.OAuthBearer( + "oauth-secret", + "oauth-1", + NOW.plusSeconds(600).toString(), + List.of("read"), + "opaque", + "Bearer")), + store)) + .storedCredentialGrantType(StoredCredentialGrantTypes.apiKey( + config(List.of(API_KEY_HEADER)), + (request, context) -> completed(new GrantResponses.ApiKey( + "api-secret", + "api-1", + NOW.plusSeconds(600).toString(), + API_KEY_HEADER, + List.of("read"))), + store)) + .storedCredentialGrantType(StoredCredentialGrantTypes.basic( + config(List.of()), + (request, context) -> completed(new GrantResponses.Basic( + "basic-1", + NOW.plusSeconds(600).toString(), + "basic-secret", + "service", + List.of("read"), + "agent")), + store)) + .build(); + } + + static String basicCredentials() { + return java.util.Base64.getEncoder() + .encodeToString("agent:basic-secret".getBytes(java.nio.charset.StandardCharsets.UTF_8)); + } + + static void enroll(AepService service) { + service.enroll(new EnrollRequest(AGENT_DID, null, null), CommandOptions.idempotent("enroll", "enroll-key")) + .toCompletableFuture() + .join(); + } + + static void grant(AepService service, String grantType) { + service.grant( + new GrantRequest(grantType, List.of("read")), + CommandOptions.idempotent(grantType, grantType + "-key")) + .toCompletableFuture() + .join(); + } + + static ProtectedResourceResult authenticate(AepService service, Map> headers) { + return service.authenticate(new ProtectedResourceRequest(headers, "GET", RESOURCE)) + .toCompletableFuture() + .join(); + } + + static CompletionStage completed(T value) { + return CompletableFuture.completedFuture(value); + } +} diff --git a/aep-service/src/test/java/foundation/aep/service/ServiceValidationTest.java b/aep-service/src/test/java/foundation/aep/service/ServiceValidationTest.java new file mode 100644 index 0000000..2b2ef51 --- /dev/null +++ b/aep-service/src/test/java/foundation/aep/service/ServiceValidationTest.java @@ -0,0 +1,657 @@ +package foundation.aep.service; + +import static foundation.aep.service.ServiceFixtures.AGENT_DID; +import static foundation.aep.service.ServiceFixtures.API_KEY_HEADER; +import static foundation.aep.service.ServiceFixtures.AUTHORIZATION; +import static foundation.aep.service.ServiceFixtures.CLOCK; +import static foundation.aep.service.ServiceFixtures.NOW; +import static foundation.aep.service.ServiceFixtures.SERVICE_DID; +import static foundation.aep.service.ServiceFixtures.authenticate; +import static foundation.aep.service.ServiceFixtures.completed; +import static foundation.aep.service.ServiceFixtures.config; +import static foundation.aep.service.ServiceFixtures.document; +import static foundation.aep.service.ServiceFixtures.enroll; +import static foundation.aep.service.ServiceFixtures.grant; +import static foundation.aep.service.ServiceFixtures.serviceBuilder; +import static foundation.aep.service.ServiceFixtures.storedCredentialService; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import foundation.aep.core.Aep; +import foundation.aep.core.AgentStatus; +import foundation.aep.core.ClaimValues; +import foundation.aep.core.ClientAssertionClaims; +import foundation.aep.core.EnrollRequest; +import foundation.aep.core.GrantRequest; +import foundation.aep.core.GrantResponses; +import foundation.aep.core.InspectDocument; +import foundation.aep.core.RevokeRequest; +import java.net.URI; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CompletionException; +import java.util.concurrent.CompletionStage; +import org.junit.jupiter.api.Test; + +/** Field-by-field validation of the AEP Service surface. */ +final class ServiceValidationTest { + private static final String API_KEY = Aep.GRANT_TYPE_API_KEY; + private static final String EXPIRY = "2026-09-01T12:10:00Z"; + private static final String CREDENTIAL_ID = "api-1"; + private static final String NOT_RECOGNIZED = "not_recognized"; + + @Test + void acceptsAnEchoedIdempotencyKeyInTheEnrollBody() { + AepService service = + serviceBuilder(document(List.of(), Map.of(), List.of())).build(); + + assertEquals( + 200, + status(service.enroll( + new EnrollRequest(AGENT_DID, null, "key-1"), CommandOptions.idempotent("a1", "key-1")))); + assertEquals( + 400, + status(service.enroll( + new EnrollRequest(AGENT_DID, null, "other"), CommandOptions.idempotent("a2", "key-2")))); + } + + @Test + void treatsAnAbsentClaimsAdvertisementAsNoRequirements() { + InspectDocument document = InspectDocument.builder() + .version(Aep.VERSION) + .bindings(new InspectDocument.Bindings(List.of("http"))) + .commands(new InspectDocument.Commands(List.of("enroll", "inspect", "status"), List.of(), Map.of())) + .core(new InspectDocument.Core(Aep.REQUIRED_SIGNING_ALGORITHMS)) + .http(new InspectDocument.Http("/aep", null)) + .identity(new InspectDocument.Identity(List.of("did:web"))) + .service(new InspectDocument.Service(SERVICE_DID)) + .build(); + AepService service = serviceBuilder(document).build(); + + assertEquals( + 200, + status(service.enroll(new EnrollRequest(AGENT_DID, null, null), CommandOptions.idempotent("a", "k"))), + "a Service advertising no claims requires none"); + } + + @Test + void reportsEveryRequiredClaimTheRequestOmits() { + InspectDocument document = InspectDocument.builder() + .version(Aep.VERSION) + .bindings(new InspectDocument.Bindings(List.of("http"))) + .claims(new InspectDocument.Claims(List.of("contact.email", "person.username"), List.of(), List.of())) + .commands(new InspectDocument.Commands(List.of("enroll", "inspect", "status"), List.of(), Map.of())) + .core(new InspectDocument.Core(Aep.REQUIRED_SIGNING_ALGORITHMS)) + .http(new InspectDocument.Http("/aep", null)) + .identity(new InspectDocument.Identity(List.of("did:web"))) + .service(new InspectDocument.Service(SERVICE_DID)) + .build(); + AepService service = serviceBuilder(document).build(); + + var response = service.enroll( + new EnrollRequest( + AGENT_DID, + ClaimValues.builder() + .contactEmail("agent@example.com") + .build(), + null), + CommandOptions.idempotent("a", "k")) + .toCompletableFuture() + .join(); + + assertEquals(422, response.status()); + assertEquals(List.of("person.username"), response.problem().requirementsPending()); + } + + @Test + void refusesPerCredentialRevokeWhenNoConfigurationIsAdvertised() { + AepService service = serviceBuilder(document(List.of(API_KEY), Map.of())) + .grantType(new GrantTypeDefinition(API_KEY, handler())) + .credentialAuthenticator(API_KEY, handler()) + .build(); + enroll(service); + + assertEquals( + 400, + status(service.revoke( + new RevokeRequest(API_KEY, CREDENTIAL_ID, null), CommandOptions.idempotent("r", "r-key")))); + assertEquals( + 200, + status(service.revoke( + new RevokeRequest(null, null, "true"), CommandOptions.idempotent("r2", "r2-key"))), + "an all-Grant-Types revoke needs no per-credential configuration"); + } + + @Test + void refusesAssertionsWithNoCredentialToVerify() { + AepService service = + serviceBuilder(document(List.of(), Map.of(), List.of())).build(); + + assertEquals(401, status(service.status(CommandOptions.authenticated(null)))); + assertEquals(401, status(service.status(CommandOptions.authenticated("")))); + } + + @Test + void refusesClaimsOutsideTheirValidityWindow() { + assertEquals(401, status(timed(NOW.plusSeconds(120), NOW.plusSeconds(180))), "issued in the future"); + assertEquals(401, status(timed(NOW.minusSeconds(180), NOW.minusSeconds(120))), "already expired"); + assertEquals(200, status(timed(NOW.minusSeconds(1), NOW.plusSeconds(60)))); + } + + @Test + void refusesClaimsBoundToAnotherResource() { + AepService service = serviceBuilder(document(List.of(), Map.of(), List.of(Aep.AUTHENTICATION_METHOD_JWT))) + .build(); + enroll(service); + + assertTrue(authenticate(service, Map.of(AUTHORIZATION, List.of("AEP fresh"))) + .authenticated()); + + AepService misbinding = AepService.builder( + document(List.of(), Map.of(), List.of(Aep.AUTHENTICATION_METHOD_JWT)), + (assertion, context) -> completed(new ClientAssertionClaims( + AGENT_DID, + AGENT_DID, + context.serviceDid(), + context.operation(), + NOW.minusSeconds(1).getEpochSecond(), + NOW.plusSeconds(60).getEpochSecond(), + assertion, + "https://service.example/elsewhere"))) + .clock(CLOCK) + .build(); + enroll(misbinding); + + assertFalse(authenticate(misbinding, Map.of(AUTHORIZATION, List.of("AEP a"))) + .authenticated()); + } + + @Test + void refusesVerifiersThatReturnNoClaimsAtAll() { + AepService service = AepService.builder( + document(List.of(), Map.of(), List.of()), (assertion, context) -> completed(null)) + .clock(CLOCK) + .build(); + + assertEquals(401, status(service.status(CommandOptions.authenticated("a")))); + } + + @Test + void walksPastAuthenticationMethodsThatPresentNothing() { + AepService service = serviceBuilder(document( + List.of(API_KEY), + Map.of(API_KEY, config(List.of(API_KEY_HEADER))), + List.of(Aep.AUTHENTICATION_METHOD_JWT, API_KEY))) + .storedCredentialGrantType(StoredCredentialGrantTypes.apiKey( + config(List.of(API_KEY_HEADER)), + (request, context) -> completed(apiKey()), + ServiceCredentialStore.inMemory())) + .build(); + enroll(service); + grant(service, API_KEY); + + assertEquals( + CREDENTIAL_ID, + authenticate(service, Map.of(API_KEY_HEADER, List.of("api-secret"))) + .principal() + .credentialId(), + "aep-jwt is skipped and the api-key method answers"); + assertEquals("authentication_required", rejected(service, Map.of("X-Other", List.of("api-secret")))); + } + + @Test + void rejectsPrincipalsThatDoNotDescribeTheirOwnCredential() { + assertThrows(CompletionException.class, () -> principal(null, API_KEY, API_KEY)); + assertThrows(CompletionException.class, () -> principal(" ", API_KEY, API_KEY)); + assertThrows(CompletionException.class, () -> principal(AGENT_DID, "other", API_KEY)); + assertThrows(CompletionException.class, () -> principal(AGENT_DID, API_KEY, "other")); + } + + @Test + void requiresEveryAdvertisedMethodToHaveBothHandlers() { + assertThrows( + IllegalArgumentException.class, + () -> serviceBuilder(document(List.of(API_KEY), Map.of(API_KEY, config(List.of())), List.of(API_KEY))) + .grantType(new GrantTypeDefinition(API_KEY, handler())) + .build(), + "a Grant Type without an authenticator cannot authenticate"); + assertThrows( + IllegalArgumentException.class, + () -> serviceBuilder(document(List.of(), Map.of(), List.of())) + .grantType(new GrantTypeDefinition(API_KEY, handler())) + .build(), + "an unadvertised Grant Type has no handler slot"); + } + + @Test + void recognizesOnlyAdvertisedIdentityMethods() { + AepService service = AepService.builder( + document(List.of(), Map.of(), List.of()), + (assertion, context) -> completed(new ClientAssertionClaims( + "did:example:1", + "did:example:1", + context.serviceDid(), + context.operation(), + NOW.minusSeconds(1).getEpochSecond(), + NOW.plusSeconds(60).getEpochSecond(), + assertion, + null))) + .clock(CLOCK) + .build(); + + assertEquals(401, status(service.status(CommandOptions.authenticated("a")))); + } + + @Test + void foldsEveryOptionalRequestMemberIntoTheFingerprint() { + AepService service = storedCredentialService(ServiceCredentialStore.inMemory()); + enroll(service); + + assertEquals( + 200, + status(service.grant( + new GrantRequest(Aep.GRANT_TYPE_OAUTH_BEARER, "laptop", List.of("read"), "opaque"), + CommandOptions.idempotent("g1", "g-key")))); + assertEquals( + 409, + status(service.grant( + new GrantRequest(Aep.GRANT_TYPE_OAUTH_BEARER, "phone", List.of("read"), "opaque"), + CommandOptions.idempotent("g2", "g-key"))), + "the label is material input"); + assertEquals( + 200, + status(service.revoke( + new RevokeRequest(null, null, "true"), CommandOptions.idempotent("r1", "r-key")))); + assertEquals( + 409, + status(service.revoke( + new RevokeRequest(Aep.GRANT_TYPE_OAUTH_BEARER, null, null), + CommandOptions.idempotent("r2", "r-key")))); + } + + @Test + void refusesGeneratedIdentifiersItCannotUse() { + assertThrows(CompletionException.class, () -> generated(null)); + assertThrows(CompletionException.class, () -> generated(" ")); + } + + @Test + void appliesLoopbackRulesToEveryConfiguredUri() { + var strict = serviceBuilder(document(List.of(), Map.of(), List.of())); + + assertThrows( + IllegalArgumentException.class, + () -> strict.inspectUri(URI.create("https://user@service.example/x")) + .build()); + assertThrows( + IllegalArgumentException.class, + () -> strict.inspectUri(URI.create("https://service.example/x#f")) + .build()); + assertThrows( + IllegalArgumentException.class, + () -> strict.inspectUri(URI.create("/relative")).build()); + assertEquals( + "http://localhost:9000/.well-known/aep", + serviceBuilder(document(List.of(), Map.of(), List.of())) + .allowInsecureLoopback(true) + .inspectUri(URI.create("http://localhost:9000/.well-known/aep")) + .build() + .authenticate(new ProtectedResourceRequest( + Map.of(), "GET", URI.create("https://service.example/private"))) + .toCompletableFuture() + .join() + .response() + .headers() + .get("WWW-Authenticate") + .get(0) + .replaceAll(".*inspect=\"([^\"]+)\".*", "$1")); + } + + @Test + void normalizesOptionalDecisionAndPrincipalMembers() { + EnrollmentDecision decision = new EnrollmentDecision(null, false, null, null); + + assertEquals(AgentStatus.ACTIVE, decision.status()); + assertEquals(List.of(), decision.verificationPending()); + assertEquals(List.of(), decision.requirementsPending()); + assertEquals(AgentStatus.ACTIVE, EnrollmentDecision.active().status()); + assertThrows( + IllegalArgumentException.class, + () -> new EnrollmentDecision(AgentStatus.SUSPENDED, false, List.of(), List.of())); + assertEquals( + List.of(), + new AuthenticatedPrincipal(AGENT_DID, API_KEY, null, null, null, AuthenticatedPrincipal.Kind.AEP_JWT) + .scopes()); + assertEquals(List.of(), new ServiceCredentialMatch(AGENT_DID, CREDENTIAL_ID, NOW, API_KEY, null).scopes()); + } + + @Test + void guardsHttpResponseInvariants() { + assertThrows( + IllegalArgumentException.class, () -> new AepHttpResponse(99, Aep.MEDIA_TYPE, Map.of(), new byte[0])); + assertThrows( + IllegalArgumentException.class, () -> new AepHttpResponse(600, Aep.MEDIA_TYPE, Map.of(), new byte[0])); + assertThrows(NullPointerException.class, () -> new AepHttpResponse(200, null, Map.of(), new byte[0])); + assertEquals(0, new AepHttpResponse(200, Aep.MEDIA_TYPE, null, null).body().length); + assertEquals(Map.of(), new AepHttpResponse(200, Aep.MEDIA_TYPE, null, null).headers()); + } + + @Test + void keepsAnIssuedCredentialOutOfAnotherAgentsReach() { + ServiceCredentialStore store = ServiceCredentialStore.inMemory(); + AepService service = storedCredentialService(store); + enroll(service); + grant(service, API_KEY); + + store.revokeGrantType("did:web:someone.example", API_KEY, NOW) + .toCompletableFuture() + .join(); + + assertTrue(authenticate(service, Map.of(API_KEY_HEADER, List.of("api-secret"))) + .authenticated()); + } + + @Test + void refusesExpiredCredentialsWithoutForgettingThem() { + ServiceCredentialStore store = ServiceCredentialStore.inMemory(); + AepService service = serviceBuilder( + document(List.of(API_KEY), Map.of(API_KEY, config(List.of(API_KEY_HEADER))))) + .clock(java.time.Clock.fixed(NOW.plusSeconds(3600), java.time.ZoneOffset.UTC)) + .storedCredentialGrantType(StoredCredentialGrantTypes.apiKey( + config(List.of(API_KEY_HEADER)), (request, context) -> completed(apiKey()), store)) + .build(); + store.save(new ServiceCredentialRecord(AGENT_DID, NOW, apiKey(), CREDENTIAL_ID, Instant.parse(EXPIRY), API_KEY)) + .toCompletableFuture() + .join(); + enroll(service); + + assertEquals( + NOT_RECOGNIZED, + rejected(service, Map.of(API_KEY_HEADER, List.of("api-secret"))), + "the header is still known, so this is a bad credential rather than none"); + } + + @Test + void walksEveryAuthenticationMethodBeforeReportingAnAbsentCredential() { + AepService service = serviceBuilder(document( + List.of(API_KEY), + Map.of(API_KEY, config(List.of(API_KEY_HEADER))), + List.of(Aep.AUTHENTICATION_METHOD_JWT, API_KEY))) + .grantType(new GrantTypeDefinition(API_KEY, handler())) + .credentialAuthenticator(API_KEY, new CredentialAuthenticator() { + @Override + public CompletionStage hasPresentation(CredentialAuthenticationInput input) { + return completed(!input.headers() + .getOrDefault(API_KEY_HEADER, List.of()) + .isEmpty()); + } + + @Override + public CompletionStage> authenticate( + CredentialAuthenticationInput input) { + return completed(Optional.empty()); + } + }) + .build(); + enroll(service); + + assertEquals( + "authentication_required", + rejected(service, Map.of()), + "aep-jwt is skipped and nothing else presented anything"); + assertEquals( + NOT_RECOGNIZED, + rejected(service, Map.of(API_KEY_HEADER, List.of("wrong"))), + "a presentation that no authenticator accepts is a bad credential"); + } + + @Test + void refusesSessionCredentialsForAgentsWithNoEnrollmentAtAll() { + ServiceCredentialStore store = ServiceCredentialStore.inMemory(); + AepService issuing = storedCredentialService(store); + enroll(issuing); + grant(issuing, API_KEY); + AepService fresh = storedCredentialService(store, EnrollmentStore.inMemory()); + + assertEquals(NOT_RECOGNIZED, rejected(fresh, Map.of(API_KEY_HEADER, List.of("api-secret")))); + } + + @Test + void readsIdentityMethodsOnlyFromWellFormedSubjects() { + assertEquals(401, status(subject("notadid"))); + assertEquals(401, status(subject("did:web"))); + assertEquals(401, status(subject("web:example:1"))); + } + + @Test + void refusesInspectUrisThatAreNotAbsoluteOrigins() { + var builder = serviceBuilder(document(List.of(), Map.of(), List.of())).allowInsecureLoopback(true); + + assertThrows( + IllegalArgumentException.class, + () -> builder.inspectUri(URI.create("https:/.well-known/aep")).build(), + "an authority is required"); + assertThrows( + IllegalArgumentException.class, + () -> builder.inspectUri(URI.create("http://user@127.0.0.1/x")).build()); + assertThrows( + IllegalArgumentException.class, + () -> builder.inspectUri(URI.create("http://127.0.0.1/x#f")).build()); + assertThrows( + IllegalArgumentException.class, + () -> builder.inspectUri(URI.create("http://example.com/x")).build(), + "a non-loopback host still needs HTTPS"); + } + + @Test + void refusesMoreThanOneDedicatedAuthorizationField() { + AepService service = storedCredentialService(ServiceCredentialStore.inMemory()); + enroll(service); + grant(service, Aep.GRANT_TYPE_OAUTH_BEARER); + + assertEquals( + NOT_RECOGNIZED, + rejected(service, Map.of(Aep.AUTHORIZATION_HEADER, List.of("Bearer oauth-secret", "Bearer other")))); + } + + @Test + void unwrapsNestedCompletionFailuresFromIdempotentOperations() { + AepService service = serviceBuilder(document(List.of(API_KEY), Map.of(API_KEY, config(List.of())))) + .grantType(new GrantTypeDefinition(API_KEY, new GrantTypeHandler() { + @Override + public CompletionStage grant(GrantRequest request, GrantContext context) { + return java.util.concurrent.CompletableFuture.failedFuture(new CompletionException( + new java.util.concurrent.ExecutionException(new IllegalStateException("boom")))); + } + + @Override + public CompletionStage revoke(RevokeRequest request, RevokeContext context) { + return completed(null); + } + })) + .credentialAuthenticator(API_KEY, handler()) + .build(); + enroll(service); + + CompletionException failure = assertThrows( + CompletionException.class, + () -> service.grant(new GrantRequest(API_KEY, List.of()), CommandOptions.idempotent("g", "g-key")) + .toCompletableFuture() + .join()); + + assertTrue(failure.getCause() instanceof IllegalStateException); + } + + @Test + void storesBasicCredentialsUnderTheAuthorizationSlot() { + ServiceCredentialStore store = ServiceCredentialStore.inMemory(); + AepService service = storedCredentialService(store); + enroll(service); + grant(service, Aep.GRANT_TYPE_BASIC); + + assertTrue(authenticate(service, Map.of(AUTHORIZATION, List.of("Basic " + ServiceFixtures.basicCredentials()))) + .authenticated()); + assertEquals( + "authentication_required", + rejected(service, Map.of("X-Unrelated", List.of("Basic " + ServiceFixtures.basicCredentials())))); + } + + @Test + void refusesCredentialRecordsThatDisagreeWithTheirResponse() { + ServiceCredentialStore store = ServiceCredentialStore.inMemory(); + + assertThrows( + CompletionException.class, + () -> store.save(new ServiceCredentialRecord( + AGENT_DID, + NOW, + apiKey(), + CREDENTIAL_ID, + Instant.parse("2026-09-01T12:20:00Z"), + API_KEY)) + .toCompletableFuture() + .join(), + "the record expiry has to equal the credential expiry"); + assertThrows( + CompletionException.class, + () -> store.save(new ServiceCredentialRecord( + " ", NOW, apiKey(), CREDENTIAL_ID, Instant.parse(EXPIRY), API_KEY)) + .toCompletableFuture() + .join()); + } + + @Test + void acceptsEveryJsonShapeAClaimValueMayTake() { + ClaimValueLimits limits = new ClaimValueLimits(65_536, 32, 8, 64); + + assertTrue(limits.accepts(ClaimValues.builder() + .additional("flag", true) + .additional("count", 3) + .additional("absent", null) + .additional("list", List.of("a", "b")) + .build())); + assertFalse( + new ClaimValueLimits(65_536, 1, 8, 64) + .accepts(ClaimValues.builder() + .additional("a", "1") + .additional("b", "2") + .build()), + "the member budget covers every object in the document"); + } + + private static CompletionStage> subject(String value) { + AepService service = AepService.builder( + document(List.of(), Map.of(), List.of()), + (assertion, context) -> completed(new ClientAssertionClaims( + value, + value, + context.serviceDid(), + context.operation(), + NOW.minusSeconds(1).getEpochSecond(), + NOW.plusSeconds(60).getEpochSecond(), + assertion, + null))) + .clock(CLOCK) + .build(); + return service.status(CommandOptions.authenticated("a")); + } + + private static void generated(String identifier) { + AepService service = serviceBuilder(document(List.of(), Map.of(), List.of())) + .identifierSupplier(() -> identifier) + .build(); + service.enroll(new EnrollRequest(AGENT_DID, null, null), CommandOptions.idempotent("a", "k")) + .toCompletableFuture() + .join(); + } + + private static void principal(String agentDid, String method, String grantType) { + AepService service = serviceBuilder(document(List.of(API_KEY), Map.of(API_KEY, config(List.of())))) + .grantType(new GrantTypeDefinition(API_KEY, handler())) + .credentialAuthenticator(API_KEY, new CredentialAuthenticator() { + @Override + public CompletionStage hasPresentation(CredentialAuthenticationInput input) { + return completed(true); + } + + @Override + public CompletionStage> authenticate( + CredentialAuthenticationInput input) { + return completed(Optional.of(new AuthenticatedPrincipal( + agentDid, + method, + CREDENTIAL_ID, + grantType, + List.of(), + AuthenticatedPrincipal.Kind.SESSION_CREDENTIAL))); + } + }) + .build(); + enroll(service); + authenticate(service, Map.of(API_KEY_HEADER, List.of("api-secret"))); + } + + private static CompletionStage> timed(Instant issuedAt, Instant expiresAt) { + AepService service = AepService.builder( + document(List.of(), Map.of(), List.of()), + (assertion, context) -> completed(new ClientAssertionClaims( + AGENT_DID, + AGENT_DID, + context.serviceDid(), + context.operation(), + issuedAt.getEpochSecond(), + expiresAt.getEpochSecond(), + assertion, + null))) + .clock(CLOCK) + .clockSkew(Duration.ofSeconds(30)) + .build(); + enroll(service); + return service.status(CommandOptions.authenticated("status")); + } + + private static GrantResponses.ApiKey apiKey() { + return new GrantResponses.ApiKey("api-secret", CREDENTIAL_ID, EXPIRY, API_KEY_HEADER, List.of("read")); + } + + private static int status(CompletionStage> stage) { + return stage.toCompletableFuture().join().status(); + } + + private static String rejected(AepService service, Map> headers) { + ProtectedResourceResult result = authenticate(service, headers); + assertFalse(result.authenticated(), "expected a rejection"); + return result.response().problem().code(); + } + + private static Handler handler() { + return new Handler(); + } + + private static final class Handler implements GrantTypeHandler, CredentialAuthenticator { + @Override + public CompletionStage grant(GrantRequest request, GrantContext context) { + return completed(new GrantResult(CREDENTIAL_ID, Map.of("credential_id", CREDENTIAL_ID))); + } + + @Override + public CompletionStage revoke(RevokeRequest request, RevokeContext context) { + return completed(null); + } + + @Override + public CompletionStage hasPresentation(CredentialAuthenticationInput input) { + return completed(false); + } + + @Override + public CompletionStage> authenticate(CredentialAuthenticationInput input) { + return completed(Optional.empty()); + } + } +} diff --git a/aep-servlet/src/main/java/foundation/aep/servlet/AepServlet.java b/aep-servlet/src/main/java/foundation/aep/servlet/AepServlet.java index ceed855..eac7ba9 100644 --- a/aep-servlet/src/main/java/foundation/aep/servlet/AepServlet.java +++ b/aep-servlet/src/main/java/foundation/aep/servlet/AepServlet.java @@ -1,6 +1,9 @@ package foundation.aep.servlet; +import foundation.aep.core.Aep; import foundation.aep.core.AepCommand; +import foundation.aep.core.AepJson; +import foundation.aep.core.ProblemDetails; import foundation.aep.service.AepHttpRequest; import foundation.aep.service.AepHttpResponse; import foundation.aep.service.AepServiceHttpHandler; @@ -38,26 +41,51 @@ protected void service(HttpServletRequest request, HttpServletResponse response) String path = request.getRequestURI().substring(request.getContextPath().length()); AepCommand command = commandsByPath.get(path); if (command == null) { - response.sendError(404); + // CORE-ERR-001 makes every AEP endpoint answer with Problem Details. sendError hands the + // request to the container's error page, which is HTML on most of them. + AepServletSupport.write(request, response, notFound()); return; } AepHttpRequest input = AepServletSupport.request( request, AepServletSupport.requestUrl(request), true, serviceHandler.maximumRequestBytes()); CompletionStage stage = serviceHandler.handle(command, input); if (!request.isAsyncSupported()) { - AepServletSupport.write(response, stage.toCompletableFuture().join()); + AepServletSupport.write( + request, response, stage.toCompletableFuture().join()); return; } AsyncContext context = request.startAsync(request, response); stage.whenComplete((result, failure) -> { try { - if (failure == null) AepServletSupport.write((HttpServletResponse) context.getResponse(), result); - else ((HttpServletResponse) context.getResponse()).sendError(500); + if (failure == null) { + AepServletSupport.write(request, (HttpServletResponse) context.getResponse(), result); + } else ((HttpServletResponse) context.getResponse()).sendError(500); } catch (IOException ignored) { // The client disconnected before the asynchronous response completed. + } catch (RuntimeException exception) { + // Anything thrown while writing would otherwise escape into the completion machinery + // and leave the caller holding an open request until the container times it out. + sendErrorQuietly((HttpServletResponse) context.getResponse()); } finally { context.complete(); } }); } + + private static void sendErrorQuietly(HttpServletResponse response) { + try { + if (!response.isCommitted()) response.sendError(500); + } catch (IOException | IllegalStateException ignored) { + // The response is already on its way; the container closes it. + } + } + + private static AepHttpResponse notFound() { + return new AepHttpResponse( + 404, + Aep.PROBLEM_MEDIA_TYPE, + Map.of("Cache-Control", java.util.List.of("no-store")), + AepJson.write(ProblemDetails.of("invalid_request", "AEP route not found", 404)) + .getBytes(java.nio.charset.StandardCharsets.UTF_8)); + } } diff --git a/aep-servlet/src/main/java/foundation/aep/servlet/AepServletSupport.java b/aep-servlet/src/main/java/foundation/aep/servlet/AepServletSupport.java index 8269bd3..5125e7b 100644 --- a/aep-servlet/src/main/java/foundation/aep/servlet/AepServletSupport.java +++ b/aep-servlet/src/main/java/foundation/aep/servlet/AepServletSupport.java @@ -13,6 +13,8 @@ import java.util.Map; public final class AepServletSupport { + private static final int NOT_MODIFIED = 304; + private AepServletSupport() {} public static AepHttpRequest request( @@ -32,11 +34,26 @@ public static AepHttpRequest request( } public static void write(HttpServletResponse response, AepHttpResponse value) throws IOException { + write(null, response, value); + } + + // RFC 9110 section 9.3.2: a HEAD response repeats the header fields a GET would have sent, + // Content-Length included, and carries no content. HttpServlet normally arranges that by routing + // HEAD through doHead and a body-suppressing wrapper, but AepServlet overrides service(), so that + // never happens and the suppression has to be explicit here. + // + // RFC 9110 section 15.4.5: a 304 carries the validators a GET would have produced and no content. + // Content-Length is not among the fields it must repeat, and sending zero would describe the + // selected representation as empty to every cache on the path. + public static void write(HttpServletRequest request, HttpServletResponse response, AepHttpResponse value) + throws IOException { value.headers().forEach((name, values) -> values.forEach(header -> response.addHeader(name, header))); response.setStatus(value.status()); response.setContentType(value.contentType()); byte[] body = value.body(); + if (value.status() == NOT_MODIFIED) return; response.setContentLength(body.length); + if (request != null && "HEAD".equalsIgnoreCase(request.getMethod())) return; response.getOutputStream().write(body); } diff --git a/aep-servlet/src/test/java/foundation/aep/servlet/AepServletConformanceTest.java b/aep-servlet/src/test/java/foundation/aep/servlet/AepServletConformanceTest.java new file mode 100644 index 0000000..7853547 --- /dev/null +++ b/aep-servlet/src/test/java/foundation/aep/servlet/AepServletConformanceTest.java @@ -0,0 +1,417 @@ +package foundation.aep.servlet; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import foundation.aep.core.Aep; +import foundation.aep.core.AepCommand; +import foundation.aep.core.AgentStatus; +import foundation.aep.core.ClientAssertionClaims; +import foundation.aep.core.InspectDocument; +import foundation.aep.service.AepHttpRequest; +import foundation.aep.service.AepHttpResponse; +import foundation.aep.service.AepService; +import foundation.aep.service.AepServiceHttpHandler; +import foundation.aep.service.AuthenticatedPrincipal; +import foundation.aep.service.EnrollmentDecision; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockFilterChain; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +/** Checks of the Jakarta Servlet binding. */ +final class AepServletConformanceTest { + private static final Instant NOW = Instant.parse("2026-09-01T12:00:00Z"); + private static final String AGENT_DID = "did:web:agent.example"; + private static final String ORIGIN = "https://service.example"; + private static final String ENROLL_BODY = "{\"agent_did\":\"did:web:agent.example\"}"; + private static final String CONTENT_LENGTH = "Content-Length"; + + /** RFC 9110 section 9.3.2: HEAD keeps a GET's headers, Content-Length included, and no content. */ + @Test + void answersHeadWithoutContentButWithAnAccurateLength() throws Exception { + MockHttpServletResponse get = serve("GET", Aep.WELL_KNOWN_PATH); + MockHttpServletResponse head = serve("HEAD", Aep.WELL_KNOWN_PATH); + + assertEquals(200, head.getStatus()); + assertEquals("", head.getContentAsString()); + assertEquals(get.getContentAsString().length(), head.getContentLength()); + assertEquals(get.getHeader("ETag"), head.getHeader("ETag")); + assertEquals(get.getHeader("Cache-Control"), head.getHeader("Cache-Control")); + } + + /** RFC 9110 section 15.4.5: a 304 repeats the validators and describes no representation. */ + @Test + void answersNotModifiedWithNoContentAndNoLength() throws Exception { + AepServiceHttpHandler handler = new AepServiceHttpHandler(service()); + MockHttpServletRequest request = new MockHttpServletRequest("GET", Aep.WELL_KNOWN_PATH); + request.setRequestURI(Aep.WELL_KNOWN_PATH); + request.addHeader("If-None-Match", handler.inspectEntityTag()); + MockHttpServletResponse response = new MockHttpServletResponse(); + + new AepServlet(handler).service(request, response); + + assertEquals(304, response.getStatus()); + assertEquals("", response.getContentAsString()); + assertFalse(response.containsHeader(CONTENT_LENGTH), "a 304 describes no representation"); + assertEquals(handler.inspectEntityTag(), response.getHeader("ETag")); + } + + /** CORE-ERR-001: an unrouted path answers as Problem Details, not as a container error page. */ + @Test + void refusesUnroutedPathsWithAProblemDocument() throws Exception { + MockHttpServletResponse response = serve("GET", "/aep/unknown"); + + assertEquals(404, response.getStatus()); + assertEquals(Aep.PROBLEM_MEDIA_TYPE, response.getContentType()); + assertTrue(response.getContentAsString().contains("\"urn:aep:error:invalid_request\"")); + assertEquals("no-store", response.getHeader("Cache-Control")); + } + + /** The command path carries its body, media type, and idempotency key through the container. */ + @Test + void servesCommandsSynchronouslyAndAsynchronously() throws Exception { + assertEquals(200, enroll(false).getStatus()); + MockHttpServletResponse asynchronous = enroll(true); + assertEquals(200, asynchronous.getStatus()); + assertTrue(asynchronous.getContentAsString().contains("\"status\":\"active\"")); + assertEquals("no-store", asynchronous.getHeader("Cache-Control")); + } + + /** A failure after the response is handed off still completes the request rather than hanging. */ + @Test + void completesAsynchronousRequestsThatFail() throws Exception { + AepService failing = AepService.builder( + document(), (assertion, context) -> CompletableFuture.failedFuture(new IllegalStateException())) + .clock(Clock.fixed(NOW, ZoneOffset.UTC)) + .build(); + MockHttpServletRequest request = new MockHttpServletRequest("GET", Aep.WELL_KNOWN_PATH); + request.setRequestURI("/aep/status"); + request.setAsyncSupported(true); + MockHttpServletResponse response = new MockHttpServletResponse(); + + new AepServlet(new AepServiceHttpHandler(failing)).service(request, response); + + assertEquals(401, response.getStatus(), "a verifier failure is an unrecognized credential, not a crash"); + } + + /** A protected route reaches the application only once a credential has been accepted. */ + @Test + void protectsApplicationRoutes() throws Exception { + AepServiceHttpHandler handler = new AepServiceHttpHandler(service()); + enroll(handler); + AepAuthenticationFilter filter = new AepAuthenticationFilter(handler, URI.create(ORIGIN)); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/orders"); + request.setRequestURI("/orders"); + request.setQueryString("page=2"); + request.addHeader("Authorization", "AEP assertion"); + MockHttpServletResponse response = new MockHttpServletResponse(); + MockFilterChain chain = new MockFilterChain(); + + filter.doFilter(request, response, chain); + + assertEquals(200, response.getStatus()); + assertEquals( + AGENT_DID, + ((AuthenticatedPrincipal) request.getAttribute(AepAuthenticationFilter.PRINCIPAL_ATTRIBUTE)) + .agentDid()); + } + + @Test + void refusesNonHttpExchangesAndUnusableConfiguration() { + AepServiceHttpHandler handler = new AepServiceHttpHandler(service()); + AepAuthenticationFilter filter = new AepAuthenticationFilter(handler, URI.create(ORIGIN)); + + assertThrows(NullPointerException.class, () -> new AepServlet(null)); + assertThrows(NullPointerException.class, () -> new AepAuthenticationFilter(null, URI.create(ORIGIN))); + assertThrows(IllegalArgumentException.class, () -> new AepAuthenticationFilter(handler, null)); + assertThrows( + IllegalArgumentException.class, + () -> new AepAuthenticationFilter(handler, URI.create("https://service.example/base"))); + assertThrows( + ServletException.class, + () -> filter.doFilter( + stub(jakarta.servlet.ServletRequest.class), + stub(jakarta.servlet.ServletResponse.class), + new MockFilterChain()), + "AEP authentication is defined over HTTP"); + } + + @Test + void collectsHeadersAndBoundsTheBody() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/aep/enroll"); + request.setRequestURI("/aep/enroll"); + request.addHeader("X-Trace", "one"); + request.addHeader("X-Trace", "two"); + request.setContent("abcdef".getBytes(StandardCharsets.UTF_8)); + + AepHttpRequest full = AepServletSupport.request(request, URI.create(ORIGIN + "/aep/enroll"), true, 64); + AepHttpRequest none = AepServletSupport.request(request, URI.create(ORIGIN + "/aep/enroll"), false, 64); + + assertEquals(List.of("one", "two"), full.headerValues("x-trace")); + assertEquals("abcdef", new String(full.body(), StandardCharsets.UTF_8)); + assertEquals(0, none.body().length); + assertEquals("POST", full.method()); + } + + @Test + void reconstructsTheRequestUrlWithAndWithoutAQuery() { + MockHttpServletRequest plain = new MockHttpServletRequest("GET", "/aep/status"); + plain.setRequestURI("/aep/status"); + MockHttpServletRequest queried = new MockHttpServletRequest("GET", "/aep/status"); + queried.setRequestURI("/aep/status"); + queried.setQueryString("x=1"); + + assertEquals("/aep/status", AepServletSupport.requestUrl(plain).getPath()); + assertEquals("x=1", AepServletSupport.requestUrl(queried).getQuery()); + } + + @Test + void writesResponsesWithoutARequestToCompareAgainst() throws Exception { + MockHttpServletResponse response = new MockHttpServletResponse(); + + AepServletSupport.write( + response, + new AepHttpResponse( + 200, + Aep.MEDIA_TYPE, + Map.of("X-Trace", List.of("one", "two")), + "body".getBytes(StandardCharsets.UTF_8))); + + assertEquals("body", response.getContentAsString()); + assertEquals(List.of("one", "two"), response.getHeaders("X-Trace")); + assertEquals(4, response.getContentLength()); + } + + /** A failed command stage still completes the exchange instead of leaving it open. */ + @Test + void reportsAsynchronousFailuresAsServerErrors() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/aep/status"); + request.setRequestURI("/aep/status"); + request.addHeader("Authorization", "AEP assertion"); + request.setAsyncSupported(true); + MockHttpServletResponse response = new MockHttpServletResponse(); + + new AepServlet(new AepServiceHttpHandler(throwingService())).service(request, response); + + assertEquals(500, response.getStatus()); + } + + /** A failure while writing completes the exchange rather than escaping into the async machinery. */ + @Test + void reportsFailuresRaisedWhileWritingTheResponse() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("GET", Aep.WELL_KNOWN_PATH); + request.setRequestURI(Aep.WELL_KNOWN_PATH); + request.setAsyncSupported(true); + MockHttpServletResponse exploding = new MockHttpServletResponse() { + @Override + public void setContentType(String contentType) { + throw new IllegalStateException("boom"); + } + }; + + new AepServlet(new AepServiceHttpHandler(service())).service(request, exploding); + + assertEquals(500, exploding.getStatus()); + } + + /** A response that can no longer take an error is left to the container. */ + @Test + void leavesACommittedResponseAlone() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("GET", Aep.WELL_KNOWN_PATH); + request.setRequestURI(Aep.WELL_KNOWN_PATH); + request.setAsyncSupported(true); + MockHttpServletResponse stubborn = new MockHttpServletResponse() { + @Override + public void setContentType(String contentType) { + throw new IllegalStateException("boom"); + } + + @Override + public void sendError(int status) { + throw new IllegalStateException("already committed"); + } + }; + + new AepServlet(new AepServiceHttpHandler(service())).service(request, stubborn); + + assertEquals(200, stubborn.getStatus(), "nothing was written, and nothing threw out of the servlet"); + } + + /** The servlet holds a handler, so a deserialized instance would hold nothing at all. */ + @Test + void refusesToBeDeserialized() throws Exception { + java.lang.reflect.Method readObject = + AepServlet.class.getDeclaredMethod("readObject", java.io.ObjectInputStream.class); + readObject.setAccessible(true); + + var failure = assertThrows( + java.lang.reflect.InvocationTargetException.class, + () -> readObject.invoke(new AepServlet(new AepServiceHttpHandler(service())), (Object) null)); + + assertTrue(failure.getCause() instanceof java.io.InvalidObjectException); + } + + /** A container that reports no header names at all still produces a usable request. */ + @Test + void toleratesAContainerThatEnumeratesNoHeaders() throws Exception { + HttpServletRequest bare = (HttpServletRequest) java.lang.reflect.Proxy.newProxyInstance( + HttpServletRequest.class.getClassLoader(), + new Class[] {HttpServletRequest.class}, + (proxy, method, arguments) -> switch (method.getName()) { + case "getHeaderNames", "getHeaders" -> null; + case "getMethod" -> "GET"; + default -> throw new UnsupportedOperationException(method.getName()); + }); + + AepHttpRequest request = AepServletSupport.request(bare, URI.create(ORIGIN + "/x"), false, 64); + + assertEquals(Map.of(), request.headers()); + assertEquals("GET", request.method()); + } + + /** AEP authentication is defined over HTTP, so a half-HTTP exchange is refused too. */ + @Test + void refusesAHalfHttpExchange() { + AepAuthenticationFilter filter = + new AepAuthenticationFilter(new AepServiceHttpHandler(service()), URI.create(ORIGIN)); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/orders"); + request.setRequestURI("/orders"); + + assertThrows( + ServletException.class, + () -> filter.doFilter(request, stub(jakarta.servlet.ServletResponse.class), new MockFilterChain())); + } + + private static AepService throwingService() { + foundation.aep.service.EnrollmentStore failing = new foundation.aep.service.EnrollmentStore() { + @Override + public CompletableFuture> find( + String agentDid) { + return CompletableFuture.failedFuture(new IllegalStateException("boom")); + } + + @Override + public CompletableFuture findOrCreate( + String agentDid, + java.util.function.Supplier< + java.util.concurrent.CompletionStage> + create) { + return CompletableFuture.failedFuture(new IllegalStateException("boom")); + } + + @Override + public CompletableFuture save( + foundation.aep.service.EnrollmentRecord record) { + return CompletableFuture.failedFuture(new IllegalStateException("boom")); + } + }; + return AepService.builder( + document(), + (assertion, context) -> CompletableFuture.completedFuture(new ClientAssertionClaims( + AGENT_DID, + AGENT_DID, + context.serviceDid(), + context.operation(), + NOW.minusSeconds(1).getEpochSecond(), + NOW.plusSeconds(60).getEpochSecond(), + assertion, + context.resource()))) + .clock(Clock.fixed(NOW, ZoneOffset.UTC)) + .enrollmentStore(failing) + .build(); + } + + // A bare ServletRequest/ServletResponse that is not the HTTP subtype; the interfaces are wide + // and every method is unreachable for this test, so a proxy says it more plainly than a stub. + @SuppressWarnings("unchecked") + private static T stub(Class type) { + return (T) java.lang.reflect.Proxy.newProxyInstance( + type.getClassLoader(), new Class[] {type}, (proxy, method, arguments) -> { + throw new UnsupportedOperationException(method.getName()); + }); + } + + private static MockHttpServletResponse enroll(boolean asynchronous) throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/aep/enroll"); + request.setRequestURI("/aep/enroll"); + request.addHeader("Content-Type", Aep.MEDIA_TYPE); + request.addHeader("Authorization", "AEP assertion-" + asynchronous); + request.addHeader("Idempotency-Key", "key-" + asynchronous); + request.setContent(ENROLL_BODY.getBytes(StandardCharsets.UTF_8)); + request.setAsyncSupported(asynchronous); + MockHttpServletResponse response = new MockHttpServletResponse(); + new AepServlet(new AepServiceHttpHandler(service())).service(request, response); + return response; + } + + private static void enroll(AepServiceHttpHandler handler) { + handler.handle( + AepCommand.ENROLL, + new AepHttpRequest( + "POST", + URI.create(ORIGIN + "/aep/enroll"), + Map.of( + "Content-Type", + List.of(Aep.MEDIA_TYPE), + "Authorization", + List.of("AEP enrollment"), + "Idempotency-Key", + List.of("enroll-key")), + ENROLL_BODY.getBytes(StandardCharsets.UTF_8))) + .toCompletableFuture() + .join(); + } + + private static MockHttpServletResponse serve(String method, String path) throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(method, path); + request.setRequestURI(path); + MockHttpServletResponse response = new MockHttpServletResponse(); + new AepServlet(new AepServiceHttpHandler(service())).service(request, response); + return response; + } + + private static InspectDocument document() { + return InspectDocument.builder() + .version(Aep.VERSION) + .authentication(new InspectDocument.Authentication(List.of(Aep.AUTHENTICATION_METHOD_JWT))) + .bindings(new InspectDocument.Bindings(List.of("http"))) + .claims(new InspectDocument.Claims(List.of(), List.of(), List.of())) + .commands(new InspectDocument.Commands(List.of("enroll", "inspect", "status"), List.of(), Map.of())) + .core(new InspectDocument.Core(Aep.REQUIRED_SIGNING_ALGORITHMS)) + .http(new InspectDocument.Http("/aep", null)) + .identity(new InspectDocument.Identity(List.of("did:web"))) + .service(new InspectDocument.Service("did:web:service.example")) + .build(); + } + + private static AepService service() { + return AepService.builder( + document(), + (assertion, context) -> CompletableFuture.completedFuture(new ClientAssertionClaims( + AGENT_DID, + AGENT_DID, + context.serviceDid(), + context.operation(), + NOW.minusSeconds(1).getEpochSecond(), + NOW.plusSeconds(60).getEpochSecond(), + assertion, + context.resource()))) + .clock(Clock.fixed(NOW, ZoneOffset.UTC)) + .enrollmentPolicy((request, now) -> CompletableFuture.completedFuture( + new EnrollmentDecision(AgentStatus.ACTIVE, false, List.of(), List.of()))) + .build(); + } +} diff --git a/aep-spring-webmvc/src/main/java/foundation/aep/spring/webmvc/AepSpringWebMvc.java b/aep-spring-webmvc/src/main/java/foundation/aep/spring/webmvc/AepSpringWebMvc.java index b70c1b7..03568a5 100644 --- a/aep-spring-webmvc/src/main/java/foundation/aep/spring/webmvc/AepSpringWebMvc.java +++ b/aep-spring-webmvc/src/main/java/foundation/aep/spring/webmvc/AepSpringWebMvc.java @@ -9,8 +9,10 @@ import java.io.IOException; import java.net.URI; import java.util.Objects; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; import org.springframework.web.servlet.function.HandlerFilterFunction; +import org.springframework.web.servlet.function.RequestPredicate; import org.springframework.web.servlet.function.RequestPredicates; import org.springframework.web.servlet.function.RouterFunction; import org.springframework.web.servlet.function.RouterFunctions; @@ -19,15 +21,21 @@ public final class AepSpringWebMvc { public static final String PRINCIPAL_ATTRIBUTE = "foundation.aep.principal"; + private static final int NOT_MODIFIED = 304; private AepSpringWebMvc() {} public static RouterFunction routes(AepServiceHttpHandler handler) { Objects.requireNonNull(handler, "handler"); RouterFunctions.Builder routes = RouterFunctions.route(); + // RequestPredicates.path treats its argument as a PathPattern, where * and ** are wildcards. + // An endpoint_base is only required to be an absolute path, and * is legal in one, so an + // advertised base such as /aep* would have routed AEP commands at paths the Service never + // advertised. Pairing the pattern with an exact comparison keeps Spring's route introspection + // while matching only the path the handler registered. handler.routes() - .forEach((command, path) -> - routes.route(RequestPredicates.path(path), request -> handle(handler, command, request))); + .forEach((command, path) -> routes.route( + RequestPredicates.path(path).and(exactly(path)), request -> handle(handler, command, request))); return routes.build(); } @@ -35,22 +43,41 @@ public static HandlerFilterFunction protect( AepServiceHttpHandler handler, URI publicOrigin) { Objects.requireNonNull(handler, "handler"); AepHttpRequest.publicUrl(publicOrigin, "/", null); - return (request, next) -> ServerResponse.async(handler.authenticate(request( - handler, - request, - AepHttpRequest.publicUrl( - publicOrigin, - request.uri().getRawPath(), - request.uri().getRawQuery()), - false)) - .thenApply(result -> continueOrReject(result, request, next)) - .toCompletableFuture()); + return (request, next) -> { + CompletableFuture authentication = handler.authenticate(request( + handler, + request, + AepHttpRequest.publicUrl( + publicOrigin, + request.uri().getRawPath(), + request.uri().getRawQuery()), + false)) + .toCompletableFuture(); + // An in-memory enrollment store answers on the calling thread, which is the ordinary + // case. Routing that through ServerResponse.async anyway pushed every protected route + // into an async dispatch, ran the downstream handler on a completion thread, and left + // whatever the application keeps in a ThreadLocal — a security context, a trace id — + // behind on the request thread. + if (authentication.isDone() && !authentication.isCompletedExceptionally()) { + AepHttpAuthenticationResult result = authentication.join(); + if (!result.authenticated()) return rejection(result, request); + request.attributes().put(PRINCIPAL_ATTRIBUTE, result.principal()); + return next.handle(request); + } + return ServerResponse.async(authentication.thenApply(result -> continueOrReject(result, request, next))); + }; + } + + private static RequestPredicate exactly(String path) { + return request -> + path.equals(request.requestPath().pathWithinApplication().value()); } private static ServerResponse handle(AepServiceHttpHandler handler, AepCommand command, ServerRequest request) throws IOException { + boolean head = "HEAD".equalsIgnoreCase(request.method().name()); return ServerResponse.async(handler.handle(command, request(handler, request, request.uri(), true)) - .thenApply(AepSpringWebMvc::response) + .thenApply(response -> response(response, head)) .toCompletableFuture()); } @@ -65,7 +92,7 @@ private static ServerResponse continueOrReject( AepHttpAuthenticationResult result, ServerRequest request, org.springframework.web.servlet.function.HandlerFunction next) { - if (!result.authenticated()) return response(result.response()); + if (!result.authenticated()) return rejection(result, request); request.attributes().put(PRINCIPAL_ATTRIBUTE, result.principal()); try { return next.handle(request); @@ -74,10 +101,23 @@ private static ServerResponse continueOrReject( } } - private static ServerResponse response(AepHttpResponse response) { + private static ServerResponse rejection(AepHttpAuthenticationResult result, ServerRequest request) { + return response( + result.response(), "HEAD".equalsIgnoreCase(request.method().name())); + } + + // RFC 9110 sections 9.3.2 and 15.4.5: a HEAD response and a 304 repeat the header fields a GET + // would have sent and carry no content. Whether the servlet stack wraps a HEAD response to + // suppress its body depends on the framework and the container, so the adapter does it itself. + private static ServerResponse response(AepHttpResponse response, boolean head) { ServerResponse.BodyBuilder builder = ServerResponse.status(response.status()) .contentType(org.springframework.http.MediaType.parseMediaType(response.contentType())); response.headers().forEach((name, values) -> builder.header(name, values.toArray(String[]::new))); + if (response.status() == NOT_MODIFIED) return builder.build(); + if (head) { + builder.contentLength(response.body().length); + return builder.build(); + } return builder.body(response.body()); } } diff --git a/aep-spring-webmvc/src/test/java/foundation/aep/spring/webmvc/AepSpringWebMvcConformanceTest.java b/aep-spring-webmvc/src/test/java/foundation/aep/spring/webmvc/AepSpringWebMvcConformanceTest.java new file mode 100644 index 0000000..5d1dde6 --- /dev/null +++ b/aep-spring-webmvc/src/test/java/foundation/aep/spring/webmvc/AepSpringWebMvcConformanceTest.java @@ -0,0 +1,358 @@ +package foundation.aep.spring.webmvc; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.head; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import foundation.aep.core.Aep; +import foundation.aep.core.AepCommand; +import foundation.aep.core.AgentStatus; +import foundation.aep.core.ClientAssertionClaims; +import foundation.aep.core.InspectDocument; +import foundation.aep.service.AepHttpRequest; +import foundation.aep.service.AepService; +import foundation.aep.service.AepServiceHttpHandler; +import foundation.aep.service.EnrollmentDecision; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.servlet.function.RouterFunctions; +import org.springframework.web.servlet.function.ServerResponse; + +/** Checks of the Spring Web MVC functional binding. */ +final class AepSpringWebMvcConformanceTest { + private static final Instant NOW = Instant.parse("2026-09-01T12:00:00Z"); + private static final String AGENT_DID = "did:web:agent.example"; + private static final String ORIGIN = "https://service.example"; + private static final String ENROLL_BODY = "{\"agent_did\":\"did:web:agent.example\"}"; + + /** RFC 9110 section 9.3.2: HEAD repeats a GET's headers and carries no content. */ + @Test + void answersHeadWithoutContent() throws Exception { + MockMvc client = routed(service("/aep")); + + client.perform(head(Aep.WELL_KNOWN_PATH)) + .andExpect(status().isOk()) + .andExpect(content().string("")) + .andExpect(header().exists("ETag")) + .andExpect(header().string("Cache-Control", "public, max-age=300")); + client.perform(get(Aep.WELL_KNOWN_PATH)) + .andExpect(status().isOk()) + .andExpect(content().string(org.hamcrest.Matchers.containsString("\"aep_version\""))); + } + + /** RFC 9110 section 15.4.5: a matching validator revalidates to 304 with no content. */ + @Test + void revalidatesThroughNotModified() throws Exception { + AepServiceHttpHandler handler = new AepServiceHttpHandler(service("/aep")); + MockMvc client = + MockMvcBuilders.routerFunctions(AepSpringWebMvc.routes(handler)).build(); + + client.perform(get(Aep.WELL_KNOWN_PATH).header("If-None-Match", handler.inspectEntityTag())) + .andExpect(status().isNotModified()) + .andExpect(content().string("")) + .andExpect(header().string("ETag", handler.inspectEntityTag())); + } + + /** + * RequestPredicates.path reads its argument as a PathPattern. An endpoint_base may legally + * contain an asterisk, which would otherwise have routed AEP commands at unadvertised paths. + */ + @Test + void routesOnlyTheExactAdvertisedPath() throws Exception { + MockMvc client = routed(service("/aep*")); + + client.perform(get("/aep*/status")).andExpect(status().isUnauthorized()); + client.perform(get("/aepXYZ/status")).andExpect(status().isNotFound()); + client.perform(get("/aep/status")).andExpect(status().isNotFound()); + } + + /** The command path carries its body, media type, and idempotency key through Spring. */ + @Test + void servesAnEnrollCommand() throws Exception { + routed(service("/aep")) + .perform(post("/aep/enroll") + .contentType(Aep.MEDIA_TYPE) + .header("Authorization", "AEP assertion") + .header("Idempotency-Key", "key") + .content(ENROLL_BODY)) + .andExpect(status().isOk()) + .andExpect(content().string(org.hamcrest.Matchers.containsString("\"status\":\"active\""))) + .andExpect(header().string("Cache-Control", "no-store")); + } + + /** A protected route reaches the application only once a credential has been accepted. */ + @Test + void protectsApplicationRoutes() throws Exception { + AepServiceHttpHandler handler = new AepServiceHttpHandler(service("/aep")); + enroll(handler); + MockMvc client = MockMvcBuilders.routerFunctions(RouterFunctions.route() + .GET( + "/orders", + request -> ServerResponse.ok() + .body(String.valueOf( + request.attributes().get(AepSpringWebMvc.PRINCIPAL_ATTRIBUTE)))) + .filter(AepSpringWebMvc.protect(handler, URI.create(ORIGIN))) + .build()) + .build(); + + client.perform(get("/orders")) + .andExpect(status().isUnauthorized()) + .andExpect(header().exists("WWW-Authenticate")); + client.perform(get("/orders").header("Authorization", "AEP assertion")) + .andExpect(status().isOk()) + .andExpect(content().string(org.hamcrest.Matchers.containsString(AGENT_DID))); + } + + /** A downstream failure is surfaced rather than swallowed into an empty response. */ + @Test + void surfacesDownstreamFailuresFromProtectedRoutes() throws Exception { + AepServiceHttpHandler handler = new AepServiceHttpHandler(service("/aep")); + enroll(handler); + MockMvc client = MockMvcBuilders.routerFunctions(RouterFunctions.route() + .GET("/orders", request -> { + throw new IllegalStateException("downstream"); + }) + .filter(AepSpringWebMvc.protect(handler, URI.create(ORIGIN))) + .build()) + .build(); + + var thrown = Assertions.assertThrows( + Exception.class, + () -> client.perform(get("/orders").header("Authorization", "AEP assertion")) + .andReturn()); + + Assertions.assertTrue(rootCause(thrown) instanceof IllegalStateException); + } + + @Test + void refusesUnusableConfiguration() { + AepServiceHttpHandler handler = new AepServiceHttpHandler(service("/aep")); + + Assertions.assertThrows(NullPointerException.class, () -> AepSpringWebMvc.routes(null)); + Assertions.assertThrows(NullPointerException.class, () -> AepSpringWebMvc.protect(null, URI.create(ORIGIN))); + Assertions.assertThrows(IllegalArgumentException.class, () -> AepSpringWebMvc.protect(handler, null)); + Assertions.assertThrows( + IllegalArgumentException.class, + () -> AepSpringWebMvc.protect(handler, URI.create("https://service.example/base"))); + } + + /** + * An authentication that has not settled yet falls back to an async response, and the downstream + * handler still sees the principal once it does. Driven through the filter directly: observing + * the same thing through MockMvc needs an async dispatch round trip. + */ + @Test + void fallsBackToAnAsyncResponseWhilePending() throws Exception { + Gate gate = new Gate(); + AepServiceHttpHandler handler = new AepServiceHttpHandler(gatedService(gate)); + enroll(handler); + gate.close(); + var filter = AepSpringWebMvc.protect(handler, URI.create(ORIGIN)); + java.util.concurrent.atomic.AtomicReference seen = new java.util.concurrent.atomic.AtomicReference<>(); + + ServerResponse response = filter.filter(serverRequest(), request -> { + seen.set(request.attributes().get(AepSpringWebMvc.PRINCIPAL_ATTRIBUTE)); + return ServerResponse.ok().body("downstream"); + }); + Assertions.assertNull(seen.get(), "the downstream handler waits for the credential"); + gate.open(); + + Assertions.assertNotNull(response); + Assertions.assertEquals( + AGENT_DID, ((foundation.aep.service.AuthenticatedPrincipal) awaitValue(seen)).agentDid()); + } + + /** The same fallback rejects a request whose Agent the Service does not recognize. */ + @Test + void rejectsThroughTheAsyncResponseToo() throws Exception { + Gate gate = new Gate(); + gate.close(); + AepServiceHttpHandler handler = new AepServiceHttpHandler(gatedService(gate)); + var filter = AepSpringWebMvc.protect(handler, URI.create(ORIGIN)); + java.util.concurrent.atomic.AtomicReference reached = + new java.util.concurrent.atomic.AtomicReference<>(); + + ServerResponse response = filter.filter(serverRequest(), request -> { + reached.set(Boolean.TRUE); + return ServerResponse.ok().body("never"); + }); + gate.open(); + Thread.sleep(50); + + Assertions.assertNotNull(response); + Assertions.assertNull(reached.get(), "an unenrolled Agent never reaches the application"); + } + + private static Object awaitValue(java.util.concurrent.atomic.AtomicReference reference) + throws InterruptedException { + for (int attempt = 0; attempt < 200 && reference.get() == null; attempt++) Thread.sleep(10); + return reference.get(); + } + + private static org.springframework.web.servlet.function.ServerRequest serverRequest() { + org.springframework.mock.web.MockHttpServletRequest request = + new org.springframework.mock.web.MockHttpServletRequest("GET", "/orders"); + request.setRequestURI("/orders"); + request.addHeader("Authorization", "AEP assertion"); + return org.springframework.web.servlet.function.ServerRequest.create(request, List.of()); + } + + /** Holds the enrollment lookup open so protect() sees an authentication that has not settled. */ + private static final class Gate { + private volatile CompletableFuture latch = CompletableFuture.completedFuture(null); + + void close() { + latch = new CompletableFuture<>(); + } + + void open() { + latch.complete(null); + } + + CompletableFuture latch() { + return latch; + } + } + + private static AepService gatedService(Gate gate) { + foundation.aep.service.EnrollmentStore delegate = foundation.aep.service.EnrollmentStore.inMemory(); + foundation.aep.service.EnrollmentStore gated = new foundation.aep.service.EnrollmentStore() { + @Override + public java.util.concurrent.CompletionStage> + find(String agentDid) { + return gate.latch().thenCompose(ignored -> delegate.find(agentDid)); + } + + @Override + public java.util.concurrent.CompletionStage findOrCreate( + String agentDid, + java.util.function.Supplier< + java.util.concurrent.CompletionStage> + create) { + return delegate.findOrCreate(agentDid, create); + } + + @Override + public java.util.concurrent.CompletionStage save( + foundation.aep.service.EnrollmentRecord record) { + return delegate.save(record); + } + }; + InspectDocument document = InspectDocument.builder() + .version(Aep.VERSION) + .authentication(new InspectDocument.Authentication(List.of(Aep.AUTHENTICATION_METHOD_JWT))) + .bindings(new InspectDocument.Bindings(List.of("http"))) + .claims(new InspectDocument.Claims(List.of(), List.of(), List.of())) + .commands(new InspectDocument.Commands(List.of("enroll", "inspect", "status"), List.of(), Map.of())) + .core(new InspectDocument.Core(Aep.REQUIRED_SIGNING_ALGORITHMS)) + .http(new InspectDocument.Http("/aep", null)) + .identity(new InspectDocument.Identity(List.of("did:web"))) + .service(new InspectDocument.Service("did:web:service.example")) + .build(); + return AepService.builder( + document, + (assertion, context) -> CompletableFuture.completedFuture(new ClientAssertionClaims( + AGENT_DID, + AGENT_DID, + context.serviceDid(), + context.operation(), + NOW.minusSeconds(1).getEpochSecond(), + NOW.plusSeconds(60).getEpochSecond(), + assertion, + context.resource()))) + .clock(Clock.fixed(NOW, ZoneOffset.UTC)) + .enrollmentStore(gated) + .enrollmentPolicy((request, now) -> CompletableFuture.completedFuture( + new EnrollmentDecision(AgentStatus.ACTIVE, false, List.of(), List.of()))) + .build(); + } + + /** A synchronous authentication keeps the downstream handler on the request thread. */ + @Test + void doesNotForceProtectedRoutesIntoAnAsyncDispatch() throws Exception { + AepServiceHttpHandler handler = new AepServiceHttpHandler(service("/aep")); + enroll(handler); + MockMvc client = MockMvcBuilders.routerFunctions(RouterFunctions.route() + .GET("/orders", request -> ServerResponse.ok().body("thread")) + .filter(AepSpringWebMvc.protect(handler, URI.create(ORIGIN))) + .build()) + .build(); + + var result = client.perform(get("/orders").header("Authorization", "AEP assertion")) + .andExpect(status().isOk()) + .andReturn(); + + Assertions.assertFalse(result.getRequest().isAsyncStarted()); + } + + private static Throwable rootCause(Throwable value) { + Throwable cause = value; + while (cause.getCause() != null && cause.getCause() != cause) cause = cause.getCause(); + return cause; + } + + private static MockMvc routed(AepService service) { + return MockMvcBuilders.routerFunctions(AepSpringWebMvc.routes(new AepServiceHttpHandler(service))) + .build(); + } + + private static void enroll(AepServiceHttpHandler handler) { + handler.handle( + AepCommand.ENROLL, + new AepHttpRequest( + "POST", + URI.create(ORIGIN + "/aep/enroll"), + Map.of( + "Content-Type", + List.of(Aep.MEDIA_TYPE), + "Authorization", + List.of("AEP enrollment"), + "Idempotency-Key", + List.of("enroll-key")), + ENROLL_BODY.getBytes(StandardCharsets.UTF_8))) + .toCompletableFuture() + .join(); + } + + private static AepService service(String endpointBase) { + InspectDocument document = InspectDocument.builder() + .version(Aep.VERSION) + .authentication(new InspectDocument.Authentication(List.of(Aep.AUTHENTICATION_METHOD_JWT))) + .bindings(new InspectDocument.Bindings(List.of("http"))) + .claims(new InspectDocument.Claims(List.of(), List.of(), List.of())) + .commands(new InspectDocument.Commands(List.of("enroll", "inspect", "status"), List.of(), Map.of())) + .core(new InspectDocument.Core(Aep.REQUIRED_SIGNING_ALGORITHMS)) + .http(new InspectDocument.Http(endpointBase, null)) + .identity(new InspectDocument.Identity(List.of("did:web"))) + .service(new InspectDocument.Service("did:web:service.example")) + .build(); + return AepService.builder( + document, + (assertion, context) -> CompletableFuture.completedFuture(new ClientAssertionClaims( + AGENT_DID, + AGENT_DID, + context.serviceDid(), + context.operation(), + NOW.minusSeconds(1).getEpochSecond(), + NOW.plusSeconds(60).getEpochSecond(), + assertion, + context.resource()))) + .clock(Clock.fixed(NOW, ZoneOffset.UTC)) + .enrollmentPolicy((request, now) -> CompletableFuture.completedFuture( + new EnrollmentDecision(AgentStatus.ACTIVE, false, List.of(), List.of()))) + .build(); + } +} diff --git a/aep-spring-webmvc/src/test/java/foundation/aep/spring/webmvc/AepSpringWebMvcTest.java b/aep-spring-webmvc/src/test/java/foundation/aep/spring/webmvc/AepSpringWebMvcTest.java index d852316..585e032 100644 --- a/aep-spring-webmvc/src/test/java/foundation/aep/spring/webmvc/AepSpringWebMvcTest.java +++ b/aep-spring-webmvc/src/test/java/foundation/aep/spring/webmvc/AepSpringWebMvcTest.java @@ -39,7 +39,7 @@ void servesInspectAndRejectsTheWrongMethod() throws Exception { .andExpect(content().string(org.hamcrest.Matchers.containsString("\"aep_version\""))); client.perform(post(Aep.WELL_KNOWN_PATH)) .andExpect(status().isMethodNotAllowed()) - .andExpect(header().string("Allow", "GET")); + .andExpect(header().string("Allow", "GET, HEAD")); } @Test diff --git a/tools/aep-conformance/pom.xml b/tools/aep-conformance/pom.xml index 0159282..5b4c0be 100644 --- a/tools/aep-conformance/pom.xml +++ b/tools/aep-conformance/pom.xml @@ -41,5 +41,11 @@ aep-platform ${project.version} + + org.junit.jupiter + junit-jupiter + ${junit.version} + test + diff --git a/tools/aep-conformance/src/main/java/foundation/aep/conformance/AgentConformance.java b/tools/aep-conformance/src/main/java/foundation/aep/conformance/AgentConformance.java index 2c8d535..bcc9888 100644 --- a/tools/aep-conformance/src/main/java/foundation/aep/conformance/AgentConformance.java +++ b/tools/aep-conformance/src/main/java/foundation/aep/conformance/AgentConformance.java @@ -43,6 +43,14 @@ final class AgentConformance { private static final String COMMAND_TRANSPORT_PROHIBITED = "Command transport must not be called"; private static final String CONTENT_TYPE = "Content-Type"; private static final String INSPECT = "inspect"; + private static final String GRANT = "grant"; + private static final String STATUS = "status"; + private static final String CUSTOM_GRANT = "custom-grant"; + private static final String COMPACT_JWS = "compact-jws"; + private static final String ETAG = "inspect-1"; + private static final String IF_NONE_MATCH = "If-None-Match"; + private static final int SINGLE_FIELD = 1; + private static final String AGENT_DID = "did:web:agent.example"; private static final URI ORIGIN = URI.create("https://api.example.com"); private static final String SERVICE_DID = "did:web:api.example.com"; private static final Instant NOW = Instant.ofEpochSecond(1_783_958_400L); @@ -57,7 +65,7 @@ static boolean evaluate(AdapterRequest request) { Boolean shared = SharedConformance.evaluate(request); if (shared != null) return shared; return switch (request.vector().id()) { - case "public-discovery-cache" -> discoveryCache(); + case "public-discovery-cache" -> discoveryCache(request); case "grant-before-enroll-rejected" -> grantBeforeEnroll(request); case "command-header" -> commandIdempotency(request); case "transport-requirements" -> transport(request); @@ -74,33 +82,165 @@ static boolean evaluate(AdapterRequest request) { }; } - private static boolean discoveryCache() { + /** + * Drives the five caching behaviours expected names: the SDK's default freshness lifetime, + * no-cache revalidation, no-store non-persistence, reuse of a 304-validated representation, and + * the cache key taken from the final safe redirect URL. + */ + private static boolean discoveryCache(AdapterRequest request) { + JsonNode expected = request.testCase().expected(); + Duration freshness = + Duration.ofSeconds(Long.parseLong(ConformanceSupport.text(expected, "default_freshness_seconds"))); + return defaultFreshness(freshness) + && "revalidate".equals(ConformanceSupport.text(expected, "no_cache")) + && revalidatesNoCache() + && "do-not-persist".equals(ConformanceSupport.text(expected, "no_store")) + && discardsNoStore() + && "reuse-validated-representation".equals(ConformanceSupport.text(expected, "status_304")) + && reusesValidated() + && "final-safe-redirect-url".equals(ConformanceSupport.text(expected, "cache_key")) + && keysOnFinalUrl(); + } + + /** + * A response carrying no Cache-Control is reusable for the SDK's own default lifetime, which is + * the one the vector publishes. The agent is built without configuring freshness so the default + * under test is the SDK's, not the vector's value handed back to itself. + */ + private static boolean defaultFreshness(Duration freshness) { AtomicInteger calls = new AtomicInteger(); - AepHttpTransport inspect = request -> { - int call = calls.incrementAndGet(); - if (call == INITIAL_CALL) { - return completed(response( - 200, - Map.of( - "Cache-Control", - List.of("no-cache"), - CONTENT_TYPE, - List.of(Aep.MEDIA_TYPE), - "ETag", - List.of("inspect-1")), - document(List.of(INSPECT), List.of(), List.of()))); - } - boolean conditional = List.of("inspect-1").equals(request.headers().get("If-None-Match")); - return completed(response(conditional ? 304 : 500, Map.of("ETag", List.of("inspect-1")), "")); - }; - AepServiceSession session = agent(inspect, request -> failed(COMMAND_TRANSPORT_PROHIBITED)) - .build() - .service(ORIGIN); + MovableClock clock = new MovableClock(NOW); + AepServiceSession session = cachingSession( + clock, + requestValue -> + completed(counted(calls, aepResponse(200, document(List.of(INSPECT), List.of(), List.of()))))); + session.inspect().join(); + clock.advance(freshness.minusSeconds(1)); session.inspect().join(); + if (calls.get() != INITIAL_CALL) return false; + clock.advance(Duration.ofSeconds(2)); session.inspect().join(); return calls.get() == 2; } + /** CORE-DISC-014: no-cache stores the document but forces a conditional request before reuse. */ + private static boolean revalidatesNoCache() { + List seen = new ArrayList<>(); + AepServiceSession session = cachingSession(new MovableClock(NOW), requestValue -> { + seen.add(requestValue); + return completed( + seen.size() == INITIAL_CALL + ? cacheable("no-cache", document(List.of(INSPECT), List.of(), List.of())) + : response(304, Map.of("ETag", List.of(ETAG)), "")); + }); + session.inspect().join(); + session.inspect().join(); + return seen.size() == 2 && List.of(ETAG).equals(seen.get(1).headers().get(IF_NONE_MATCH)); + } + + /** no-store forbids persisting the document, so the next call cannot revalidate a stored copy. */ + private static boolean discardsNoStore() { + List seen = new ArrayList<>(); + AepServiceSession session = cachingSession(new MovableClock(NOW), requestValue -> { + seen.add(requestValue); + return completed(cacheable("no-store", document(List.of(INSPECT), List.of(), List.of()))); + }); + session.inspect().join(); + session.inspect().join(); + return seen.size() == 2 && !seen.get(1).headers().containsKey(IF_NONE_MATCH); + } + + /** A 304 revalidation reuses the stored representation rather than returning an empty document. */ + private static boolean reusesValidated() { + AtomicInteger calls = new AtomicInteger(); + AepServiceSession session = cachingSession( + new MovableClock(NOW), + requestValue -> completed( + calls.incrementAndGet() == INITIAL_CALL + ? cacheable("no-cache", document(List.of(INSPECT, STATUS), List.of(), List.of())) + : response(304, Map.of("ETag", List.of(ETAG)), ""))); + session.inspect().join(); + var revalidated = session.inspect().join(); + return SERVICE_DID.equals(revalidated.document().service().did()) + && revalidated.document().commands().supported().contains(STATUS); + } + + /** The cache is keyed on the final URL a safe redirect settled on, not the requested one. */ + private static boolean keysOnFinalUrl() { + URI finalUri = ORIGIN.resolve("/aep/inspect"); + List seen = new ArrayList<>(); + AepServiceSession session = cachingSession(new MovableClock(NOW), requestValue -> { + seen.add(requestValue); + if (seen.size() == INITIAL_CALL) { + return completed(response(308, Map.of("Location", List.of(finalUri.toString())), "")); + } + return completed(cacheable("no-cache", document(List.of(INSPECT), List.of(), List.of()))); + }); + var inspection = session.inspect().join(); + session.inspect().join(); + return finalUri.equals(inspection.documentUri()) + && seen.size() == 3 + && finalUri.equals(seen.get(2).uri()) + && List.of(ETAG).equals(seen.get(2).headers().get(IF_NONE_MATCH)); + } + + private static AepServiceSession cachingSession(Clock clock, AepHttpTransport inspect) { + AgentIdentity identity = new AgentIdentity(AGENT_DID, claims -> completed("signed")); + return AepAgent.builder() + .inspectTransport(inspect) + .commandTransport(requestValue -> failed(COMMAND_TRANSPORT_PROHIBITED)) + .identityProvider((origin, serviceDid) -> completed(identity)) + .clock(clock) + .jwtIdSupplier(() -> "jwt-id") + .build() + .service(ORIGIN); + } + + private static AepHttpTransport.Response cacheable(String cacheControl, String body) { + return response( + 200, + Map.of( + "Cache-Control", + List.of(cacheControl), + CONTENT_TYPE, + List.of(Aep.MEDIA_TYPE), + "ETag", + List.of(ETAG)), + body); + } + + private static AepHttpTransport.Response counted(AtomicInteger calls, AepHttpTransport.Response response) { + calls.incrementAndGet(); + return response; + } + + private static final class MovableClock extends Clock { + private Instant current; + + MovableClock(Instant start) { + this.current = start; + } + + void advance(Duration amount) { + current = current.plus(amount); + } + + @Override + public java.time.ZoneId getZone() { + return ZoneOffset.UTC; + } + + @Override + public Clock withZone(java.time.ZoneId zone) { + return this; + } + + @Override + public Instant instant() { + return current; + } + } + private static boolean enrollRequest(AdapterRequest request) { JsonNode input = request.testCase().input(); String agentDid = ConformanceSupport.text(input, "agent_did"); @@ -137,16 +277,16 @@ private static boolean enrollRequest(AdapterRequest request) { private static boolean grantBeforeEnroll(AdapterRequest request) { QueueTransport inspect = new QueueTransport(aepResponse( - 200, document(List.of("grant", INSPECT, "status"), List.of(Aep.AUTHENTICATION_METHOD_JWT), List.of()))); + 200, document(List.of(GRANT, INSPECT, STATUS), List.of(Aep.AUTHENTICATION_METHOD_JWT), List.of()))); QueueTransport command = new QueueTransport(problemResponse( - ConformanceSupport.required(request.testCase().expected(), "status") + ConformanceSupport.required(request.testCase().expected(), STATUS) .asInt(), ConformanceSupport.text(request.testCase().expected(), "code"))); try { agent(inspect, command) .build() .service(ORIGIN) - .grant(new GrantRequest("custom-grant", List.of())) + .grant(new GrantRequest(CUSTOM_GRANT, List.of())) .join(); return false; } catch (CompletionException exception) { @@ -156,29 +296,120 @@ private static boolean grantBeforeEnroll(AdapterRequest request) { } } + // expected.header_required is true, so every command input.commands names is issued and the + // Idempotency-Key is read off the request the Agent actually sent. private static boolean commandIdempotency(AdapterRequest request) { + if (!ConformanceSupport.required(request.testCase().expected(), "header_required") + .asBoolean()) return false; String key = ConformanceSupport.text(request.testCase().input(), "idempotency_key"); for (JsonNode command : ConformanceSupport.required(request.testCase().input(), "commands")) { AepCommand parsed = command(command.asText()); - if (AepHttp.commandPath(parsed, null).isBlank()) return false; - if (parsed == AepCommand.ENROLL - && !AepJson.parseEnrollRequest(AepJson.write(new EnrollRequest("did:web:agent.example", null, key))) - .idempotencyKey() - .equals(key)) return false; + AepHttpTransport.Request outgoing = issue(parsed, key); + if (outgoing == null || !List.of(key).equals(outgoing.headers().get("Idempotency-Key"))) return false; } return true; } + /** Runs one command through an Agent and hands back the request that reached the transport. */ + private static AepHttpTransport.Request issue(AepCommand command, String idempotencyKey) { + AtomicReference captured = new AtomicReference<>(); + QueueTransport inspect = new QueueTransport(aepResponse( + 200, + document( + List.of("enroll", GRANT, "revoke", STATUS, INSPECT), + List.of(Aep.AUTHENTICATION_METHOD_JWT), + List.of()))); + String path = AepHttp.commandPath(command, null); + AepHttpTransport transport = outgoing -> { + // grant runs a status check first, so only the request for the command under test counts. + if (outgoing.uri().getPath().equals(path)) captured.set(outgoing); + return completed(aepResponse( + 200, commandResponse(outgoing.uri().getPath().equals(path) ? command : AepCommand.STATUS))); + }; + AepServiceSession session = agent(inspect, transport) + .idempotencyKeyProvider((serviceDid, operation, discriminator) -> idempotencyKey) + .build() + .service(ORIGIN); + try { + switch (command) { + case ENROLL -> session.enroll(null).join(); + case GRANT -> + session.grant(new GrantRequest(CUSTOM_GRANT, List.of())).join(); + case REVOKE -> + session.revoke(new foundation.aep.core.RevokeRequest(CUSTOM_GRANT, null, null)) + .join(); + default -> { + return null; + } + } + } catch (CompletionException rejectedResponse) { + // A command whose canned response the Agent rejects still produced the request under test. + assert rejectedResponse != null; + } + return captured.get(); + } + + private static AssertionOperation operationOf(AepCommand command) { + return AssertionOperation.fromValue(command.value()); + } + + private static String commandResponse(AepCommand command) { + return switch (command) { + case ENROLL, STATUS -> "{\"status\":\"active\"}"; + case GRANT -> "{\"credential_id\":\"credential-1\"}"; + default -> "{}"; + }; + } + + // Each redirect policy expected names is put to the Agent: a same-origin redirect is followed, a + // cross-origin one and a scheme downgrade are not, and the advertised media-type essence is + // accepted with its parameters and casing intact. private static boolean transport(AdapterRequest request) { JsonNode input = request.testCase().input(); + JsonNode expected = request.testCase().expected(); URI requested = URI.create(ConformanceSupport.text(input, "request_url")); URI redirected = URI.create(ConformanceSupport.text(input, "redirect_url")); - return "https".equals(requested.getScheme()) - && requested.getHost().equals(redirected.getHost()) - && requested.getPort() == redirected.getPort() - && ConformanceSupport.text(input, "content_type") - .toLowerCase(java.util.Locale.ROOT) - .startsWith(Aep.MEDIA_TYPE); + String contentType = ConformanceSupport.text(input, "content_type"); + if (!contentType + .toLowerCase(java.util.Locale.ROOT) + .startsWith(ConformanceSupport.text(expected, "media_type_essence"))) return false; + if (!inspectFollows(requested, redirected, contentType)) return false; + boolean rejectsCrossOrigin = "reject".equals(ConformanceSupport.text(expected, "cross_origin_redirect")) + && inspectRejects(requested, URI.create("https://other.example.net/discovery/aep"), contentType); + boolean rejectsDowngrade = "reject".equals(ConformanceSupport.text(expected, "scheme_downgrade")) + && inspectRejects(requested, URI.create("http://api.example.com/discovery/aep"), contentType); + return rejectsCrossOrigin && rejectsDowngrade; + } + + /** Answers a redirect to {@code location}, then the document, and reports whether Inspect completed. */ + private static boolean inspectFollows(URI from, URI location, String contentType) { + try { + redirectingSession(from, location, contentType).inspect().join(); + return true; + } catch (CompletionException exception) { + return false; + } + } + + private static boolean inspectRejects(URI from, URI location, String contentType) { + try { + redirectingSession(from, location, contentType).inspect().join(); + return false; + } catch (CompletionException exception) { + return exception.getCause() instanceof AepAgentException; + } + } + + private static AepServiceSession redirectingSession(URI from, URI location, String contentType) { + AepHttpTransport inspect = outgoing -> outgoing.uri().equals(from) + ? completed(response(302, Map.of("Location", List.of(location.toString())), "")) + : completed(response( + 200, + Map.of(CONTENT_TYPE, List.of(contentType)), + document(List.of(INSPECT), List.of(), List.of()))); + return agent(inspect, outgoing -> failed(COMMAND_TRANSPORT_PROHIBITED)) + .build() + .service(URI.create(from.getScheme() + "://" + from.getAuthority())); } private static boolean apiKeyHeader(AdapterRequest request) { @@ -202,7 +433,7 @@ private static boolean authenticateAssertion(AdapterRequest request) { AgentIdentity identity = new AgentIdentity( ConformanceSupport.text(request.testCase().expected().get(CLAIMS), "iss"), claims -> { assertion.set(claims); - return completed("compact-jws"); + return completed(COMPACT_JWS); }); QueueTransport inspect = new QueueTransport( aepResponse(200, document(List.of(INSPECT), List.of(Aep.AUTHENTICATION_METHOD_JWT), List.of()))); @@ -222,58 +453,148 @@ private static boolean authenticateAssertion(AdapterRequest request) { assertion.get(), request.testCase().expected().get(CLAIMS)); } + // Each composition in expected names the field the Agent puts its own credential in and the + // field the payment credential occupies beside it. The Agent must render into the first and + // leave the second untouched. private static boolean paymentComposition(AdapterRequest request) { - JsonNode expected = request.testCase().expected(); - Map aep = AepHttp.renderAuthorization(new ProtectedResourceAuthorization( - AuthorizationCarrier.DEDICATED, AuthorizationScheme.AEP, "compact-jws")); - return aep.get(Aep.AUTHORIZATION_HEADER) - .equals(expected.get("mpp") - .get(Aep.AUTHORIZATION_HEADER) - .asText()) - && !Aep.AUTHORIZATION_HEADER.equals("Authorization") - && expected.get("x402").has("PAYMENT-SIGNATURE"); + var compositions = request.testCase().expected().fields(); + while (compositions.hasNext()) { + JsonNode composition = compositions.next().getValue(); + if (!composition.isObject() || !composition.has(Aep.AUTHORIZATION_HEADER)) continue; + if (composition.path("ambiguous").asBoolean()) return false; + String carried = ConformanceSupport.text(composition, Aep.AUTHORIZATION_HEADER); + // The assertion is on the field, not the value: the Agent renders into the one the + // composition names for it and claims none of the fields beside it. + Map rendered = AepHttp.renderAuthorization(new ProtectedResourceAuthorization( + AuthorizationCarrier.DEDICATED, + carried.startsWith(Aep.AUTHENTICATION_SCHEME + " ") + ? AuthorizationScheme.AEP + : AuthorizationScheme.BEARER, + carried.substring(carried.indexOf(' ') + 1))); + if (!rendered.containsKey(Aep.AUTHORIZATION_HEADER)) return false; + var fields = composition.fields(); + while (fields.hasNext()) { + var field = fields.next(); + if (!field.getValue().isTextual() || Aep.AUTHORIZATION_HEADER.equals(field.getKey())) continue; + if (rendered.containsKey(field.getKey())) return false; + } + } + return true; } + // expected.allowed pairs each operation with the endpoint it may be presented at. The previous + // check asked AssertionOperation.fromValue whether its own lookup table was self-consistent; this + // one reads the operation and resource the Agent puts in the assertion it mints for each command, + // which is the binding the vector is about. private static boolean operationBinding(AdapterRequest request) { - for (JsonNode value : ConformanceSupport.required(request.testCase().input(), "operations")) { - AssertionOperation operation = AssertionOperation.fromValue(value.asText()); - boolean authenticate = operation == AssertionOperation.AUTHENTICATE; - if (authenticate != "authenticate".equals(value.asText())) return false; + String resource = ConformanceSupport.text(request.testCase().input(), "protected_resource"); + for (JsonNode pair : ConformanceSupport.required(request.testCase().expected(), "allowed")) { + String[] parts = pair.asText().split(":", 2); + AssertionOperation operation = AssertionOperation.fromValue(parts[0]); + ClientAssertionClaims minted = operation == AssertionOperation.AUTHENTICATE + ? mintedForResource(URI.create(resource)) + : mintedForCommand(command(parts[0])); + if (minted == null || minted.operation() != operation) return false; + boolean atResource = "protected-resource".equals(parts[1]); + if (atResource != resource.equals(minted.resource())) return false; + if (!atResource && minted.resource() != null) return false; } return true; } + private static ClientAssertionClaims mintedForCommand(AepCommand command) { + AtomicReference minted = new AtomicReference<>(); + QueueTransport inspect = new QueueTransport(aepResponse( + 200, + document( + List.of("enroll", GRANT, "revoke", STATUS, INSPECT), + List.of(Aep.AUTHENTICATION_METHOD_JWT), + List.of()))); + AssertionOperation wanted = operationOf(command); + AgentIdentity identity = new AgentIdentity(AGENT_DID, claims -> { + // grant mints a status assertion first; the one under test is the one for this command. + if (claims.operation() == wanted) minted.compareAndSet(null, claims); + return completed(COMPACT_JWS); + }); + String path = AepHttp.commandPath(command, null); + AepServiceSession session = agent( + inspect, + outgoing -> completed(aepResponse( + 200, + commandResponse(outgoing.uri().getPath().equals(path) ? command : AepCommand.STATUS)))) + .identityProvider((origin, serviceDid) -> completed(identity)) + .build() + .service(ORIGIN); + try { + switch (command) { + case ENROLL -> session.enroll(null).join(); + case GRANT -> + session.grant(new GrantRequest(CUSTOM_GRANT, List.of())).join(); + case REVOKE -> + session.revoke(new foundation.aep.core.RevokeRequest(CUSTOM_GRANT, null, null)) + .join(); + case STATUS -> session.status().join(); + default -> { + return null; + } + } + } catch (CompletionException rejectedResponse) { + // The assertion is minted before the response is read, so a rejected response is fine. + assert rejectedResponse != null; + } + return minted.get(); + } + + private static ClientAssertionClaims mintedForResource(URI resource) { + AtomicReference minted = new AtomicReference<>(); + AgentIdentity identity = new AgentIdentity(AGENT_DID, claims -> { + minted.compareAndSet(null, claims); + return completed(COMPACT_JWS); + }); + QueueTransport inspect = new QueueTransport( + aepResponse(200, document(List.of(INSPECT), List.of(Aep.AUTHENTICATION_METHOD_JWT), List.of()))); + agent(inspect, outgoing -> failed(COMMAND_TRANSPORT_PROHIBITED)) + .identityProvider((origin, serviceDid) -> completed(identity)) + .build() + .service(ORIGIN) + .authenticate(resource) + .join(); + return minted.get(); + } + + // CORE-PR-032: following a same-origin redirect means minting a fresh assertion bound to the + // redirect target, never forwarding the one bound to the request that started there. The two + // assertions the Agent mints must carry the source and target resources respectively. private static boolean redirectSafety(AdapterRequest request) { + JsonNode expected = request.testCase().expected(); + JsonNode sameOrigin = ConformanceSupport.required(expected, "same_origin"); + if (ConformanceSupport.required(sameOrigin, "credential_forwarded").asBoolean()) return false; URI source = URI.create(ConformanceSupport.text(request.testCase().input(), "source")); - URI sameOrigin = URI.create(ConformanceSupport.text(request.testCase().input(), "same_origin")); - URI crossOrigin = URI.create(ConformanceSupport.text(request.testCase().input(), "cross_origin")); - return source.getScheme().equals(sameOrigin.getScheme()) - && source.getAuthority().equals(sameOrigin.getAuthority()) - && !source.getAuthority().equals(crossOrigin.getAuthority()); + String target = ConformanceSupport.text(sameOrigin, "new_authenticate_assertion_resource"); + ClientAssertionClaims first = mintedForResource(source); + ClientAssertionClaims second = mintedForResource(URI.create(target)); + return first != null + && second != null + && source.toString().equals(first.resource()) + && target.equals(second.resource()) + && !first.resource().equals(second.resource()); } private static boolean failClosed(AdapterRequest request) { return switch (request.vector().id()) { - case "authorization-field-safety" -> - Aep.AUTHORIZATION_HEADER.equalsIgnoreCase( - ConformanceSupport.text(request.testCase().input(), "field_name")); - case "authorization-ambiguity" -> - rejectsAuthorization("AEP first,AEP second", AuthorizationCarrier.DEDICATED); - case "unadvertised-authentication-method" -> { - QueueTransport inspect = - new QueueTransport(aepResponse(200, document(List.of(INSPECT), List.of(), List.of()))); - try { - agent(inspect, requestValue -> failed(COMMAND_TRANSPORT_PROHIBITED)) - .build() - .service(ORIGIN) - .authenticate(URI.create("https://api.example.com/private")) - .join(); - yield false; - } catch (CompletionException exception) { - yield exception.getCause() instanceof AepAgentException; - } + // expected.redirect_new_assertion_carrier names the field the Agent puts a fresh + // assertion in, and the Agent's own rendered carrier must be that field. + case "authorization-field-safety" -> { + String carrier = + ConformanceSupport.text(request.testCase().expected(), "redirect_new_assertion_carrier"); + Map rendered = AepHttp.renderAuthorization(new ProtectedResourceAuthorization( + AuthorizationCarrier.DEDICATED, AuthorizationScheme.AEP, COMPACT_JWS)); + yield rendered.containsKey(carrier) + && carrier.equalsIgnoreCase( + ConformanceSupport.text(request.testCase().input(), "field_name")); } - default -> { + case "authorization-ambiguity" -> authorizationAmbiguity(request); + case "unadvertised-authentication-method" -> { QueueTransport inspect = new QueueTransport(aepResponse(200, document(List.of(INSPECT), List.of(), List.of()))); try { @@ -287,15 +608,95 @@ private static boolean failClosed(AdapterRequest request) { yield exception.getCause() instanceof AepAgentException; } } + default -> credentialFailures(request); + }; + } + + /** + * expected maps each failure mode to the code the Agent must surface. Only the modes this SDK + * decides locally are exercised here; the rest are the Service's to report, and the Agent's job + * is to carry the Service's code through unchanged, which is what the last case checks. + */ + private static boolean credentialFailures(AdapterRequest request) { + JsonNode expected = request.testCase().expected(); + QueueTransport inspect = new QueueTransport(aepResponse(200, document(List.of(INSPECT), List.of(), List.of()))); + try { + agent(inspect, requestValue -> failed(COMMAND_TRANSPORT_PROHIBITED)) + .build() + .service(ORIGIN) + .authenticate(URI.create("https://api.example.com/private")) + .join(); + return false; + } catch (CompletionException exception) { + // The codes in this vector are the ones a Service reports. What the Agent owns locally is + // failing closed when no advertised method is usable, under its own failure identifier. + if (!(exception.getCause() instanceof AepAgentException)) return false; + } + // Every other mode is the Service's to detect; the Agent's part is to surface the code it + // was given rather than collapsing them all into one. + java.util.Set codes = new java.util.LinkedHashSet<>(); + expected.fields().forEachRemaining(mode -> codes.add(mode.getValue().asText())); + return codes.stream().allMatch(AgentConformance::surfacesCommandCode); + } + + private static boolean surfacesCommandCode(String code) { + QueueTransport inspect = new QueueTransport(aepResponse( + 200, document(List.of(STATUS, INSPECT), List.of(Aep.AUTHENTICATION_METHOD_JWT), List.of()))); + QueueTransport command = new QueueTransport(problemResponse(401, code)); + try { + agent(inspect, command).build().service(ORIGIN).status().join(); + return false; + } catch (CompletionException exception) { + return exception.getCause() instanceof AepAgentException failure && code.equals(failure.code()); + } + } + + /** + * input.cases names four ambiguous presentations and expected names one outcome for all of them: + * the published code, with no fallback to a second carrier and no credential selected. Every case + * is driven and every member of expected is read. + */ + private static boolean authorizationAmbiguity(AdapterRequest request) { + JsonNode expected = request.testCase().expected(); + if (ConformanceSupport.required(expected, "fallback").asBoolean() + || !ConformanceSupport.required(expected, "selected_credential").isNull()) return false; + String code = ConformanceSupport.text(expected, "code"); + for (JsonNode value : ConformanceSupport.required(request.testCase().input(), "cases")) { + if (!ambiguous(value.asText(), code)) return false; + } + return true; + } + + private static boolean ambiguous(String name, String code) { + return switch (name) { + // An Agent renders one presentation into one field, so it can never be the party that + // puts an AEP credential in both carriers at once. + case "both-aep-recognized" -> + AepHttp.renderAuthorization(new ProtectedResourceAuthorization( + AuthorizationCarrier.DEDICATED, AuthorizationScheme.AEP, COMPACT_JWS)) + .size() + == SINGLE_FIELD; + // A malformed dedicated field is rejected outright; the valid standard field alongside it + // is not a fallback the Agent may quietly use instead. + case "invalid-dedicated-plus-valid-standard" -> + rejects("AEP", AuthorizationCarrier.DEDICATED, code) + && AepHttp.parseAuthorization("Bearer opaque", AuthorizationCarrier.STANDARD) + .carrier() + == AuthorizationCarrier.STANDARD; + case "duplicate-dedicated-lines" -> rejects("AEP first,AEP second", AuthorizationCarrier.DEDICATED, code); + // Two schemes packed into one field value are not two credentials to choose between. + case "combined-dedicated-values" -> + rejects("AEP first Bearer second", AuthorizationCarrier.DEDICATED, code); + default -> throw new IllegalArgumentException("Unknown authorization ambiguity case: " + name); }; } - private static boolean rejectsAuthorization(String value, AuthorizationCarrier carrier) { + private static boolean rejects(String value, AuthorizationCarrier carrier, String code) { try { AepHttp.parseAuthorization(value, carrier); return false; } catch (AepAuthorizationException exception) { - return true; + return code.equals(exception.code()); } } @@ -307,7 +708,7 @@ private static AepCommand command(String value) { } private static AepAgent.Builder agent(AepHttpTransport inspect, AepHttpTransport command) { - AgentIdentity identity = new AgentIdentity("did:web:agent.example", claims -> completed("signed")); + AgentIdentity identity = new AgentIdentity(AGENT_DID, claims -> completed("signed")); return AepAgent.builder() .inspectTransport(inspect) .commandTransport(command) @@ -317,9 +718,9 @@ private static AepAgent.Builder agent(AepHttpTransport inspect, AepHttpTransport } private static String document(List commands, List methods, List requiredClaims) { - String grants = commands.contains("grant") || commands.contains("revoke") ? "[\"custom-grant\"]" : "[]"; + String grants = commands.contains(GRANT) || commands.contains("revoke") ? "[\"custom-grant\"]" : "[]"; String config = - commands.contains("grant") ? "{\"custom-grant\":{\"supports_per_credential_revoke\":\"true\"}}" : "{}"; + commands.contains(GRANT) ? "{\"custom-grant\":{\"supports_per_credential_revoke\":\"true\"}}" : "{}"; String authentication = methods.isEmpty() ? "" : "\"authentication\":{\"methods\":" + AepJson.write(methods) + "},"; return "{\"aep_version\":\"1.0\"," + authentication diff --git a/tools/aep-conformance/src/main/java/foundation/aep/conformance/ConformanceSupport.java b/tools/aep-conformance/src/main/java/foundation/aep/conformance/ConformanceSupport.java index f002499..c5fc6d8 100644 --- a/tools/aep-conformance/src/main/java/foundation/aep/conformance/ConformanceSupport.java +++ b/tools/aep-conformance/src/main/java/foundation/aep/conformance/ConformanceSupport.java @@ -64,6 +64,35 @@ static boolean jsonEquals(Object value, JsonNode expected) { return json(AepJson.write(value)).equals(expected); } + /** + * Checks that writing a parsed value back out preserves everything the vector published. + * + * Straight equality fails on members the SDK materialises as canonical empty defaults, such as + * an absent {@code requested_scopes} becoming {@code []}. Every member the vector does publish + * must survive unchanged, and a member the round-trip added is only tolerated when it is an + * empty array or object; anything dropped, renamed, or altered fails. + */ + static boolean preserves(Object value, JsonNode source) { + return preserved(json(AepJson.write(value)), source); + } + + private static boolean preserved(JsonNode written, JsonNode source) { + if (!source.isObject()) return written.equals(source); + if (!written.isObject()) return false; + var names = source.fieldNames(); + while (names.hasNext()) { + String name = names.next(); + if (!written.has(name) || !preserved(written.get(name), source.get(name))) return false; + } + var added = written.fields(); + while (added.hasNext()) { + var entry = added.next(); + if (source.has(entry.getKey())) continue; + if (!entry.getValue().isContainerNode() || !entry.getValue().isEmpty()) return false; + } + return true; + } + static IllegalArgumentException unmapped(AdapterRequest request) { return new IllegalArgumentException("No " + request.role() + " operation maps vector " + request.vector().category() + "/" + request.vector().id()); diff --git a/tools/aep-conformance/src/main/java/foundation/aep/conformance/PlatformConformance.java b/tools/aep-conformance/src/main/java/foundation/aep/conformance/PlatformConformance.java index bc5f09d..f1a0ce6 100644 --- a/tools/aep-conformance/src/main/java/foundation/aep/conformance/PlatformConformance.java +++ b/tools/aep-conformance/src/main/java/foundation/aep/conformance/PlatformConformance.java @@ -39,10 +39,15 @@ import java.util.concurrent.atomic.AtomicInteger; final class PlatformConformance { + private static final String LIFETIME_SECONDS = "300"; + private static final String STATUS_FIELD = "status"; + private static final String CLIENT_ASSERTION = "client_assertion"; private static final String LIFECYCLE_RESPONSE = "lifecycle-response"; private static final String PROVISION_KEY = "provision"; private static final String SERVICE_DID_FIELD = "service_did"; private static final String VERIFY = "verify"; + private static final String ASSERTION_ID = "01J0AEPASSERTION0000000001"; + private static final String IDEMPOTENCY_KEY_HEADER = "idempotency_key_header"; private static final Instant NOW = Instant.parse("2026-07-06T12:00:00Z"); private static final String PRINCIPAL = "stable-principal-123"; private static final String SERVICE_DID = "did:web:api.service.example"; @@ -53,7 +58,7 @@ static boolean evaluate(AdapterRequest request) { return switch (request.vector().id()) { case "authorization-required" -> authorization(); case "discovery" -> discovery(request); - case "idempotency-replay-conflict" -> idempotency(); + case "idempotency-replay-conflict" -> idempotency(request); case "lifecycle-request", "lifecycle-response" -> lifecycle(request); case "list-response" -> list(request); case "provision-request", "provision-response" -> provision(request); @@ -62,7 +67,7 @@ static boolean evaluate(AdapterRequest request) { case "sign-response-pending" -> pendingSign(request); case "verification-authenticate-missing-resource" -> missingResource(request); case "verification-request" -> verificationRequest(request); - case "verification-response-recognized" -> recognizedVerification(); + case "verification-response-recognized" -> recognizedVerification(request); case "verification-response-unrecognized" -> unrecognizedVerification(); default -> throw ConformanceSupport.unmapped(request); }; @@ -87,32 +92,130 @@ private static boolean discovery(AdapterRequest request) { response.body(), request.testCase().expected()); } - private static boolean idempotency() { + // Each operation in input.operations is replayed under one key and then retried with changed + // material, which must answer the conflict status and code expected names. + private static boolean idempotency(AdapterRequest request) { + JsonNode input = request.testCase().input(); + JsonNode expected = request.testCase().expected(); + int conflictStatus = ConformanceSupport.required(expected, "changed_input_or_operation_status") + .asInt(); + String conflictCode = ConformanceSupport.text(expected, "changed_input_or_operation_code"); + String signKey = ConformanceSupport.text(input, "initial_sign_key"); + for (JsonNode operation : ConformanceSupport.required(input, "operations")) { + if (!replays(operation.asText(), signKey, conflictStatus, conflictCode)) return false; + } + return "stored_http_result".equals(ConformanceSupport.text(expected, "exact_replay")) + && retains(Long.parseLong(ConformanceSupport.text(expected, "retention_seconds_minimum"))); + } + + /** + * expected.retention_seconds_minimum is how long a recorded result must stay replayable. A store + * that forgot the key earlier would provision a second identity under it instead of returning + * the first, which is exactly the duplicate the key exists to prevent. + */ + private static boolean retains(long seconds) { Fixture fixture = fixture(true, true, null); - PlatformRequestContext context = fixture.context("shared"); - PlatformResponse first = fixture.platform() - .provision(new PlatformProvisionRequest(SERVICE_DID), context) + PlatformAgentIdentity identity = provision(fixture, SERVICE_DID, PROVISION_KEY); + PlatformSignRequest signRequest = new PlatformSignRequest( + ASSERTION_ID, LIFETIME_SECONDS, AssertionOperation.ENROLL, null, null, SERVICE_DID); + // Signing is used rather than provisioning because a second provision for the same principal + // and Service returns the same identity whether or not the key was remembered, so it could + // not tell a retained result from a fresh one. A forgotten sign key mints a new assertion. + PlatformResponse recorded = fixture.platform() + .sign(identity.agentIdentityId(), signRequest, fixture.context("retained")) .toCompletableFuture() .join(); + fixture.advanceSeconds(seconds - 1); PlatformResponse replay = fixture.platform() - .provision(new PlatformProvisionRequest(SERVICE_DID), context) - .toCompletableFuture() - .join(); - PlatformResponse conflict = fixture.platform() - .provision(new PlatformProvisionRequest("did:web:other.example"), context) + .sign(identity.agentIdentityId(), signRequest, fixture.context("retained")) .toCompletableFuture() .join(); + return recorded.status() == replay.status() + && ConformanceSupport.json(AepJson.write(recorded.body())) + .equals(ConformanceSupport.json(AepJson.write(replay.body()))); + } + + /** Runs one replayable operation twice with one key, then once more with changed material. */ + private static boolean replays(String operation, String signKey, int conflictStatus, String conflictCode) { + Fixture fixture = fixture(true, true, null); + PlatformResponse first; + PlatformResponse replay; + PlatformResponse conflict; + switch (operation) { + case "provision" -> { + PlatformRequestContext context = fixture.context("shared"); + first = fixture.platform() + .provision(new PlatformProvisionRequest(SERVICE_DID), context) + .toCompletableFuture() + .join(); + replay = fixture.platform() + .provision(new PlatformProvisionRequest(SERVICE_DID), context) + .toCompletableFuture() + .join(); + conflict = fixture.platform() + .provision(new PlatformProvisionRequest("did:web:other.example"), context) + .toCompletableFuture() + .join(); + } + case "sign" -> { + PlatformAgentIdentity identity = provision(fixture, SERVICE_DID, PROVISION_KEY); + PlatformRequestContext context = fixture.context(signKey); + PlatformSignRequest signRequest = new PlatformSignRequest( + ASSERTION_ID, LIFETIME_SECONDS, AssertionOperation.ENROLL, null, null, SERVICE_DID); + first = fixture.platform() + .sign(identity.agentIdentityId(), signRequest, context) + .toCompletableFuture() + .join(); + replay = fixture.platform() + .sign(identity.agentIdentityId(), signRequest, context) + .toCompletableFuture() + .join(); + conflict = fixture.platform() + .sign( + identity.agentIdentityId(), + new PlatformSignRequest( + "01J0AEPASSERTION0000000002", + LIFETIME_SECONDS, + AssertionOperation.ENROLL, + null, + null, + SERVICE_DID), + context) + .toCompletableFuture() + .join(); + } + case "hosted_verification" -> { + PlatformRequestContext context = fixture.context(VERIFY); + PlatformVerificationRequest verification = + new PlatformVerificationRequest("invalid", AssertionOperation.ENROLL, null, SERVICE_DID); + first = fixture.platform() + .verify(verification, context) + .toCompletableFuture() + .join(); + replay = fixture.platform() + .verify(verification, context) + .toCompletableFuture() + .join(); + conflict = fixture.platform() + .verify( + new PlatformVerificationRequest("other", AssertionOperation.ENROLL, null, SERVICE_DID), + context) + .toCompletableFuture() + .join(); + } + default -> throw new IllegalArgumentException("Unknown replayable operation: " + operation); + } return ConformanceSupport.jsonEquals(first.body(), ConformanceSupport.json(AepJson.write(replay.body()))) - && conflict.status() == 409 - && "idempotency_conflict".equals(conflict.problem().code()); + && conflict.status() == conflictStatus + && conflictCode.equals(conflict.problem().code()); } private static boolean lifecycle(AdapterRequest request) { Fixture fixture = fixture(true, true, null); PlatformAgentIdentity identity = provision(fixture, SERVICE_DID, PROVISION_KEY); - ManagedAgentStatus status = request.testCase().input().has("status") + ManagedAgentStatus status = request.testCase().input().has(STATUS_FIELD) ? ManagedAgentStatus.fromValue( - ConformanceSupport.text(request.testCase().input(), "status")) + ConformanceSupport.text(request.testCase().input(), STATUS_FIELD)) : ManagedAgentStatus.SUSPENDED; if (LIFECYCLE_RESPONSE.equals(request.vector().id())) fixture.advanceSeconds(600); PlatformResponse response = fixture.platform() @@ -135,7 +238,7 @@ private static boolean list(AdapterRequest request) { ConformanceSupport.required(queryNode, "limit").asInt(), ConformanceSupport.required(queryNode, "offset").asInt(), ConformanceSupport.text(queryNode, SERVICE_DID_FIELD), - ManagedAgentStatus.fromValue(ConformanceSupport.text(queryNode, "status"))); + ManagedAgentStatus.fromValue(ConformanceSupport.text(queryNode, STATUS_FIELD))); var response = fixture.platform() .list(query, fixture.context(null)) .toCompletableFuture() @@ -158,16 +261,10 @@ private static boolean provision(AdapterRequest request) { private static boolean distinctServices(AdapterRequest request) { Fixture fixture = fixture(true, true, null); - String firstDid = request.testCase() - .input() - .get("first_request") - .get(SERVICE_DID_FIELD) - .asText(); - String secondDid = request.testCase() - .input() - .get("second_request") - .get(SERVICE_DID_FIELD) - .asText(); + String firstDid = ConformanceSupport.text( + ConformanceSupport.required(request.testCase().input(), "first_request"), SERVICE_DID_FIELD); + String secondDid = ConformanceSupport.text( + ConformanceSupport.required(request.testCase().input(), "second_request"), SERVICE_DID_FIELD); PlatformAgentIdentity first = provision(fixture, firstDid, "first"); fixture.advanceMinute(); PlatformAgentIdentity second = provision(fixture, secondDid, "second"); @@ -178,21 +275,98 @@ private static boolean distinctServices(AdapterRequest request) { second, request.testCase().expected().get("second_response")); } + /** + * sign-request is about the request contract: PLT-SIGN-002 makes the Idempotency-Key header + * mandatory and PLT-IDP-005 makes an exact retry replay the stored result. sign-response is + * about the body, which is compared member by member against the one the vector publishes. + */ private static boolean sign(AdapterRequest request) { + boolean requestVector = "sign-request".equals(request.vector().id()); + PlatformSignRequest signRequest = requestVector + ? AepJson.parsePlatformSignRequest(request.testCase().input().toString()) + : signRequestFor(request.testCase().expected()); + if (requestVector) { + String key = ConformanceSupport.text(request.testCase().expected(), IDEMPOTENCY_KEY_HEADER); + return signRequiresAKey(signRequest) && signReplays(signRequest, key); + } Fixture fixture = fixture(true, true, null); PlatformAgentIdentity identity = provision(fixture, SERVICE_DID, PROVISION_KEY); - PlatformSignRequest signRequest = "sign-request".equals(request.vector().id()) - ? AepJson.parsePlatformSignRequest(request.testCase().input().toString()) - : new PlatformSignRequest( - "01J0AEPASSERTION0000000001", "300", AssertionOperation.ENROLL, null, null, SERVICE_DID); PlatformResponse response = fixture.platform() .sign(identity.agentIdentityId(), signRequest, fixture.context("sign")) .toCompletableFuture() .join(); return response.status() == 200 && response.body() instanceof PlatformSignResponses.Completed completed - && !completed.clientAssertion().isBlank() - && signRequest.jwtId().equals(completed.jwtId()); + && matchesExpectedAssertion(completed, request.testCase().expected()); + } + + /** Rebuilds the request that produces the response the vector publishes. */ + private static PlatformSignRequest signRequestFor(JsonNode expected) { + JsonNode context = expected.get("platform_context"); + return new PlatformSignRequest( + ConformanceSupport.text(expected, "jti"), + LIFETIME_SECONDS, + AssertionOperation.ENROLL, + context == null ? null : AepJson.parseObject(context.toString(), "platform_context"), + null, + ConformanceSupport.text(expected, SERVICE_DID_FIELD)); + } + + /** + * Every member of the expected body is compared except client_assertion. + * + * That one is excluded because the token the vector publishes is illustrative and does not agree + * with the response it sits in: its `aud` is did:web:s.example where the body's service_did is + * did:web:api.service.example, and its iat/exp fall on 2026-07-07 where the body's issued_at and + * expires_at are 2026-07-06. PLT-SIGN-020 and PLT-SIGN-021 require the minted assertion to agree + * with the response carrying it, which is what {@link #boundToItsResponse} checks instead. + */ + private static boolean matchesExpectedAssertion(PlatformSignResponses.Completed completed, JsonNode expected) { + com.fasterxml.jackson.databind.node.ObjectNode actual = (com.fasterxml.jackson.databind.node.ObjectNode) + ConformanceSupport.json(AepJson.write(completed)).deepCopy(); + com.fasterxml.jackson.databind.node.ObjectNode published = + (com.fasterxml.jackson.databind.node.ObjectNode) expected.deepCopy(); + String minted = ConformanceSupport.text(actual, CLIENT_ASSERTION); + actual.remove(CLIENT_ASSERTION); + published.remove(CLIENT_ASSERTION); + return actual.equals(published) && boundToItsResponse(minted, completed); + } + + /** PLT-SIGN-021: iss and sub are the Agent DID, aud is the Service DID the response names. */ + private static boolean boundToItsResponse(String minted, PlatformSignResponses.Completed completed) { + ClientAssertionClaims claims = ClientAssertions.decodeUnverified(minted); + return claims.issuer().equals(completed.agentDid()) + && claims.subject().equals(completed.agentDid()) + && claims.audience().equals(completed.serviceDid()) + && claims.jwtId().equals(completed.jwtId()) + && Instant.ofEpochSecond(claims.issuedAt()).equals(Instant.parse(completed.issuedAt())) + && Instant.ofEpochSecond(claims.expiresAt()).equals(Instant.parse(completed.expiresAt())); + } + + private static boolean signRequiresAKey(PlatformSignRequest signRequest) { + Fixture fixture = fixture(true, true, null); + PlatformAgentIdentity identity = provision(fixture, SERVICE_DID, PROVISION_KEY); + var refused = fixture.platform() + .sign(identity.agentIdentityId(), signRequest, new PlatformRequestContext(PRINCIPAL, null)) + .toCompletableFuture() + .join(); + return refused.status() == 400; + } + + private static boolean signReplays(PlatformSignRequest signRequest, String key) { + Fixture fixture = fixture(true, true, null); + PlatformAgentIdentity identity = provision(fixture, SERVICE_DID, PROVISION_KEY); + PlatformRequestContext context = fixture.context(key); + var first = fixture.platform() + .sign(identity.agentIdentityId(), signRequest, context) + .toCompletableFuture() + .join(); + var replay = fixture.platform() + .sign(identity.agentIdentityId(), signRequest, context) + .toCompletableFuture() + .join(); + return first.status() == 200 + && ConformanceSupport.jsonEquals(first.body(), ConformanceSupport.json(AepJson.write(replay.body()))); } private static boolean pendingSign(AdapterRequest request) { @@ -218,7 +392,7 @@ private static boolean pendingSign(AdapterRequest request) { private static boolean missingResource(AdapterRequest request) { JsonNode input = ConformanceSupport.required(request.testCase().input(), "request"); PlatformVerificationRequest verification = new PlatformVerificationRequest( - ConformanceSupport.text(input, "client_assertion"), + ConformanceSupport.text(input, CLIENT_ASSERTION), AssertionOperation.fromValue(ConformanceSupport.text(input, "op")), null, ConformanceSupport.text(input, "service_did")); @@ -227,32 +401,49 @@ private static boolean missingResource(AdapterRequest request) { .verify(verification, new PlatformRequestContext(PRINCIPAL, VERIFY)) .toCompletableFuture() .join(); - return response.status() == 400; + // The status the Platform answers must track expected.valid, whichever polarity the vector + // publishes. + return !ConformanceSupport.valid(request) == (response.status() == 400); } + /** + * PLT-VER-002 makes the Idempotency-Key header mandatory on hosted verification. The request is + * round-tripped, refused without a key, accepted with the key the vector names, and an exact + * retry must replay the stored result. + */ private static boolean verificationRequest(AdapterRequest request) { PlatformVerificationRequest input = AepJson.parsePlatformVerificationRequest( request.testCase().input().toString()); + if (!ConformanceSupport.jsonEquals(input, request.testCase().input())) return false; + String key = ConformanceSupport.text(request.testCase().expected(), IDEMPOTENCY_KEY_HEADER); Fixture fixture = fixture(true, true, null); - var response = fixture.platform() - .verify(input, fixture.context(VERIFY)) + var withoutKey = fixture.platform() + .verify(input, new PlatformRequestContext(PRINCIPAL, null)) .toCompletableFuture() .join(); - return response.status() == 200 - && !response.body().verified() - && "not_recognized".equals(response.body().reason()); + PlatformRequestContext context = fixture.context(key); + var first = + fixture.platform().verify(input, context).toCompletableFuture().join(); + var replay = + fixture.platform().verify(input, context).toCompletableFuture().join(); + return withoutKey.status() == 400 + && first.status() == 200 + && ConformanceSupport.jsonEquals(first.body(), ConformanceSupport.json(AepJson.write(replay.body()))); } - private static boolean recognizedVerification() { + private static boolean recognizedVerification(AdapterRequest request) { Fixture fixture = fixture(true, true, null); PlatformAgentIdentity identity = provision(fixture, SERVICE_DID, PROVISION_KEY); PlatformSignRequest signRequest = new PlatformSignRequest("verification", null, AssertionOperation.ENROLL, null, null, SERVICE_DID); - var signed = (PlatformSignResponses.Completed) fixture.platform() + var body = fixture.platform() .sign(identity.agentIdentityId(), signRequest, fixture.context("sign")) .toCompletableFuture() .join() .body(); + // A Pending body here raised a ClassCastException and was reported as an opaque failure + // rather than as the wrong kind of response. + if (!(body instanceof PlatformSignResponses.Completed signed)) return false; var verified = fixture.platform() .verify( new PlatformVerificationRequest( @@ -260,9 +451,18 @@ private static boolean recognizedVerification() { fixture.context(VERIFY)) .toCompletableFuture() .join(); + JsonNode expected = request.testCase().expected(); return verified.status() == 200 && verified.body().verified() - && identity.agentIdentityId().equals(verified.body().agentIdentityId()); + && identity.agentIdentityId().equals(verified.body().agentIdentityId()) + && ConformanceSupport.text(expected, "reason") + .equals(verified.body().reason()) + && ConformanceSupport.text(expected, "op") + .equals(verified.body().operation().value()) + && ConformanceSupport.text(expected, STATUS_FIELD) + .equals(verified.body().status().value()) + && ConformanceSupport.text(expected, SERVICE_DID_FIELD) + .equals(verified.body().serviceDid()); } private static boolean unrecognizedVerification() { @@ -279,11 +479,14 @@ private static boolean unrecognizedVerification() { } private static PlatformAgentIdentity provision(Fixture fixture, String serviceDid, String key) { - return fixture.platform() + PlatformResponse response = fixture.platform() .provision(new PlatformProvisionRequest(serviceDid), fixture.context(key)) .toCompletableFuture() - .join() - .body(); + .join(); + if (response.status() != 200 || response.body() == null) { + throw new IllegalArgumentException("Provisioning the conformance fixture returned " + response.status()); + } + return response.body(); } private static Fixture fixture(boolean authorized, boolean hostedVerification, PlatformSignHandler handler) { @@ -343,7 +546,7 @@ private static PlatformDiscoveryDocument discovery(boolean hostedVerification) { new PlatformDiscoveryDocument.Identity( List.of(Aep.IDENTITY_METHOD_DID_WEB), "https://p.example/a/{agent_did_id}/did.json"), new PlatformDiscoveryDocument.Platform("did:web:p.example", hostedVerification, "Example Platform"), - new PlatformDiscoveryDocument.Signing(List.of("ES256"), "300")); + new PlatformDiscoveryDocument.Signing(List.of("ES256"), LIFETIME_SECONDS)); } private static CompletableFuture completed(T value) { diff --git a/tools/aep-conformance/src/main/java/foundation/aep/conformance/ServiceConformance.java b/tools/aep-conformance/src/main/java/foundation/aep/conformance/ServiceConformance.java index bad75d2..f23f489 100644 --- a/tools/aep-conformance/src/main/java/foundation/aep/conformance/ServiceConformance.java +++ b/tools/aep-conformance/src/main/java/foundation/aep/conformance/ServiceConformance.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.databind.JsonNode; import foundation.aep.core.Aep; +import foundation.aep.core.AepCommand; import foundation.aep.core.AepJson; import foundation.aep.core.AgentStatus; import foundation.aep.core.AssertionOperation; @@ -10,7 +11,10 @@ import foundation.aep.core.GrantRequest; import foundation.aep.core.GrantResponses; import foundation.aep.core.InspectDocument; +import foundation.aep.service.AepHttpRequest; +import foundation.aep.service.AepHttpResponse; import foundation.aep.service.AepService; +import foundation.aep.service.AepServiceHttpHandler; import foundation.aep.service.CommandOptions; import foundation.aep.service.EnrollmentDecision; import foundation.aep.service.GrantContext; @@ -24,6 +28,7 @@ import foundation.aep.service.StoredCredentialGrantTypes; import java.net.URI; import java.time.Clock; +import java.time.Duration; import java.time.Instant; import java.time.ZoneOffset; import java.util.List; @@ -31,14 +36,20 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionStage; import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; final class ServiceConformance { private static final String AUTHORIZATION = "Authorization"; + private static final String SUPPORTED = "true"; + private static final String ENROLLMENT_ID = "enrollment-1"; + private static final String AUTHORIZATION_AMBIGUITY = "authorization-ambiguity"; private static final String AUTHORIZATION_FIELD_SAFETY = "authorization-field-safety"; private static final String CUSTOM_SESSION = "custom-session"; private static final String ENROLL = "enroll"; private static final String GRANT = "grant"; private static final String INSPECT = "inspect"; + private static final String CREDENTIAL_ID = "credential-1"; + private static final String GET_METHOD = "GET"; private static final Instant NOW = Instant.parse("2026-08-30T12:00:00Z"); private static final URI PROTECTED_RESOURCE = URI.create("https://service.example/private"); private static final String AGENT_DID = "did:web:agent.example"; @@ -56,9 +67,9 @@ static boolean evaluate(AdapterRequest request) { case "transport-requirements" -> transport(request); case "api-key-wrong-header-rejected" -> apiKeyHeader(request); case "authenticate-assertion" -> authenticateAssertion(); - case "authorization-payment-composition" -> paymentComposition(); - case "operation-substitution-rejected" -> operationBinding(); - case "assertion-and-credential-failures", "authorization-ambiguity", "authorization-field-safety" -> + case "authorization-payment-composition" -> paymentComposition(request); + case "operation-substitution-rejected" -> operationBinding(request); + case "assertion-and-credential-failures", AUTHORIZATION_AMBIGUITY, AUTHORIZATION_FIELD_SAFETY -> protectedFailure(request); case "redirect-safety" -> redirectSafety(request); default -> throw ConformanceSupport.unmapped(request); @@ -126,9 +137,28 @@ private static boolean idempotency() { && "idempotency_conflict".equals(conflict.problem().code()); } + // CORE-CACHE-008: the media-type essence is compared case-insensitively and valid parameters are + // ignored. The HTTP binding is driven with input.content_type verbatim, and must accept that + // field value while refusing application/json. private static boolean transport(AdapterRequest request) { - String type = ConformanceSupport.text(request.testCase().input(), "content_type"); - return type.toLowerCase(java.util.Locale.ROOT).startsWith(Aep.MEDIA_TYPE); + String contentType = ConformanceSupport.text(request.testCase().input(), "content_type"); + String essence = ConformanceSupport.text(request.testCase().expected(), "media_type_essence"); + AepServiceHttpHandler handler = new AepServiceHttpHandler( + serviceBuilder(document(List.of(ENROLL, INSPECT), List.of(Aep.AUTHENTICATION_METHOD_JWT))) + .build()); + AepHttpResponse inspect = handler.handle(AepCommand.INSPECT, get(Aep.WELL_KNOWN_PATH)) + .toCompletableFuture() + .join(); + AepHttpResponse accepted = handler.handle(AepCommand.ENROLL, enrollPost(contentType)) + .toCompletableFuture() + .join(); + AepHttpResponse refused = handler.handle(AepCommand.ENROLL, enrollPost("application/json")) + .toCompletableFuture() + .join(); + return essence.equals(inspect.contentType()) + && inspect.status() == 200 + && accepted.status() == 200 + && refused.status() == 415; } private static boolean apiKeyHeader(AdapterRequest request) { @@ -136,12 +166,12 @@ private static boolean apiKeyHeader(AdapterRequest request) { String issued = ConformanceSupport.text(input, "issued_header"); String presented = ConformanceSupport.text(input, "presented_header"); String apiKey = ConformanceSupport.text(input, "api_key"); - InspectDocument.GrantTypeConfig config = - new InspectDocument.GrantTypeConfig(null, null, List.of(issued), null, List.of(), "true", null, null); + InspectDocument.GrantTypeConfig config = new InspectDocument.GrantTypeConfig( + null, null, List.of(issued), null, List.of(), SUPPORTED, null, null); var stored = StoredCredentialGrantTypes.apiKey( config, (grant, context) -> completed(new GrantResponses.ApiKey( - apiKey, "credential-1", NOW.plusSeconds(3600).toString(), issued, List.of())), + apiKey, CREDENTIAL_ID, NOW.plusSeconds(3600).toString(), issued, List.of())), ServiceCredentialStore.inMemory()); AepService service = serviceBuilder(apiKeyDocument(config)) .storedCredentialGrantType(stored) @@ -152,8 +182,8 @@ private static boolean apiKeyHeader(AdapterRequest request) { service.grant(new GrantRequest(Aep.GRANT_TYPE_API_KEY, List.of()), options(GRANT, GRANT)) .toCompletableFuture() .join(); - ProtectedResourceResult result = service.authenticate( - new ProtectedResourceRequest(Map.of(presented, List.of(apiKey)), "GET", PROTECTED_RESOURCE)) + ProtectedResourceResult result = service.authenticate(new ProtectedResourceRequest( + Map.of(presented, List.of(apiKey)), GET_METHOD, PROTECTED_RESOURCE)) .toCompletableFuture() .join(); return !result.authenticated() @@ -168,7 +198,7 @@ private static boolean authenticateAssertion() { .toCompletableFuture() .join(); ProtectedResourceResult result = service.authenticate(new ProtectedResourceRequest( - Map.of(AUTHORIZATION, List.of("AEP authenticate")), "GET", PROTECTED_RESOURCE)) + Map.of(AUTHORIZATION, List.of("AEP authenticate")), GET_METHOD, PROTECTED_RESOURCE)) .toCompletableFuture() .join(); return result.authenticated() @@ -176,44 +206,416 @@ private static boolean authenticateAssertion() { && Aep.AUTHENTICATION_METHOD_JWT.equals(result.principal().authenticationMethod()); } - private static boolean paymentComposition() { - Map> headers = Map.of( - Aep.AUTHORIZATION_HEADER, List.of("AEP assertion"), AUTHORIZATION, List.of("Payment credential")); - return headers.size() == 2 && !Aep.AUTHORIZATION_HEADER.equals(AUTHORIZATION); + // An AEP credential on the dedicated carrier composes with an unrelated payment credential on + // Authorization without either becoming ambiguous. Each composition expected describes is + // presented to the Service, which must authenticate the Agent from it. + private static boolean paymentComposition(AdapterRequest request) { + var compositions = request.testCase().expected().fields(); + while (compositions.hasNext()) { + var composition = compositions.next(); + JsonNode value = composition.getValue(); + if (!value.isObject() || !value.has(Aep.AUTHORIZATION_HEADER)) continue; + if (value.path("ambiguous").asBoolean()) return false; + String carried = ConformanceSupport.text(value, Aep.AUTHORIZATION_HEADER); + Map> headers = new java.util.LinkedHashMap<>(); + value.fields().forEachRemaining(field -> { + if (field.getValue().isTextual()) + headers.put(field.getKey(), List.of(field.getValue().asText())); + }); + // The AEP credential on the dedicated carrier is an assertion in one composition and a + // bearer session credential in the other; the Service under test has to accept whichever + // the composition carries while leaving the payment credential beside it alone. + AepService service = + carried.startsWith(Aep.AUTHENTICATION_SCHEME + " ") ? enrolledService() : bearerService(carried); + ProtectedResourceResult result = service.authenticate( + new ProtectedResourceRequest(headers, GET_METHOD, PROTECTED_RESOURCE)) + .toCompletableFuture() + .join(); + if (!result.authenticated() || !AGENT_DID.equals(result.principal().agentDid())) return false; + } + return true; } - private static boolean operationBinding() { - for (AssertionOperation operation : AssertionOperation.values()) { - String resource = operation == AssertionOperation.AUTHENTICATE ? PROTECTED_RESOURCE.toString() : null; - ClientAssertionClaims claims = claims(operation, operation.value(), resource); - if (!foundation.aep.core.AepValidation.clientAssertionClaims(claims).isEmpty()) return false; + /** A Service that has issued the bearer session credential a composition presents. */ + private static AepService bearerService(String carried) { + String token = carried.substring(carried.indexOf(' ') + 1); + InspectDocument.GrantTypeConfig config = new InspectDocument.GrantTypeConfig(SUPPORTED); + AepService service = serviceBuilder(bearerDocument(config)) + .storedCredentialGrantType(StoredCredentialGrantTypes.oauthBearer( + config, + (grant, context) -> completed(new GrantResponses.OAuthBearer( + token, CREDENTIAL_ID, NOW.plusSeconds(3600).toString(), List.of(), "opaque", "Bearer")), + ServiceCredentialStore.inMemory())) + .build(); + service.enroll(new EnrollRequest(AGENT_DID, null, null), options(ENROLL, ENROLL)) + .toCompletableFuture() + .join(); + service.grant(new GrantRequest(Aep.GRANT_TYPE_OAUTH_BEARER, List.of()), options(GRANT, GRANT)) + .toCompletableFuture() + .join(); + return service; + } + + private static InspectDocument bearerDocument(InspectDocument.GrantTypeConfig config) { + return InspectDocument.builder() + .version(Aep.VERSION) + .authentication(new InspectDocument.Authentication( + List.of(Aep.AUTHENTICATION_METHOD_JWT, Aep.GRANT_TYPE_OAUTH_BEARER))) + .bindings(new InspectDocument.Bindings(List.of("http"))) + .claims(new InspectDocument.Claims(List.of(), List.of(), List.of())) + .commands(new InspectDocument.Commands( + List.of(ENROLL, GRANT, INSPECT), + List.of(Aep.GRANT_TYPE_OAUTH_BEARER), + Map.of(Aep.GRANT_TYPE_OAUTH_BEARER, config))) + .core(new InspectDocument.Core(Aep.REQUIRED_SIGNING_ALGORITHMS)) + .extensions(new InspectDocument.Extensions(List.of())) + .http(new InspectDocument.Http(Aep.DEFAULT_ENDPOINT_BASE, null)) + .identity(new InspectDocument.Identity(List.of(Aep.IDENTITY_METHOD_DID_WEB))) + .service(new InspectDocument.Service(SERVICE_DID)) + .build(); + } + + // expected.all_other_pairs is "not_recognized": only an authenticate assertion opens a protected + // resource, and only a command's own assertion reaches that command. A well-formed assertion + // carrying any other operation is substituted, and the Service must refuse it under that code. + private static boolean operationBinding(AdapterRequest request) { + String substituted = ConformanceSupport.text(request.testCase().expected(), "all_other_pairs"); + for (JsonNode value : ConformanceSupport.required(request.testCase().input(), "operations")) { + AssertionOperation minted = AssertionOperation.fromValue(value.asText()); + AepService service = substitutingService(minted); + ProtectedResourceResult result = service.authenticate(new ProtectedResourceRequest( + Map.of(AUTHORIZATION, List.of("AEP substituted")), GET_METHOD, PROTECTED_RESOURCE)) + .toCompletableFuture() + .join(); + if (minted == AssertionOperation.AUTHENTICATE) { + if (result.authenticated()) continue; + return false; + } + if (result.authenticated() + || !substituted.equals(result.response().problem().code())) return false; } return true; } private static boolean protectedFailure(AdapterRequest request) { - if (AUTHORIZATION_FIELD_SAFETY.equals(request.vector().id())) { - return Aep.AUTHORIZATION_HEADER.equalsIgnoreCase( - ConformanceSupport.text(request.testCase().input(), "field_name")); + if (AUTHORIZATION_FIELD_SAFETY.equals(request.vector().id())) return fieldNameSafety(request); + if (AUTHORIZATION_AMBIGUITY.equals(request.vector().id())) return authorizationAmbiguity(request); + return credentialFailures(request); + } + + /** + * expected maps each of the vector's seven failure modes to the code the Service must report. + * Every mode the SDK decides is driven against its own code, so the three distinct codes stay + * distinguishable rather than collapsing into one. + */ + private static boolean credentialFailures(AdapterRequest request) { + JsonNode expected = request.testCase().expected(); + for (JsonNode value : ConformanceSupport.required(request.testCase().input(), "cases")) { + String mode = value.asText(); + Boolean outcome = failureMode(mode, ConformanceSupport.text(expected, mode)); + // insufficient_scope is reported by the application that owns the scope, from the scopes + // the match carries; the SDK has no scope policy of its own to exercise here. + if (outcome != null && !outcome) return false; } - AepService service = serviceBuilder(document(List.of(INSPECT), List.of(Aep.AUTHENTICATION_METHOD_JWT))) + return true; + } + + private static Boolean failureMode(String mode, String code) { + return switch (mode) { + // A field value carrying a scheme and no credentials is not a presentation at all. + case "malformed_credential" -> rejected(Map.of(AUTHORIZATION, List.of("AEP")), code); + // Bearer against a document advertising only aep-jwt names a method the Service does not + // offer, which is a different answer from "the credential was not recognized". + case "unsupported_method" -> rejected(Map.of(AUTHORIZATION, List.of("Bearer opaque")), code); + case "wrong_audience" -> corrupted(claims -> withAudience(claims, "did:web:elsewhere.example"), code); + case "expired_assertion" -> corrupted(claims -> withExpiry(claims, NOW.minusSeconds(600)), code); + case "replayed_assertion" -> replayed(code); + case "expired_credential" -> expiredCredential(code); + case "insufficient_scope" -> null; + default -> throw new IllegalArgumentException("Unknown protected-resource failure mode: " + mode); + }; + } + + /** A Service whose verifier returns claims the protected-resource path must refuse to accept. */ + private static boolean corrupted(java.util.function.UnaryOperator corrupt, String code) { + AtomicReference> active = new AtomicReference<>(); + AepService service = AepService.builder( + document(List.of(ENROLL, INSPECT), List.of(Aep.AUTHENTICATION_METHOD_JWT)), + (assertion, context) -> { + ClientAssertionClaims issued = claims(context.operation(), assertion, context.resource()); + var mutate = active.get(); + return completed(mutate == null ? issued : mutate.apply(issued)); + }) + .clock(Clock.fixed(NOW, ZoneOffset.UTC)) + .identifierSupplier(() -> ENROLLMENT_ID) + .build(); + // Enrollment runs through the same verifier, so the corruption starts once the Agent is + // ACTIVE and the protected-resource path is the thing under test. + service.enroll(new EnrollRequest(AGENT_DID, null, null), options(ENROLL, ENROLL)) + .toCompletableFuture() + .join(); + active.set(corrupt); + return rejectedBy(service, Map.of(AUTHORIZATION, List.of("AEP assertion")), code); + } + + /** The same assertion presented twice: the second presentation is a replay the Service consumes once. */ + private static boolean replayed(String code) { + AepService service = enrolledService(); + Map> headers = Map.of(AUTHORIZATION, List.of("AEP replayed-assertion")); + ProtectedResourceResult first = service.authenticate( + new ProtectedResourceRequest(headers, GET_METHOD, PROTECTED_RESOURCE)) + .toCompletableFuture() + .join(); + return first.authenticated() && rejectedBy(service, headers, code); + } + + /** + * A stored session credential past its expiry no longer authenticates. The credential is minted + * with a lifetime the Service accepts and the clock is then moved past it, because issuing one + * that has already expired is refused at the Grant, which would test the wrong thing. + */ + private static boolean expiredCredential(String code) { + InspectDocument.GrantTypeConfig config = new InspectDocument.GrantTypeConfig(SUPPORTED); + MovableClock clock = new MovableClock(NOW); + AepService service = AepService.builder( + bearerDocument(config), + (assertion, context) -> completed(claims(context.operation(), assertion, context.resource()))) + .clock(clock) + .identifierSupplier(() -> ENROLLMENT_ID) + .storedCredentialGrantType(StoredCredentialGrantTypes.oauthBearer( + config, + (grant, context) -> completed(new GrantResponses.OAuthBearer( + "stale-token", + CREDENTIAL_ID, + NOW.plusSeconds(60).toString(), + List.of(), + "opaque", + "Bearer")), + ServiceCredentialStore.inMemory())) .build(); - Map> headers = - "authorization-ambiguity".equals(request.vector().id()) - ? Map.of(Aep.AUTHORIZATION_HEADER, List.of("AEP first", "AEP second")) - : Map.of(AUTHORIZATION, List.of("AEP malformed")); + service.enroll(new EnrollRequest(AGENT_DID, null, null), options(ENROLL, ENROLL)) + .toCompletableFuture() + .join(); + service.grant(new GrantRequest(Aep.GRANT_TYPE_OAUTH_BEARER, List.of()), options(GRANT, GRANT)) + .toCompletableFuture() + .join(); + clock.advance(Duration.ofSeconds(120)); + return rejectedBy(service, Map.of(AUTHORIZATION, List.of("Bearer stale-token")), code); + } + + private static final class MovableClock extends Clock { + private Instant current; + + MovableClock(Instant start) { + this.current = start; + } + + void advance(Duration amount) { + current = current.plus(amount); + } + + @Override + public java.time.ZoneId getZone() { + return ZoneOffset.UTC; + } + + @Override + public Clock withZone(java.time.ZoneId zone) { + return this; + } + + @Override + public Instant instant() { + return current; + } + } + + private static ClientAssertionClaims withAudience(ClientAssertionClaims claims, String audience) { + return new ClientAssertionClaims( + claims.issuer(), + claims.subject(), + audience, + claims.operation(), + claims.issuedAt(), + claims.expiresAt(), + claims.jwtId(), + claims.resource()); + } + + private static ClientAssertionClaims withExpiry(ClientAssertionClaims claims, Instant expiresAt) { + return new ClientAssertionClaims( + claims.issuer(), + claims.subject(), + claims.audience(), + claims.operation(), + expiresAt.minusSeconds(60).getEpochSecond(), + expiresAt.getEpochSecond(), + claims.jwtId(), + claims.resource()); + } + + /** + * input.cases names four ambiguous presentations and expected names one outcome for all of them: + * the published code, with no fallback to the standard field and no credential selected. Every + * case is driven and every member of expected is read. + */ + private static boolean authorizationAmbiguity(AdapterRequest request) { + JsonNode expected = request.testCase().expected(); + if (ConformanceSupport.required(expected, "fallback").asBoolean() + || !ConformanceSupport.required(expected, "selected_credential").isNull()) return false; + String code = ConformanceSupport.text(expected, "code"); + for (JsonNode value : ConformanceSupport.required(request.testCase().input(), "cases")) { + if (!rejected(ambiguousHeaders(value.asText()), code)) return false; + } + return true; + } + + private static Map> ambiguousHeaders(String name) { + return switch (name) { + // An AEP credential in both carriers at once names no single presentation to verify. + case "both-aep-recognized" -> + Map.of(Aep.AUTHORIZATION_HEADER, List.of("AEP dedicated"), AUTHORIZATION, List.of("AEP standard")); + // The malformed dedicated field is the presentation; the valid standard one beside it is + // not a fallback the Service may authenticate instead. + case "invalid-dedicated-plus-valid-standard" -> + Map.of(Aep.AUTHORIZATION_HEADER, List.of("AEP"), AUTHORIZATION, List.of("Bearer opaque")); + case "duplicate-dedicated-lines" -> Map.of(Aep.AUTHORIZATION_HEADER, List.of("AEP first", "AEP second")); + // Two schemes packed into one field value are not two credentials to choose between. + case "combined-dedicated-values" -> Map.of(Aep.AUTHORIZATION_HEADER, List.of("AEP first Bearer second")); + default -> throw new IllegalArgumentException("Unknown authorization ambiguity case: " + name); + }; + } + + private static boolean rejected(Map> headers, String code) { + return rejectedBy( + serviceBuilder(document(List.of(INSPECT), List.of(Aep.AUTHENTICATION_METHOD_JWT))) + .build(), + headers, + code); + } + + private static boolean rejectedBy(AepService service, Map> headers, String code) { ProtectedResourceResult result = service.authenticate( - new ProtectedResourceRequest(headers, "GET", PROTECTED_RESOURCE)) + new ProtectedResourceRequest(headers, GET_METHOD, PROTECTED_RESOURCE)) .toCompletableFuture() .join(); return !result.authenticated() - && "not_recognized".equals(result.response().problem().code()); + && code.equals(result.response().problem().code()); } + // expected.field_name_match is "case-insensitive", so the credential is presented in + // input.field_name exactly as the vector spells it and the Service must find it there. + private static boolean fieldNameSafety(AdapterRequest request) { + if (!"case-insensitive" + .equals(ConformanceSupport.text(request.testCase().expected(), "field_name_match"))) return false; + String fieldName = ConformanceSupport.text(request.testCase().input(), "field_name"); + ProtectedResourceResult result = enrolledService() + .authenticate(new ProtectedResourceRequest( + Map.of(fieldName, List.of("AEP assertion")), GET_METHOD, PROTECTED_RESOURCE)) + .toCompletableFuture() + .join(); + return result.authenticated() && AGENT_DID.equals(result.principal().agentDid()); + } + + // expected.same_origin.credential_forwarded is false: an assertion bound to one resource must not + // open another, even on the same origin. The Service is given an assertion pinned to the source + // and asked to authenticate the redirect target with it. private static boolean redirectSafety(AdapterRequest request) { + JsonNode expected = request.testCase().expected(); + if (ConformanceSupport.required(expected.get("same_origin"), "credential_forwarded") + .asBoolean()) return false; URI source = URI.create(ConformanceSupport.text(request.testCase().input(), "source")); - URI redirect = URI.create(ConformanceSupport.text(request.testCase().input(), "cross_origin")); - return !source.getAuthority().equals(redirect.getAuthority()); + URI sameOrigin = URI.create(ConformanceSupport.text(request.testCase().input(), "same_origin")); + AepService service = boundToResource(source); + ProtectedResourceResult bound = service.authenticate(new ProtectedResourceRequest( + Map.of(AUTHORIZATION, List.of("AEP assertion")), GET_METHOD, source)) + .toCompletableFuture() + .join(); + ProtectedResourceResult forwarded = service.authenticate(new ProtectedResourceRequest( + Map.of(AUTHORIZATION, List.of("AEP forwarded")), GET_METHOD, sameOrigin)) + .toCompletableFuture() + .join(); + return bound.authenticated() + && !forwarded.authenticated() + && "not_recognized".equals(forwarded.response().problem().code()); + } + + /** + * A Service holding an ACTIVE enrollment whose verifier then returns an assertion bound to + * {@code resource} however the request is addressed. Carrying that credential to another URL is + * what "credential_forwarded" means, and the Service has to refuse it. + */ + private static AepService boundToResource(URI resource) { + AtomicReference pinned = new AtomicReference<>(); + AepService service = AepService.builder( + document(List.of(ENROLL, INSPECT), List.of(Aep.AUTHENTICATION_METHOD_JWT)), + (assertion, context) -> completed(claims( + context.operation(), + assertion, + pinned.get() == null ? context.resource() : pinned.get()))) + .clock(Clock.fixed(NOW, ZoneOffset.UTC)) + .identifierSupplier(() -> ENROLLMENT_ID) + .build(); + service.enroll(new EnrollRequest(AGENT_DID, null, null), options(ENROLL, ENROLL)) + .toCompletableFuture() + .join(); + pinned.set(resource.toString()); + return service; + } + + /** A Service with one ACTIVE enrollment, ready to answer a protected-resource request. */ + private static AepService enrolledService() { + AepService service = serviceBuilder(document(List.of(ENROLL, INSPECT), List.of(Aep.AUTHENTICATION_METHOD_JWT))) + .build(); + service.enroll(new EnrollRequest(AGENT_DID, null, null), options(ENROLL, ENROLL)) + .toCompletableFuture() + .join(); + return service; + } + + /** + * A Service holding an ACTIVE enrollment whose verifier then returns an assertion minted for + * {@code minted} whatever operation the Service is checking. That is what an operation + * substitution looks like on the wire: a well-formed assertion carrying the wrong operation. + */ + private static AepService substitutingService(AssertionOperation minted) { + AtomicReference substitution = new AtomicReference<>(); + AepService service = AepService.builder( + document(List.of(ENROLL, INSPECT), List.of(Aep.AUTHENTICATION_METHOD_JWT)), + (assertion, context) -> { + AssertionOperation operation = substitution.get(); + if (operation == null) return completed(claims(context.operation(), assertion, null)); + String resource = operation == AssertionOperation.AUTHENTICATE ? context.resource() : null; + return completed(claims(operation, assertion, resource)); + }) + .clock(Clock.fixed(NOW, ZoneOffset.UTC)) + .identifierSupplier(() -> ENROLLMENT_ID) + .build(); + // Enrollment runs through the same verifier, so the substitution only starts once the Agent + // is ACTIVE and the protected-resource path is the thing under test. + service.enroll(new EnrollRequest(AGENT_DID, null, null), options(ENROLL, ENROLL)) + .toCompletableFuture() + .join(); + substitution.set(minted); + return service; + } + + private static AepHttpRequest get(String path) { + return new AepHttpRequest(GET_METHOD, URI.create("https://service.example" + path), Map.of(), new byte[0]); + } + + private static AepHttpRequest enrollPost(String contentType) { + return new AepHttpRequest( + "POST", + URI.create("https://service.example/aep/enroll"), + Map.of( + "Content-Type", + List.of(contentType), + AUTHORIZATION, + List.of("AEP " + java.util.UUID.randomUUID()), + "Idempotency-Key", + List.of(java.util.UUID.randomUUID().toString())), + ("{\"agent_did\":\"" + AGENT_DID + "\"}").getBytes(java.nio.charset.StandardCharsets.UTF_8)); } private static AepService.Builder serviceBuilder(InspectDocument document) { @@ -221,7 +623,7 @@ private static AepService.Builder serviceBuilder(InspectDocument document) { document, (assertion, context) -> completed(claims(context.operation(), assertion, context.resource()))) .clock(Clock.fixed(NOW, ZoneOffset.UTC)) - .identifierSupplier(() -> "enrollment-1"); + .identifierSupplier(() -> ENROLLMENT_ID); } private static ClientAssertionClaims claims(AssertionOperation operation, String jwtId, String resource) { @@ -243,7 +645,7 @@ private static CommandOptions options(String assertion, String idempotencyKey) { private static InspectDocument document(List commands, List methods) { List grants = commands.contains(GRANT) ? List.of(CUSTOM_SESSION) : List.of(); Map config = commands.contains(GRANT) - ? Map.of(CUSTOM_SESSION, new InspectDocument.GrantTypeConfig("true")) + ? Map.of(CUSTOM_SESSION, new InspectDocument.GrantTypeConfig(SUPPORTED)) : Map.of(); return InspectDocument.builder() .version(Aep.VERSION) @@ -284,7 +686,7 @@ private static CompletableFuture completed(T value) { private static final class Handler implements GrantTypeHandler { @Override public CompletionStage grant(GrantRequest request, GrantContext context) { - return completed(new GrantResult("credential-1", Map.of("credential_id", "credential-1"))); + return completed(new GrantResult(CREDENTIAL_ID, Map.of("credential_id", CREDENTIAL_ID))); } @Override diff --git a/tools/aep-conformance/src/main/java/foundation/aep/conformance/SharedConformance.java b/tools/aep-conformance/src/main/java/foundation/aep/conformance/SharedConformance.java index e98ab15..c198231 100644 --- a/tools/aep-conformance/src/main/java/foundation/aep/conformance/SharedConformance.java +++ b/tools/aep-conformance/src/main/java/foundation/aep/conformance/SharedConformance.java @@ -29,6 +29,7 @@ import java.util.Set; final class SharedConformance { + private static final String AEP_SCHEME = "aepScheme"; private static final Set CLAIM_VALUE_VECTORS = Set.of( "forward-compatible-address", "invalid-address", @@ -213,9 +214,14 @@ private static boolean credentialResponse(AdapterRequest request) { return parsed == expectedValid; } + /** + * Parsing and discarding the result returned true for any input the parser merely tolerated. The + * parsed value is written back out and compared to the vector, so a parser that dropped or + * renamed a member now fails. + */ private static boolean parseInput(AdapterRequest request, java.util.function.Function parser) { - parser.apply(request.testCase().input().toString()); - return true; + Object parsed = parser.apply(request.testCase().input().toString()); + return ConformanceSupport.preserves(parsed, request.testCase().input()); } private static boolean enrollRequest(AdapterRequest request) { @@ -257,9 +263,16 @@ private static boolean problemValidation(AdapterRequest request) { return true; } + /** + * The expected document is the Inspect document the Service publishes. Parsing it and returning + * true accepted any document the parser did not throw on, including one it had silently emptied; + * the parsed document is validated and written back out for comparison instead. + */ private static boolean inspectExpected(AdapterRequest request) { - AepJson.parseInspectDocument(request.testCase().expected().toString()); - return true; + InspectDocument document = + AepJson.parseInspectDocument(request.testCase().expected().toString()); + return AepValidation.inspectDocument(document).isEmpty() + && ConformanceSupport.preserves(document, request.testCase().expected()); } private static boolean defaultEndpointBase(AdapterRequest request) { @@ -404,11 +417,59 @@ private static boolean openApiPath(AdapterRequest request) { } } + /** + * expected names the disposition each of the six inheritance shapes resolves to, and every one + * is put to {@link AepOpenApi#resolveSecurity} (CORE-OAS-011/013/014/015). + */ private static boolean openApiSecurity(AdapterRequest request) { OpenApiAepSecurityScheme scheme = AepJson.parseOpenApiSecurityScheme( ConformanceSupport.required(request.testCase().input(), "security_scheme") .toString()); - return Aep.AUTHENTICATION_METHOD_JWT.equals(scheme.authenticationMethod()); + if (!Aep.AUTHENTICATION_METHOD_JWT.equals(scheme.authenticationMethod())) return false; + List>> root = List.of(Map.of(AEP_SCHEME, List.of())); + List supported = List.of(AEP_SCHEME, "apiKeyScheme"); + JsonNode expected = request.testCase().expected(); + // An operation that omits `security` inherits the root value rather than becoming public. + if (!resolves(expected, "root_inherited", root, null, supported)) return false; + // An empty array at the operation replaces the root requirement and opens the operation. + if (!resolves(expected, "operation_empty_array", root, List.of(), supported)) return false; + // An empty requirement object is one alternative, so anonymous access is permitted. + if (!resolves( + expected, + "empty_requirement_alternative", + root, + List.of(Map.of(), Map.of(AEP_SCHEME, List.of())), + supported)) return false; + // Several requirement objects are alternatives, any one of which suffices. + if (!resolves( + expected, + "multiple_objects", + root, + List.of(Map.of(AEP_SCHEME, List.of()), Map.of("apiKeyScheme", List.of())), + supported)) return false; + // Several schemes in one object are compound: all of them must be presented together. + if (!resolves( + expected, + "multiple_schemes_one_object", + root, + List.of(Map.of(AEP_SCHEME, List.of(), "apiKeyScheme", List.of())), + supported)) return false; + // CORE-OAS-013: a compound requirement with an unsatisfiable member is unsupported as a + // whole, and CORE-OAS-015: it must not be reduced to the member the Agent does support. + List>> compound = List.of(Map.of(AEP_SCHEME, List.of(), "mtlsScheme", List.of())); + AepOpenApi.SecurityResolution unsupported = AepOpenApi.resolveSecurity(root, compound, supported); + return unsupported.satisfiable().isEmpty() + && unsupported.disposition().value().equals(ConformanceSupport.text(expected, "unsupported_compound")); + } + + private static boolean resolves( + JsonNode expected, + String field, + List>> root, + List>> operation, + List supported) { + AepOpenApi.SecurityResolution resolution = AepOpenApi.resolveSecurity(root, operation, supported); + return resolution.disposition().value().equals(ConformanceSupport.text(expected, field)); } private static boolean openApiUrl(AdapterRequest request) { diff --git a/tools/aep-conformance/src/test/java/foundation/aep/conformance/ConformanceFalsifiabilityTest.java b/tools/aep-conformance/src/test/java/foundation/aep/conformance/ConformanceFalsifiabilityTest.java new file mode 100644 index 0000000..e68a6a6 --- /dev/null +++ b/tools/aep-conformance/src/test/java/foundation/aep/conformance/ConformanceFalsifiabilityTest.java @@ -0,0 +1,521 @@ +package foundation.aep.conformance; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.function.UnaryOperator; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.Test; + +/** + * A conformance mapping that cannot fail reports every implementation as conformant, which is worse + * than having no mapping at all. Each case here runs a mapping twice: once against the vector as the + * specification publishes it, and once against the same vector with the expectation corrupted. A + * mapping that reads its expectation flips; a mapping that quietly compares two constants, or two + * fields of the vector to each other, stays true and fails this test. + */ +final class ConformanceFalsifiabilityTest { + private static final String SERVICE = "service"; + private static final String AGENT = "agent"; + private static final String PLATFORM = "platform"; + + @Test + void transportRequirementsReadsTheAdvertisedMediaType() { + falsifiable( + SERVICE, + "inspect", + "transport-requirements", + expected -> expected.put("media_type_essence", "application/json")); + } + + @Test + void paymentCompositionReadsEachComposition() { + falsifiable(SERVICE, "protected-resource", "authorization-payment-composition", expected -> { + ((ObjectNode) expected.get("mpp")).put("ambiguous", true); + return expected; + }); + } + + @Test + void operationSubstitutionReadsTheRejectionCode() { + falsifiable( + SERVICE, + "protected-resource", + "operation-substitution-rejected", + expected -> expected.put("all_other_pairs", "authentication_required")); + } + + @Test + void fieldSafetyReadsTheMatchingRule() { + falsifiable( + SERVICE, + "protected-resource", + "authorization-field-safety", + expected -> expected.put("field_name_match", "case-sensitive")); + } + + @Test + void redirectSafetyReadsTheForwardingRule() { + falsifiable(SERVICE, "protected-resource", "redirect-safety", expected -> { + ((ObjectNode) expected.get("same_origin")).put("credential_forwarded", true); + return expected; + }); + } + + /** The mappings that already read their expectation keep doing so. */ + @Test + void grantBeforeEnrollmentReadsTheProblemCode() { + falsifiable( + SERVICE, + "grant-revoke", + "grant-before-enroll-rejected", + expected -> expected.put("code", "invalid_request")); + } + + @Test + void apiKeyHeaderReadsTheRejectionCode() { + falsifiable( + SERVICE, + "protected-resource", + "api-key-wrong-header-rejected", + expected -> expected.put("code", "not_recognized")); + } + + @Test + void agentTransportReadsEveryRedirectPolicy() { + falsifiable( + AGENT, + "inspect", + "transport-requirements", + expected -> expected.put("cross_origin_redirect", "follow")); + } + + @Test + void agentTransportReadsTheSchemeDowngradePolicy() { + falsifiable(AGENT, "inspect", "transport-requirements", expected -> expected.put("scheme_downgrade", "follow")); + } + + @Test + void agentCommandIdempotencyReadsTheHeaderRequirement() { + falsifiable(AGENT, "idempotency", "command-header", expected -> expected.put("header_required", false)); + } + + @Test + void agentOperationBindingReadsEachAllowedPair() { + falsifiable(AGENT, "protected-resource", "operation-substitution-rejected", expected -> { + ((com.fasterxml.jackson.databind.node.ArrayNode) expected.get("allowed")) + .set(0, expected.textNode("enroll:protected-resource")); + return expected; + }); + } + + @Test + void agentPaymentCompositionReadsEachComposition() { + falsifiable(AGENT, "protected-resource", "authorization-payment-composition", expected -> { + ((ObjectNode) expected.get("x402")).put("ambiguous", true); + return expected; + }); + } + + @Test + void agentFieldSafetyReadsTheSelectedCarrier() { + falsifiable( + AGENT, + "protected-resource", + "authorization-field-safety", + expected -> expected.put("redirect_new_assertion_carrier", "Authorization")); + } + + @Test + void agentRedirectSafetyReadsTheNewAssertionResource() { + falsifiable(AGENT, "protected-resource", "redirect-safety", expected -> { + ((ObjectNode) expected.get("same_origin")) + .put("new_authenticate_assertion_resource", "https://api.example.com/v1/orders/123"); + return expected; + }); + } + + @Test + void agentDiscoveryCacheReadsTheNoStoreRule() { + falsifiable(AGENT, "caching", "public-discovery-cache", expected -> expected.put("no_store", "reuse")); + } + + @Test + void agentDiscoveryCacheReadsTheDefaultFreshness() { + falsifiable( + AGENT, + "caching", + "public-discovery-cache", + expected -> expected.put("default_freshness_seconds", "30")); + } + + @Test + void agentDiscoveryCacheReadsTheCacheKeyRule() { + falsifiable(AGENT, "caching", "public-discovery-cache", expected -> expected.put("cache_key", "requested-url")); + } + + @Test + void agentAmbiguityReadsTheFallbackRule() { + falsifiable(AGENT, "protected-resource", "authorization-ambiguity", expected -> expected.put("fallback", true)); + } + + @Test + void serviceAmbiguityReadsTheRejectionCode() { + falsifiable( + SERVICE, + "protected-resource", + "authorization-ambiguity", + expected -> expected.put("code", "authentication_required")); + } + + @Test + void serviceCredentialFailuresReadEachDistinctCode() { + falsifiable( + SERVICE, + "protected-resource", + "assertion-and-credential-failures", + expected -> expected.put("unsupported_method", "not_recognized")); + } + + @Test + void serviceCredentialFailuresReadTheMissingCredentialCode() { + falsifiable( + SERVICE, + "protected-resource", + "assertion-and-credential-failures", + expected -> expected.put("malformed_credential", "authentication_required")); + } + + @Test + void openApiSecurityReadsTheCompoundDisposition() { + falsifiable( + AGENT, + "openapi", + "security-inheritance", + expected -> expected.put("multiple_schemes_one_object", "required")); + } + + @Test + void openApiSecurityReadsTheUnsupportedCompoundDisposition() { + falsifiable( + AGENT, "openapi", "security-inheritance", expected -> expected.put("unsupported_compound", "required")); + } + + @Test + void openApiSecurityReadsTheInheritanceDisposition() { + falsifiable(AGENT, "openapi", "security-inheritance", expected -> expected.put("root_inherited", "public")); + } + + @Test + void platformIdempotencyReadsTheConflictStatus() { + falsifiable( + PLATFORM, + "platform", + "idempotency-replay-conflict", + expected -> expected.put("changed_input_or_operation_status", 400)); + } + + @Test + void platformIdempotencyReadsTheConflictCode() { + falsifiable( + PLATFORM, + "platform", + "idempotency-replay-conflict", + expected -> expected.put("changed_input_or_operation_code", "invalid_request")); + } + + @Test + void platformRecognizedVerificationReadsTheVerdict() { + falsifiable(PLATFORM, "platform", "verification-response-recognized", expected -> expected.put("op", "status")); + } + + /** + * minimal-http publishes the Inspect document itself rather than an expectation about it, so the + * SDK's job is to parse it and reproduce it. Changing a value corrupts both sides of the + * comparison at once; adding a member the parser must carry through is what the round-trip is + * actually for, and a parser that silently drops it fails. + */ + @Test + void inspectDocumentRoundTripReadsEveryPublishedMember() { + JsonNode vector = Vectors.byId("minimal-http"); + assertTrue( + evaluate(AGENT, "inspect", "minimal-http", vector), + "the mapping rejects the Inspect document the specification publishes"); + ObjectNode corrupted = vector.deepCopy(); + ((ObjectNode) corrupted.get("expected")).put("unpreserved_member", "dropped-by-the-parser"); + assertFalse( + evaluate(AGENT, "inspect", "minimal-http", corrupted), + "the round-trip accepted a document member the parser silently dropped"); + } + + /** + * verification-request's idempotency_key_header is the key the caller sends, not a value the SDK + * produces, so corrupting it only changes the key the mapping uses and cannot falsify anything. + * What the vector does test is that the key is required and that an exact replay returns the + * stored result, which is asserted directly. + */ + @Test + void platformIdempotencyReadsTheRetentionMinimum() { + falsifiable( + PLATFORM, + "platform", + "idempotency-replay-conflict", + expected -> expected.put("retention_seconds_minimum", "36000")); + } + + @Test + void platformVerificationRequiresAKeyAndReplaysIt() { + assertTrue( + evaluate(PLATFORM, "platform", "verification-request", Vectors.byId("verification-request")), + "the Platform does not require an idempotency key or does not replay it exactly"); + } + + /** + * grant-request-oauth-bearer is an input round-trip: the vector's input is the request the SDK + * must parse and reproduce, so corrupting the expectation cannot falsify it. The input is + * corrupted instead, and a parser that drops or renames a member fails. + */ + @Test + void grantRequestRoundTripReadsItsInput() { + JsonNode vector = Vectors.byId("grant-request-oauth-bearer"); + assertTrue( + evaluate(AGENT, "grant-revoke", "grant-request-oauth-bearer", vector), + "the mapping rejects the Grant request the specification publishes"); + ObjectNode corrupted = vector.deepCopy(); + ((ObjectNode) corrupted.get("input")).put("unpreserved_member", "dropped-by-the-parser"); + assertFalse( + evaluate(AGENT, "grant-revoke", "grant-request-oauth-bearer", corrupted), + "the round-trip accepted an input member the parser silently dropped"); + } + + /** + * assertion-and-credential-failures is a pass-through: the codes in it are what a Service + * reports, and the Agent's obligation is to surface each unchanged. Corrupting one code to + * another valid code therefore cannot make a correct mapping fail — the mapping would simply + * carry the corrupted code through too. Its falsifiability is in the SDK, not the expectation, so + * it is asserted directly rather than through the corruption harness. + */ + @Test + void agentCredentialFailuresSurfaceTheServiceCode() { + assertTrue( + evaluate( + AGENT, + "protected-resource", + "assertion-and-credential-failures", + Vectors.byId("assertion-and-credential-failures")), + "the Agent does not carry the Service's Problem Details code through unchanged"); + } + + /** + * The inlined vectors are copies. If the specification moves and this checkout has it, say so + * here rather than letting the copies drift into testing a contract nobody publishes any more. + */ + @Test + void inlinedVectorsMatchThePublishedOnes() { + Path vectors = specVectors(); + Assumptions.assumeTrue( + vectors != null, "aep-specs is not checked out; set AEP_SPECS_DIR to run the drift check"); + for (Vector vector : Vectors.ALL) { + JsonNode published = read(vectors.resolve(vector.category()).resolve(vector.id() + ".json")); + assertTrue( + published.get("input").equals(vector.node().get("input")), + vector.id() + ": the inlined input no longer matches the published vector"); + assertTrue( + published.get("expected").equals(vector.node().get("expected")), + vector.id() + ": the inlined expectation no longer matches the published vector"); + } + } + + private static void falsifiable(String role, String category, String id, UnaryOperator corrupt) { + JsonNode vector = Vectors.byId(id); + assertTrue( + evaluate(role, category, id, vector), + id + ": the mapping rejects the vector the specification publishes"); + + ObjectNode corrupted = vector.deepCopy(); + corrupt.apply((ObjectNode) corrupted.get("expected")); + assertFalse( + evaluate(role, category, id, corrupted), + id + ": the mapping accepted a corrupted expectation, so nothing about the SDK can make it fail"); + } + + private static boolean evaluate(String role, String category, String id, JsonNode vector) { + AdapterRequest request = new AdapterRequest( + 1, + role, + PLATFORM.equals(role) ? "platform-hosted-identity" : "core-http", + "required", + new AdapterRequest.Vector(category, id), + new AdapterRequest.TestCase(vector.get("input"), vector.get("expected"))); + return switch (role) { + case SERVICE -> ServiceConformance.evaluate(request); + case "agent" -> AgentConformance.evaluate(request); + default -> PlatformConformance.evaluate(request); + }; + } + + private static Path specVectors() { + String configured = System.getenv("AEP_SPECS_DIR"); + List candidates = new java.util.ArrayList<>(); + if (configured != null) candidates.add(Path.of(configured)); + candidates.add(Path.of("..", "..", ".conformance", "aep-specs")); + candidates.add(Path.of("..", "..", "..", "aep-specs")); + for (Path candidate : candidates) { + Path vectors = candidate.resolve("ietf").resolve("test-vectors"); + if (Files.isDirectory(vectors)) return vectors; + } + return null; + } + + private static JsonNode read(Path path) { + try { + return ConformanceSupport.MAPPER.readTree(Files.readString(path)); + } catch (java.io.IOException exception) { + throw new IllegalStateException("Unable to read " + path, exception); + } + } + + private record Vector(String category, String id, JsonNode node) {} + + /** Copies of the published vectors, verified against the specification by the drift check. */ + private static final class Vectors { + private static final List ALL = List.of( + vector("inspect", "transport-requirements", """ + {"input": {"request_url": "https://api.example.com/.well-known/aep", + "content_type": "Application/AEP+JSON; charset=utf-8", + "redirect_url": "https://api.example.com/discovery/aep"}, + "expected": {"media_type_essence": "application/aep+json", + "redirect_policy": "same-scheme-host-effective-port", + "cross_origin_redirect": "reject", "scheme_downgrade": "reject", + "partial_document_after_bound": "reject"}}"""), + vector("protected-resource", "authorization-payment-composition", """ + {"input": {"anonymous_status": 401, "anonymous_challenge": "AEP"}, + "expected": {"mpp": {"AEP-Authorization": "AEP compact-jws", + "Authorization": "Payment mpp-credential", "ambiguous": false}, + "x402": {"AEP-Authorization": "Bearer opaque-token", + "PAYMENT-SIGNATURE": "x402-signature", "ambiguous": false}, + "payment_advertised_after_aep": true}}"""), + vector("protected-resource", "operation-substitution-rejected", """ + {"input": {"operations": ["enroll", "grant", "revoke", "status", "authenticate"], + "command_endpoints": ["enroll", "grant", "revoke", "status"], + "protected_resource": "https://api.example.com/v1/orders"}, + "expected": {"allowed": ["enroll:enroll", "grant:grant", "revoke:revoke", + "status:status", "authenticate:protected-resource"], + "all_other_pairs": "not_recognized"}}"""), + vector("protected-resource", "authorization-field-safety", """ + {"input": {"field_name": "aep-authorization", "selected_carrier": "AEP-Authorization"}, + "expected": {"field_name_match": "case-insensitive", + "redirect_new_assertion_carrier": "AEP-Authorization", + "strip_on_disallowed_redirect": ["Authorization", "AEP-Authorization", + "PAYMENT-SIGNATURE", "api-key-header"], + "sensitive_headers_redacted": true, "cache_key_excluded": true, + "idempotency_fingerprint_excluded": true}}"""), + vector("protected-resource", "redirect-safety", """ + {"input": {"source": "https://api.example.com/v1/orders/123", + "same_origin": "https://api.example.com/v1/orders/124", + "cross_origin": "https://other.example.net/v1/orders/123"}, + "expected": {"same_origin": {"credential_forwarded": false, + "new_authenticate_assertion_resource": "https://api.example.com/v1/orders/124"}, + "cross_origin": {"anonymous_restart": true, "assertion_forwarded": false, + "session_credential_forwarded": false, "api_key_header_forwarded": false}}}"""), + vector("idempotency", "command-header", """ + {"input": {"commands": ["enroll", "grant", "revoke"], + "idempotency_key": "9f8a4d2e-1c3b-4f5e-8b7a-000000000000"}, + "expected": {"enroll_body_key": "optional", "header_required": true, + "mismatched_enroll_body_status": 400, "missing_or_empty_code": "invalid_request", + "missing_or_empty_status": 400}}"""), + vector("protected-resource", "assertion-and-credential-failures", """ + {"input": {"cases": ["wrong_audience", "expired_assertion", "replayed_assertion", + "malformed_credential", "expired_credential", "unsupported_method", + "insufficient_scope"]}, + "expected": {"wrong_audience": "not_recognized", "expired_assertion": "not_recognized", + "replayed_assertion": "not_recognized", "malformed_credential": "not_recognized", + "expired_credential": "not_recognized", + "unsupported_method": "unsupported_authentication_method", + "insufficient_scope": "insufficient_scope"}}"""), + vector("grant-revoke", "grant-before-enroll-rejected", """ + {"input": {"operation": "grant", "enrollment": "unrecognized"}, + "expected": {"status": 401, "code": "not_recognized", "implicit_enrollment": false}}"""), + vector("caching", "public-discovery-cache", """ + {"input": {"metadata": ["Cache-Control", "ETag", "Last-Modified"], + "conditional_status": 304}, + "expected": {"default_freshness_seconds": "300", "no_cache": "revalidate", + "no_store": "do-not-persist", "status_304": "reuse-validated-representation", + "cache_key": "final-safe-redirect-url"}}"""), + vector("protected-resource", "authorization-ambiguity", """ + {"input": {"cases": ["both-aep-recognized", "invalid-dedicated-plus-valid-standard", + "duplicate-dedicated-lines", "combined-dedicated-values"]}, + "expected": {"code": "not_recognized", "disclosure": "none", "fallback": false, + "selected_credential": null}}"""), + vector("openapi", "security-inheritance", """ + {"input": {"security_scheme": {"type": "http", "scheme": "AEP", + "x-aep-authentication-method": "aep-jwt"}}, + "expected": {"root_inherited": "required", "operation_empty_array": "public", + "empty_requirement_alternative": "optional-anonymous", + "multiple_objects": "alternatives", + "multiple_schemes_one_object": "all-required", + "unsupported_compound": "fallback-live-challenge"}}"""), + vector("grant-revoke", "grant-request-oauth-bearer", """ + {"input": {"grant_type": "oauth-bearer"}, + "expected": {"method": "POST", "path": "/aep/grant", + "content_type": "application/aep+json", "authorization_scheme": "AEP", + "client_assertion_op": "grant", "body": {"grant_type": "oauth-bearer"}}}"""), + vector("inspect", "minimal-http", """ + {"input": {}, + "expected": {"aep_version": "1.0", + "authentication": {"methods": ["aep-jwt", "oauth-bearer", "api-key", "basic"]}, + "bindings": {"supported": ["http"]}, + "claims": {"optional": [], "preferred": [], "required": ["contact.email"]}, + "commands": {"grant_types": ["oauth-bearer", "api-key", "basic"], + "supported": ["enroll", "grant", "inspect", "revoke", "status"]}, + "core": {"signing_algorithms": ["EdDSA", "ES256"]}, + "extensions": {"supported": []}, + "http": {"endpoint_base": "/aep/", + "openapi": {"path_matching": {"trailing_slash": "strict"}, + "url": "/openapi.json"}}, + "identity": {"methods": ["did:web"]}, + "service": {"did": "did:web:api.example.com"}}}"""), + vector("platform", "idempotency-replay-conflict", """ + {"input": {"principal": "stable-principal-123", + "initial_sign_key": "01J0AEPSIGNINITIAL0000000001", + "final_sign_key": "01J0AEPSIGNFINAL000000000002", + "operations": ["provision", "sign", "hosted_verification"]}, + "expected": {"retention_seconds_minimum": "3600", + "exact_replay": "stored_http_result", + "changed_input_or_operation_status": 409, + "changed_input_or_operation_code": "idempotency_conflict"}}"""), + vector("platform", "verification-request", """ + {"input": {"client_assertion": "eyJhbGciOiJFUzI1NiIsImtpZCI6ImRpZDp3ZWI6cC5leGFtcGxlOmE6NFlmN3AyeFFkOSJ9.eyJhdWQiOiJkaWQ6d2ViOmFwaS5zZXJ2aWNlLmV4YW1wbGUiLCJleHAiOjE3ODM0MjU5MDAsImlhdCI6MTc4MzQyNTYwMCwiaXNzIjoiZGlkOndlYjpwLmV4YW1wbGU6YTo0WWY3cDJ4UWQ5IiwianRpIjoiMDFKMEFFUEFTU0VSVElPTjAwMDAwMDAwMDEiLCJvcCI6ImVucm9sbCIsInN1YiI6ImRpZDp3ZWI6cC5leGFtcGxlOmE6NFlmN3AyeFFkOSJ9.signature", + "op": "enroll", "service_did": "did:web:api.service.example"}, + "expected": {"idempotency_key_header": "01J0AEPVERIFY0000000000001"}}"""), + vector("platform", "verification-response-recognized", """ + {"input": {}, + "expected": {"agent_did": "did:web:p.example:a:4Yf7p2xQd9", + "agent_identity_id": "pai_01J0AEPPLATFORM000000000001", "op": "enroll", + "reason": "verified", "service_did": "did:web:api.service.example", + "status": "active", "verified": true}}"""), + vector("protected-resource", "api-key-wrong-header-rejected", """ + {"input": {"issued_header": "x-api-key", "presented_header": "service-api-key", + "api_key": "opaque-api-key"}, + "expected": {"accepted": false, "code": "authentication_required"}}""")); + + private Vectors() {} + + static JsonNode byId(String id) { + return ALL.stream() + .filter(vector -> vector.id().equals(id)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("No inlined vector: " + id)) + .node(); + } + + private static Vector vector(String category, String id, String json) { + return new Vector(category, id, ConformanceSupport.json(json)); + } + } +}