From 3bd07c9b07332468e5afc25724ec923147b4ff28 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sun, 5 Apr 2026 13:10:08 +0200 Subject: [PATCH] feat: add OIDC resource server support with JWKS discovery and scope-based roles Co-Authored-By: Claude Sonnet 4.6 --- .../app/security/JwtAuthenticationFilter.java | 87 +++++++++++++----- .../server/app/security/SecurityConfig.java | 89 ++++++++++++++++++- 2 files changed, 155 insertions(+), 21 deletions(-) diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/JwtAuthenticationFilter.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/JwtAuthenticationFilter.java index 1637c570..bd659639 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/JwtAuthenticationFilter.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/JwtAuthenticationFilter.java @@ -13,6 +13,8 @@ import org.springframework.security.authentication.UsernamePasswordAuthenticatio import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtDecoder; import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; @@ -22,8 +24,9 @@ import java.util.List; * JWT authentication filter that extracts and validates JWT tokens from * the {@code Authorization: Bearer} header or the {@code token} query parameter. *

- * Populates Spring Security {@code GrantedAuthority} from the JWT {@code roles} claim. - * Agent tokens without roles get {@code ROLE_AGENT}; UI tokens get authorities from the claim. + * Tries internal HMAC validation first (agents, local users). If that fails and an + * OIDC {@link JwtDecoder} is configured, falls back to OIDC token validation + * (SaaS M2M tokens, external OIDC users). Scope-based role mapping for OIDC tokens. *

* Not annotated {@code @Component} -- constructed explicitly in {@link SecurityConfig} * to avoid double filter registration. @@ -36,10 +39,14 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtService jwtService; private final AgentRegistryService agentRegistryService; + private final JwtDecoder oidcDecoder; - public JwtAuthenticationFilter(JwtService jwtService, AgentRegistryService agentRegistryService) { + public JwtAuthenticationFilter(JwtService jwtService, + AgentRegistryService agentRegistryService, + JwtDecoder oidcDecoder) { this.jwtService = jwtService; this.agentRegistryService = agentRegistryService; + this.oidcDecoder = oidcDecoder; } @Override @@ -49,29 +56,69 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { String token = extractToken(request); if (token != null) { - try { - JwtValidationResult result = jwtService.validateAccessToken(token); - String subject = result.subject(); - - // Authenticate any valid JWT — agent registry is not authoritative - // (agents may hold valid tokens after server restart clears the in-memory registry) - List roles = result.roles(); - if (!subject.startsWith("user:") && roles.isEmpty()) { - roles = List.of("AGENT"); - } - List authorities = toAuthorities(roles); - UsernamePasswordAuthenticationToken auth = - new UsernamePasswordAuthenticationToken(subject, null, authorities); - SecurityContextHolder.getContext().setAuthentication(auth); - request.setAttribute(JWT_RESULT_ATTR, result); - } catch (Exception e) { - log.debug("JWT validation failed: {}", e.getMessage()); + if (tryInternalToken(token, request)) { + chain.doFilter(request, response); + return; + } + if (oidcDecoder != null) { + tryOidcToken(token, request); } } chain.doFilter(request, response); } + private boolean tryInternalToken(String token, HttpServletRequest request) { + try { + JwtValidationResult result = jwtService.validateAccessToken(token); + String subject = result.subject(); + + List roles = result.roles(); + if (!subject.startsWith("user:") && roles.isEmpty()) { + roles = List.of("AGENT"); + } + List authorities = toAuthorities(roles); + UsernamePasswordAuthenticationToken auth = + new UsernamePasswordAuthenticationToken(subject, null, authorities); + SecurityContextHolder.getContext().setAuthentication(auth); + request.setAttribute(JWT_RESULT_ATTR, result); + return true; + } catch (Exception e) { + log.debug("Internal JWT validation failed: {}", e.getMessage()); + return false; + } + } + + private void tryOidcToken(String token, HttpServletRequest request) { + try { + Jwt jwt = oidcDecoder.decode(token); + List roles = extractRolesFromScopes(jwt); + List authorities = toAuthorities(roles); + UsernamePasswordAuthenticationToken auth = + new UsernamePasswordAuthenticationToken( + "oidc:" + jwt.getSubject(), null, authorities); + SecurityContextHolder.getContext().setAuthentication(auth); + } catch (Exception e) { + log.debug("OIDC token validation failed: {}", e.getMessage()); + } + } + + /** + * Maps OAuth2 scopes to server RBAC roles. + * Scopes are defined on the Logto API Resource for this server. + */ + private List extractRolesFromScopes(Jwt jwt) { + String scopeStr = jwt.getClaimAsString("scope"); + if (scopeStr == null || scopeStr.isBlank()) { + return List.of("VIEWER"); + } + List scopes = List.of(scopeStr.split(" ")); + if (scopes.contains("admin")) return List.of("ADMIN"); + if (scopes.contains("operator")) return List.of("OPERATOR"); + if (scopes.contains("viewer")) return List.of("VIEWER"); + return List.of("VIEWER"); + } + private List toAuthorities(List roles) { return roles.stream() .map(role -> (GrantedAuthority) new SimpleGrantedAuthority("ROLE_" + role)) diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityConfig.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityConfig.java index 73ecac06..b388d793 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityConfig.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityConfig.java @@ -2,6 +2,16 @@ package com.cameleer3.server.app.security; import com.cameleer3.server.core.agent.AgentRegistryService; import com.cameleer3.server.core.security.JwtService; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.jwk.source.JWKSourceBuilder; +import com.nimbusds.jose.proc.JWSVerificationKeySelector; +import com.nimbusds.jose.proc.SecurityContext; +import com.nimbusds.jwt.proc.DefaultJWTProcessor; +import com.nimbusds.openid.connect.sdk.op.OIDCProviderMetadata; +import net.minidev.json.JSONObject; +import net.minidev.json.parser.JSONParser; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; @@ -11,6 +21,13 @@ 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.AbstractHttpConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator; +import org.springframework.security.oauth2.core.OAuth2TokenValidator; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtClaimValidator; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtValidators; +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.HttpStatusEntryPoint; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; @@ -18,24 +35,46 @@ import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import java.io.InputStream; +import java.net.URI; +import java.net.URL; import java.util.List; +import java.util.Set; /** * Spring Security configuration for JWT-based stateless authentication with RBAC. *

* Public endpoints: health, agent registration, refresh, auth, API docs, Swagger UI, static resources. * All other endpoints require a valid JWT access token with appropriate roles. + *

+ * When {@code security.oidc-issuer-uri} is configured, builds an OIDC {@link JwtDecoder} + * for validating external access tokens (Logto M2M / OIDC user tokens) as a fallback + * after internal HMAC validation. */ @Configuration @EnableWebSecurity @EnableMethodSecurity public class SecurityConfig { + private static final Logger log = LoggerFactory.getLogger(SecurityConfig.class); + @Bean public SecurityFilterChain filterChain(HttpSecurity http, JwtService jwtService, AgentRegistryService registryService, + SecurityProperties securityProperties, CorsConfigurationSource corsConfigurationSource) throws Exception { + JwtDecoder oidcDecoder = null; + String issuer = securityProperties.getOidcIssuerUri(); + if (issuer != null && !issuer.isBlank()) { + try { + oidcDecoder = buildOidcDecoder(securityProperties); + log.info("OIDC resource server enabled: issuer={}", issuer); + } catch (Exception e) { + log.error("Failed to initialize OIDC decoder for issuer={}: {}", issuer, e.getMessage()); + } + } + http .cors(cors -> cors.configurationSource(corsConfigurationSource)) .csrf(AbstractHttpConfigurer::disable) @@ -101,13 +140,61 @@ public class SecurityConfig { .authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)) ) .addFilterBefore( - new JwtAuthenticationFilter(jwtService, registryService), + new JwtAuthenticationFilter(jwtService, registryService, oidcDecoder), UsernamePasswordAuthenticationFilter.class ); return http.build(); } + /** + * Builds an OIDC {@link JwtDecoder} for validating external access tokens. + * Discovers JWKS URI from the OIDC well-known endpoint. Handles Logto's + * {@code at+jwt} token type (RFC 9068) by accepting any JWT type. + */ + private JwtDecoder buildOidcDecoder(SecurityProperties properties) throws Exception { + String issuerUri = properties.getOidcIssuerUri(); + + // Discover JWKS URI and supported algorithms from OIDC discovery + String discoveryUrl = issuerUri.endsWith("/") + ? issuerUri + ".well-known/openid-configuration" + : issuerUri + "/.well-known/openid-configuration"; + URL url = new URI(discoveryUrl).toURL(); + OIDCProviderMetadata metadata; + try (InputStream in = url.openStream()) { + JSONObject json = (JSONObject) new JSONParser(JSONParser.DEFAULT_PERMISSIVE_MODE).parse(in); + metadata = OIDCProviderMetadata.parse(json); + } + URL jwksUri = metadata.getJWKSetURI().toURL(); + + // Build decoder supporting ES384 (Logto default) and ES256, RS256 + var jwkSource = JWKSourceBuilder.create(jwksUri).build(); + Set algorithms = Set.of(JWSAlgorithm.ES384, JWSAlgorithm.ES256, JWSAlgorithm.RS256); + var keySelector = new JWSVerificationKeySelector(algorithms, jwkSource); + var processor = new DefaultJWTProcessor(); + processor.setJWSKeySelector(keySelector); + // Accept any JWT type — Logto uses "at+jwt" (RFC 9068) + processor.setJWSTypeVerifier((type, ctx) -> { }); + var decoder = new NimbusJwtDecoder(processor); + + // Validate issuer + optionally audience + OAuth2TokenValidator validators; + String audience = properties.getOidcAudience(); + if (audience != null && !audience.isBlank()) { + validators = new DelegatingOAuth2TokenValidator<>( + JwtValidators.createDefaultWithIssuer(issuerUri), + new JwtClaimValidator>("aud", + aud -> aud != null && aud.contains(audience)) + ); + } else { + validators = JwtValidators.createDefaultWithIssuer(issuerUri); + } + decoder.setJwtValidator(validators); + + log.info("OIDC decoder initialized: jwks={}", jwksUri); + return decoder; + } + @Bean public CorsConfigurationSource corsConfigurationSource(SecurityProperties properties) { CorsConfiguration config = new CorsConfiguration();