Skip to content

Commit af94f97

Browse files
dfcoffinclaude
andauthored
fix(authserver): canonical Spring Security 7.x filter chain + Jackson modules on custom repo (#128)
* fix(authserver): adopt canonical Spring Security 7.x filter chain pattern 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 <noreply@anthropic.com> * fix(authserver): register Spring Authorization Server Jackson modules on custom repo 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 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent a7e66a5 commit af94f97

2 files changed

Lines changed: 70 additions & 81 deletions

File tree

openespi-authserver/src/main/java/org/greenbuttonalliance/espi/authserver/config/AuthorizationServerConfig.java

Lines changed: 54 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@
3535
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
3636
import org.springframework.security.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration;
3737
import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer;
38+
import org.springframework.security.config.annotation.web.configurers.oauth2.server.authorization.OAuth2AuthorizationServerConfigurer;
39+
import org.springframework.security.web.util.matcher.RequestMatcher;
3840
import org.springframework.security.crypto.password.PasswordEncoder;
3941
import org.springframework.security.oauth2.core.AuthorizationGrantType;
4042
import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
@@ -118,42 +120,70 @@ public class AuthorizationServerConfig {
118120
*/
119121
@Bean
120122
@Order(1)
121-
public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) {
123+
public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
124+
// Canonical Spring Security 7.x Authorization Server setup.
125+
// The Spring Authorization Server 1.x static
126+
// OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http) was
127+
// removed in Spring Security 7.x; the replacement pattern manually
128+
// installs the configurer via http.with(...) and scopes the chain via
129+
// http.securityMatcher(configurer.getEndpointsMatcher()), so THIS chain
130+
// only claims the auth-server endpoints (/oauth2/authorize, /oauth2/token,
131+
// /oauth2/jwks, /oauth2/introspect, /oauth2/revoke, /connect/register,
132+
// /userinfo, etc.). Everything else falls through to
133+
// defaultSecurityFilterChain @Order(2).
134+
//
135+
// Without this scoping, the resource-server bearer-token filter (added
136+
// by .oauth2ResourceServer().jwt(...)) intercepts POST /oauth2/token
137+
// before the token-endpoint filter can run its basic-auth handler.
138+
OAuth2AuthorizationServerConfigurer authorizationServerConfigurer =
139+
new OAuth2AuthorizationServerConfigurer();
140+
authorizationServerConfigurer.oidc(Customizer.withDefaults()); // OIDC 1.0
141+
RequestMatcher endpointsMatcher = authorizationServerConfigurer.getEndpointsMatcher();
122142

123143
http
124-
.authorizeHttpRequests((authorize) -> authorize
125-
.requestMatchers("/assets/**", "/webjars/**", "/login").permitAll())
126-
.formLogin(Customizer.withDefaults())
127-
.oauth2AuthorizationServer(authorizationServer ->
128-
authorizationServer.oidc(Customizer.withDefaults()) // Enable OpenID Connect 1.0
129-
)
144+
.securityMatcher(endpointsMatcher)
130145
.authorizeHttpRequests(authorize -> authorize.anyRequest().authenticated())
131-
.csrf(Customizer.withDefaults())
132-
// Redirect to the login page when not authenticated from the authorization endpoint
146+
.csrf(csrf -> csrf.ignoringRequestMatchers(endpointsMatcher))
147+
.with(authorizationServerConfigurer, Customizer.withDefaults())
148+
// Redirect HTML user-agents to the login page when accessing the
149+
// authorization endpoint without an active session
133150
.exceptionHandling(exceptions -> exceptions
134151
.defaultAuthenticationEntryPointFor(
135152
new LoginUrlAuthenticationEntryPoint("/login"),
136153
new MediaTypeRequestMatcher(MediaType.TEXT_HTML)
137154
)
138155
)
139-
// Accept access tokens for User Info and/or Client Registration.
140-
// OIDC auto-configures JWT validation for self-protected endpoints
141-
// (id_token signing requires JWT). Outbound tokens to ESPI clients
142-
// remain opaque via accessTokenFormat(REFERENCE) on each RegisteredClient.
143-
// Cannot configure both .jwt() and .opaqueToken() on the same chain
144-
// in Spring Security 7.x.
156+
// Accept access tokens for /userinfo and /connect/register.
157+
// OIDC always issues id_token as JWT, so JWT is the right token
158+
// type here. Outbound tokens to ESPI clients remain opaque via
159+
// accessTokenFormat(REFERENCE) on each RegisteredClient.
145160
.oauth2ResourceServer(resourceServer -> resourceServer
146161
.jwt(Customizer.withDefaults())
162+
);
163+
164+
return http.build();
165+
}
166+
167+
/**
168+
* Default Security Filter Chain for everything NOT claimed by the
169+
* authorization-server endpoints matcher: login form, static resources,
170+
* H2 console (in dev), and any custom controllers.
171+
*/
172+
@Bean
173+
@Order(2)
174+
public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
175+
http
176+
.authorizeHttpRequests(authorize -> authorize
177+
.requestMatchers(
178+
"/assets/**", "/webjars/**",
179+
"/login", "/error",
180+
"/.well-known/**",
181+
"/actuator/health", "/actuator/info"
182+
).permitAll()
183+
.anyRequest().authenticated()
147184
)
148-
// HTTPS Channel Security for Production
149-
//should be able to use property server.ssl.enabled=true
150-
//todo - test this
151-
// .requiresChannel(channel -> {
152-
// if (requireHttps) {
153-
// channel.anyRequest().requiresSecure();
154-
// }
155-
// })
156-
// Enhanced Security Headers for ESPI Compliance
185+
.formLogin(Customizer.withDefaults())
186+
.csrf(Customizer.withDefaults())
157187
.headers(headers -> headers
158188
.frameOptions(HeadersConfigurer.FrameOptionsConfig::deny)
159189
.contentTypeOptions(Customizer.withDefaults())
@@ -175,62 +205,6 @@ public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity h
175205
return http.build();
176206
}
177207

178-
/**
179-
* Default Security Filter Chain for non-OAuth2 endpoints
180-
* <p>
181-
* Handles authentication for:
182-
* - Login page
183-
* - User consent page
184-
* - Static resources
185-
*/
186-
//todo remove if not needed
187-
// @Bean
188-
// @Order(2)
189-
public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http)
190-
throws Exception {
191-
// http
192-
//********Moved to order 1
193-
// .authorizeHttpRequests((authorize) -> authorize
194-
// .requestMatchers("/assets/**", "/webjars/**", "/login").permitAll()
195-
// .anyRequest().authenticated()
196-
// )
197-
// Form login handles the redirect to the login page from the
198-
// authorization server filter chain
199-
//******moved to order 1
200-
// .formLogin(Customizer.withDefaults())
201-
// HTTPS Channel Security for Production (Default Security Chain)
202-
//should be able to use property server.ssl.enabled=true
203-
//todo - test this
204-
// .requiresChannel(channel -> {
205-
// if (requireHttps) {
206-
// channel.anyRequest().requiresSecure();
207-
// }
208-
// })
209-
// Enhanced Security Headers
210-
// ****Dup of Order 1
211-
// .headers(headers -> headers
212-
// .frameOptions(HeadersConfigurer.FrameOptionsConfig::deny)
213-
// .contentTypeOptions(Customizer.withDefaults())
214-
// .httpStrictTransportSecurity(hstsConfig -> hstsConfig
215-
// .maxAgeInSeconds(31536000)
216-
// .includeSubDomains(true)
217-
// .preload(true)
218-
// )
219-
// .referrerPolicy(referrer -> referrer
220-
// .policy(ReferrerPolicyHeaderWriter.ReferrerPolicy.STRICT_ORIGIN_WHEN_CROSS_ORIGIN)
221-
// )
222-
// )
223-
// Secure session configuration
224-
// ******Moved to order 1
225-
// .sessionManagement(session -> session
226-
// .sessionCreationPolicy(org.springframework.security.config.http.SessionCreationPolicy.IF_REQUIRED)
227-
// .maximumSessions(1)
228-
// .maxSessionsPreventsLogin(false)
229-
// );
230-
231-
return http.build();
232-
}
233-
234208
/**
235209
* Registered Client Repository
236210
*

openespi-authserver/src/main/java/org/greenbuttonalliance/espi/authserver/repository/JdbcRegisteredClientRepository.java

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,16 +27,20 @@
2727
import org.springframework.jdbc.core.JdbcTemplate;
2828
import org.springframework.jdbc.core.RowMapper;
2929
import org.springframework.security.crypto.password.PasswordEncoder;
30+
import org.springframework.security.jackson.SecurityJacksonModules;
3031
import org.springframework.security.oauth2.core.AuthorizationGrantType;
3132
import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
3233
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
3334
import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
35+
import org.springframework.security.oauth2.server.authorization.jackson.OAuth2AuthorizationServerJacksonModule;
3436
import org.springframework.security.oauth2.server.authorization.settings.ClientSettings;
3537
import org.springframework.security.oauth2.server.authorization.settings.TokenSettings;
3638
import org.springframework.stereotype.Repository;
3739
import org.springframework.util.StringUtils;
3840
import tools.jackson.core.type.TypeReference;
41+
import tools.jackson.databind.JacksonModule;
3942
import tools.jackson.databind.ObjectMapper;
43+
import tools.jackson.databind.json.JsonMapper;
4044

4145
import java.sql.ResultSet;
4246
import java.sql.SQLException;
@@ -98,7 +102,18 @@ public class JdbcRegisteredClientRepository implements RegisteredClientRepositor
98102
public JdbcRegisteredClientRepository(JdbcTemplate jdbcTemplate, PasswordEncoder passwordEncoder) {
99103
this.jdbcTemplate = jdbcTemplate;
100104
this.passwordEncoder = passwordEncoder;
101-
this.objectMapper = new ObjectMapper();
105+
// Register Spring Security's Jackson modules so typed values inside
106+
// ClientSettings / TokenSettings (e.g. OAuth2TokenFormat.REFERENCE)
107+
// round-trip correctly through serialize -> DB -> deserialize. Without
108+
// these modules, typed values come back as LinkedHashMap and crash
109+
// downstream consumers with ClassCastException (see #127 for the
110+
// architectural plan to swap to Spring's stock JdbcRegisteredClientRepository).
111+
ClassLoader classLoader = JdbcRegisteredClientRepository.class.getClassLoader();
112+
List<JacksonModule> securityModules = SecurityJacksonModules.getModules(classLoader);
113+
this.objectMapper = JsonMapper.builder()
114+
.addModules(securityModules)
115+
.addModule(new OAuth2AuthorizationServerJacksonModule())
116+
.build();
102117
}
103118

104119
@Override

0 commit comments

Comments
 (0)