feat: add OIDC resource server support with JWKS discovery and scope-based roles

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-05 13:10:08 +02:00
parent a5c4e0cead
commit 3bd07c9b07
2 changed files with 155 additions and 21 deletions

View File

@@ -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.
* <p>
* 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.
* <p>
* 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<String> roles = result.roles();
if (!subject.startsWith("user:") && roles.isEmpty()) {
roles = List.of("AGENT");
}
List<GrantedAuthority> 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<String> roles = result.roles();
if (!subject.startsWith("user:") && roles.isEmpty()) {
roles = List.of("AGENT");
}
List<GrantedAuthority> 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<String> roles = extractRolesFromScopes(jwt);
List<GrantedAuthority> 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<String> extractRolesFromScopes(Jwt jwt) {
String scopeStr = jwt.getClaimAsString("scope");
if (scopeStr == null || scopeStr.isBlank()) {
return List.of("VIEWER");
}
List<String> 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<GrantedAuthority> toAuthorities(List<String> roles) {
return roles.stream()
.map(role -> (GrantedAuthority) new SimpleGrantedAuthority("ROLE_" + role))

View File

@@ -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.
* <p>
* 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.
* <p>
* 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<JWSAlgorithm> algorithms = Set.of(JWSAlgorithm.ES384, JWSAlgorithm.ES256, JWSAlgorithm.RS256);
var keySelector = new JWSVerificationKeySelector<SecurityContext>(algorithms, jwkSource);
var processor = new DefaultJWTProcessor<SecurityContext>();
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<Jwt> validators;
String audience = properties.getOidcAudience();
if (audience != null && !audience.isBlank()) {
validators = new DelegatingOAuth2TokenValidator<>(
JwtValidators.createDefaultWithIssuer(issuerUri),
new JwtClaimValidator<List<String>>("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();