diff --git a/.github/workflows/interoperability.yml b/.github/workflows/interoperability.yml new file mode 100644 index 0000000..d872981 --- /dev/null +++ b/.github/workflows/interoperability.yml @@ -0,0 +1,66 @@ +name: Node.js interoperability + +on: + pull_request: + paths: + - ".github/workflows/interoperability.yml" + - "Makefile" + - "aep-agent/**" + - "aep-core/**" + - "aep-httpserver/**" + - "aep-json-jackson2/**" + - "aep-platform/**" + - "aep-service/**" + - "examples/**" + - "pom.xml" + - "mvnw" + - ".mvn/**" + - "scripts/run-node-interoperability.sh" + - "scripts/verify-node-interoperability.mjs" + workflow_dispatch: + +permissions: + contents: read + +jobs: + interoperability: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + path: aep-java + + - uses: actions/checkout@v7 + with: + repository: aep-foundation/aep-node + path: aep-node + + - uses: actions/setup-java@v6 + with: + distribution: temurin + java-version: "17" + + - uses: actions/setup-node@v6 + with: + node-version: 24 + + - run: corepack enable + + - name: Install Node.js dependencies + working-directory: aep-node + run: pnpm install --frozen-lockfile + + - name: Run bidirectional interoperability + working-directory: aep-java + run: make interoperability + env: + AEP_INTEROP_OUTPUT_DIR: .interop/reports + AEP_NODE_DIR: ../aep-node + + - name: Upload interoperability report + uses: actions/upload-artifact@v7 + with: + name: aep-java-node-interoperability + path: aep-java/.interop/reports/aep-java-node-interoperability.json + if-no-files-found: error + retention-days: 7 diff --git a/.gitignore b/.gitignore index 18c8197..330b829 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .DS_Store .conformance/ +.interop/ .idea/ .project .classpath diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 29fe0e0..934be9f 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -26,6 +26,15 @@ Format Java sources with: ./mvnw spotless:apply ``` +Run the bidirectional Java and Node.js interoperability flow with adjacent repository checkouts: + +```sh +AEP_NODE_DIR=../aep-node make interoperability +``` + +The flow runs the Java Agent against the Node.js Service and Platform, then the Node.js Agent +against the Java Service and Platform. Its report is written under `.interop/reports/`. + ## Module boundaries `aep-core` owns transport-independent wire contracts, validation, identity and assertion diff --git a/Makefile b/Makefile index e6b3fa1..576ebce 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: clean format format-check verify +.PHONY: clean format format-check interoperability verify clean: ./mvnw clean @@ -9,5 +9,8 @@ format: format-check: ./mvnw spotless:check +interoperability: + ./scripts/run-node-interoperability.sh + verify: ./mvnw verify 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 56791c5..b453c21 100644 --- a/aep-agent/src/main/java/foundation/aep/agent/PlatformIdentityProvider.java +++ b/aep-agent/src/main/java/foundation/aep/agent/PlatformIdentityProvider.java @@ -492,7 +492,7 @@ private void requireUsableIdentity(PlatformAgentIdentity identity, PlatformDisco } URI expected; try { - expected = DidWeb.documentUri(identity.agentDid(), allowInsecureLoopback); + expected = DidWeb.documentUri(identity.agentDid(), false); } catch (IllegalArgumentException exception) { throw new AepAgentException( PLATFORM_IDENTITY_INVALID, "AEP Platform returned an invalid hosted identity", exception); diff --git a/aep-agent/src/test/java/foundation/aep/agent/PlatformIdentityProviderTest.java b/aep-agent/src/test/java/foundation/aep/agent/PlatformIdentityProviderTest.java index 428621e..ae28408 100644 --- a/aep-agent/src/test/java/foundation/aep/agent/PlatformIdentityProviderTest.java +++ b/aep-agent/src/test/java/foundation/aep/agent/PlatformIdentityProviderTest.java @@ -254,6 +254,30 @@ void sharesAnInFlightDiscoveryRequestAcrossConcurrentOperations() { assertEquals(2, listRequests.get()); } + @Test + void acceptsNormativeHttpsDidDocumentsFromALoopbackPlatform() { + String loopbackDid = "did:web:127.0.0.1%3A4310:agents:managed"; + String loopbackDiscovery = discovery() + .replace( + "https://p.example/a/{agent_did_id}/did.json", + "https://127.0.0.1:4310/agents/{agent_did_id}/did.json"); + String loopbackIdentity = identity() + .replace(AGENT_DID, loopbackDid) + .replace("https://p.example/a/4Yf7p2xQd9/did.json", "https://127.0.0.1:4310/agents/managed/did.json"); + QueueTransport transport = new QueueTransport( + aepResponse(200, loopbackDiscovery), + aepResponse(200, "{\"count\":\"1\",\"data\":[" + loopbackIdentity + "],\"total\":\"1\"}")); + PlatformIdentityProvider provider = PlatformIdentityProvider.builder(URI.create("http://127.0.0.1:4310")) + .transport(transport) + .allowInsecureLoopback(true) + .build(); + + AgentIdentity selected = + provider.getOrCreate(SERVICE, SERVICE_DID).toCompletableFuture().join(); + + assertEquals(loopbackDid, selected.did()); + } + @Test void pendingStagesRejectAReusedIdempotencyKeyAndRetryAfterHeader() { QueueTransport duplicateTransport = new QueueTransport( diff --git a/examples/src/main/java/foundation/aep/examples/NodeInteroperability.java b/examples/src/main/java/foundation/aep/examples/NodeInteroperability.java new file mode 100644 index 0000000..73292ac --- /dev/null +++ b/examples/src/main/java/foundation/aep/examples/NodeInteroperability.java @@ -0,0 +1,441 @@ +package foundation.aep.examples; + +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 com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import foundation.aep.agent.AepAgent; +import foundation.aep.agent.AepAgentException; +import foundation.aep.agent.AgentAuthentication; +import foundation.aep.agent.PlatformIdentityProvider; +import foundation.aep.core.Aep; +import foundation.aep.core.AepJson; +import foundation.aep.core.ClaimValues; +import foundation.aep.core.ClientAssertionClaims; +import foundation.aep.core.ClientAssertions; +import foundation.aep.core.GrantResponses; +import foundation.aep.core.InspectDocument; +import foundation.aep.core.ManagedAgentStatus; +import foundation.aep.core.PlatformDiscoveryDocument; +import foundation.aep.core.PlatformLifecycleRequest; +import foundation.aep.core.PlatformProvisionRequest; +import foundation.aep.core.PlatformSignRequest; +import foundation.aep.core.RevokeRequest; +import foundation.aep.httpserver.AepHttpServer; +import foundation.aep.platform.AepPlatform; +import foundation.aep.platform.PlatformDidVerificationMethod; +import foundation.aep.platform.PlatformIdentityListQuery; +import foundation.aep.platform.PlatformIdentityRecord; +import foundation.aep.platform.PlatformKeyStore; +import foundation.aep.platform.PlatformRequestContext; +import foundation.aep.platform.PlatformResponse; +import foundation.aep.service.AepService; +import foundation.aep.service.AepServiceHttpHandler; +import foundation.aep.service.ClientAssertionVerifier; +import foundation.aep.service.ServiceCredentialStore; +import foundation.aep.service.StoredCredentialGrantTypes; +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.URI; +import java.net.URLDecoder; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.temporal.ChronoUnit; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.CompletionStage; + +public final class NodeInteroperability { + private static final String API_KEY_HEADER = "X-API-Key"; + private static final String COMMAND_AGENT = "agent"; + private static final String COMMAND_SERVER = "server"; + private static final String HTTP_GET = "GET"; + private static final String HTTP_POST = "POST"; + private static final String PLATFORM_AUTHORIZATION = "Bearer demo-agent"; + private static final String PLATFORM_IDENTITIES = "/platform/agent-identities"; + private static final int MAXIMUM_BODY_BYTES = 65_536; + + private NodeInteroperability() {} + + public static void main(String[] arguments) throws Exception { + if (arguments.length == 0) throw new IllegalArgumentException("Expected agent or server."); + if (COMMAND_AGENT.equals(arguments[0])) { + runAgent(arguments); + return; + } + if (COMMAND_SERVER.equals(arguments[0])) { + runServer(arguments); + return; + } + throw new IllegalArgumentException("Unknown interoperability command: " + arguments[0]); + } + + private static void runAgent(String... arguments) throws IOException, InterruptedException { + URI platformOrigin = URI.create(argument(arguments, "--platform-url")); + URI serviceOrigin = URI.create(argument(arguments, "--service-url")); + JdkAepTransport transport = new JdkAepTransport(); + PlatformIdentityProvider identities = PlatformIdentityProvider.builder(platformOrigin) + .transport(transport) + .authorization(PLATFORM_AUTHORIZATION) + .allowInsecureLoopback(true) + .build(); + AepAgent agent = AepAgent.builder() + .inspectTransport(transport) + .commandTransport(transport) + .identityProvider(identities) + .allowInsecureLoopback(true) + .build(); + var session = agent.service(serviceOrigin); + var inspection = session.inspect().join(); + var enrollment = session.enroll(ClaimValues.builder().build()).join(); + var grant = session.grant(Aep.GRANT_TYPE_API_KEY, List.of("read:resource", "write:profile")) + .join(); + String credentialId = grant.credential().orElseThrow().credentialId(); + URI resource = serviceOrigin.resolve("/api/resource"); + AgentAuthentication authentication = session.authenticate(resource).join(); + HttpResponse protectedResponse = send(transport, resource, HTTP_GET, null, authentication); + session.revoke(RevokeRequest.credential(Aep.GRANT_TYPE_API_KEY, credentialId)) + .join(); + boolean revoked = false; + try { + session.authenticate(resource).join(); + } catch (CompletionException exception) { + if (exception.getCause() instanceof AepAgentException agentException + && "authentication_unavailable".equals(agentException.code())) { + revoked = true; + } else { + throw exception; + } + } + Map result = new LinkedHashMap<>(); + result.put("agent", "java"); + result.put("credential_mode", grant.grantType()); + result.put("enrollment", enrollment.status().value()); + result.put("platform", "node"); + result.put("protected_resource_status", protectedResponse.statusCode()); + result.put("revoked", revoked); + result.put("service", "node"); + result.put("service_did", inspection.document().service().did()); + System.out.println(AepJson.write(result)); // NOPMD - Interoperability process output. + } + + private static void runServer(String... arguments) throws IOException, InterruptedException { + String listen = argument(arguments, "--listen"); + int separator = listen.lastIndexOf(':'); + String host = listen.substring(0, separator); + int port = Integer.parseInt(listen.substring(separator + 1)); + String encodedHost = listen.replace(":", "%3A"); + String serviceDid = "did:web:" + encodedHost + ":services:store"; + InteroperabilityKeyStore keys = new InteroperabilityKeyStore(); + AepPlatform platform = platform(listen, serviceDid, keys); + AepServiceHttpHandler service = service(serviceDid, keys); + HttpServer server = HttpServer.create(new InetSocketAddress(host, port), 0); + AepHttpServer.register(server, service); + URI origin = URI.create("http://" + listen); + server.createContext( + "/api/resource", + AepHttpServer.protect( + service, + origin, + exchange -> json(exchange, 200, "application/json", Map.of("available", true)))); + server.createContext( + "/api/profile", + AepHttpServer.protect( + service, origin, exchange -> json(exchange, 200, "application/json", Map.of("updated", true)))); + server.createContext( + "/services/store/did.json", + exchange -> json( + exchange, + 200, + Aep.DID_MEDIA_TYPE, + Map.of("@context", List.of("https://www.w3.org/ns/did/v1"), "id", serviceDid))); + server.createContext("/.well-known/aep-platform", exchange -> write(exchange, platform.discovery())); + server.createContext(PLATFORM_IDENTITIES, exchange -> platform(exchange, platform)); + server.createContext("/agents", exchange -> didDocument(exchange, platform)); + server.createContext("/health", exchange -> json(exchange, 200, "application/json", Map.of("ok", true))); + server.start(); + new java.util.concurrent.CountDownLatch(1).await(); + } + + private static AepServiceHttpHandler service(String serviceDid, InteroperabilityKeyStore keys) { + InspectDocument.GrantTypeConfig apiKeyConfig = new InspectDocument.GrantTypeConfig( + null, + "3600", + List.of(API_KEY_HEADER), + null, + List.of("read:resource", "write:profile"), + "true", + null, + null); + InspectDocument document = InspectDocument.builder() + .version(Aep.VERSION) + .authentication(new InspectDocument.Authentication(List.of(Aep.GRANT_TYPE_API_KEY))) + .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", "revoke", "status"), + List.of(Aep.GRANT_TYPE_API_KEY), + Map.of(Aep.GRANT_TYPE_API_KEY, apiKeyConfig))) + .core(new InspectDocument.Core(Aep.REQUIRED_SIGNING_ALGORITHMS)) + .http(new InspectDocument.Http("/aep/", null)) + .identity(new InspectDocument.Identity(List.of(Aep.IDENTITY_METHOD_DID_WEB))) + .service(new InspectDocument.Service(serviceDid)) + .build(); + ServiceCredentialStore credentials = ServiceCredentialStore.inMemory(); + var apiKey = StoredCredentialGrantTypes.apiKey( + apiKeyConfig, + (request, context) -> CompletableFuture.completedFuture(new GrantResponses.ApiKey( + UUID.randomUUID().toString(), + UUID.randomUUID().toString(), + context.now().plus(1, ChronoUnit.HOURS).toString(), + API_KEY_HEADER, + request.requestedScopes())), + credentials); + AepService protocol = AepService.builder( + document, + ClientAssertionVerifier.withKeyResolver( + (assertion, claims, context) -> keys.publicKey(claims.issuer()))) + .allowInsecureLoopback(true) + .storedCredentialGrantType(apiKey) + .build(); + return new AepServiceHttpHandler(protocol); + } + + private static AepPlatform platform(String host, String serviceDid, InteroperabilityKeyStore keys) { + PlatformDiscoveryDocument discovery = new PlatformDiscoveryDocument( + Aep.VERSION, + new PlatformDiscoveryDocument.Endpoints( + null, + PLATFORM_IDENTITIES + "/{agent_identity_id}", + PLATFORM_IDENTITIES, + PLATFORM_IDENTITIES, + PLATFORM_IDENTITIES + "/{agent_identity_id}/sign"), + new PlatformDiscoveryDocument.Http("/platform/"), + new PlatformDiscoveryDocument.Identity( + List.of(Aep.IDENTITY_METHOD_DID_WEB), "https://" + host + "/agents/{agent_did_id}/did.json"), + new PlatformDiscoveryDocument.Platform("did:web:" + host.replace(":", "%3A"), false, "Java Platform"), + new PlatformDiscoveryDocument.Signing(List.of("ES256"), "300")); + return AepPlatform.builder( + discovery, + host, + (request, context) -> CompletableFuture.completedFuture( + context != null && "demo-agent".equals(context.principal())), + keys, + (candidate, context) -> CompletableFuture.completedFuture(serviceDid.equals(candidate))) + .build(); + } + + private static void platform(HttpExchange exchange, AepPlatform platform) throws IOException { + String path = exchange.getRequestURI().getRawPath(); + String root = PLATFORM_IDENTITIES; + PlatformRequestContext context = platformContext(exchange); + if (root.equals(path)) { + if (HTTP_GET.equals(exchange.getRequestMethod())) { + write( + exchange, + platform.list(listQuery(exchange.getRequestURI().getRawQuery()), context) + .toCompletableFuture() + .join()); + return; + } + if (HTTP_POST.equals(exchange.getRequestMethod())) { + PlatformProvisionRequest request = AepJson.parsePlatformProvisionRequest(body(exchange)); + write( + exchange, + platform.provision(request, context) + .toCompletableFuture() + .join()); + return; + } + } + String remainder = path.startsWith(root + "/") ? path.substring(root.length() + 1) : ""; + if (remainder.endsWith("/sign") && HTTP_POST.equals(exchange.getRequestMethod())) { + String identity = decode(remainder.substring(0, remainder.length() - "/sign".length())); + PlatformSignRequest request = AepJson.parsePlatformSignRequest(body(exchange)); + write( + exchange, + platform.sign(identity, request, context) + .toCompletableFuture() + .join()); + return; + } + if (!remainder.isEmpty() && HTTP_GET.equals(exchange.getRequestMethod())) { + write( + exchange, + platform.getIdentity(decode(remainder), context) + .toCompletableFuture() + .join()); + return; + } + if (!remainder.isEmpty() && "PATCH".equals(exchange.getRequestMethod())) { + PlatformLifecycleRequest request = AepJson.parsePlatformLifecycleRequest(body(exchange)); + write( + exchange, + platform.updateIdentity(decode(remainder), request, context) + .toCompletableFuture() + .join()); + return; + } + exchange.sendResponseHeaders(405, -1); + exchange.close(); + } + + private static void didDocument(HttpExchange exchange, AepPlatform platform) throws IOException { + String prefix = "/agents/"; + String path = exchange.getRequestURI().getRawPath(); + if (!HTTP_GET.equals(exchange.getRequestMethod()) || !path.startsWith(prefix) || !path.endsWith("/did.json")) { + exchange.sendResponseHeaders(404, -1); + exchange.close(); + return; + } + String agent = decode(path.substring(prefix.length(), path.length() - "/did.json".length())); + write( + exchange, + platform.getDidDocument(agent, new PlatformRequestContext(null, null)) + .toCompletableFuture() + .join()); + } + + private static PlatformIdentityListQuery listQuery(String rawQuery) { + Map values = query(rawQuery); + ManagedAgentStatus status = + values.containsKey("status") ? ManagedAgentStatus.fromValue(values.get("status")) : null; + return new PlatformIdentityListQuery( + Boolean.parseBoolean(values.getOrDefault("descending", "false")), + Integer.parseInt(values.getOrDefault("limit", "100")), + Integer.parseInt(values.getOrDefault("offset", "0")), + values.get("service_did"), + status); + } + + private static PlatformRequestContext platformContext(HttpExchange exchange) { + String authorization = exchange.getRequestHeaders().getFirst("Authorization"); + String principal = PLATFORM_AUTHORIZATION.equals(authorization) ? "demo-agent" : null; + return new PlatformRequestContext( + principal, exchange.getRequestHeaders().getFirst("Idempotency-Key")); + } + + private static HttpResponse send( + JdkAepTransport transport, URI uri, String method, String body, AgentAuthentication authentication) + throws IOException, InterruptedException { + HttpRequest.Builder request = HttpRequest.newBuilder(uri); + authentication.headers().forEach((name, values) -> values.forEach(value -> request.header(name, value))); + request.method( + method, body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(body)); + return transport.send(request.build()); + } + + private static void write(HttpExchange exchange, PlatformResponse response) throws IOException { + response.headers() + .forEach((name, values) -> + values.forEach(value -> exchange.getResponseHeaders().add(name, value))); + Object body = response.body() == null ? response.problem() : response.body(); + json(exchange, response.status(), response.contentType(), body); + } + + private static void json(HttpExchange exchange, int status, String contentType, Object value) throws IOException { + byte[] encoded = AepJson.write(value).getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", contentType); + exchange.sendResponseHeaders(status, encoded.length); + try (exchange; + var output = exchange.getResponseBody()) { + output.write(encoded); + } + } + + private static String body(HttpExchange exchange) throws IOException { + byte[] bytes = exchange.getRequestBody().readNBytes(MAXIMUM_BODY_BYTES + 1); + if (bytes.length > MAXIMUM_BODY_BYTES) throw new IllegalArgumentException("Request body is too large."); + return new String(bytes, StandardCharsets.UTF_8); + } + + private static Map query(String rawQuery) { + Map values = new LinkedHashMap<>(); + if (rawQuery == null || rawQuery.isEmpty()) return values; + for (String pair : rawQuery.split("&")) { + int separator = pair.indexOf('='); + String name = separator < 0 ? pair : pair.substring(0, separator); + String value = separator < 0 ? "" : pair.substring(separator + 1); + values.put(decode(name), decode(value)); + } + return values; + } + + private static String decode(String value) { + return URLDecoder.decode(value, StandardCharsets.UTF_8); + } + + private static String argument(String[] arguments, String name) { + for (int index = 1; index + 1 < arguments.length; index++) { + if (name.equals(arguments[index])) return arguments[index + 1]; + } + throw new IllegalArgumentException("Missing argument: " + name); + } + + private static final class InteroperabilityKeyStore implements PlatformKeyStore { + private final Map byIdentity = new java.util.concurrent.ConcurrentHashMap<>(); + private final Map byAgentDid = new java.util.concurrent.ConcurrentHashMap<>(); + + @Override + public CompletionStage create(PlatformIdentityRecord identity, PlatformRequestContext context) { + ECKey key = generate(identity.keyId()); + byIdentity.put(identity.agentIdentityId(), key); + byAgentDid.put(identity.agentDid(), key); + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletionStage didVerificationMethod( + PlatformIdentityRecord identity, PlatformRequestContext context) { + PlatformDidVerificationMethod method = new PlatformDidVerificationMethod( + identity.agentDid(), + identity.keyId(), + key(identity.agentIdentityId()).toPublicJWK().toJSONObject(), + "JsonWebKey2020"); + return CompletableFuture.completedStage(method); + } + + @Override + public CompletionStage sign( + PlatformIdentityRecord identity, ClientAssertionClaims claims, PlatformRequestContext context) { + ECKey key = key(identity.agentIdentityId()); + String assertion = ClientAssertions.sign(claims, key, identity.keyId(), true); + return CompletableFuture.completedStage(assertion); + } + + @Override + public CompletionStage verificationKey(PlatformIdentityRecord identity, PlatformRequestContext context) { + JWK publicKey = key(identity.agentIdentityId()).toPublicJWK(); + return CompletableFuture.completedStage(publicKey); + } + + CompletionStage publicKey(String agentDid) { + ECKey key = byAgentDid.get(agentDid); + return key == null + ? CompletableFuture.failedFuture(new IllegalArgumentException("Unknown Agent DID.")) + : CompletableFuture.completedFuture(key.toPublicJWK()); + } + + private ECKey key(String identityId) { + ECKey key = byIdentity.get(identityId); + if (key == null) throw new IllegalArgumentException("Unknown Agent identity."); + return key; + } + + private static ECKey generate(String id) { + try { + return new ECKeyGenerator(Curve.P_256).keyID(id).generate(); + } catch (JOSEException exception) { + throw new IllegalStateException("Unable to create interoperability key.", exception); + } + } + } +} diff --git a/scripts/run-node-interoperability.sh b/scripts/run-node-interoperability.sh new file mode 100755 index 0000000..a1fb52d --- /dev/null +++ b/scripts/run-node-interoperability.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env bash +set -euo pipefail + +repository_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +node_repository="${AEP_NODE_DIR:-${repository_root}/../aep-node}" +output_directory="${AEP_INTEROP_OUTPUT_DIR:-${repository_root}/.interop/reports}" +work_directory="${repository_root}/.interop/work" +node_platform_port="${AEP_NODE_PLATFORM_PORT:-4310}" +node_service_port="${AEP_NODE_SERVICE_PORT:-4300}" +java_server_port="${AEP_JAVA_SERVER_PORT:-4330}" + +wait_for_url() { + local url="$1" + local process="$2" + local name="$3" + local log_file="$4" + for _ in {1..120}; do + if curl --fail --silent --show-error "${url}" >/dev/null 2>&1; then + return 0 + fi + if ! kill -0 "${process}" 2>/dev/null; then + echo "${name} exited before it became ready." >&2 + sed -n '1,240p' "${log_file}" >&2 + return 1 + fi + sleep 0.25 + done + echo "${name} did not become ready." >&2 + sed -n '1,240p' "${log_file}" >&2 + return 1 +} + +run_java() { + java -cp "${java_classpath}" foundation.aep.examples.NodeInteroperability "$@" +} + +if [[ ! -f "${node_repository}/package.json" ]]; then + echo "AEP_NODE_DIR must identify an aep-node checkout." >&2 + exit 1 +fi + +mkdir -p "${output_directory}" "${work_directory}" +"${repository_root}/mvnw" --batch-mode --no-transfer-progress -DskipTests install +"${repository_root}/mvnw" --quiet --no-transfer-progress \ + -f "${repository_root}/examples/pom.xml" \ + -Dmdep.outputFile="${work_directory}/java-classpath.txt" \ + dependency:build-classpath +java_classpath="${repository_root}/examples/target/classes:$(<"${work_directory}/java-classpath.txt")" +( + cd "${node_repository}" + corepack pnpm build +) + +processes=() +cleanup() { + for process in "${processes[@]:-}"; do + kill "${process}" 2>/dev/null || true + wait "${process}" 2>/dev/null || true + done +} +trap cleanup EXIT + +PUBLIC_BASE_URL="http://127.0.0.1:${node_platform_port}" \ + DID_HOST="127.0.0.1:${node_platform_port}" \ + PORT="${node_platform_port}" \ + node "${node_repository}/examples/aep-platform-ephemeral/dist/index.js" \ + >"${work_directory}/node-platform.log" 2>&1 & +node_platform_process=$! +processes+=("${node_platform_process}") + +SERVICE_DID="did:web:127.0.0.1%3A${node_service_port}:services:example-service" \ + PORT="${node_service_port}" \ + node "${node_repository}/examples/aep-service-credential-api-key/dist/index.js" \ + >"${work_directory}/node-service.log" 2>&1 & +node_service_process=$! +processes+=("${node_service_process}") + +wait_for_url "http://127.0.0.1:${node_platform_port}/health" "${node_platform_process}" "Node Platform" "${work_directory}/node-platform.log" +wait_for_url "http://127.0.0.1:${node_service_port}/.well-known/aep" "${node_service_process}" "Node Service" "${work_directory}/node-service.log" + +run_java agent \ + --platform-url "http://127.0.0.1:${node_platform_port}" \ + --service-url "http://127.0.0.1:${node_service_port}" \ + >"${work_directory}/java-agent-node.json" + +cleanup +processes=() + +run_java server --listen "127.0.0.1:${java_server_port}" \ + >"${work_directory}/java-server.log" 2>&1 & +java_server_process=$! +processes+=("${java_server_process}") + +wait_for_url "http://127.0.0.1:${java_server_port}/health" "${java_server_process}" "Java Server" "${work_directory}/java-server.log" + +PLATFORM_URL="http://127.0.0.1:${java_server_port}" \ + SERVICE_URL="http://127.0.0.1:${java_server_port}" \ + node "${node_repository}/examples/aep-agent-did-web-grant-status-revoke/dist/index.js" \ + >"${work_directory}/node-agent-java.json" + +node "${repository_root}/scripts/verify-node-interoperability.mjs" \ + "${work_directory}/java-agent-node.json" \ + "${work_directory}/node-agent-java.json" \ + "${output_directory}/aep-java-node-interoperability.json" + +echo "Interoperability report: ${output_directory}/aep-java-node-interoperability.json" diff --git a/scripts/verify-node-interoperability.mjs b/scripts/verify-node-interoperability.mjs new file mode 100644 index 0000000..21c1c83 --- /dev/null +++ b/scripts/verify-node-interoperability.mjs @@ -0,0 +1,71 @@ +import { readFile, writeFile } from "node:fs/promises"; + +const [javaResultPath, nodeResultPath, reportPath] = process.argv.slice(2); + +if (javaResultPath === undefined || nodeResultPath === undefined || reportPath === undefined) { + throw new Error("usage: verify-node-interoperability.mjs JAVA_RESULT NODE_RESULT REPORT"); +} + +const javaResult = JSON.parse(await readFile(javaResultPath, "utf8")); +const nodeResult = JSON.parse(await readFile(nodeResultPath, "utf8")); + +requireEqual(javaResult.agent, "java", "Java Agent identity"); +requireEqual(javaResult.service, "node", "Java Agent Service counterpart"); +requireEqual(javaResult.platform, "node", "Java Agent Platform counterpart"); +requireEqual(javaResult.enrollment, "active", "Java Agent enrollment"); +requireEqual(javaResult.credential_mode, "api-key", "Java Agent credential mode"); +requireEqual(javaResult.protected_resource_status, 200, "Java Agent protected resource"); +requireEqual(javaResult.revoked, true, "Java Agent credential revocation"); + +requireEqual(nodeResult.credentialMode, "api-key", "Node Agent credential mode"); +requireEqual(nodeResult.enroll?.status, "active", "Node Agent enrollment"); +requireEqual(nodeResult.statusBeforeGrant?.status, "active", "Node Agent pre-Grant status"); +requireEqual(nodeResult.statusAfterRevoke?.status, "active", "Node Agent post-Revoke status"); +requireEqual(nodeResult.resource?.available, true, "Node Agent protected resource"); +requireEqual(nodeResult.profile?.updated, true, "Node Agent protected profile"); +requireEqual(typeof nodeResult.grant?.credential_id, "string", "Node Agent credential identifier type"); +requireEqual(nodeResult.grant?.credential_id.length > 0, true, "Node Agent credential identifier"); +requireEqual(Object.keys(nodeResult.revoke ?? {}).length, 0, "Node Agent Revoke response"); + +const report = { + aep_version: "1.0", + evidence: [ + { + agent: "java", + counterpart: "node", + flow: "Inspect, Enroll, Grant, protected resource, Revoke", + role: "service", + status: "passed" + }, + { + agent: "java", + counterpart: "node", + flow: "Discovery, List, Provision, delegated Sign", + role: "platform", + status: "passed" + }, + { + agent: "node", + counterpart: "java", + flow: "Inspect, Enroll, Grant, protected resource, Revoke", + role: "service", + status: "passed" + }, + { + agent: "node", + counterpart: "java", + flow: "Discovery, List, Provision, delegated Sign", + role: "platform", + status: "passed" + } + ], + status: "passed" +}; + +await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`, "utf8"); + +function requireEqual(actual, expected, name) { + if (!Object.is(actual, expected)) { + throw new Error(`${name}: expected ${JSON.stringify(expected)}, received ${JSON.stringify(actual)}`); + } +}