From ef493616e39b88efda82fbb6808bb183fab22b42 Mon Sep 17 00:00:00 2001 From: Luca Muscariello Date: Wed, 14 Oct 2026 20:17:44 +0200 Subject: [PATCH] feat!: upgrade to slim-bindings-java 3.3 and expose the verified caller identity Bump slim-bindings-java from 2.0.0 to 3.3.2 to match the .NET, Rust and SLIM node peers, and bump protoc-gen-slimrpc-java to 3.0.0 in CI (the regenerated stubs are unchanged). SlimA2AHandler now builds the ServerCallContext user from the verified MLS session token (Context.peerSubject()/peerIssuer()) as a SlimUser, and falls back to the anonymous user when there is no subject. Identity is never read from request metadata. Raw SlimRPC metadata is not copied into the ServerCallContext, so the slim-peer-subject and slim-payload-sig keys added by SLIM 3.4, and the routing keys, never reach A2A handlers. A test covers this. BREAKING CHANGE: requests from MLS callers whose token has a subject now reach handlers as an authenticated SlimUser instead of the anonymous user, so handlers that give authenticated users extra access will now give it to any verified peer. Fixes #15 Signed-off-by: Luca Muscariello --- .github/workflows/ci.yaml | 2 +- .github/workflows/codeql.yaml | 2 +- README.md | 33 ++++-- .../a2a/examples/echo/EchoAgentExecutor.java | 6 +- pom.xml | 2 +- .../agntcy/slim/a2a/NativeLibraryLoader.java | 2 +- .../io/agntcy/slim/a2a/SlimA2AHandler.java | 51 +++++++-- .../java/io/agntcy/slim/a2a/SlimUser.java | 86 +++++++++++++++ .../io/agntcy/slim/a2a/SlimHelperTest.java | 104 ++++++++++++++++++ 9 files changed, 264 insertions(+), 24 deletions(-) create mode 100644 slim-a2a/src/main/java/io/agntcy/slim/a2a/SlimUser.java 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"; } + }; + } }