diff --git a/.classpath b/.classpath index e7c4f49ac2055aac6978f0bd949528595cff19cd..3261bfe9bff699cbff09a1c18b2d79dd3e735ee5 100644 --- a/.classpath +++ b/.classpath @@ -1,37 +1,50 @@ - + + + + + + + + - + + - + - - + - + + + + + + - + - + - + - + - + + diff --git a/.project b/.project index ec91655dce6f23bb621faebfc80549036fb2499c..7ae1829f99830031a51ebcc8c32d75f0064e5386 100644 --- a/.project +++ b/.project @@ -20,4 +20,15 @@ org.eclipse.jdt.core.javanature org.eclipse.m2e.core.maven2Nature + + + 1747415794116 + + 30 + + org.eclipse.core.resources.regexFilterMatcher + node_modules|\.git|__CREATED_BY_JAVA_LANGUAGE_SERVER__ + + + diff --git a/.settings/org.eclipse.core.resources.prefs b/.settings/org.eclipse.core.resources.prefs index 29abf999564110a0d6aca109f55f439c72b7031c..742ce1f0065ae0f4cd57127a673e1acdb7df7a56 100644 --- a/.settings/org.eclipse.core.resources.prefs +++ b/.settings/org.eclipse.core.resources.prefs @@ -1,6 +1,6 @@ eclipse.preferences.version=1 -encoding//src/main/java=UTF-8 +encoding//src/main/java=utf-8 encoding//src/main/resources=UTF-8 -encoding//src/test/java=UTF-8 +encoding//src/test/java=utf-8 encoding//src/test/resources=UTF-8 encoding/=UTF-8 diff --git a/.settings/org.eclipse.jdt.core.prefs b/.settings/org.eclipse.jdt.core.prefs index 3328195d65370652055cb326fd9d76eb1bee1841..1c94aa9cfd6dfa7ebb0dde47da29b3e4bf88f2f1 100644 --- a/.settings/org.eclipse.jdt.core.prefs +++ b/.settings/org.eclipse.jdt.core.prefs @@ -1,4 +1,9 @@ eclipse.preferences.version=1 +org.eclipse.jdt.core.compiler.annotation.missingNonNullByDefaultAnnotation=ignore +org.eclipse.jdt.core.compiler.annotation.nonnull=org.springframework.lang.NonNull +org.eclipse.jdt.core.compiler.annotation.nonnullbydefault=org.springframework.lang.NonNullApi +org.eclipse.jdt.core.compiler.annotation.nullable=org.springframework.lang.Nullable +org.eclipse.jdt.core.compiler.annotation.nullanalysis=enabled org.eclipse.jdt.core.compiler.codegen.inlineJsrBytecode=enabled org.eclipse.jdt.core.compiler.codegen.methodParameters=generate org.eclipse.jdt.core.compiler.codegen.targetPlatform=17 @@ -11,6 +16,13 @@ org.eclipse.jdt.core.compiler.problem.assertIdentifier=error org.eclipse.jdt.core.compiler.problem.enablePreviewFeatures=disabled org.eclipse.jdt.core.compiler.problem.enumIdentifier=error org.eclipse.jdt.core.compiler.problem.forbiddenReference=warning +org.eclipse.jdt.core.compiler.problem.nullAnnotationInferenceConflict=warning +org.eclipse.jdt.core.compiler.problem.nullReference=warning +org.eclipse.jdt.core.compiler.problem.nullSpecViolation=warning +org.eclipse.jdt.core.compiler.problem.nullUncheckedConversion=ignore +org.eclipse.jdt.core.compiler.problem.potentialNullReference=warning org.eclipse.jdt.core.compiler.problem.reportPreviewFeatures=warning -org.eclipse.jdt.core.compiler.release=enabled +org.eclipse.jdt.core.compiler.problem.syntacticNullAnalysisForFields=enabled +org.eclipse.jdt.core.compiler.processAnnotations=enabled +org.eclipse.jdt.core.compiler.release=disabled org.eclipse.jdt.core.compiler.source=17 diff --git a/pom.xml b/pom.xml index 07b912380bd3408c154e65fa45a9d1c4dbf94e62..97bd890dd9427bcb93ae55d6cb697bec01e7f83f 100644 --- a/pom.xml +++ b/pom.xml @@ -26,7 +26,7 @@ UTF-8 UTF-8 3.4.5 - 1.0.0-M7 + 1.1.0 1.18.28 2.1.0 1.5.3.Final @@ -98,15 +98,32 @@ org.springframework.ai - spring-ai-starter-mcp-server-webflux + spring-ai-starter-mcp-server-webmvc + + org.springaicommunity + mcp-server-security + 0.0.4 + + + org.springframework.boot + spring-boot-starter-security + + + + + org.springframework.boot + spring-boot-starter-oauth2-resource-server + - - + + com.jayway.jsonpath + json-path + org.projectlombok @@ -133,8 +150,6 @@ - - diff --git a/src/main/java/org/etsi/osl/mcp/server/OSLMCPServerApplication.java b/src/main/java/org/etsi/osl/mcp/server/OSLMCPServerApplication.java index 7d75775f85c92a514bd2f9ee82ed1b72f8303ff6..5808501beeefe89f9577585515e1ca2cb0aa785d 100644 --- a/src/main/java/org/etsi/osl/mcp/server/OSLMCPServerApplication.java +++ b/src/main/java/org/etsi/osl/mcp/server/OSLMCPServerApplication.java @@ -35,11 +35,11 @@ public class OSLMCPServerApplication { SpringApplication.run(OSLMCPServerApplication.class, args); } - @Bean - public ToolCallbackProvider serviceTools( ServiceCatalogTools oslServices) { - return MethodToolCallbackProvider.builder().toolObjects( oslServices ).build(); - } - +// @Bean +// public ToolCallbackProvider serviceTools( ServiceCatalogTools oslServices) { +// return MethodToolCallbackProvider.builder().toolObjects( oslServices ).build(); +// } +// @Bean diff --git a/src/main/java/org/etsi/osl/mcp/server/ProductCatalogTools.java b/src/main/java/org/etsi/osl/mcp/server/ProductCatalogTools.java index 8d966452e2451de547a8515ef2072429c9c4c351..d3ae3208e062d5fbe5f6ff66f8f65dbaf4e0cfec 100644 --- a/src/main/java/org/etsi/osl/mcp/server/ProductCatalogTools.java +++ b/src/main/java/org/etsi/osl/mcp/server/ProductCatalogTools.java @@ -26,6 +26,8 @@ import org.etsi.osl.tmf.prm669.model.RelatedParty; import org.etsi.osl.tmf.ri639.model.Resource; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springaicommunity.mcp.annotation.McpTool; +import org.springaicommunity.mcp.context.McpSyncRequestContext; import org.springframework.ai.tool.annotation.Tool; import org.springframework.beans.factory.annotation.Autowired; @@ -167,12 +169,35 @@ public class ProductCatalogTools { } - @Tool(description = "Search for OSL product Offerings that are published and available for product ordering in all categories") - public JsonNode searchOSLProductOfferings( List searchStrings) { + @McpTool(description = "Search for OSL product Offerings that are published and available for product ordering in all categories") + public JsonNode searchOSLProductOfferings( + McpSyncRequestContext context,List searchStrings) { - logger.info("searchOSLProductOfferings containing workds: {}", searchStrings); + // Send logging notification + context.info("Processing data: " + searchStrings); + // Send progress notification (using convenient method) + context.progress(p -> p.progress(0.5).total(1.0).message("Processing...")); + logger.info("searchOSLServiceSpecifications containing words: {}", searchStrings); + + // Split strings that contain multiple words separated by comma or space + List expandedSearchStrings = new java.util.ArrayList<>(); + for (String searchString : searchStrings) { + if (searchString.contains(",") || searchString.contains(" ")) { + // Split by comma or space and add each word separately + String[] words = searchString.split("[,\\s]+"); + for (String word : words) { + String trimmedWord = word.trim(); + if (!trimmedWord.isEmpty()) { + expandedSearchStrings.add(trimmedWord); + } + } + } else { + expandedSearchStrings.add(searchString); + } + } + logger.info("Expanded search strings: {}", expandedSearchStrings); - List spec = aCatalogClient.searchProductOfferings( searchStrings ); + List spec = aCatalogClient.searchProductOfferings( expandedSearchStrings ); // Filter and get result as JSON string try { diff --git a/src/main/java/org/etsi/osl/mcp/server/ServiceCatalogQClient.java b/src/main/java/org/etsi/osl/mcp/server/ServiceCatalogQClient.java index a9756afa7689eb68870245bd9929f8d63663ad80..a08fd4b11864c1281a5b337af0e288b0c579ae60 100644 --- a/src/main/java/org/etsi/osl/mcp/server/ServiceCatalogQClient.java +++ b/src/main/java/org/etsi/osl/mcp/server/ServiceCatalogQClient.java @@ -107,7 +107,7 @@ public class ServiceCatalogQClient extends RouteBuilder { * @throws IOException */ public ServiceSpecification retrieveServiceSpec(String specid) { - logger.info("will retrieve Service Specification from catalog orderid=" + specid ); + logger.info("will retrieve Service Specification from catalog specid=" + specid ); try { Object response = template. diff --git a/src/main/java/org/etsi/osl/mcp/server/ServiceCatalogTools.java b/src/main/java/org/etsi/osl/mcp/server/ServiceCatalogTools.java index 0508f4003d574ea6e3b62abade0ab36f45683cee..10b27d945f7092ba2193bda25c2f167166348c40 100644 --- a/src/main/java/org/etsi/osl/mcp/server/ServiceCatalogTools.java +++ b/src/main/java/org/etsi/osl/mcp/server/ServiceCatalogTools.java @@ -2,14 +2,8 @@ package org.etsi.osl.mcp.server; import java.io.IOException; import java.time.OffsetDateTime; -import java.util.Arrays; -import java.util.HashSet; import java.util.List; import java.util.Map; -import java.util.Set; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; import org.etsi.osl.tmf.common.model.Any; import org.etsi.osl.tmf.common.model.service.Characteristic; import org.etsi.osl.tmf.common.model.service.Note; @@ -17,10 +11,8 @@ import org.etsi.osl.tmf.common.model.service.ServiceSpecificationRef; import org.etsi.osl.tmf.prm669.model.RelatedParty; import org.etsi.osl.tmf.rcm634.model.LogicalResourceSpecification; import org.etsi.osl.tmf.ri639.model.Resource; -import org.etsi.osl.tmf.scm633.model.ServiceCandidate; import org.etsi.osl.tmf.scm633.model.ServiceCatalog; import org.etsi.osl.tmf.scm633.model.ServiceCategory; -import org.etsi.osl.tmf.scm633.model.ServiceCategoryRef; import org.etsi.osl.tmf.scm633.model.ServiceSpecification; import org.etsi.osl.tmf.sim638.model.Service; import org.etsi.osl.tmf.sim638.model.ServiceUpdate; @@ -31,14 +23,14 @@ import org.etsi.osl.tmf.so641.model.ServiceOrderStateType; import org.etsi.osl.tmf.so641.model.ServiceRestriction; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.ai.chat.model.ToolContext; -import org.springframework.ai.tool.annotation.Tool; +import org.springaicommunity.mcp.annotation.McpTool; +import org.springaicommunity.mcp.annotation.McpToolParam; +import org.springaicommunity.mcp.context.McpSyncRequestContext; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.core.ParameterizedTypeReference; -//import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.web.client.RestClient; -import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.context.SecurityContextHolder; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; /** * @@ -57,21 +49,24 @@ public class ServiceCatalogTools { ServiceCatalogQClient aCatalogClient; -// @Tool(description="Get your name") +// @McpTool(description="Get your name") // public String getYourName(ToolContext context) { // logger.info("ToolContext: {}", McpRequestHolder.get(context).headers()); // return SecurityContextHolder.getContext().getAuthentication().getName(); // } - @Tool(description = "Get a list of all published OSL OpenSlice service catalogs." + @McpTool (description = "Get a list of all published OSL OpenSlice service catalogs." + "Each catalog contains service categories, that we can search individually to get the details and contents of each category.") public JsonNode getOSLServiceCatalogs() { logger.info("getOSLServiceCatalogs"); List serviceCatalogs = aCatalogClient.retrieveServiceCatalogs(); - + var authentication = SecurityContextHolder.getContext().getAuthentication(); + var name = authentication.getName(); + + logger.info("getOSLServiceCatalogs {}", name); // Filter and get result as JSON string try { @@ -89,8 +84,8 @@ public class ServiceCatalogTools { } - @Tool(description = "Get OSL categories in catalog providing a catalog name") - public JsonNode getOSLServiceCategories(String catalogName) { + @McpTool(description = "Get OSL categories in catalog providing a catalog name") + public JsonNode getOSLServiceCategories(@McpToolParam(description = "The service catalog name", required = true) String catalogName) { logger.info("getOSLServiceCategories {}", catalogName); @@ -113,8 +108,8 @@ public class ServiceCatalogTools { } - @Tool(description = "Get a list of OSL service specification references in a service category, given a category ID") - public JsonNode getOSLServiceSpecsInCategory(String categoryId) { + @McpTool(description = "Get a list of OSL service specification references in a service category, given a category ID") + public JsonNode getOSLServiceSpecsInCategory(@McpToolParam(description = "The categoryId needed to search service specification references", required = true) String categoryId) { logger.info("getOSLServiceSpecsInCategory {}", categoryId); @@ -141,8 +136,9 @@ public class ServiceCatalogTools { } - @Tool(description = "Get all the details of an OSL service specification given a service Specification Id") - public JsonNode getOSLServiceSpecificationByServiceSpecificationId(String serviceSpecId) { + @McpTool(description = "Get all the details of an OSL service specification given a service Specification Id") + public JsonNode getOSLServiceSpecificationByServiceSpecificationId( + @McpToolParam(description = "The service Specification Id needed to get service specification details", required = true) String serviceSpecId) { logger.info("getOSLServiceByServiceSpecificationId {}", serviceSpecId); @@ -166,8 +162,9 @@ public class ServiceCatalogTools { - @Tool(description = "Get all the details of an OSL resource specification given a resource Specification Id") - public JsonNode getOSLResourceSpecificationByResourceSpecificationId(String resourceSpecId) { + @McpTool(description = "Get all the details of an OSL resource specification given a resource Specification Id") + public JsonNode getOSLResourceSpecificationByResourceSpecificationId( + @McpToolParam(description = "The resource Specification Id needed to get resource specification details", required = true) String resourceSpecId) { logger.info("getOSLResourceSpecificationByResourceSpecificationId {}", resourceSpecId); @@ -190,13 +187,40 @@ public class ServiceCatalogTools { - @Tool(description = "Search for OSL service specifications that are published and available for service ordering in all categories") - public JsonNode searchOSLServiceSpecifications( List searchStrings) { - + @McpTool(description = "Search for OSL service specifications that are published and available for service ordering in all categories") + public JsonNode searchOSLServiceSpecifications( + McpSyncRequestContext context, + @McpToolParam(description = "A list of search strings", required = true) List searchStrings) { + + // Send logging notification + context.info("Processing data: " + searchStrings); + // Send progress notification (using convenient method) + context.progress(p -> p.progress(0.5).total(1.0).message("Processing...")); logger.info("searchOSLServiceSpecifications containing words: {}", searchStrings); - List spec = aCatalogClient.searchServiceSpecs( searchStrings ); + // Split strings that contain multiple words separated by comma or space + List expandedSearchStrings = new java.util.ArrayList<>(); + for (String searchString : searchStrings) { + if (searchString.contains(",") || searchString.contains(" ")) { + // Split by comma or space and add each word separately + String[] words = searchString.split("[,\\s]+"); + for (String word : words) { + String trimmedWord = word.trim(); + if (!trimmedWord.isEmpty()) { + expandedSearchStrings.add(trimmedWord); + } + } + } else { + expandedSearchStrings.add(searchString); + } + } + logger.info("Expanded search strings: {}", expandedSearchStrings); + List spec = aCatalogClient.searchServiceSpecs( expandedSearchStrings ); + + // Ping the client + context.ping(); + // Filter and get result as JSON string try { @@ -213,12 +237,20 @@ public class ServiceCatalogTools { return rootNode; } - @Tool(description = "Create a service order given a Service Specification id, the Start date an end date of the order. " + @PreAuthorize("isAuthenticated()") + @McpTool(description = "Create a service order given a Service Specification id, the Start date an end date of the order. " + "The user can provide also characteristics of service in the map with format key, value" + "Date Time has the format YYYY-MM-DDTHH:mm:ss+00:00") - public String createServiceOrder(String serviceSpecId, String startDate, String endDate, Map characteristics) { + public String createServiceOrder( + @McpToolParam(description = "The Service Specification id", required = true) String serviceSpecId, + @McpToolParam(description = "The start date", required = true) String startDate, + @McpToolParam(description = "Then end date", required = true) String endDate, + @McpToolParam(description = "A list of characteristics, key=characteristic name, value=characterisitc value", required = true) Map characteristics) { - logger.info("createServiceOrder {} {} {} {}", serviceSpecId, startDate, endDate, characteristics.toString()); + var authentication = SecurityContextHolder.getContext().getAuthentication(); + var username = authentication.getName(); + + logger.info("createServiceOrder {} {} {} {} {}", username, serviceSpecId, startDate, endDate, characteristics.toString()); ServiceOrderCreate sonew = new ServiceOrderCreate(); @@ -244,16 +276,14 @@ public class ServiceCatalogTools { if (sonew.getRelatedParty() == null) { RelatedParty rp = new RelatedParty(); - rp.setName("MCP"); + rp.setName(username); rp.setRole("REQUESTER"); sonew.addRelatedPartyItem(rp); } if (sonew.getNote() == null) { Note n = new Note(); - - n.setText( "Order created by MCP"); - + n.setText( "Order created by MCP for user: " + username); sonew.addNoteItem(n); } @@ -295,12 +325,13 @@ public class ServiceCatalogTools { } - @Tool(description = "Provide details for a service order given a Service Order id. " + @McpTool(description = "Provide details for a service order given a Service Order id. " + "Focus attention to:" + "- the state of the service order" + "- and each order item. Especially for each order item focus to the service and especially: status, characteristics and supporting services." + "- For each supporting service we can retrieve more information by using the service id.") - public JsonNode getServiceOrder(String serviceOrderId) { + public JsonNode getServiceOrder( + @McpToolParam(description = "The Service Order id", required = true) String serviceOrderId) { logger.info("serviceOrderId {} {} {} {}", serviceOrderId); ServiceOrder so = aCatalogClient.retrieveServiceOrder(serviceOrderId); @@ -324,10 +355,10 @@ public class ServiceCatalogTools { } - @Tool(description = "Provide details for a service given a Service id. Especially for name, state, characteristics, supporting Services and supporting Resources." + @McpTool(description = "Provide details for a service given a Service id. Especially for name, state, characteristics, supporting Services and supporting Resources." + "We can get details for each supporting service using the service id." + "and for each supporting resource using the resource id.") - public JsonNode getService(String serviceId) { + public JsonNode getService(@McpToolParam(description = "The Service id", required = true) String serviceId) { logger.info("getService {} {} {} {}", serviceId); Service s = aCatalogClient.retrieveService(serviceId); // Filter and get result as JSON string @@ -348,9 +379,9 @@ public class ServiceCatalogTools { } - @Tool(description = "Provide details for a resource given a Resourceid. Especially for name, status, characteristics." + @McpTool(description = "Provide details for a resource given a Resourceid. Especially for name, status, characteristics." + "We can get details for each supporting resource using the resource id.") - public JsonNode getResource(String resourceId) { + public JsonNode getResource(@McpToolParam(description = "The Resource id", required = true) String resourceId) { logger.info("getResource {} {} {} {}", resourceId); Resource r = aCatalogClient.retrieveResource ( resourceId); // Filter and get result as JSON string @@ -371,9 +402,11 @@ public class ServiceCatalogTools { } - @Tool(description = "Update and change a service given a Service id." + @McpTool(description = "Update and change a service given a Service id." + "We provide also characteristics of service in the map with format key, value") - public String updateService(String serviceId, Map characteristics) { + public String updateService( + @McpToolParam(description = "The Service id", required = true) String serviceId, + @McpToolParam(description = "A list of characteristics, key=characteristic name, value=characterisitc value", required = true) Map characteristics) { logger.info("updateService {} {} {} {}", serviceId); ServiceUpdate su = new ServiceUpdate(); diff --git a/src/main/java/org/etsi/osl/mcp/server/WebSecurityConfigKeycloak.java b/src/main/java/org/etsi/osl/mcp/server/WebSecurityConfigKeycloak.java new file mode 100644 index 0000000000000000000000000000000000000000..58655535680ce1ae3ffa4bbd4fec6f8dc5f96ac6 --- /dev/null +++ b/src/main/java/org/etsi/osl/mcp/server/WebSecurityConfigKeycloak.java @@ -0,0 +1,335 @@ +package org.etsi.osl.mcp.server; + +import java.net.URL; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import com.jayway.jsonpath.JsonPath; +import com.jayway.jsonpath.PathNotFoundException; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.web.ServerProperties; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.NestedConfigurationProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.core.convert.converter.Converter; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.AuthenticationManagerResolver; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtClaimNames; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtDecoders; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationProvider; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; +import org.springframework.security.oauth2.server.resource.authentication.JwtIssuerAuthenticationManagerResolver; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.stereotype.Component; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import jakarta.servlet.http.HttpServletRequest; +import lombok.Data; +import lombok.RequiredArgsConstructor; + +@Configuration +@EnableWebSecurity +@EnableMethodSecurity +@Profile("!testing") +public class WebSecurityConfigKeycloak { + + @Bean + SecurityFilterChain filterChain(HttpSecurity http, ServerProperties serverProperties, + @Value("${origins:[]}") String[] origins, @Value("${permit-all:[]}") String[] permitAll, + AuthenticationManagerResolver authenticationManagerResolver) throws Exception { + + http.oauth2ResourceServer(oauth2 -> oauth2.authenticationManagerResolver(authenticationManagerResolver)); + + // Enable and configure CORS + http.cors(cors -> cors.configurationSource(corsConfigurationSource(origins))); + + // State-less session (state in access-token only) + http.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); + + // Disable CSRF because of state-less session-management + http.csrf(csrf -> csrf.disable()); + + // Return 401 (unauthorized) instead of 302 (redirect to login) when + // authorization is missing or invalid + http.exceptionHandling(eh -> eh.authenticationEntryPoint((request, response, authException) -> { + response.addHeader(HttpHeaders.WWW_AUTHENTICATE, "Basic realm=\"Restricted Content\""); + response.sendError(HttpStatus.UNAUTHORIZED.value(), HttpStatus.UNAUTHORIZED.getReasonPhrase()); + })); + + // If SSL enabled, disable http (https only) + if (serverProperties.getSsl() != null && serverProperties.getSsl().isEnabled()) { + http.requiresChannel(channel -> channel.anyRequest().requiresSecure()); + } + + // @formatter:off + http.authorizeHttpRequests(requests -> requests + //.requestMatchers(permitAll).permitAll() + .anyRequest().permitAll()); + // @formatter:on + + return http.build(); + } + + private UrlBasedCorsConfigurationSource corsConfigurationSource(String[] origins) { + final var configuration = new CorsConfiguration(); + configuration.setAllowedOrigins(Arrays.asList(origins)); + configuration.setAllowedMethods(List.of("*")); + configuration.setAllowedHeaders(List.of("*")); + configuration.setExposedHeaders(List.of("*")); + configuration.setAllowCredentials(true); + + final var source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } + + @Data + @Configuration + @ConfigurationProperties(prefix = "spring-addons") + static class SpringAddonsProperties { + private IssuerProperties[] issuers = {}; + + @Data + static class IssuerProperties { + private URL uri; + + @NestedConfigurationProperty + private ClaimMappingProperties[] claims; + + private String usernameJsonPath = JwtClaimNames.SUB; + + @Data + static class ClaimMappingProperties { + private String jsonPath; + private CaseProcessing caseProcessing = CaseProcessing.UNCHANGED; + private String prefix = ""; + + static enum CaseProcessing { + UNCHANGED, TO_LOWER, TO_UPPER + } + } + } + + public IssuerProperties get(URL issuerUri) throws MisconfigurationException { + final var issuerProperties = Stream.of(issuers).filter(iss -> issuerUri.toString().equals(iss.getUri().toString())).toList(); + if (issuerProperties.size() == 0) { + throw new MisconfigurationException( + "Missing authorities mapping properties for %s".formatted(issuerUri.toString())); + } + if (issuerProperties.size() > 1) { + throw new MisconfigurationException( + "Too many authorities mapping properties for %s".formatted(issuerUri.toString())); + } + return issuerProperties.get(0); + } + + static class MisconfigurationException extends RuntimeException { + private static final long serialVersionUID = 5887967904749547431L; + + public MisconfigurationException(String msg) { + super(msg); + } + } + } + + @RequiredArgsConstructor + static class JwtGrantedAuthoritiesConverter implements Converter> { + private final SpringAddonsProperties.IssuerProperties properties; + + @Override + @SuppressWarnings({ "rawtypes", "unchecked" }) + public Collection convert(Jwt jwt) { + + + return Stream.of(properties.claims).flatMap(claimProperties -> { + Object claim; + try { + claim = JsonPath.read(jwt.getClaims(), claimProperties.jsonPath); + } catch (PathNotFoundException e) { + claim = null; + } + if (claim == null) { + return Stream.empty(); + } + if (claim instanceof String claimStr) { + return Stream.of(claimStr.split(",")); + } + if (claim instanceof String[] claimArr) { + return Stream.of(claimArr); + } + if (Collection.class.isAssignableFrom(claim.getClass())) { + final var iter = ((Collection) claim).iterator(); + if (!iter.hasNext()) { + return Stream.empty(); + } + final var firstItem = iter.next(); + if (firstItem instanceof String) { + return (Stream) ((Collection) claim).stream(); + } + if (Collection.class.isAssignableFrom(firstItem.getClass())) { + return (Stream) ((Collection) claim).stream() + .flatMap(colItem -> ((Collection) colItem).stream()).map(String.class::cast); + } + } + return Stream.empty(); + }) /* Insert some transformation here if you want to add a prefix like "ROLE_" or force upper-case authorities */ + + .map(s -> "ROLE_" + s) + .map(SimpleGrantedAuthority::new) + .map(GrantedAuthority.class::cast).toList(); + } + } + + @Component + @RequiredArgsConstructor + static class SpringAddonsJwtAuthenticationConverter implements Converter { + private final SpringAddonsProperties springAddonsProperties; + + @Override + public AbstractAuthenticationToken convert(Jwt jwt) { + final var issuerProperties = springAddonsProperties.get(jwt.getIssuer()); + final var authorities = new JwtGrantedAuthoritiesConverter(issuerProperties).convert(jwt); + final String username = JsonPath.read(jwt.getClaims(), issuerProperties.getUsernameJsonPath()); + return new JwtAuthenticationToken(jwt, authorities, username); + } + } + + @Bean + AuthenticationManagerResolver authenticationManagerResolver( + SpringAddonsProperties addonsProperties, SpringAddonsJwtAuthenticationConverter authenticationConverter) { + final Map authenticationProviders = Stream.of(addonsProperties.getIssuers()) + .map(SpringAddonsProperties.IssuerProperties::getUri).map(URL::toString) + .collect(Collectors.toMap(issuer -> issuer, + issuer -> authenticationProvider(issuer, authenticationConverter)::authenticate)); + return new JwtIssuerAuthenticationManagerResolver( + (AuthenticationManagerResolver) authenticationProviders::get); + } + + JwtAuthenticationProvider authenticationProvider(String issuer, + SpringAddonsJwtAuthenticationConverter authenticationConverter) { + JwtDecoder decoder = JwtDecoders.fromIssuerLocation(issuer); + var provider = new JwtAuthenticationProvider(decoder); + provider.setJwtAuthenticationConverter(authenticationConverter); + return provider; + } +} + +//@Configuration +//@EnableWebSecurity +//@ComponentScan(basePackageClasses = KeycloakSecurityComponents.class) +//@Profile("!testing") +//public class WebSecurityConfigKeycloak extends KeycloakWebSecurityConfigurerAdapter { +// +// +// +// @Autowired +// private RestAuthenticationEntryPoint restAuthenticationEntryPoint; +// +// @Autowired +// public void configureGlobal( +// AuthenticationManagerBuilder auth) throws Exception { +// +// KeycloakAuthenticationProvider keycloakAuthenticationProvider +// = keycloakAuthenticationProvider(); +// keycloakAuthenticationProvider.setGrantedAuthoritiesMapper( +// new SimpleAuthorityMapper()); +// auth.authenticationProvider(keycloakAuthenticationProvider); +// } +// +// @Bean +// public KeycloakSpringBootConfigResolver KeycloakConfigResolver() { +// return new KeycloakSpringBootConfigResolver(); +// } +// +// @Bean +// @Override +// protected SessionAuthenticationStrategy sessionAuthenticationStrategy() { +// return new RegisterSessionAuthenticationStrategy( +// new SessionRegistryImpl()); +// } +// +//// +//// @Override +//// @Bean("authenticationManager") +//// public AuthenticationManager authenticationManagerBean() throws Exception { +//// return super.authenticationManagerBean(); +//// } +// +// //see also https://www.baeldung.com/securing-a-restful-web-service-with-spring-security +// @Override +// protected void configure(final HttpSecurity http) throws Exception { +// // @formatter:off +// http.authorizeRequests() +//// .antMatchers("/sessions/**").permitAll() +//// .antMatchers("/register/**").permitAll() +//// //.antMatchers("/sessions/logout").permitAll() +//// .antMatchers("/categories/**").permitAll() +//// .antMatchers("/experiments/**").permitAll() +//// .antMatchers("/vxfs/**").permitAll() +//// .antMatchers("/login").permitAll() +//// .antMatchers("/images/**").permitAll() +//// .antMatchers("/packages/**").permitAll() +//// .antMatchers("/testweb/**").permitAll() +//// .antMatchers("/oauth/token/revokeById/**").permitAll() +//// .antMatchers("/tokens/**").permitAll() +//// .antMatchers("/actuator/**").permitAll() +//// .antMatchers("/swagger/**").permitAll() +//// .antMatchers("/v2/**").permitAll() +//// .antMatchers("/swagger-ui.html").permitAll() +//// .antMatchers("/webjars/**").permitAll() +//// .antMatchers("/swagger-resources/**").permitAll() +// //.antMatchers("/admin/**").permitAll()//.hasAnyRole("admin","user","ROLE_admin","ROLE_user") +// +// .anyRequest().permitAll() +// //.and().formLogin().permitAll() +// .and().csrf().disable() +// //.cors().and().csrf().disable() // we use the filter..see below +// .exceptionHandling() +// .authenticationEntryPoint(restAuthenticationEntryPoint) +// .and() +// .logout(); +// // @formatter:on +// } +// +// +// @Bean +// public FilterRegistrationBean corsFilter() { +// +// UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); +// CorsConfiguration config = new CorsConfiguration(); +// config.setAllowCredentials(true); +// config.setAllowedOriginPatterns(Collections.singletonList("*")); +// config.addAllowedHeader("*"); +// config.addAllowedMethod("*"); +// config.addAllowedOriginPattern( "*" ); +// source.registerCorsConfiguration("/**", config); +// FilterRegistrationBean bean = new FilterRegistrationBean(new CorsFilter(source)); +// +// bean.setOrder(0); +// +// return bean; +// +// } +// +// +// +//} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index d31c35db973a5ce2f3b1ce3941492d79b4ca61be..41655a576fc6aa1934789e5d6ef48913d8e1767e 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -12,6 +12,7 @@ spring: name: org.etsi.osl.mcp.server version: 0.0.1 type: SYNC + protocol: STREAMABLE sse-message-endpoint: /mcp/messages stdio: false resource-change-notification: true @@ -32,7 +33,15 @@ spring: jwt: issuer-uri: http://keycloak:8080/auth/realms/openslice jwk-set-uri: http://keycloak:8080/auth/realms/openslice/.well-known/openid-configuration - + + +spring-addons: + issuers: + - uri: http://keycloak:8080/auth/realms/openslice + username-json-path: $.preferred_username + claims: + - jsonPath: $.realm_access.roles + - jsonPath: $.resource_access.*.roles logging: level: