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.
This commit is contained in:
124
docs/authorization-server/08-client.md
Normal file
124
docs/authorization-server/08-client.md
Normal file
@@ -0,0 +1,124 @@
|
||||
[← 07 Diagnostics](07-diagnostics.md) · [index](README.md) · next: [09 — The entry point and the Accept header](09-entry-point.md)
|
||||
|
||||
# The relying party
|
||||
|
||||
Source:
|
||||
[`ClientSecurityConfig.java`](../../authorization-server/oidc-client/src/main/java/com/ankurm/client/ClientSecurityConfig.java),
|
||||
[`HomeController.java`](../../authorization-server/oidc-client/src/main/java/com/ankurm/client/HomeController.java),
|
||||
[`PkceConfig.java`](../../authorization-server/oidc-client/src/main/java/com/ankurm/client/PkceConfig.java).
|
||||
|
||||
## The whole client side, in one method
|
||||
|
||||
```java
|
||||
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
|
||||
|
||||
```java
|
||||
@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](05-token-customisation.md).
|
||||
|
||||
## The full flow, hop by hop
|
||||
|
||||
[`as-client-flow.txt`](../output/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`](../output/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`](../output/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](09-entry-point.md)
|
||||
Reference in New Issue
Block a user