Three modules on Spring Boot 4.1.1 with Spring Authorization Server 7.1.1: the provider
itself, a relying party, and an API that trusts its tokens. Client registration, PKCE,
a custom consent page and token customisation, with profiles that make each failure
reproducible.
Every claim is backed by captured output in docs/output/as-*.txt, regenerated by
authorization-server/scripts/run-all.sh. Notable findings, verified against the jars:
- OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(HttpSecurity) was deleted
in 7.0, and both configuration classes moved into spring-security-config
- ClientSettings.requireProofKey flipped from false to true, on the authorization server
(1.5.8 -> 7.1.1) and on the OAuth2 client (6.5.1 -> 7.1.1)
- requireProofKey(false) does not make PKCE optional for a public client; the code
verifier is that client's only authentication at the token endpoint
- MediaTypeRequestMatcher(TEXT_HTML) matches Accept: */*, so the token endpoint answers
API callers with 302 -> /login unless setIgnoredMediaTypes(ALL) is called
Also renames the repository to spring-auth-demo and cross-links the new chapter set from
the existing documentation.
5.2 KiB
← 07 Diagnostics · index · next: 09 — The entry point and the Accept header
The relying party
Source:
ClientSecurityConfig.java,
HomeController.java,
PkceConfig.java.
The whole client side, in one method
http
.authorizeHttpRequests(auth -> auth.requestMatchers("/", "/error").permitAll()
.anyRequest().authenticated())
.oauth2Login(Customizer.withDefaults())
.oauth2Client(Customizer.withDefaults())
.logout(logout -> logout.logoutSuccessUrl("/"));
Plus one provider entry and one registration in application.yaml. Spring reads
/.well-known/openid-configuration at first use and fills in every endpoint from it.
Run the client on 127.0.0.1, not localhost
The authorization server is on localhost:9000 and the client on 127.0.0.1:8080. Those
are different origins to a browser cookie jar. Put both on localhost and the two
JSESSIONID cookies collide — one app's session clobbers the other's — and you
get a login loop that looks like a Spring Security bug.
Use the access token, not the id_token
@GetMapping("/orders")
public String orders(@RegisteredOAuth2AuthorizedClient("demo-web") OAuth2AuthorizedClient client, …)
@RegisteredOAuth2AuthorizedClient hands you the access token Spring already holds. Reading
a token out of the OidcUser gives you the id_token instead, which produces a 401 from a
resource server with a token that looks perfectly valid — because it is; it is just
the wrong one. See 05.
The full flow, hop by hop
as-client-flow.txt is the real thing, driven with curl so
every redirect is visible:
302 http://127.0.0.1:8080/orders
302 http://127.0.0.1:8080/oauth2/authorization/demo-web
302 http://localhost:9000/oauth2/authorize?…&code_challenge=…&code_challenge_method=S256
200 http://localhost:9000/login
302 POST http://localhost:9000/login
302 http://localhost:9000/oauth2/authorize?…&continue
200 http://localhost:9000/oauth2/consent?…
302 POST http://localhost:9000/oauth2/authorize
302 http://127.0.0.1:8080/login/oauth2/code/demo-web?code=g51U-dZi…&state=…
200 http://127.0.0.1:8080/orders
Nine hops for one login. Ending with the resource server's answer rendered by the client:
{orders=[{id=1, total=42.00}], subject=alice, scopes=[orders.write, openid, profile,
orders.read], roles=[ADMIN, USER], tenant=acme, audience=[orders-api]}
The client-side PKCE rule
DefaultOAuth2AuthorizationRequestResolver.getBuilder(...), disassembled
(as-pkce-applier.txt), applies its PKCE customizer when
either the registration's authentication method is NONE or
registration.getClientSettings().isRequireProofKey():
57: getstatic ClientAuthenticationMethod.NONE
64: invokevirtual ClientAuthenticationMethod.equals
67: ifne 80
71: invokevirtual ClientRegistration$ClientSettings.isRequireProofKey
77: ifeq 89
80: getstatic DEFAULT_PKCE_APPLIER
And ClientRegistration.ClientSettings.Builder initialises requireProofKey to false in
Spring Security 6.5.1 and to true in 7.1.1. So a confidential Spring client now sends
PKCE where it previously did not.
Note the consequence for configuration: setting an authorization request customizer can
turn PKCE on, but cannot turn it off, because the default applier runs inside getBuilder
independently of the customizer. To disable it you have to rebuild the ClientRegistration
with requireProofKey(false), which is what the nopkce profile does.
What a pre-7.0 client looks like against a 7.1 server
302 http://127.0.0.1:8080/oauth2/authorization/demo-web
302 http://localhost:9000/oauth2/authorize?response_type=code&client_id=demo-web&…&nonce=…
>>> NO code_challenge
302 http://127.0.0.1:8080/login/oauth2/code/demo-web
?error=invalid_request
&error_description=OAuth%202.0%20Parameter%3A%20code_challenge
200 http://127.0.0.1:8080/login?error
The user never sees a login page. They land on the client's error page, and nothing in
the client's logs names the provider as the cause — the reason exists only in a query
string that the client discards. Fix it on either side: requireProofKey(false) on the
RegisteredClient, or OAuth2AuthorizationRequestCustomizers.withPkce() on the client.
Prefer the second.
A dependency-cycle trap
A @Bean that takes ClientRegistrationRepository and returns one is a cycle, and Boot
refuses to start:
Relying upon circular references is discouraged and they are prohibited by default.
Post-process the repository Boot already built with a static BeanPostProcessor instead.