Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/codeql.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
33 changes: 26 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>

<slim.bindings.version>2.0.0</slim.bindings.version>
<slim.bindings.version>3.3.2</slim.bindings.version>
<a2a.java.sdk.version>1.1.0.Final</a2a.java.sdk.version>
<protobuf.version>4.33.2</protobuf.version>
<jna.version>5.14.0</jna.version>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
* it, restoring the "just works" behavior the JNA-based 1.3.x loader used to
* provide.
*
* <p>slim-bindings-java 2.0 exposes two UniFFI namespaces — {@code slim_bindings}
* <p>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.
*/
Expand Down
51 changes: 39 additions & 12 deletions slim-a2a/src/main/java/io/agntcy/slim/a2a/SlimA2AHandler.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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}.
*
* <p>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 {

Expand All @@ -45,7 +50,7 @@ public CompletableFuture<org.a2aproject.sdk.grpc.SendMessageResponse> 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));
Expand All @@ -60,7 +65,7 @@ public CompletableFuture<Void> SendStreamingMessage(
try {
var params = ProtoUtils.FromProto.messageSendParams(request);
Flow.Publisher<StreamingEventKind> publisher =
requestHandler.onMessageSendStream(params, newCallContext());
requestHandler.onMessageSendStream(params, newCallContext(context));
streamToSink(publisher, sink);
} catch (A2AError e) {
throw new CompletionException(A2ARpcErrorMapping.toRpc(e));
Expand All @@ -74,7 +79,7 @@ public CompletableFuture<org.a2aproject.sdk.grpc.Task> 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));
Expand All @@ -88,7 +93,7 @@ public CompletableFuture<org.a2aproject.sdk.grpc.ListTasksResponse> 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));
Expand All @@ -102,7 +107,7 @@ public CompletableFuture<org.a2aproject.sdk.grpc.Task> 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));
Expand All @@ -117,7 +122,7 @@ public CompletableFuture<Void> SubscribeToTask(
try {
var params = ProtoUtils.FromProto.taskIdParams(request);
Flow.Publisher<StreamingEventKind> publisher =
requestHandler.onSubscribeToTask(params, newCallContext());
requestHandler.onSubscribeToTask(params, newCallContext(context));
streamToSink(publisher, sink);
} catch (A2AError e) {
throw new CompletionException(A2ARpcErrorMapping.toRpc(e));
Expand All @@ -131,7 +136,7 @@ public CompletableFuture<org.a2aproject.sdk.grpc.TaskPushNotificationConfig> 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));
Expand All @@ -145,7 +150,7 @@ public CompletableFuture<org.a2aproject.sdk.grpc.TaskPushNotificationConfig> 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));
Expand All @@ -159,7 +164,7 @@ public CompletableFuture<org.a2aproject.sdk.grpc.ListTaskPushNotificationConfigs
return CompletableFuture.supplyAsync(() -> {
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));
Expand All @@ -179,16 +184,38 @@ public CompletableFuture<com.google.protobuf.Empty> 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));
}
});
}

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());
}

/**
Expand Down
86 changes: 86 additions & 0 deletions slim-a2a/src/main/java/io/agntcy/slim/a2a/SlimUser.java
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>{@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 + "]";
}
}
Loading
Loading