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:
@@ -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))
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user