diff --git a/client/transport/spi/src/test/java/io/a2a/client/transport/spi/interceptors/auth/AuthInterceptorTest.java b/client/transport/spi/src/test/java/io/a2a/client/transport/spi/interceptors/auth/AuthInterceptorTest.java index 0aeacff66..a9f07c83e 100644 --- a/client/transport/spi/src/test/java/io/a2a/client/transport/spi/interceptors/auth/AuthInterceptorTest.java +++ b/client/transport/spi/src/test/java/io/a2a/client/transport/spi/interceptors/auth/AuthInterceptorTest.java @@ -77,7 +77,7 @@ public void testAPIKeySecurityScheme() { AuthTestCase authTestCase = new AuthTestCase( "http://agent.com/rpc", "session-id", - APIKeySecurityScheme.API_KEY, + APIKeySecurityScheme.TYPE, "secret-api-key", new APIKeySecurityScheme(APIKeySecurityScheme.Location.HEADER, "x-api-key", "API Key authentication"), "x-api-key", @@ -91,7 +91,7 @@ public void testOAuth2SecurityScheme() { AuthTestCase authTestCase = new AuthTestCase( "http://agent.com/rpc", "session-id", - OAuth2SecurityScheme.OAUTH2, + OAuth2SecurityScheme.TYPE, "secret-oauth-access-token", new OAuth2SecurityScheme(OAuthFlows.builder().build(), "OAuth2 authentication", null), "Authorization", @@ -105,7 +105,7 @@ public void testOidcSecurityScheme() { AuthTestCase authTestCase = new AuthTestCase( "http://agent.com/rpc", "session-id", - OpenIdConnectSecurityScheme.OPENID_CONNECT, + OpenIdConnectSecurityScheme.TYPE, "secret-oidc-id-token", new OpenIdConnectSecurityScheme("http://provider.com/.well-known/openid-configuration", "OIDC authentication"), "Authorization", diff --git a/jsonrpc-common/src/main/java/io/a2a/jsonrpc/common/json/JsonUtil.java b/jsonrpc-common/src/main/java/io/a2a/jsonrpc/common/json/JsonUtil.java index c6de924bf..c0aea8ad2 100644 --- a/jsonrpc-common/src/main/java/io/a2a/jsonrpc/common/json/JsonUtil.java +++ b/jsonrpc-common/src/main/java/io/a2a/jsonrpc/common/json/JsonUtil.java @@ -12,6 +12,10 @@ import static io.a2a.spec.A2AErrorCodes.TASK_NOT_CANCELABLE_ERROR_CODE; import static io.a2a.spec.A2AErrorCodes.TASK_NOT_FOUND_ERROR_CODE; import static io.a2a.spec.A2AErrorCodes.UNSUPPORTED_OPERATION_ERROR_CODE; +import static io.a2a.spec.DataPart.DATA; +import static io.a2a.spec.FilePart.FILE; +import static io.a2a.spec.TextPart.TEXT; +import static java.lang.String.format; import java.io.StringReader; import java.lang.reflect.Type; @@ -19,6 +23,7 @@ import java.time.format.DateTimeFormatter; import java.time.format.DateTimeParseException; import java.util.Map; +import java.util.Set; import com.google.gson.Gson; import com.google.gson.GsonBuilder; @@ -29,21 +34,28 @@ import com.google.gson.stream.JsonReader; import com.google.gson.stream.JsonToken; import com.google.gson.stream.JsonWriter; + import io.a2a.spec.A2AError; +import io.a2a.spec.APIKeySecurityScheme; import io.a2a.spec.ContentTypeNotSupportedError; import io.a2a.spec.DataPart; import io.a2a.spec.FileContent; import io.a2a.spec.FilePart; import io.a2a.spec.FileWithBytes; import io.a2a.spec.FileWithUri; +import io.a2a.spec.HTTPAuthSecurityScheme; import io.a2a.spec.InvalidAgentResponseError; import io.a2a.spec.InvalidParamsError; import io.a2a.spec.InvalidRequestError; import io.a2a.spec.JSONParseError; import io.a2a.spec.Message; import io.a2a.spec.MethodNotFoundError; +import io.a2a.spec.MutualTLSSecurityScheme; +import io.a2a.spec.OAuth2SecurityScheme; +import io.a2a.spec.OpenIdConnectSecurityScheme; import io.a2a.spec.Part; import io.a2a.spec.PushNotificationNotSupportedError; +import io.a2a.spec.SecurityScheme; import io.a2a.spec.StreamingEventKind; import io.a2a.spec.Task; import io.a2a.spec.TaskArtifactUpdateEvent; @@ -53,6 +65,7 @@ import io.a2a.spec.TaskStatusUpdateEvent; import io.a2a.spec.TextPart; import io.a2a.spec.UnsupportedOperationError; + import org.jspecify.annotations.Nullable; /** @@ -83,6 +96,7 @@ private static GsonBuilder createBaseGsonBuilder() { public static final Gson OBJECT_MAPPER = createBaseGsonBuilder() .registerTypeHierarchyAdapter(Part.class, new PartTypeAdapter()) .registerTypeHierarchyAdapter(StreamingEventKind.class, new StreamingEventKindTypeAdapter()) + .registerTypeHierarchyAdapter(SecurityScheme.class, new SecuritySchemeTypeAdapter()) .create(); /** @@ -530,6 +544,8 @@ public void write(JsonWriter out, Message.Role value) throws java.io.IOException */ static class PartTypeAdapter extends TypeAdapter> { + private static final Set VALID_KEYS = Set.of(TEXT, FILE, DATA); + // Create separate Gson instance without the Part adapter to avoid recursion private final Gson delegateGson = createBaseGsonBuilder().create(); @@ -539,21 +555,20 @@ public void write(JsonWriter out, Part value) throws java.io.IOException { out.nullValue(); return; } - // Write wrapper object with member name as discriminator out.beginObject(); if (value instanceof TextPart textPart) { // TextPart: { "text": "value" } - direct string value - out.name("text"); + out.name(TEXT); out.value(textPart.text()); } else if (value instanceof FilePart filePart) { // FilePart: { "file": {...} } - out.name("file"); + out.name(FILE); delegateGson.toJson(filePart.file(), FileContent.class, out); } else if (value instanceof DataPart dataPart) { // DataPart: { "data": {...} } - out.name("data"); + out.name(DATA); delegateGson.toJson(dataPart.data(), Map.class, out); } else { throw new JsonSyntaxException("Unknown Part subclass: " + value.getClass().getName()); @@ -579,23 +594,27 @@ Part read(JsonReader in) throws java.io.IOException { com.google.gson.JsonObject jsonObject = jsonElement.getAsJsonObject(); // Check for member name discriminators (v1.0 protocol) - if (jsonObject.has("text")) { - // TextPart: { "text": "value" } - direct string value - return new TextPart(jsonObject.get("text").getAsString()); - } else if (jsonObject.has("file")) { - // FilePart: { "file": {...} } - return new FilePart(delegateGson.fromJson(jsonObject.get("file"), FileContent.class)); - } else if (jsonObject.has("data")) { - // DataPart: { "data": {...} } - @SuppressWarnings("unchecked") - Map dataMap = delegateGson.fromJson( - jsonObject.get("data"), - new TypeToken>(){}.getType() - ); - return new DataPart(dataMap); - } else { - throw new JsonSyntaxException("Part must have one of: text, file, data (found: " + jsonObject.keySet() + ")"); - } + Set keys = jsonObject.keySet(); + if (keys.size() != 1) { + throw new JsonSyntaxException(format("Part object must have exactly one key, which must be one of: %s (found: %s)", VALID_KEYS, keys)); + } + + String discriminator = keys.iterator().next(); + + return switch (discriminator) { + case TEXT -> new TextPart(jsonObject.get(TEXT).getAsString()); + case FILE -> new FilePart(delegateGson.fromJson(jsonObject.get(FILE), FileContent.class)); + case DATA -> { + @SuppressWarnings("unchecked") + Map dataMap = delegateGson.fromJson( + jsonObject.get(DATA), + new TypeToken>(){}.getType() + ); + yield new DataPart(dataMap); + } + default -> + throw new JsonSyntaxException(format("Part must have one of: %s (found: %s)", VALID_KEYS, discriminator)); + }; } } @@ -627,20 +646,10 @@ public void write(JsonWriter out, StreamingEventKind value) throws java.io.IOExc out.nullValue(); return; } - // Write wrapper object with member name as discriminator out.beginObject(); - - Type type = switch (value.kind()) { - case Task.STREAMING_EVENT_ID -> Task.class; - case Message.STREAMING_EVENT_ID -> Message.class; - case TaskStatusUpdateEvent.STREAMING_EVENT_ID -> TaskStatusUpdateEvent.class; - case TaskArtifactUpdateEvent.STREAMING_EVENT_ID -> TaskArtifactUpdateEvent.class; - default -> throw new JsonSyntaxException("Unknown StreamingEventKind implementation: " + value.getClass().getName()); - }; - out.name(value.kind()); - delegateGson.toJson(value, type, out); + delegateGson.toJson(value, value.getClass(), out); out.endObject(); } @@ -714,7 +723,9 @@ StreamingEventKind read(JsonReader in) throws java.io.IOException { static class FileContentTypeAdapter extends TypeAdapter { // Create separate Gson instance without the FileContent adapter to avoid recursion - private final Gson delegateGson = new Gson(); + private final Gson delegateGson = new GsonBuilder() + .registerTypeAdapter(OffsetDateTime.class, new OffsetDateTimeTypeAdapter()) + .create(); @Override 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 return; } // Delegate to Gson's default serialization for the concrete type - if (value instanceof FileWithBytes fileWithBytes) { - delegateGson.toJson(fileWithBytes, FileWithBytes.class, out); - } else if (value instanceof FileWithUri fileWithUri) { - delegateGson.toJson(fileWithUri, FileWithUri.class, out); - } else { - throw new JsonSyntaxException("Unknown FileContent implementation: " + value.getClass().getName()); - } + delegateGson.toJson(value, value.getClass(), out); } @Override @@ -759,4 +764,151 @@ FileContent read(JsonReader in) throws java.io.IOException { } } + /** + * Gson TypeAdapter for serializing and deserializing {@link APIKeySecurityScheme.Location} enum. + *

+ * This adapter ensures that Location enum values are serialized using their + * wire format string representation (e.g., "header") rather than + * the Java enum constant name (e.g., "HEADER"). + *

+ * For serialization, it uses {@link APIKeySecurityScheme.Location#asString()} to get the wire format. + * For deserialization, it uses {@link APIKeySecurityScheme.Location#fromString(String)} to parse the + * wire format back to the enum constant. + * + * @see APIKeySecurityScheme.Location + */ + static class APIKeyLocationTypeAdapter extends TypeAdapter { + + @Override + public void write(JsonWriter out, APIKeySecurityScheme.Location value) throws java.io.IOException { + if (value == null) { + out.nullValue(); + return; + } + out.value(value.asString()); + } + + @Override + public APIKeySecurityScheme.@Nullable Location read(JsonReader in) throws java.io.IOException { + if (in.peek() == JsonToken.NULL) { + in.nextNull(); + return null; + } + String locationString = in.nextString(); + try { + return APIKeySecurityScheme.Location.fromString(locationString); + } catch (IllegalArgumentException e) { + throw new JsonSyntaxException("Invalid APIKeySecurityScheme.Location: " + locationString, e); + } + } + } + + /** + * Gson TypeAdapter for serializing and deserializing {@link SecurityScheme} and its implementations. + *

+ * This adapter handles polymorphic deserialization for the sealed SecurityScheme interface, + * which permits five implementations: + *

    + *
  • {@link APIKeySecurityScheme} - API key authentication
  • + *
  • {@link HTTPAuthSecurityScheme} - HTTP authentication (basic or bearer)
  • + *
  • {@link OAuth2SecurityScheme} - OAuth 2.0 flows
  • + *
  • {@link OpenIdConnectSecurityScheme} - OpenID Connect discovery
  • + *
  • {@link MutualTLSSecurityScheme} - Client certificate authentication
  • + *
+ *

+ * The adapter uses a wrapper object with the security scheme type as the discriminator field. + * Each SecurityScheme is serialized as a JSON object with a single field whose name identifies + * the security scheme type. + *

+ * Serialization format examples: + *

{@code
+     * // HTTPAuthSecurityScheme
+     * {
+     *   "httpAuthSecurityScheme": {
+     *     "scheme": "bearer",
+     *     "bearerFormat": "JWT",
+     *     "description": "..."
+     *   }
+     * }
+     *
+     * // APIKeySecurityScheme
+     * {
+     *   "apiKeySecurityScheme": {
+     *     "location": "header",
+     *     "name": "X-API-Key",
+     *     "description": "..."
+     *   }
+     * }
+     * }
+ * + * @see SecurityScheme + * @see APIKeySecurityScheme + * @see HTTPAuthSecurityScheme + * @see OAuth2SecurityScheme + * @see OpenIdConnectSecurityScheme + * @see MutualTLSSecurityScheme + */ + static class SecuritySchemeTypeAdapter extends TypeAdapter { + + private static final Set VALID_KEYS = Set.of(APIKeySecurityScheme.TYPE, + HTTPAuthSecurityScheme.TYPE, + OAuth2SecurityScheme.TYPE, + OpenIdConnectSecurityScheme.TYPE, + MutualTLSSecurityScheme.TYPE); + + // Create separate Gson instance without the SecurityScheme adapter to avoid recursion + // Register custom adapter for APIKeySecurityScheme.Location enum + private final Gson delegateGson = createBaseGsonBuilder() + .registerTypeAdapter(APIKeySecurityScheme.Location.class, new APIKeyLocationTypeAdapter()) + .create(); + + @Override + public void write(JsonWriter out, SecurityScheme value) throws java.io.IOException { + if (value == null) { + out.nullValue(); + return; + } + + // Write wrapper object with member name as discriminator + out.beginObject(); + out.name(value.type()); + delegateGson.toJson(value, value.getClass(), out); + out.endObject(); + } + + @Override + public @Nullable + SecurityScheme read(JsonReader in) throws java.io.IOException { + if (in.peek() == JsonToken.NULL) { + in.nextNull(); + return null; + } + + // Read the JSON as a tree to inspect the member name discriminator + com.google.gson.JsonElement jsonElement = com.google.gson.JsonParser.parseReader(in); + if (!jsonElement.isJsonObject()) { + throw new JsonSyntaxException("SecurityScheme must be a JSON object"); + } + + com.google.gson.JsonObject jsonObject = jsonElement.getAsJsonObject(); + + // Check for member name discriminators + Set keys = jsonObject.keySet(); + if (keys.size() != 1) { + throw new JsonSyntaxException(format("A SecurityScheme object must have exactly one key, which must be one of: %s (found: %s)", VALID_KEYS, keys)); + } + + String discriminator = keys.iterator().next(); + com.google.gson.JsonElement nestedObject = jsonObject.get(discriminator); + + return switch (discriminator) { + case APIKeySecurityScheme.TYPE -> delegateGson.fromJson(nestedObject, APIKeySecurityScheme.class); + case HTTPAuthSecurityScheme.TYPE -> delegateGson.fromJson(nestedObject, HTTPAuthSecurityScheme.class); + case OAuth2SecurityScheme.TYPE -> delegateGson.fromJson(nestedObject, OAuth2SecurityScheme.class); + case OpenIdConnectSecurityScheme.TYPE -> delegateGson.fromJson(nestedObject, OpenIdConnectSecurityScheme.class); + case MutualTLSSecurityScheme.TYPE -> delegateGson.fromJson(nestedObject, MutualTLSSecurityScheme.class); + default -> throw new JsonSyntaxException(format("Unknown SecurityScheme type. Must be one of: %s (found: %s)", VALID_KEYS, discriminator)); + }; + } + } } diff --git a/jsonrpc-common/src/test/java/io/a2a/jsonrpc/common/json/SecuritySchemeSerializationTest.java b/jsonrpc-common/src/test/java/io/a2a/jsonrpc/common/json/SecuritySchemeSerializationTest.java new file mode 100644 index 000000000..bbc297baa --- /dev/null +++ b/jsonrpc-common/src/test/java/io/a2a/jsonrpc/common/json/SecuritySchemeSerializationTest.java @@ -0,0 +1,276 @@ +package io.a2a.jsonrpc.common.json; + +import static io.a2a.spec.APIKeySecurityScheme.Location.COOKIE; +import static io.a2a.spec.APIKeySecurityScheme.Location.HEADER; +import static io.a2a.spec.APIKeySecurityScheme.Location.QUERY; +import static java.lang.String.format; +import static org.junit.jupiter.api.Assertions.assertEquals; +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.assertTrue; + +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import io.a2a.spec.APIKeySecurityScheme; +import io.a2a.spec.HTTPAuthSecurityScheme; +import io.a2a.spec.MutualTLSSecurityScheme; +import io.a2a.spec.OAuth2SecurityScheme; +import io.a2a.spec.OAuthFlows; +import io.a2a.spec.OpenIdConnectSecurityScheme; +import io.a2a.spec.PasswordOAuthFlow; +import io.a2a.spec.SecurityScheme; + +/** + * Tests for SecurityScheme serialization and deserialization using Gson. + */ +class SecuritySchemeSerializationTest { + + @Test + void testHTTPAuthSecuritySchemeSerialization() throws JsonProcessingException { + SecurityScheme scheme = HTTPAuthSecurityScheme.builder() + .scheme("basic") + .description("Basic HTTP authentication") + .build(); + + doTestSecuritySchemeSerialization(scheme, HTTPAuthSecurityScheme.TYPE, Map.of("scheme", "basic")); + + } + + @Test + void testHTTPAuthSecuritySchemeDeserialization() throws JsonProcessingException { + String json = """ + { + "httpAuthSecurityScheme" : { + "scheme": "basic" + } + }"""; + SecurityScheme securityScheme = JsonUtil.fromJson(json, SecurityScheme.class); + assertInstanceOf(HTTPAuthSecurityScheme.class, securityScheme); + HTTPAuthSecurityScheme scheme = (HTTPAuthSecurityScheme) securityScheme; + assertEquals("basic", scheme.scheme()); + assertNull(scheme.bearerFormat()); + } + + @Test + void testHTTPAuthSecuritySchemeWithBearerFormatSerialization() throws JsonProcessingException { + SecurityScheme scheme = HTTPAuthSecurityScheme.builder() + .scheme("bearer") + .bearerFormat("JWT") + .description("JWT bearer token authentication") + .build(); + + doTestSecuritySchemeSerialization(scheme, HTTPAuthSecurityScheme.TYPE, + Map.of("scheme", "bearer", + "bearerFormat", "JWT", + "description", "JWT bearer token authentication")); + } + + @Test + void testHTTPAuthSecuritySchemeWithBearerFormatDeserialization() throws JsonProcessingException { + String json = """ + { + "httpAuthSecurityScheme" : { + "scheme": "bearer", + "bearerFormat": "JWT", + "description": "JWT authentication" + } + }"""; + SecurityScheme securityScheme = JsonUtil.fromJson(json, SecurityScheme.class); + assertInstanceOf(HTTPAuthSecurityScheme.class, securityScheme); + HTTPAuthSecurityScheme scheme = (HTTPAuthSecurityScheme) securityScheme; + assertEquals("bearer", scheme.scheme()); + assertEquals("JWT", scheme.bearerFormat()); + assertEquals("JWT authentication", scheme.description()); + } + + + @Test + void testAPIKeySecuritySchemeSerialization() throws JsonProcessingException { + SecurityScheme scheme = APIKeySecurityScheme.builder() + .location(HEADER) + .name("bar") + .build(); + + doTestSecuritySchemeSerialization(scheme, APIKeySecurityScheme.TYPE, Map.of("location", "header", + "name", "bar")); + } + + @Test + void testAPIKeySecuritySchemeDeserialization() throws JsonProcessingException { + String json = """ + { + "apiKeySecurityScheme" : { + "location": "cookie", + "name": "bar" + } + }"""; + SecurityScheme securityScheme = JsonUtil.fromJson(json, SecurityScheme.class); + assertInstanceOf(APIKeySecurityScheme.class, securityScheme); + APIKeySecurityScheme scheme = (APIKeySecurityScheme) securityScheme; + assertEquals(COOKIE, scheme.location()); + assertEquals("bar", scheme.name()); + } + + @Test + void testAPIKeySecuritySchemeWithDescriptionSerialization() throws JsonProcessingException { + SecurityScheme scheme = APIKeySecurityScheme.builder() + .location(QUERY) + .name("api_key") + .description("API key authentication via query parameter") + .build(); + + doTestSecuritySchemeSerialization(scheme, APIKeySecurityScheme.TYPE, + Map.of("location", "query", + "name", "api_key", + "description", "API key authentication via query parameter")); + } + + @Test + void testOAuth2SecuritySchemeSerialization() throws JsonProcessingException { + PasswordOAuthFlow passwordFlow = new PasswordOAuthFlow( + "https://example.com/oauth/refresh", + Map.of("read", "Read access", "write", "Write access"), + "https://example.com/oauth/token" + ); + + OAuthFlows flows = OAuthFlows.builder() + .password(passwordFlow) + .build(); + + SecurityScheme scheme = OAuth2SecurityScheme.builder() + .flows(flows) + .description("OAuth 2.0 password flow") + .oauth2MetadataUrl("https://example.com/.well-known/oauth-authorization-server") + .build(); + + // Verify serialization with nested OAuth flow fields + String json = JsonUtil.toJson(scheme); + assertNotNull(json); + assertTrue(json.contains(OAuth2SecurityScheme.TYPE)); + assertTrue(json.contains("\"description\":\"OAuth 2.0 password flow\"")); + assertTrue(json.contains("\"oauth2MetadataUrl\":\"https://example.com/.well-known/oauth-authorization-server\"")); + assertTrue(json.contains("\"tokenUrl\":\"https://example.com/oauth/token\"")); + assertTrue(json.contains("\"read\":\"Read access\"")); + + SecurityScheme deserialized = JsonUtil.fromJson(json, SecurityScheme.class); + assertEquals(scheme, deserialized); + } + + @Test + void testOAuth2SecuritySchemeDeserialization() throws JsonProcessingException { + String json = """ + { + "oauth2SecurityScheme" : { + "flows": { + "password": { + "tokenUrl": "https://example.com/oauth/token", + "refreshUrl": "https://example.com/oauth/refresh", + "scopes": { + "read": "Read access", + "write": "Write access" + } + } + }, + "description": "OAuth 2.0 authentication" + } + }"""; + + SecurityScheme securityScheme = JsonUtil.fromJson(json, SecurityScheme.class); + assertInstanceOf(OAuth2SecurityScheme.class, securityScheme); + OAuth2SecurityScheme scheme = (OAuth2SecurityScheme) securityScheme; + assertEquals("OAuth 2.0 authentication", scheme.description()); + assertNotNull(scheme.flows()); + assertNotNull(scheme.flows().password()); + assertEquals("https://example.com/oauth/token", scheme.flows().password().tokenUrl()); + assertEquals("https://example.com/oauth/refresh", scheme.flows().password().refreshUrl()); + assertEquals(2, scheme.flows().password().scopes().size()); + assertEquals("Read access", scheme.flows().password().scopes().get("read")); + } + + @Test + void testOpenIdConnectSecuritySchemeSerialization() throws JsonProcessingException { + SecurityScheme scheme = OpenIdConnectSecurityScheme.builder() + .openIdConnectUrl("https://example.com/.well-known/openid-configuration") + .description("OpenID Connect authentication") + .build(); + + doTestSecuritySchemeSerialization(scheme, OpenIdConnectSecurityScheme.TYPE, + Map.of("openIdConnectUrl", "https://example.com/.well-known/openid-configuration", + "description", "OpenID Connect authentication")); + } + + @Test + void testOpenIdConnectSecuritySchemeDeserialization() throws JsonProcessingException { + String json = """ + { + "openIdConnectSecurityScheme" : { + "openIdConnectUrl": "https://example.com/.well-known/openid-configuration", + "description": "OIDC authentication" + } + }"""; + + SecurityScheme securityScheme = JsonUtil.fromJson(json, SecurityScheme.class); + assertInstanceOf(OpenIdConnectSecurityScheme.class, securityScheme); + OpenIdConnectSecurityScheme scheme = (OpenIdConnectSecurityScheme) securityScheme; + assertEquals("https://example.com/.well-known/openid-configuration", scheme.openIdConnectUrl()); + assertEquals("OIDC authentication", scheme.description()); + } + + @Test + void testMutualTLSSecuritySchemeSerialization() throws JsonProcessingException { + SecurityScheme scheme = new MutualTLSSecurityScheme("Client certificate authentication required"); + + doTestSecuritySchemeSerialization(scheme, MutualTLSSecurityScheme.TYPE, + Map.of("description", "Client certificate authentication required")); + } + + @Test + void testMutualTLSSecuritySchemeDeserialization() throws JsonProcessingException { + String json = """ + { + "mtlsSecurityScheme" : { + "description": "mTLS authentication" + } + }"""; + + SecurityScheme securityScheme = JsonUtil.fromJson(json, SecurityScheme.class); + assertInstanceOf(MutualTLSSecurityScheme.class, securityScheme); + MutualTLSSecurityScheme scheme = (MutualTLSSecurityScheme) securityScheme; + assertEquals("mTLS authentication", scheme.description()); + } + + @Test + void testMutualTLSSecuritySchemeWithNullDescriptionDeserialization() throws JsonProcessingException { + String json = """ + { + "mtlsSecurityScheme" : { + } + }"""; + + SecurityScheme securityScheme = JsonUtil.fromJson(json, SecurityScheme.class); + assertInstanceOf(MutualTLSSecurityScheme.class, securityScheme); + MutualTLSSecurityScheme scheme = (MutualTLSSecurityScheme) securityScheme; + assertNull(scheme.description()); + } + + void doTestSecuritySchemeSerialization(SecurityScheme scheme, String schemeType, Map expectedFields) throws JsonProcessingException { + // Serialize to JSON + String json = JsonUtil.toJson(scheme); + + // Verify JSON contains expected fields + assertNotNull(json); + assertTrue(json.contains(schemeType)); + for (Map.Entry entry : expectedFields.entrySet()) { + String expectedField = format("\"%s\":\"%s\"", entry.getKey(), entry.getValue()); + assertTrue(json.contains(expectedField), expectedField + " not found in JSON"); + } + + // Deserialize back to Task + SecurityScheme deserialized = JsonUtil.fromJson(json, SecurityScheme.class); + + assertEquals(scheme, deserialized); + } +} \ No newline at end of file diff --git a/reference/jsonrpc/src/main/java/io/a2a/server/apps/quarkus/A2AServerRoutes.java b/reference/jsonrpc/src/main/java/io/a2a/server/apps/quarkus/A2AServerRoutes.java index 1bf037e7f..9f4b31d83 100644 --- a/reference/jsonrpc/src/main/java/io/a2a/server/apps/quarkus/A2AServerRoutes.java +++ b/reference/jsonrpc/src/main/java/io/a2a/server/apps/quarkus/A2AServerRoutes.java @@ -26,6 +26,7 @@ import io.a2a.jsonrpc.common.json.InvalidParamsJsonMappingException; import io.a2a.jsonrpc.common.json.JsonMappingException; import io.a2a.jsonrpc.common.json.JsonProcessingException; +import io.a2a.jsonrpc.common.json.JsonUtil; import io.a2a.jsonrpc.common.json.MethodNotFoundJsonMappingException; import io.a2a.jsonrpc.common.wrappers.A2AErrorResponse; import io.a2a.jsonrpc.common.wrappers.A2ARequest; @@ -156,8 +157,8 @@ public void invokeJSONRPCHandler(@Body String body, RoutingContext rc) { * @return the agent card */ @Route(path = "/.well-known/agent-card.json", methods = Route.HttpMethod.GET, produces = APPLICATION_JSON) - public AgentCard getAgentCard() { - return jsonRpcHandler.getAgentCard(); + public String getAgentCard() throws JsonProcessingException { + return JsonUtil.toJson(jsonRpcHandler.getAgentCard()); } private A2AResponse processNonStreamingRequest(NonStreamingJSONRPCRequest request, ServerCallContext context) { diff --git a/spec/src/main/java/io/a2a/spec/APIKeySecurityScheme.java b/spec/src/main/java/io/a2a/spec/APIKeySecurityScheme.java index fb72a00e9..ffa886849 100644 --- a/spec/src/main/java/io/a2a/spec/APIKeySecurityScheme.java +++ b/spec/src/main/java/io/a2a/spec/APIKeySecurityScheme.java @@ -24,7 +24,12 @@ public record APIKeySecurityScheme( ) implements SecurityScheme { /** The security scheme type identifier for API key authentication. */ - public static final String API_KEY = "apiKey"; + public static final String TYPE = "apiKeySecurityScheme"; + + @Override + public String type() { + return TYPE; + } /** * Compact constructor with validation. diff --git a/spec/src/main/java/io/a2a/spec/HTTPAuthSecurityScheme.java b/spec/src/main/java/io/a2a/spec/HTTPAuthSecurityScheme.java index 7e4a9a968..996a0c0a6 100644 --- a/spec/src/main/java/io/a2a/spec/HTTPAuthSecurityScheme.java +++ b/spec/src/main/java/io/a2a/spec/HTTPAuthSecurityScheme.java @@ -42,7 +42,7 @@ public record HTTPAuthSecurityScheme( ) implements SecurityScheme { /** The HTTP security scheme type identifier. */ - public static final String HTTP = "http"; + public static final String TYPE = "httpAuthSecurityScheme"; /** * Compact constructor with validation. @@ -56,6 +56,11 @@ public record HTTPAuthSecurityScheme( Assert.checkNotNullParam("scheme", scheme); } + @Override + public String type() { + return TYPE; + } + /** * Create a new Builder * diff --git a/spec/src/main/java/io/a2a/spec/MutualTLSSecurityScheme.java b/spec/src/main/java/io/a2a/spec/MutualTLSSecurityScheme.java index 86480258b..7210e1db7 100644 --- a/spec/src/main/java/io/a2a/spec/MutualTLSSecurityScheme.java +++ b/spec/src/main/java/io/a2a/spec/MutualTLSSecurityScheme.java @@ -27,8 +27,12 @@ public record MutualTLSSecurityScheme(String description) implements SecurityScheme { /** - * The type identifier for mutual TLS security schemes: "mutualTLS". + * The type identifier for mutual TLS security schemes: "mtlsSecurityScheme". */ - public static final String MUTUAL_TLS = "mutualTLS"; + public static final String TYPE = "mtlsSecurityScheme"; + @Override + public String type() { + return TYPE; + } } diff --git a/spec/src/main/java/io/a2a/spec/OAuth2SecurityScheme.java b/spec/src/main/java/io/a2a/spec/OAuth2SecurityScheme.java index 58d969de2..ffaa323aa 100644 --- a/spec/src/main/java/io/a2a/spec/OAuth2SecurityScheme.java +++ b/spec/src/main/java/io/a2a/spec/OAuth2SecurityScheme.java @@ -26,9 +26,9 @@ public record OAuth2SecurityScheme( ) implements SecurityScheme { /** - * The type identifier for OAuth 2.0 security schemes: "oauth2". + * The type identifier for OAuth 2.0 security schemes: "oauth2SecurityScheme". */ - public static final String OAUTH2 = "oauth2"; + public static final String TYPE = "oauth2SecurityScheme"; /** * Compact constructor with validation. @@ -42,6 +42,11 @@ public record OAuth2SecurityScheme( Assert.checkNotNullParam("flows", flows); } + @Override + public String type() { + return TYPE; + } + /** * Create a new Builder * diff --git a/spec/src/main/java/io/a2a/spec/OpenIdConnectSecurityScheme.java b/spec/src/main/java/io/a2a/spec/OpenIdConnectSecurityScheme.java index 6f73770ce..63700853a 100644 --- a/spec/src/main/java/io/a2a/spec/OpenIdConnectSecurityScheme.java +++ b/spec/src/main/java/io/a2a/spec/OpenIdConnectSecurityScheme.java @@ -36,7 +36,7 @@ public record OpenIdConnectSecurityScheme( /** * The type identifier for OpenID Connect security schemes: "openIdConnect". */ - public static final String OPENID_CONNECT = "openIdConnect"; + public static final String TYPE = "openIdConnectSecurityScheme"; /** * Compact constructor with validation. @@ -49,6 +49,11 @@ public record OpenIdConnectSecurityScheme( Assert.checkNotNullParam("openIdConnectUrl", openIdConnectUrl); } + @Override + public String type() { + return TYPE; + } + /** * Create a new Builder * diff --git a/spec/src/main/java/io/a2a/spec/SecurityScheme.java b/spec/src/main/java/io/a2a/spec/SecurityScheme.java index 322262eb9..b481b4c4a 100644 --- a/spec/src/main/java/io/a2a/spec/SecurityScheme.java +++ b/spec/src/main/java/io/a2a/spec/SecurityScheme.java @@ -29,4 +29,9 @@ public sealed interface SecurityScheme permits APIKeySecurityScheme, HTTPAuthSec * @return the description, or null if not provided */ String description(); + + /** + * Returns the type of the security scheme. + */ + String type(); }