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 @@
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 {@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