From 0ba902cb89103e3d52313d30b8267a4a83842643 Mon Sep 17 00:00:00 2001 From: denazi Date: Thu, 22 Jan 2026 17:01:42 +0200 Subject: [PATCH 1/2] Issue #6: Fix Authorization for BE Service --- Dockerfile | 26 ++++++-- .../etsi/osl/mcp/backend/AuthController.java | 36 +++++++---- .../backend/WebSecurityConfigKeycloak.java | 64 +++++++++++++------ .../configuration/McpConfiguration.java | 30 ++++----- 4 files changed, 103 insertions(+), 53 deletions(-) diff --git a/Dockerfile b/Dockerfile index c09bfca..dc10fc3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,12 +1,30 @@ -FROM eclipse-temurin:17-jre-alpine +# Build stage +FROM maven:3.9-eclipse-temurin-17 AS build MAINTAINER osl.etsi.org +# Set working directory +WORKDIR /app + +# Copy pom.xml and download dependencies +COPY pom.xml . +RUN mvn dependency:go-offline -B || true + +# Copy source code +COPY src ./src + +# Build the application +RUN mvn clean package -DskipTests -B + +# Runtime stage +FROM eclipse-temurin:17-jre-alpine +MAINTAINER osl.etsi.org # Create application directory RUN mkdir -p /opt/openslice/lib/ -# Copy the built JAR from a previous pipeline -COPY /target/org.etsi.osl.mcp.backend-1.0.0-SNAPSHOT.jar /opt/openslice/lib +# Copy the built JAR from build stage +COPY --from=build /app/target/org.etsi.osl.mcp.backend-*.jar /opt/openslice/lib/org.etsi.osl.mcp.backend.jar + # Expose port EXPOSE 11880 @@ -15,4 +33,4 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \ CMD wget --no-verbose --tries=1 --spider http://localhost:11880/actuator/health || exit 1 # Run application -CMD ["java", "-jar", "/opt/openslice/lib/org.etsi.osl.mcp.backend-1.0.0-SNAPSHOT.jar"] +CMD ["java", "-jar", "/opt/openslice/lib/org.etsi.osl.mcp.backend.jar"] diff --git a/src/main/java/org/etsi/osl/mcp/backend/AuthController.java b/src/main/java/org/etsi/osl/mcp/backend/AuthController.java index 1905527..16a4c10 100644 --- a/src/main/java/org/etsi/osl/mcp/backend/AuthController.java +++ b/src/main/java/org/etsi/osl/mcp/backend/AuthController.java @@ -1,11 +1,14 @@ package org.etsi.osl.mcp.backend; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.security.core.Authentication; import org.springframework.security.oauth2.client.OAuth2AuthorizedClient; -import org.springframework.security.oauth2.client.annotation.RegisteredOAuth2AuthorizedClient; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService; import org.springframework.security.oauth2.core.oidc.user.OidcUser; import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler; import org.springframework.web.bind.annotation.GetMapping; @@ -13,19 +16,20 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.util.UriComponentsBuilder; -import java.io.IOException; -import java.util.HashMap; -import java.util.Map; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; @RestController public class AuthController { @Value("${spring.security.oauth2.client.provider.keycloak.issuer-uri:}") private String issuerUri; + + @Autowired(required = false) + private OAuth2AuthorizedClientService authorizedClientService; @GetMapping("/api/user") - public Map getUserInfo(Authentication authentication, - @RegisteredOAuth2AuthorizedClient("keycloak") OAuth2AuthorizedClient authorizedClient) { + public Map getUserInfo(Authentication authentication) { Map userInfo = new HashMap<>(); if (authentication != null && authentication.isAuthenticated()) { @@ -42,9 +46,19 @@ public class AuthController { } userInfo.put("username", username); - if (authorizedClient != null && authorizedClient.getAccessToken() != null) { - userInfo.put("access_token", authorizedClient.getAccessToken().getTokenValue()); - userInfo.put("token", authorizedClient.getAccessToken().getTokenValue()); + // Try to get OAuth2AuthorizedClient if available (from OAuth2 login) + if (authorizedClientService != null && authentication.getName() != null) { + try { + OAuth2AuthorizedClient authorizedClient = + authorizedClientService.loadAuthorizedClient("keycloak", authentication.getName()); + if (authorizedClient != null && authorizedClient.getAccessToken() != null) { + userInfo.put("access_token", authorizedClient.getAccessToken().getTokenValue()); + userInfo.put("token", authorizedClient.getAccessToken().getTokenValue()); + } + } catch (Exception e) { + // OAuth2 client not available - user authenticated via JWT Bearer token + // This is normal for API requests with Bearer tokens + } } } else { userInfo.put("authenticated", false); diff --git a/src/main/java/org/etsi/osl/mcp/backend/WebSecurityConfigKeycloak.java b/src/main/java/org/etsi/osl/mcp/backend/WebSecurityConfigKeycloak.java index 744cd36..51fda1a 100644 --- a/src/main/java/org/etsi/osl/mcp/backend/WebSecurityConfigKeycloak.java +++ b/src/main/java/org/etsi/osl/mcp/backend/WebSecurityConfigKeycloak.java @@ -1,40 +1,66 @@ package org.etsi.osl.mcp.backend; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Profile; -import org.springframework.security.config.Customizer; +import org.springframework.core.annotation.Order; +import org.springframework.http.HttpStatus; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.CsrfConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter; import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.HttpStatusEntryPoint; -import org.springframework.security.web.util.matcher.AntPathRequestMatcher; -import org.springframework.security.web.util.matcher.RequestMatcher; -import org.springframework.http.HttpStatus; @Configuration @EnableWebSecurity @Profile("!testing") public class WebSecurityConfigKeycloak { + @Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri}") + private String issuerUri; + + // Security filter chain for JWT Bearer token only (API endpoints) + @Bean + @Order(1) + SecurityFilterChain apiSecurityFilterChain(HttpSecurity http) throws Exception { + return http + .securityMatcher("/ask/**") + .authorizeHttpRequests(auth -> auth + .anyRequest().authenticated() + ) + .oauth2ResourceServer(oauth2 -> oauth2 + .jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())) + ) + .sessionManagement(session -> session + .sessionCreationPolicy(SessionCreationPolicy.STATELESS) + ) + .exceptionHandling(exception -> exception + .authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)) + ) + .csrf(CsrfConfigurer::disable) + .build(); + } + + // Security filter chain for OAuth2 login (UI) @Bean - SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + @Order(2) + SecurityFilterChain webSecurityFilterChain(HttpSecurity http) throws Exception { return http .authorizeHttpRequests(auth -> auth // Public static resources .requestMatchers("/", "/index.html", "/styles.css", "/robot.svg", "/favicon.ico").permitAll() .requestMatchers("/api/health").permitAll() - .requestMatchers("/api/user").permitAll() - .requestMatchers("/api/logout").permitAll() - .requestMatchers("/logout").permitAll() - .requestMatchers("/ask", "/api/**").authenticated() + // UI-only endpoints (OAuth2 login) + .requestMatchers("/api/user", "/api/logout", "/logout").permitAll() .anyRequest().permitAll() ) - // OAuth2 Login for browser-based authentication + // OAuth2 Login for browser-based authentication (UI) .oauth2Login(oauth2 -> oauth2 .defaultSuccessUrl("/", true) ) @@ -45,17 +71,6 @@ public class WebSecurityConfigKeycloak { .invalidateHttpSession(true) .deleteCookies("JSESSIONID") ) - .oauth2Client(Customizer.withDefaults()) - .oauth2ResourceServer(oauth2 -> oauth2 - .jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())) - .authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)) - ) - .exceptionHandling(exception -> exception - .defaultAuthenticationEntryPointFor( - new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED), - new AntPathRequestMatcher("/ask") - ) - ) .sessionManagement(session -> session .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) ) @@ -73,4 +88,11 @@ public class WebSecurityConfigKeycloak { jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(grantedAuthoritiesConverter); return jwtAuthenticationConverter; } + + @Bean + public JwtDecoder jwtDecoder() { + // Eagerly initialize the JWT decoder on application startup + // This fetches the JWKS from Keycloak immediately + return NimbusJwtDecoder.withJwkSetUri(issuerUri + "/protocol/openid-connect/certs").build(); + } } diff --git a/src/main/java/org/etsi/osl/mcp/backend/configuration/McpConfiguration.java b/src/main/java/org/etsi/osl/mcp/backend/configuration/McpConfiguration.java index d446dc1..87ca06c 100644 --- a/src/main/java/org/etsi/osl/mcp/backend/configuration/McpConfiguration.java +++ b/src/main/java/org/etsi/osl/mcp/backend/configuration/McpConfiguration.java @@ -1,13 +1,6 @@ package org.etsi.osl.mcp.backend.configuration; -import io.modelcontextprotocol.client.transport.customizer.McpSyncHttpClientRequestCustomizer; -import org.springaicommunity.mcp.security.client.sync.AuthenticationMcpTransportContextProvider; -import org.springaicommunity.mcp.security.client.sync.oauth2.http.client.OAuth2AuthorizationCodeSyncHttpRequestCustomizer; - -import org.springframework.ai.mcp.customizer.McpSyncClientCustomizer; -import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager; import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository; @@ -17,17 +10,20 @@ import org.springframework.security.oauth2.client.registration.InMemoryClientReg @Configuration class McpConfiguration { - @Bean - McpSyncHttpClientRequestCustomizer requestCustomizer(OAuth2AuthorizedClientManager oAuth2AuthorizedClientManager, - ClientRegistrationRepository clientRegistrationRepository) { - var registrationId = findUniqueClientRegistration(clientRegistrationRepository); - return new OAuth2AuthorizationCodeSyncHttpRequestCustomizer(oAuth2AuthorizedClientManager, registrationId); - } + // Disabled OAuth2 customization for MCP client - not needed for JWT-based API + // The MCP server connection doesn't need OAuth2 authentication + + // @Bean + // McpSyncHttpClientRequestCustomizer requestCustomizer(OAuth2AuthorizedClientManager oAuth2AuthorizedClientManager, + // ClientRegistrationRepository clientRegistrationRepository) { + // var registrationId = findUniqueClientRegistration(clientRegistrationRepository); + // return new OAuth2AuthorizationCodeSyncHttpRequestCustomizer(oAuth2AuthorizedClientManager, registrationId); + // } - @Bean - McpSyncClientCustomizer syncClientCustomizer() { - return (name, syncSpec) -> syncSpec.transportContextProvider(new AuthenticationMcpTransportContextProvider()); - } + // @Bean + // McpSyncClientCustomizer syncClientCustomizer() { + // return (name, syncSpec) -> syncSpec.transportContextProvider(new AuthenticationMcpTransportContextProvider()); + // } /** * Returns the ID of the {@code spring.security.oauth2.client.registration}, if -- GitLab From e222f3b128249ca1a40d945860f1b3a4a7d4f2dc Mon Sep 17 00:00:00 2001 From: denazi Date: Thu, 22 Jan 2026 17:11:13 +0200 Subject: [PATCH 2/2] Revert Dockerfile --- Dockerfile | 26 ++++---------------------- 1 file changed, 4 insertions(+), 22 deletions(-) diff --git a/Dockerfile b/Dockerfile index dc10fc3..c09bfca 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,30 +1,12 @@ -# Build stage -FROM maven:3.9-eclipse-temurin-17 AS build -MAINTAINER osl.etsi.org - -# Set working directory -WORKDIR /app - -# Copy pom.xml and download dependencies -COPY pom.xml . -RUN mvn dependency:go-offline -B || true - -# Copy source code -COPY src ./src - -# Build the application -RUN mvn clean package -DskipTests -B - -# Runtime stage FROM eclipse-temurin:17-jre-alpine MAINTAINER osl.etsi.org + # Create application directory RUN mkdir -p /opt/openslice/lib/ -# Copy the built JAR from build stage -COPY --from=build /app/target/org.etsi.osl.mcp.backend-*.jar /opt/openslice/lib/org.etsi.osl.mcp.backend.jar - +# Copy the built JAR from a previous pipeline +COPY /target/org.etsi.osl.mcp.backend-1.0.0-SNAPSHOT.jar /opt/openslice/lib # Expose port EXPOSE 11880 @@ -33,4 +15,4 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \ CMD wget --no-verbose --tries=1 --spider http://localhost:11880/actuator/health || exit 1 # Run application -CMD ["java", "-jar", "/opt/openslice/lib/org.etsi.osl.mcp.backend.jar"] +CMD ["java", "-jar", "/opt/openslice/lib/org.etsi.osl.mcp.backend-1.0.0-SNAPSHOT.jar"] -- GitLab