Skip to content

Auth-server SecurityFilterChain: replace non-canonical pattern with Spring Authorization Server canonical config #124

@dfcoffin

Description

@dfcoffin

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:

  1. No securityMatcher — chain matches every request, including /oauth2/token
  2. 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

  • authorizationServerSecurityFilterChain rewritten to use OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http) pattern
  • Second @Order(2) defaultSecurityFilterChain bean added for non-OAuth2 endpoints (form login, static resources)
  • POST /oauth2/token with client_credentials grant against seeded data_custodian_admin client returns 200 with a valid opaque access token
  • POST /oauth2/introspect with 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    ESPI 4.0Touches the NAESB ESPI 4.0 implementationblockingBlocks other work or CIbugSomething isn't working

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions