1
0
Files
spring-auth-demo/docs/authorization-server/03-clients-and-pkce.md
Ankur Mhatre 38c0a5f358 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:12:36 +05:30

124 lines
5.3 KiB
Markdown

[← 02 Minimum provider](02-minimum-provider.md) · [index](README.md) · next: [04 — The consent page](04-consent-page.md)
# Clients, PKCE and the defaults that moved
A `RegisteredClient` is a policy, not a credential. It states which grants a caller may
use, which redirect URIs are acceptable, which scopes it may request, whether consent is
required, whether PKCE is mandatory, and how long the tokens live. Most “works in
Postman, not in the browser” reports are one of those fields.
Source:
[`RegisteredClientConfig.java`](../../authorization-server/auth-server/src/main/java/com/ankurm/authserver/config/RegisteredClientConfig.java).
Three clients:
| client | authentication | grants | demonstrates |
|---|---|---|---|
| `demo-web` | `client_secret_basic` | code + refresh | consent, refresh rotation |
| `demo-spa` | `none` (public) | code + refresh | PKCE, and no refresh token |
| `demo-service` | `client_secret_basic` | client credentials | opaque vs JWT tokens |
## The default that flipped
`ClientSettings.builder().build()` run against both jars
([`tools/SettingsDefaults.java`](../../authorization-server/tools/SettingsDefaults.java),
output in [`as-settings-defaults.txt`](../output/as-settings-defaults.txt)):
```
=== Spring Authorization Server 1.5.8 ===
requireProofKey = false
=== Spring Authorization Server 7.1.1 ===
requireProofKey = true
```
**PKCE is now mandatory for every client you did not think about.** `demo-service` in this
project never touches `ClientSettings`, and `/diag/clients` reports
`"requireProofKey": true` for it. An authorization request with no `code_challenge` is
rejected at the authorization endpoint, before login:
```
302 http://127.0.0.1:8080/authorized
?error=invalid_request
&error_description=OAuth%202.0%20Parameter%3A%20code_challenge
&error_uri=…rfc7636%23section-4.4.1
```
That is [`as-authcode-pkce-enforced.txt`](../output/as-authcode-pkce-enforced.txt).
The client side moved in the same release. `ClientRegistration.ClientSettings.Builder`
initialises `requireProofKey` to `false` in Spring Security 6.5.1 and to `true` in 7.1.1
(same output file). So Spring-client-to-Spring-server keeps working; what breaks is a 7.1
server in front of a 6.x client, a non-Spring client, or a saved Postman collection. See
[08](08-client.md) for that failure end to end.
## `requireProofKey(false)` does not make PKCE optional for a public client
Two separate experiments, both in `docs/output`:
1. [`as-authcode-nopkce.txt`](../output/as-authcode-nopkce.txt) — `requireProofKey(false)`,
but the authorization request still carries a challenge. The token endpoint still demands
the verifier. Sending a challenge and then omitting the verifier is never accepted.
2. [`as-authcode-nochallenge.txt`](../output/as-authcode-nochallenge.txt) —
`requireProofKey(false)` and no challenge at all. The authorization endpoint issues a
code, and the token exchange then fails with **401 and an empty body**.
The second is the interesting one. `PublicClientAuthenticationProvider` delegates entirely
to `CodeVerifierAuthenticator` and raises `invalid_client` when there is nothing to verify:
```
private final CodeVerifierAuthenticator codeVerifierAuthenticator;
// String invalid_client
// String https://datatracker.ietf.org/doc/html/rfc6749#section-3.2.1
```
For a client registered with `ClientAuthenticationMethod.NONE`, the code verifier *is* the
client authentication. Turning `requireProofKey` off does not make PKCE optional; it makes
the client unable to authenticate. The setting relaxes the authorization endpoint only.
## Client secrets are hashed
```java
.clientSecret(encoder.encode("web-secret"))
```
Registering the bare string and then sending it produces `invalid_client` with no further
detail, because the server bcrypt-compares the presented secret against what it believes is
a hash. This is the most common first-hour failure and the error message is deliberately
unhelpful.
## Redirect URIs are exact
Scheme, host, port and path, byte for byte. No wildcards. A mismatch is rejected *before*
login and rendered by the authorization server rather than sent to the client — by
design, since redirecting to an unvalidated URI is the vulnerability.
## Public clients get no refresh token
`demo-spa` is registered with `AuthorizationGrantType.REFRESH_TOKEN` and the token response
contains no `refresh_token`
([`as-authcode-pkce.txt`](../output/as-authcode-pkce.txt)). `demo-web`, identically
registered but confidential, does get one
([`as-authcode-web.txt`](../output/as-authcode-web.txt)).
## Refresh rotation
`reuseRefreshTokens` defaults to `true` in both 1.5.8 and 7.1.1. `demo-web` sets it to
`false`, and the transcript shows the old token dying on first use:
```
old refresh token: 1BYxixPcmy4PLVmNvkTIo-00...
new refresh token: P-7KeaSx9alEBp5CFpDBt-c0...
DIFFERENT - reuseRefreshTokens(false), the old one is now dead
Replaying the old one:
{"error":"invalid_grant"}
```
## Related
- [`docs/12-issuer-and-audience.md`](../12-issuer-and-audience.md) — the same `iss`/`aud` questions from the resource server's side
- [`docs/05-hs256-vs-rs256.md`](../05-hs256-vs-rs256.md) — why the provider signs with RS256 here
Next: [04 — The consent page](04-consent-page.md)