Skip to content

Commit 7f5c6c8

Browse files
authored
fix: Fix Security JSON serialization (#592)
fix: Use GSON serialization for JSON-RPC Agent card endpoint fix: Fix Security JSON serialization Fixes #590 & #591 --------- Signed-off-by: Jeff Mesnil <jmesnil@ibm.com>
1 parent 5938c78 commit 7f5c6c8

10 files changed

Lines changed: 510 additions & 52 deletions

File tree

client/transport/spi/src/test/java/io/a2a/client/transport/spi/interceptors/auth/AuthInterceptorTest.java

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ public void testAPIKeySecurityScheme() {
7777
AuthTestCase authTestCase = new AuthTestCase(
7878
"http://agent.com/rpc",
7979
"session-id",
80-
APIKeySecurityScheme.API_KEY,
80+
APIKeySecurityScheme.TYPE,
8181
"secret-api-key",
8282
new APIKeySecurityScheme(APIKeySecurityScheme.Location.HEADER, "x-api-key", "API Key authentication"),
8383
"x-api-key",
@@ -91,7 +91,7 @@ public void testOAuth2SecurityScheme() {
9191
AuthTestCase authTestCase = new AuthTestCase(
9292
"http://agent.com/rpc",
9393
"session-id",
94-
OAuth2SecurityScheme.OAUTH2,
94+
OAuth2SecurityScheme.TYPE,
9595
"secret-oauth-access-token",
9696
new OAuth2SecurityScheme(OAuthFlows.builder().build(), "OAuth2 authentication", null),
9797
"Authorization",
@@ -105,7 +105,7 @@ public void testOidcSecurityScheme() {
105105
AuthTestCase authTestCase = new AuthTestCase(
106106
"http://agent.com/rpc",
107107
"session-id",
108-
OpenIdConnectSecurityScheme.OPENID_CONNECT,
108+
OpenIdConnectSecurityScheme.TYPE,
109109
"secret-oidc-id-token",
110110
new OpenIdConnectSecurityScheme("http://provider.com/.well-known/openid-configuration", "OIDC authentication"),
111111
"Authorization",

jsonrpc-common/src/main/java/io/a2a/jsonrpc/common/json/JsonUtil.java

Lines changed: 192 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,18 @@
1212
import static io.a2a.spec.A2AErrorCodes.TASK_NOT_CANCELABLE_ERROR_CODE;
1313
import static io.a2a.spec.A2AErrorCodes.TASK_NOT_FOUND_ERROR_CODE;
1414
import static io.a2a.spec.A2AErrorCodes.UNSUPPORTED_OPERATION_ERROR_CODE;
15+
import static io.a2a.spec.DataPart.DATA;
16+
import static io.a2a.spec.FilePart.FILE;
17+
import static io.a2a.spec.TextPart.TEXT;
18+
import static java.lang.String.format;
1519

1620
import java.io.StringReader;
1721
import java.lang.reflect.Type;
1822
import java.time.OffsetDateTime;
1923
import java.time.format.DateTimeFormatter;
2024
import java.time.format.DateTimeParseException;
2125
import java.util.Map;
26+
import java.util.Set;
2227

2328
import com.google.gson.Gson;
2429
import com.google.gson.GsonBuilder;
@@ -29,21 +34,28 @@
2934
import com.google.gson.stream.JsonReader;
3035
import com.google.gson.stream.JsonToken;
3136
import com.google.gson.stream.JsonWriter;
37+
3238
import io.a2a.spec.A2AError;
39+
import io.a2a.spec.APIKeySecurityScheme;
3340
import io.a2a.spec.ContentTypeNotSupportedError;
3441
import io.a2a.spec.DataPart;
3542
import io.a2a.spec.FileContent;
3643
import io.a2a.spec.FilePart;
3744
import io.a2a.spec.FileWithBytes;
3845
import io.a2a.spec.FileWithUri;
46+
import io.a2a.spec.HTTPAuthSecurityScheme;
3947
import io.a2a.spec.InvalidAgentResponseError;
4048
import io.a2a.spec.InvalidParamsError;
4149
import io.a2a.spec.InvalidRequestError;
4250
import io.a2a.spec.JSONParseError;
4351
import io.a2a.spec.Message;
4452
import io.a2a.spec.MethodNotFoundError;
53+
import io.a2a.spec.MutualTLSSecurityScheme;
54+
import io.a2a.spec.OAuth2SecurityScheme;
55+
import io.a2a.spec.OpenIdConnectSecurityScheme;
4556
import io.a2a.spec.Part;
4657
import io.a2a.spec.PushNotificationNotSupportedError;
58+
import io.a2a.spec.SecurityScheme;
4759
import io.a2a.spec.StreamingEventKind;
4860
import io.a2a.spec.Task;
4961
import io.a2a.spec.TaskArtifactUpdateEvent;
@@ -53,6 +65,7 @@
5365
import io.a2a.spec.TaskStatusUpdateEvent;
5466
import io.a2a.spec.TextPart;
5567
import io.a2a.spec.UnsupportedOperationError;
68+
5669
import org.jspecify.annotations.Nullable;
5770

5871
/**
@@ -83,6 +96,7 @@ private static GsonBuilder createBaseGsonBuilder() {
8396
public static final Gson OBJECT_MAPPER = createBaseGsonBuilder()
8497
.registerTypeHierarchyAdapter(Part.class, new PartTypeAdapter())
8598
.registerTypeHierarchyAdapter(StreamingEventKind.class, new StreamingEventKindTypeAdapter())
99+
.registerTypeHierarchyAdapter(SecurityScheme.class, new SecuritySchemeTypeAdapter())
86100
.create();
87101

88102
/**
@@ -530,6 +544,8 @@ public void write(JsonWriter out, Message.Role value) throws java.io.IOException
530544
*/
531545
static class PartTypeAdapter extends TypeAdapter<Part<?>> {
532546

547+
private static final Set<String> VALID_KEYS = Set.of(TEXT, FILE, DATA);
548+
533549
// Create separate Gson instance without the Part adapter to avoid recursion
534550
private final Gson delegateGson = createBaseGsonBuilder().create();
535551

@@ -539,21 +555,20 @@ public void write(JsonWriter out, Part<?> value) throws java.io.IOException {
539555
out.nullValue();
540556
return;
541557
}
542-
543558
// Write wrapper object with member name as discriminator
544559
out.beginObject();
545560

546561
if (value instanceof TextPart textPart) {
547562
// TextPart: { "text": "value" } - direct string value
548-
out.name("text");
563+
out.name(TEXT);
549564
out.value(textPart.text());
550565
} else if (value instanceof FilePart filePart) {
551566
// FilePart: { "file": {...} }
552-
out.name("file");
567+
out.name(FILE);
553568
delegateGson.toJson(filePart.file(), FileContent.class, out);
554569
} else if (value instanceof DataPart dataPart) {
555570
// DataPart: { "data": {...} }
556-
out.name("data");
571+
out.name(DATA);
557572
delegateGson.toJson(dataPart.data(), Map.class, out);
558573
} else {
559574
throw new JsonSyntaxException("Unknown Part subclass: " + value.getClass().getName());
@@ -579,23 +594,27 @@ Part<?> read(JsonReader in) throws java.io.IOException {
579594
com.google.gson.JsonObject jsonObject = jsonElement.getAsJsonObject();
580595

581596
// Check for member name discriminators (v1.0 protocol)
582-
if (jsonObject.has("text")) {
583-
// TextPart: { "text": "value" } - direct string value
584-
return new TextPart(jsonObject.get("text").getAsString());
585-
} else if (jsonObject.has("file")) {
586-
// FilePart: { "file": {...} }
587-
return new FilePart(delegateGson.fromJson(jsonObject.get("file"), FileContent.class));
588-
} else if (jsonObject.has("data")) {
589-
// DataPart: { "data": {...} }
590-
@SuppressWarnings("unchecked")
591-
Map<String, Object> dataMap = delegateGson.fromJson(
592-
jsonObject.get("data"),
593-
new TypeToken<Map<String, Object>>(){}.getType()
594-
);
595-
return new DataPart(dataMap);
596-
} else {
597-
throw new JsonSyntaxException("Part must have one of: text, file, data (found: " + jsonObject.keySet() + ")");
598-
}
597+
Set<String> keys = jsonObject.keySet();
598+
if (keys.size() != 1) {
599+
throw new JsonSyntaxException(format("Part object must have exactly one key, which must be one of: %s (found: %s)", VALID_KEYS, keys));
600+
}
601+
602+
String discriminator = keys.iterator().next();
603+
604+
return switch (discriminator) {
605+
case TEXT -> new TextPart(jsonObject.get(TEXT).getAsString());
606+
case FILE -> new FilePart(delegateGson.fromJson(jsonObject.get(FILE), FileContent.class));
607+
case DATA -> {
608+
@SuppressWarnings("unchecked")
609+
Map<String, Object> dataMap = delegateGson.fromJson(
610+
jsonObject.get(DATA),
611+
new TypeToken<Map<String, Object>>(){}.getType()
612+
);
613+
yield new DataPart(dataMap);
614+
}
615+
default ->
616+
throw new JsonSyntaxException(format("Part must have one of: %s (found: %s)", VALID_KEYS, discriminator));
617+
};
599618
}
600619
}
601620

@@ -627,20 +646,10 @@ public void write(JsonWriter out, StreamingEventKind value) throws java.io.IOExc
627646
out.nullValue();
628647
return;
629648
}
630-
631649
// Write wrapper object with member name as discriminator
632650
out.beginObject();
633-
634-
Type type = switch (value.kind()) {
635-
case Task.STREAMING_EVENT_ID -> Task.class;
636-
case Message.STREAMING_EVENT_ID -> Message.class;
637-
case TaskStatusUpdateEvent.STREAMING_EVENT_ID -> TaskStatusUpdateEvent.class;
638-
case TaskArtifactUpdateEvent.STREAMING_EVENT_ID -> TaskArtifactUpdateEvent.class;
639-
default -> throw new JsonSyntaxException("Unknown StreamingEventKind implementation: " + value.getClass().getName());
640-
};
641-
642651
out.name(value.kind());
643-
delegateGson.toJson(value, type, out);
652+
delegateGson.toJson(value, value.getClass(), out);
644653
out.endObject();
645654
}
646655

@@ -714,7 +723,9 @@ StreamingEventKind read(JsonReader in) throws java.io.IOException {
714723
static class FileContentTypeAdapter extends TypeAdapter<FileContent> {
715724

716725
// Create separate Gson instance without the FileContent adapter to avoid recursion
717-
private final Gson delegateGson = new Gson();
726+
private final Gson delegateGson = new GsonBuilder()
727+
.registerTypeAdapter(OffsetDateTime.class, new OffsetDateTimeTypeAdapter())
728+
.create();
718729

719730
@Override
720731
public void write(JsonWriter out, FileContent value) throws java.io.IOException {
@@ -723,13 +734,7 @@ public void write(JsonWriter out, FileContent value) throws java.io.IOException
723734
return;
724735
}
725736
// Delegate to Gson's default serialization for the concrete type
726-
if (value instanceof FileWithBytes fileWithBytes) {
727-
delegateGson.toJson(fileWithBytes, FileWithBytes.class, out);
728-
} else if (value instanceof FileWithUri fileWithUri) {
729-
delegateGson.toJson(fileWithUri, FileWithUri.class, out);
730-
} else {
731-
throw new JsonSyntaxException("Unknown FileContent implementation: " + value.getClass().getName());
732-
}
737+
delegateGson.toJson(value, value.getClass(), out);
733738
}
734739

735740
@Override
@@ -759,4 +764,151 @@ FileContent read(JsonReader in) throws java.io.IOException {
759764
}
760765
}
761766

767+
/**
768+
* Gson TypeAdapter for serializing and deserializing {@link APIKeySecurityScheme.Location} enum.
769+
* <p>
770+
* This adapter ensures that Location enum values are serialized using their
771+
* wire format string representation (e.g., "header") rather than
772+
* the Java enum constant name (e.g., "HEADER").
773+
* <p>
774+
* For serialization, it uses {@link APIKeySecurityScheme.Location#asString()} to get the wire format.
775+
* For deserialization, it uses {@link APIKeySecurityScheme.Location#fromString(String)} to parse the
776+
* wire format back to the enum constant.
777+
*
778+
* @see APIKeySecurityScheme.Location
779+
*/
780+
static class APIKeyLocationTypeAdapter extends TypeAdapter<APIKeySecurityScheme.Location> {
781+
782+
@Override
783+
public void write(JsonWriter out, APIKeySecurityScheme.Location value) throws java.io.IOException {
784+
if (value == null) {
785+
out.nullValue();
786+
return;
787+
}
788+
out.value(value.asString());
789+
}
790+
791+
@Override
792+
public APIKeySecurityScheme.@Nullable Location read(JsonReader in) throws java.io.IOException {
793+
if (in.peek() == JsonToken.NULL) {
794+
in.nextNull();
795+
return null;
796+
}
797+
String locationString = in.nextString();
798+
try {
799+
return APIKeySecurityScheme.Location.fromString(locationString);
800+
} catch (IllegalArgumentException e) {
801+
throw new JsonSyntaxException("Invalid APIKeySecurityScheme.Location: " + locationString, e);
802+
}
803+
}
804+
}
805+
806+
/**
807+
* Gson TypeAdapter for serializing and deserializing {@link SecurityScheme} and its implementations.
808+
* <p>
809+
* This adapter handles polymorphic deserialization for the sealed SecurityScheme interface,
810+
* which permits five implementations:
811+
* <ul>
812+
* <li>{@link APIKeySecurityScheme} - API key authentication</li>
813+
* <li>{@link HTTPAuthSecurityScheme} - HTTP authentication (basic or bearer)</li>
814+
* <li>{@link OAuth2SecurityScheme} - OAuth 2.0 flows</li>
815+
* <li>{@link OpenIdConnectSecurityScheme} - OpenID Connect discovery</li>
816+
* <li>{@link MutualTLSSecurityScheme} - Client certificate authentication</li>
817+
* </ul>
818+
* <p>
819+
* The adapter uses a wrapper object with the security scheme type as the discriminator field.
820+
* Each SecurityScheme is serialized as a JSON object with a single field whose name identifies
821+
* the security scheme type.
822+
* <p>
823+
* Serialization format examples:
824+
* <pre>{@code
825+
* // HTTPAuthSecurityScheme
826+
* {
827+
* "httpAuthSecurityScheme": {
828+
* "scheme": "bearer",
829+
* "bearerFormat": "JWT",
830+
* "description": "..."
831+
* }
832+
* }
833+
*
834+
* // APIKeySecurityScheme
835+
* {
836+
* "apiKeySecurityScheme": {
837+
* "location": "header",
838+
* "name": "X-API-Key",
839+
* "description": "..."
840+
* }
841+
* }
842+
* }</pre>
843+
*
844+
* @see SecurityScheme
845+
* @see APIKeySecurityScheme
846+
* @see HTTPAuthSecurityScheme
847+
* @see OAuth2SecurityScheme
848+
* @see OpenIdConnectSecurityScheme
849+
* @see MutualTLSSecurityScheme
850+
*/
851+
static class SecuritySchemeTypeAdapter extends TypeAdapter<SecurityScheme> {
852+
853+
private static final Set<String> VALID_KEYS = Set.of(APIKeySecurityScheme.TYPE,
854+
HTTPAuthSecurityScheme.TYPE,
855+
OAuth2SecurityScheme.TYPE,
856+
OpenIdConnectSecurityScheme.TYPE,
857+
MutualTLSSecurityScheme.TYPE);
858+
859+
// Create separate Gson instance without the SecurityScheme adapter to avoid recursion
860+
// Register custom adapter for APIKeySecurityScheme.Location enum
861+
private final Gson delegateGson = createBaseGsonBuilder()
862+
.registerTypeAdapter(APIKeySecurityScheme.Location.class, new APIKeyLocationTypeAdapter())
863+
.create();
864+
865+
@Override
866+
public void write(JsonWriter out, SecurityScheme value) throws java.io.IOException {
867+
if (value == null) {
868+
out.nullValue();
869+
return;
870+
}
871+
872+
// Write wrapper object with member name as discriminator
873+
out.beginObject();
874+
out.name(value.type());
875+
delegateGson.toJson(value, value.getClass(), out);
876+
out.endObject();
877+
}
878+
879+
@Override
880+
public @Nullable
881+
SecurityScheme read(JsonReader in) throws java.io.IOException {
882+
if (in.peek() == JsonToken.NULL) {
883+
in.nextNull();
884+
return null;
885+
}
886+
887+
// Read the JSON as a tree to inspect the member name discriminator
888+
com.google.gson.JsonElement jsonElement = com.google.gson.JsonParser.parseReader(in);
889+
if (!jsonElement.isJsonObject()) {
890+
throw new JsonSyntaxException("SecurityScheme must be a JSON object");
891+
}
892+
893+
com.google.gson.JsonObject jsonObject = jsonElement.getAsJsonObject();
894+
895+
// Check for member name discriminators
896+
Set<String> keys = jsonObject.keySet();
897+
if (keys.size() != 1) {
898+
throw new JsonSyntaxException(format("A SecurityScheme object must have exactly one key, which must be one of: %s (found: %s)", VALID_KEYS, keys));
899+
}
900+
901+
String discriminator = keys.iterator().next();
902+
com.google.gson.JsonElement nestedObject = jsonObject.get(discriminator);
903+
904+
return switch (discriminator) {
905+
case APIKeySecurityScheme.TYPE -> delegateGson.fromJson(nestedObject, APIKeySecurityScheme.class);
906+
case HTTPAuthSecurityScheme.TYPE -> delegateGson.fromJson(nestedObject, HTTPAuthSecurityScheme.class);
907+
case OAuth2SecurityScheme.TYPE -> delegateGson.fromJson(nestedObject, OAuth2SecurityScheme.class);
908+
case OpenIdConnectSecurityScheme.TYPE -> delegateGson.fromJson(nestedObject, OpenIdConnectSecurityScheme.class);
909+
case MutualTLSSecurityScheme.TYPE -> delegateGson.fromJson(nestedObject, MutualTLSSecurityScheme.class);
910+
default -> throw new JsonSyntaxException(format("Unknown SecurityScheme type. Must be one of: %s (found: %s)", VALID_KEYS, discriminator));
911+
};
912+
}
913+
}
762914
}

0 commit comments

Comments
 (0)