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() — the BearerTokenAuthenticationFilter registered by oauth2ResourceServer runs 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
Estimated effort
0.5-1 working day (rewrite + new chain + integration test).
Related
Summary
The
authorizationServerSecurityFilterChainbean inopenespi-authserveruses a non-canonical Spring Security 7.x DSL combination that preventsPOST /oauth2/tokenfrom 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 byBearerTokenAuthenticationEntryPoint, 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-173Two problems:
securityMatcher— chain matches every request, including/oauth2/tokenoauth2ResourceServer().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:
The critical mechanism is the implicit
securityMatcherset byapplyDefaultSecurity: chain #1 only matches OAuth2 endpoints, sooauth2ResourceServeronly protects those endpoints — and since the token endpoint's own filter handles/oauth2/tokendirectly (with basic auth), there's no conflict.Acceptance criteria
authorizationServerSecurityFilterChainrewritten to useOAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http)pattern@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 tokenPOST /oauth2/introspectwith that token returns{ "active": true, ... }per RFC 7662Estimated effort
0.5-1 working day (rewrite + new chain + integration test).
Related
feature/issue-122-auth-server-bringup(PR pending)