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.GrantedAuthority;
|
||||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||||
import org.springframework.security.core.context.SecurityContextHolder;
|
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 org.springframework.web.filter.OncePerRequestFilter;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
@@ -22,8 +24,9 @@ import java.util.List;
|
|||||||
* JWT authentication filter that extracts and validates JWT tokens from
|
* JWT authentication filter that extracts and validates JWT tokens from
|
||||||
* the {@code Authorization: Bearer} header or the {@code token} query parameter.
|
* the {@code Authorization: Bearer} header or the {@code token} query parameter.
|
||||||
* <p>
|
* <p>
|
||||||
* Populates Spring Security {@code GrantedAuthority} from the JWT {@code roles} claim.
|
* Tries internal HMAC validation first (agents, local users). If that fails and an
|
||||||
* Agent tokens without roles get {@code ROLE_AGENT}; UI tokens get authorities from the claim.
|
* 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>
|
* <p>
|
||||||
* Not annotated {@code @Component} -- constructed explicitly in {@link SecurityConfig}
|
* Not annotated {@code @Component} -- constructed explicitly in {@link SecurityConfig}
|
||||||
* to avoid double filter registration.
|
* to avoid double filter registration.
|
||||||
@@ -36,10 +39,14 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
|||||||
|
|
||||||
private final JwtService jwtService;
|
private final JwtService jwtService;
|
||||||
private final AgentRegistryService agentRegistryService;
|
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.jwtService = jwtService;
|
||||||
this.agentRegistryService = agentRegistryService;
|
this.agentRegistryService = agentRegistryService;
|
||||||
|
this.oidcDecoder = oidcDecoder;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -49,12 +56,23 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
|||||||
String token = extractToken(request);
|
String token = extractToken(request);
|
||||||
|
|
||||||
if (token != null) {
|
if (token != null) {
|
||||||
|
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 {
|
try {
|
||||||
JwtValidationResult result = jwtService.validateAccessToken(token);
|
JwtValidationResult result = jwtService.validateAccessToken(token);
|
||||||
String subject = result.subject();
|
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();
|
List<String> roles = result.roles();
|
||||||
if (!subject.startsWith("user:") && roles.isEmpty()) {
|
if (!subject.startsWith("user:") && roles.isEmpty()) {
|
||||||
roles = List.of("AGENT");
|
roles = List.of("AGENT");
|
||||||
@@ -64,12 +82,41 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
|||||||
new UsernamePasswordAuthenticationToken(subject, null, authorities);
|
new UsernamePasswordAuthenticationToken(subject, null, authorities);
|
||||||
SecurityContextHolder.getContext().setAuthentication(auth);
|
SecurityContextHolder.getContext().setAuthentication(auth);
|
||||||
request.setAttribute(JWT_RESULT_ATTR, result);
|
request.setAttribute(JWT_RESULT_ATTR, result);
|
||||||
|
return true;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.debug("JWT validation failed: {}", e.getMessage());
|
log.debug("Internal JWT validation failed: {}", e.getMessage());
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
chain.doFilter(request, response);
|
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) {
|
private List<GrantedAuthority> toAuthorities(List<String> roles) {
|
||||||
|
|||||||
@@ -2,6 +2,16 @@ package com.cameleer3.server.app.security;
|
|||||||
|
|
||||||
import com.cameleer3.server.core.agent.AgentRegistryService;
|
import com.cameleer3.server.core.agent.AgentRegistryService;
|
||||||
import com.cameleer3.server.core.security.JwtService;
|
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.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
import org.springframework.http.HttpMethod;
|
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.configuration.EnableWebSecurity;
|
||||||
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
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.SecurityFilterChain;
|
||||||
import org.springframework.security.web.authentication.HttpStatusEntryPoint;
|
import org.springframework.security.web.authentication.HttpStatusEntryPoint;
|
||||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
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.CorsConfigurationSource;
|
||||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
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.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Spring Security configuration for JWT-based stateless authentication with RBAC.
|
* Spring Security configuration for JWT-based stateless authentication with RBAC.
|
||||||
* <p>
|
* <p>
|
||||||
* Public endpoints: health, agent registration, refresh, auth, API docs, Swagger UI, static resources.
|
* 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.
|
* 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
|
@Configuration
|
||||||
@EnableWebSecurity
|
@EnableWebSecurity
|
||||||
@EnableMethodSecurity
|
@EnableMethodSecurity
|
||||||
public class SecurityConfig {
|
public class SecurityConfig {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(SecurityConfig.class);
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public SecurityFilterChain filterChain(HttpSecurity http,
|
public SecurityFilterChain filterChain(HttpSecurity http,
|
||||||
JwtService jwtService,
|
JwtService jwtService,
|
||||||
AgentRegistryService registryService,
|
AgentRegistryService registryService,
|
||||||
|
SecurityProperties securityProperties,
|
||||||
CorsConfigurationSource corsConfigurationSource) throws Exception {
|
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
|
http
|
||||||
.cors(cors -> cors.configurationSource(corsConfigurationSource))
|
.cors(cors -> cors.configurationSource(corsConfigurationSource))
|
||||||
.csrf(AbstractHttpConfigurer::disable)
|
.csrf(AbstractHttpConfigurer::disable)
|
||||||
@@ -101,13 +140,61 @@ public class SecurityConfig {
|
|||||||
.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))
|
.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))
|
||||||
)
|
)
|
||||||
.addFilterBefore(
|
.addFilterBefore(
|
||||||
new JwtAuthenticationFilter(jwtService, registryService),
|
new JwtAuthenticationFilter(jwtService, registryService, oidcDecoder),
|
||||||
UsernamePasswordAuthenticationFilter.class
|
UsernamePasswordAuthenticationFilter.class
|
||||||
);
|
);
|
||||||
|
|
||||||
return http.build();
|
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
|
@Bean
|
||||||
public CorsConfigurationSource corsConfigurationSource(SecurityProperties properties) {
|
public CorsConfigurationSource corsConfigurationSource(SecurityProperties properties) {
|
||||||
CorsConfiguration config = new CorsConfiguration();
|
CorsConfiguration config = new CorsConfiguration();
|
||||||
|
|||||||
Reference in New Issue
Block a user