Repository navigation
Description
Summary
The authorizationServerSecurityFilterChain bean in openespi-authserver uses a non-canonical Spring Security 7.x DSL combination that prevents POST /oauth2/token from actually minting tokens — the resource server's bearer-token filter intercepts the request before the token-endpoint filter can authenticate it via basic auth.
Discovered during Phase 2.0 boot verification (#122 defect #7).
Reproduction
On branch feature/issue-122-auth-server-bringup (which has all 6 prerequisite fixes applied), boot succeeds and discovery endpoints return 200:
$ curl -i -X POST http://localhost:9999/oauth2/token \
-H 'Accept: application/json' \
-u 'data_custodian_admin:{bcrypt}secret' \
-d 'grant_type=client_credentials&scope=DataCustodian_Admin_Access'
HTTP/1.1 401
WWW-Authenticate: Bearer resource_metadata="http://localhost:9999/.well-known/oauth-protected-resource"The WWW-Authenticate: Bearer ... response header is the smoking gun — that's emitted by BearerTokenAuthenticationEntryPoint, NOT by Spring Authorization Server's token endpoint. The token endpoint is being preempted.
Root cause
openespi-authserver/src/main/java/org/greenbuttonalliance/espi/authserver/config/AuthorizationServerConfig.java:119-173
@Bean @Order(1)
public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) {
http
.authorizeHttpRequests(a -> a.requestMatchers("/assets/**", "/webjars/**", "/login").permitAll())
.formLogin(Customizer.withDefaults())
.oauth2AuthorizationServer(authzServer -> authzServer.oidc(Customizer.withDefaults()))
.authorizeHttpRequests(a -> a.anyRequest().authenticated()) // <-- requires auth for /oauth2/token
.csrf(Customizer.withDefaults())
.exceptionHandling(e -> e.defaultAuthenticationEntryPointFor(
new LoginUrlAuthenticationEntryPoint("/login"),
new MediaTypeRequestMatcher(MediaType.TEXT_HTML)))
.oauth2ResourceServer(rs -> rs.jwt(Customizer.withDefaults())) // <-- bearer filter intercepts everything else
...
return http.build();
}Two problems:
- No
securityMatcher— chain matches every request, including/oauth2/token oauth2ResourceServer().jwt(...)+anyRequest().authenticated()— theBearerTokenAuthenticationFilterregistered byoauth2ResourceServerruns ahead of the token endpoint's basic-auth filter. With no bearer token in the request, it returns 401 before the token endpoint's filter chain gets a chance.
The .oauth2AuthorizationServer(authzServer -> authzServer.oidc(...)) DSL is intended for adding OIDC to an existing auth-server config, not for the primary auth-server chain setup.
Canonical Spring Authorization Server pattern
Per the Spring Authorization Server reference samples and project documentation:
@Bean @Order(1)
public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
// applyDefaultSecurity() does the following:
// - registers OAuth2AuthorizationServerConfigurer with default endpoints
// - calls http.securityMatcher(authServerEndpointsMatcher) so this chain
// ONLY handles /oauth2/authorize, /oauth2/token, /oauth2/jwks,
// /oauth2/introspect, /oauth2/revoke, /oauth2/device_*, /connect/register, etc.
// - configures csrf to ignore those endpoints (basic-auth-based, not session-based)
http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
.oidc(Customizer.withDefaults()); // OIDC support
http
.exceptionHandling(e -> e.defaultAuthenticationEntryPointFor(
new LoginUrlAuthenticationEntryPoint("/login"),
new MediaTypeRequestMatcher(MediaType.TEXT_HTML)))
.oauth2ResourceServer(rs -> rs.jwt(Customizer.withDefaults())); // protects /userinfo, /connect/register
return http.build();
}
@Bean @Order(2)
public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
// Catches everything NOT matched by chain #1 — form login, static resources, /login
http
.authorizeHttpRequests(a -> a
.requestMatchers("/assets/**", "/webjars/**", "/login", "/.well-known/**").permitAll()
.anyRequest().authenticated())
.formLogin(Customizer.withDefaults())
.csrf(Customizer.withDefaults());
return http.build();
}The critical mechanism is the implicit securityMatcher set by applyDefaultSecurity: chain #1 only matches OAuth2 endpoints, so oauth2ResourceServer only protects those endpoints — and since the token endpoint's own filter handles /oauth2/token directly (with basic auth), there's no conflict.
Acceptance criteria
-
authorizationServerSecurityFilterChainrewritten to useOAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http)pattern - Second
@Order(2)defaultSecurityFilterChainbean added for non-OAuth2 endpoints (form login, static resources) -
POST /oauth2/tokenwithclient_credentialsgrant against seededdata_custodian_adminclient returns 200 with a valid opaque access token -
POST /oauth2/introspectwith that token returns{ "active": true, ... }per RFC 7662 - Integration test added covering both flows (token mint + introspection)
- Existing tests still pass
Estimated effort
0.5-1 working day (rewrite + new chain + integration test).
Related
- #122 Phase 2.0 auth-server bring-up (this blocks Step 3 introspection verification)
- Branch with the 6 prerequisite fixes:
feature/issue-122-auth-server-bringup(PR pending) - Spring Authorization Server reference: https://docs.spring.io/spring-authorization-server/reference/getting-started.html#defining-required-components
Activity
fix(authserver): six pre-existing defects blocking dev-mysql boot (#125)
fix(authserver): canonical Spring Security 7.x filter chain + Jackson…