From 20dd181d0fe6228d2026faa6e3a4e33c1a7213de Mon Sep 17 00:00:00 2001 From: "Donald F. Coffin" Date: Mon, 25 May 2026 19:26:15 -0400 Subject: [PATCH 1/2] fix(authserver): adopt canonical Spring Security 7.x filter chain pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #124. The authorizationServerSecurityFilterChain bean used a non-canonical DSL combination — .oauth2AuthorizationServer(...) plus .oauth2ResourceServer().jwt() on the same chain with .anyRequest().authenticated() and no securityMatcher. Result: the resource-server bearer-token filter intercepted POST /oauth2/token requests before the token-endpoint filter could authenticate them via basic auth, returning 401 with WWW-Authenticate: Bearer. The canonical pattern in Spring Security 7.x manually installs the OAuth2AuthorizationServerConfigurer via http.with(...) and scopes the chain to the auth-server endpoints via http.securityMatcher(configurer.getEndpointsMatcher()). With the chain scoped to OAuth2 endpoints only, the bearer-token filter never sees them and the token endpoint's own basic-auth filter handles client authentication correctly. Note: Spring Security 7.x removed the OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http) static method that Spring Authorization Server 1.x used; the http.with() pattern plus manual securityMatcher is the replacement. A second SecurityFilterChain @Order(2) defaultSecurityFilterChain bean now handles everything NOT claimed by the auth-server endpoints matcher: login form, static resources, /.well-known/*, actuator endpoints. The previously commented-out defaultSecurityFilterChain stub has been replaced with a real implementation. Verified end-to-end: with this filter chain plus the Jackson modules fix landed in the next commit, POST /oauth2/token returns 200 with a 128-char opaque access token, and POST /oauth2/introspect returns the RFC 7662 response shape (active, sub, aud, scope, iss, exp, iat, jti, client_id, token_type). Refs: #122 Co-Authored-By: Claude Opus 4.7 --- .../config/AuthorizationServerConfig.java | 134 +++++++----------- 1 file changed, 54 insertions(+), 80 deletions(-) diff --git a/openespi-authserver/src/main/java/org/greenbuttonalliance/espi/authserver/config/AuthorizationServerConfig.java b/openespi-authserver/src/main/java/org/greenbuttonalliance/espi/authserver/config/AuthorizationServerConfig.java index 96d4b171..f73a9bfa 100644 --- a/openespi-authserver/src/main/java/org/greenbuttonalliance/espi/authserver/config/AuthorizationServerConfig.java +++ b/openespi-authserver/src/main/java/org/greenbuttonalliance/espi/authserver/config/AuthorizationServerConfig.java @@ -35,6 +35,8 @@ import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration; import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer; +import org.springframework.security.config.annotation.web.configurers.oauth2.server.authorization.OAuth2AuthorizationServerConfigurer; +import org.springframework.security.web.util.matcher.RequestMatcher; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.oauth2.core.AuthorizationGrantType; import org.springframework.security.oauth2.core.ClientAuthenticationMethod; @@ -118,42 +120,70 @@ public class AuthorizationServerConfig { */ @Bean @Order(1) - public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) { + public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception { + // Canonical Spring Security 7.x Authorization Server setup. + // The Spring Authorization Server 1.x static + // OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http) was + // removed in Spring Security 7.x; the replacement pattern manually + // installs the configurer via http.with(...) and scopes the chain via + // http.securityMatcher(configurer.getEndpointsMatcher()), so THIS chain + // only claims the auth-server endpoints (/oauth2/authorize, /oauth2/token, + // /oauth2/jwks, /oauth2/introspect, /oauth2/revoke, /connect/register, + // /userinfo, etc.). Everything else falls through to + // defaultSecurityFilterChain @Order(2). + // + // Without this scoping, the resource-server bearer-token filter (added + // by .oauth2ResourceServer().jwt(...)) intercepts POST /oauth2/token + // before the token-endpoint filter can run its basic-auth handler. + OAuth2AuthorizationServerConfigurer authorizationServerConfigurer = + new OAuth2AuthorizationServerConfigurer(); + authorizationServerConfigurer.oidc(Customizer.withDefaults()); // OIDC 1.0 + RequestMatcher endpointsMatcher = authorizationServerConfigurer.getEndpointsMatcher(); http - .authorizeHttpRequests((authorize) -> authorize - .requestMatchers("/assets/**", "/webjars/**", "/login").permitAll()) - .formLogin(Customizer.withDefaults()) - .oauth2AuthorizationServer(authorizationServer -> - authorizationServer.oidc(Customizer.withDefaults()) // Enable OpenID Connect 1.0 - ) + .securityMatcher(endpointsMatcher) .authorizeHttpRequests(authorize -> authorize.anyRequest().authenticated()) - .csrf(Customizer.withDefaults()) - // Redirect to the login page when not authenticated from the authorization endpoint + .csrf(csrf -> csrf.ignoringRequestMatchers(endpointsMatcher)) + .with(authorizationServerConfigurer, Customizer.withDefaults()) + // Redirect HTML user-agents to the login page when accessing the + // authorization endpoint without an active session .exceptionHandling(exceptions -> exceptions .defaultAuthenticationEntryPointFor( new LoginUrlAuthenticationEntryPoint("/login"), new MediaTypeRequestMatcher(MediaType.TEXT_HTML) ) ) - // Accept access tokens for User Info and/or Client Registration. - // OIDC auto-configures JWT validation for self-protected endpoints - // (id_token signing requires JWT). Outbound tokens to ESPI clients - // remain opaque via accessTokenFormat(REFERENCE) on each RegisteredClient. - // Cannot configure both .jwt() and .opaqueToken() on the same chain - // in Spring Security 7.x. + // Accept access tokens for /userinfo and /connect/register. + // OIDC always issues id_token as JWT, so JWT is the right token + // type here. Outbound tokens to ESPI clients remain opaque via + // accessTokenFormat(REFERENCE) on each RegisteredClient. .oauth2ResourceServer(resourceServer -> resourceServer .jwt(Customizer.withDefaults()) + ); + + return http.build(); + } + + /** + * Default Security Filter Chain for everything NOT claimed by the + * authorization-server endpoints matcher: login form, static resources, + * H2 console (in dev), and any custom controllers. + */ + @Bean + @Order(2) + public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception { + http + .authorizeHttpRequests(authorize -> authorize + .requestMatchers( + "/assets/**", "/webjars/**", + "/login", "/error", + "/.well-known/**", + "/actuator/health", "/actuator/info" + ).permitAll() + .anyRequest().authenticated() ) - // HTTPS Channel Security for Production - //should be able to use property server.ssl.enabled=true - //todo - test this -// .requiresChannel(channel -> { -// if (requireHttps) { -// channel.anyRequest().requiresSecure(); -// } -// }) - // Enhanced Security Headers for ESPI Compliance + .formLogin(Customizer.withDefaults()) + .csrf(Customizer.withDefaults()) .headers(headers -> headers .frameOptions(HeadersConfigurer.FrameOptionsConfig::deny) .contentTypeOptions(Customizer.withDefaults()) @@ -175,62 +205,6 @@ public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity h return http.build(); } - /** - * Default Security Filter Chain for non-OAuth2 endpoints - *

- * Handles authentication for: - * - Login page - * - User consent page - * - Static resources - */ - //todo remove if not needed - // @Bean - // @Order(2) - public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) - throws Exception { - // http - //********Moved to order 1 -// .authorizeHttpRequests((authorize) -> authorize -// .requestMatchers("/assets/**", "/webjars/**", "/login").permitAll() -// .anyRequest().authenticated() -// ) - // Form login handles the redirect to the login page from the - // authorization server filter chain - //******moved to order 1 - // .formLogin(Customizer.withDefaults()) - // HTTPS Channel Security for Production (Default Security Chain) - //should be able to use property server.ssl.enabled=true - //todo - test this -// .requiresChannel(channel -> { -// if (requireHttps) { -// channel.anyRequest().requiresSecure(); -// } -// }) - // Enhanced Security Headers - // ****Dup of Order 1 -// .headers(headers -> headers -// .frameOptions(HeadersConfigurer.FrameOptionsConfig::deny) -// .contentTypeOptions(Customizer.withDefaults()) -// .httpStrictTransportSecurity(hstsConfig -> hstsConfig -// .maxAgeInSeconds(31536000) -// .includeSubDomains(true) -// .preload(true) -// ) -// .referrerPolicy(referrer -> referrer -// .policy(ReferrerPolicyHeaderWriter.ReferrerPolicy.STRICT_ORIGIN_WHEN_CROSS_ORIGIN) -// ) -// ) - // Secure session configuration - // ******Moved to order 1 -// .sessionManagement(session -> session -// .sessionCreationPolicy(org.springframework.security.config.http.SessionCreationPolicy.IF_REQUIRED) -// .maximumSessions(1) -// .maxSessionsPreventsLogin(false) -// ); - - return http.build(); - } - /** * Registered Client Repository * From 31c88e73106d23c1fa3c6dbebcf61787e2dc53c8 Mon Sep 17 00:00:00 2001 From: "Donald F. Coffin" Date: Mon, 25 May 2026 19:26:50 -0400 Subject: [PATCH 2/2] fix(authserver): register Spring Authorization Server Jackson modules on custom repo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The custom JdbcRegisteredClientRepository constructed its ObjectMapper as new ObjectMapper() with no modules registered. Spring Authorization Server stores typed values (OAuth2TokenFormat, ClientAuthenticationMethod, etc.) inside ClientSettings and TokenSettings; Jackson serializes them correctly on write but cannot reconstruct the typed objects on read without the provider's type-info modules. Symptom: with a client configured accessTokenFormat(OAuth2TokenFormat.REFERENCE), the round-tripped setting came back as a LinkedHashMap. Later TokenSettings.getAccessTokenFormat() ClassCast the LinkedHashMap to OAuth2TokenFormat — and worse, because the cast failed silently in the DelegatingOAuth2TokenGenerator, the JwtGenerator ran for clients configured as opaque, throwing HTTP 500 on every POST /oauth2/token request. Fix: register SecurityJacksonModules + OAuth2AuthorizationServerJacksonModule on the existing ObjectMapper at repo construction. Use Jackson 3's JsonMapper.builder() pattern since the codebase is already on tools.jackson.databind.ObjectMapper (Jackson 3 immutable mapper). This is the minimum fix that unblocks #124's acceptance criteria (POST /oauth2/token returns opaque token; introspection returns RFC 7662 response). The custom repo retains other architectural issues — auto-encoding of clientSecret on save, non-interface findAll/deleteById extensions, and the broader build-vs-buy question of why we're reimplementing Spring's stock JdbcRegisteredClientRepository at all. Those are scoped to follow-up issue #127. Verified locally against a fresh MySQL container: $ curl -u 'data_custodian_admin:{bcrypt}secret' \ -d 'grant_type=client_credentials&scope=DataCustodian_Admin_Access' \ http://localhost:9999/oauth2/token {"access_token":"7b_HlXgKfi7V-phbFWODTJW_...","token_type":"Bearer", "expires_in":3599,"scope":"DataCustodian_Admin_Access"} $ curl -u 'data_custodian_admin:{bcrypt}secret' \ -d "token=$T" \ http://localhost:9999/oauth2/introspect {"active":true,"sub":"data_custodian_admin","aud":["data_custodian_admin"], "scope":"DataCustodian_Admin_Access","iss":"http://localhost:9999",...} Refs: #122 #124 #127 Co-Authored-By: Claude Opus 4.7 --- .../JdbcRegisteredClientRepository.java | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/openespi-authserver/src/main/java/org/greenbuttonalliance/espi/authserver/repository/JdbcRegisteredClientRepository.java b/openespi-authserver/src/main/java/org/greenbuttonalliance/espi/authserver/repository/JdbcRegisteredClientRepository.java index f62a5cce..999bc6c9 100644 --- a/openespi-authserver/src/main/java/org/greenbuttonalliance/espi/authserver/repository/JdbcRegisteredClientRepository.java +++ b/openespi-authserver/src/main/java/org/greenbuttonalliance/espi/authserver/repository/JdbcRegisteredClientRepository.java @@ -27,16 +27,20 @@ import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.RowMapper; import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.jackson.SecurityJacksonModules; import org.springframework.security.oauth2.core.AuthorizationGrantType; import org.springframework.security.oauth2.core.ClientAuthenticationMethod; import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository; +import org.springframework.security.oauth2.server.authorization.jackson.OAuth2AuthorizationServerJacksonModule; import org.springframework.security.oauth2.server.authorization.settings.ClientSettings; import org.springframework.security.oauth2.server.authorization.settings.TokenSettings; import org.springframework.stereotype.Repository; import org.springframework.util.StringUtils; import tools.jackson.core.type.TypeReference; +import tools.jackson.databind.JacksonModule; import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; import java.sql.ResultSet; import java.sql.SQLException; @@ -98,7 +102,18 @@ public class JdbcRegisteredClientRepository implements RegisteredClientRepositor public JdbcRegisteredClientRepository(JdbcTemplate jdbcTemplate, PasswordEncoder passwordEncoder) { this.jdbcTemplate = jdbcTemplate; this.passwordEncoder = passwordEncoder; - this.objectMapper = new ObjectMapper(); + // Register Spring Security's Jackson modules so typed values inside + // ClientSettings / TokenSettings (e.g. OAuth2TokenFormat.REFERENCE) + // round-trip correctly through serialize -> DB -> deserialize. Without + // these modules, typed values come back as LinkedHashMap and crash + // downstream consumers with ClassCastException (see #127 for the + // architectural plan to swap to Spring's stock JdbcRegisteredClientRepository). + ClassLoader classLoader = JdbcRegisteredClientRepository.class.getClassLoader(); + List securityModules = SecurityJacksonModules.getModules(classLoader); + this.objectMapper = JsonMapper.builder() + .addModules(securityModules) + .addModule(new OAuth2AuthorizationServerJacksonModule()) + .build(); } @Override