From a1d844cddfed46d8ee753e7b15af7c06123f3fdf Mon Sep 17 00:00:00 2001 From: Jeff Mesnil Date: Wed, 18 Feb 2026 12:32:56 +0100 Subject: [PATCH] feat!: Add SecurityRequirement to the domain API This replaces the `List>>` that was not properly serialized to JSON. Rename spec-grpc SecurityMapper to SecurityRequirementMapper.java This fixes #667 Signed-off-by: Jeff Mesnil --- .../jsonrpc/JSONRPCTransportTest.java | 5 +- .../interceptors/auth/AuthInterceptor.java | 8 +- .../auth/AuthInterceptorTest.java | 5 +- .../io/a2a/jsonrpc/common/json/JsonUtil.java | 127 ++++++++++++++++++ .../SecurityRequirementSerializationTest.java | 85 ++++++++++++ .../io/a2a/grpc/mapper/AgentCardMapper.java | 2 +- .../io/a2a/grpc/mapper/AgentSkillMapper.java | 2 +- .../io/a2a/grpc/mapper/SecurityMapper.java | 110 --------------- .../mapper/SecurityRequirementMapper.java | 112 +++++++++++++++ .../java/io/a2a/grpc/utils/ToProtoTest.java | 6 +- spec/src/main/java/io/a2a/spec/AgentCard.java | 14 +- .../src/main/java/io/a2a/spec/AgentSkill.java | 11 +- .../java/io/a2a/spec/SecurityRequirement.java | 110 +++++++++++++++ 13 files changed, 465 insertions(+), 132 deletions(-) create mode 100644 jsonrpc-common/src/test/java/io/a2a/jsonrpc/common/json/SecurityRequirementSerializationTest.java delete mode 100644 spec-grpc/src/main/java/io/a2a/grpc/mapper/SecurityMapper.java create mode 100644 spec-grpc/src/main/java/io/a2a/grpc/mapper/SecurityRequirementMapper.java create mode 100644 spec/src/main/java/io/a2a/spec/SecurityRequirement.java diff --git a/client/transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportTest.java b/client/transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportTest.java index 012b373ff..0bd42a978 100644 --- a/client/transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportTest.java +++ b/client/transport/jsonrpc/src/test/java/io/a2a/client/transport/jsonrpc/JSONRPCTransportTest.java @@ -57,6 +57,7 @@ import io.a2a.spec.OpenIdConnectSecurityScheme; import io.a2a.spec.Part; import io.a2a.spec.PushNotificationConfig; +import io.a2a.spec.SecurityRequirement; import io.a2a.spec.SecurityScheme; import io.a2a.spec.Task; import io.a2a.spec.TaskIdParams; @@ -376,9 +377,9 @@ public void testA2AClientGetExtendedAgentCard() throws Exception { assertNotNull(securitySchemes); OpenIdConnectSecurityScheme google = (OpenIdConnectSecurityScheme) securitySchemes.get("google"); assertEquals("https://accounts.google.com/.well-known/openid-configuration", google.openIdConnectUrl()); - List>> security = agentCard.securityRequirements(); + List security = agentCard.securityRequirements(); assertEquals(1, security.size()); - Map> securityMap = security.get(0); + Map> securityMap = security.get(0).schemes(); List scopes = securityMap.get("google"); List expectedScopes = List.of("openid", "profile", "email"); assertEquals(expectedScopes, scopes); diff --git a/client/transport/spi/src/main/java/io/a2a/client/transport/spi/interceptors/auth/AuthInterceptor.java b/client/transport/spi/src/main/java/io/a2a/client/transport/spi/interceptors/auth/AuthInterceptor.java index 6cfc322da..2b073d413 100644 --- a/client/transport/spi/src/main/java/io/a2a/client/transport/spi/interceptors/auth/AuthInterceptor.java +++ b/client/transport/spi/src/main/java/io/a2a/client/transport/spi/interceptors/auth/AuthInterceptor.java @@ -13,6 +13,7 @@ import io.a2a.spec.HTTPAuthSecurityScheme; import io.a2a.spec.OAuth2SecurityScheme; import io.a2a.spec.OpenIdConnectSecurityScheme; +import io.a2a.spec.SecurityRequirement; import io.a2a.spec.SecurityScheme; import org.jspecify.annotations.Nullable; @@ -38,8 +39,11 @@ public PayloadAndHeaders intercept(String methodName, @Nullable Object payload, if (agentCard == null || agentCard.securityRequirements()== null || agentCard.securitySchemes() == null) { return new PayloadAndHeaders(payload, updatedHeaders); } - for (Map> requirement : agentCard.securityRequirements()) { - for (String securitySchemeName : requirement.keySet()) { + for (SecurityRequirement requirement : agentCard.securityRequirements()) { + if (requirement == null) { + continue; + } + for (String securitySchemeName : requirement.schemes().keySet()) { String credential = credentialService.getCredential(securitySchemeName, clientCallContext); if (credential != null && agentCard.securitySchemes().containsKey(securitySchemeName)) { SecurityScheme securityScheme = agentCard.securitySchemes().get(securitySchemeName); 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 9ea69f354..ed29dafab 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 @@ -18,6 +18,7 @@ import io.a2a.spec.OAuth2SecurityScheme; import io.a2a.spec.OAuthFlows; import io.a2a.spec.OpenIdConnectSecurityScheme; +import io.a2a.spec.SecurityRequirement; import io.a2a.spec.SecurityScheme; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -235,7 +236,7 @@ void testAvailableSecuritySchemeNotInAgentCardSecuritySchemes() { .defaultInputModes(List.of("text")) .defaultOutputModes(List.of("text")) .skills(List.of()) - .securityRequirements(List.of(Map.of(schemeName, List.of()))) + .securityRequirements(List.of(SecurityRequirement.builder().scheme(schemeName, List.of()).build())) .securitySchemes(Map.of()) // no security schemes .build(); @@ -321,7 +322,7 @@ private AgentCard createAgentCard(String schemeName, SecurityScheme securitySche .defaultInputModes(List.of("text")) .defaultOutputModes(List.of("text")) .skills(List.of()) - .securityRequirements(List.of(Map.of(schemeName, List.of()))) + .securityRequirements(List.of(SecurityRequirement.builder().scheme(schemeName, List.of()).build())) .securitySchemes(Map.of(schemeName, securityScheme)) .build(); } 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 29aa36ae8..4b473712d 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 @@ -16,12 +16,17 @@ import static io.a2a.spec.FilePart.FILE; import static io.a2a.spec.TextPart.TEXT; import static java.lang.String.format; +import static java.util.Collections.emptyMap; import java.io.StringReader; import java.lang.reflect.Type; import java.time.OffsetDateTime; import java.time.format.DateTimeFormatter; import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import java.util.Set; @@ -55,6 +60,7 @@ import io.a2a.spec.OpenIdConnectSecurityScheme; import io.a2a.spec.Part; import io.a2a.spec.PushNotificationNotSupportedError; +import io.a2a.spec.SecurityRequirement; import io.a2a.spec.SecurityScheme; import io.a2a.spec.StreamingEventKind; import io.a2a.spec.Task; @@ -76,8 +82,10 @@ private static GsonBuilder createBaseGsonBuilder() { return new GsonBuilder() .setObjectToNumberStrategy(ToNumberPolicy.LONG_OR_DOUBLE) .registerTypeAdapter(OffsetDateTime.class, new OffsetDateTimeTypeAdapter()) + .registerTypeAdapter(SecurityRequirement.class, new SecurityRequirementTypeAdapter()) .registerTypeHierarchyAdapter(A2AError.class, new A2AErrorTypeAdapter()) .registerTypeHierarchyAdapter(FileContent.class, new FileContentTypeAdapter()); + } /** @@ -841,4 +849,123 @@ SecurityScheme read(JsonReader in) throws java.io.IOException { }; } } + + /** + * Gson TypeAdapter for serializing and deserializing {@link SecurityRequirement}. + *

+ * This adapter handles the JSON structure where a SecurityRequirement is represented + * as an object with a "schemes" field containing a map of security scheme names to + * StringList objects (matching the protobuf representation). + *

+ * Serialization format: + *

{@code
+     * {
+     *   "schemes": {
+     *     "oauth2": { "list": ["read", "write"] },
+     *     "apiKey": { "list": [] }
+     *   }
+     * }
+     * }
+ * + * @see SecurityRequirement + */ + static class SecurityRequirementTypeAdapter extends TypeAdapter { + + private static final String SCHEMES_FIELD = "schemes"; + private static final String LIST_FIELD = "list"; + + @Override + public void write(JsonWriter out, SecurityRequirement value) throws java.io.IOException { + if (value == null) { + out.nullValue(); + return; + } + + out.beginObject(); + out.name(SCHEMES_FIELD); + + Map> schemes = value.schemes(); + if (schemes == null || schemes.isEmpty()) { + out.beginObject(); + out.endObject(); + } else { + out.beginObject(); + for (Map.Entry> entry : schemes.entrySet()) { + out.name(entry.getKey()); + out.beginObject(); + out.name(LIST_FIELD); + out.beginArray(); + List scopes = entry.getValue(); + if (scopes != null) { + for (String scope : scopes) { + out.value(scope); + } + } + out.endArray(); + out.endObject(); + } + out.endObject(); + } + + out.endObject(); + } + + @Override + public @Nullable SecurityRequirement read(JsonReader in) throws java.io.IOException { + if (in.peek() == JsonToken.NULL) { + in.nextNull(); + return null; + } + + Map> schemes = emptyMap(); + + in.beginObject(); + while (in.hasNext()) { + String fieldName = in.nextName(); + if (SCHEMES_FIELD.equals(fieldName)) { + schemes = readSchemesMap(in); + } else { + in.skipValue(); + } + } + in.endObject(); + + return new SecurityRequirement(schemes); + } + + private Map> readSchemesMap(JsonReader in) throws java.io.IOException { + Map> schemes = new LinkedHashMap<>(); + + in.beginObject(); + while (in.hasNext()) { + String schemeName = in.nextName(); + List scopes = readStringList(in); + schemes.put(schemeName, scopes); + } + in.endObject(); + + return schemes; + } + + private List readStringList(JsonReader in) throws java.io.IOException { + List scopes = new ArrayList<>(); + + in.beginObject(); + while (in.hasNext()) { + String fieldName = in.nextName(); + if (LIST_FIELD.equals(fieldName)) { + in.beginArray(); + while (in.hasNext()) { + scopes.add(in.nextString()); + } + in.endArray(); + } else { + in.skipValue(); + } + } + in.endObject(); + + return scopes; + } + } } diff --git a/jsonrpc-common/src/test/java/io/a2a/jsonrpc/common/json/SecurityRequirementSerializationTest.java b/jsonrpc-common/src/test/java/io/a2a/jsonrpc/common/json/SecurityRequirementSerializationTest.java new file mode 100644 index 000000000..b71809367 --- /dev/null +++ b/jsonrpc-common/src/test/java/io/a2a/jsonrpc/common/json/SecurityRequirementSerializationTest.java @@ -0,0 +1,85 @@ +package io.a2a.jsonrpc.common.json; + +import static java.util.Collections.emptyMap; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +import io.a2a.spec.SecurityRequirement; + +/** + * Tests for SecurityRequirement serialization and deserialization with JSON. + */ +class SecurityRequirementSerializationTest { + + @Test + void testSecurityRequirementSerializationWithSingleScheme() throws JsonProcessingException { + SecurityRequirement requirement = SecurityRequirement.builder() + .scheme("oauth2", List.of("read", "write")) + .build(); + + String json = JsonUtil.toJson(requirement); + assertNotNull(json); + + String expected = """ + {"schemes":{"oauth2":{"list":["read","write"]}}}"""; + assertEquals(expected, json); + + SecurityRequirement deserialized = JsonUtil.fromJson(json, SecurityRequirement.class); + assertEquals(requirement, deserialized); + } + + @Test + void testSecurityRequirementSerializationWithMultipleSchemes() throws JsonProcessingException { + SecurityRequirement requirement = SecurityRequirement.builder() + .scheme("oauth2", List.of("profile")) + .scheme("apiKey", List.of()) + .build(); + + String json = JsonUtil.toJson(requirement); + assertNotNull(json); + + String expected = """ + {"schemes":{"oauth2":{"list":["profile"]},"apiKey":{"list":[]}}}"""; + assertEquals(expected, json); + + SecurityRequirement deserialized = JsonUtil.fromJson(json, SecurityRequirement.class); + assertEquals(requirement, deserialized); + } + + @Test + void testSecurityRequirementSerializationWithEmptyScopes() throws JsonProcessingException { + SecurityRequirement requirement = SecurityRequirement.builder() + .scheme("apiKey", List.of()) + .build(); + + String json = JsonUtil.toJson(requirement); + assertNotNull(json); + + String expected = """ + {"schemes":{"apiKey":{"list":[]}}}"""; + assertEquals(expected, json); + + SecurityRequirement deserialized = JsonUtil.fromJson(json, SecurityRequirement.class); + assertEquals(requirement, deserialized); + } + + @Test + void testSecurityRequirementSerializationWithNullSchemes() throws JsonProcessingException { + SecurityRequirement requirement = new SecurityRequirement(emptyMap()); + + String json = JsonUtil.toJson(requirement); + + assertNotNull(json); + String expected = """ + {"schemes":{}}"""; + assertEquals(expected, json); + + SecurityRequirement deserialized = JsonUtil.fromJson(json, SecurityRequirement.class); + assertNotNull(deserialized); + assertEquals(requirement, deserialized); + } +} diff --git a/spec-grpc/src/main/java/io/a2a/grpc/mapper/AgentCardMapper.java b/spec-grpc/src/main/java/io/a2a/grpc/mapper/AgentCardMapper.java index 97b9ed0a1..268d54714 100644 --- a/spec-grpc/src/main/java/io/a2a/grpc/mapper/AgentCardMapper.java +++ b/spec-grpc/src/main/java/io/a2a/grpc/mapper/AgentCardMapper.java @@ -14,7 +14,7 @@ AgentCapabilitiesMapper.class, AgentSkillMapper.class, SecuritySchemeMapper.class, - SecurityMapper.class, + SecurityRequirementMapper.class, AgentInterfaceMapper.class, AgentCardSignatureMapper.class }) diff --git a/spec-grpc/src/main/java/io/a2a/grpc/mapper/AgentSkillMapper.java b/spec-grpc/src/main/java/io/a2a/grpc/mapper/AgentSkillMapper.java index daa911867..d56c572b8 100644 --- a/spec-grpc/src/main/java/io/a2a/grpc/mapper/AgentSkillMapper.java +++ b/spec-grpc/src/main/java/io/a2a/grpc/mapper/AgentSkillMapper.java @@ -8,7 +8,7 @@ */ @Mapper(config = A2AProtoMapperConfig.class, collectionMappingStrategy = CollectionMappingStrategy.ADDER_PREFERRED, - uses = SecurityMapper.class) + uses = SecurityRequirementMapper.class) public interface AgentSkillMapper { AgentSkillMapper INSTANCE = A2AMappers.getMapper(AgentSkillMapper.class); diff --git a/spec-grpc/src/main/java/io/a2a/grpc/mapper/SecurityMapper.java b/spec-grpc/src/main/java/io/a2a/grpc/mapper/SecurityMapper.java deleted file mode 100644 index e00e5f70f..000000000 --- a/spec-grpc/src/main/java/io/a2a/grpc/mapper/SecurityMapper.java +++ /dev/null @@ -1,110 +0,0 @@ -package io.a2a.grpc.mapper; - -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; - -import com.google.protobuf.ProtocolStringList; -import io.a2a.grpc.SecurityRequirement; -import io.a2a.grpc.StringList; -import org.mapstruct.Mapper; - -/** - * Mapper between domain security requirements and protobuf SecurityRequirement messages. - *

- * Domain representation: {@code List>>} where each map represents - * one security option with scheme names as keys and scopes as values. - *

- * Proto representation: {@code repeated SecurityRequirement} where each SecurityRequirement has - * {@code map schemes}. - *

- * Example: A security requirement that allows either OAuth2 with read/write scopes OR API Key: - *

- * Domain: [
- *   {"oauth2": ["read", "write"]},
- *   {"apiKey": []}
- * ]
- * Proto: [
- *   SecurityRequirement{schemes: {"oauth2": StringList{values: ["read", "write"]}}},
- *   SecurityRequirement{schemes: {"apiKey": StringList{values: []}}}
- * ]
- * 
- *

- * Manual Implementation Required: Handles complex nested structure ({@code List>>} ↔ - * {@code repeated SecurityRequirement} with {@code map}) requiring manual iteration and StringList wrapper handling. - */ -@Mapper(config = A2AProtoMapperConfig.class) -public interface SecurityMapper { - - SecurityMapper INSTANCE = A2AMappers.getMapper(SecurityMapper.class); - - /** - * Converts a single domain security requirement map to a proto SecurityRequirement message. - *

- * MapStruct will call this method for each element when mapping the list. - * - * @param schemeMap map of scheme names to scopes - * @return SecurityRequirement proto message, or null if input is null - */ - default SecurityRequirement mapSecurityItem(Map> schemeMap) { - if (schemeMap == null) { - return null; - } - - SecurityRequirement.Builder securityBuilder = SecurityRequirement.newBuilder(); - for (Map.Entry> entry : schemeMap.entrySet()) { - StringList.Builder stringListBuilder = StringList.newBuilder(); - if (entry.getValue() != null) { - stringListBuilder.addAllList(entry.getValue()); - } - securityBuilder.putSchemes(entry.getKey(), stringListBuilder.build()); - } - return securityBuilder.build(); - } - - /** - * Converts domain security requirements to proto SecurityRequirement messages. - *

- * Each Map in the domain list becomes one SecurityRequirement message in proto, representing - * one way to satisfy the security requirements (OR relationship between list items). - * - * @param domainSecurity list of maps representing security requirement options - * @return list of SecurityRequirement proto messages, or null if input is null - */ - default List toProto(List>> domainSecurity) { - if (domainSecurity == null) { - return null; - } - - List protoList = new ArrayList<>(domainSecurity.size()); - for (Map> schemeMap : domainSecurity) { - protoList.add(mapSecurityItem(schemeMap)); - } - return protoList; - } - - /** - * Converts proto SecurityRequirement messages to domain security requirements. - * - * @param protoSecurity list of SecurityRequirement proto messages - * @return list of maps representing security requirement options, or null if input is null - */ - default List>> fromProto(List protoSecurity) { - if (protoSecurity == null) { - return null; - } - - List>> domainList = new ArrayList<>(protoSecurity.size()); - for (SecurityRequirement security : protoSecurity) { - Map> schemeMap = new LinkedHashMap<>(); - for (Map.Entry entry : security.getSchemesMap().entrySet()) { - ProtocolStringList listList = entry.getValue().getListList(); - List values = new ArrayList<>(listList); - schemeMap.put(entry.getKey(), values); - } - domainList.add(schemeMap); - } - return domainList; - } -} diff --git a/spec-grpc/src/main/java/io/a2a/grpc/mapper/SecurityRequirementMapper.java b/spec-grpc/src/main/java/io/a2a/grpc/mapper/SecurityRequirementMapper.java new file mode 100644 index 000000000..8f9f98fe6 --- /dev/null +++ b/spec-grpc/src/main/java/io/a2a/grpc/mapper/SecurityRequirementMapper.java @@ -0,0 +1,112 @@ +package io.a2a.grpc.mapper; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import com.google.protobuf.ProtocolStringList; +import io.a2a.grpc.StringList; +import org.mapstruct.Mapper; + +/** + * Mapper between domain security requirements and protobuf SecurityRequirement messages. + *

+ * Domain representation: {@code List} where each SecurityRequirement contains + * a schemes map with scheme names as keys and scopes as values. + *

+ * Proto representation: {@code repeated SecurityRequirement} where each SecurityRequirement has + * {@code map schemes}. + *

+ * Example: A security requirement that allows either OAuth2 with read/write scopes OR API Key: + *

+ * Domain: [
+ *   SecurityRequirement{schemes: {"oauth2": ["read", "write"]}},
+ *   SecurityRequirement{schemes: {"apiKey": []}}
+ * ]
+ * Proto: [
+ *   SecurityRequirement{schemes: {"oauth2": StringList{values: ["read", "write"]}}},
+ *   SecurityRequirement{schemes: {"apiKey": StringList{values: []}}}
+ * ]
+ * 
+ *

+ * Manual Implementation Required: Handles complex nested structure ({@code List} ↔ + * {@code repeated SecurityRequirement} with {@code map}) requiring manual iteration and StringList wrapper handling. + */ +@Mapper(config = A2AProtoMapperConfig.class) +public interface SecurityRequirementMapper { + + SecurityRequirementMapper INSTANCE = A2AMappers.getMapper(SecurityRequirementMapper.class); + + /** + * Converts a single domain SecurityRequirement to a proto SecurityRequirement message. + *

+ * MapStruct will call this method for each element when mapping the list. + * + * @param domainRequirement domain SecurityRequirement with schemes map + * @return SecurityRequirement proto message, or null if input is null + */ + default io.a2a.grpc.SecurityRequirement mapSecurityRequirement(io.a2a.spec.SecurityRequirement domainRequirement) { + if (domainRequirement == null) { + return null; + } + + io.a2a.grpc.SecurityRequirement.Builder securityBuilder = io.a2a.grpc.SecurityRequirement.newBuilder(); + Map> schemes = domainRequirement.schemes(); + if (schemes != null) { + for (Map.Entry> entry : schemes.entrySet()) { + StringList.Builder stringListBuilder = StringList.newBuilder(); + if (entry.getValue() != null) { + stringListBuilder.addAllList(entry.getValue()); + } + securityBuilder.putSchemes(entry.getKey(), stringListBuilder.build()); + } + } + return securityBuilder.build(); + } + + /** + * Converts domain security requirements to proto SecurityRequirement messages. + *

+ * Each SecurityRequirement in the domain list becomes one SecurityRequirement message in proto, + * representing one way to satisfy the security requirements (OR relationship between list items). + * + * @param domainSecurity list of SecurityRequirement domain objects + * @return list of SecurityRequirement proto messages, or null if input is null + */ + default List toProto(List domainSecurity) { + if (domainSecurity == null) { + return null; + } + + List protoList = new ArrayList<>(domainSecurity.size()); + for (io.a2a.spec.SecurityRequirement requirement : domainSecurity) { + protoList.add(mapSecurityRequirement(requirement)); + } + return protoList; + } + + /** + * Converts proto SecurityRequirement messages to domain security requirements. + * + * @param protoSecurity list of SecurityRequirement proto messages + * @return list of SecurityRequirement domain objects, or null if input is null + */ + default List fromProto(List protoSecurity) { + if (protoSecurity == null) { + return null; + } + + List domainList = new ArrayList<>(protoSecurity.size()); + for (io.a2a.grpc.SecurityRequirement security : protoSecurity) { + Map> schemeMap = new LinkedHashMap<>(); + for (Map.Entry entry : security.getSchemesMap().entrySet()) { + ProtocolStringList listList = entry.getValue().getListList(); + List values = new ArrayList<>(listList); + schemeMap.put(entry.getKey(), values); + } + domainList.add(new io.a2a.spec.SecurityRequirement(schemeMap)); + } + return domainList; + } +} diff --git a/spec-grpc/src/test/java/io/a2a/grpc/utils/ToProtoTest.java b/spec-grpc/src/test/java/io/a2a/grpc/utils/ToProtoTest.java index fc8771736..b857cb6b1 100644 --- a/spec-grpc/src/test/java/io/a2a/grpc/utils/ToProtoTest.java +++ b/spec-grpc/src/test/java/io/a2a/grpc/utils/ToProtoTest.java @@ -17,6 +17,7 @@ import io.a2a.spec.AgentCard; import io.a2a.spec.AgentInterface; import io.a2a.spec.AgentSkill; +import io.a2a.spec.SecurityRequirement; import io.a2a.spec.Artifact; import io.a2a.spec.AuthenticationInfo; import io.a2a.spec.DeleteTaskPushNotificationConfigParams; @@ -100,7 +101,10 @@ public void convertAgentCard() { .build())) // .iconUrl("http://example.com/icon.svg") .securitySchemes(Map.of("basic", HTTPAuthSecurityScheme.builder().scheme("basic").description("Basic Auth").build())) - .securityRequirements(List.of(Map.of("oauth", List.of("read")))) + .securityRequirements(List.of(SecurityRequirement.builder() + .scheme("oauth", + List.of("read")) + .build())) .build(); result = ProtoUtils.ToProto.agentCard(agentCard); assertEquals("Hello World Agent", result.getName()); diff --git a/spec/src/main/java/io/a2a/spec/AgentCard.java b/spec/src/main/java/io/a2a/spec/AgentCard.java index dec9ce722..4bfca0284 100644 --- a/spec/src/main/java/io/a2a/spec/AgentCard.java +++ b/spec/src/main/java/io/a2a/spec/AgentCard.java @@ -1,11 +1,11 @@ package io.a2a.spec; import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.Map; import io.a2a.util.Assert; -import java.util.Collections; import org.jspecify.annotations.Nullable; /** @@ -53,7 +53,7 @@ public record AgentCard( List defaultOutputModes, List skills, @Nullable Map securitySchemes, - @Nullable List>> securityRequirements, + @Nullable List securityRequirements, @Nullable String iconUrl, List supportedInterfaces, @Nullable List signatures) { @@ -152,7 +152,7 @@ public static class Builder { private @Nullable List defaultOutputModes; private @Nullable List skills; private @Nullable Map securitySchemes; - private @Nullable List>> securityRequirements; + private @Nullable List securityRequirements; private @Nullable String iconUrl; private @Nullable List supportedInterfaces; private @Nullable List signatures; @@ -182,7 +182,7 @@ private Builder(AgentCard card) { this.defaultOutputModes = card.defaultOutputModes != null ? new ArrayList<>(card.defaultOutputModes) : Collections.emptyList(); this.skills = card.skills != null ? new ArrayList<>(card.skills) : Collections.emptyList(); this.securitySchemes = card.securitySchemes != null ? Map.copyOf(card.securitySchemes) : Collections.emptyMap(); - this.securityRequirements = card.securityRequirements != null ? new ArrayList<>(card.securityRequirements) :Collections.emptyList(); + this.securityRequirements = card.securityRequirements != null ? new ArrayList<>(card.securityRequirements) : Collections.emptyList(); this.iconUrl = card.iconUrl; this.supportedInterfaces = card.supportedInterfaces != null ? new ArrayList<>(card.supportedInterfaces) : Collections.emptyList(); this.signatures = card.signatures != null ? new ArrayList<>(card.signatures) : null; @@ -316,14 +316,12 @@ public Builder securitySchemes(Map securitySchemes) { /** * Sets the list of security requirements for accessing the agent. - *

- * Each entry in the list represents an alternative security requirement, - * where each map contains scheme names and their required scopes. * * @param securityRequirements the list of security requirements (optional) * @return this builder for method chaining + * @see SecurityRequirement */ - public Builder securityRequirements(List>> securityRequirements) { + public Builder securityRequirements(List securityRequirements) { this.securityRequirements = securityRequirements; return this; } diff --git a/spec/src/main/java/io/a2a/spec/AgentSkill.java b/spec/src/main/java/io/a2a/spec/AgentSkill.java index 6d60baec4..2bddf57bf 100644 --- a/spec/src/main/java/io/a2a/spec/AgentSkill.java +++ b/spec/src/main/java/io/a2a/spec/AgentSkill.java @@ -1,7 +1,6 @@ package io.a2a.spec; import java.util.List; -import java.util.Map; import io.a2a.util.Assert; import org.jspecify.annotations.Nullable; @@ -42,7 +41,7 @@ */ public record AgentSkill(String id, String name, String description, List tags, @Nullable List examples, @Nullable List inputModes, @Nullable List outputModes, - @Nullable List>> securityRequirements) { + @Nullable List securityRequirements) { /** * Compact constructor that validates required fields. @@ -105,7 +104,7 @@ public static class Builder { private @Nullable List examples; private @Nullable List inputModes; private @Nullable List outputModes; - private @Nullable List>> securityRequirements; + private @Nullable List securityRequirements; /** * Creates a new Builder with all fields unset. @@ -215,12 +214,14 @@ public Builder outputModes(List outputModes) { *

* Security requirements override or supplement the agent-level security * defined in the AgentCard. Each entry represents an alternative security - * requirement, where each map contains scheme names and their required scopes. + * requirement (OR relationship). Schemes within a single SecurityRequirement + * must all be satisfied (AND relationship). * * @param securityRequirements list of security requirements (optional) * @return this builder for method chaining + * @see SecurityRequirement */ - public Builder securityRequirements(List>> securityRequirements) { + public Builder securityRequirements(List securityRequirements) { this.securityRequirements = securityRequirements; return this; } diff --git a/spec/src/main/java/io/a2a/spec/SecurityRequirement.java b/spec/src/main/java/io/a2a/spec/SecurityRequirement.java new file mode 100644 index 000000000..1a3729369 --- /dev/null +++ b/spec/src/main/java/io/a2a/spec/SecurityRequirement.java @@ -0,0 +1,110 @@ +package io.a2a.spec; + +import static java.util.Collections.unmodifiableMap; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import io.a2a.util.Assert; + +/** + * Represents a security requirement in the A2A Protocol. + *

+ * A SecurityRequirement defines which security schemes must be satisfied to access an agent or skill. + * It maps security scheme names to lists of required scopes. When multiple scheme entries are present + * in a single SecurityRequirement, ALL must be satisfied (AND relationship). When multiple + * SecurityRequirements are present in a list, any ONE may be satisfied (OR relationship). + *

+ * This class corresponds to the {@code SecurityRequirement} type in the A2A Protocol specification, + * which contains a {@code schemes} field mapping scheme names to scope arrays. + *

+ * Example usage: + *

{@code
+ * // Single OAuth2 requirement with specific scopes
+ * SecurityRequirement oauth2Req = SecurityRequirement.builder()
+ *     .scheme("oauth2", List.of("read", "write"))
+ *     .build();
+ *
+ * // API key requirement with no scopes
+ * SecurityRequirement apiKeyReq = SecurityRequirement.builder()
+ *     .scheme("apiKey", List.of())
+ *     .build();
+ *
+ * // Combined requirement: both OAuth2 AND API key required
+ * SecurityRequirement combinedReq = SecurityRequirement.builder()
+ *     .scheme("oauth2", List.of("profile"))
+ *     .scheme("apiKey", List.of())
+ *     .build();
+ * }
+ * + * @param schemes map of security scheme names to lists of required scopes + * @see SecurityScheme + * @see AgentCard#securityRequirements() + * @see AgentSkill#securityRequirements() + * @see A2A Protocol Specification + */ +public record SecurityRequirement(Map> schemes) { + + /** + * Creates a SecurityRequirement with the specified schemes map. + * + * @param schemes map of security scheme names to lists of required scopes + */ + public SecurityRequirement { + Assert.checkNotNullParam("schemes", schemes); + schemes = unmodifiableMap(new LinkedHashMap<>(schemes)); + } + + /** + * Creates a new Builder for constructing SecurityRequirement instances. + * + * @return a new builder instance + */ + public static Builder builder() { + return new Builder(); + } + + /** + * Builder for constructing immutable {@link SecurityRequirement} instances. + *

+ * Example usage: + *

{@code
+     * SecurityRequirement requirement = SecurityRequirement.builder()
+     *     .scheme("oauth2", List.of("read", "write"))
+     *     .scheme("apiKey", List.of())
+     *     .build();
+     * }
+ */ + public static class Builder { + + private final Map> schemes = new LinkedHashMap<>(); + + /** + * Creates a new Builder with an empty schemes map. + */ + private Builder() { + } + + /** + * Adds a security scheme with its required scopes. + * + * @param schemeName the name of the security scheme (must match a key in securitySchemes) + * @param scopes the list of required scopes for this scheme (empty list for no specific scopes) + * @return this builder for method chaining + */ + public Builder scheme(String schemeName, List scopes) { + this.schemes.put(schemeName, List.copyOf(scopes)); + return this; + } + + /** + * Builds an immutable {@link SecurityRequirement} from the current builder state. + * + * @return a new SecurityRequirement instance + */ + public SecurityRequirement build() { + return new SecurityRequirement(schemes); + } + } +}