diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 3943932..6c200d6 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -31,7 +31,7 @@ jobs: uses: ./.github/actions/setup-java - name: Install protoc-gen-slimrpc-java - run: cargo install agntcy-protoc-slimrpc-plugin --version 2.0.0 --locked + run: cargo install agntcy-protoc-slimrpc-plugin --version 3.0.0 --locked - uses: bufbuild/buf-setup-action@a47c93e0b1648d5651a065437926377d060baa99 # v1.50.0 with: diff --git a/.github/workflows/codeql.yaml b/.github/workflows/codeql.yaml index 6a3a973..7a0a7d2 100644 --- a/.github/workflows/codeql.yaml +++ b/.github/workflows/codeql.yaml @@ -40,7 +40,7 @@ jobs: uses: ./.github/actions/setup-java - name: Install protoc-gen-slimrpc-java - run: cargo install agntcy-protoc-slimrpc-plugin --version 2.0.0 --locked + run: cargo install agntcy-protoc-slimrpc-plugin --version 3.0.0 --locked - uses: bufbuild/buf-setup-action@a47c93e0b1648d5651a065437926377d060baa99 # v1.50.0 with: diff --git a/README.md b/README.md index f319bce..7a15cec 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ slim-a2a-java/ The library depends on: -- `io.agntcy.slim:slim-bindings-java` 2.0.x -- SLIM Java bindings (Foreign Function & Memory API based) +- `io.agntcy.slim:slim-bindings-java` 3.3.x -- SLIM Java bindings (Foreign Function & Memory API based) - `org.a2aproject.sdk:a2a-java-sdk-server-common` -- A2A SDK server core - `org.a2aproject.sdk:a2a-java-sdk-spec-grpc` -- A2A protobuf types and ProtoUtils mappers @@ -71,13 +71,13 @@ The generated stubs are checked in under `slim-a2a/generated/slimrpc/`. To regen task generate ``` -This requires `protoc-gen-slimrpc-java` 2.0.x on your PATH: +This requires `protoc-gen-slimrpc-java` 3.0.x on your PATH: ```bash -cargo install agntcy-protoc-slimrpc-plugin --version 2.0.0 --locked +cargo install agntcy-protoc-slimrpc-plugin --version 3.0.0 --locked ``` -Older plugin versions emit imports from the pre-2.0 package layout and will not compile against `slim-bindings-java` 2.0. +Plugin versions older than 2.0 emit imports from the pre-2.0 package layout and will not compile against `slim-bindings-java` 3.x. ## Running the Echo Agent Example @@ -164,9 +164,28 @@ Every member of a SLIM session needs a compatible identity type, so the server a its clients must agree on the provider/verifier kind. `SlimHelper.sharedSecretProvider` and `sharedSecretVerifier` build the shared-secret configs used by the shortcut overloads. -With slim-bindings-java 2.0, the SlimRPC handler context does not expose the caller's -verified identity, so `SlimA2AHandler` still reports an anonymous user to the A2A -`RequestHandler`. +### Caller identity + +`SlimA2AHandler` passes the caller's verified SLIM identity to the A2A `RequestHandler` +as the `ServerCallContext` user. The identity comes from the MLS session token +(`Context.peerSubject()` / `peerIssuer()`), never from request metadata. When the +token has a subject, the user is a `SlimUser`. Otherwise the user is anonymous and +unauthenticated: + +```java +@Override +public void execute(RequestContext context, AgentEmitter emitter) { + User user = context.getCallContext().getUser(); + if (user instanceof SlimUser caller) { + String subject = caller.getSubject(); // token `sub`, also user.getUsername() + String issuer = caller.getIssuer(); // token `iss`, or null + } +} +``` + +`getAttribute("sub")` and `getAttribute("iss")` return the same values. In group +sessions the subject is that of the MLS sender of the request. With a shared secret, +the subject is self-asserted: anyone who holds the secret can claim any name. ## Architecture diff --git a/examples/echo-agent/src/main/java/io/agntcy/slim/a2a/examples/echo/EchoAgentExecutor.java b/examples/echo-agent/src/main/java/io/agntcy/slim/a2a/examples/echo/EchoAgentExecutor.java index 90e93fb..5d20370 100644 --- a/examples/echo-agent/src/main/java/io/agntcy/slim/a2a/examples/echo/EchoAgentExecutor.java +++ b/examples/echo-agent/src/main/java/io/agntcy/slim/a2a/examples/echo/EchoAgentExecutor.java @@ -36,7 +36,11 @@ public final class EchoAgentExecutor implements AgentExecutor { @Override public void execute(RequestContext context, AgentEmitter emitter) throws A2AError { String userInput = context.getUserInput(); - System.out.println("[EchoAgent] request contextId=" + context.getContextId() + var caller = context.getCallContext() != null ? context.getCallContext().getUser() : null; + System.out.println("[EchoAgent] request caller=" + + (caller != null ? caller.getUsername() : "unknown") + + " authenticated=" + (caller != null && caller.isAuthenticated()) + + " contextId=" + context.getContextId() + " taskId=" + emitter.getTaskId() + " userText=" + userInput); if (userInput.startsWith(STREAM_PREFIX)) { diff --git a/pom.xml b/pom.xml index abec57b..91aa735 100644 --- a/pom.xml +++ b/pom.xml @@ -44,7 +44,7 @@ 21 21 - 2.0.0 + 3.3.2 1.1.0.Final 4.33.2 5.14.0 diff --git a/slim-a2a/src/main/java/io/agntcy/slim/a2a/NativeLibraryLoader.java b/slim-a2a/src/main/java/io/agntcy/slim/a2a/NativeLibraryLoader.java index 5a2f6e3..2ee7bc1 100644 --- a/slim-a2a/src/main/java/io/agntcy/slim/a2a/NativeLibraryLoader.java +++ b/slim-a2a/src/main/java/io/agntcy/slim/a2a/NativeLibraryLoader.java @@ -18,7 +18,7 @@ * it, restoring the "just works" behavior the JNA-based 1.3.x loader used to * provide. * - *

slim-bindings-java 2.0 exposes two UniFFI namespaces — {@code slim_bindings} + *

slim-bindings-java 2.0 and later expose two UniFFI namespaces — {@code slim_bindings} * for the transport and {@code slim_rpc} for SlimRPC — that are compiled into the * same shared object, so both overrides are pointed at the same extracted file. */ diff --git a/slim-a2a/src/main/java/io/agntcy/slim/a2a/SlimA2AHandler.java b/slim-a2a/src/main/java/io/agntcy/slim/a2a/SlimA2AHandler.java index 0cd3cd9..91513a8 100644 --- a/slim-a2a/src/main/java/io/agntcy/slim/a2a/SlimA2AHandler.java +++ b/slim-a2a/src/main/java/io/agntcy/slim/a2a/SlimA2AHandler.java @@ -10,6 +10,7 @@ import java.util.concurrent.Flow; import io.agntcy.slim.bindings.slimrpc.Context; +import io.agntcy.slim.bindings.slimrpc.ContextInterface; import io.agntcy.slim.bindings.slimrpc.ResponseSink; import io.agntcy.slim.bindings.slimrpc.RpcCode; import io.agntcy.slim.bindings.slimrpc.RpcException; @@ -24,6 +25,10 @@ /** * Adapts a {@link RequestHandler} to the generated {@link A2AServiceSlimrpc.A2AServiceServer} * contract, bridging A2A SDK domain types and protobuf wire types via {@link ProtoUtils}. + * + *

Each request's {@link ServerCallContext} user is the caller's verified SLIM + * identity ({@link SlimUser}) when the session token carries a subject, and an + * unauthenticated anonymous user otherwise. */ public final class SlimA2AHandler implements A2AServiceSlimrpc.A2AServiceServer { @@ -45,7 +50,7 @@ public CompletableFuture SendMessag return CompletableFuture.supplyAsync(() -> { try { var params = ProtoUtils.FromProto.messageSendParams(request); - var result = requestHandler.onMessageSend(params, newCallContext()); + var result = requestHandler.onMessageSend(params, newCallContext(context)); return ProtoUtils.ToProto.taskOrMessage(result); } catch (A2AError e) { throw new CompletionException(A2ARpcErrorMapping.toRpc(e)); @@ -60,7 +65,7 @@ public CompletableFuture SendStreamingMessage( try { var params = ProtoUtils.FromProto.messageSendParams(request); Flow.Publisher publisher = - requestHandler.onMessageSendStream(params, newCallContext()); + requestHandler.onMessageSendStream(params, newCallContext(context)); streamToSink(publisher, sink); } catch (A2AError e) { throw new CompletionException(A2ARpcErrorMapping.toRpc(e)); @@ -74,7 +79,7 @@ public CompletableFuture GetTask( return CompletableFuture.supplyAsync(() -> { try { var params = ProtoUtils.FromProto.taskQueryParams(request); - var task = requestHandler.onGetTask(params, newCallContext()); + var task = requestHandler.onGetTask(params, newCallContext(context)); return ProtoUtils.ToProto.task(task); } catch (A2AError e) { throw new CompletionException(A2ARpcErrorMapping.toRpc(e)); @@ -88,7 +93,7 @@ public CompletableFuture ListTasks( return CompletableFuture.supplyAsync(() -> { try { var params = ProtoUtils.FromProto.listTasksParams(request); - var result = requestHandler.onListTasks(params, newCallContext()); + var result = requestHandler.onListTasks(params, newCallContext(context)); return ProtoUtils.ToProto.listTasksResult(result); } catch (A2AError e) { throw new CompletionException(A2ARpcErrorMapping.toRpc(e)); @@ -102,7 +107,7 @@ public CompletableFuture CancelTask( return CompletableFuture.supplyAsync(() -> { try { var params = ProtoUtils.FromProto.cancelTaskParams(request); - var task = requestHandler.onCancelTask(params, newCallContext()); + var task = requestHandler.onCancelTask(params, newCallContext(context)); return ProtoUtils.ToProto.task(task); } catch (A2AError e) { throw new CompletionException(A2ARpcErrorMapping.toRpc(e)); @@ -117,7 +122,7 @@ public CompletableFuture SubscribeToTask( try { var params = ProtoUtils.FromProto.taskIdParams(request); Flow.Publisher publisher = - requestHandler.onSubscribeToTask(params, newCallContext()); + requestHandler.onSubscribeToTask(params, newCallContext(context)); streamToSink(publisher, sink); } catch (A2AError e) { throw new CompletionException(A2ARpcErrorMapping.toRpc(e)); @@ -131,7 +136,7 @@ public CompletableFuture Cre return CompletableFuture.supplyAsync(() -> { try { var params = ProtoUtils.FromProto.createTaskPushNotificationConfig(request); - var result = requestHandler.onCreateTaskPushNotificationConfig(params, newCallContext()); + var result = requestHandler.onCreateTaskPushNotificationConfig(params, newCallContext(context)); return ProtoUtils.ToProto.taskPushNotificationConfig(result); } catch (A2AError e) { throw new CompletionException(A2ARpcErrorMapping.toRpc(e)); @@ -145,7 +150,7 @@ public CompletableFuture Get return CompletableFuture.supplyAsync(() -> { try { var params = ProtoUtils.FromProto.getTaskPushNotificationConfigParams(request); - var result = requestHandler.onGetTaskPushNotificationConfig(params, newCallContext()); + var result = requestHandler.onGetTaskPushNotificationConfig(params, newCallContext(context)); return ProtoUtils.ToProto.taskPushNotificationConfig(result); } catch (A2AError e) { throw new CompletionException(A2ARpcErrorMapping.toRpc(e)); @@ -159,7 +164,7 @@ public CompletableFuture { try { var params = ProtoUtils.FromProto.listTaskPushNotificationConfigsParams(request); - var result = requestHandler.onListTaskPushNotificationConfigs(params, newCallContext()); + var result = requestHandler.onListTaskPushNotificationConfigs(params, newCallContext(context)); return ProtoUtils.ToProto.listTaskPushNotificationConfigsResponse(result); } catch (A2AError e) { throw new CompletionException(A2ARpcErrorMapping.toRpc(e)); @@ -179,7 +184,7 @@ public CompletableFuture DeleteTaskPushNotificationCo return CompletableFuture.supplyAsync(() -> { try { var params = ProtoUtils.FromProto.deleteTaskPushNotificationConfigParams(request); - requestHandler.onDeleteTaskPushNotificationConfig(params, newCallContext()); + requestHandler.onDeleteTaskPushNotificationConfig(params, newCallContext(context)); return com.google.protobuf.Empty.getDefaultInstance(); } catch (A2AError e) { throw new CompletionException(A2ARpcErrorMapping.toRpc(e)); @@ -187,8 +192,30 @@ public CompletableFuture DeleteTaskPushNotificationCo }); } - private ServerCallContext newCallContext() { - return new ServerCallContext(ANONYMOUS_USER, Map.of(), Collections.emptySet()); + /** + * Raw SlimRPC metadata (routing keys, {@code slim-peer-subject}, + * {@code slim-payload-sig}, ...) is deliberately not copied into the call + * context's state or requested extensions. + */ + static ServerCallContext newCallContext(ContextInterface context) { + return new ServerCallContext(callerOf(context), Map.of(), Collections.emptySet()); + } + + /** + * The caller's verified SLIM identity, taken only from the MLS session token + * ({@link ContextInterface#peerSubject()} / {@link ContextInterface#peerIssuer()}), + * never from caller-supplied metadata. Falls back to an anonymous user when the + * session carries no subject. + */ + static User callerOf(ContextInterface context) { + if (context == null) { + return ANONYMOUS_USER; + } + String subject = context.peerSubject(); + if (subject == null || subject.isBlank()) { + return ANONYMOUS_USER; + } + return new SlimUser(subject, context.peerIssuer()); } /** diff --git a/slim-a2a/src/main/java/io/agntcy/slim/a2a/SlimUser.java b/slim-a2a/src/main/java/io/agntcy/slim/a2a/SlimUser.java new file mode 100644 index 0000000..a3d3969 --- /dev/null +++ b/slim-a2a/src/main/java/io/agntcy/slim/a2a/SlimUser.java @@ -0,0 +1,86 @@ +// Copyright AGNTCY Contributors (https://github.com/agntcy) +// SPDX-License-Identifier: Apache-2.0 + +package io.agntcy.slim.a2a; + +import java.util.Objects; + +import org.a2aproject.sdk.server.auth.User; + +/** + * The verified SLIM identity of an A2A caller, as reported by the SlimRPC + * {@link io.agntcy.slim.bindings.slimrpc.Context}. The subject and issuer come + * from the peer's MLS session token, never from request metadata. + * + *

{@link #getUsername()} is the token subject ({@code sub}). The subject and + * issuer are also available as the {@code "sub"} and {@code "iss"} attributes. + */ +public final class SlimUser implements User { + + /** Attribute name for the verified token subject. */ + public static final String SUBJECT_ATTRIBUTE = "sub"; + /** Attribute name for the verified token issuer. */ + public static final String ISSUER_ATTRIBUTE = "iss"; + + private final String subject; + private final String issuer; + + /** + * @param subject the verified token subject; must not be blank + * @param issuer the verified token issuer, or {@code null} if the token has none + */ + public SlimUser(String subject, String issuer) { + Objects.requireNonNull(subject, "subject"); + if (subject.isBlank()) { + throw new IllegalArgumentException("subject must not be blank"); + } + this.subject = subject; + this.issuer = issuer == null || issuer.isBlank() ? null : issuer; + } + + /** The verified token subject ({@code sub}). */ + public String getSubject() { + return subject; + } + + /** The verified token issuer ({@code iss}), or {@code null} if the token has none. */ + public String getIssuer() { + return issuer; + } + + @Override + public boolean isAuthenticated() { + return true; + } + + @Override + public String getUsername() { + return subject; + } + + @Override + public Object getAttribute(String name) { + return switch (name) { + case SUBJECT_ATTRIBUTE -> subject; + case ISSUER_ATTRIBUTE -> issuer; + case null, default -> null; + }; + } + + @Override + public boolean equals(Object o) { + return o instanceof SlimUser other + && subject.equals(other.subject) + && Objects.equals(issuer, other.issuer); + } + + @Override + public int hashCode() { + return Objects.hash(subject, issuer); + } + + @Override + public String toString() { + return "SlimUser[subject=" + subject + ", issuer=" + issuer + "]"; + } +} diff --git a/slim-a2a/src/test/java/io/agntcy/slim/a2a/SlimHelperTest.java b/slim-a2a/src/test/java/io/agntcy/slim/a2a/SlimHelperTest.java index faf4f08..e963eda 100644 --- a/slim-a2a/src/test/java/io/agntcy/slim/a2a/SlimHelperTest.java +++ b/slim-a2a/src/test/java/io/agntcy/slim/a2a/SlimHelperTest.java @@ -4,9 +4,13 @@ package io.agntcy.slim.a2a; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertInstanceOf; 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.nio.charset.StandardCharsets; import java.nio.file.Files; @@ -14,9 +18,11 @@ import java.security.KeyPair; import java.security.KeyPairGenerator; import java.time.Duration; +import java.time.Instant; import java.util.Arrays; import java.util.Base64; import java.util.List; +import java.util.Map; import java.util.concurrent.TimeUnit; import io.agntcy.slim.bindings.App; @@ -33,6 +39,8 @@ import io.agntcy.slim.bindings.Name; import io.agntcy.slim.bindings.SlimBindings; import io.agntcy.slim.bindings.SlimException; +import io.agntcy.slim.bindings.slimrpc.ContextInterface; +import org.a2aproject.sdk.server.auth.User; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Timeout; @@ -142,4 +150,100 @@ void rejectsNullArguments() { assertThrows(NullPointerException.class, () -> SlimHelper.createChannel(name, null, provider, verifier, clientConfig)); } + + @Test + void callerIsVerifiedPeerIdentity() { + User user = SlimA2AHandler.callerOf(fakeContext("did:key:z6MkCaller", "test-issuer", Map.of())); + + var slimUser = assertInstanceOf(SlimUser.class, user); + assertTrue(user.isAuthenticated()); + assertEquals("did:key:z6MkCaller", user.getUsername()); + assertEquals("did:key:z6MkCaller", slimUser.getSubject()); + assertEquals("test-issuer", slimUser.getIssuer()); + assertEquals("did:key:z6MkCaller", user.getAttribute(SlimUser.SUBJECT_ATTRIBUTE)); + assertEquals("test-issuer", user.getAttribute(SlimUser.ISSUER_ATTRIBUTE)); + } + + @Test + void callerWithoutIssuer() { + var user = assertInstanceOf(SlimUser.class, + SlimA2AHandler.callerOf(fakeContext("agent", null, Map.of()))); + assertTrue(user.isAuthenticated()); + assertNull(user.getIssuer()); + assertNull(user.getAttribute(SlimUser.ISSUER_ATTRIBUTE)); + } + + @Test + void callerFallsBackToAnonymousWithoutSubject() { + for (String subject : new String[] {null, "", " "}) { + User user = SlimA2AHandler.callerOf(fakeContext(subject, "test-issuer", Map.of())); + assertFalse(user.isAuthenticated()); + assertEquals("anonymous", user.getUsername()); + } + assertFalse(SlimA2AHandler.callerOf(null).isAuthenticated()); + } + + @Test + void callerIgnoresMetadataIdentity() { + Map spoofed = Map.of("sub", "admin", "iss", "evil", "user", "admin", + "authorization", "Bearer admin"); + + User anonymous = SlimA2AHandler.callerOf(fakeContext(null, null, spoofed)); + assertFalse(anonymous.isAuthenticated()); + assertEquals("anonymous", anonymous.getUsername()); + + User verified = SlimA2AHandler.callerOf(fakeContext("agent", "test-issuer", spoofed)); + assertEquals("agent", verified.getUsername()); + assertEquals("test-issuer", verified.getAttribute(SlimUser.ISSUER_ATTRIBUTE)); + } + + @Test + void slimUserRejectsBlankSubjectAndNormalizesIssuer() { + assertThrows(NullPointerException.class, () -> new SlimUser(null, "iss")); + assertThrows(IllegalArgumentException.class, () -> new SlimUser(" ", "iss")); + assertNull(new SlimUser("agent", "").getIssuer()); + assertNull(new SlimUser("agent", null).getAttribute(null)); + assertEquals(new SlimUser("agent", "iss"), new SlimUser("agent", "iss")); + } + + @Test + void callerIdentityIsResolvedPerContext() { + User a = SlimA2AHandler.callerOf(fakeContext("a", null, Map.of())); + User b = SlimA2AHandler.callerOf(fakeContext("b", null, Map.of())); + assertEquals("a", a.getUsername()); + assertEquals("b", b.getUsername()); + User anon1 = SlimA2AHandler.callerOf(fakeContext(null, null, Map.of())); + User anon2 = SlimA2AHandler.callerOf(fakeContext(null, null, Map.of())); + assertSame(anon1, anon2); + } + + @Test + void sessionAndRoutingMetadataDoNotLeakIntoCallContext() { + Map metadata = Map.of( + "slim-peer-subject", "did:key:z6MkSpoofed", + "slim-payload-sig", "c2lnbmF0dXJl", + "service", "lf.a2a.v1.A2AService", + "method", "SendMessage", + "rpc-id", "42", + "slimrpc-deadline", "1700000000", + "a2a-extensions", "https://example.com/ext"); + + var callContext = SlimA2AHandler.newCallContext(fakeContext("did:key:z6MkCaller", "iss", metadata)); + + assertTrue(callContext.getState().isEmpty()); + assertTrue(callContext.getRequestedExtensions().isEmpty()); + assertEquals("did:key:z6MkCaller", callContext.getUser().getUsername()); + } + + private static ContextInterface fakeContext(String subject, String issuer, Map metadata) { + return new ContextInterface() { + @Override public Instant deadline() { return Instant.MAX; } + @Override public boolean isDeadlineExceeded() { return false; } + @Override public Map metadata() { return metadata; } + @Override public String peerIssuer() { return issuer; } + @Override public String peerSubject() { return subject; } + @Override public Duration remainingTime() { return Duration.ofMinutes(1); } + @Override public String sessionId() { return "session"; } + }; + } }