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
* 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>("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();