Skip to content

fix(authserver): canonical Spring Security 7.x filter chain + Jackson modules on custom repo#128

Merged
dfcoffin merged 2 commits into
mainfrom
feature/issue-124-filter-chain-canonical
May 26, 2026
Merged

fix(authserver): canonical Spring Security 7.x filter chain + Jackson modules on custom repo#128
dfcoffin merged 2 commits into
mainfrom
feature/issue-124-filter-chain-canonical

Conversation

@dfcoffin
Copy link
Copy Markdown
Contributor

@dfcoffin dfcoffin commented May 25, 2026

Summary

Closes #124. End-to-end opaque token issuance + RFC 7662 introspection now works on dev-mysql.

Two related commits, one PR — they form a dependency chain (the filter-chain fix exposed the serialization defect; the serialization fix is what makes the filter-chain fix verifiable end-to-end). Splitting them would create a PR whose acceptance criteria provably can't be met.

Commits

1. fix(authserver): adopt canonical Spring Security 7.x filter chain pattern

authorizationServerSecurityFilterChain rewritten from the non-canonical .oauth2AuthorizationServer(...) + .oauth2ResourceServer().jwt() + .anyRequest().authenticated() combination to the canonical Spring Security 7.x pattern:

OAuth2AuthorizationServerConfigurer configurer = new OAuth2AuthorizationServerConfigurer();
configurer.oidc(Customizer.withDefaults());
RequestMatcher endpointsMatcher = configurer.getEndpointsMatcher();

http
    .securityMatcher(endpointsMatcher)
    .authorizeHttpRequests(a -> a.anyRequest().authenticated())
    .csrf(csrf -> csrf.ignoringRequestMatchers(endpointsMatcher))
    .with(configurer, Customizer.withDefaults())
    .exceptionHandling(...)
    .oauth2ResourceServer(rs -> rs.jwt(Customizer.withDefaults()));

securityMatcher(endpointsMatcher) scopes this chain to OAuth2 endpoints only, so the resource-server bearer-token filter no longer intercepts POST /oauth2/token before its basic-auth handler can run.

A second @Order(2) defaultSecurityFilterChain bean now handles login form, static resources, /.well-known/*, and actuator — replacing the previously commented-out stub.

Note: Spring Security 7.x removed the OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http) static method that Spring Authorization Server 1.x docs commonly cite. The http.with(...) + manual securityMatcher is the replacement.

2. fix(authserver): register Spring Authorization Server Jackson modules on custom repo

The custom JdbcRegisteredClientRepository constructed new ObjectMapper() with no Jackson modules. Spring Authorization Server stores typed values (OAuth2TokenFormat, ClientAuthenticationMethod) inside ClientSettings/TokenSettings; Jackson serializes correctly on write but reads them back as LinkedHashMap without the provider's type-info modules. TokenSettings.getAccessTokenFormat() then ClassCasts, and the DelegatingOAuth2TokenGenerator falls through to JwtGenerator even for clients configured as opaque — HTTP 500 on every POST /oauth2/token.

Fix: register SecurityJacksonModules + OAuth2AuthorizationServerJacksonModule on the existing ObjectMapper using Jackson 3's JsonMapper.builder() pattern (codebase is already on tools.jackson.databind.ObjectMapper).

This is the minimum fix that unblocks #124's acceptance criteria. The custom repo's other architectural issues — auto-encoding clientSecret on save, non-interface findAll/deleteById extensions, the "build-vs-buy" question of reimplementing Spring's stock impl — are scoped to follow-up issue #127.

Verification (local, 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_Cfn--uBuKRClixyqFyxPHTpEiMQw29wf33nhor0Tx5dmBUgSmDCFOfoTwQVMsTbVX8P9WOdm_cyAYOET9IIy12THZR9OKe8D7ZsazjUp",
 "token_type":"Bearer","expires_in":3599,"scope":"DataCustodian_Admin_Access"}

$ curl -u 'data_custodian_admin:{bcrypt}secret' \
    -d "token=$ACCESS_TOKEN" \
    http://localhost:9999/oauth2/introspect
{"active":true,"sub":"data_custodian_admin","aud":["data_custodian_admin"],
 "nbf":1779751451,"scope":"DataCustodian_Admin_Access","iss":"http://localhost:9999",
 "exp":1779755052,"iat":1779751452,"jti":"19dca850-f8a3-4b5e-bf8a-3fbec3596943",
 "client_id":"data_custodian_admin","token_type":"Bearer"}
  • Access token: 128 chars, zero dots (not a JWT — proper opaque/REFERENCE format per ESPI 4.0)
  • Introspection response: RFC 7662-compliant — exactly the shape Step 4 of #122 will enrich with a subscription_id custom claim once grant→subscription wiring lands.

Test plan

  • Manual: mvn -pl openespi-authserver compile succeeds
  • Manual: mvn spring-boot:run -Dspring-boot.run.profiles=dev-mysql reaches Started AuthorizationServerApplication (~45s)
  • Manual: POST /oauth2/token returns 200 with opaque token; POST /oauth2/introspect returns RFC 7662 response (results above)
  • CI: full test suite passes (will verify)
  • Automated integration test exercising the full mint+introspect cycle — deferred to #127 which will refactor the repo and add proper test coverage at the same time

Related

  • Closes #124
  • Advances #122 Phase 2.0 (Step 3 — verify opaque token introspection — finally satisfied after 11 patches across 3 issues)
  • Filed #127 for the deeper "swap custom repo for stock" refactor

🤖 Generated with Claude Code

dfcoffin and others added 2 commits May 25, 2026 19:26
…tern

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>
… 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>
@dfcoffin dfcoffin added bug Something isn't working ESPI 4.0 Touches the NAESB ESPI 4.0 implementation blocking Blocks other work or CI labels May 25, 2026
@dfcoffin dfcoffin merged commit af94f97 into main May 26, 2026
4 checks passed
@dfcoffin dfcoffin deleted the feature/issue-124-filter-chain-canonical branch May 26, 2026 00:12
dfcoffin added a commit that referenced this pull request May 26, 2026
…pring stock (#130)

Closes #127.

The hand-written JdbcRegisteredClientRepository (335 lines) reimplemented
Spring Authorization Server's stock repository and carried multiple defects:
the TokenSettings/ClientSettings serialization bug worked around in #128,
auto-encoding of client secrets on save, and a findAll() that returned empty
under the autocommit defect fixed in #125. This replaces it with Spring's
stock org.springframework.security.oauth2.server.authorization.client.JdbcRegisteredClientRepository,
which is maintained upstream and uses the correct Jackson modules out of the box.

Changes:

- AuthorizationServerConfig: construct the stock JdbcRegisteredClientRepository
  (JdbcOperations-only constructor); initializeDefaultClients() now takes the
  RegisteredClientRepository interface and no longer calls the custom findAll().

- OAuth2ClientManagementConfig: PasswordEncoder bean is now
  PasswordEncoderFactories.createDelegatingPasswordEncoder() instead of a bare
  BCryptPasswordEncoder(12). The stock repo stores client secrets verbatim
  (prefix included) rather than re-encoding on save, so authentication needs a
  prefix-aware encoder: {bcrypt}... for production, {noop}... for dev seeds.
  The old bare bcrypt encoder ignored the prefix and is why {noop}secret failed
  with invalid_client until this change.

- Default seed clients use distinct secrets ({noop}dc-secret, {noop}tp-secret,
  {noop}tpadmin-secret). The stock repo enforces client-secret uniqueness
  (a security check the custom repo lacked); three identical {noop}secret values
  were rejected with "duplicate client secret".

- New RegisteredClientAdminDao: small JdbcTemplate-backed component exposing the
  two operations not on the RegisteredClientRepository interface that the admin
  UI needs - findAllClientIds() and deleteById(). OAuthAdminController now
  resolves each id through RegisteredClientRepository.findByClientId(), so
  listing goes through Spring's tested deserialization path.

- OAuthAdminController: dropped the `instanceof JdbcRegisteredClientRepository`
  branches (and their dead fallback paths) in favor of the admin DAO.

- Deleted the custom JdbcRegisteredClientRepository and its 559-line test.
  Updated AuthorizationServerConfigTest (stock class + 1-arg bean signature),
  OAuthAdminControllerTest (admin DAO mocks; removed 2 tests for the now-gone
  instanceof fallback), and the MySQL/PostgreSQL TestContainers tests
  (findAll/deleteById -> RegisteredClientAdminDao).

Verified end-to-end against a fresh MySQL container (dev-mysql):
  POST /oauth2/token (data_custodian_admin:dc-secret, client_credentials)
    -> 200, 128-char opaque token (0 dots, REFERENCE format)
  POST /oauth2/introspect
    -> 200, RFC 7662 response (active, sub, aud, scope, iss, exp, iat, jti,
       client_id, token_type)
  POST /oauth2/token with wrong secret -> 401 (delegating encoder enforces auth)

Net: +112 / -1045 lines.

Pre-existing test debt (NOT introduced here): AuthorizationServerConfigTest's
mock-HttpSecurity unit tests and OAuthAdminControllerTest's standaloneSetup
security tests fail because of how they're written, and the authserver module
is excluded from CI entirely. Both are documented in #129; this change leaves
them no worse (OAuthAdminControllerTest 5 -> 4 failures).

Refs: #122 #128 #129

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
dfcoffin added a commit that referenced this pull request May 27, 2026
Closes #134.

ESPI 4.0 uses opaque access tokens only and the GBA Resource Server carries no
JWK/JWT. The auth-server carried OIDC + JWK/JWT-signing machinery the opaque flow
never exercises. Removed it; the opaque token mint + RFC 7662 introspection path is
unchanged.

AuthorizationServerConfig:
- Removed .oidc(...) from the authorization-server configurer.
- Removed the .oauth2ResourceServer().jwt(...) self-protection added in #128. The
  auth-server no longer validates bearer tokens on its own endpoints: OAuth2 protocol
  endpoints (token/introspect/revoke) authenticate clients via client_secret_basic;
  admin/UI endpoints are protected by the @order(2) session-login chain (#122 design §5).
- Removed jwkSource()/jwtDecoder() beans, generateRsaKey(), the .jwkSetEndpoint()
  declaration, and all JWK/JWT imports.

application.yml:
- Excluded OAuth2AuthorizationServerJwtAutoConfiguration so Spring Boot no longer
  auto-generates a JWKSource / stands up /oauth2/jwks. Removed jwk-set-uri property.

EspiTokenCustomizer RETAINED (not stripped): it is OAuth2TokenCustomizer<JwtEncodingContext>
and currently inert (short-circuits unless espi.token.format=jwt), BUT it is the sole
home of the ESPI logic adding resource/authorization URIs to the token. That logic must
migrate to the opaque token-response path (#122 token-response augmentation), so the
class + bean + test are kept deliberately.

OIDC removal is a DEFERRAL, not permanent — OIDC returns when multi-utility Third-Party
registration is built (#122).

Data-custodian: replaced the misleading "Future enhancement: Add JWT support" comment
with a note that JWT/JWK is intentionally absent (ESPI opaque-only).

Tests:
- AuthorizationServerConfigTest: removed the JWK Configuration test class + jwkSource()/
  jwtDecoder() assertions (beans gone); espiTokenCustomizer assertions kept.
- ClientRegistrationEndpointIntegrationTest: @disabled — every test hits the removed OIDC
  /connect/register endpoint. Restore when OIDC returns (#122); suite repair tracked in #129.
- Blast radius is exactly that one class; OAuth2FlowIntegrationTest and
  SecurityIntegrationTest exercise only the opaque /oauth2/{authorize,token,introspect}
  + admin endpoints this change preserves.

Verified (fresh MySQL, dev-mysql):
- Boots clean (~37s) with NO JWKSource
- POST /oauth2/token (client_credentials) -> 200 opaque token (128 chars, 0 dots)
- POST /oauth2/introspect -> 200 RFC 7662
- wrong secret -> 401
- /.well-known/openid-configuration -> 404; /oauth2/jwks -> 302 (no key served)

Known minor residue: the OAuth2 metadata doc still advertises a jwks_uri that now 302s
(removing it needs a metadata customizer; harmless for opaque-only). Left as-is.

Refs: #122 #128 #129

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

blocking Blocks other work or CI bug Something isn't working ESPI 4.0 Touches the NAESB ESPI 4.0 implementation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant