1
0
Files
spring-auth-demo/docs/authorization-server/08-client.md
Ankur Mhatre e9381dc5be Add Spring Authorization Server project: OAuth2/OIDC provider, client and resource server
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.
2026-08-24 08:20:38 +05:30

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

Ten steps 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

as-client-flow-nopkce.txt:

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.

Next: 09 — The entry point and the Accept header