diff --git a/README.md b/README.md
index 4ade748..724166a 100644
--- a/README.md
+++ b/README.md
@@ -1,15 +1,21 @@
-# jwt-auth-demo
+# spring-auth-demo
-Runnable companion code for two articles on [ankurm.com](https://ankurm.com):
+Runnable companion code for three articles on [ankurm.com](https://ankurm.com):
| | article | code |
|---|---|---|
| 1 | [Spring Security 7.1 JWT Authentication: The Complete Guide (Spring Boot 4.1)](https://ankurm.com/spring-security-7-1-jwt-authentication-guide/) | [`jwt-authentication/`](jwt-authentication) |
| 2 | [Spring Security OAuth2 Resource Server: JWT Validation, JWKS and Key Rotation](https://ankurm.com/spring-security-oauth2-resource-server-jwks-key-rotation/) | [`oauth2-resource-server/`](oauth2-resource-server) |
+| 3 | [Spring Authorization Server: Running Your Own OAuth2 / OIDC Provider](https://ankurm.com/spring-authorization-server-oauth2-oidc-provider/) | [`authorization-server/`](authorization-server) |
-Two Maven projects, one shared [`docs/`](docs) tree. The first mints and verifies its own
-tokens with a hand-written filter; the second verifies tokens minted by somebody else —
-a real Keycloak, and a stub issuer whose signing keys can be rotated on command.
+Three Maven projects, one shared [`docs/`](docs) tree. The first mints and verifies its own
+tokens with a hand-written filter. The second verifies tokens minted by somebody else —
+a real Keycloak, and a stub issuer whose signing keys can be rotated on command. The third
+*is* the somebody else: a real OAuth2 / OIDC provider, with a client and a resource server
+in front of it.
+
+> This repository was called `jwt-auth-demo` until the third project landed. Gitea keeps the
+> old URL redirecting, but please update any bookmarks to `spring-auth-demo`.
Everything here was compiled and executed. Every file under [`docs/output/`](docs/output) is
real program output, regenerated by a script — not transcribed by hand.
@@ -23,6 +29,7 @@ real program output, regenerated by a script — not transcribed by hand.
| Nimbus JOSE+JWT | **10.9.1** |
| Tomcat | **11.0.24** |
| Jackson | **3.1.5** (`tools.jackson`) |
+| Spring Authorization Server | **7.1.1** — the same artifact, now versioned with Spring Security |
| Keycloak | **26.7.2** (resource server project only) |
| Caffeine | **3.2.4** (resource server project only) |
@@ -33,8 +40,8 @@ real program output, regenerated by a script — not transcribed by hand.
### Project 1 — JWT authentication with a hand-written filter
```bash
-git clone https://ankurm.com/git.app/asmhatre/jwt-auth-demo.git
-cd jwt-auth-demo/jwt-authentication
+git clone https://ankurm.com/git.app/asmhatre/spring-auth-demo.git
+cd spring-auth-demo/jwt-authentication
./scripts/run.sh hs256 # or: mvn spring-boot:run -Dspring-boot.run.profiles=hs256
# in another shell
@@ -44,7 +51,7 @@ cd jwt-auth-demo/jwt-authentication
### Project 2 — OAuth2 resource server, JWKS and rotation
```bash
-cd jwt-auth-demo/oauth2-resource-server
+cd spring-auth-demo/oauth2-resource-server
# a stub issuer whose JWK Set can be mutated on command
./scripts/run-stub-issuer.sh
@@ -57,6 +64,20 @@ docker compose -f docker/compose.yaml up -d
./scripts/keycloak-demo.sh
```
+### Project 3 — your own OAuth2 / OIDC provider
+
+```bash
+cd spring-auth-demo/authorization-server
+
+./scripts/run.sh auth # the provider, :9000
+./scripts/run.sh rs # an API that trusts it, :8090
+./scripts/run.sh client # a relying party, :8080
+
+# then open http://127.0.0.1:8080/orders and log in as alice / password
+# or drive the whole thing with curl:
+./scripts/authcode-pkce.sh
+```
+
---
## Project 1 — `jwt-authentication/`
@@ -167,9 +188,70 @@ Regenerate its captured output with `./oauth2-resource-server/scripts/run-all.sh
---
+## Project 3 — `authorization-server/`
+
+Three Maven modules, three JVMs, three ports. Nothing about an authorization server is
+observable without a client to drive the browser redirect and a resource server to accept
+or reject what comes out.
+
+| module | port | what it is |
+|---|---|---|
+| [`auth-server/`](authorization-server/auth-server) | 9000 | the provider: clients, PKCE, consent, token customisation |
+| [`resource-server/`](authorization-server/resource-server) | 8090 | an API that trusts its tokens |
+| [`oidc-client/`](authorization-server/oidc-client) | 8080 | a relying party that logs in and calls the API |
+
+Two users: `alice` / `password` (`ROLE_USER`, `ROLE_ADMIN`) and `bob` / `password`
+(`ROLE_USER`).
+
+Three registered clients:
+
+| client | secret | authentication | grants |
+|---|---|---|---|
+| `demo-web` | `web-secret` | `client_secret_basic` | authorization code + refresh |
+| `demo-spa` | — | `none` (public) | authorization code + refresh |
+| `demo-service` | `service-secret` | `client_secret_basic` | client credentials |
+
+### Profiles
+
+| module | profile | what it changes |
+|---|---|---|
+| auth-server | *(none)* | consent on, PKCE required, custom claims, JWT tokens |
+| auth-server | `noconsent` | `requireAuthorizationConsent(false)` on every client |
+| auth-server | `nopkce` | `requireProofKey(false)` on the public client |
+| auth-server | `noclaims` | the `OAuth2TokenCustomizer` bean is not registered |
+| auth-server | `opaque` | `demo-service` gets reference tokens instead of JWTs |
+| auth-server | `acceptall` | the entry-point matcher without `setIgnoredMediaTypes` — see [docs/authorization-server/09](docs/authorization-server/09-entry-point.md) |
+| auth-server | `trace` | `TRACE` logging for `org.springframework.security` |
+| resource-server | `noaud` | audience validation off, i.e. the Spring Boot default |
+| oidc-client | `nopkce` | rebuilds the registration the way Spring Security 6.x would |
+
+### Endpoints
+
+| method | path | port | what it is |
+|---|---|---|---|
+| `GET` | `/.well-known/openid-configuration` | 9000 | OIDC discovery. Only present because `.oidc(...)` is on |
+| `GET` | `/.well-known/oauth-authorization-server` | 9000 | the OAuth2 metadata document, always present |
+| `GET` | `/oauth2/jwks` | 9000 | public keys |
+| `GET` | `/oauth2/authorize` | 9000 | the authorization endpoint |
+| `POST` | `/oauth2/token` | 9000 | the token endpoint |
+| `POST` | `/oauth2/introspect` | 9000 | for opaque tokens |
+| `GET` | `/oauth2/consent` | 9000 | **our** consent page |
+| `GET` | `/userinfo` | 9000 | OIDC UserInfo |
+| `GET` | `/diag/settings`, `/diag/clients`, `/diag/chains` | 9000 | **diagnostics. Delete before shipping** |
+| `GET` | `/api/orders` | 8090 | needs `SCOPE_orders.read` |
+| `POST` | `/api/orders` | 8090 | needs `SCOPE_orders.write` |
+| `GET` | `/api/admin` | 8090 | needs `ROLE_ADMIN`, which only exists via the token customiser |
+| `GET` | `/whoami` | 8090 | everything the resource server decoded |
+| `GET` | `/orders` | 8080 | the relying party's page; triggers the whole flow |
+
+Regenerate its captured output with `./authorization-server/scripts/run-all.sh`
+(no Docker needed; roughly three minutes).
+
+---
+
## Documentation
-One numbered trail across both projects. Start at
+One numbered trail across the first two projects, plus a separate set for the third. Start at
[`docs/01-architecture.md`](docs/01-architecture.md).
| doc | covers |
@@ -193,6 +275,24 @@ One numbered trail across both projects. Start at
| [17 — Keycloak setup](docs/17-keycloak-setup.md) | compose, realm import, and three ways it bites |
| [18 — Resource server checklist](docs/18-resource-server-checklist.md) | the list for the resource-server side |
+### Project 3 — running your own provider
+
+A separate chapter set, indexed at
+[`docs/authorization-server/`](docs/authorization-server/README.md).
+
+| doc | covers |
+|---|---|
+| [01 — Versions and the 7.0 move](docs/authorization-server/01-versions.md) | why there is no SAS version to pin, and which starter to use |
+| [02 — The minimum working provider](docs/authorization-server/02-minimum-provider.md) | two chains, and the API that replaced `applyDefaultSecurity` |
+| [03 — Clients, PKCE and the defaults that moved](docs/authorization-server/03-clients-and-pkce.md) | `requireProofKey` flipped to `true` on both sides |
+| [04 — The consent page](docs/authorization-server/04-consent-page.md) | the form contract, and the redirect loop |
+| [05 — Token customisation](docs/authorization-server/05-token-customisation.md) | the bean the JWT generator looks for, and the one it ignores |
+| [06 — The resource server side](docs/authorization-server/06-resource-server.md) | what `issuer-uri` does and does not validate |
+| [07 — Diagnostics](docs/authorization-server/07-diagnostics.md) | reading the effective configuration back out |
+| [08 — The relying party](docs/authorization-server/08-client.md) | a real browser flow, and the client-side PKCE default |
+| [09 — Entry point and the Accept header](docs/authorization-server/09-entry-point.md) | why the token endpoint 302s to a login page |
+| [10 — Should you run one at all](docs/authorization-server/10-should-you.md) | the honest answer |
+
---
## Captured output
@@ -229,6 +329,22 @@ One numbered trail across both projects. Start at
| [`rs-keycloak-default-converter.txt`](docs/output/rs-keycloak-default-converter.txt) | real Keycloak, roles unmapped |
| [`rs-test-run.txt`](docs/output/rs-test-run.txt) | 10 tests pinning the default validator stack |
+### Project 3
+
+Indexed in full at
+[`docs/authorization-server/README.md`](docs/authorization-server/README.md). The ones worth
+opening first:
+
+| file | what it shows |
+|---|---|
+| [`as-settings-defaults.txt`](docs/output/as-settings-defaults.txt) | `requireProofKey` false in SAS 1.5.8 and Spring Security 6.5.1, true in 7.1.1 — both sides |
+| [`as-legacy-compile-failure.txt`](docs/output/as-legacy-compile-failure.txt) | the pre-7.0 configuration, and the four compiler errors it now produces |
+| [`as-authcode-pkce.txt`](docs/output/as-authcode-pkce.txt) | the whole authorization-code + PKCE flow, every parameter visible |
+| [`as-client-flow-nopkce.txt`](docs/output/as-client-flow-nopkce.txt) | a pre-7.0 client against a 7.1 provider, failing on the client's own error page |
+| [`as-entrypoint-accept.txt`](docs/output/as-entrypoint-accept.txt) | 302 vs 401 from the token endpoint, decided by the `Accept` header |
+| [`as-client-credentials-opaque.txt`](docs/output/as-client-credentials-opaque.txt) | a reference token, and what introspection returns for it |
+| [`as-test-run.txt`](docs/output/as-test-run.txt) | 7 contract tests |
+
---
## Security note
@@ -244,6 +360,12 @@ them at anything you care about — see
`/api/public/decoder` reads private fields by reflection and prints your JWK Set URI and
cache timings. It is a diagnostic. Delete it before you ship.
+The authorization server's `/diag/*` endpoints are the same kind of thing: they publish
+client ids, grant types, scopes and your filter-chain ordering with no authentication. Its
+signing key is generated fresh on every boot, and its users are hard-coded. Read
+[docs/authorization-server/10-should-you.md](docs/authorization-server/10-should-you.md)
+before taking any of it near production.
+
## License
MIT.
diff --git a/authorization-server/auth-server/pom.xml b/authorization-server/auth-server/pom.xml
new file mode 100644
index 0000000..768d9ae
--- /dev/null
+++ b/authorization-server/auth-server/pom.xml
@@ -0,0 +1,65 @@
+
+
+ 4.0.0
+
+
+ com.ankurm
+ authorization-server-demo
+ 1.0.0
+
+
+ auth-server
+ auth-server
+ The OAuth2 / OIDC provider itself
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-security-oauth2-authorization-server
+
+
+ org.springframework.boot
+ spring-boot-starter-thymeleaf
+
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+
+ org.springframework.boot
+ spring-boot-starter-webmvc-test
+ test
+
+
+ org.springframework.boot
+ spring-boot-starter-security-test
+ test
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+
+
diff --git a/authorization-server/auth-server/src/main/java/com/ankurm/authserver/AuthServerApplication.java b/authorization-server/auth-server/src/main/java/com/ankurm/authserver/AuthServerApplication.java
new file mode 100644
index 0000000..94fdd99
--- /dev/null
+++ b/authorization-server/auth-server/src/main/java/com/ankurm/authserver/AuthServerApplication.java
@@ -0,0 +1,28 @@
+package com.ankurm.authserver;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+/**
+ * The provider. Runs on :9000 and is the issuer for everything else in this module.
+ *
+ *
Start it with {@code ../scripts/run.sh auth} (default profile) or with one of the
+ * variant profiles that deliberately break something:
+ *
+ *
+ * {@code noconsent} — consent turned off for the confidential client
+ * {@code nopkce} — the public client no longer requires PKCE
+ * {@code noclaims} — the token customiser is not registered
+ * {@code opaque} — the service client gets reference tokens, not JWTs
+ *
+ *
+ * @see
+ * docs/authorization-server/02-minimum-provider.md
+ */
+@SpringBootApplication
+public class AuthServerApplication {
+
+ public static void main(String[] args) {
+ SpringApplication.run(AuthServerApplication.class, args);
+ }
+}
diff --git a/authorization-server/auth-server/src/main/java/com/ankurm/authserver/config/AuthorizationServerConfig.java b/authorization-server/auth-server/src/main/java/com/ankurm/authserver/config/AuthorizationServerConfig.java
new file mode 100644
index 0000000..8573765
--- /dev/null
+++ b/authorization-server/auth-server/src/main/java/com/ankurm/authserver/config/AuthorizationServerConfig.java
@@ -0,0 +1,206 @@
+package com.ankurm.authserver.config;
+
+import com.nimbusds.jose.jwk.JWKSet;
+import com.nimbusds.jose.jwk.RSAKey;
+import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
+import com.nimbusds.jose.jwk.source.JWKSource;
+import com.nimbusds.jose.proc.SecurityContext;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.core.Ordered;
+import org.springframework.core.annotation.Order;
+import org.springframework.http.MediaType;
+import org.springframework.security.config.Customizer;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration;
+import org.springframework.security.config.annotation.web.configurers.oauth2.server.authorization.OAuth2AuthorizationServerConfigurer;
+import org.springframework.security.oauth2.jwt.JwtDecoder;
+import org.springframework.security.oauth2.server.authorization.InMemoryOAuth2AuthorizationConsentService;
+import org.springframework.security.oauth2.server.authorization.InMemoryOAuth2AuthorizationService;
+import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsentService;
+import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService;
+import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings;
+import org.springframework.security.web.SecurityFilterChain;
+import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
+import org.springframework.security.web.util.matcher.MediaTypeRequestMatcher;
+
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.interfaces.RSAPrivateKey;
+import java.security.interfaces.RSAPublicKey;
+import java.util.Set;
+import java.util.UUID;
+
+/**
+ * The protocol filter chain — the one that owns {@code /oauth2/**} and the OIDC
+ * endpoints, and nothing else.
+ *
+ * The two import lines that break every tutorial
+ *
+ * Up to Spring Authorization Server 1.5.x these two classes lived in the
+ * {@code spring-security-oauth2-authorization-server} jar:
+ *
+ *
+ * org.springframework.security.oauth2.server.authorization.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration
+ * org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers.OAuth2AuthorizationServerConfigurer
+ *
+ *
+ * As of 7.0 they live in {@code spring-security-config}, under different packages:
+ *
+ *
+ * org.springframework.security.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration
+ * org.springframework.security.config.annotation.web.configurers.oauth2.server.authorization.OAuth2AuthorizationServerConfigurer
+ *
+ *
+ * And {@code OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http)} — the
+ * static method essentially every blog post calls — no longer exists. It was public
+ * and static in 1.5.8; in 7.1.1 the class exposes only {@code @Bean} methods. The
+ * replacement is {@code http.with(new OAuth2AuthorizationServerConfigurer(), ...)}, which
+ * is what this class does.
+ *
+ * @see
+ * docs/authorization-server/01-versions.md
+ * @see
+ * docs/authorization-server/02-minimum-provider.md
+ */
+@Configuration(proxyBeanMethods = false)
+public class AuthorizationServerConfig {
+
+ /**
+ * Order matters and the ordering is not cosmetic. This chain carries a
+ * {@code securityMatcher} restricted to the protocol endpoints, so it must be
+ * consulted before the catch-all form-login chain in {@link DefaultSecurityConfig}.
+ * If the two are swapped, the form-login chain matches {@code /oauth2/token} first,
+ * the token endpoint is never reached, and a token request 302s to {@code /login}.
+ * That redirect is the fingerprint of a mis-ordered pair of chains.
+ */
+ @Bean
+ @Order(Ordered.HIGHEST_PRECEDENCE)
+ public SecurityFilterChain authorizationServerChain(
+ HttpSecurity http,
+ @Value("${demo.ignore-accept-all:true}") boolean ignoreAcceptAll) throws Exception {
+ OAuth2AuthorizationServerConfigurer authorizationServer =
+ new OAuth2AuthorizationServerConfigurer();
+
+ http
+ .securityMatcher(authorizationServer.getEndpointsMatcher())
+ .with(authorizationServer, server -> server
+ // Turning OIDC on is one line, and it is not on by default: without it
+ // there is no /userinfo, no id_token, and no
+ // /.well-known/openid-configuration - only the OAuth2 metadata document
+ // at /.well-known/oauth-authorization-server.
+ .oidc(Customizer.withDefaults())
+ // Replace the built-in consent page with our own. The value is a path the
+ // authorization endpoint redirects the browser to; it is *our* MVC
+ // controller, protected by the form-login chain, and it must POST back to
+ // /oauth2/authorize. See ConsentController.
+ .authorizationEndpoint(endpoint -> endpoint.consentPage("/oauth2/consent"))
+ )
+ .authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
+ // An unauthenticated GET /oauth2/authorize must send a *browser* to the login
+ // page, and must not do that to a machine calling /oauth2/token.
+ //
+ // Scoping the entry point with MediaTypeRequestMatcher(TEXT_HTML) is the
+ // documented way to express that, and on its own it does not work. curl,
+ // most HTTP clients and anything that does not set Accept send `*/*`, and
+ // `*/*` matches text/html, so the matcher fires and the token endpoint
+ // answers a failed public-client authentication with `302 -> /login`
+ // instead of a JSON 401. setIgnoredMediaTypes(ALL) is the line that fixes
+ // it. Run with the `acceptall` profile to see the unfixed behaviour, and
+ // compare docs/output/as-entrypoint-accept.txt.
+ .exceptionHandling(ex -> ex.defaultAuthenticationEntryPointFor(
+ new LoginUrlAuthenticationEntryPoint("/login"),
+ htmlOnly(ignoreAcceptAll)))
+ .oauth2ResourceServer(rs -> rs.jwt(Customizer.withDefaults()));
+
+ return http.build();
+ }
+
+ private static MediaTypeRequestMatcher htmlOnly(boolean ignoreAcceptAll) {
+ MediaTypeRequestMatcher matcher = new MediaTypeRequestMatcher(MediaType.TEXT_HTML);
+ if (ignoreAcceptAll) {
+ matcher.setIgnoredMediaTypes(Set.of(MediaType.ALL));
+ }
+ return matcher;
+ }
+
+ /**
+ * These two look optional and are not, the moment you write a custom consent page.
+ *
+ * {@code OAuth2AuthorizationServerConfigurer} creates in-memory implementations for
+ * its own use when you do not supply them, but they are not published as beans you can
+ * inject. A {@code ConsentController} that constructor-injects
+ * {@code OAuth2AuthorizationConsentService} therefore fails the context at startup with
+ * {@code No qualifying bean of type ...OAuth2AuthorizationConsentService available} -
+ * the exact message is committed in
+ * {@code docs/output/as-missing-consent-service.txt}. Declaring them here fixes it and,
+ * more usefully, makes the storage decision explicit: both of these are per-instance
+ * memory, so a second replica of the authorization server cannot complete a code
+ * exchange started on the first.
+ */
+ @Bean
+ public OAuth2AuthorizationService authorizationService() {
+ return new InMemoryOAuth2AuthorizationService();
+ }
+
+ @Bean
+ public OAuth2AuthorizationConsentService authorizationConsentService() {
+ return new InMemoryOAuth2AuthorizationConsentService();
+ }
+
+ /**
+ * The issuer identifier. Everything downstream keys off this string: it is the
+ * {@code iss} claim, the base of the discovery document, and the value a resource
+ * server compares against. {@code http://localhost:9000} works only because both
+ * sides agree on it exactly — a trailing slash here and no trailing slash on
+ * the resource server is a mismatch, and produces
+ * {@code The iss claim is not valid} at validation time, not at startup.
+ */
+ @Bean
+ public AuthorizationServerSettings authorizationServerSettings() {
+ return AuthorizationServerSettings.builder()
+ .issuer("http://localhost:9000")
+ .build();
+ }
+
+ /**
+ * A fresh RSA keypair per boot. That is deliberate for a demo — restart the
+ * server and every previously issued token stops verifying, which is exactly the
+ * behaviour you want to notice now rather than in production. A real deployment
+ * loads a persistent key (or a rotating set) and serves both the current and the
+ * previous public key from the JWK Set so in-flight tokens survive rotation; that is
+ * the subject of docs/15-jwks-caching-and-rotation.md in the resource-server project.
+ */
+ @Bean
+ public JWKSource jwkSource() {
+ KeyPair keyPair = generateRsaKey();
+ RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
+ RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();
+ RSAKey rsaKey = new RSAKey.Builder(publicKey)
+ .privateKey(privateKey)
+ .keyID(UUID.randomUUID().toString())
+ .build();
+ return new ImmutableJWKSet<>(new JWKSet(rsaKey));
+ }
+
+ private static KeyPair generateRsaKey() {
+ try {
+ KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
+ generator.initialize(2048);
+ return generator.generateKeyPair();
+ } catch (Exception ex) {
+ throw new IllegalStateException("cannot generate an RSA keypair", ex);
+ }
+ }
+
+ /**
+ * The authorization server is also a resource server for its own UserInfo endpoint.
+ * This static helper is one of the few things that survived the 7.0 package move
+ * unchanged in shape - only its package changed.
+ */
+ @Bean
+ public JwtDecoder jwtDecoder(JWKSource jwkSource) {
+ return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource);
+ }
+}
diff --git a/authorization-server/auth-server/src/main/java/com/ankurm/authserver/config/DefaultSecurityConfig.java b/authorization-server/auth-server/src/main/java/com/ankurm/authserver/config/DefaultSecurityConfig.java
new file mode 100644
index 0000000..e5bba8d
--- /dev/null
+++ b/authorization-server/auth-server/src/main/java/com/ankurm/authserver/config/DefaultSecurityConfig.java
@@ -0,0 +1,63 @@
+package com.ankurm.authserver.config;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.security.config.Customizer;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.core.userdetails.User;
+import org.springframework.security.core.userdetails.UserDetails;
+import org.springframework.security.core.userdetails.UserDetailsService;
+import org.springframework.security.crypto.factory.PasswordEncoderFactories;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.security.provisioning.InMemoryUserDetailsManager;
+import org.springframework.security.web.SecurityFilterChain;
+
+/**
+ * The second chain: everything that is not a protocol endpoint. The login form, the
+ * consent page, static assets, the diagnostics endpoint.
+ *
+ * Two chains is the shape, not an optimisation. The protocol chain in
+ * {@link AuthorizationServerConfig} carries a {@code securityMatcher} and therefore
+ * declines every request that is not an OAuth2 or OIDC endpoint; something has to pick
+ * those up, and it needs a completely different authentication mechanism (a browser
+ * session, not a bearer token).
+ *
+ * @see
+ * docs/authorization-server/03-clients-and-pkce.md
+ */
+@Configuration(proxyBeanMethods = false)
+public class DefaultSecurityConfig {
+
+ @Bean
+ public SecurityFilterChain defaultChain(HttpSecurity http) throws Exception {
+ http
+ .authorizeHttpRequests(auth -> auth
+ .requestMatchers("/diag/**", "/error", "/favicon.ico").permitAll()
+ .anyRequest().authenticated())
+ .formLogin(Customizer.withDefaults());
+ return http.build();
+ }
+
+ /**
+ * Two users so the consent screen and the token claims have something to differ on.
+ * {@code alice} is an admin, {@code bob} is not, and the token customiser copies
+ * their authorities into a {@code roles} claim the resource server reads.
+ *
+ *
{@code {noop}} is not a placeholder for a password hash - it is a
+ * {@code DelegatingPasswordEncoder} prefix meaning "stored in clear". Useful for a
+ * demo, an incident in production.
+ */
+ @Bean
+ public UserDetailsService users() {
+ PasswordEncoder encoder = PasswordEncoderFactories.createDelegatingPasswordEncoder();
+ UserDetails alice = User.withUsername("alice")
+ .password(encoder.encode("password"))
+ .roles("USER", "ADMIN")
+ .build();
+ UserDetails bob = User.withUsername("bob")
+ .password(encoder.encode("password"))
+ .roles("USER")
+ .build();
+ return new InMemoryUserDetailsManager(alice, bob);
+ }
+}
diff --git a/authorization-server/auth-server/src/main/java/com/ankurm/authserver/config/RegisteredClientConfig.java b/authorization-server/auth-server/src/main/java/com/ankurm/authserver/config/RegisteredClientConfig.java
new file mode 100644
index 0000000..56590c2
--- /dev/null
+++ b/authorization-server/auth-server/src/main/java/com/ankurm/authserver/config/RegisteredClientConfig.java
@@ -0,0 +1,126 @@
+package com.ankurm.authserver.config;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.security.crypto.factory.PasswordEncoderFactories;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.security.oauth2.core.AuthorizationGrantType;
+import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
+import org.springframework.security.oauth2.core.oidc.OidcScopes;
+import org.springframework.security.oauth2.server.authorization.client.InMemoryRegisteredClientRepository;
+import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
+import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
+import org.springframework.security.oauth2.server.authorization.settings.ClientSettings;
+import org.springframework.security.oauth2.server.authorization.settings.OAuth2TokenFormat;
+import org.springframework.security.oauth2.server.authorization.settings.TokenSettings;
+
+import java.time.Duration;
+import java.util.UUID;
+
+/**
+ * Client registration. Three clients, each demonstrating a different half of the
+ * {@link ClientSettings} / {@link TokenSettings} surface.
+ *
+ *
A {@code RegisteredClient} is not a user and not a credential - it is a *policy*.
+ * It says which grants this caller may use, which redirect URIs are acceptable, which
+ * scopes it may ask for, whether the user has to consent, whether PKCE is mandatory, and
+ * how long the resulting tokens live. Almost every "it works in Postman but not in the
+ * browser" report is one of these fields.
+ *
+ * @see
+ * docs/authorization-server/03-clients-and-pkce.md
+ */
+@Configuration(proxyBeanMethods = false)
+public class RegisteredClientConfig {
+
+ /**
+ * Set to {@code false} by the {@code noconsent} profile, and to {@code false} for PKCE
+ * by the {@code nopkce} profile. Both exist so the failure is one flag away and can be
+ * captured as real output rather than described.
+ */
+ @Bean
+ public RegisteredClientRepository registeredClientRepository(
+ @Value("${demo.require-consent:true}") boolean requireConsent,
+ @Value("${demo.require-pkce:true}") boolean requirePkce,
+ @Value("${demo.service-token-format:jwt}") String serviceTokenFormat) {
+
+ PasswordEncoder encoder = PasswordEncoderFactories.createDelegatingPasswordEncoder();
+
+ // ---- 1. A confidential web application: authorization_code + refresh_token ----
+ RegisteredClient webClient = RegisteredClient.withId(UUID.randomUUID().toString())
+ .clientId("demo-web")
+ // The secret is *hashed*. Registering a bare string here and then sending
+ // it from the client 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 single most common first-hour failure.
+ .clientSecret(encoder.encode("web-secret"))
+ .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
+ .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
+ .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
+ // Exact-match, including scheme, port and path. No wildcards, no prefix
+ // matching. A mismatch is rejected *before* login, with the error rendered
+ // by the authorization server rather than sent to the client - by design,
+ // since redirecting to an unvalidated URI is the vulnerability.
+ .redirectUri("http://127.0.0.1:8080/login/oauth2/code/demo-web")
+ .postLogoutRedirectUri("http://127.0.0.1:8080/")
+ .scope(OidcScopes.OPENID)
+ .scope(OidcScopes.PROFILE)
+ .scope("orders.read")
+ .scope("orders.write")
+ .clientSettings(ClientSettings.builder()
+ .requireAuthorizationConsent(requireConsent)
+ .build())
+ .tokenSettings(TokenSettings.builder()
+ .accessTokenTimeToLive(Duration.ofMinutes(5))
+ .refreshTokenTimeToLive(Duration.ofMinutes(60))
+ // false means every refresh returns a *new* refresh token and
+ // invalidates the old one. That is rotation, and it is how a
+ // stolen refresh token becomes detectable.
+ .reuseRefreshTokens(false)
+ .build())
+ .build();
+
+ // ---- 2. A public client (SPA / native): no secret, PKCE mandatory ----
+ RegisteredClient spaClient = RegisteredClient.withId(UUID.randomUUID().toString())
+ .clientId("demo-spa")
+ .clientAuthenticationMethod(ClientAuthenticationMethod.NONE)
+ .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
+ .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
+ .redirectUri("http://127.0.0.1:8080/authorized")
+ .scope(OidcScopes.OPENID)
+ .scope("orders.read")
+ .clientSettings(ClientSettings.builder()
+ // With NONE authentication this is the only thing standing between
+ // an intercepted authorization code and an access token. Spring
+ // Authorization Server does not infer it: a public client with
+ // requireProofKey(false) will happily complete a code exchange
+ // with no verifier at all. The `nopkce` profile shows that.
+ .requireProofKey(requirePkce)
+ .requireAuthorizationConsent(requireConsent)
+ .build())
+ .build();
+
+ // ---- 3. A machine client: client_credentials, no user, no refresh token ----
+ RegisteredClient serviceClient = RegisteredClient.withId(UUID.randomUUID().toString())
+ .clientId("demo-service")
+ .clientSecret(encoder.encode("service-secret"))
+ .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
+ .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
+ .scope("orders.read")
+ .tokenSettings(TokenSettings.builder()
+ .accessTokenTimeToLive(Duration.ofMinutes(10))
+ // SELF_CONTAINED is a signed JWT the resource server verifies
+ // offline. REFERENCE is an opaque string that only means anything
+ // to /oauth2/introspect - which turns every API call into a
+ // network round trip to the authorization server, and gives you
+ // instant revocation in exchange. The `opaque` profile flips it.
+ .accessTokenFormat("reference".equals(serviceTokenFormat)
+ ? OAuth2TokenFormat.REFERENCE
+ : OAuth2TokenFormat.SELF_CONTAINED)
+ .build())
+ .build();
+
+ return new InMemoryRegisteredClientRepository(webClient, spaClient, serviceClient);
+ }
+}
diff --git a/authorization-server/auth-server/src/main/java/com/ankurm/authserver/diag/ProviderDiagnostics.java b/authorization-server/auth-server/src/main/java/com/ankurm/authserver/diag/ProviderDiagnostics.java
new file mode 100644
index 0000000..ede736f
--- /dev/null
+++ b/authorization-server/auth-server/src/main/java/com/ankurm/authserver/diag/ProviderDiagnostics.java
@@ -0,0 +1,105 @@
+package com.ankurm.authserver.diag;
+
+import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
+import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
+import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings;
+import org.springframework.security.web.FilterChainProxy;
+import org.springframework.security.web.SecurityFilterChain;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Runtime state that is otherwise invisible: the resolved endpoint paths, the registered
+ * clients as the server actually holds them, and the live filter chains in order.
+ *
+ *
This exists because the interesting configuration in an authorization server is
+ * spread across three builders and two filter chains, and the effective result is not
+ * printed anywhere at startup. Reading the beans back is faster than reasoning about them.
+ *
+ *
Delete this before shipping. It exposes client ids, scopes, grant types and
+ * your chain ordering to anyone who can reach {@code /diag}.
+ *
+ * @see
+ * docs/authorization-server/07-diagnostics.md
+ */
+@RestController
+@RequestMapping("/diag")
+public class ProviderDiagnostics {
+
+ private final RegisteredClientRepository clients;
+ private final AuthorizationServerSettings settings;
+ private final FilterChainProxy filterChainProxy;
+
+ public ProviderDiagnostics(RegisteredClientRepository clients,
+ AuthorizationServerSettings settings,
+ FilterChainProxy filterChainProxy) {
+ this.clients = clients;
+ this.settings = settings;
+ this.filterChainProxy = filterChainProxy;
+ }
+
+ /** Every endpoint path the server resolved, including the ones you did not set. */
+ @GetMapping("/settings")
+ public Map settings() {
+ return new LinkedHashMap<>(this.settings.getSettings());
+ }
+
+ /**
+ * The registered clients, flattened. Note that the client secret is not returned -
+ * it is a hash, and printing it invites people to try to use it as a secret.
+ */
+ @GetMapping("/clients")
+ public List> clients() {
+ List> out = new ArrayList<>();
+ for (String id : List.of("demo-web", "demo-spa", "demo-service")) {
+ RegisteredClient c = this.clients.findByClientId(id);
+ if (c == null) {
+ continue;
+ }
+ Map m = new LinkedHashMap<>();
+ m.put("clientId", c.getClientId());
+ m.put("authenticationMethods", c.getClientAuthenticationMethods().stream()
+ .map(a -> a.getValue()).toList());
+ m.put("grantTypes", c.getAuthorizationGrantTypes().stream()
+ .map(g -> g.getValue()).toList());
+ m.put("redirectUris", c.getRedirectUris());
+ m.put("scopes", c.getScopes());
+ m.put("requireProofKey", c.getClientSettings().isRequireProofKey());
+ m.put("requireAuthorizationConsent",
+ c.getClientSettings().isRequireAuthorizationConsent());
+ m.put("accessTokenFormat",
+ c.getTokenSettings().getAccessTokenFormat().getValue());
+ m.put("accessTokenTtlSeconds",
+ c.getTokenSettings().getAccessTokenTimeToLive().toSeconds());
+ m.put("reuseRefreshTokens", c.getTokenSettings().isReuseRefreshTokens());
+ out.add(m);
+ }
+ return out;
+ }
+
+ /**
+ * The filter chains in the order Spring Security will consult them, with the matcher
+ * each one carries. If the authorization server chain is not first, the token endpoint
+ * is unreachable - and this is where you see that, rather than inferring it from a 302.
+ */
+ @GetMapping("/chains")
+ public List> chains() {
+ List> out = new ArrayList<>();
+ int i = 0;
+ for (SecurityFilterChain chain : this.filterChainProxy.getFilterChains()) {
+ Map m = new LinkedHashMap<>();
+ m.put("position", i++);
+ m.put("matcher", String.valueOf(chain));
+ m.put("filters", chain.getFilters().stream()
+ .map(f -> f.getClass().getSimpleName()).toList());
+ out.add(m);
+ }
+ return out;
+ }
+}
diff --git a/authorization-server/auth-server/src/main/java/com/ankurm/authserver/token/TokenClaimsCustomizer.java b/authorization-server/auth-server/src/main/java/com/ankurm/authserver/token/TokenClaimsCustomizer.java
new file mode 100644
index 0000000..0afeaef
--- /dev/null
+++ b/authorization-server/auth-server/src/main/java/com/ankurm/authserver/token/TokenClaimsCustomizer.java
@@ -0,0 +1,81 @@
+package com.ankurm.authserver.token;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Profile;
+import org.springframework.security.core.GrantedAuthority;
+import org.springframework.security.oauth2.server.authorization.OAuth2TokenType;
+import org.springframework.security.oauth2.core.oidc.endpoint.OidcParameterNames;
+import org.springframework.security.oauth2.server.authorization.token.JwtEncodingContext;
+import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenCustomizer;
+
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/**
+ * Token customisation — the hook that decides what a downstream service can
+ * authorise on without calling back.
+ *
+ * One bean of type {@code OAuth2TokenCustomizer} is picked up
+ * automatically by the JWT generator. There is no registration step and no annotation;
+ * if the bean exists with that exact generic type, it runs. Declare it as
+ * {@code OAuth2TokenCustomizer} by mistake — the type used
+ * for *opaque* tokens — and it will be silently ignored, because the JWT generator
+ * resolves the bean by generic type and simply will not find it. Nothing logs. Your
+ * claims are just absent.
+ *
+ * The {@code noclaims} profile disables this bean, so the difference is a diff of two
+ * decoded tokens rather than a paragraph.
+ *
+ * @see
+ * docs/authorization-server/05-token-customisation.md
+ */
+@Configuration(proxyBeanMethods = false)
+@Profile("!noclaims")
+public class TokenClaimsCustomizer {
+
+ @Bean
+ public OAuth2TokenCustomizer jwtCustomizer() {
+ return context -> {
+ // context.getTokenType() distinguishes the access token, the id_token and the
+ // refresh token. Writing the same claim into all three is a common accident:
+ // an id_token is for the client, an access token is for the API, and putting
+ // authorisation data in the id_token invites the client to make security
+ // decisions it has no business making.
+ if (OAuth2TokenType.ACCESS_TOKEN.equals(context.getTokenType())) {
+ context.getClaims().claim("tenant", "acme");
+
+ // client_credentials has no user: getPrincipal() is the client's own
+ // authentication, and asking it for roles yields nothing useful. Guard on
+ // the grant type or on whether a user is present, or a machine token
+ // silently inherits whatever authorities the client authentication carries.
+ if (context.getPrincipal() != null
+ && context.getAuthorizationGrantType() != null
+ && !"client_credentials".equals(
+ context.getAuthorizationGrantType().getValue())) {
+ Set roles = context.getPrincipal().getAuthorities().stream()
+ .map(GrantedAuthority::getAuthority)
+ .filter(a -> a.startsWith("ROLE_"))
+ .map(a -> a.substring("ROLE_".length()))
+ .collect(Collectors.toCollection(java.util.LinkedHashSet::new));
+ context.getClaims().claim("roles", roles);
+ }
+
+ // The audience is what stops a token minted for one API being replayed
+ // against another. Spring Authorization Server does not populate `aud` on
+ // access tokens by default - there is no per-client audience setting - so
+ // if your resource servers validate audience (they should), this line or
+ // something like it is mandatory.
+ context.getClaims().audience(List.of("orders-api"));
+ }
+
+ if (OidcParameterNames.ID_TOKEN.equals(context.getTokenType().getValue())) {
+ // Profile-ish claims for the relying party to render. Deliberately not
+ // roles: see above.
+ context.getClaims().claim("preferred_username",
+ context.getPrincipal().getName());
+ }
+ };
+ }
+}
diff --git a/authorization-server/auth-server/src/main/java/com/ankurm/authserver/web/ConsentController.java b/authorization-server/auth-server/src/main/java/com/ankurm/authserver/web/ConsentController.java
new file mode 100644
index 0000000..fb235a0
--- /dev/null
+++ b/authorization-server/auth-server/src/main/java/com/ankurm/authserver/web/ConsentController.java
@@ -0,0 +1,104 @@
+package com.ankurm.authserver.web;
+
+import jakarta.servlet.http.HttpServletRequest;
+import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
+import org.springframework.security.oauth2.core.oidc.OidcScopes;
+import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsent;
+import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsentService;
+import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
+import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
+import org.springframework.stereotype.Controller;
+import org.springframework.ui.Model;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+
+import java.security.Principal;
+import java.util.LinkedHashSet;
+import java.util.Set;
+
+/**
+ * The consent page.
+ *
+ * Wiring a custom one is deceptively small: {@code .consentPage("/oauth2/consent")} on
+ * the authorization endpoint, plus a controller that renders a form. What the
+ * documentation does not spell out is the contract the form has to satisfy, and getting
+ * any part of it wrong produces a redirect loop rather than an error:
+ *
+ *
+ * the form POSTs back to {@code /oauth2/authorize} , not to the consent path
+ * it must echo {@code client_id} and {@code state} exactly as received
+ * each approved scope goes back as a separate {@code scope} parameter
+ * CSRF token included — this is the browser chain, not the protocol chain
+ * {@code openid} is not shown as a checkbox: it is requested implicitly and
+ * the server does not require consent for it
+ *
+ *
+ * Miss the {@code state} parameter and the authorization endpoint cannot correlate the
+ * approval with the pending request, so it starts a new one — which redirects to the
+ * consent page again. The loop looks like a session problem and is not.
+ *
+ * @see
+ * docs/authorization-server/04-consent-page.md
+ */
+@Controller
+public class ConsentController {
+
+ private final RegisteredClientRepository registeredClientRepository;
+ private final OAuth2AuthorizationConsentService authorizationConsentService;
+
+ public ConsentController(RegisteredClientRepository registeredClientRepository,
+ OAuth2AuthorizationConsentService authorizationConsentService) {
+ this.registeredClientRepository = registeredClientRepository;
+ this.authorizationConsentService = authorizationConsentService;
+ }
+
+ @GetMapping("/oauth2/consent")
+ public String consent(Principal principal, Model model,
+ @RequestParam(OAuth2ParameterNames.CLIENT_ID) String clientId,
+ @RequestParam(OAuth2ParameterNames.SCOPE) String scope,
+ @RequestParam(OAuth2ParameterNames.STATE) String state,
+ @RequestParam(name = "user_code", required = false) String userCode,
+ HttpServletRequest request) {
+
+ RegisteredClient registeredClient = this.registeredClientRepository.findByClientId(clientId);
+ if (registeredClient == null) {
+ throw new IllegalArgumentException("unknown client_id: " + clientId);
+ }
+
+ // Scopes the user has already approved in an earlier authorization are pre-ticked
+ // and, in the default flow, would not have brought us here at all - the consent
+ // page only appears for scopes not yet consented to.
+ OAuth2AuthorizationConsent currentConsent =
+ this.authorizationConsentService.findById(registeredClient.getId(), principal.getName());
+ Set alreadyApproved = currentConsent != null
+ ? currentConsent.getScopes()
+ : Set.of();
+
+ Set toApprove = new LinkedHashSet<>();
+ Set previouslyApproved = new LinkedHashSet<>();
+ for (String requested : scope.split(" ")) {
+ // openid is requested implicitly by every OIDC client and the authorization
+ // endpoint never asks for consent on it. Rendering it as a checkbox is
+ // harmless but misleading, and unticking it does nothing.
+ if (OidcScopes.OPENID.equals(requested)) {
+ continue;
+ }
+ if (alreadyApproved.contains(requested)) {
+ previouslyApproved.add(requested);
+ } else {
+ toApprove.add(requested);
+ }
+ }
+
+ model.addAttribute("clientId", clientId);
+ model.addAttribute("clientName", registeredClient.getClientName());
+ model.addAttribute("state", state);
+ model.addAttribute("scopes", toApprove);
+ model.addAttribute("previouslyApprovedScopes", previouslyApproved);
+ model.addAttribute("principalName", principal.getName());
+ model.addAttribute("userCode", userCode);
+ model.addAttribute("requestURI", "/oauth2/authorize");
+ return "consent";
+ }
+
+}
diff --git a/authorization-server/auth-server/src/main/resources/application-acceptall.yaml b/authorization-server/auth-server/src/main/resources/application-acceptall.yaml
new file mode 100644
index 0000000..bb1b362
--- /dev/null
+++ b/authorization-server/auth-server/src/main/resources/application-acceptall.yaml
@@ -0,0 +1,5 @@
+# The entry-point matcher is left as MediaTypeRequestMatcher(TEXT_HTML) with no ignored
+# types. `Accept: */*` then matches text/html and the token endpoint redirects API callers
+# to the HTML login page. See docs/authorization-server/09-entry-point.md.
+demo:
+ ignore-accept-all: false
diff --git a/authorization-server/auth-server/src/main/resources/application-noclaims.yaml b/authorization-server/auth-server/src/main/resources/application-noclaims.yaml
new file mode 100644
index 0000000..dc7c80e
--- /dev/null
+++ b/authorization-server/auth-server/src/main/resources/application-noclaims.yaml
@@ -0,0 +1,3 @@
+# The OAuth2TokenCustomizer bean is not registered (see TokenClaimsCustomizer's @Profile).
+# Diff a token from this profile against a default one to see exactly what the default
+# access token does and does not carry.
diff --git a/authorization-server/auth-server/src/main/resources/application-noconsent.yaml b/authorization-server/auth-server/src/main/resources/application-noconsent.yaml
new file mode 100644
index 0000000..773d5ad
--- /dev/null
+++ b/authorization-server/auth-server/src/main/resources/application-noconsent.yaml
@@ -0,0 +1,4 @@
+# Consent off. The authorization endpoint issues the code immediately after login.
+# Correct for a first-party client you own; wrong the moment a third party registers.
+demo:
+ require-consent: false
diff --git a/authorization-server/auth-server/src/main/resources/application-nopkce.yaml b/authorization-server/auth-server/src/main/resources/application-nopkce.yaml
new file mode 100644
index 0000000..8d5ae7f
--- /dev/null
+++ b/authorization-server/auth-server/src/main/resources/application-nopkce.yaml
@@ -0,0 +1,4 @@
+# The public client no longer requires a code_verifier. The code exchange then succeeds
+# with nothing but the authorization code - which is the whole attack PKCE prevents.
+demo:
+ require-pkce: false
diff --git a/authorization-server/auth-server/src/main/resources/application-opaque.yaml b/authorization-server/auth-server/src/main/resources/application-opaque.yaml
new file mode 100644
index 0000000..0c5959a
--- /dev/null
+++ b/authorization-server/auth-server/src/main/resources/application-opaque.yaml
@@ -0,0 +1,4 @@
+# demo-service gets REFERENCE (opaque) access tokens instead of self-contained JWTs.
+# The string is meaningless to a resource server without a call to /oauth2/introspect.
+demo:
+ service-token-format: reference
diff --git a/authorization-server/auth-server/src/main/resources/application-trace.yaml b/authorization-server/auth-server/src/main/resources/application-trace.yaml
new file mode 100644
index 0000000..5996e4c
--- /dev/null
+++ b/authorization-server/auth-server/src/main/resources/application-trace.yaml
@@ -0,0 +1,5 @@
+logging:
+ level:
+ org.springframework.security: TRACE
+ org.springframework.security.oauth2.server.authorization: TRACE
+ org.springframework.security.web.FilterChainProxy: DEBUG
diff --git a/authorization-server/auth-server/src/main/resources/application.yaml b/authorization-server/auth-server/src/main/resources/application.yaml
new file mode 100644
index 0000000..07cd408
--- /dev/null
+++ b/authorization-server/auth-server/src/main/resources/application.yaml
@@ -0,0 +1,19 @@
+server:
+ port: 9000
+
+spring:
+ application:
+ name: auth-server
+ thymeleaf:
+ cache: false
+
+# Flags the RegisteredClientRepository reads. Every variant profile below flips exactly
+# one of them, so the resulting failure is attributable to one line.
+demo:
+ require-consent: true
+ require-pkce: true
+ service-token-format: jwt
+
+logging:
+ level:
+ org.springframework.security: INFO
diff --git a/authorization-server/auth-server/src/main/resources/templates/consent.html b/authorization-server/auth-server/src/main/resources/templates/consent.html
new file mode 100644
index 0000000..90a06b7
--- /dev/null
+++ b/authorization-server/auth-server/src/main/resources/templates/consent.html
@@ -0,0 +1,71 @@
+
+
+
+
+ Approve access
+
+
+
+
+
Approve access
+
+ Signed in as user .
+ The application client wants to act on your behalf.
+
+
+
+
+
+
+
+
+
diff --git a/authorization-server/auth-server/src/test/java/com/ankurm/authserver/ProviderContractTests.java b/authorization-server/auth-server/src/test/java/com/ankurm/authserver/ProviderContractTests.java
new file mode 100644
index 0000000..4543e0a
--- /dev/null
+++ b/authorization-server/auth-server/src/test/java/com/ankurm/authserver/ProviderContractTests.java
@@ -0,0 +1,102 @@
+package com.ankurm.authserver;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
+import org.springframework.http.MediaType;
+import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
+import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
+import org.springframework.test.web.servlet.MockMvc;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/**
+ * Contract tests, not happy-path tests. Each one pins a decision that would otherwise be
+ * silently reversible by a config change.
+ */
+@SpringBootTest
+@AutoConfigureMockMvc
+class ProviderContractTests {
+
+ @Autowired MockMvc mvc;
+ @Autowired RegisteredClientRepository clients;
+
+ @Test
+ void discoveryAdvertisesTheConfiguredIssuer() throws Exception {
+ this.mvc.perform(get("/.well-known/openid-configuration"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.issuer").value("http://localhost:9000"))
+ // Present only because .oidc(...) was switched on.
+ .andExpect(jsonPath("$.userinfo_endpoint").exists());
+ }
+
+ @Test
+ void jwkSetNeverExposesPrivateMaterial() throws Exception {
+ String body = this.mvc.perform(get("/oauth2/jwks"))
+ .andExpect(status().isOk())
+ .andReturn().getResponse().getContentAsString();
+ // "d" is the RSA private exponent. Its presence here would be a full compromise.
+ assertThat(body).doesNotContain("\"d\"").doesNotContain("\"p\"").doesNotContain("\"q\"");
+ }
+
+ @Test
+ void thePublicClientRequiresProofKey() {
+ RegisteredClient spa = this.clients.findByClientId("demo-spa");
+ assertThat(spa).isNotNull();
+ assertThat(spa.getClientSettings().isRequireProofKey())
+ .as("a public client without PKCE can have its authorization code replayed")
+ .isTrue();
+ }
+
+ @Test
+ void requireProofKeyIsOnByDefaultInSevenPointOne() {
+ // demo-service never touches ClientSettings. In Spring Authorization Server 1.5.8
+ // this was false; in 7.1.1 the default flipped to true, which silently makes PKCE
+ // mandatory for every client you did not think about. See
+ // docs/output/as-settings-defaults.txt.
+ RegisteredClient service = this.clients.findByClientId("demo-service");
+ assertThat(service.getClientSettings().isRequireProofKey()).isTrue();
+ }
+
+ @Test
+ void refreshTokensRotate() {
+ RegisteredClient web = this.clients.findByClientId("demo-web");
+ assertThat(web.getTokenSettings().isReuseRefreshTokens())
+ .as("reuse means a stolen refresh token stays valid until it expires")
+ .isFalse();
+ }
+
+ @Test
+ void aFailedTokenRequestFromAnApiClientIsNotAnHtmlRedirect() throws Exception {
+ // Accept: */* is what curl and most HTTP clients send. Without
+ // setIgnoredMediaTypes(ALL) on the entry-point matcher this is a 302 to /login.
+ this.mvc.perform(post("/oauth2/token")
+ .accept(MediaType.ALL)
+ .param("grant_type", "authorization_code")
+ .param("code", "bogus")
+ .param("client_id", "demo-spa"))
+ .andExpect(status().isUnauthorized());
+ }
+
+ @Test
+ void aBrowserStillGetsRedirectedToTheLoginPage() throws Exception {
+ this.mvc.perform(get("/oauth2/authorize")
+ .accept(MediaType.TEXT_HTML)
+ .queryParam("response_type", "code")
+ .queryParam("client_id", "demo-web")
+ .queryParam("redirect_uri", "http://127.0.0.1:8080/login/oauth2/code/demo-web")
+ .queryParam("scope", "openid")
+ // Required even for a confidential client, because requireProofKey
+ // now defaults to true. Omit it and the authorization endpoint
+ // redirects to the client with error=invalid_request rather than
+ // sending the browser to the login page.
+ .queryParam("code_challenge", "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM")
+ .queryParam("code_challenge_method", "S256"))
+ .andExpect(status().is3xxRedirection());
+ }
+}
diff --git a/authorization-server/oidc-client/pom.xml b/authorization-server/oidc-client/pom.xml
new file mode 100644
index 0000000..64ecde1
--- /dev/null
+++ b/authorization-server/oidc-client/pom.xml
@@ -0,0 +1,40 @@
+
+
+ 4.0.0
+
+
+ com.ankurm
+ authorization-server-demo
+ 1.0.0
+
+
+ oidc-client
+ oidc-client
+ A relying party that logs in against auth-server and calls resource-server
+
+
+
+ org.springframework.boot
+ spring-boot-starter-webmvc
+
+
+ org.springframework.boot
+ spring-boot-starter-security-oauth2-client
+
+
+ org.springframework.boot
+ spring-boot-starter-thymeleaf
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+
+
diff --git a/authorization-server/oidc-client/src/main/java/com/ankurm/client/ClientApplication.java b/authorization-server/oidc-client/src/main/java/com/ankurm/client/ClientApplication.java
new file mode 100644
index 0000000..46dfcdf
--- /dev/null
+++ b/authorization-server/oidc-client/src/main/java/com/ankurm/client/ClientApplication.java
@@ -0,0 +1,17 @@
+package com.ankurm.client;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+/**
+ * The relying party. Runs on :8080 and is the only piece a human touches.
+ *
+ * @see
+ * docs/authorization-server/08-client.md
+ */
+@SpringBootApplication
+public class ClientApplication {
+ public static void main(String[] args) {
+ SpringApplication.run(ClientApplication.class, args);
+ }
+}
diff --git a/authorization-server/oidc-client/src/main/java/com/ankurm/client/ClientSecurityConfig.java b/authorization-server/oidc-client/src/main/java/com/ankurm/client/ClientSecurityConfig.java
new file mode 100644
index 0000000..551010a
--- /dev/null
+++ b/authorization-server/oidc-client/src/main/java/com/ankurm/client/ClientSecurityConfig.java
@@ -0,0 +1,39 @@
+package com.ankurm.client;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.security.config.Customizer;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.web.SecurityFilterChain;
+
+/**
+ * The whole client side of OIDC, in one method.
+ *
+ * {@code oauth2Login()} makes this an OIDC relying party: it adds the redirect
+ * endpoint at {@code /login/oauth2/code/{registrationId}}, exchanges the code, validates
+ * the {@code id_token}, and creates a session. {@code oauth2Client()} additionally makes
+ * the access token available to outgoing HTTP calls.
+ *
+ *
The redirect URI is derived from the registration id, and it must match what the
+ * provider has registered byte for byte . This app is reached at
+ * {@code 127.0.0.1:8080} rather than {@code localhost:8080} for a reason: they are
+ * different origins to a browser cookie jar, and running the client on {@code localhost}
+ * next to an authorization server on {@code localhost} means one session cookie can
+ * clobber the other. Using {@code 127.0.0.1} for the client keeps the two sessions
+ * separate, which is the difference between a working demo and a login loop.
+ */
+@Configuration(proxyBeanMethods = false)
+public class ClientSecurityConfig {
+
+ @Bean
+ public SecurityFilterChain chain(HttpSecurity http) throws Exception {
+ http
+ .authorizeHttpRequests(auth -> auth
+ .requestMatchers("/", "/error").permitAll()
+ .anyRequest().authenticated())
+ .oauth2Login(Customizer.withDefaults())
+ .oauth2Client(Customizer.withDefaults())
+ .logout(logout -> logout.logoutSuccessUrl("/"));
+ return http.build();
+ }
+}
diff --git a/authorization-server/oidc-client/src/main/java/com/ankurm/client/HomeController.java b/authorization-server/oidc-client/src/main/java/com/ankurm/client/HomeController.java
new file mode 100644
index 0000000..b3f11c5
--- /dev/null
+++ b/authorization-server/oidc-client/src/main/java/com/ankurm/client/HomeController.java
@@ -0,0 +1,56 @@
+package com.ankurm.client;
+
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.security.oauth2.client.OAuth2AuthorizedClient;
+import org.springframework.security.oauth2.client.annotation.RegisteredOAuth2AuthorizedClient;
+import org.springframework.security.oauth2.core.oidc.user.OidcUser;
+import org.springframework.stereotype.Controller;
+import org.springframework.ui.Model;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.client.RestClient;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * Renders what the client received, and calls the resource server with it.
+ *
+ *
{@code @RegisteredOAuth2AuthorizedClient} hands you the access token Spring already
+ * holds for this user and this registration. Reading it out of the {@code OidcUser} would
+ * give you the id_token instead - a mistake that produces a 401 from the resource
+ * server with a token that looks perfectly valid, because it is: it is just the wrong one.
+ */
+@Controller
+public class HomeController {
+
+ private final RestClient restClient = RestClient.create();
+
+ @GetMapping("/")
+ public String home(@AuthenticationPrincipal OidcUser user, Model model) {
+ model.addAttribute("user", user);
+ if (user != null) {
+ model.addAttribute("idTokenClaims", user.getIdToken().getClaims());
+ }
+ return "home";
+ }
+
+ @GetMapping("/orders")
+ public String orders(@RegisteredOAuth2AuthorizedClient("demo-web") OAuth2AuthorizedClient client,
+ Model model) {
+ String token = client.getAccessToken().getTokenValue();
+ Map body;
+ try {
+ body = this.restClient.get()
+ .uri("http://localhost:8090/api/orders")
+ .header("Authorization", "Bearer " + token)
+ .retrieve()
+ .body(Map.class);
+ } catch (Exception ex) {
+ body = new LinkedHashMap<>(Map.of("error", String.valueOf(ex.getMessage())));
+ }
+ model.addAttribute("accessToken", token);
+ model.addAttribute("scopes", client.getAccessToken().getScopes());
+ model.addAttribute("orders", body);
+ return "orders";
+ }
+}
diff --git a/authorization-server/oidc-client/src/main/java/com/ankurm/client/PkceConfig.java b/authorization-server/oidc-client/src/main/java/com/ankurm/client/PkceConfig.java
new file mode 100644
index 0000000..d47244d
--- /dev/null
+++ b/authorization-server/oidc-client/src/main/java/com/ankurm/client/PkceConfig.java
@@ -0,0 +1,82 @@
+package com.ankurm.client;
+
+import org.springframework.beans.BeansException;
+import org.springframework.beans.factory.config.BeanPostProcessor;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Profile;
+import org.springframework.security.oauth2.client.registration.ClientRegistration;
+import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
+import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository;
+
+/**
+ * Reproduces the pre-7.0 client, so the mismatch can be observed rather than described.
+ *
+ * What changed, on both sides at once
+ *
+ * Read out of the jars with {@code javap} — see
+ * {@code docs/output/as-settings-defaults.txt}:
+ *
+ *
+ * previous current
+ * Authorization server — {@code ClientSettings.requireProofKey}
+ * {@code false} (SAS 1.5.8) {@code true} (7.1.1)
+ * Client — {@code ClientRegistration.ClientSettings.requireProofKey}
+ * {@code false} (Spring Security 6.5.1) {@code true} (7.1.1)
+ *
+ *
+ * Because both moved together, Spring-client-to-Spring-server keeps working. The
+ * combination that breaks is a 7.1 authorization server with anything older or
+ * anything hand-rolled in front of it: a Spring Security 6.x confidential client, a
+ * bespoke server-side integration, a Postman collection someone saved last year. The
+ * server rejects the authorization request before login, and the user sees the client's
+ * error page rather than the provider's:
+ *
+ *
+ * error=invalid_request&error_description=OAuth%202.0%20Parameter%3A%20code_challenge
+ *
+ *
+ * Activating the {@code nopkce} profile rebuilds this client's registration with
+ * {@code requireProofKey(false)}, which is exactly what a 6.x client would have sent.
+ *
+ * @see
+ * docs/authorization-server/08-client.md
+ */
+@Configuration(proxyBeanMethods = false)
+@Profile("nopkce")
+public class PkceConfig {
+
+ /**
+ * Note that {@code DefaultOAuth2AuthorizationRequestResolver} consults
+ * {@code ClientRegistration}'s own {@code ClientSettings}, not the resolver's
+ * customizer — so setting a customizer cannot turn PKCE off . It has to be
+ * turned off on the registration itself.
+ *
+ *
A {@code @Bean} that takes {@code ClientRegistrationRepository} and returns one
+ * is a dependency cycle, and Boot refuses to start
+ * ({@code Relying upon circular references is discouraged and they are prohibited by
+ * default}). Post-processing the bean Boot already built avoids that.
+ */
+ @Bean
+ static BeanPostProcessor downgradeToPreSevenClient() {
+ return new BeanPostProcessor() {
+ @Override
+ public Object postProcessAfterInitialization(Object bean, String name)
+ throws BeansException {
+ if (!(bean instanceof ClientRegistrationRepository repo)) {
+ return bean;
+ }
+ ClientRegistration original = repo.findByRegistrationId("demo-web");
+ if (original == null) {
+ return bean;
+ }
+ return new InMemoryClientRegistrationRepository(
+ ClientRegistration.withClientRegistration(original)
+ .clientSettings(ClientRegistration.ClientSettings.builder()
+ .requireProofKey(false)
+ .build())
+ .build());
+ }
+ };
+ }
+}
diff --git a/authorization-server/oidc-client/src/main/resources/application-nopkce.yaml b/authorization-server/oidc-client/src/main/resources/application-nopkce.yaml
new file mode 100644
index 0000000..f2d7fd1
--- /dev/null
+++ b/authorization-server/oidc-client/src/main/resources/application-nopkce.yaml
@@ -0,0 +1,3 @@
+# PkceConfig is @Profile("nopkce"): it rebuilds the demo-web registration with
+# requireProofKey(false), reproducing what a Spring Security 6.x confidential client
+# sends. See docs/authorization-server/08-client.md.
diff --git a/authorization-server/oidc-client/src/main/resources/application.yaml b/authorization-server/oidc-client/src/main/resources/application.yaml
new file mode 100644
index 0000000..1c02c44
--- /dev/null
+++ b/authorization-server/oidc-client/src/main/resources/application.yaml
@@ -0,0 +1,27 @@
+server:
+ port: 8080
+
+spring:
+ application:
+ name: oidc-client
+ thymeleaf:
+ cache: false
+ security:
+ oauth2:
+ client:
+ # One provider entry. Spring reads
+ # http://localhost:9000/.well-known/openid-configuration at first use and fills in
+ # every endpoint from it - authorization, token, jwks, userinfo, issuer.
+ provider:
+ demo-provider:
+ issuer-uri: http://localhost:9000
+ registration:
+ demo-web:
+ provider: demo-provider
+ client-id: demo-web
+ client-secret: web-secret
+ authorization-grant-type: authorization_code
+ client-authentication-method: client_secret_basic
+ # Must equal the redirect-uri registered on the server, exactly.
+ redirect-uri: "http://127.0.0.1:8080/login/oauth2/code/demo-web"
+ scope: openid,profile,orders.read,orders.write
diff --git a/authorization-server/oidc-client/src/main/resources/templates/home.html b/authorization-server/oidc-client/src/main/resources/templates/home.html
new file mode 100644
index 0000000..95c2784
--- /dev/null
+++ b/authorization-server/oidc-client/src/main/resources/templates/home.html
@@ -0,0 +1,18 @@
+
+
+
OIDC client
+
+
+Relying party
+
+
Not signed in.
+
Go to /orders — this triggers the authorization code flow.
+
+
+
diff --git a/authorization-server/oidc-client/src/main/resources/templates/orders.html b/authorization-server/oidc-client/src/main/resources/templates/orders.html
new file mode 100644
index 0000000..21a787f
--- /dev/null
+++ b/authorization-server/oidc-client/src/main/resources/templates/orders.html
@@ -0,0 +1,14 @@
+
+
+Orders
+
+
+Resource server response
+
+Granted scopes
+
+Access token (raw)
+
+back
+
diff --git a/authorization-server/pom.xml b/authorization-server/pom.xml
new file mode 100644
index 0000000..afea21a
--- /dev/null
+++ b/authorization-server/pom.xml
@@ -0,0 +1,44 @@
+
+
+ 4.0.0
+
+
+ org.springframework.boot
+ spring-boot-starter-parent
+ 4.1.1
+
+
+
+ com.ankurm
+ authorization-server-demo
+ 1.0.0
+ pom
+ authorization-server-demo
+ Spring Authorization Server 7.1 on Spring Boot 4.1 - runnable companion for ankurm.com
+
+
+
+ auth-server
+ resource-server
+ oidc-client
+
+
+
+ 25
+ UTF-8
+
+
diff --git a/authorization-server/resource-server/pom.xml b/authorization-server/resource-server/pom.xml
new file mode 100644
index 0000000..1e8832d
--- /dev/null
+++ b/authorization-server/resource-server/pom.xml
@@ -0,0 +1,39 @@
+
+
+ 4.0.0
+
+
+ com.ankurm
+ authorization-server-demo
+ 1.0.0
+
+
+ resource-server
+ resource-server
+ An API that trusts tokens minted by auth-server
+
+
+
+ org.springframework.boot
+ spring-boot-starter-webmvc
+
+
+
+ org.springframework.boot
+ spring-boot-starter-security-oauth2-resource-server
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+
+
diff --git a/authorization-server/resource-server/src/main/java/com/ankurm/rs/ApiController.java b/authorization-server/resource-server/src/main/java/com/ankurm/rs/ApiController.java
new file mode 100644
index 0000000..bc00f6d
--- /dev/null
+++ b/authorization-server/resource-server/src/main/java/com/ankurm/rs/ApiController.java
@@ -0,0 +1,63 @@
+package com.ankurm.rs;
+
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.security.oauth2.jwt.Jwt;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/** Four endpoints, each guarded by a different consequence of the token's contents. */
+@RestController
+public class ApiController {
+
+ @GetMapping("/public")
+ public Map open() {
+ return Map.of("message", "no token required");
+ }
+
+ /** Requires SCOPE_orders.read. */
+ @GetMapping("/api/orders")
+ public Map orders(@AuthenticationPrincipal Jwt jwt) {
+ Map out = new LinkedHashMap<>();
+ out.put("orders", List.of(Map.of("id", 1, "total", "42.00")));
+ out.put("subject", jwt.getSubject());
+ out.put("clientId", jwt.getClaimAsString("azp") != null
+ ? jwt.getClaimAsString("azp") : jwt.getClaimAsString("client_id"));
+ out.put("scopes", jwt.getClaimAsStringList("scope"));
+ out.put("roles", jwt.getClaimAsStringList("roles"));
+ out.put("tenant", jwt.getClaimAsString("tenant"));
+ out.put("audience", jwt.getAudience());
+ return out;
+ }
+
+ /** Requires SCOPE_orders.write. */
+ @PostMapping("/api/orders")
+ public Map create(@AuthenticationPrincipal Jwt jwt) {
+ return Map.of("created", true, "by", jwt.getSubject());
+ }
+
+ /** Requires ROLE_ADMIN, which only exists because of the provider's token customiser. */
+ @GetMapping("/api/admin")
+ public Map admin() {
+ return Map.of(
+ "message", "admin only",
+ "authorities", SecurityContextHolder.getContext().getAuthentication()
+ .getAuthorities().stream().map(Object::toString).toList());
+ }
+
+ /** Whatever the resource server actually decoded. Useful when a 403 makes no sense. */
+ @GetMapping("/whoami")
+ public Map whoami(@AuthenticationPrincipal Jwt jwt) {
+ Map out = new LinkedHashMap<>();
+ out.put("claims", jwt.getClaims());
+ out.put("headers", jwt.getHeaders());
+ out.put("authorities", SecurityContextHolder.getContext().getAuthentication()
+ .getAuthorities().stream().map(Object::toString).toList());
+ return out;
+ }
+}
diff --git a/authorization-server/resource-server/src/main/java/com/ankurm/rs/ResourceServerApplication.java b/authorization-server/resource-server/src/main/java/com/ankurm/rs/ResourceServerApplication.java
new file mode 100644
index 0000000..b741c3d
--- /dev/null
+++ b/authorization-server/resource-server/src/main/java/com/ankurm/rs/ResourceServerApplication.java
@@ -0,0 +1,17 @@
+package com.ankurm.rs;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+/**
+ * The API that trusts what the provider mints. Runs on :8090.
+ *
+ * @see
+ * docs/authorization-server/06-resource-server.md
+ */
+@SpringBootApplication
+public class ResourceServerApplication {
+ public static void main(String[] args) {
+ SpringApplication.run(ResourceServerApplication.class, args);
+ }
+}
diff --git a/authorization-server/resource-server/src/main/java/com/ankurm/rs/SecurityConfig.java b/authorization-server/resource-server/src/main/java/com/ankurm/rs/SecurityConfig.java
new file mode 100644
index 0000000..fa43698
--- /dev/null
+++ b/authorization-server/resource-server/src/main/java/com/ankurm/rs/SecurityConfig.java
@@ -0,0 +1,119 @@
+package com.ankurm.rs;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.security.config.Customizer;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.oauth2.core.OAuth2TokenValidator;
+import org.springframework.security.oauth2.jwt.Jwt;
+import org.springframework.security.oauth2.jwt.JwtClaimNames;
+import org.springframework.security.oauth2.jwt.JwtDecoder;
+import org.springframework.security.oauth2.jwt.JwtDecoders;
+import org.springframework.security.oauth2.jwt.JwtValidators;
+import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
+import org.springframework.security.oauth2.core.OAuth2Error;
+import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
+import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
+import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;
+import org.springframework.security.web.SecurityFilterChain;
+
+import java.util.List;
+
+/**
+ * What a resource server has to do with the provider's tokens, and what it gets for free.
+ *
+ * {@code spring.security.oauth2.resourceserver.jwt.issuer-uri} alone gives you
+ * signature verification, {@code exp}/{@code nbf}, and an {@code iss} check. It does
+ * not give you an audience check, and it does not map {@code scope} to anything
+ * other than {@code SCOPE_*} authorities. Both of those gaps are filled here.
+ *
+ * @see
+ * docs/authorization-server/06-resource-server.md
+ */
+@Configuration(proxyBeanMethods = false)
+public class SecurityConfig {
+
+ @Bean
+ public SecurityFilterChain api(HttpSecurity http) throws Exception {
+ http
+ .authorizeHttpRequests(auth -> auth
+ .requestMatchers("/public").permitAll()
+ // SCOPE_ is the prefix JwtGrantedAuthoritiesConverter uses for the
+ // `scope` (or `scp`) claim. ROLE_ comes from our own mapping below.
+ .requestMatchers("/api/orders").hasAuthority("SCOPE_orders.read")
+ .requestMatchers("/api/orders/**").hasAuthority("SCOPE_orders.write")
+ .requestMatchers("/api/admin").hasRole("ADMIN")
+ .anyRequest().authenticated())
+ .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
+ // No sessions, no CSRF - the token is the entire request context. Leaving CSRF
+ // on for a bearer-token API produces a 403 on every POST with no useful body,
+ // which is the single most-reported false alarm in this space.
+ .csrf(csrf -> csrf.disable())
+ .sessionManagement(s -> s.sessionCreationPolicy(
+ org.springframework.security.config.http.SessionCreationPolicy.STATELESS));
+ return http.build();
+ }
+
+ /**
+ * Built explicitly rather than left to auto-configuration, purely so the audience
+ * validator can be added. {@code JwtValidators.createDefaultWithIssuer} is the same
+ * stack Boot would have installed: timestamp + issuer.
+ */
+ @Bean
+ public JwtDecoder jwtDecoder(
+ @Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri}") String issuer,
+ @Value("${demo.required-audience:orders-api}") String audience,
+ @Value("${demo.validate-audience:true}") boolean validateAudience) {
+
+ NimbusJwtDecoder decoder = (NimbusJwtDecoder) JwtDecoders.fromIssuerLocation(issuer);
+
+ OAuth2TokenValidator withIssuer = JwtValidators.createDefaultWithIssuer(issuer);
+ if (!validateAudience) {
+ decoder.setJwtValidator(withIssuer);
+ return decoder;
+ }
+
+ OAuth2TokenValidator audienceValidator = jwt -> {
+ List aud = jwt.getClaimAsStringList(JwtClaimNames.AUD);
+ if (aud != null && aud.contains(audience)) {
+ return OAuth2TokenValidatorResult.success();
+ }
+ // The error code matters: `invalid_token` is what puts the reason into the
+ // WWW-Authenticate header, which is the only place a client will see it.
+ return OAuth2TokenValidatorResult.failure(new OAuth2Error(
+ "invalid_token",
+ "the required audience " + audience + " is missing",
+ "https://tools.ietf.org/html/rfc6750#section-3.1"));
+ };
+
+ decoder.setJwtValidator(new org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator<>(
+ withIssuer, audienceValidator));
+ return decoder;
+ }
+
+ /**
+ * Maps the custom {@code roles} claim the provider's token customiser writes onto
+ * {@code ROLE_*} authorities, while keeping the default {@code SCOPE_*} mapping.
+ * Returning a converter that only handles {@code roles} silently deletes every
+ * scope authority, which turns {@code hasAuthority("SCOPE_orders.read")} into a 403
+ * on a perfectly valid token.
+ */
+ @Bean
+ public JwtAuthenticationConverter jwtAuthenticationConverter() {
+ JwtGrantedAuthoritiesConverter scopes = new JwtGrantedAuthoritiesConverter();
+
+ JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
+ converter.setJwtGrantedAuthoritiesConverter(jwt -> {
+ var authorities = new java.util.ArrayList(
+ scopes.convert(jwt));
+ List roles = jwt.getClaimAsStringList("roles");
+ if (roles != null) {
+ roles.forEach(r -> authorities.add(
+ new org.springframework.security.core.authority.SimpleGrantedAuthority("ROLE_" + r)));
+ }
+ return authorities;
+ });
+ return converter;
+ }
+}
diff --git a/authorization-server/resource-server/src/main/resources/application-noaud.yaml b/authorization-server/resource-server/src/main/resources/application-noaud.yaml
new file mode 100644
index 0000000..8d3d818
--- /dev/null
+++ b/authorization-server/resource-server/src/main/resources/application-noaud.yaml
@@ -0,0 +1,4 @@
+# Audience validation off - the default Boot behaviour. A token minted for a different
+# API but signed by the same issuer is now accepted here.
+demo:
+ validate-audience: false
diff --git a/authorization-server/resource-server/src/main/resources/application.yaml b/authorization-server/resource-server/src/main/resources/application.yaml
new file mode 100644
index 0000000..05879e6
--- /dev/null
+++ b/authorization-server/resource-server/src/main/resources/application.yaml
@@ -0,0 +1,18 @@
+server:
+ port: 8090
+
+spring:
+ application:
+ name: resource-server
+ security:
+ oauth2:
+ resourceserver:
+ jwt:
+ # One property. From it Spring fetches /.well-known/openid-configuration at
+ # startup, reads jwks_uri out of it, and builds a decoder. If the auth server
+ # is not up when this one starts, startup fails - by design.
+ issuer-uri: http://localhost:9000
+
+demo:
+ required-audience: orders-api
+ validate-audience: true
diff --git a/authorization-server/scripts/audience.sh b/authorization-server/scripts/audience.sh
new file mode 100755
index 0000000..d0b197a
--- /dev/null
+++ b/authorization-server/scripts/audience.sh
@@ -0,0 +1,35 @@
+#!/usr/bin/env bash
+# The gap between "the signature is valid" and "this token was meant for me".
+set -u
+cd "$(dirname "$0")/.."
+. scripts/lib.sh
+OUT=../docs/output/as-audience.txt
+mkdir -p ../docs/output
+{
+ section "A token for a DIFFERENT audience, signed by the SAME issuer"
+ echo "demo-service's tokens carry aud=[orders-api] thanks to the token customiser."
+ echo "Here we ask for one and then present it to a resource server configured to"
+ echo "require a different audience - and to one that does not check at all."
+ TOKEN=$(curl -s -u demo-service:service-secret -d grant_type=client_credentials \
+ -d scope=orders.read "$AS/oauth2/token" \
+ | python3 -c 'import sys,json;print(json.load(sys.stdin).get("access_token",""))')
+ echo
+ echo "aud claim in the token:"
+ jwt_payload "$TOKEN" | grep -A3 '"aud"'
+
+ section "Resource server running with demo.validate-audience=false"
+ echo "This is the Spring Boot default: issuer-uri alone validates signature, exp/nbf"
+ echo "and iss. Audience is not checked unless you add a validator."
+ curl -s -o /tmp/b -w 'GET /api/orders -> %{http_code}\n' -H "Authorization: Bearer $TOKEN" "$RS/api/orders"
+ head -c 300 /tmp/b; echo
+
+ section "The same token with a deliberately mangled signature"
+ BAD="${TOKEN%?}X"
+ curl -s -D - -o /dev/null -H "Authorization: Bearer $BAD" "$RS/api/orders" \
+ | sed -n '1p;/^WWW-Authenticate/p'
+
+ section "No token at all"
+ curl -s -D - -o /dev/null "$RS/api/orders" | sed -n '1p;/^WWW-Authenticate/p'
+} > "$OUT" 2>&1
+sed -i 's/[[:space:]]*$//' "$OUT"
+echo "wrote $OUT"
diff --git a/authorization-server/scripts/authcode-pkce.sh b/authorization-server/scripts/authcode-pkce.sh
new file mode 100755
index 0000000..d9ed3d4
--- /dev/null
+++ b/authorization-server/scripts/authcode-pkce.sh
@@ -0,0 +1,256 @@
+#!/usr/bin/env bash
+# The full authorization code flow with PKCE, driven entirely by curl so that every
+# redirect, form and parameter is visible. A browser hides all of this.
+#
+# ./scripts/authcode-pkce.sh [output-name] [client]
+#
+# client defaults to demo-spa (public, PKCE required). Pass demo-web for the confidential
+# client with a secret.
+set -u
+cd "$(dirname "$0")/.."
+. scripts/lib.sh
+
+NAME="${1:-as-authcode-pkce}"
+CLIENT="${2:-demo-spa}"
+# NO_CHALLENGE=1 sends an authorization request with no code_challenge at all. That is a
+# different thing from sending one and then omitting the verifier: the server only demands
+# a verifier if the authorization request carried a challenge, OR if the client is
+# registered with requireProofKey(true).
+NO_CHALLENGE="${NO_CHALLENGE:-0}"
+OUT="../docs/output/${NAME}.txt"
+mkdir -p ../docs/output
+
+if [ "$CLIENT" = "demo-web" ]; then
+ REDIRECT="http://127.0.0.1:8080/login/oauth2/code/demo-web"
+ SCOPE="openid orders.read orders.write"
+else
+ REDIRECT="http://127.0.0.1:8080/authorized"
+ SCOPE="openid orders.read"
+fi
+
+JAR=$(mktemp)
+trap 'rm -f "$JAR" /tmp/as-page.html' EXIT
+
+# --- PKCE parameters. RFC 7636: verifier is 43-128 chars of unreserved characters,
+# --- challenge is BASE64URL(SHA256(verifier)) with the padding stripped.
+read -r VERIFIER CHALLENGE <<<"$(python3 - <<'PY'
+import base64, hashlib, secrets
+v = base64.urlsafe_b64encode(secrets.token_bytes(48)).decode().rstrip('=')
+c = base64.urlsafe_b64encode(hashlib.sha256(v.encode()).digest()).decode().rstrip('=')
+print(v, c)
+PY
+)"
+
+{
+ section "PKCE parameters (RFC 7636)"
+ echo "code_verifier ${VERIFIER} (${#VERIFIER} chars)"
+ echo "code_challenge ${CHALLENGE}"
+ echo "code_challenge_method S256"
+ echo
+ echo "The verifier never leaves the client until the token request. The challenge is"
+ echo "all the authorization request carries, and it is a one-way hash of the verifier."
+
+ section "1. Log in to the authorization server (browser session)"
+ # The login page carries a CSRF token; the form chain has CSRF enabled, as it should.
+ curl -s -c "$JAR" "$AS/login" -o /tmp/as-page.html
+ CSRF=$(form_value /tmp/as-page.html _csrf)
+ echo "\$ curl -c jar -d username=alice -d password=password -d _csrf= $AS/login"
+ curl -s -i -b "$JAR" -c "$JAR" \
+ -d "username=alice" -d "password=password" -d "_csrf=$CSRF" \
+ "$AS/login" | sed -n '1p;/^[Ll]ocation:/p'
+
+ section "2. GET /oauth2/authorize (client=$CLIENT)"
+ PKCE_PARAMS="&code_challenge=$CHALLENGE&code_challenge_method=S256"
+ if [ "$NO_CHALLENGE" = "1" ]; then
+ PKCE_PARAMS=""
+ echo "NO_CHALLENGE=1: the authorization request carries no code_challenge."
+ echo
+ fi
+ AUTHZ="$AS/oauth2/authorize?response_type=code&client_id=$CLIENT&redirect_uri=$(python3 -c "import urllib.parse,sys;print(urllib.parse.quote(sys.argv[1],safe=''))" "$REDIRECT")&scope=$(python3 -c "import urllib.parse,sys;print(urllib.parse.quote(sys.argv[1]))" "$SCOPE")&state=xyz123${PKCE_PARAMS}"
+ echo "\$ curl -b jar '$AUTHZ'"
+ LOC=$(curl -s -i -b "$JAR" -c "$JAR" "$AUTHZ" | tr -d '\r' | grep -i '^location:' | head -1 | sed 's/^[Ll]ocation: *//')
+ echo "-> 302 $LOC"
+
+ if [ -z "$LOC" ]; then
+ echo "no redirect - the authorization endpoint rendered a page instead:"
+ curl -s -b "$JAR" "$AUTHZ" | head -30
+ exit 1
+ fi
+
+ case "$LOC" in
+ */oauth2/consent*)
+ section "3. The consent page"
+ echo "The authorization endpoint redirected to OUR page, at the path given to"
+ echo ".consentPage(\"/oauth2/consent\"). Note the query string it hands over:"
+ echo "$LOC" | tr '&' '\n' | sed 's/^/ /'
+ curl -s -b "$JAR" -c "$JAR" "$AS${LOC#*9000}" -o /tmp/as-page.html
+ echo
+ echo "Scopes rendered as checkboxes (openid deliberately not among them):"
+ form_values /tmp/as-page.html scope | sed 's/^/ /'
+ CSRF=$(form_value /tmp/as-page.html _csrf)
+ STATE=$(form_value /tmp/as-page.html state)
+ echo
+ echo "The hidden state the form must echo back: $STATE"
+ echo "(this is NOT the client's state=xyz123 - it is the server's own correlation"
+ echo " handle for the pending authorization request, and sending the client's value"
+ echo " instead is what produces the consent redirect loop)"
+
+ section "4. POST the approval to /oauth2/authorize"
+ ARGS=(-d "client_id=$CLIENT" -d "state=$STATE" -d "_csrf=$CSRF")
+ for s in $(form_values /tmp/as-page.html scope); do
+ ARGS+=(-d "scope=$s")
+ done
+ echo "\$ curl -b jar -X POST ${ARGS[*]} $AS/oauth2/authorize"
+ LOC=$(curl -s -i -b "$JAR" -c "$JAR" "${ARGS[@]}" "$AS/oauth2/authorize" \
+ | tr -d '\r' | grep -i '^location:' | head -1 | sed 's/^[Ll]ocation: *//')
+ echo "-> 302 $LOC"
+ ;;
+ *)
+ section "3. No consent page"
+ echo "The authorization endpoint went straight back to the client. Either consent is"
+ echo "off for this client, or every requested scope was already approved."
+ ;;
+ esac
+
+ CODE=$(echo "$LOC" | sed -n 's/.*[?&]code=\([^&]*\).*/\1/p')
+ RETURNED_STATE=$(echo "$LOC" | sed -n 's/.*[?&]state=\([^&]*\).*/\1/p')
+ section "5. The authorization code"
+ echo "code = $CODE"
+ echo "state = $RETURNED_STATE (the client's own value, returned untouched - compare it)"
+ if [ -z "$CODE" ]; then
+ echo "no code in the redirect. The error was:"
+ echo "$LOC" | tr '&' '\n' | sed 's/^/ /'
+ exit 1
+ fi
+
+ section "6a. Exchange the code WITHOUT the verifier"
+ echo "This is the request an attacker who stole the code can make."
+ AUTH_ARGS=()
+ [ "$CLIENT" = "demo-web" ] && AUTH_ARGS=(-u demo-web:web-secret)
+ NOVERIFIER=$(curl -s -w '\n<>' "${AUTH_ARGS[@]}" \
+ -d grant_type=authorization_code -d "code=$CODE" \
+ -d "redirect_uri=$REDIRECT" -d "client_id=$CLIENT" \
+ "$AS/oauth2/token")
+ echo "$NOVERIFIER" | sed -n 's/^<>$/HTTP \1/p'
+ BODY=${NOVERIFIER%%$'\n'</dev/null || echo "$BODY"
+ else
+ echo "(empty response body)"
+ fi
+ echo
+ case "$NOVERIFIER" in
+ *access_token*)
+ echo ">>> A TOKEN WAS ISSUED. The code alone was sufficient. This is what"
+ echo ">>> requireProofKey(false) on a public client means in practice."
+ ;;
+ *)
+ echo ">>> Rejected. invalid_grant is deliberately vague: the server will not tell"
+ echo ">>> a caller whether the code was wrong, expired, already used, or missing a"
+ echo ">>> verifier, because each of those is information an attacker can use."
+ ;;
+ esac
+ echo "Note: this consumed the code. Authorization codes are single-use, so the"
+ echo "successful exchange below needs a fresh one."
+
+ section "6b. A fresh code, exchanged properly"
+ LOC=$(curl -s -i -b "$JAR" -c "$JAR" "$AUTHZ" | tr -d '\r' | grep -i '^location:' | head -1 | sed 's/^[Ll]ocation: *//')
+ case "$LOC" in
+ */oauth2/consent*)
+ curl -s -b "$JAR" -c "$JAR" "$AS${LOC#*9000}" -o /tmp/as-page.html
+ CSRF=$(form_value /tmp/as-page.html _csrf)
+ STATE=$(form_value /tmp/as-page.html state)
+ ARGS=(-d "client_id=$CLIENT" -d "state=$STATE" -d "_csrf=$CSRF")
+ for s in $(form_values /tmp/as-page.html scope); do
+ ARGS+=(-d "scope=$s")
+ done
+ LOC=$(curl -s -i -b "$JAR" -c "$JAR" "${ARGS[@]}" "$AS/oauth2/authorize" \
+ | tr -d '\r' | grep -i '^location:' | head -1 | sed 's/^[Ll]ocation: *//')
+ ;;
+ esac
+ CODE=$(echo "$LOC" | sed -n 's/.*[?&]code=\([^&]*\).*/\1/p')
+ echo "fresh code = $CODE"
+ echo
+ if [ "$NO_CHALLENGE" = "1" ]; then
+ echo "\$ curl -d grant_type=authorization_code -d code=... $AS/oauth2/token"
+ echo " (no code_verifier - there was no challenge to verify against)"
+ else
+ echo "\$ curl -d grant_type=authorization_code -d code=... -d code_verifier=... $AS/oauth2/token"
+ fi
+ VERIFIER_ARG=(-d "code_verifier=$VERIFIER")
+ [ "$NO_CHALLENGE" = "1" ] && VERIFIER_ARG=()
+ HTTPCODE=$(curl -s -o /tmp/as-tok.json -w '%{http_code}' "${AUTH_ARGS[@]}" \
+ -d grant_type=authorization_code -d "code=$CODE" \
+ -d "redirect_uri=$REDIRECT" -d "client_id=$CLIENT" \
+ "${VERIFIER_ARG[@]}" \
+ "$AS/oauth2/token")
+ RESP=$(cat /tmp/as-tok.json)
+ echo "HTTP $HTTPCODE"
+ if [ -s /tmp/as-tok.json ]; then
+ python3 -m json.tool < /tmp/as-tok.json 2>/dev/null || cat /tmp/as-tok.json
+ else
+ echo "(empty response body)"
+ fi
+ if [ "$HTTPCODE" != "200" ] && [ "$NO_CHALLENGE" = "1" ]; then
+ echo
+ echo ">>> No token, even though the client is registered with requireProofKey(false)"
+ echo ">>> and the authorization request carried no challenge. The reason is that a"
+ echo ">>> public client has no other way to authenticate at the token endpoint:"
+ echo ">>> PublicClientAuthenticationProvider delegates entirely to"
+ echo ">>> CodeVerifierAuthenticator, and raises invalid_client when there is nothing"
+ echo ">>> to verify. requireProofKey(false) relaxes the AUTHORIZATION endpoint only."
+ fi
+
+ read_claim() { python3 -c 'import sys,json
+try: print(json.load(sys.stdin).get(sys.argv[1],""))
+except Exception: print("")' "$1" < /tmp/as-tok.json; }
+ AT=$(read_claim access_token)
+ IDT=$(read_claim id_token)
+ RT=$(read_claim refresh_token)
+
+ if [ -n "$AT" ]; then
+ section "7. The access token"
+ jwt_header "$AT"
+ jwt_payload "$AT"
+ fi
+ if [ -n "$IDT" ]; then
+ section "8. The id_token - a different token, for a different audience"
+ jwt_payload "$IDT"
+ echo
+ echo "aud is the CLIENT here, not the API. Sending this to a resource server is the"
+ echo "classic mix-up: it verifies (same issuer, same key) and then fails the audience"
+ echo "check, or worse, passes it if nobody checks audience."
+ fi
+
+ if [ -n "$AT" ]; then
+ section "9. Calling the resource server"
+ for path in /api/orders /api/admin; do
+ CODE_HTTP=$(curl -s -o /tmp/rsbody -w '%{http_code}' -H "Authorization: Bearer $AT" "$RS$path")
+ echo "GET $path -> $CODE_HTTP"
+ head -c 500 /tmp/rsbody; echo
+ done
+
+ section "10. Sending the id_token instead"
+ if [ -n "$IDT" ]; then
+ curl -s -i -H "Authorization: Bearer $IDT" "$RS/api/orders" \
+ | sed -n '1p;/^WWW-Authenticate/p'
+ fi
+ fi
+
+ if [ -n "$RT" ]; then
+ section "11. Refresh, with rotation"
+ echo "old refresh token: ${RT:0:24}..."
+ R2=$(curl -s "${AUTH_ARGS[@]}" -d grant_type=refresh_token -d "refresh_token=$RT" \
+ -d "client_id=$CLIENT" "$AS/oauth2/token")
+ NEW=$(echo "$R2" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("refresh_token",""))')
+ echo "new refresh token: ${NEW:0:24}..."
+ [ "$RT" = "$NEW" ] && echo "SAME - reuseRefreshTokens(true)" || echo "DIFFERENT - reuseRefreshTokens(false), the old one is now dead"
+ echo
+ echo "Replaying the old one:"
+ curl -s "${AUTH_ARGS[@]}" -d grant_type=refresh_token -d "refresh_token=$RT" \
+ -d "client_id=$CLIENT" "$AS/oauth2/token"
+ echo
+ fi
+} > "$OUT" 2>&1
+sed -i 's/[[:space:]]*$//' "$OUT"
+echo "wrote $OUT"; tail -5 "$OUT"
diff --git a/authorization-server/scripts/client-credentials.sh b/authorization-server/scripts/client-credentials.sh
new file mode 100755
index 0000000..a4b8da0
--- /dev/null
+++ b/authorization-server/scripts/client-credentials.sh
@@ -0,0 +1,63 @@
+#!/usr/bin/env bash
+# The simplest grant, and what the token customiser does and does not add to it.
+set -u
+cd "$(dirname "$0")/.."
+. scripts/lib.sh
+OUT="../docs/output/${1:-as-client-credentials}.txt"
+mkdir -p ../docs/output
+{
+ section "POST /oauth2/token grant_type=client_credentials"
+ echo "\$ curl -su demo-service:service-secret -d grant_type=client_credentials \\"
+ echo " -d scope=orders.read $AS/oauth2/token"
+ RESP=$(curl -s -u demo-service:service-secret \
+ -d grant_type=client_credentials -d scope=orders.read \
+ "$AS/oauth2/token")
+ echo "$RESP" | python3 -m json.tool
+
+ TOKEN=$(echo "$RESP" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("access_token",""))')
+ if [ -z "$TOKEN" ]; then echo "no access token - stopping"; exit 1; fi
+
+ case "$TOKEN" in
+ *.*.*)
+ section "JOSE header"
+ jwt_header "$TOKEN"
+ section "Claims"
+ jwt_payload "$TOKEN"
+ ;;
+ *)
+ section "Not a JWT"
+ echo "The access token is an opaque reference: $TOKEN"
+ echo "Length ${#TOKEN}. It carries no claims; the resource server must introspect it."
+ section "POST /oauth2/introspect"
+ curl -s -u demo-service:service-secret -d "token=$TOKEN" \
+ "$AS/oauth2/introspect" | python3 -m json.tool
+ ;;
+ esac
+
+ section "Wrong secret"
+ echo "\$ curl -si -u demo-service:WRONG -d grant_type=client_credentials $AS/oauth2/token"
+ curl -s -i -u demo-service:WRONG -d grant_type=client_credentials \
+ "$AS/oauth2/token" | sed -n '1p;/^WWW-Authenticate/p;/^{/p'
+
+ section "A grant the client is not registered for"
+ echo "\$ curl -si -u demo-service:service-secret -d grant_type=authorization_code -d code=x $AS/oauth2/token"
+ curl -s -i -u demo-service:service-secret -d grant_type=authorization_code -d code=x \
+ "$AS/oauth2/token" | sed -n '1p;/^{/p'
+
+ section "A scope the client is not registered for"
+ echo "\$ curl -s -u demo-service:service-secret -d grant_type=client_credentials -d scope=orders.write $AS/oauth2/token"
+ curl -s -u demo-service:service-secret -d grant_type=client_credentials -d scope=orders.write \
+ "$AS/oauth2/token"
+ echo
+
+ section "Calling the resource server with the token"
+ for path in /public /api/orders /api/admin; do
+ CODE=$(curl -s -o /tmp/rsbody -w '%{http_code}' -H "Authorization: Bearer $TOKEN" "$RS$path")
+ WWW=$(curl -s -D - -o /dev/null -H "Authorization: Bearer $TOKEN" "$RS$path" | grep -i '^WWW-Authenticate' || true)
+ echo "GET $path -> $CODE"
+ [ -n "$WWW" ] && echo " $WWW"
+ head -c 400 /tmp/rsbody; echo
+ done
+} > "$OUT" 2>&1
+sed -i 's/[[:space:]]*$//' "$OUT"
+echo "wrote $OUT"
diff --git a/authorization-server/scripts/client-flow.sh b/authorization-server/scripts/client-flow.sh
new file mode 100755
index 0000000..e7a56ec
--- /dev/null
+++ b/authorization-server/scripts/client-flow.sh
@@ -0,0 +1,110 @@
+#!/usr/bin/env bash
+# Drives the real Spring OAuth2 client through a real browser flow with curl, so the
+# behaviour is the client's own and not this script's.
+#
+# ./scripts/client-flow.sh
+set -u
+cd "$(dirname "$0")/.."
+. scripts/lib.sh
+CLIENT=http://127.0.0.1:8080
+LAST_AUTHZ=""
+NAME="${1:-as-client-flow}"
+LABEL="${2:-default}"
+OUT="../docs/output/${NAME}.txt"
+mkdir -p ../docs/output
+JAR=$(mktemp); trap 'rm -f "$JAR" /tmp/cf.html' EXIT
+
+follow() { # url -> prints status + location, follows same-host redirects up to 6 hops
+ local url="$1" hop=0
+ while [ $hop -lt 8 ]; do
+ local hdrs status loc
+ # A browser sends Accept: text/html. curl's default is */*, and with the entry-point
+ # matcher configured to ignore */* that difference decides whether /oauth2/authorize
+ # redirects you to the login page or answers 401.
+ hdrs=$(curl -s -D - -o /tmp/cf.html -b "$JAR" -c "$JAR" -H 'Accept: text/html' "$url" | tr -d '\r')
+ status=$(echo "$hdrs" | head -1 | awk '{print $2}')
+ loc=$(echo "$hdrs" | grep -i '^location:' | head -1 | sed 's/^[Ll]ocation: *//')
+ echo " $status $url"
+ case "$url" in *"/oauth2/authorize?"*) LAST_AUTHZ="$url" ;; esac
+ [ -z "$loc" ] && { LAST_URL="$url"; return 0; }
+ case "$loc" in http*) url="$loc" ;; *) url="$(echo "$url" | grep -oE '^https?://[^/]+')$loc" ;; esac
+ hop=$((hop+1))
+ done
+ LAST_URL="$url"
+}
+
+{
+ section "The relying party drives the flow [$LABEL]"
+ echo "GET $CLIENT/orders while unauthenticated. Every hop below is a real redirect."
+ echo
+ follow "$CLIENT/orders"
+ echo
+ echo "The authorization request the client built:"
+ echo "$LAST_AUTHZ" | tr '&?' '\n\n' | sed 's/^/ /'
+ case "$LAST_AUTHZ" in
+ *code_challenge*) echo " >>> code_challenge IS present" ;;
+ *) echo " >>> NO code_challenge - a client registered with"
+ echo " >>> requireProofKey(true) will reject this outright" ;;
+ esac
+
+ case "$LAST_URL" in
+ "$AS/login"*)
+ echo
+ echo "Landed on the authorization server's login page. Submitting credentials:"
+ CSRF=$(form_value /tmp/cf.html _csrf)
+ HDRS=$(curl -s -D - -o /dev/null -b "$JAR" -c "$JAR" -H 'Accept: text/html' \
+ -d username=alice -d password=password -d "_csrf=$CSRF" \
+ "$AS/login" | tr -d '\r')
+ echo " $(echo "$HDRS" | head -1 | awk '{print $2}') POST $AS/login"
+ NEXT=$(echo "$HDRS" | grep -i '^location:' | head -1 | sed 's/^[Ll]ocation: *//')
+ echo
+ echo "Resuming the authorization request:"
+ follow "$NEXT"
+ ;;
+ esac
+
+ # The consent page, if we reached it.
+ if grep -q 'name="scope"' /tmp/cf.html 2>/dev/null; then
+ echo
+ echo "Consent page reached. Approving:"
+ CSRF=$(form_value /tmp/cf.html _csrf)
+ STATE=$(form_value /tmp/cf.html state)
+ CID=$(form_value /tmp/cf.html client_id)
+ ARGS=(-d "client_id=$CID" -d "state=$STATE" -d "_csrf=$CSRF")
+ for s in $(form_values /tmp/cf.html scope); do ARGS+=(-d "scope=$s"); done
+ HDRS=$(curl -s -D - -o /dev/null -b "$JAR" -c "$JAR" -H 'Accept: text/html' "${ARGS[@]}" "$AS/oauth2/authorize" | tr -d '\r')
+ echo " $(echo "$HDRS" | head -1 | awk '{print $2}') POST $AS/oauth2/authorize"
+ NEXT=$(echo "$HDRS" | grep -i '^location:' | head -1 | sed 's/^[Ll]ocation: *//')
+ echo
+ echo "Back to the client with the code:"
+ follow "$NEXT"
+ fi
+
+ case "$LAST_URL" in
+ *error*)
+ echo
+ echo "The flow ended at the CLIENT's error page, not the provider's. The provider"
+ echo "rejected the authorization request and redirected the failure back to the"
+ echo "registered redirect_uri, so nothing in the client's logs names the provider"
+ echo "as the cause. The reason is only in the query string above."
+ ;;
+ esac
+
+ section "What the client rendered"
+ if grep -qi 'error' /tmp/cf.html && ! grep -q 'orders' /tmp/cf.html; then
+ echo "An error page. The provider rejected the authorization request:"
+ echo "$LAST_URL" | tr '&?' '\n\n' | sed 's/^/ /'
+ echo
+ python3 -c "
+import html,re,sys
+t = re.sub(r'<[^>]+>', ' ', open('/tmp/cf.html', encoding='utf-8', errors='replace').read())
+print(' '.join(html.unescape(t).split())[:600])"
+ else
+ python3 -c "
+import html,re
+t = re.sub(r'<[^>]+>', '\n', open('/tmp/cf.html', encoding='utf-8', errors='replace').read())
+print('\n'.join(l.strip() for l in html.unescape(t).splitlines() if l.strip())[:1400])"
+ fi
+} > "$OUT" 2>&1
+sed -i 's/[[:space:]]*$//' "$OUT"
+echo "wrote $OUT"
diff --git a/authorization-server/scripts/compile-legacy.sh b/authorization-server/scripts/compile-legacy.sh
new file mode 100755
index 0000000..f347129
--- /dev/null
+++ b/authorization-server/scripts/compile-legacy.sh
@@ -0,0 +1,34 @@
+#!/usr/bin/env bash
+# Compiles the SAS 1.x-style configuration against the 7.1.1 classpath and records the
+# compiler's own words. The point is that the error text is what you will actually see,
+# not a paraphrase of it.
+set -u
+cd "$(dirname "$0")/.."
+OUT=../docs/output/as-legacy-compile-failure.txt
+mkdir -p ../docs/output
+
+mvn -B -q -pl auth-server dependency:build-classpath \
+ -Dmdep.outputFile=/tmp/as-cp.txt -DincludeScope=compile >/dev/null 2>&1
+CP=$(cat /tmp/as-cp.txt)
+
+WORK=$(mktemp -d)
+mkdir -p "$WORK/com/ankurm/authserver/legacy" "$WORK/out"
+cp src-broken/LegacySasConfig.java.txt "$WORK/com/ankurm/authserver/legacy/LegacySasConfig.java"
+
+javac -nowarn -d "$WORK/out" -cp "$CP" \
+ "$WORK/com/ankurm/authserver/legacy/LegacySasConfig.java" > "$WORK/err.txt" 2>&1
+STATUS=$?
+
+{
+ echo "# The SAS 1.x configuration, compiled against Spring Boot 4.1.1 / Spring Security 7.1.1."
+ echo "# Source: src-broken/LegacySasConfig.java.txt"
+ echo
+ echo "\$ javac -cp LegacySasConfig.java"
+ echo
+ sed "s|$WORK|.|g" "$WORK/err.txt"
+ echo
+ echo "javac exit status: $STATUS"
+} > "$OUT"
+
+rm -rf "$WORK"
+cat "$OUT"
diff --git a/authorization-server/scripts/discovery.sh b/authorization-server/scripts/discovery.sh
new file mode 100755
index 0000000..7cdeba3
--- /dev/null
+++ b/authorization-server/scripts/discovery.sh
@@ -0,0 +1,30 @@
+#!/usr/bin/env bash
+# What the provider advertises, and the difference between the two metadata documents.
+set -u
+cd "$(dirname "$0")/.."
+. scripts/lib.sh
+OUT=../docs/output/as-discovery.txt
+mkdir -p ../docs/output
+{
+ section "OpenID Connect discovery: GET /.well-known/openid-configuration"
+ echo "\$ curl -s $AS/.well-known/openid-configuration"
+ curl -s "$AS/.well-known/openid-configuration" | python3 -m json.tool
+
+ section "OAuth2 metadata: GET /.well-known/oauth-authorization-server"
+ echo "Present even with .oidc(...) switched off. The OIDC document above is the one"
+ echo "that additionally advertises userinfo_endpoint and id_token signing algorithms."
+ echo "\$ curl -s $AS/.well-known/oauth-authorization-server"
+ curl -s "$AS/.well-known/oauth-authorization-server" | python3 -m json.tool
+
+ section "JWK Set: GET /oauth2/jwks"
+ echo "Public keys only. No 'd' member - if you ever see one here, stop the server."
+ curl -s "$AS/oauth2/jwks" | python3 -m json.tool
+
+ section "Resolved endpoint settings, read back from AuthorizationServerSettings"
+ curl -s "$AS/diag/settings" | python3 -m json.tool
+
+ section "Registered clients, as the server actually holds them"
+ curl -s "$AS/diag/clients" | python3 -m json.tool
+} > "$OUT" 2>&1
+sed -i 's/[[:space:]]*$//' "$OUT"
+echo "wrote $OUT"; wc -l "$OUT"
diff --git a/authorization-server/scripts/entrypoint-accept.sh b/authorization-server/scripts/entrypoint-accept.sh
new file mode 100755
index 0000000..25df0b1
--- /dev/null
+++ b/authorization-server/scripts/entrypoint-accept.sh
@@ -0,0 +1,37 @@
+#!/usr/bin/env bash
+# Why a failed token request sometimes answers 302 -> /login instead of 401.
+set -u
+cd "$(dirname "$0")/.."
+. scripts/lib.sh
+OUT=../docs/output/as-entrypoint-accept.txt
+LABEL="${1:-}"
+mkdir -p ../docs/output
+probe() {
+ local accept="$1" desc="$2"
+ echo "--- $desc"
+ echo "\$ curl -H 'Accept: $accept' -d grant_type=authorization_code -d code=bogus \\"
+ echo " -d client_id=demo-spa $AS/oauth2/token"
+ curl -s -i -H "Accept: $accept" -d grant_type=authorization_code -d code=bogus \
+ -d client_id=demo-spa "$AS/oauth2/token" \
+ | sed -n '1p;/^[Ll]ocation:/p;/^WWW-Authenticate/p'
+ echo
+}
+{
+ section "Public client, failed authentication at the token endpoint [$LABEL]"
+ echo "A public client authenticates at /oauth2/token by presenting a code_verifier."
+ echo "With no verifier there is nothing to authenticate with, so the request falls"
+ echo "through to the AuthenticationEntryPoint - and which entry point runs depends on"
+ echo "the Accept header."
+ echo
+ probe "*/*" "Accept: */* (curl's default, and most HTTP clients')"
+ probe "application/json" "Accept: application/json"
+ probe "text/html" "Accept: text/html (a browser)"
+
+ section "Confidential client with a wrong secret, for contrast"
+ echo "This never reaches the entry point: OAuth2ClientAuthenticationFilter writes the"
+ echo "error itself, so the Accept header makes no difference."
+ curl -s -i -u demo-web:wrong -d grant_type=client_credentials "$AS/oauth2/token" \
+ | sed -n '1p;/^[Ll]ocation:/p'
+} >> "$OUT" 2>&1
+sed -i 's/[[:space:]]*$//' "$OUT"
+echo "appended $LABEL to $OUT"
diff --git a/authorization-server/scripts/lib.sh b/authorization-server/scripts/lib.sh
new file mode 100755
index 0000000..80ccf9d
--- /dev/null
+++ b/authorization-server/scripts/lib.sh
@@ -0,0 +1,91 @@
+#!/usr/bin/env bash
+# Shared helpers. Sourced by every demo script.
+AS=http://localhost:9000
+RS=http://localhost:8090
+
+# Kill by main class, never by a pattern that could match this script's own command line.
+# `pkill -f spring-boot` matches the shell running it and takes the shell with it.
+kill_app() {
+ local mainclass="$1" port="${2:-}"
+ for p in $(ps -eo pid,cmd | grep "[${mainclass:0:1}]${mainclass:1}" | awk '{print $1}'); do
+ kill -9 "$p" 2>/dev/null || true
+ done
+ # Then wait for the port to actually close. `ss -lptn` often reports the socket with no
+ # PID, so a port-based kill can silently do nothing while the old process keeps serving -
+ # which looks exactly like your config change having had no effect. Waiting for the
+ # listener to disappear is the only reliable signal that the restart is real.
+ if [ -n "$port" ]; then
+ for _ in $(seq 1 30); do
+ curl -s -o /dev/null --max-time 1 "http://localhost:$port/" || return 0
+ sleep 1
+ done
+ echo "WARNING: something is still listening on :$port after kill_app $mainclass" >&2
+ return 1
+ fi
+ sleep 1
+}
+
+wait_for() {
+ local url="$1" tries="${2:-90}"
+ for _ in $(seq 1 "$tries"); do
+ if curl -s -o /dev/null --max-time 2 "$url"; then return 0; fi
+ sleep 1
+ done
+ echo "timed out waiting for $url - check the app log" >&2
+ return 1
+}
+
+hr() { printf '%s\n' "------------------------------------------------------------------"; }
+
+section() { echo; hr; echo "== $*"; hr; }
+
+# Decode a JWS payload without verifying it. Debug only - never do this to decide anything.
+jwt_payload() {
+ python3 - "$1" <<'PY'
+import base64, json, sys
+part = sys.argv[1].split('.')[1]
+part += '=' * (-len(part) % 4)
+print(json.dumps(json.loads(base64.urlsafe_b64decode(part)), indent=2, sort_keys=True))
+PY
+}
+
+jwt_header() {
+ python3 - "$1" <<'PY'
+import base64, json, sys
+part = sys.argv[1].split('.')[0]
+part += '=' * (-len(part) % 4)
+print(json.dumps(json.loads(base64.urlsafe_b64decode(part)), indent=2, sort_keys=True))
+PY
+}
+
+# Pull a hidden input's value out of a page. Attribute order is not fixed - Spring
+# Security's default login page renders name before value, Thymeleaf renders value before
+# name - so a naive grep for name="x" value="y" works on one and silently returns empty on
+# the other. Empty CSRF token, HTTP 403, and an hour lost.
+form_value() {
+ python3 - "$1" "$2" <<'PY'
+import re, sys
+html = open(sys.argv[1], encoding='utf-8', errors='replace').read()
+want = sys.argv[2]
+for tag in re.findall(r' ]*>', html, re.I):
+ attrs = dict((m.group(1).lower(), m.group(2))
+ for m in re.finditer(r'([\w:-]+)\s*=\s*"([^"]*)"', tag))
+ if attrs.get('name') == want:
+ print(attrs.get('value', ''))
+ break
+PY
+}
+
+# Every value of a repeated input (the scope checkboxes on the consent page).
+form_values() {
+ python3 - "$1" "$2" <<'PY'
+import re, sys
+html = open(sys.argv[1], encoding='utf-8', errors='replace').read()
+want = sys.argv[2]
+for tag in re.findall(r' ]*>', html, re.I):
+ attrs = dict((m.group(1).lower(), m.group(2))
+ for m in re.finditer(r'([\w:-]+)\s*=\s*"([^"]*)"', tag))
+ if attrs.get('name') == want and 'disabled' not in tag.lower():
+ print(attrs.get('value', ''))
+PY
+}
diff --git a/authorization-server/scripts/pkce-applier.sh b/authorization-server/scripts/pkce-applier.sh
new file mode 100755
index 0000000..dfa9f7c
--- /dev/null
+++ b/authorization-server/scripts/pkce-applier.sh
@@ -0,0 +1,32 @@
+#!/usr/bin/env bash
+# Why a confidential Spring OAuth2 client does not send PKCE, straight from the bytecode
+# of DefaultOAuth2AuthorizationRequestResolver rather than from documentation.
+set -u
+cd "$(dirname "$0")/.."
+OUT=../docs/output/as-pkce-applier.txt
+mkdir -p ../docs/output
+mvn -B -q -pl oidc-client dependency:build-classpath \
+ -Dmdep.outputFile=/tmp/cl-cp.txt -DincludeScope=compile >/dev/null 2>&1
+JAR=$(tr ':' '\n' < /tmp/cl-cp.txt | grep 'spring-security-oauth2-client-' | head -1)
+WORK=$(mktemp -d); trap 'rm -rf "$WORK"' EXIT
+(cd "$WORK" && unzip -o -q "$JAR" 'org/springframework/security/oauth2/client/web/DefaultOAuth2AuthorizationRequestResolver*')
+{
+ echo "# From $(basename "$JAR")"
+ echo "#"
+ echo "# The resolver applies its default PKCE customizer only when the registration's"
+ echo "# client authentication method is NONE - that is, only for public clients."
+ echo "# A registration that has a client secret gets no code_challenge."
+ echo
+ javap -p -c -cp "$WORK" \
+ org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizationRequestResolver \
+ 2>/dev/null \
+ | grep -E 'ClientAuthenticationMethod.NONE|DEFAULT_PKCE_APPLIER|withPkce' \
+ | sed 's/^ *//' | head -8
+ echo
+ echo "# The fields and the opt-in setter:"
+ javap -p -cp "$WORK" \
+ org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizationRequestResolver \
+ 2>/dev/null \
+ | grep -E 'DEFAULT_PKCE_APPLIER|setAuthorizationRequestCustomizer' | sed 's/^ *//'
+} > "$OUT" 2>&1
+cat "$OUT"
diff --git a/authorization-server/scripts/rs-startup-failure.sh b/authorization-server/scripts/rs-startup-failure.sh
new file mode 100755
index 0000000..03a2330
--- /dev/null
+++ b/authorization-server/scripts/rs-startup-failure.sh
@@ -0,0 +1,22 @@
+#!/usr/bin/env bash
+# What happens when the resource server starts and the issuer is not reachable.
+# Worth capturing because the failure is at STARTUP, not at first request - which means
+# a provider outage during a rolling deploy takes your API down with it.
+set -u
+cd "$(dirname "$0")/.."
+. scripts/lib.sh
+OUT=../docs/output/as-rs-startup-failure.txt
+mkdir -p ../docs/output
+kill_app ResourceServerApplication 8090
+kill_app AuthServerApplication 9000
+sleep 1
+mvn -B -o -pl resource-server org.springframework.boot:spring-boot-maven-plugin:run \
+ > /tmp/rs-fail.log 2>&1 || true
+{
+ echo "# resource-server started with spring.security.oauth2.resourceserver.jwt.issuer-uri"
+ echo "# pointing at an authorization server that is not running."
+ echo
+ grep -E '^Caused by|Unable to resolve the Configuration' /tmp/rs-fail.log \
+ | sed 's/^Caused by: //' | head -8
+} > "$OUT"
+cat "$OUT"
diff --git a/authorization-server/scripts/run-all.sh b/authorization-server/scripts/run-all.sh
new file mode 100755
index 0000000..98ef17b
--- /dev/null
+++ b/authorization-server/scripts/run-all.sh
@@ -0,0 +1,114 @@
+#!/usr/bin/env bash
+# Regenerates every file in ../docs/output that belongs to this project.
+#
+# ./scripts/run-all.sh
+#
+# Starts and stops the servers itself. Takes a few minutes. The only non-deterministic
+# content is timestamps, key ids and token values, which change on every run by design.
+set -u
+cd "$(dirname "$0")/.."
+. scripts/lib.sh
+
+start_auth() {
+ kill_app AuthServerApplication 9000
+ local profiles="${1:-}"
+ local args=(-B -o -pl auth-server org.springframework.boot:spring-boot-maven-plugin:run)
+ [ -n "$profiles" ] && args+=("-Dspring-boot.run.profiles=$profiles")
+ setsid nohup mvn "${args[@]}" > /tmp/auth-server.log 2>&1 < /dev/null &
+ wait_for "$AS/oauth2/jwks" 90
+}
+start_client() {
+ kill_app ClientApplication 8080
+ local profiles="${1:-}"
+ local args=(-B -o -pl oidc-client org.springframework.boot:spring-boot-maven-plugin:run)
+ [ -n "$profiles" ] && args+=("-Dspring-boot.run.profiles=$profiles")
+ setsid nohup mvn "${args[@]}" > /tmp/oidc-client.log 2>&1 < /dev/null &
+ wait_for "http://127.0.0.1:8080/" 90
+}
+start_rs() {
+ kill_app ResourceServerApplication 8090
+ local profiles="${1:-}"
+ local args=(-B -o -pl resource-server org.springframework.boot:spring-boot-maven-plugin:run)
+ [ -n "$profiles" ] && args+=("-Dspring-boot.run.profiles=$profiles")
+ setsid nohup mvn "${args[@]}" > /tmp/rs.log 2>&1 < /dev/null &
+ wait_for "$RS/public" 90
+}
+
+echo "== ClientSettings / TokenSettings defaults, 1.5.8 vs 7.1.1"
+./scripts/settings-defaults.sh > /dev/null
+
+echo "== the SAS 1.x configuration against 7.1.1 (compile only)"
+./scripts/compile-legacy.sh > /dev/null
+
+echo "== default profile"
+start_auth ""
+start_rs ""
+./scripts/discovery.sh
+./scripts/client-credentials.sh as-client-credentials
+./scripts/authcode-pkce.sh as-authcode-pkce demo-spa
+./scripts/authcode-pkce.sh as-authcode-web demo-web
+
+echo "== why a confidential Spring client does not send PKCE by default"
+./scripts/pkce-applier.sh > /dev/null
+
+echo "== the real Spring OAuth2 client, end to end"
+start_client ""
+./scripts/client-flow.sh as-client-flow "client sends PKCE"
+# Restart the authorization server too, so the consent already granted above does not
+# short-circuit the second run.
+start_auth ""
+start_client "nopkce"
+./scripts/client-flow.sh as-client-flow-nopkce "confidential client, no PKCE - the Boot default"
+kill_app ClientApplication 8080
+
+echo "== noclaims: the token customiser removed"
+start_auth "noclaims"
+./scripts/client-credentials.sh as-client-credentials-noclaims
+./scripts/authcode-pkce.sh as-authcode-noclaims demo-spa
+
+echo "== nopkce: the public client no longer requires a verifier"
+start_auth "nopkce"
+# With a challenge present, the server still demands the verifier - requireProofKey only
+# controls whether a challenge is MANDATORY, not whether one that was sent is honoured.
+./scripts/authcode-pkce.sh as-authcode-nopkce demo-spa
+# Without any challenge at all, the code alone is enough. This is the actual exposure.
+NO_CHALLENGE=1 ./scripts/authcode-pkce.sh as-authcode-nochallenge demo-spa
+
+echo "== the same request against a client that DOES require PKCE"
+start_auth ""
+NO_CHALLENGE=1 ./scripts/authcode-pkce.sh as-authcode-pkce-enforced demo-spa
+
+echo "== noconsent: consent turned off"
+start_auth "noconsent"
+./scripts/authcode-pkce.sh as-authcode-noconsent demo-spa
+
+echo "== entry point and the Accept header"
+rm -f ../docs/output/as-entrypoint-accept.txt
+start_auth "acceptall"
+./scripts/entrypoint-accept.sh "acceptall profile: setIgnoredMediaTypes NOT called"
+start_auth ""
+./scripts/entrypoint-accept.sh "default profile: setIgnoredMediaTypes(ALL) called"
+
+echo "== opaque: reference tokens for the service client"
+start_auth "opaque"
+./scripts/client-credentials.sh as-client-credentials-opaque
+
+echo "== audience validation off on the resource server"
+start_auth ""
+start_rs "noaud"
+./scripts/audience.sh
+
+echo "== the contract tests"
+mvn -B -o -pl auth-server test 2>&1 | grep -E "Tests run:|^\[INFO\] Running" \
+ > ../docs/output/as-test-run.txt || true
+
+kill_app AuthServerApplication 9000
+kill_app ResourceServerApplication 8090
+kill_app ClientApplication 8080
+
+echo "== resource server startup with no provider"
+./scripts/rs-startup-failure.sh > /dev/null
+
+echo
+echo "docs/output:"
+ls -1 ../docs/output/as-*.txt
diff --git a/authorization-server/scripts/run.sh b/authorization-server/scripts/run.sh
new file mode 100755
index 0000000..cb2aaea
--- /dev/null
+++ b/authorization-server/scripts/run.sh
@@ -0,0 +1,28 @@
+#!/usr/bin/env bash
+# ./scripts/run.sh [profiles]
+#
+# ./scripts/run.sh auth the provider on :9000
+# ./scripts/run.sh auth nopkce the provider with PKCE not required
+# ./scripts/run.sh rs the API on :8090
+# ./scripts/run.sh client the relying party on :8080
+set -eu
+cd "$(dirname "$0")/.."
+. scripts/lib.sh
+
+case "${1:-}" in
+ auth) MODULE=auth-server; MAIN=AuthServerApplication; PORT=9000 ;;
+ rs) MODULE=resource-server; MAIN=ResourceServerApplication; PORT=8090 ;;
+ client) MODULE=oidc-client; MAIN=ClientApplication; PORT=8080 ;;
+ *) echo "usage: $0 [profiles]" >&2; exit 2 ;;
+esac
+PROFILES="${2:-}"
+
+kill_app "$MAIN" "$PORT"
+LOG="/tmp/${MODULE}.log"
+ARGS=(-B -pl "$MODULE" org.springframework.boot:spring-boot-maven-plugin:run)
+[ -n "$PROFILES" ] && ARGS+=("-Dspring-boot.run.profiles=$PROFILES")
+
+# Detached, so the script returns and the demo scripts can drive it.
+setsid nohup mvn "${ARGS[@]}" > "$LOG" 2>&1 < /dev/null &
+echo "starting $MODULE${PROFILES:+ [$PROFILES]} -> $LOG"
+wait_for "http://localhost:$PORT/" 120 && echo "$MODULE up on :$PORT"
diff --git a/authorization-server/scripts/settings-defaults.sh b/authorization-server/scripts/settings-defaults.sh
new file mode 100755
index 0000000..d27657b
--- /dev/null
+++ b/authorization-server/scripts/settings-defaults.sh
@@ -0,0 +1,60 @@
+#!/usr/bin/env bash
+# Compiles one tiny program twice - once against Spring Authorization Server 1.5.8, once
+# against 7.1.1 - and prints the defaults each version hands you. This is how the
+# requireProofKey change was found; no documentation was involved.
+set -u
+cd "$(dirname "$0")/.."
+OUT=../docs/output/as-settings-defaults.txt
+mkdir -p ../docs/output
+WORK=$(mktemp -d); trap 'rm -rf "$WORK"' EXIT
+mkdir -p "$WORK/old"
+
+fetch() { # group artifact version
+ curl -sL -o "$WORK/old/$2-$3.jar" \
+ "https://repo1.maven.org/maven2/$1/$2/$3/$2-$3.jar"
+}
+for a in spring-security-oauth2-authorization-server:1.5.8 spring-security-oauth2-core:6.5.1 \
+ spring-security-oauth2-jose:6.5.1 spring-security-core:6.5.1 \
+ spring-security-oauth2-client:6.5.1 spring-security-web:6.5.1; do
+ fetch org/springframework/security "${a%%:*}" "${a##*:}"
+done
+for a in spring-core:6.2.7 spring-jcl:6.2.7; do
+ fetch org/springframework "${a%%:*}" "${a##*:}"
+done
+
+mvn -B -q -pl auth-server dependency:build-classpath \
+ -Dmdep.outputFile=/tmp/as-cp.txt -DincludeScope=compile >/dev/null 2>&1
+CP_NEW=$(cat /tmp/as-cp.txt)
+CP_OLD=$(ls "$WORK"/old/*.jar | tr '\n' ':')
+
+{
+ echo "# Defaults of ClientSettings.builder().build() and TokenSettings.builder().build(),"
+ echo "# read out of the jars themselves rather than from documentation."
+ echo "# Source: tools/SettingsDefaults.java"
+ echo
+ echo "=== Spring Authorization Server 1.5.8 (last release of the standalone project) ==="
+ javac -nowarn -cp "$CP_OLD" -d "$WORK/out-old" tools/SettingsDefaults.java \
+ && java -cp "$CP_OLD:$WORK/out-old" SettingsDefaults
+ echo
+ echo "=== Spring Authorization Server 7.1.1 (inside Spring Security, Boot 4.1.1 BOM) ==="
+ javac -nowarn -cp "$CP_NEW" -d "$WORK/out-new" tools/SettingsDefaults.java \
+ && java -cp "$CP_NEW:$WORK/out-new" SettingsDefaults
+
+ echo
+ echo "# The same question on the CLIENT side. Source: tools/ClientPkceDefault.java"
+ mvn -B -q -pl oidc-client dependency:build-classpath \
+ -Dmdep.outputFile=/tmp/cl-cp.txt -DincludeScope=compile >/dev/null 2>&1
+ CP_CLIENT=$(cat /tmp/cl-cp.txt)
+ echo
+ echo "=== spring-security-oauth2-client 6.5.1 ==="
+ javac -nowarn -cp "$CP_OLD" -d "$WORK/co" tools/ClientPkceDefault.java \
+ && java -cp "$CP_OLD:$WORK/co" ClientPkceDefault
+ echo
+ echo "=== spring-security-oauth2-client 7.1.1 (Boot 4.1.1 BOM) ==="
+ javac -nowarn -cp "$CP_CLIENT" -d "$WORK/cn" tools/ClientPkceDefault.java \
+ && java -cp "$CP_CLIENT:$WORK/cn" ClientPkceDefault
+ echo
+ echo "# Both sides flipped in the 7.x line. Spring-to-Spring therefore still works;"
+ echo "# a 7.1 authorization server in front of a 6.x or hand-rolled client does not."
+} > "$OUT" 2>&1
+cat "$OUT"
diff --git a/authorization-server/src-broken/LegacySasConfig.java.txt b/authorization-server/src-broken/LegacySasConfig.java.txt
new file mode 100644
index 0000000..1a7d18d
--- /dev/null
+++ b/authorization-server/src-broken/LegacySasConfig.java.txt
@@ -0,0 +1,40 @@
+// The configuration every Spring Authorization Server tutorial written before
+// September 2025 tells you to write. It compiled against 1.5.8 and does not compile
+// against 7.1.1. Kept out of the build on purpose - scripts/compile-legacy.sh runs
+// javac against it and captures the real compiler output into
+// docs/output/as-legacy-compile-failure.txt.
+//
+// Three separate breakages in nine lines:
+// 1. the *Configuration class moved out of the SAS jar into spring-security-config
+// 2. the *Configurer class moved AND changed package shape
+// 3. applyDefaultSecurity(HttpSecurity) was deleted outright
+package com.ankurm.authserver.legacy;
+
+import org.springframework.security.oauth2.server.authorization.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration;
+import org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers.OAuth2AuthorizationServerConfigurer;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.core.Ordered;
+import org.springframework.core.annotation.Order;
+import org.springframework.security.config.Customizer;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.web.SecurityFilterChain;
+
+@Configuration
+public class LegacySasConfig {
+
+ @Bean
+ @Order(Ordered.HIGHEST_PRECEDENCE)
+ public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http)
+ throws Exception {
+
+ // The 1.x one-liner.
+ OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
+
+ http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
+ .oidc(Customizer.withDefaults());
+
+ return http.build();
+ }
+}
diff --git a/authorization-server/tools/ClientPkceDefault.java b/authorization-server/tools/ClientPkceDefault.java
new file mode 100644
index 0000000..8a76353
--- /dev/null
+++ b/authorization-server/tools/ClientPkceDefault.java
@@ -0,0 +1,15 @@
+import org.springframework.security.oauth2.client.registration.ClientRegistration;
+
+/**
+ * Prints whether a Spring OAuth2 client registration requires PKCE out of the box.
+ * Run against spring-security-oauth2-client 6.5.1 and 7.1.1, it shows the default flipping
+ * in the same release that flipped it on the authorization server.
+ */
+public class ClientPkceDefault {
+ public static void main(String[] args) {
+ ClientRegistration.ClientSettings cs =
+ ClientRegistration.ClientSettings.builder().build();
+ System.out.println("ClientRegistration.ClientSettings.requireProofKey = "
+ + cs.isRequireProofKey());
+ }
+}
diff --git a/authorization-server/tools/SettingsDefaults.java b/authorization-server/tools/SettingsDefaults.java
new file mode 100644
index 0000000..fa6ca32
--- /dev/null
+++ b/authorization-server/tools/SettingsDefaults.java
@@ -0,0 +1,20 @@
+import org.springframework.security.oauth2.server.authorization.settings.ClientSettings;
+import org.springframework.security.oauth2.server.authorization.settings.TokenSettings;
+/**
+ * Prints the out-of-the-box values of ClientSettings and TokenSettings. Run against two
+ * different Spring Authorization Server jars, it is the shortest proof that a default
+ * changed. scripts/settings-defaults.sh does exactly that for 1.5.8 and 7.1.1.
+ */
+public class SettingsDefaults {
+ public static void main(String[] a) {
+ ClientSettings cs = ClientSettings.builder().build();
+ TokenSettings ts = TokenSettings.builder().build();
+ System.out.println("requireProofKey = " + cs.isRequireProofKey());
+ System.out.println("requireAuthorizationConsent= " + cs.isRequireAuthorizationConsent());
+ System.out.println("accessTokenTimeToLive = " + ts.getAccessTokenTimeToLive());
+ System.out.println("accessTokenFormat = " + ts.getAccessTokenFormat().getValue());
+ System.out.println("refreshTokenTimeToLive = " + ts.getRefreshTokenTimeToLive());
+ System.out.println("reuseRefreshTokens = " + ts.isReuseRefreshTokens());
+ System.out.println("authorizationCodeTTL = " + ts.getAuthorizationCodeTimeToLive());
+ }
+}
diff --git a/docs/01-architecture.md b/docs/01-architecture.md
index 05a660a..342dc15 100644
--- a/docs/01-architecture.md
+++ b/docs/01-architecture.md
@@ -95,3 +95,9 @@ A JWT deployment is an **issuer** that trades a password for a signed claims set
**verifier** that trades a signed claims set for an `Authentication` — and every failure
mode in this repository comes from one of the two doing slightly less checking than the
other assumed.
+
+---
+
+A third project joined this repository later: a real OAuth2 / OIDC provider, with its own
+client and resource server. Its architecture is a superset of the one drawn above —
+see [`docs/authorization-server/`](authorization-server/README.md).
diff --git a/docs/09-manual-filter-vs-resource-server.md b/docs/09-manual-filter-vs-resource-server.md
index cca8a6a..1790f01 100644
--- a/docs/09-manual-filter-vs-resource-server.md
+++ b/docs/09-manual-filter-vs-resource-server.md
@@ -121,3 +121,10 @@ A useful third option for a real system: run
[Spring Authorization Server](https://spring.io/projects/spring-authorization-server) as
the issuer and consume its tokens with `oauth2ResourceServer()`. Then neither half of
this repository is your code.
+
+---
+
+There is a third option this comparison leaves out: **do not mint tokens at all in your
+application, and run a real authorization server instead**. That is
+[`docs/authorization-server/`](authorization-server/README.md), and the honest cost/benefit
+is in [10 — Should you run one at all](authorization-server/10-should-you.md).
diff --git a/docs/11-spring-security-7-changes.md b/docs/11-spring-security-7-changes.md
index 784f4b8..40f85a1 100644
--- a/docs/11-spring-security-7-changes.md
+++ b/docs/11-spring-security-7-changes.md
@@ -130,3 +130,19 @@ ankurm.com has a dedicated
[gh-18926]: https://github.com/spring-projects/spring-security/issues/18926
[gh-18634]: https://github.com/spring-projects/spring-security/pull/18634
[gh-18113]: https://github.com/spring-projects/spring-security/issues/18113
+
+---
+
+Two more 7.x changes surfaced while building the authorization-server project, both
+verified by reading the jars rather than the release notes:
+
+- `ClientSettings.requireProofKey` flipped from `false` to `true` on **both** the
+ authorization server and the OAuth2 client —
+ [`authorization-server/03`](authorization-server/03-clients-and-pkce.md)
+- `OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(HttpSecurity)` was deleted,
+ and its class moved into `spring-security-config` —
+ [`authorization-server/02`](authorization-server/02-minimum-provider.md)
+
+`FactorGrantedAuthority` now appears in every authority list, and `WWW-Authenticate` carries
+an RFC 9728 `resource_metadata` parameter —
+[`authorization-server/06`](authorization-server/06-resource-server.md).
diff --git a/docs/12-issuer-and-audience.md b/docs/12-issuer-and-audience.md
index 11e3651..f29a2f0 100644
--- a/docs/12-issuer-and-audience.md
+++ b/docs/12-issuer-and-audience.md
@@ -208,3 +208,9 @@ there and that it is yours.
---
[← Spring Security 7 changes](11-spring-security-7-changes.md) · [next: the validator stack →](13-validator-stack.md)
+
+---
+
+Seen from the issuer's side, `aud` on an access token defaults to the **client id**, and
+there is no per-client audience setting — you set it in an `OAuth2TokenCustomizer` or
+not at all. [`authorization-server/05`](authorization-server/05-token-customisation.md).
diff --git a/docs/15-jwks-caching-and-rotation.md b/docs/15-jwks-caching-and-rotation.md
index fdeee2c..ce1814b 100644
--- a/docs/15-jwks-caching-and-rotation.md
+++ b/docs/15-jwks-caching-and-rotation.md
@@ -178,3 +178,11 @@ JWK Set its own dedicated cache; do not point it at a cache you share with anyth
---
[← the authentication converter](14-authentication-converter.md) · [next: what an unknown kid costs →](16-jwks-amplification.md)
+
+---
+
+The provider side of rotation — generating, publishing and retiring the keys this
+chapter watches from the outside — is
+[`authorization-server/02`](authorization-server/02-minimum-provider.md), and why a demo
+provider regenerating its keypair per boot is a feature rather than a bug is in
+[`authorization-server/10`](authorization-server/10-should-you.md).
diff --git a/docs/17-keycloak-setup.md b/docs/17-keycloak-setup.md
index 5628926..5bf2684 100644
--- a/docs/17-keycloak-setup.md
+++ b/docs/17-keycloak-setup.md
@@ -151,3 +151,9 @@ unless reconfigured. Keycloak puts the client in `azp`.
---
[← what an unknown kid costs](16-jwks-amplification.md) · [next: resource server checklist →](18-resource-server-checklist.md)
+
+---
+
+For the comparison Keycloak invites — what it costs to run the equivalent yourself in
+Spring — see [`authorization-server/`](authorization-server/README.md), and in
+particular [10 — Should you run one at all](authorization-server/10-should-you.md).
diff --git a/docs/18-resource-server-checklist.md b/docs/18-resource-server-checklist.md
index 86a9886..fd5854d 100644
--- a/docs/18-resource-server-checklist.md
+++ b/docs/18-resource-server-checklist.md
@@ -80,3 +80,8 @@ document is a list of ways to get something wrong that you could simply not have
---
[← Keycloak setup](17-keycloak-setup.md) · [README](../README.md)
+
+---
+
+If you also own the issuer, the matching list for that side is
+[`authorization-server/10`](authorization-server/10-should-you.md).
diff --git a/docs/authorization-server/01-versions.md b/docs/authorization-server/01-versions.md
new file mode 100644
index 0000000..c92020f
--- /dev/null
+++ b/docs/authorization-server/01-versions.md
@@ -0,0 +1,81 @@
+[← index](README.md) · next: [02 — The minimum working provider](02-minimum-provider.md)
+
+# Versions, artifacts and the 7.0 move
+
+## There is no Spring Authorization Server version to pin
+
+The brief for this project was “pin the SAS version from the Boot 4.1 BOM”. There
+is nothing to pin. `spring-boot-dependencies:4.1.1` has no
+`` property, because Spring Authorization Server is no
+longer a separate project.
+
+```
+$ grep -oP '[^<]+' spring-boot-dependencies-4.1.1.pom
+7.1.1
+
+$ curl -s .../spring-security-bom/7.1.1/spring-security-bom-7.1.1.pom | grep -A1 authorization-server
+ spring-security-oauth2-authorization-server
+ 7.1.1
+```
+
+The Maven coordinates are unchanged —
+`org.springframework.security:spring-security-oauth2-authorization-server` — and the
+version now tracks Spring Security. Spring Boot 4.1.1 therefore gives you **7.1.1**.
+
+## The version numbers skipped
+
+The published version list on Maven Central tells the story on its own:
+
+```
+… 1.5.6 1.5.7 1.5.8 2.0.0-M1 2.0.0-M2 7.0.0-M3 7.0.0-RC1 … 7.0.0 7.0.1 … 7.1.1 7.2.0-M1
+```
+
+`2.0.0` was started and abandoned. There is **no 2.x GA**, and anything that tells you to
+upgrade to Spring Authorization Server 2 is describing a milestone that was renumbered.
+The line jumps from 1.5.8 to 7.0.0 to align with Spring Security 7.0.
+
+[Joe Grandja's announcement](https://spring.io/blog/2025/09/11/spring-authorization-server-moving-to-spring-security-7-0/)
+(11 September 2025) says the migration impact is “quite minimal” with “a
+couple of minor package relocation changes”. That is true in the sense that the
+relocations are mechanical. It is optimistic in the sense that one of them is the class
+every tutorial calls — see [02](02-minimum-provider.md).
+
+## Which starter
+
+Boot 4.1 publishes both of these, and they resolve the same four dependencies:
+
+| artifact | status |
+|---|---|
+| `spring-boot-starter-oauth2-authorization-server` | deprecated |
+| `spring-boot-starter-security-oauth2-authorization-server` | current |
+
+That is not inference. It is in the deprecated starter's own published POM:
+
+```xml
+Starter for using Spring Authorization Server features (deprecated in favor
+ of spring-boot-starter-security-oauth2-authorization-server)
+```
+
+The same rename happened to the client and resource-server starters
+(`spring-boot-starter-security-oauth2-client`,
+`spring-boot-starter-security-oauth2-resource-server`), and there is a new
+`spring-boot-starter-security-oauth2-authorization-server-test`. Boot 4 also renamed
+`spring-boot-starter-web` to `spring-boot-starter-webmvc`; the authorization server starter
+pulls the latter in transitively, so you do not need to declare a web starter at all.
+
+## Exact versions this project was built and run against
+
+| | |
+|---|---|
+| JDK | Temurin 25.0.4.1+1 (current LTS) |
+| Spring Boot | 4.1.1 |
+| Spring Framework | 7.0.9 |
+| Spring Security / Authorization Server | 7.1.1 |
+| Maven | 3.9.11 |
+
+## Related
+
+- [Spring Security 7.1 JWT Authentication: The Complete Guide](https://ankurm.com/spring-security-7-1-jwt-authentication-guide/) and [`docs/11-spring-security-7-changes.md`](../11-spring-security-7-changes.md) — the rest of what moved in Spring Security 7
+- [`docs/output/as-settings-defaults.txt`](../output/as-settings-defaults.txt) — defaults read out of the 1.5.8 and 7.1.1 jars side by side
+
+Next: [02 — The minimum working provider](02-minimum-provider.md)
diff --git a/docs/authorization-server/02-minimum-provider.md b/docs/authorization-server/02-minimum-provider.md
new file mode 100644
index 0000000..a840c63
--- /dev/null
+++ b/docs/authorization-server/02-minimum-provider.md
@@ -0,0 +1,101 @@
+[← 01 Versions](01-versions.md) · [index](README.md) · next: [03 — Clients, PKCE and the defaults that moved](03-clients-and-pkce.md)
+
+# The minimum working provider
+
+## The two imports that break every tutorial
+
+Two classes moved out of the Spring Authorization Server jar and into
+`spring-security-config`:
+
+| | 1.5.8 | 7.1.1 |
+|---|---|---|
+| `OAuth2AuthorizationServerConfiguration` | `o.s.s.oauth2.server.authorization.config.annotation.web.configuration` | `o.s.s.config.annotation.web.configuration` |
+| `OAuth2AuthorizationServerConfigurer` | `o.s.s.oauth2.server.authorization.config.annotation.web.configurers` | `o.s.s.config.annotation.web.configurers.oauth2.server.authorization` |
+
+And one method was deleted. `javap` on both jars:
+
+```
+# 1.5.8
+public static void applyDefaultSecurity(HttpSecurity) throws Exception;
+
+# 7.1.1
+ (absent)
+```
+
+`OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http)` is the one-liner in
+essentially every article and sample written before September 2025. It is gone.
+
+[`src-broken/LegacySasConfig.java.txt`](../../authorization-server/src-broken/LegacySasConfig.java.txt)
+is that configuration, kept out of the build.
+[`scripts/compile-legacy.sh`](../../authorization-server/scripts/compile-legacy.sh) compiles
+it against the real 7.1.1 classpath and commits the compiler's own words to
+[`docs/output/as-legacy-compile-failure.txt`](../output/as-legacy-compile-failure.txt):
+
+```
+error: package org.springframework.security.oauth2.server.authorization.config.annotation.web.configuration does not exist
+error: package org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers does not exist
+error: cannot find symbol
+ OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
+ symbol: variable OAuth2AuthorizationServerConfiguration
+4 errors
+```
+
+Four errors from nine lines of copied configuration.
+
+## What replaces it
+
+```java
+OAuth2AuthorizationServerConfigurer authorizationServer =
+ new OAuth2AuthorizationServerConfigurer();
+
+http
+ .securityMatcher(authorizationServer.getEndpointsMatcher())
+ .with(authorizationServer, server -> server
+ .oidc(Customizer.withDefaults())
+ .authorizationEndpoint(endpoint -> endpoint.consentPage("/oauth2/consent")))
+ .authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
+ .exceptionHandling(...)
+ .oauth2ResourceServer(rs -> rs.jwt(Customizer.withDefaults()));
+```
+
+Source:
+[`AuthorizationServerConfig.java`](../../authorization-server/auth-server/src/main/java/com/ankurm/authserver/config/AuthorizationServerConfig.java).
+
+## Why two filter chains
+
+The protocol chain carries `securityMatcher(getEndpointsMatcher())`, so it declines every
+request that is not an OAuth2 or OIDC endpoint. Something has to serve the login form and
+the consent page, and it needs a completely different authentication mechanism — a
+browser session rather than a bearer token. That is
+[`DefaultSecurityConfig`](../../authorization-server/auth-server/src/main/java/com/ankurm/authserver/config/DefaultSecurityConfig.java).
+
+**Order is load-bearing.** The protocol chain must be `@Order(HIGHEST_PRECEDENCE)`. Swap
+them and the catch-all form-login chain matches `/oauth2/token` first: a token request 302s
+to `/login` and the token endpoint is never reached. That redirect is the fingerprint.
+[`/diag/chains`](07-diagnostics.md) prints the live ordering.
+
+## OIDC is not on by default
+
+`.oidc(Customizer.withDefaults())` is one line and omitting it costs you `/userinfo`, the
+`id_token`, and `/.well-known/openid-configuration`. You still get the OAuth2 metadata
+document at `/.well-known/oauth-authorization-server` — the two are different
+documents, and [`as-discovery.txt`](../output/as-discovery.txt) prints both.
+
+## The bean that is not a bean
+
+A custom consent page needs to read `OAuth2AuthorizationConsentService`. It is not exposed
+as an injectable bean. The configurer creates one for its own use; a controller that
+constructor-injects it fails the context at startup, and the real message is kept in
+[`as-missing-consent-service.txt`](../output/as-missing-consent-service.txt):
+
+```
+No qualifying bean of type 'org.springframework.security.oauth2.server.authorization
+.OAuth2AuthorizationConsentService' available: expected at least 1 bean which qualifies
+as autowire candidate.
+```
+
+Declare `OAuth2AuthorizationService` and `OAuth2AuthorizationConsentService` yourself. That
+also forces the storage decision into the open: the in-memory implementations mean a second
+replica of the authorization server cannot complete a code exchange started on the first.
+
+Next: [03 — Clients, PKCE and the defaults that moved](03-clients-and-pkce.md)
diff --git a/docs/authorization-server/03-clients-and-pkce.md b/docs/authorization-server/03-clients-and-pkce.md
new file mode 100644
index 0000000..175bca3
--- /dev/null
+++ b/docs/authorization-server/03-clients-and-pkce.md
@@ -0,0 +1,123 @@
+[← 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)
diff --git a/docs/authorization-server/04-consent-page.md b/docs/authorization-server/04-consent-page.md
new file mode 100644
index 0000000..9044c15
--- /dev/null
+++ b/docs/authorization-server/04-consent-page.md
@@ -0,0 +1,77 @@
+[← 03 Clients and PKCE](03-clients-and-pkce.md) · [index](README.md) · next: [05 — Token customisation](05-token-customisation.md)
+
+# The consent page
+
+Wiring a custom consent page is one line:
+
+```java
+.authorizationEndpoint(endpoint -> endpoint.consentPage("/oauth2/consent"))
+```
+
+The path is your own MVC controller, served by the *browser* chain, not the protocol chain.
+Source:
+[`ConsentController.java`](../../authorization-server/auth-server/src/main/java/com/ankurm/authserver/web/ConsentController.java)
+and [`consent.html`](../../authorization-server/auth-server/src/main/resources/templates/consent.html).
+
+## The form contract
+
+The undocumented part is what the form has to send back. Getting any of it wrong produces a
+redirect loop rather than an error.
+
+| requirement | consequence of getting it wrong |
+|---|---|
+| POST to `/oauth2/authorize`, not to the consent path | 404 or a fresh authorization request |
+| echo `state` **as the consent page received it** | redirect loop |
+| echo `client_id` | `invalid_request` |
+| one `scope` parameter per approved scope | consent appears to succeed, token comes back short |
+| include the CSRF token | 403 |
+| omit `openid` from the checkboxes | harmless, but unticking it does nothing |
+
+## The `state` is not the client's `state`
+
+This is the one that costs an afternoon. From
+[`as-authcode-pkce.txt`](../output/as-authcode-pkce.txt):
+
+```
+GET /oauth2/authorize?…&state=xyz123
+-> 302 /oauth2/consent?scope=openid%20orders.read&client_id=demo-spa
+ &state=RXHrz8avEvUmNxYMLZoT0CyJS2E0t99pJtMJ5fyJBVM%3D
+```
+
+The client sent `state=xyz123`. The consent page is handed
+`RXHrz8avEvUmNxYMLZoT0CyJS2E0t99pJtMJ5fyJBVM=` — the authorization server's own
+correlation handle for the pending request. Echo the client's value instead and the endpoint
+cannot find the pending authorization, so it starts a new one, which redirects to the
+consent page again. The loop looks like a session problem and is not.
+
+The client's `state` comes back at the end, untouched, in the redirect to the client:
+
+```
+-> 302 http://127.0.0.1:8080/authorized?code=B6iUSZ…&state=xyz123
+```
+
+## Approving and denying
+
+Approve: POST with one `scope` parameter per approved scope.
+Deny: POST with **no** `scope` parameters at all. The endpoint then redirects to the client
+with `error=access_denied`.
+
+## Consent is remembered
+
+`OAuth2AuthorizationConsentService` stores what the user approved, keyed by client and
+principal. A second authorization for scopes already approved skips the page entirely.
+That is why `run-all.sh` restarts the authorization server between the two client-flow
+runs — otherwise the second one silently takes the no-consent path and proves nothing.
+
+The in-memory implementation loses all of it on restart, and is per-instance. Two replicas
+of your authorization server will ask the same user twice.
+
+## Turning consent off
+
+The `noconsent` profile sets `requireAuthorizationConsent(false)`
+([`as-authcode-noconsent.txt`](../output/as-authcode-noconsent.txt)). Correct for a
+first-party client you own and ship together with the provider. Wrong the moment a third
+party registers, because consent is the only point at which the user is told what they are
+agreeing to.
+
+Next: [05 — Token customisation](05-token-customisation.md)
diff --git a/docs/authorization-server/05-token-customisation.md b/docs/authorization-server/05-token-customisation.md
new file mode 100644
index 0000000..83175b8
--- /dev/null
+++ b/docs/authorization-server/05-token-customisation.md
@@ -0,0 +1,106 @@
+[← 04 Consent page](04-consent-page.md) · [index](README.md) · next: [06 — The resource server side](06-resource-server.md)
+
+# Token customisation
+
+Source:
+[`TokenClaimsCustomizer.java`](../../authorization-server/auth-server/src/main/java/com/ankurm/authserver/token/TokenClaimsCustomizer.java).
+
+## The bean is found by generic type, and nothing logs if it is not
+
+One bean of type `OAuth2TokenCustomizer` is picked up automatically by
+the JWT generator. No annotation, no registration step.
+
+Declare it as `OAuth2TokenCustomizer` — the type used for
+*opaque* tokens — and it is silently ignored. The generator resolves the bean by
+generic type and simply does not find it. Your claims are just absent, and nothing in the
+logs says why.
+
+## What the default access token actually contains
+
+Diff two runs of the same flow, with and without the customiser
+([`as-authcode-pkce.txt`](../output/as-authcode-pkce.txt) vs
+[`as-authcode-noclaims.txt`](../output/as-authcode-noclaims.txt)):
+
+```
+ default (noclaims) with the customiser
+ { {
+ "aud": "demo-spa", <--> "aud": "orders-api",
+ "roles": ["ADMIN", "USER"],
+ "tenant": "acme",
+ "exp": …, "exp": …,
+ "iat": …, "iat": …,
+ "iss": "http://localhost:9000", "iss": "http://localhost:9000",
+ "jti": …, "jti": …,
+ "nbf": …, "nbf": …,
+ "scope": ["openid","orders.read"], "scope": ["openid","orders.read"],
+ "sub": "alice" "sub": "alice"
+ } }
+```
+
+Two things worth noticing.
+
+**`aud` defaults to the client id.** Not the API. There is no per-client audience setting on
+`RegisteredClient`, so if your resource servers validate audience — and they should
+— the token customiser is where you set it. A resource server that naively checks
+`aud == "orders-api"` will reject every default-issued token.
+
+**Roles are not there by default.** `scope` is, as `SCOPE_*` authorities. Anything else
+about the user — roles, tenant, entitlements — you put there or you make a
+network call per request.
+
+## Guard on the grant type
+
+`client_credentials` has no user. `context.getPrincipal()` is the client's own
+authentication, and copying its authorities into a `roles` claim gives a machine token
+whatever the client authentication happened to carry. The customiser here excludes that
+grant explicitly.
+
+## The id_token is a different token
+
+```java
+if (OidcParameterNames.ID_TOKEN.equals(context.getTokenType().getValue())) { … }
+```
+
+The `id_token`'s audience is the **client**; the access token's is the **API**. From the
+transcript:
+
+```
+access token "aud": "orders-api"
+id_token "aud": "demo-spa", "azp": "demo-spa", "sid": "1ZK2c__DhcDY…"
+```
+
+Sending the `id_token` to a resource server is the classic mix-up. It verifies — same
+issuer, same signing key — and then fails the audience check:
+
+```
+HTTP/1.1 401
+WWW-Authenticate: Bearer error="invalid_token",
+ error_description="An error occurred while attempting to decode the Jwt:
+ the required audience orders-api is missing", …
+```
+
+If nobody checks audience, it *passes*, and a token the client was allowed to read becomes
+a token the API accepts. That is the argument for [06](06-resource-server.md).
+
+Put authorisation data in the access token. Put profile data in the `id_token`. The
+`id_token` is for the client to render a username; it is not a credential for your APIs.
+
+## Self-contained versus reference tokens
+
+`TokenSettings.accessTokenFormat` takes `SELF_CONTAINED` (a signed JWT, verified offline)
+or `REFERENCE` (an opaque string). The `opaque` profile flips `demo-service` to the latter
+([`as-client-credentials-opaque.txt`](../output/as-client-credentials-opaque.txt)):
+
+```
+The access token is an opaque reference: unf4kl7MSFlYyNpNqcVFcIT4Hbny…
+Length 128. It carries no claims; the resource server must introspect it.
+
+POST /oauth2/introspect
+{ "active": true, "sub": "demo-service", "scope": "orders.read", … }
+```
+
+The trade is instant revocation for a network round trip on every API call. Note that the
+introspection response reports `"aud": ["demo-service"]` — the customiser did not run,
+because opaque tokens go through `OAuth2TokenClaimsContext`, not `JwtEncodingContext`.
+
+Next: [06 — The resource server side](06-resource-server.md)
diff --git a/docs/authorization-server/06-resource-server.md b/docs/authorization-server/06-resource-server.md
new file mode 100644
index 0000000..4c11436
--- /dev/null
+++ b/docs/authorization-server/06-resource-server.md
@@ -0,0 +1,96 @@
+[← 05 Token customisation](05-token-customisation.md) · [index](README.md) · next: [07 — Diagnostics](07-diagnostics.md)
+
+# The resource server side
+
+Source:
+[`SecurityConfig.java`](../../authorization-server/resource-server/src/main/java/com/ankurm/rs/SecurityConfig.java).
+The deeper treatment of this half lives in [`docs/12`–`18`](../12-issuer-and-audience.md);
+this chapter is only what changes when the issuer is *yours*.
+
+## One property, and what it buys
+
+```yaml
+spring:
+ security:
+ oauth2:
+ resourceserver:
+ jwt:
+ issuer-uri: http://localhost:9000
+```
+
+At startup, Spring fetches `/.well-known/openid-configuration`, reads `jwks_uri` from it,
+and builds a decoder. You get signature verification, `exp`/`nbf`, and an `iss` check.
+
+You do **not** get an audience check. See [05](05-token-customisation.md) for why that
+matters when the default `aud` is the client id.
+
+## The startup coupling nobody mentions
+
+If the authorization server is not reachable, the resource server does not start
+([`as-rs-startup-failure.txt`](../output/as-rs-startup-failure.txt)):
+
+```
+java.lang.IllegalArgumentException: Unable to resolve the Configuration with the provided
+ Issuer of "http://localhost:9000"
+org.springframework.web.client.ResourceAccessException: I/O error on GET request for
+ "http://localhost:9000/.well-known/openid-configuration": Connection refused
+```
+
+This is deliberate — fail fast rather than serve unauthenticated traffic — but
+it means a provider outage during a rolling deploy takes every API with it. If that is not
+acceptable, configure `jwk-set-uri` directly and validate `iss` yourself, which removes the
+discovery call at the cost of pinning the endpoint.
+
+## The issuer string must match exactly
+
+`http://localhost:9000` and `http://localhost:9000/` are different values. A mismatch fails
+at *validation* time with `The iss claim is not valid`, not at startup, so it looks like a
+token problem rather than a configuration one.
+
+## Mapping custom claims without deleting the scopes
+
+The provider writes a `roles` claim. Mapping it is easy to get wrong in one specific way:
+
+```java
+converter.setJwtGrantedAuthoritiesConverter(jwt -> {
+ var authorities = new ArrayList(scopes.convert(jwt)); // keep these
+ List roles = jwt.getClaimAsStringList("roles");
+ if (roles != null) {
+ roles.forEach(r -> authorities.add(new SimpleGrantedAuthority("ROLE_" + r)));
+ }
+ return authorities;
+});
+```
+
+Returning a converter that only handles `roles` silently deletes every `SCOPE_*` authority,
+which turns `hasAuthority("SCOPE_orders.read")` into a 403 on a perfectly valid token. The
+same trap, in its properties-driven form, is
+[`docs/14-authentication-converter.md`](../14-authentication-converter.md).
+
+## What the authorities actually look like
+
+From a real request ([`as-authcode-pkce.txt`](../output/as-authcode-pkce.txt)):
+
+```json
+["SCOPE_openid","ROLE_USER","SCOPE_orders.read","ROLE_ADMIN",
+ "FactorGrantedAuthority [authority=FACTOR_BEARER, issuedAt=2026-08-24T01:52:21.799Z]"]
+```
+
+`FactorGrantedAuthority` is new in Spring Security 7 — it records *how* the principal
+authenticated, for multi-factor authorisation rules. It shows up in every authority list now.
+Code that asserts on the exact contents of `getAuthorities()` will fail on upgrade.
+
+## Protected resource metadata, also new
+
+The `WWW-Authenticate` header now carries a `resource_metadata` parameter:
+
+```
+WWW-Authenticate: Bearer error="invalid_token", error_description="…",
+ resource_metadata="http://localhost:8090/.well-known/oauth-protected-resource"
+```
+
+That is [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728), emitted by default. It
+tells a client where to learn what this API expects. Harmless, but it is a new endpoint on
+your resource server that you did not add.
+
+Next: [07 — Diagnostics](07-diagnostics.md)
diff --git a/docs/authorization-server/07-diagnostics.md b/docs/authorization-server/07-diagnostics.md
new file mode 100644
index 0000000..53781be
--- /dev/null
+++ b/docs/authorization-server/07-diagnostics.md
@@ -0,0 +1,61 @@
+[← 06 Resource server](06-resource-server.md) · [index](README.md) · next: [08 — The relying party](08-client.md)
+
+# Diagnostics
+
+The interesting configuration in an authorization server is spread across three builders
+and two filter chains, and the effective result is printed nowhere at startup. Reading the
+beans back is faster than reasoning about them.
+
+Source:
+[`ProviderDiagnostics.java`](../../authorization-server/auth-server/src/main/java/com/ankurm/authserver/diag/ProviderDiagnostics.java).
+**Delete it before shipping** — it exposes client ids, scopes, grant types and your
+chain ordering to anyone who can reach `/diag`.
+
+## `/diag/settings`
+
+Every endpoint path the server resolved, including the ones you never configured. Useful
+when a client insists your token endpoint is somewhere else.
+
+## `/diag/clients`
+
+The registered clients as the server actually holds them. This is where
+`requireProofKey: true` on a client you never configured shows up
+([`as-discovery.txt`](../output/as-discovery.txt)):
+
+```json
+{
+ "clientId": "demo-service",
+ "grantTypes": ["client_credentials"],
+ "requireProofKey": true,
+ "requireAuthorizationConsent": false,
+ "accessTokenFormat": "self-contained",
+ "accessTokenTtlSeconds": 600,
+ "reuseRefreshTokens": true
+}
+```
+
+The client secret is deliberately not returned. It is a hash, and printing it invites
+someone to try to use it as a secret.
+
+## `/diag/chains`
+
+The filter chains in the order Spring Security will consult them. If the authorization
+server chain is not first, the token endpoint is unreachable, and this is where you see
+that rather than inferring it from a 302 to `/login`. The equivalent for the resource-server
+project is [`docs/02-filter-chain-and-ordering.md`](../02-filter-chain-and-ordering.md).
+
+## The `trace` profile
+
+```bash
+./scripts/run.sh auth trace
+```
+
+Turns `org.springframework.security` up to TRACE. Verbose, but it is the only way to see
+which `AuthenticationProvider` handled — or declined — a token request.
+
+## Decoding a token without verifying it
+
+`scripts/lib.sh` has `jwt_header` and `jwt_payload`, three lines of base64url each. Debug
+only. Never make a decision on an unverified payload; that is the entire attack.
+
+Next: [08 — The relying party](08-client.md)
diff --git a/docs/authorization-server/08-client.md b/docs/authorization-server/08-client.md
new file mode 100644
index 0000000..43b6b9d
--- /dev/null
+++ b/docs/authorization-server/08-client.md
@@ -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)
diff --git a/docs/authorization-server/09-entry-point.md b/docs/authorization-server/09-entry-point.md
new file mode 100644
index 0000000..cfbf006
--- /dev/null
+++ b/docs/authorization-server/09-entry-point.md
@@ -0,0 +1,71 @@
+[← 08 The relying party](08-client.md) · [index](README.md) · next: [10 — Should you run one at all](10-should-you.md)
+
+# The entry point and the Accept header
+
+## The symptom
+
+A failed token request answers `302 -> /login` instead of a JSON `401`. Your API client
+follows the redirect, gets 200 and an HTML login page, and reports “the token endpoint
+returned HTML”.
+
+## The cause
+
+The authorization server chain needs two behaviours from one entry point: send a *browser*
+hitting `/oauth2/authorize` to the login page, and send a *machine* hitting `/oauth2/token`
+a protocol error. The documented way to express that is:
+
+```java
+.exceptionHandling(ex -> ex.defaultAuthenticationEntryPointFor(
+ new LoginUrlAuthenticationEntryPoint("/login"),
+ new MediaTypeRequestMatcher(MediaType.TEXT_HTML)))
+```
+
+On its own, that does not work. `MediaTypeRequestMatcher` treats `*/*` as matching
+`text/html`, and `*/*` is what curl, most HTTP clients, and anything that does not set
+`Accept` send. So the matcher fires for API callers too.
+
+## The fix
+
+```java
+MediaTypeRequestMatcher matcher = new MediaTypeRequestMatcher(MediaType.TEXT_HTML);
+matcher.setIgnoredMediaTypes(Set.of(MediaType.ALL));
+```
+
+## The difference, measured
+
+[`as-entrypoint-accept.txt`](../output/as-entrypoint-accept.txt), same request three ways
+against both configurations:
+
+| `Accept` | without `setIgnoredMediaTypes` | with it |
+|---|---|---|
+| `*/*` | **302 → /login** | **401** |
+| `application/json` | 401 | 401 |
+| `text/html` | 302 → /login | 302 → /login |
+
+The browser case is preserved either way. Only the `*/*` case changes, and that is the case
+every API client falls into.
+
+## Why only public clients hit it
+
+A confidential client presenting a wrong secret never reaches the entry point at all:
+`OAuth2ClientAuthenticationFilter` writes the error itself, so the `Accept` header makes no
+difference and you get a clean 401. It is the *public* client — whose only
+authentication mechanism is the code verifier — that falls through to the entry point
+when there is nothing to authenticate with. Which means the bug is invisible until you add
+your first SPA.
+
+## The mirror image in the test suite
+
+```java
+this.mvc.perform(post("/oauth2/token")
+ .accept(MediaType.ALL)
+ .param("grant_type", "authorization_code")
+ .param("code", "bogus")
+ .param("client_id", "demo-spa"))
+ .andExpect(status().isUnauthorized());
+```
+
+Pinning it as a test matters because the fix is one line in an `exceptionHandling` lambda
+and is exactly the kind of thing a later refactor drops.
+
+Next: [10 — Should you run one at all](10-should-you.md)
diff --git a/docs/authorization-server/10-should-you.md b/docs/authorization-server/10-should-you.md
new file mode 100644
index 0000000..0223039
--- /dev/null
+++ b/docs/authorization-server/10-should-you.md
@@ -0,0 +1,49 @@
+[← 09 Entry point](09-entry-point.md) · [index](README.md)
+
+# Should you run one at all
+
+Mostly: no.
+
+## What the demo does not have
+
+This project is roughly 700 lines and it is a demo. What it is missing is the actual work:
+
+| missing | what production needs |
+|---|---|
+| Key management | keys generated per boot; restart invalidates every token. Real deployments need persistent keys, a rotating JWK Set serving current **and** previous public keys, and an HSM or KMS for the private half |
+| Storage | `InMemoryOAuth2AuthorizationService` / `…ConsentService` / `…RegisteredClientRepository`. Two replicas cannot complete each other's code exchanges. The JDBC implementations exist and bring schema migrations with them |
+| User management | two hard-coded users. No registration, password reset, lockout, MFA, or audit |
+| Operations | no rate limiting on `/oauth2/token`, no metrics on grant failures, no alerting on a spike in `invalid_client` |
+| Compliance | consent records are the artefact an auditor asks for. In-memory ones do not exist |
+| Upgrades | you now own an OAuth2 implementation. The `requireProofKey` default change in [03](03-clients-and-pkce.md) is the kind of thing that will break your clients on a patch upgrade |
+
+## When it is the right call
+
+- **You need control an off-the-shelf product will not give you** — a bespoke consent
+ flow, a token shape a vendor cannot express, an unusual grant.
+- **The identity source is already yours** and adding a second user store is worse than
+ running the protocol.
+- **Air-gapped or heavily regulated deployment** where a hosted IdP is not permitted and a
+ commercial on-prem product is not affordable.
+- **You want to understand the protocol.** This is a real reason. Running one for a week
+ teaches you more about OAuth2 than any amount of integrating with one.
+
+## When to use something else
+
+If you want an authorization server because you need “login”, use Keycloak, or
+your cloud provider's identity service, or a hosted IdP. All of them do key rotation,
+storage, user management, MFA and audit already, and the reason they look heavy is that
+those things are heavy.
+
+[`docs/17-keycloak-setup.md`](../17-keycloak-setup.md) in this repository sets up Keycloak
+against the same resource server, so you can compare the two directly.
+
+## The middle path
+
+Run Spring Authorization Server as an **internal** provider for machine-to-machine traffic
+— `client_credentials` only, no users, no consent, no browser flows — and use a
+real IdP for humans. That configuration is a fraction of this one, has no session handling,
+and removes most of the table above. It is the only version of “write your own”
+that I would defend without qualification.
+
+[← back to the index](README.md)
diff --git a/docs/authorization-server/README.md b/docs/authorization-server/README.md
new file mode 100644
index 0000000..378949d
--- /dev/null
+++ b/docs/authorization-server/README.md
@@ -0,0 +1,64 @@
+# Running your own OAuth2 / OIDC provider
+
+Companion documentation for
+[Spring Authorization Server: Running Your Own OAuth2 / OIDC Provider](https://ankurm.com/spring-authorization-server-oauth2-oidc-provider/)
+on ankurm.com, and for the code in [`authorization-server/`](../../authorization-server).
+
+Where the other two projects in this repository *consume* tokens, this one **mints** them.
+[`docs/01`–`18`](../) cover a hand-written JWT filter and a resource server in front of
+somebody else's issuer; the chapters here cover the issuer itself.
+
+| | |
+|---|---|
+| JDK | Temurin **25.0.4.1+1** (current LTS) |
+| Spring Boot | **4.1.1** |
+| Spring Framework | **7.0.9** |
+| Spring Security | **7.1.1** |
+| Spring Authorization Server | **7.1.1** — the same artifact, now versioned with Spring Security |
+| Maven | 3.9.11 |
+
+Everything in [`docs/output/as-*.txt`](../output) is real program output, regenerated by
+[`authorization-server/scripts/run-all.sh`](../../authorization-server/scripts/run-all.sh).
+
+## Chapters
+
+| # | chapter | what it settles |
+|---|---|---|
+| 01 | [Versions, artifacts and the 7.0 move](01-versions.md) | why there is no SAS version to pin any more, and which starter to use |
+| 02 | [The minimum working provider](02-minimum-provider.md) | two filter chains, and the API that replaced `applyDefaultSecurity` |
+| 03 | [Clients, PKCE and the defaults that moved](03-clients-and-pkce.md) | `requireProofKey` flipped to `true` on both sides |
+| 04 | [The consent page](04-consent-page.md) | the form contract, and the redirect loop you get for breaking it |
+| 05 | [Token customisation](05-token-customisation.md) | the bean the JWT generator looks for, and the one it ignores |
+| 06 | [The resource server side](06-resource-server.md) | what `issuer-uri` does and does not validate |
+| 07 | [Diagnostics](07-diagnostics.md) | reading the effective configuration back out of the running server |
+| 08 | [The relying party](08-client.md) | driving a real browser flow, and the client-side PKCE default |
+| 09 | [The entry point and the Accept header](09-entry-point.md) | why the token endpoint 302s to a login page |
+| 10 | [Should you run one at all](10-should-you.md) | the honest answer, and what you are signing up for |
+
+## Captured output
+
+| file | produced by |
+|---|---|
+| [`as-settings-defaults.txt`](../output/as-settings-defaults.txt) | `scripts/settings-defaults.sh` |
+| [`as-legacy-compile-failure.txt`](../output/as-legacy-compile-failure.txt) | `scripts/compile-legacy.sh` |
+| [`as-missing-consent-service.txt`](../output/as-missing-consent-service.txt) | a real startup failure, kept |
+| [`as-discovery.txt`](../output/as-discovery.txt) | `scripts/discovery.sh` |
+| [`as-client-credentials.txt`](../output/as-client-credentials.txt) | `scripts/client-credentials.sh` |
+| [`as-client-credentials-noclaims.txt`](../output/as-client-credentials-noclaims.txt) | same, `noclaims` profile |
+| [`as-client-credentials-opaque.txt`](../output/as-client-credentials-opaque.txt) | same, `opaque` profile |
+| [`as-authcode-pkce.txt`](../output/as-authcode-pkce.txt) | `scripts/authcode-pkce.sh`, public client |
+| [`as-authcode-web.txt`](../output/as-authcode-web.txt) | same, confidential client |
+| [`as-authcode-noclaims.txt`](../output/as-authcode-noclaims.txt) | same, `noclaims` profile |
+| [`as-authcode-noconsent.txt`](../output/as-authcode-noconsent.txt) | same, `noconsent` profile |
+| [`as-authcode-nopkce.txt`](../output/as-authcode-nopkce.txt) | same, `nopkce` profile, challenge still sent |
+| [`as-authcode-nochallenge.txt`](../output/as-authcode-nochallenge.txt) | same, `nopkce` profile, no challenge at all |
+| [`as-authcode-pkce-enforced.txt`](../output/as-authcode-pkce-enforced.txt) | same, defaults, no challenge — rejected |
+| [`as-pkce-applier.txt`](../output/as-pkce-applier.txt) | `scripts/pkce-applier.sh` |
+| [`as-client-flow.txt`](../output/as-client-flow.txt) | `scripts/client-flow.sh` |
+| [`as-client-flow-nopkce.txt`](../output/as-client-flow-nopkce.txt) | same, pre-7.0 client |
+| [`as-entrypoint-accept.txt`](../output/as-entrypoint-accept.txt) | `scripts/entrypoint-accept.sh` |
+| [`as-audience.txt`](../output/as-audience.txt) | `scripts/audience.sh` |
+| [`as-rs-startup-failure.txt`](../output/as-rs-startup-failure.txt) | `scripts/rs-startup-failure.sh` |
+| [`as-test-run.txt`](../output/as-test-run.txt) | `mvn -pl auth-server test` |
+
+Next: [01 — Versions, artifacts and the 7.0 move](01-versions.md)
diff --git a/docs/output/as-audience.txt b/docs/output/as-audience.txt
new file mode 100644
index 0000000..5c30557
--- /dev/null
+++ b/docs/output/as-audience.txt
@@ -0,0 +1,32 @@
+
+------------------------------------------------------------------
+== A token for a DIFFERENT audience, signed by the SAME issuer
+------------------------------------------------------------------
+demo-service's tokens carry aud=[orders-api] thanks to the token customiser.
+Here we ask for one and then present it to a resource server configured to
+require a different audience - and to one that does not check at all.
+
+aud claim in the token:
+ "aud": "orders-api",
+ "exp": 1787539386,
+ "iat": 1787538786,
+ "iss": "http://localhost:9000",
+
+------------------------------------------------------------------
+== Resource server running with demo.validate-audience=false
+------------------------------------------------------------------
+This is the Spring Boot default: issuer-uri alone validates signature, exp/nbf
+and iss. Audience is not checked unless you add a validator.
+GET /api/orders -> 200
+{"orders":[{"total":"42.00","id":1}],"subject":"demo-service","clientId":null,"scopes":["orders.read"],"roles":null,"tenant":"acme","audience":["orders-api"]}
+
+------------------------------------------------------------------
+== The same token with a deliberately mangled signature
+------------------------------------------------------------------
+HTTP/1.1 200
+
+------------------------------------------------------------------
+== No token at all
+------------------------------------------------------------------
+HTTP/1.1 401
+WWW-Authenticate: Bearer resource_metadata="http://localhost:8090/.well-known/oauth-protected-resource"
diff --git a/docs/output/as-authcode-nochallenge.txt b/docs/output/as-authcode-nochallenge.txt
new file mode 100644
index 0000000..b0a9d43
--- /dev/null
+++ b/docs/output/as-authcode-nochallenge.txt
@@ -0,0 +1,67 @@
+
+------------------------------------------------------------------
+== PKCE parameters (RFC 7636)
+------------------------------------------------------------------
+code_verifier RnM5322FJWXI9zUZDUkmRmK-x_rjKT6HHFQyIhSEqjtVkb527uqjZmfQCwrNmavk (64 chars)
+code_challenge rzSywj6JKmWWVVBuZsD26hYR4_3PK0zZRCXrn_4sU9M
+code_challenge_method S256
+
+The verifier never leaves the client until the token request. The challenge is
+all the authorization request carries, and it is a one-way hash of the verifier.
+
+------------------------------------------------------------------
+== 1. Log in to the authorization server (browser session)
+------------------------------------------------------------------
+$ curl -c jar -d username=alice -d password=password -d _csrf= http://localhost:9000/login
+HTTP/1.1 302
+Location: http://localhost:9000/
+
+------------------------------------------------------------------
+== 2. GET /oauth2/authorize (client=demo-spa)
+------------------------------------------------------------------
+NO_CHALLENGE=1: the authorization request carries no code_challenge.
+
+$ curl -b jar 'http://localhost:9000/oauth2/authorize?response_type=code&client_id=demo-spa&redirect_uri=http%3A%2F%2F127.0.0.1%3A8080%2Fauthorized&scope=openid%20orders.read&state=xyz123'
+-> 302 http://127.0.0.1:8080/authorized?code=ydxIVNbSbrjWRlj1YxX6LS4cnXgDdzxTIyZgqoqlmX6cD8q-AYnL_Sdy6m5gqheZ-yd8qKUgSrrgMtg331sYHjblO3IYqGb1i94OxMypdyLr6RMqm_O_XuVZX2g1FSeD&state=xyz123
+
+------------------------------------------------------------------
+== 3. No consent page
+------------------------------------------------------------------
+The authorization endpoint went straight back to the client. Either consent is
+off for this client, or every requested scope was already approved.
+
+------------------------------------------------------------------
+== 5. The authorization code
+------------------------------------------------------------------
+code = ydxIVNbSbrjWRlj1YxX6LS4cnXgDdzxTIyZgqoqlmX6cD8q-AYnL_Sdy6m5gqheZ-yd8qKUgSrrgMtg331sYHjblO3IYqGb1i94OxMypdyLr6RMqm_O_XuVZX2g1FSeD
+state = xyz123 (the client's own value, returned untouched - compare it)
+
+------------------------------------------------------------------
+== 6a. Exchange the code WITHOUT the verifier
+------------------------------------------------------------------
+This is the request an attacker who stole the code can make.
+HTTP 401
+(empty response body)
+
+>>> Rejected. invalid_grant is deliberately vague: the server will not tell
+>>> a caller whether the code was wrong, expired, already used, or missing a
+>>> verifier, because each of those is information an attacker can use.
+Note: this consumed the code. Authorization codes are single-use, so the
+successful exchange below needs a fresh one.
+
+------------------------------------------------------------------
+== 6b. A fresh code, exchanged properly
+------------------------------------------------------------------
+fresh code = uvr0H49ByWte7NfWDoDnZ61-11rMP0wxYFJMY8eM73Lm2dcc2Edl-oFdW3DwJNSqUBMyIRX8rY9JqPDZIvwnEo4grUU-D5Z-GTwYYV1B8Xvosdbg-vAjiss1wbRsvD03
+
+$ curl -d grant_type=authorization_code -d code=... http://localhost:9000/oauth2/token
+ (no code_verifier - there was no challenge to verify against)
+HTTP 401
+(empty response body)
+
+>>> No token, even though the client is registered with requireProofKey(false)
+>>> and the authorization request carried no challenge. The reason is that a
+>>> public client has no other way to authenticate at the token endpoint:
+>>> PublicClientAuthenticationProvider delegates entirely to
+>>> CodeVerifierAuthenticator, and raises invalid_client when there is nothing
+>>> to verify. requireProofKey(false) relaxes the AUTHORIZATION endpoint only.
diff --git a/docs/output/as-authcode-noclaims.txt b/docs/output/as-authcode-noclaims.txt
new file mode 100644
index 0000000..1ef922e
--- /dev/null
+++ b/docs/output/as-authcode-noclaims.txt
@@ -0,0 +1,134 @@
+
+------------------------------------------------------------------
+== PKCE parameters (RFC 7636)
+------------------------------------------------------------------
+code_verifier Iw8HeF3yGXVXGbb7-1--z99v8prKuEaJsvcbuUCsIyPLPjoLmFL3ugA7jKMKXrs- (64 chars)
+code_challenge HmY3EXZTXZ3o7cMa9zsushNacFOu76m71sP160ZUvmQ
+code_challenge_method S256
+
+The verifier never leaves the client until the token request. The challenge is
+all the authorization request carries, and it is a one-way hash of the verifier.
+
+------------------------------------------------------------------
+== 1. Log in to the authorization server (browser session)
+------------------------------------------------------------------
+$ curl -c jar -d username=alice -d password=password -d _csrf= http://localhost:9000/login
+HTTP/1.1 302
+Location: http://localhost:9000/
+
+------------------------------------------------------------------
+== 2. GET /oauth2/authorize (client=demo-spa)
+------------------------------------------------------------------
+$ curl -b jar 'http://localhost:9000/oauth2/authorize?response_type=code&client_id=demo-spa&redirect_uri=http%3A%2F%2F127.0.0.1%3A8080%2Fauthorized&scope=openid%20orders.read&state=xyz123&code_challenge=HmY3EXZTXZ3o7cMa9zsushNacFOu76m71sP160ZUvmQ&code_challenge_method=S256'
+-> 302 http://localhost:9000/oauth2/consent?scope=openid%20orders.read&client_id=demo-spa&state=m02fs6cglXagctSASVX0sCCxeeDSeu72XdFVtdTcFKI%3D
+
+------------------------------------------------------------------
+== 3. The consent page
+------------------------------------------------------------------
+The authorization endpoint redirected to OUR page, at the path given to
+.consentPage("/oauth2/consent"). Note the query string it hands over:
+ http://localhost:9000/oauth2/consent?scope=openid%20orders.read
+ client_id=demo-spa
+ state=m02fs6cglXagctSASVX0sCCxeeDSeu72XdFVtdTcFKI%3D
+
+Scopes rendered as checkboxes (openid deliberately not among them):
+ orders.read
+
+The hidden state the form must echo back: m02fs6cglXagctSASVX0sCCxeeDSeu72XdFVtdTcFKI=
+(this is NOT the client's state=xyz123 - it is the server's own correlation
+ handle for the pending authorization request, and sending the client's value
+ instead is what produces the consent redirect loop)
+
+------------------------------------------------------------------
+== 4. POST the approval to /oauth2/authorize
+------------------------------------------------------------------
+$ curl -b jar -X POST -d client_id=demo-spa -d state=m02fs6cglXagctSASVX0sCCxeeDSeu72XdFVtdTcFKI= -d _csrf=MAR-q4pdtKeD5di-IHeoaLRqgfgFYFA6F0y8f8RdqDhoLtygB2JLnrI50JGu1O_cRVqcDNUJrME8VzQXIyiESvM7kF0LHeuU -d scope=orders.read http://localhost:9000/oauth2/authorize
+-> 302 http://127.0.0.1:8080/authorized?code=SZzaL3XfpYK5WflGaIQRq9NdFO_kPBK0jX5T19PfPZ9QZO6hyB8vQuID5L9uRizF0qd_Vu4XjNm4oe04nRKn1EsHwvmBndHMJ_C2I5b3wROlLL_tI-gAjinXV72LBKtl&state=xyz123
+
+------------------------------------------------------------------
+== 5. The authorization code
+------------------------------------------------------------------
+code = SZzaL3XfpYK5WflGaIQRq9NdFO_kPBK0jX5T19PfPZ9QZO6hyB8vQuID5L9uRizF0qd_Vu4XjNm4oe04nRKn1EsHwvmBndHMJ_C2I5b3wROlLL_tI-gAjinXV72LBKtl
+state = xyz123 (the client's own value, returned untouched - compare it)
+
+------------------------------------------------------------------
+== 6a. Exchange the code WITHOUT the verifier
+------------------------------------------------------------------
+This is the request an attacker who stole the code can make.
+HTTP 401
+(empty response body)
+
+>>> Rejected. invalid_grant is deliberately vague: the server will not tell
+>>> a caller whether the code was wrong, expired, already used, or missing a
+>>> verifier, because each of those is information an attacker can use.
+Note: this consumed the code. Authorization codes are single-use, so the
+successful exchange below needs a fresh one.
+
+------------------------------------------------------------------
+== 6b. A fresh code, exchanged properly
+------------------------------------------------------------------
+fresh code = L0f5Q2ZTW6TLHG9tOCddSEnQRM7U-C1ikbB7QU2glxHSoKs75R54iAYiPJmlpDMNDabWWEP17ZaZ-6jFlmvwlSnTgqxwJDUljiEQHhuZdr8rJXAjy4wPjpgn-hsje6pz
+
+$ curl -d grant_type=authorization_code -d code=... -d code_verifier=... http://localhost:9000/oauth2/token
+HTTP 200
+{
+ "access_token": "eyJraWQiOiIyNDgwNWM5Ni02MGY1LTQ5MDItYTczYi03ODgxYmFkNWY5ZGMiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJhbGljZSIsImF1ZCI6ImRlbW8tc3BhIiwibmJmIjoxNzg3NTM4NzQwLCJzY29wZSI6WyJvcGVuaWQiLCJvcmRlcnMucmVhZCJdLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjkwMDAiLCJleHAiOjE3ODc1MzkwNDAsImlhdCI6MTc4NzUzODc0MCwianRpIjoiYTg0YjUyMzYtZWE3ZC00ZjZjLWE2NzItNDkyNTAwNDJjODU3In0.Gu0qaHsAyLMCOp6PfufdSgex6k67zg2SL9i1NKzWKfyHV5Es1QSVyiy5CyjmCW2NaOrM-N18VybrA36f2WVG466dHWLZ17vDgE9BX1slFRQTMnVBzGi7kB6S06PwI_4l8MPe24XNUTSOT9L0OKWyRE5zVA4jw0p14Yn4qmCkG-ur3lqEPlPPEc0nnoALnazv_kQrpm-4xw42E5j0SBlOWXEwv7SwMw59VyamXQonY_LaflwNmUeevUtic-PeGBN9JJafsx31we63Vjcfri7d1dW0YtHQVAYzzJGiXA15pRChIZvbvY7miLiGryRQ8iPghHflALGpfITO2OmZHihoJQ",
+ "scope": "openid orders.read",
+ "id_token": "eyJraWQiOiIyNDgwNWM5Ni02MGY1LTQ5MDItYTczYi03ODgxYmFkNWY5ZGMiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJhbGljZSIsImF1ZCI6ImRlbW8tc3BhIiwiYXpwIjoiZGVtby1zcGEiLCJhdXRoX3RpbWUiOjE3ODc1Mzg3NDAsImlzcyI6Imh0dHA6Ly9sb2NhbGhvc3Q6OTAwMCIsImV4cCI6MTc4NzU0MDU0MCwiaWF0IjoxNzg3NTM4NzQwLCJqdGkiOiI3YzUzMDc5OS05ZGZiLTQxMDYtOWViOS02ZGU4NDFiN2FjYmIiLCJzaWQiOiJCcC02UWNZZW9tclJ1VGFlcUZQbWRscF9nZmNLVVJfSS1wakRuTGFaYXpnIn0.ELFbbP-luao7YrJlEOr2RL86dBlw1M-ultJk2LnstkE996a2MvghwTe4N0r_qJBSvkdvUmO0oaxdafDjbKMmJHFCtTHAJDBxguD35vGTpxXL_nLEMvJZy86Nu-joUoJ30Dy_4tkNMQDlBWapomLHAxmyRi6Nmv2yNujcejp9auHJjb_qeEuFbri_tV_znYBVJd0tg4BXibV_nYZ4vmkUZR_FpHIdXHGX4xMan478BsZSXmt0QNpWXrPiewrVYZqGPXAvp1_Uu-pzxZ3XZthitP1SKGl6HCUFIeWr8izcmKeErcCxzRZsz7Ymq0qjUPMnOY98VJof_0jp8tt5XKSqkw",
+ "token_type": "Bearer",
+ "expires_in": 299
+}
+
+------------------------------------------------------------------
+== 7. The access token
+------------------------------------------------------------------
+{
+ "alg": "RS256",
+ "kid": "24805c96-60f5-4902-a73b-7881bad5f9dc"
+}
+{
+ "aud": "demo-spa",
+ "exp": 1787539040,
+ "iat": 1787538740,
+ "iss": "http://localhost:9000",
+ "jti": "a84b5236-ea7d-4f6c-a672-49250042c857",
+ "nbf": 1787538740,
+ "scope": [
+ "openid",
+ "orders.read"
+ ],
+ "sub": "alice"
+}
+
+------------------------------------------------------------------
+== 8. The id_token - a different token, for a different audience
+------------------------------------------------------------------
+{
+ "aud": "demo-spa",
+ "auth_time": 1787538740,
+ "azp": "demo-spa",
+ "exp": 1787540540,
+ "iat": 1787538740,
+ "iss": "http://localhost:9000",
+ "jti": "7c530799-9dfb-4106-9eb9-6de841b7acbb",
+ "sid": "Bp-6QcYeomrRuTaeqFPmdlp_gfcKUR_I-pjDnLaZazg",
+ "sub": "alice"
+}
+
+aud is the CLIENT here, not the API. Sending this to a resource server is the
+classic mix-up: it verifies (same issuer, same key) and then fails the audience
+check, or worse, passes it if nobody checks audience.
+
+------------------------------------------------------------------
+== 9. Calling the resource server
+------------------------------------------------------------------
+GET /api/orders -> 401
+
+GET /api/admin -> 401
+
+
+------------------------------------------------------------------
+== 10. Sending the id_token instead
+------------------------------------------------------------------
+HTTP/1.1 401
+WWW-Authenticate: Bearer error="invalid_token", error_description="An error occurred while attempting to decode the Jwt: the required audience orders-api is missing", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1", resource_metadata="http://localhost:8090/.well-known/oauth-protected-resource"
diff --git a/docs/output/as-authcode-noconsent.txt b/docs/output/as-authcode-noconsent.txt
new file mode 100644
index 0000000..fb83b08
--- /dev/null
+++ b/docs/output/as-authcode-noconsent.txt
@@ -0,0 +1,123 @@
+
+------------------------------------------------------------------
+== PKCE parameters (RFC 7636)
+------------------------------------------------------------------
+code_verifier 1cYLvhzx4gH7jAoNyYB34Nv0jcuHMVqDPC8fFzZBuyiaYeArg2yU_LJIC2BBR8x1 (64 chars)
+code_challenge vocXBOiUBp_mSdSMvkfZUMxcKHxo021R2rZ46NndJ7c
+code_challenge_method S256
+
+The verifier never leaves the client until the token request. The challenge is
+all the authorization request carries, and it is a one-way hash of the verifier.
+
+------------------------------------------------------------------
+== 1. Log in to the authorization server (browser session)
+------------------------------------------------------------------
+$ curl -c jar -d username=alice -d password=password -d _csrf= http://localhost:9000/login
+HTTP/1.1 302
+Location: http://localhost:9000/
+
+------------------------------------------------------------------
+== 2. GET /oauth2/authorize (client=demo-spa)
+------------------------------------------------------------------
+$ curl -b jar 'http://localhost:9000/oauth2/authorize?response_type=code&client_id=demo-spa&redirect_uri=http%3A%2F%2F127.0.0.1%3A8080%2Fauthorized&scope=openid%20orders.read&state=xyz123&code_challenge=vocXBOiUBp_mSdSMvkfZUMxcKHxo021R2rZ46NndJ7c&code_challenge_method=S256'
+-> 302 http://127.0.0.1:8080/authorized?code=SEUTqNBFCouk0Ip0zD1IGctT21q8_hboy-3B5ofrsKPWUhZe43_dQ-mIhmSwgtHeHka6lyeow-SMgcVb4kLGCl0czBIt1tnbiMsJbNu3HLdW7M06FVrYILKrmJqGFsoE&state=xyz123
+
+------------------------------------------------------------------
+== 3. No consent page
+------------------------------------------------------------------
+The authorization endpoint went straight back to the client. Either consent is
+off for this client, or every requested scope was already approved.
+
+------------------------------------------------------------------
+== 5. The authorization code
+------------------------------------------------------------------
+code = SEUTqNBFCouk0Ip0zD1IGctT21q8_hboy-3B5ofrsKPWUhZe43_dQ-mIhmSwgtHeHka6lyeow-SMgcVb4kLGCl0czBIt1tnbiMsJbNu3HLdW7M06FVrYILKrmJqGFsoE
+state = xyz123 (the client's own value, returned untouched - compare it)
+
+------------------------------------------------------------------
+== 6a. Exchange the code WITHOUT the verifier
+------------------------------------------------------------------
+This is the request an attacker who stole the code can make.
+HTTP 401
+(empty response body)
+
+>>> Rejected. invalid_grant is deliberately vague: the server will not tell
+>>> a caller whether the code was wrong, expired, already used, or missing a
+>>> verifier, because each of those is information an attacker can use.
+Note: this consumed the code. Authorization codes are single-use, so the
+successful exchange below needs a fresh one.
+
+------------------------------------------------------------------
+== 6b. A fresh code, exchanged properly
+------------------------------------------------------------------
+fresh code = _TiGoBrAj3MPpOg3uoh76reyCg-YfoYTvHSraf-ljGyPjrPF6C0ccK_C4IG4oXjavEbTym0HttIURO7Mrt2U-YgBQ8Q_TSrgLgwpU357VYRtDm5rFdu4WVtET2Lt0YjB
+
+$ curl -d grant_type=authorization_code -d code=... -d code_verifier=... http://localhost:9000/oauth2/token
+HTTP 200
+{
+ "access_token": "eyJraWQiOiJmMjgxYTNkYy0zZjlmLTRkMGItOTk1YS1iZmUwYzcwYTEwODEiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJhbGljZSIsImF1ZCI6Im9yZGVycy1hcGkiLCJuYmYiOjE3ODc1Mzg3NTksInNjb3BlIjpbIm9wZW5pZCIsIm9yZGVycy5yZWFkIl0sInJvbGVzIjpbIkFETUlOIiwiVVNFUiJdLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjkwMDAiLCJleHAiOjE3ODc1MzkwNTksImlhdCI6MTc4NzUzODc1OSwianRpIjoiZGYxNzJlNTgtOGMxZi00NWVlLTlkZWEtMGE3OWQ2Y2NlMDU1IiwidGVuYW50IjoiYWNtZSJ9.TGm_0gOhoVk1mSX4YVVLA6iQp_bDFTvFAjE_1DFltFyg33FLdl7tV0Z97587SB40SgL53Vx5AUan0egPVzxZscsYwUHVxQgnXHsm0FFfIGywqccXNn2IDleUAoKtMF4Lz6oMwNc9lC6XU32UgeKGOweM_IamKcHox9GnY7q9M57nG6boOp89FZzGcYsgQ9zkbG0XvzfX3WY-FS7O5cFm8oF0b3duJ3Hb3nP8WN8VrUJdOTzuGRJC9dwxZV0Ss6sI5Z-tGr0uz_Kf0tYmJ-zlx21zh6pVKHwCEnaN6T9crl4qa784DFJlW9MA4NI_E6vXOfpHbAMyRHONSrdUAcTCtA",
+ "scope": "openid orders.read",
+ "id_token": "eyJraWQiOiJmMjgxYTNkYy0zZjlmLTRkMGItOTk1YS1iZmUwYzcwYTEwODEiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJhbGljZSIsImF1ZCI6ImRlbW8tc3BhIiwiYXpwIjoiZGVtby1zcGEiLCJhdXRoX3RpbWUiOjE3ODc1Mzg3NTgsImlzcyI6Imh0dHA6Ly9sb2NhbGhvc3Q6OTAwMCIsInByZWZlcnJlZF91c2VybmFtZSI6ImFsaWNlIiwiZXhwIjoxNzg3NTQwNTU5LCJpYXQiOjE3ODc1Mzg3NTksImp0aSI6ImE5NjFkNGJkLWIwNGUtNDc1Ni04N2JkLWE5ZjI4ZjkxMWM3NCIsInNpZCI6IkNlZlExc2ZTblJkRzBMZnY0SGdhX25iZ1pPckJiWTV6dzRwN2F4U1V1ZU0ifQ.o3-P70sm-3n2GST9kQdc0TMTazGy5vjbtO6RLdkrkbqpdz5bFmEwirdZ7ajymyutPHaGF1vGpq76fnSoBCADpNtfqNJsir8aUyKoqPxGw9HLRN_Ocky3rP-XuKAVSuaplrlEiIC6CKvwoX1oH7CnJqrI1362oLaza7ThriIyzJhArrmCIZsn7AR5h0gqFp-ivMnVgPSiRI9Gg_IpD8Jr1ZREPHo6z304vpwfTU9CJeHQb2k8wWueuJJVcjnd2hfVvqqkXX9nyIqiwy3TGG_3bbEtQ4yA4uhLACcH0E1KCtFIOFPcLujcwsNa0EnsO4OLB3w1teppZyK4vpIfGUSXcA",
+ "token_type": "Bearer",
+ "expires_in": 299
+}
+
+------------------------------------------------------------------
+== 7. The access token
+------------------------------------------------------------------
+{
+ "alg": "RS256",
+ "kid": "f281a3dc-3f9f-4d0b-995a-bfe0c70a1081"
+}
+{
+ "aud": "orders-api",
+ "exp": 1787539059,
+ "iat": 1787538759,
+ "iss": "http://localhost:9000",
+ "jti": "df172e58-8c1f-45ee-9dea-0a79d6cce055",
+ "nbf": 1787538759,
+ "roles": [
+ "ADMIN",
+ "USER"
+ ],
+ "scope": [
+ "openid",
+ "orders.read"
+ ],
+ "sub": "alice",
+ "tenant": "acme"
+}
+
+------------------------------------------------------------------
+== 8. The id_token - a different token, for a different audience
+------------------------------------------------------------------
+{
+ "aud": "demo-spa",
+ "auth_time": 1787538758,
+ "azp": "demo-spa",
+ "exp": 1787540559,
+ "iat": 1787538759,
+ "iss": "http://localhost:9000",
+ "jti": "a961d4bd-b04e-4756-87bd-a9f28f911c74",
+ "preferred_username": "alice",
+ "sid": "CefQ1sfSnRdG0Lfv4Hga_nbgZOrBbY5zw4p7axSUueM",
+ "sub": "alice"
+}
+
+aud is the CLIENT here, not the API. Sending this to a resource server is the
+classic mix-up: it verifies (same issuer, same key) and then fails the audience
+check, or worse, passes it if nobody checks audience.
+
+------------------------------------------------------------------
+== 9. Calling the resource server
+------------------------------------------------------------------
+GET /api/orders -> 200
+{"orders":[{"total":"42.00","id":1}],"subject":"alice","clientId":null,"scopes":["openid","orders.read"],"roles":["ADMIN","USER"],"tenant":"acme","audience":["orders-api"]}
+GET /api/admin -> 200
+{"authorities":["FactorGrantedAuthority [authority=FACTOR_BEARER, issuedAt=2026-08-24T02:32:39.220990600Z]","SCOPE_openid","ROLE_USER","SCOPE_orders.read","ROLE_ADMIN"],"message":"admin only"}
+
+------------------------------------------------------------------
+== 10. Sending the id_token instead
+------------------------------------------------------------------
+HTTP/1.1 401
+WWW-Authenticate: Bearer error="invalid_token", error_description="An error occurred while attempting to decode the Jwt: the required audience orders-api is missing", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1", resource_metadata="http://localhost:8090/.well-known/oauth-protected-resource"
diff --git a/docs/output/as-authcode-nopkce.txt b/docs/output/as-authcode-nopkce.txt
new file mode 100644
index 0000000..0af90dd
--- /dev/null
+++ b/docs/output/as-authcode-nopkce.txt
@@ -0,0 +1,140 @@
+
+------------------------------------------------------------------
+== PKCE parameters (RFC 7636)
+------------------------------------------------------------------
+code_verifier s3e6dmr3wSf4_lvze8F93m7moAckkHVcu6hKAerx3Ug77jqz38_iNhnOtio2dNj6 (64 chars)
+code_challenge fIGXtoxshuoWNmv8gk1b7wWtLzxOzB0fSenfe02UwAs
+code_challenge_method S256
+
+The verifier never leaves the client until the token request. The challenge is
+all the authorization request carries, and it is a one-way hash of the verifier.
+
+------------------------------------------------------------------
+== 1. Log in to the authorization server (browser session)
+------------------------------------------------------------------
+$ curl -c jar -d username=alice -d password=password -d _csrf= http://localhost:9000/login
+HTTP/1.1 302
+Location: http://localhost:9000/
+
+------------------------------------------------------------------
+== 2. GET /oauth2/authorize (client=demo-spa)
+------------------------------------------------------------------
+$ curl -b jar 'http://localhost:9000/oauth2/authorize?response_type=code&client_id=demo-spa&redirect_uri=http%3A%2F%2F127.0.0.1%3A8080%2Fauthorized&scope=openid%20orders.read&state=xyz123&code_challenge=fIGXtoxshuoWNmv8gk1b7wWtLzxOzB0fSenfe02UwAs&code_challenge_method=S256'
+-> 302 http://localhost:9000/oauth2/consent?scope=openid%20orders.read&client_id=demo-spa&state=zjgza5hwc4CbJeJepOgTFZXTDk1IBgjWjsGo_mupymU%3D
+
+------------------------------------------------------------------
+== 3. The consent page
+------------------------------------------------------------------
+The authorization endpoint redirected to OUR page, at the path given to
+.consentPage("/oauth2/consent"). Note the query string it hands over:
+ http://localhost:9000/oauth2/consent?scope=openid%20orders.read
+ client_id=demo-spa
+ state=zjgza5hwc4CbJeJepOgTFZXTDk1IBgjWjsGo_mupymU%3D
+
+Scopes rendered as checkboxes (openid deliberately not among them):
+ orders.read
+
+The hidden state the form must echo back: zjgza5hwc4CbJeJepOgTFZXTDk1IBgjWjsGo_mupymU=
+(this is NOT the client's state=xyz123 - it is the server's own correlation
+ handle for the pending authorization request, and sending the client's value
+ instead is what produces the consent redirect loop)
+
+------------------------------------------------------------------
+== 4. POST the approval to /oauth2/authorize
+------------------------------------------------------------------
+$ curl -b jar -X POST -d client_id=demo-spa -d state=zjgza5hwc4CbJeJepOgTFZXTDk1IBgjWjsGo_mupymU= -d _csrf=f7zUD7ezBE6G0H5DQuPikT9CkEEKR9JuA2Y8njWwhSkoewGDG4ziN46BZXerthpycc7Wp1x6vSBrJetDOwMLr1SI4EpOGDG1 -d scope=orders.read http://localhost:9000/oauth2/authorize
+-> 302 http://127.0.0.1:8080/authorized?code=whzdCa9Z_53d6sCkZYkNNswxQLQ46kDsMy9ynR0rOvy6KqP741fpHOAC5nF4PsN6O0Sqvi3R9kksVer6L6V3Zk9Ii_LcHOYdHqWGEiE9O8jDDRJjqr-LiTnpEDI12St1&state=xyz123
+
+------------------------------------------------------------------
+== 5. The authorization code
+------------------------------------------------------------------
+code = whzdCa9Z_53d6sCkZYkNNswxQLQ46kDsMy9ynR0rOvy6KqP741fpHOAC5nF4PsN6O0Sqvi3R9kksVer6L6V3Zk9Ii_LcHOYdHqWGEiE9O8jDDRJjqr-LiTnpEDI12St1
+state = xyz123 (the client's own value, returned untouched - compare it)
+
+------------------------------------------------------------------
+== 6a. Exchange the code WITHOUT the verifier
+------------------------------------------------------------------
+This is the request an attacker who stole the code can make.
+HTTP 401
+(empty response body)
+
+>>> Rejected. invalid_grant is deliberately vague: the server will not tell
+>>> a caller whether the code was wrong, expired, already used, or missing a
+>>> verifier, because each of those is information an attacker can use.
+Note: this consumed the code. Authorization codes are single-use, so the
+successful exchange below needs a fresh one.
+
+------------------------------------------------------------------
+== 6b. A fresh code, exchanged properly
+------------------------------------------------------------------
+fresh code = B3GUYBE-2tE9C3eSx8_m8ejjXhPwFYEHuTrMwvmNpj-xZuDsPhutSgDt3pY88aE4bSmclgB8mDWFb_SiCODkeheeyCkogZkPwSLFFY4MqbZ1WlUipMynXR1jn9L0UIav
+
+$ curl -d grant_type=authorization_code -d code=... -d code_verifier=... http://localhost:9000/oauth2/token
+HTTP 200
+{
+ "access_token": "eyJraWQiOiIyYjNjZGFhMy1kMDE1LTQ3NTItOWZiYS0xNmM3YjI0NTJiNTciLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJhbGljZSIsImF1ZCI6Im9yZGVycy1hcGkiLCJuYmYiOjE3ODc1Mzg3NDYsInNjb3BlIjpbIm9wZW5pZCIsIm9yZGVycy5yZWFkIl0sInJvbGVzIjpbIkFETUlOIiwiVVNFUiJdLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjkwMDAiLCJleHAiOjE3ODc1MzkwNDYsImlhdCI6MTc4NzUzODc0NiwianRpIjoiZmI3ZTUyOTEtMTc2OC00NjI2LWJhODUtNWQ3NWI2OGZmNjcyIiwidGVuYW50IjoiYWNtZSJ9.e048ANvC61A6JPe7rE5k_aQDFcQTTCWNRg11j6Nj3MoUsqPvRIPFtYWvK0_l20HnUWZn0X84hy-ifgi4f_nBYDXDXFI-Ya2bRjRnnhtincjZsLNB8RUUDK5tqAPMSdfcceM48cTLLDrvwrxIp0ASG697aBuRVmndcUWyMVfZPzSidR3h0ydeWUVaY7NmL8d6pOzgLLNlDTIjyipURUboda7Mcw7KTHxodM_saz1xwQTzozRtcybmreUw44O6b07wjATcIzAwAJzASaiX2ElUyYs9lWAtxQ-JSiGX4htmfLJiONVnuEZNSy3uvLQnTfnl4G4OY2mjK1zJzIT-wBfX6w",
+ "scope": "openid orders.read",
+ "id_token": "eyJraWQiOiIyYjNjZGFhMy1kMDE1LTQ3NTItOWZiYS0xNmM3YjI0NTJiNTciLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJhbGljZSIsImF1ZCI6ImRlbW8tc3BhIiwiYXpwIjoiZGVtby1zcGEiLCJhdXRoX3RpbWUiOjE3ODc1Mzg3NDYsImlzcyI6Imh0dHA6Ly9sb2NhbGhvc3Q6OTAwMCIsInByZWZlcnJlZF91c2VybmFtZSI6ImFsaWNlIiwiZXhwIjoxNzg3NTQwNTQ2LCJpYXQiOjE3ODc1Mzg3NDYsImp0aSI6IjBjY2IzZjI4LTk1MWYtNGU5ZS1iNzkzLTNjNWExNjdmNDg1MSIsInNpZCI6InQ1dUpXUDM2V1dYWTNXX2hkaURueTR1OGlEZlVyZHlLd1EyRE9XWUdoSzgifQ.B6kGKui5mOyakllBv7xmmlVcsy6BYUN-JNALZLKMPz-O0WZO6WigSYfHKXdHtYVGsNdxVpFgeyWYPyi6JOTX9ALaDeJcEAiUwZFEN4PJa-lSzZvdT6m5atD1OCQqXOd7c_4bqD596I94Gu22arBij6UYaNBagLdBmtbAhJYMhBT5Q7Ygzo5m0w7Ru-oxU_cK06R4ZggNGo9B0NWJfqXroTOdFzfJmptl3CN9Ddh14pj5pz4w0hAMkxSMsJ-GAgm85ldD9aGVzFfjZ4-lzI7gveUVn_IzWZQbCY3_SYUuoGQFe60I0L6cDyot88qfuXn3m1udEaQVvrJlE7SBxMIFmQ",
+ "token_type": "Bearer",
+ "expires_in": 299
+}
+
+------------------------------------------------------------------
+== 7. The access token
+------------------------------------------------------------------
+{
+ "alg": "RS256",
+ "kid": "2b3cdaa3-d015-4752-9fba-16c7b2452b57"
+}
+{
+ "aud": "orders-api",
+ "exp": 1787539046,
+ "iat": 1787538746,
+ "iss": "http://localhost:9000",
+ "jti": "fb7e5291-1768-4626-ba85-5d75b68ff672",
+ "nbf": 1787538746,
+ "roles": [
+ "ADMIN",
+ "USER"
+ ],
+ "scope": [
+ "openid",
+ "orders.read"
+ ],
+ "sub": "alice",
+ "tenant": "acme"
+}
+
+------------------------------------------------------------------
+== 8. The id_token - a different token, for a different audience
+------------------------------------------------------------------
+{
+ "aud": "demo-spa",
+ "auth_time": 1787538746,
+ "azp": "demo-spa",
+ "exp": 1787540546,
+ "iat": 1787538746,
+ "iss": "http://localhost:9000",
+ "jti": "0ccb3f28-951f-4e9e-b793-3c5a167f4851",
+ "preferred_username": "alice",
+ "sid": "t5uJWP36WWXY3W_hdiDny4u8iDfUrdyKwQ2DOWYGhK8",
+ "sub": "alice"
+}
+
+aud is the CLIENT here, not the API. Sending this to a resource server is the
+classic mix-up: it verifies (same issuer, same key) and then fails the audience
+check, or worse, passes it if nobody checks audience.
+
+------------------------------------------------------------------
+== 9. Calling the resource server
+------------------------------------------------------------------
+GET /api/orders -> 200
+{"orders":[{"total":"42.00","id":1}],"subject":"alice","clientId":null,"scopes":["openid","orders.read"],"roles":["ADMIN","USER"],"tenant":"acme","audience":["orders-api"]}
+GET /api/admin -> 200
+{"authorities":["SCOPE_openid","ROLE_USER","SCOPE_orders.read","ROLE_ADMIN","FactorGrantedAuthority [authority=FACTOR_BEARER, issuedAt=2026-08-24T02:32:26.714311634Z]"],"message":"admin only"}
+
+------------------------------------------------------------------
+== 10. Sending the id_token instead
+------------------------------------------------------------------
+HTTP/1.1 401
+WWW-Authenticate: Bearer error="invalid_token", error_description="An error occurred while attempting to decode the Jwt: the required audience orders-api is missing", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1", resource_metadata="http://localhost:8090/.well-known/oauth-protected-resource"
diff --git a/docs/output/as-authcode-pkce-enforced.txt b/docs/output/as-authcode-pkce-enforced.txt
new file mode 100644
index 0000000..64ddba2
--- /dev/null
+++ b/docs/output/as-authcode-pkce-enforced.txt
@@ -0,0 +1,42 @@
+
+------------------------------------------------------------------
+== PKCE parameters (RFC 7636)
+------------------------------------------------------------------
+code_verifier BTbNq-KzR5R5zjPoT5s8JVMvvweKRQq_dMcGF-2P7Ea8-lku7Jgv0Na6RZkxBjlY (64 chars)
+code_challenge B_BI3Dk68d1epKthalJJxOKFAN0mf3FkUOoJRuV26kM
+code_challenge_method S256
+
+The verifier never leaves the client until the token request. The challenge is
+all the authorization request carries, and it is a one-way hash of the verifier.
+
+------------------------------------------------------------------
+== 1. Log in to the authorization server (browser session)
+------------------------------------------------------------------
+$ curl -c jar -d username=alice -d password=password -d _csrf= http://localhost:9000/login
+HTTP/1.1 302
+Location: http://localhost:9000/
+
+------------------------------------------------------------------
+== 2. GET /oauth2/authorize (client=demo-spa)
+------------------------------------------------------------------
+NO_CHALLENGE=1: the authorization request carries no code_challenge.
+
+$ curl -b jar 'http://localhost:9000/oauth2/authorize?response_type=code&client_id=demo-spa&redirect_uri=http%3A%2F%2F127.0.0.1%3A8080%2Fauthorized&scope=openid%20orders.read&state=xyz123'
+-> 302 http://127.0.0.1:8080/authorized?error=invalid_request&error_description=OAuth%202.0%20Parameter%3A%20code_challenge&error_uri=https%3A%2F%2Fdatatracker.ietf.org%2Fdoc%2Fhtml%2Frfc7636%23section-4.4.1&state=xyz123
+
+------------------------------------------------------------------
+== 3. No consent page
+------------------------------------------------------------------
+The authorization endpoint went straight back to the client. Either consent is
+off for this client, or every requested scope was already approved.
+
+------------------------------------------------------------------
+== 5. The authorization code
+------------------------------------------------------------------
+code =
+state = xyz123 (the client's own value, returned untouched - compare it)
+no code in the redirect. The error was:
+ http://127.0.0.1:8080/authorized?error=invalid_request
+ error_description=OAuth%202.0%20Parameter%3A%20code_challenge
+ error_uri=https%3A%2F%2Fdatatracker.ietf.org%2Fdoc%2Fhtml%2Frfc7636%23section-4.4.1
+ state=xyz123
diff --git a/docs/output/as-authcode-pkce.txt b/docs/output/as-authcode-pkce.txt
new file mode 100644
index 0000000..e1b2701
--- /dev/null
+++ b/docs/output/as-authcode-pkce.txt
@@ -0,0 +1,140 @@
+
+------------------------------------------------------------------
+== PKCE parameters (RFC 7636)
+------------------------------------------------------------------
+code_verifier C9PyP-Bxq-_AwoSFt1hAHGO6klWLddojkvIaIR1-vTzzTkjtc6o9c71G-yVYQwKf (64 chars)
+code_challenge FlJK8n-vjKPJ9-ZQPGrBm4JqIvhUotk67SUZRkXXJ3o
+code_challenge_method S256
+
+The verifier never leaves the client until the token request. The challenge is
+all the authorization request carries, and it is a one-way hash of the verifier.
+
+------------------------------------------------------------------
+== 1. Log in to the authorization server (browser session)
+------------------------------------------------------------------
+$ curl -c jar -d username=alice -d password=password -d _csrf= http://localhost:9000/login
+HTTP/1.1 302
+Location: http://localhost:9000/
+
+------------------------------------------------------------------
+== 2. GET /oauth2/authorize (client=demo-spa)
+------------------------------------------------------------------
+$ curl -b jar 'http://localhost:9000/oauth2/authorize?response_type=code&client_id=demo-spa&redirect_uri=http%3A%2F%2F127.0.0.1%3A8080%2Fauthorized&scope=openid%20orders.read&state=xyz123&code_challenge=FlJK8n-vjKPJ9-ZQPGrBm4JqIvhUotk67SUZRkXXJ3o&code_challenge_method=S256'
+-> 302 http://localhost:9000/oauth2/consent?scope=openid%20orders.read&client_id=demo-spa&state=s4VpkAnwpXo3Q9sekOo3MJVSaZgwc_AJnZMNIqcHvpE%3D
+
+------------------------------------------------------------------
+== 3. The consent page
+------------------------------------------------------------------
+The authorization endpoint redirected to OUR page, at the path given to
+.consentPage("/oauth2/consent"). Note the query string it hands over:
+ http://localhost:9000/oauth2/consent?scope=openid%20orders.read
+ client_id=demo-spa
+ state=s4VpkAnwpXo3Q9sekOo3MJVSaZgwc_AJnZMNIqcHvpE%3D
+
+Scopes rendered as checkboxes (openid deliberately not among them):
+ orders.read
+
+The hidden state the form must echo back: s4VpkAnwpXo3Q9sekOo3MJVSaZgwc_AJnZMNIqcHvpE=
+(this is NOT the client's state=xyz123 - it is the server's own correlation
+ handle for the pending authorization request, and sending the client's value
+ instead is what produces the consent redirect loop)
+
+------------------------------------------------------------------
+== 4. POST the approval to /oauth2/authorize
+------------------------------------------------------------------
+$ curl -b jar -X POST -d client_id=demo-spa -d state=s4VpkAnwpXo3Q9sekOo3MJVSaZgwc_AJnZMNIqcHvpE= -d _csrf=pQeZvH9HfbMeKSIJ5Tgv-82ImtETjf8f3pu1C0Ukgwnkyy9_lDX9iE12HNUzGRA_3RUbmqy9t-glv50y7qKGOiRCujHU_Eod -d scope=orders.read http://localhost:9000/oauth2/authorize
+-> 302 http://127.0.0.1:8080/authorized?code=f5H5d-BObbl65Mzu2LE_Lg6QmVgG31gd_1__sJ28KSIjHqbr9yp0o4sq-Q9NqADqXc2Bemfdiod46rduBfDmvygYYvvhadjjn5ZQcYHGNfipjZoTQ4oxjDFQJYldbS4H&state=xyz123
+
+------------------------------------------------------------------
+== 5. The authorization code
+------------------------------------------------------------------
+code = f5H5d-BObbl65Mzu2LE_Lg6QmVgG31gd_1__sJ28KSIjHqbr9yp0o4sq-Q9NqADqXc2Bemfdiod46rduBfDmvygYYvvhadjjn5ZQcYHGNfipjZoTQ4oxjDFQJYldbS4H
+state = xyz123 (the client's own value, returned untouched - compare it)
+
+------------------------------------------------------------------
+== 6a. Exchange the code WITHOUT the verifier
+------------------------------------------------------------------
+This is the request an attacker who stole the code can make.
+HTTP 401
+(empty response body)
+
+>>> Rejected. invalid_grant is deliberately vague: the server will not tell
+>>> a caller whether the code was wrong, expired, already used, or missing a
+>>> verifier, because each of those is information an attacker can use.
+Note: this consumed the code. Authorization codes are single-use, so the
+successful exchange below needs a fresh one.
+
+------------------------------------------------------------------
+== 6b. A fresh code, exchanged properly
+------------------------------------------------------------------
+fresh code = 5eNIeklP92R9ihz7RAuR_3cwTdlRKhqMXLSVVyNGei_jHwwsTiFIpHe_4bDLIdO0GbrLkkQ4NWAOD5XE1u1gsNB7xtfNEf9t6zTEyeu0EQ4tEtklkKpGtnfGMsKU1XZR
+
+$ curl -d grant_type=authorization_code -d code=... -d code_verifier=... http://localhost:9000/oauth2/token
+HTTP 200
+{
+ "access_token": "eyJraWQiOiIyNjJiZDU1MC0zNjU3LTQ2YzQtYmFmYy1jY2U0YzZmNGUwY2IiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJhbGljZSIsImF1ZCI6Im9yZGVycy1hcGkiLCJuYmYiOjE3ODc1Mzg3MTQsInNjb3BlIjpbIm9wZW5pZCIsIm9yZGVycy5yZWFkIl0sInJvbGVzIjpbIkFETUlOIiwiVVNFUiJdLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjkwMDAiLCJleHAiOjE3ODc1MzkwMTQsImlhdCI6MTc4NzUzODcxNCwianRpIjoiMWQ5NGFjYmMtZTI0NS00MGZiLWE3NWQtMDVhYjNiYzI5NGI2IiwidGVuYW50IjoiYWNtZSJ9.f0zh8PyK_6luy-npcr4zZj-ZHeqbrCtGI-b3SBlyE36RAu1PDXv6WP7ZQjV-9DWn7fSmD7mClOVSOdCZDXx9Y9jsCAvGwyV3DHeFutJHcM5pqrdX7n31TmPgFZgaXko8bK34qs62ic8pwNKKEL2R0jAYeVLqlGtPYVo1a5hMvXNxYARC519wKzfIJMMYtEiecOlk5n9m41lXk3WT4EvqN72zeQBgOnJBqd75vwTyr27UwLlXoGfeuu1cUBXzEg0COw4Eirv-P7zhvTpGSc8oich_At_TYip9GyOLnfNK60p_QJEPMnhCvye6ooXi6tiQ2kbHxdodTlTWDYId0wA6nQ",
+ "scope": "openid orders.read",
+ "id_token": "eyJraWQiOiIyNjJiZDU1MC0zNjU3LTQ2YzQtYmFmYy1jY2U0YzZmNGUwY2IiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJhbGljZSIsImF1ZCI6ImRlbW8tc3BhIiwiYXpwIjoiZGVtby1zcGEiLCJhdXRoX3RpbWUiOjE3ODc1Mzg3MTMsImlzcyI6Imh0dHA6Ly9sb2NhbGhvc3Q6OTAwMCIsInByZWZlcnJlZF91c2VybmFtZSI6ImFsaWNlIiwiZXhwIjoxNzg3NTQwNTE0LCJpYXQiOjE3ODc1Mzg3MTQsImp0aSI6ImRjM2JiZTMyLTBlMmEtNDUwYi1hNjVjLTU0YzQ0M2E4NTcwZCIsInNpZCI6ImNIb29WN2Nqa0ctV0t4ZC1NWTJyNDM0SXRJYXhwcXJZR1VPbENCVjY3OHcifQ.fCsxjhoqx7WQLXoN5eV6e1zrPB1MgHPZVbmW5HixQPcmvqqu2Zk--4sPsngPJBrLXKTcKEJeUM4jGvugkfQMnAIYu4stafM5_lJXwgA-Rvd7DgDzmzpXtfWpBDPsuuoiHyG00Hp9evbru0qHbfKFA4d4KarJTw9F1OHx5b_3H2Z3CGLh33ZZZG6zC2ki4wOg__GKmfw00p6OeRRfNIL_1zr4ZFv6xF0VynZdSOA1_0XtRBv1J-kp4G1YQcn2KwU1i1j2_5CU_dN8_kTC-T2HdF_-ANV37PoNYyZ9TdRuKwArQfBlbHsyD5WC6KDLMGf7MxK-DTMuoPlZh3SP12POTw",
+ "token_type": "Bearer",
+ "expires_in": 299
+}
+
+------------------------------------------------------------------
+== 7. The access token
+------------------------------------------------------------------
+{
+ "alg": "RS256",
+ "kid": "262bd550-3657-46c4-bafc-cce4c6f4e0cb"
+}
+{
+ "aud": "orders-api",
+ "exp": 1787539014,
+ "iat": 1787538714,
+ "iss": "http://localhost:9000",
+ "jti": "1d94acbc-e245-40fb-a75d-05ab3bc294b6",
+ "nbf": 1787538714,
+ "roles": [
+ "ADMIN",
+ "USER"
+ ],
+ "scope": [
+ "openid",
+ "orders.read"
+ ],
+ "sub": "alice",
+ "tenant": "acme"
+}
+
+------------------------------------------------------------------
+== 8. The id_token - a different token, for a different audience
+------------------------------------------------------------------
+{
+ "aud": "demo-spa",
+ "auth_time": 1787538713,
+ "azp": "demo-spa",
+ "exp": 1787540514,
+ "iat": 1787538714,
+ "iss": "http://localhost:9000",
+ "jti": "dc3bbe32-0e2a-450b-a65c-54c443a8570d",
+ "preferred_username": "alice",
+ "sid": "cHooV7cjkG-WKxd-MY2r434ItIaxpqrYGUOlCBV678w",
+ "sub": "alice"
+}
+
+aud is the CLIENT here, not the API. Sending this to a resource server is the
+classic mix-up: it verifies (same issuer, same key) and then fails the audience
+check, or worse, passes it if nobody checks audience.
+
+------------------------------------------------------------------
+== 9. Calling the resource server
+------------------------------------------------------------------
+GET /api/orders -> 200
+{"orders":[{"total":"42.00","id":1}],"subject":"alice","clientId":null,"scopes":["openid","orders.read"],"roles":["ADMIN","USER"],"tenant":"acme","audience":["orders-api"]}
+GET /api/admin -> 200
+{"authorities":["FactorGrantedAuthority [authority=FACTOR_BEARER, issuedAt=2026-08-24T02:31:54.540547140Z]","SCOPE_openid","ROLE_USER","SCOPE_orders.read","ROLE_ADMIN"],"message":"admin only"}
+
+------------------------------------------------------------------
+== 10. Sending the id_token instead
+------------------------------------------------------------------
+HTTP/1.1 401
+WWW-Authenticate: Bearer error="invalid_token", error_description="An error occurred while attempting to decode the Jwt: the required audience orders-api is missing", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1", resource_metadata="http://localhost:8090/.well-known/oauth-protected-resource"
diff --git a/docs/output/as-authcode-web.txt b/docs/output/as-authcode-web.txt
new file mode 100644
index 0000000..81d2030
--- /dev/null
+++ b/docs/output/as-authcode-web.txt
@@ -0,0 +1,155 @@
+
+------------------------------------------------------------------
+== PKCE parameters (RFC 7636)
+------------------------------------------------------------------
+code_verifier D6kPMLmsNKTof_0_UEga6cyBnpBSX5UkfU6eUgWFZW3BJ4iU9OVB5xvNk0hPQsW3 (64 chars)
+code_challenge 0E5PYxr7XERt0s3OvrJY-HsaIhqh7JcFqJIjb8KnZjg
+code_challenge_method S256
+
+The verifier never leaves the client until the token request. The challenge is
+all the authorization request carries, and it is a one-way hash of the verifier.
+
+------------------------------------------------------------------
+== 1. Log in to the authorization server (browser session)
+------------------------------------------------------------------
+$ curl -c jar -d username=alice -d password=password -d _csrf= http://localhost:9000/login
+HTTP/1.1 302
+Location: http://localhost:9000/
+
+------------------------------------------------------------------
+== 2. GET /oauth2/authorize (client=demo-web)
+------------------------------------------------------------------
+$ curl -b jar 'http://localhost:9000/oauth2/authorize?response_type=code&client_id=demo-web&redirect_uri=http%3A%2F%2F127.0.0.1%3A8080%2Flogin%2Foauth2%2Fcode%2Fdemo-web&scope=openid%20orders.read%20orders.write&state=xyz123&code_challenge=0E5PYxr7XERt0s3OvrJY-HsaIhqh7JcFqJIjb8KnZjg&code_challenge_method=S256'
+-> 302 http://localhost:9000/oauth2/consent?scope=orders.write%20openid%20orders.read&client_id=demo-web&state=FzvOBX_5f12Rwa9HAYcy7YjIMz-J-W5qTbcZAYpYl24%3D
+
+------------------------------------------------------------------
+== 3. The consent page
+------------------------------------------------------------------
+The authorization endpoint redirected to OUR page, at the path given to
+.consentPage("/oauth2/consent"). Note the query string it hands over:
+ http://localhost:9000/oauth2/consent?scope=orders.write%20openid%20orders.read
+ client_id=demo-web
+ state=FzvOBX_5f12Rwa9HAYcy7YjIMz-J-W5qTbcZAYpYl24%3D
+
+Scopes rendered as checkboxes (openid deliberately not among them):
+ orders.write
+ orders.read
+
+The hidden state the form must echo back: FzvOBX_5f12Rwa9HAYcy7YjIMz-J-W5qTbcZAYpYl24=
+(this is NOT the client's state=xyz123 - it is the server's own correlation
+ handle for the pending authorization request, and sending the client's value
+ instead is what produces the consent redirect loop)
+
+------------------------------------------------------------------
+== 4. POST the approval to /oauth2/authorize
+------------------------------------------------------------------
+$ curl -b jar -X POST -d client_id=demo-web -d state=FzvOBX_5f12Rwa9HAYcy7YjIMz-J-W5qTbcZAYpYl24= -d _csrf=P_NC_egjNFLN-1IOgLWK3N9qIgIvnhma_LGeHCLVKyZKXF1SB5J7zdgXUGHgzmU7tZi-7epcDztK-y-3ndStLBSwSRR5Pm5j -d scope=orders.write -d scope=orders.read http://localhost:9000/oauth2/authorize
+-> 302 http://127.0.0.1:8080/login/oauth2/code/demo-web?code=7K02csgk4cAepvRDnCiDqNA9gOVLCSGnjU-ByFlWBaHdFxe1byEXN14iQ3UOAMn_rWnY_jUz3xWAeeYke2UA8G74BcAhgzlmaRkxIAs6e6MywPQz-6eJ6H5XB6okDZVT&state=xyz123
+
+------------------------------------------------------------------
+== 5. The authorization code
+------------------------------------------------------------------
+code = 7K02csgk4cAepvRDnCiDqNA9gOVLCSGnjU-ByFlWBaHdFxe1byEXN14iQ3UOAMn_rWnY_jUz3xWAeeYke2UA8G74BcAhgzlmaRkxIAs6e6MywPQz-6eJ6H5XB6okDZVT
+state = xyz123 (the client's own value, returned untouched - compare it)
+
+------------------------------------------------------------------
+== 6a. Exchange the code WITHOUT the verifier
+------------------------------------------------------------------
+This is the request an attacker who stole the code can make.
+HTTP 400
+{
+ "error": "invalid_grant"
+}
+
+>>> Rejected. invalid_grant is deliberately vague: the server will not tell
+>>> a caller whether the code was wrong, expired, already used, or missing a
+>>> verifier, because each of those is information an attacker can use.
+Note: this consumed the code. Authorization codes are single-use, so the
+successful exchange below needs a fresh one.
+
+------------------------------------------------------------------
+== 6b. A fresh code, exchanged properly
+------------------------------------------------------------------
+fresh code = dR8FdRofdCXW63nskaWklVZAALCEFP4eG8kNmo6KctU7_KUBzxyPenduDcuFwP3ek7ohYsf5Mff7zVbfkyzPaYhC-k-x9hAlteWsQNFkmZO6xZQyaCMaUp-H3DLPn4M3
+
+$ curl -d grant_type=authorization_code -d code=... -d code_verifier=... http://localhost:9000/oauth2/token
+HTTP 200
+{
+ "access_token": "eyJraWQiOiIyNjJiZDU1MC0zNjU3LTQ2YzQtYmFmYy1jY2U0YzZmNGUwY2IiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJhbGljZSIsImF1ZCI6Im9yZGVycy1hcGkiLCJuYmYiOjE3ODc1Mzg3MTUsInNjb3BlIjpbIm9yZGVycy53cml0ZSIsIm9wZW5pZCIsIm9yZGVycy5yZWFkIl0sInJvbGVzIjpbIkFETUlOIiwiVVNFUiJdLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjkwMDAiLCJleHAiOjE3ODc1MzkwMTUsImlhdCI6MTc4NzUzODcxNSwianRpIjoiYjQxNzljNDYtZTA1NS00YjY0LWJhNTktZjE1ZjkyYTkyY2QxIiwidGVuYW50IjoiYWNtZSJ9.LdqBUotxR3briYnuL56ZuFHKmKCr1hehZtzO-7sMa9sclA30jTsvn2sadm-kj9vnk0HRJP0WyR2UX7NGl1quIGlUlSN_0YqV4gTYbfKSIhAV3JilNeXXgSIe2X04UjjnesOxp2Ui-Umk5v3zpZqeSM0ZJtTafDyBXMNA_I3n5CzJ1_AiYFQ9DxvfN0pQCN-hik2gP1a_u9l1sSD0r_Su8YCtReYue37wA2tGgviA1mRMM4xaDOCSyGmie41kq4Bj0K9a8bOCSHEZ9CGnjmVTQENF3ZhkPjX_EnkDvzLcBZh84DtEue6ddp5jdWH5DSJTk8YKipvNqL6eK3CGjX4Byw",
+ "refresh_token": "8BsQgRSdo4MCHf2sZb9-paqL0tOhYGYOfpYxdJ659LUefpK3csiabvBV5JyNaE9PZuweNEADbNuTtbfHIVtinxyaoH871wqd3YXTVHOQCMZ46_FUM9CkicHeRU17TL_E",
+ "scope": "orders.write openid orders.read",
+ "id_token": "eyJraWQiOiIyNjJiZDU1MC0zNjU3LTQ2YzQtYmFmYy1jY2U0YzZmNGUwY2IiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJhbGljZSIsImF1ZCI6ImRlbW8td2ViIiwiYXpwIjoiZGVtby13ZWIiLCJhdXRoX3RpbWUiOjE3ODc1Mzg3MTQsImlzcyI6Imh0dHA6Ly9sb2NhbGhvc3Q6OTAwMCIsInByZWZlcnJlZF91c2VybmFtZSI6ImFsaWNlIiwiZXhwIjoxNzg3NTQwNTE1LCJpYXQiOjE3ODc1Mzg3MTUsImp0aSI6IjQwZmJjMGEwLTMyNTQtNDFkMC04M2Q1LTJlOTBhZjcxZmY2MyIsInNpZCI6Ilk0Zk5vZjQ3bldodlFMSjB4Z3lRX3BPbTdfVERxTXAyMm1tamg0Mjl2Sm8ifQ.BmuLT6VfjSP_EUPSLKwnkGVAThNHDv-9Z0uRnlGNC39nJL2P8SbgDYB7pWxy0eLQ8mqi4iwyoL9faFLFGaqAvndaPqLSSiWmJ5L4PfCGEW7PTa0vaRjgO-MHtFdAvlUfltWzm39nskyj0Q94QNNv5p7ZW7NAwBFwRL9IFzPzxi80IivvdLwgPZEHOKc6DLAmtrpTOgTkucOIwW_FF3FWbOJ4XgqT5dApqdon74ikq_8ZcwotqhVlkv2Z1VfiSLj4OBh9t35McLah4UszvAl8aYQlEkY6xrzoal8bWqpaJOMJs709-cd13NCv58WV45BbVMakphc_jk0XM7rXIhC45w",
+ "token_type": "Bearer",
+ "expires_in": 299
+}
+
+------------------------------------------------------------------
+== 7. The access token
+------------------------------------------------------------------
+{
+ "alg": "RS256",
+ "kid": "262bd550-3657-46c4-bafc-cce4c6f4e0cb"
+}
+{
+ "aud": "orders-api",
+ "exp": 1787539015,
+ "iat": 1787538715,
+ "iss": "http://localhost:9000",
+ "jti": "b4179c46-e055-4b64-ba59-f15f92a92cd1",
+ "nbf": 1787538715,
+ "roles": [
+ "ADMIN",
+ "USER"
+ ],
+ "scope": [
+ "orders.write",
+ "openid",
+ "orders.read"
+ ],
+ "sub": "alice",
+ "tenant": "acme"
+}
+
+------------------------------------------------------------------
+== 8. The id_token - a different token, for a different audience
+------------------------------------------------------------------
+{
+ "aud": "demo-web",
+ "auth_time": 1787538714,
+ "azp": "demo-web",
+ "exp": 1787540515,
+ "iat": 1787538715,
+ "iss": "http://localhost:9000",
+ "jti": "40fbc0a0-3254-41d0-83d5-2e90af71ff63",
+ "preferred_username": "alice",
+ "sid": "Y4fNof47nWhvQLJ0xgyQ_pOm7_TDqMp22mmjh429vJo",
+ "sub": "alice"
+}
+
+aud is the CLIENT here, not the API. Sending this to a resource server is the
+classic mix-up: it verifies (same issuer, same key) and then fails the audience
+check, or worse, passes it if nobody checks audience.
+
+------------------------------------------------------------------
+== 9. Calling the resource server
+------------------------------------------------------------------
+GET /api/orders -> 200
+{"orders":[{"total":"42.00","id":1}],"subject":"alice","clientId":null,"scopes":["orders.write","openid","orders.read"],"roles":["ADMIN","USER"],"tenant":"acme","audience":["orders-api"]}
+GET /api/admin -> 200
+{"authorities":["FactorGrantedAuthority [authority=FACTOR_BEARER, issuedAt=2026-08-24T02:31:55.262560965Z]","SCOPE_openid","ROLE_USER","SCOPE_orders.read","ROLE_ADMIN","SCOPE_orders.write"],"message":"admin only"}
+
+------------------------------------------------------------------
+== 10. Sending the id_token instead
+------------------------------------------------------------------
+HTTP/1.1 401
+WWW-Authenticate: Bearer error="invalid_token", error_description="An error occurred while attempting to decode the Jwt: the required audience orders-api is missing", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1", resource_metadata="http://localhost:8090/.well-known/oauth-protected-resource"
+
+------------------------------------------------------------------
+== 11. Refresh, with rotation
+------------------------------------------------------------------
+old refresh token: 8BsQgRSdo4MCHf2sZb9-paqL...
+new refresh token: aPYpv9v2EcgMOzCvQdhMLycP...
+DIFFERENT - reuseRefreshTokens(false), the old one is now dead
+
+Replaying the old one:
+{"error":"invalid_grant"}
diff --git a/docs/output/as-client-credentials-noclaims.txt b/docs/output/as-client-credentials-noclaims.txt
new file mode 100644
index 0000000..e5ba4ca
--- /dev/null
+++ b/docs/output/as-client-credentials-noclaims.txt
@@ -0,0 +1,67 @@
+
+------------------------------------------------------------------
+== POST /oauth2/token grant_type=client_credentials
+------------------------------------------------------------------
+$ curl -su demo-service:service-secret -d grant_type=client_credentials \
+ -d scope=orders.read http://localhost:9000/oauth2/token
+{
+ "access_token": "eyJraWQiOiIyNDgwNWM5Ni02MGY1LTQ5MDItYTczYi03ODgxYmFkNWY5ZGMiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJkZW1vLXNlcnZpY2UiLCJhdWQiOiJkZW1vLXNlcnZpY2UiLCJuYmYiOjE3ODc1Mzg3MzksInNjb3BlIjpbIm9yZGVycy5yZWFkIl0sImlzcyI6Imh0dHA6Ly9sb2NhbGhvc3Q6OTAwMCIsImV4cCI6MTc4NzUzOTMzOSwiaWF0IjoxNzg3NTM4NzM5LCJqdGkiOiIzN2E0NTlmMS0xZjdlLTQ5ZTktOWQyNi01YmY0MjM5YmM1ZTkifQ.UxWq_Cs1bBhqQicgcLU7Z-vYW-jWxuVbrmco1gmXM8cxRvRjXoNpaDkYCiTI51gu21K9mXsOxf45l5dg7Rh9Gku7fNWJeKcWll5Ekcjpgq9msCwjLNPVxbuDVV8K-2f8OcPJ1Y6ojDtqxQq9RCBKEMxuBhl1Plz8nMjUYUm1A-njz43wL9SDJslz2xiIgoEkLkiRyVdk4ArzWGOQ4WLKR_y-bIn0dIhyTh4bVDy4rf2LTFqyPl_ZTCAH_ZUqXZuJhFn73MpxEaavwIHl-b8EDCpyk2vCUQnSykIMEaAbEPs0JBNaBpah-lPR0FFZIr2vZQxI4xpxButI98W2d9CtBQ",
+ "scope": "orders.read",
+ "token_type": "Bearer",
+ "expires_in": 599
+}
+
+------------------------------------------------------------------
+== JOSE header
+------------------------------------------------------------------
+{
+ "alg": "RS256",
+ "kid": "24805c96-60f5-4902-a73b-7881bad5f9dc"
+}
+
+------------------------------------------------------------------
+== Claims
+------------------------------------------------------------------
+{
+ "aud": "demo-service",
+ "exp": 1787539339,
+ "iat": 1787538739,
+ "iss": "http://localhost:9000",
+ "jti": "37a459f1-1f7e-49e9-9d26-5bf4239bc5e9",
+ "nbf": 1787538739,
+ "scope": [
+ "orders.read"
+ ],
+ "sub": "demo-service"
+}
+
+------------------------------------------------------------------
+== Wrong secret
+------------------------------------------------------------------
+$ curl -si -u demo-service:WRONG -d grant_type=client_credentials http://localhost:9000/oauth2/token
+HTTP/1.1 401
+{"error":"invalid_client"}
+------------------------------------------------------------------
+== A grant the client is not registered for
+------------------------------------------------------------------
+$ curl -si -u demo-service:service-secret -d grant_type=authorization_code -d code=x http://localhost:9000/oauth2/token
+HTTP/1.1 400
+{"error":"invalid_grant"}
+------------------------------------------------------------------
+== A scope the client is not registered for
+------------------------------------------------------------------
+$ curl -s -u demo-service:service-secret -d grant_type=client_credentials -d scope=orders.write http://localhost:9000/oauth2/token
+{"error":"invalid_scope"}
+
+------------------------------------------------------------------
+== Calling the resource server with the token
+------------------------------------------------------------------
+GET /public -> 401
+ WWW-Authenticate: Bearer error="invalid_token", error_description="An error occurred while attempting to decode the Jwt: the required audience orders-api is missing", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1", resource_metadata="http://localhost:8090/.well-known/oauth-protected-resource"
+
+GET /api/orders -> 401
+ WWW-Authenticate: Bearer error="invalid_token", error_description="An error occurred while attempting to decode the Jwt: the required audience orders-api is missing", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1", resource_metadata="http://localhost:8090/.well-known/oauth-protected-resource"
+
+GET /api/admin -> 401
+ WWW-Authenticate: Bearer error="invalid_token", error_description="An error occurred while attempting to decode the Jwt: the required audience orders-api is missing", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1", resource_metadata="http://localhost:8090/.well-known/oauth-protected-resource"
+
diff --git a/docs/output/as-client-credentials-opaque.txt b/docs/output/as-client-credentials-opaque.txt
new file mode 100644
index 0000000..7a09a88
--- /dev/null
+++ b/docs/output/as-client-credentials-opaque.txt
@@ -0,0 +1,68 @@
+
+------------------------------------------------------------------
+== POST /oauth2/token grant_type=client_credentials
+------------------------------------------------------------------
+$ curl -su demo-service:service-secret -d grant_type=client_credentials \
+ -d scope=orders.read http://localhost:9000/oauth2/token
+{
+ "access_token": "DE0Ps3Jo6f6IdZ1Jd6bqBg_HnYTatIanVFutxdNihyAkHjlgMHWElxo4TGiWI9oNkxgdOAZZu6vgW9BXZrd_KkYVkKrV238fmHUCD9Xpz0_U3k55Brs9fXcMwxxTKGWa",
+ "scope": "orders.read",
+ "token_type": "Bearer",
+ "expires_in": 599
+}
+
+------------------------------------------------------------------
+== Not a JWT
+------------------------------------------------------------------
+The access token is an opaque reference: DE0Ps3Jo6f6IdZ1Jd6bqBg_HnYTatIanVFutxdNihyAkHjlgMHWElxo4TGiWI9oNkxgdOAZZu6vgW9BXZrd_KkYVkKrV238fmHUCD9Xpz0_U3k55Brs9fXcMwxxTKGWa
+Length 128. It carries no claims; the resource server must introspect it.
+
+------------------------------------------------------------------
+== POST /oauth2/introspect
+------------------------------------------------------------------
+{
+ "active": true,
+ "sub": "demo-service",
+ "aud": [
+ "demo-service"
+ ],
+ "nbf": 1787538776,
+ "scope": "orders.read",
+ "iss": "http://localhost:9000",
+ "exp": 1787539376,
+ "iat": 1787538776,
+ "jti": "9d96a819-4f1e-4efb-8816-4523bb6def61",
+ "client_id": "demo-service",
+ "token_type": "Bearer"
+}
+
+------------------------------------------------------------------
+== Wrong secret
+------------------------------------------------------------------
+$ curl -si -u demo-service:WRONG -d grant_type=client_credentials http://localhost:9000/oauth2/token
+HTTP/1.1 401
+{"error":"invalid_client"}
+------------------------------------------------------------------
+== A grant the client is not registered for
+------------------------------------------------------------------
+$ curl -si -u demo-service:service-secret -d grant_type=authorization_code -d code=x http://localhost:9000/oauth2/token
+HTTP/1.1 400
+{"error":"invalid_grant"}
+------------------------------------------------------------------
+== A scope the client is not registered for
+------------------------------------------------------------------
+$ curl -s -u demo-service:service-secret -d grant_type=client_credentials -d scope=orders.write http://localhost:9000/oauth2/token
+{"error":"invalid_scope"}
+
+------------------------------------------------------------------
+== Calling the resource server with the token
+------------------------------------------------------------------
+GET /public -> 401
+ WWW-Authenticate: Bearer error="invalid_token", error_description="An error occurred while attempting to decode the Jwt: Malformed token", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1", resource_metadata="http://localhost:8090/.well-known/oauth-protected-resource"
+
+GET /api/orders -> 401
+ WWW-Authenticate: Bearer error="invalid_token", error_description="An error occurred while attempting to decode the Jwt: Malformed token", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1", resource_metadata="http://localhost:8090/.well-known/oauth-protected-resource"
+
+GET /api/admin -> 401
+ WWW-Authenticate: Bearer error="invalid_token", error_description="An error occurred while attempting to decode the Jwt: Malformed token", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1", resource_metadata="http://localhost:8090/.well-known/oauth-protected-resource"
+
diff --git a/docs/output/as-client-credentials.txt b/docs/output/as-client-credentials.txt
new file mode 100644
index 0000000..562ddad
--- /dev/null
+++ b/docs/output/as-client-credentials.txt
@@ -0,0 +1,66 @@
+
+------------------------------------------------------------------
+== POST /oauth2/token grant_type=client_credentials
+------------------------------------------------------------------
+$ curl -su demo-service:service-secret -d grant_type=client_credentials \
+ -d scope=orders.read http://localhost:9000/oauth2/token
+{
+ "access_token": "eyJraWQiOiIyNjJiZDU1MC0zNjU3LTQ2YzQtYmFmYy1jY2U0YzZmNGUwY2IiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJkZW1vLXNlcnZpY2UiLCJhdWQiOiJvcmRlcnMtYXBpIiwibmJmIjoxNzg3NTM4NzEzLCJzY29wZSI6WyJvcmRlcnMucmVhZCJdLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjkwMDAiLCJleHAiOjE3ODc1MzkzMTMsImlhdCI6MTc4NzUzODcxMywianRpIjoiYWZkMDFkNTYtMTlhYS00MDk1LWJjYmYtZGQxMjlmNGNlMTRkIiwidGVuYW50IjoiYWNtZSJ9.P63TpKkWrcqxnCTVtTw3XRlmWRromcCmrynK1k1vWM9SoXzAvT7Pu03-JtwrH8b3-Js2QylXGiP2cah2HZHwNNNlft0zpIwosNtIWxSEVI4K5_M5IgAALgCqlwXs3rIFRvXGY5IyPXDJNkUslHO2OMQqdonHO8JL1cSLLe6MQKcKPS9jc-byqaLHgyYtAhO7acCmKSvzmP1kTN6cE33FtEOlCg_9HHB6hwphl5e2Sbacc8wPZU8pyGBD02QymvlH0LUMC-b2F0pnGka0os1pYL6cVI48irvKK6hhty-l7CNOfhjKJRaGwMHf4SAoFT2TBmzAKArNrJY2o_zganeZEg",
+ "scope": "orders.read",
+ "token_type": "Bearer",
+ "expires_in": 599
+}
+
+------------------------------------------------------------------
+== JOSE header
+------------------------------------------------------------------
+{
+ "alg": "RS256",
+ "kid": "262bd550-3657-46c4-bafc-cce4c6f4e0cb"
+}
+
+------------------------------------------------------------------
+== Claims
+------------------------------------------------------------------
+{
+ "aud": "orders-api",
+ "exp": 1787539313,
+ "iat": 1787538713,
+ "iss": "http://localhost:9000",
+ "jti": "afd01d56-19aa-4095-bcbf-dd129f4ce14d",
+ "nbf": 1787538713,
+ "scope": [
+ "orders.read"
+ ],
+ "sub": "demo-service",
+ "tenant": "acme"
+}
+
+------------------------------------------------------------------
+== Wrong secret
+------------------------------------------------------------------
+$ curl -si -u demo-service:WRONG -d grant_type=client_credentials http://localhost:9000/oauth2/token
+HTTP/1.1 401
+{"error":"invalid_client"}
+------------------------------------------------------------------
+== A grant the client is not registered for
+------------------------------------------------------------------
+$ curl -si -u demo-service:service-secret -d grant_type=authorization_code -d code=x http://localhost:9000/oauth2/token
+HTTP/1.1 400
+{"error":"invalid_grant"}
+------------------------------------------------------------------
+== A scope the client is not registered for
+------------------------------------------------------------------
+$ curl -s -u demo-service:service-secret -d grant_type=client_credentials -d scope=orders.write http://localhost:9000/oauth2/token
+{"error":"invalid_scope"}
+
+------------------------------------------------------------------
+== Calling the resource server with the token
+------------------------------------------------------------------
+GET /public -> 200
+{"message":"no token required"}
+GET /api/orders -> 200
+{"orders":[{"total":"42.00","id":1}],"subject":"demo-service","clientId":null,"scopes":["orders.read"],"roles":null,"tenant":"acme","audience":["orders-api"]}
+GET /api/admin -> 403
+ WWW-Authenticate: Bearer error="insufficient_scope", error_description="The request requires higher privileges than provided by the access token.", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1"
+
diff --git a/docs/output/as-client-flow-nopkce.txt b/docs/output/as-client-flow-nopkce.txt
new file mode 100644
index 0000000..ac2a514
--- /dev/null
+++ b/docs/output/as-client-flow-nopkce.txt
@@ -0,0 +1,35 @@
+
+------------------------------------------------------------------
+== The relying party drives the flow [confidential client, no PKCE - the Boot default]
+------------------------------------------------------------------
+GET http://127.0.0.1:8080/orders while unauthenticated. Every hop below is a real redirect.
+
+ 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?response_type=code&client_id=demo-web&scope=orders.write%20openid%20profile%20orders.read&state=ONw1L2XOM3BG7j5kgeISWex_263H9-sYOJeG5lcrGv8%3D&redirect_uri=http://127.0.0.1:8080/login/oauth2/code/demo-web&nonce=VSauV4DIo1T-zZKYq8as0fEwQjqmhx462L3sCCBnAHk
+ 302 http://127.0.0.1:8080/login/oauth2/code/demo-web?error=invalid_request&error_description=OAuth%202.0%20Parameter%3A%20code_challenge&error_uri=https%3A%2F%2Fdatatracker.ietf.org%2Fdoc%2Fhtml%2Frfc7636%23section-4.4.1&state=ONw1L2XOM3BG7j5kgeISWex_263H9-sYOJeG5lcrGv8%3D
+ 200 http://127.0.0.1:8080/login?error
+
+The authorization request the client built:
+ http://localhost:9000/oauth2/authorize
+ response_type=code
+ client_id=demo-web
+ scope=orders.write%20openid%20profile%20orders.read
+ state=ONw1L2XOM3BG7j5kgeISWex_263H9-sYOJeG5lcrGv8%3D
+ redirect_uri=http://127.0.0.1:8080/login/oauth2/code/demo-web
+ nonce=VSauV4DIo1T-zZKYq8as0fEwQjqmhx462L3sCCBnAHk
+ >>> NO code_challenge - a client registered with
+ >>> requireProofKey(true) will reject this outright
+
+The flow ended at the CLIENT's error page, not the provider's. The provider
+rejected the authorization request and redirected the failure back to the
+registered redirect_uri, so nothing in the client's logs names the provider
+as the cause. The reason is only in the query string above.
+
+------------------------------------------------------------------
+== What the client rendered
+------------------------------------------------------------------
+Please sign in
+Login with OAuth 2.0
+Invalid credentials
+http://localhost:9000
diff --git a/docs/output/as-client-flow.txt b/docs/output/as-client-flow.txt
new file mode 100644
index 0000000..a052089
--- /dev/null
+++ b/docs/output/as-client-flow.txt
@@ -0,0 +1,50 @@
+
+------------------------------------------------------------------
+== The relying party drives the flow [client sends PKCE]
+------------------------------------------------------------------
+GET http://127.0.0.1:8080/orders while unauthenticated. Every hop below is a real redirect.
+
+ 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?response_type=code&client_id=demo-web&scope=openid%20profile%20orders.read%20orders.write&state=Fp-5wpXOPVoiWsvQQ3CiBOPxfQW759f5XzpAjesCqzw%3D&redirect_uri=http://127.0.0.1:8080/login/oauth2/code/demo-web&nonce=gTVVonl0f_lFOa4XBskyk0Rn2RwgiDLPbkni3UhL5Js&code_challenge=PkejOsdpC8l7T_ZRKp7LyJOmKrbAvZy8bw2OGAwZIlI&code_challenge_method=S256
+ 200 http://localhost:9000/login
+
+The authorization request the client built:
+ http://localhost:9000/oauth2/authorize
+ response_type=code
+ client_id=demo-web
+ scope=openid%20profile%20orders.read%20orders.write
+ state=Fp-5wpXOPVoiWsvQQ3CiBOPxfQW759f5XzpAjesCqzw%3D
+ redirect_uri=http://127.0.0.1:8080/login/oauth2/code/demo-web
+ nonce=gTVVonl0f_lFOa4XBskyk0Rn2RwgiDLPbkni3UhL5Js
+ code_challenge=PkejOsdpC8l7T_ZRKp7LyJOmKrbAvZy8bw2OGAwZIlI
+ code_challenge_method=S256
+ >>> code_challenge IS present
+
+Landed on the authorization server's login page. Submitting credentials:
+ 302 POST http://localhost:9000/login
+
+Resuming the authorization request:
+ 302 http://localhost:9000/oauth2/authorize?response_type=code&client_id=demo-web&scope=openid%20profile%20orders.read%20orders.write&state=Fp-5wpXOPVoiWsvQQ3CiBOPxfQW759f5XzpAjesCqzw%3D&redirect_uri=http://127.0.0.1:8080/login/oauth2/code/demo-web&nonce=gTVVonl0f_lFOa4XBskyk0Rn2RwgiDLPbkni3UhL5Js&code_challenge=PkejOsdpC8l7T_ZRKp7LyJOmKrbAvZy8bw2OGAwZIlI&code_challenge_method=S256&continue
+ 200 http://localhost:9000/oauth2/consent?scope=orders.write%20openid%20profile%20orders.read&client_id=demo-web&state=es7hi90inWtTT6_LtEb7zFjBGcmoAvOXR9mR4SW1Atw%3D
+
+Consent page reached. Approving:
+ 302 POST http://localhost:9000/oauth2/authorize
+
+Back to the client with the code:
+ 302 http://127.0.0.1:8080/login/oauth2/code/demo-web?code=u_QjA35AcYq3OlGDu6skLcZndAM8JAKlMxDiyMrCw-AFr0an52L6PNGVyfI6eYeM8gZogMNRnG0DeZGFKyeeVF74F82yeY47qI5GRmX1BYNnPYt7PiEl8MorixXZI89q&state=Fp-5wpXOPVoiWsvQQ3CiBOPxfQW759f5XzpAjesCqzw%3D
+ 200 http://127.0.0.1:8080/orders?continue
+
+------------------------------------------------------------------
+== What the client rendered
+------------------------------------------------------------------
+Orders
+body{font-family:system-ui,sans-serif;max-width:44rem;margin:3rem auto;color:#222}
+pre{background:#f4f5f7;padding:1rem;border-radius:6px;overflow:auto;word-break:break-all;white-space:pre-wrap}
+Resource server response
+{orders=[{total=42.00, id=1}], subject=alice, clientId=null, scopes=[orders.write, openid, profile, orders.read], roles=[ADMIN, USER], tenant=acme, audience=[orders-api]}
+Granted scopes
+[orders.write, openid, profile, orders.read]
+Access token (raw)
+eyJraWQiOiIyNjJiZDU1MC0zNjU3LTQ2YzQtYmFmYy1jY2U0YzZmNGUwY2IiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJhbGljZSIsImF1ZCI6Im9yZGVycy1hcGkiLCJuYmYiOjE3ODc1Mzg3MjMsInNjb3BlIjpbIm9yZGVycy53cml0ZSIsIm9wZW5pZCIsInByb2ZpbGUiLCJvcmRlcnMucmVhZCJdLCJyb2xlcyI6WyJBRE1JTiIsIlVTRVIiXSwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo5MDAwIiwiZXhwIjoxNzg3NTM5MDIzLCJpYXQiOjE3ODc1Mzg3MjMsImp0aSI6ImU2MGJkZWVhLTJhYmUtNDdhNC04YjI5LWQxYjhmYjA0MzBhMyIsInRlbmFudCI6ImFjbWUifQ.bR3y7idlv63l4zIyXQKu9pjfaHXlFWaaIVupi4eITuWaMFJMk4S-V96CyajRWzDEur7K0wBxvlRNieBWoLetZ2_sfKexbfYfrJjjSzcZvtvtWygA7prqBspDzIEKOlHfSckh0Y1d7c6hQhyCTU3Z5SuCi8_ZiFPxT1bHBxSnqtr4j3K5a4yc_6E93e3M5-VCkuMpKJiiDVxw4bAeqbegp8LOSxChLmmoz9sGJ9HeMLUrjjHvtQZ1ZFM8q9eAhA0KvDXukWFPxfq7UUfbDMqLNcQdHGcwUTAgwIklO6MsCfvtLQRUaN2KsX-FWjHnG49C_jLT1h5CRcSKdCnqKuxrAw
+back
diff --git a/docs/output/as-discovery.txt b/docs/output/as-discovery.txt
new file mode 100644
index 0000000..227743d
--- /dev/null
+++ b/docs/output/as-discovery.txt
@@ -0,0 +1,240 @@
+
+------------------------------------------------------------------
+== OpenID Connect discovery: GET /.well-known/openid-configuration
+------------------------------------------------------------------
+$ curl -s http://localhost:9000/.well-known/openid-configuration
+{
+ "issuer": "http://localhost:9000",
+ "authorization_endpoint": "http://localhost:9000/oauth2/authorize",
+ "token_endpoint": "http://localhost:9000/oauth2/token",
+ "token_endpoint_auth_methods_supported": [
+ "client_secret_basic",
+ "client_secret_post",
+ "client_secret_jwt",
+ "private_key_jwt",
+ "tls_client_auth",
+ "self_signed_tls_client_auth"
+ ],
+ "jwks_uri": "http://localhost:9000/oauth2/jwks",
+ "userinfo_endpoint": "http://localhost:9000/userinfo",
+ "end_session_endpoint": "http://localhost:9000/connect/logout",
+ "response_types_supported": [
+ "code"
+ ],
+ "grant_types_supported": [
+ "authorization_code",
+ "client_credentials",
+ "refresh_token",
+ "urn:ietf:params:oauth:grant-type:token-exchange"
+ ],
+ "revocation_endpoint": "http://localhost:9000/oauth2/revoke",
+ "revocation_endpoint_auth_methods_supported": [
+ "client_secret_basic",
+ "client_secret_post",
+ "client_secret_jwt",
+ "private_key_jwt",
+ "tls_client_auth",
+ "self_signed_tls_client_auth"
+ ],
+ "introspection_endpoint": "http://localhost:9000/oauth2/introspect",
+ "introspection_endpoint_auth_methods_supported": [
+ "client_secret_basic",
+ "client_secret_post",
+ "client_secret_jwt",
+ "private_key_jwt",
+ "tls_client_auth",
+ "self_signed_tls_client_auth"
+ ],
+ "code_challenge_methods_supported": [
+ "S256"
+ ],
+ "tls_client_certificate_bound_access_tokens": true,
+ "dpop_signing_alg_values_supported": [
+ "RS256",
+ "RS384",
+ "RS512",
+ "PS256",
+ "PS384",
+ "PS512",
+ "ES256",
+ "ES384",
+ "ES512"
+ ],
+ "subject_types_supported": [
+ "public"
+ ],
+ "id_token_signing_alg_values_supported": [
+ "RS256"
+ ],
+ "scopes_supported": [
+ "openid"
+ ]
+}
+
+------------------------------------------------------------------
+== OAuth2 metadata: GET /.well-known/oauth-authorization-server
+------------------------------------------------------------------
+Present even with .oidc(...) switched off. The OIDC document above is the one
+that additionally advertises userinfo_endpoint and id_token signing algorithms.
+$ curl -s http://localhost:9000/.well-known/oauth-authorization-server
+{
+ "issuer": "http://localhost:9000",
+ "authorization_endpoint": "http://localhost:9000/oauth2/authorize",
+ "token_endpoint": "http://localhost:9000/oauth2/token",
+ "token_endpoint_auth_methods_supported": [
+ "client_secret_basic",
+ "client_secret_post",
+ "client_secret_jwt",
+ "private_key_jwt",
+ "tls_client_auth",
+ "self_signed_tls_client_auth"
+ ],
+ "jwks_uri": "http://localhost:9000/oauth2/jwks",
+ "response_types_supported": [
+ "code"
+ ],
+ "grant_types_supported": [
+ "authorization_code",
+ "client_credentials",
+ "refresh_token",
+ "urn:ietf:params:oauth:grant-type:token-exchange"
+ ],
+ "revocation_endpoint": "http://localhost:9000/oauth2/revoke",
+ "revocation_endpoint_auth_methods_supported": [
+ "client_secret_basic",
+ "client_secret_post",
+ "client_secret_jwt",
+ "private_key_jwt",
+ "tls_client_auth",
+ "self_signed_tls_client_auth"
+ ],
+ "introspection_endpoint": "http://localhost:9000/oauth2/introspect",
+ "introspection_endpoint_auth_methods_supported": [
+ "client_secret_basic",
+ "client_secret_post",
+ "client_secret_jwt",
+ "private_key_jwt",
+ "tls_client_auth",
+ "self_signed_tls_client_auth"
+ ],
+ "code_challenge_methods_supported": [
+ "S256"
+ ],
+ "tls_client_certificate_bound_access_tokens": true,
+ "dpop_signing_alg_values_supported": [
+ "RS256",
+ "RS384",
+ "RS512",
+ "PS256",
+ "PS384",
+ "PS512",
+ "ES256",
+ "ES384",
+ "ES512"
+ ]
+}
+
+------------------------------------------------------------------
+== JWK Set: GET /oauth2/jwks
+------------------------------------------------------------------
+Public keys only. No 'd' member - if you ever see one here, stop the server.
+{
+ "keys": [
+ {
+ "kty": "RSA",
+ "e": "AQAB",
+ "kid": "262bd550-3657-46c4-bafc-cce4c6f4e0cb",
+ "n": "pFCIstpVnGQm7Mp8bskE_-_Rz-oI6mPaiyQLiWMBuVip4fkKYwapZVbsZf9fmy1w1KXWIdtXOhe-fqa3-KqYzsrje-o2u6_D27rqR1Z0I9ezbDfw2A4Gsj5AlmnXzWMLnNMzSueSf8luRk04MHO4bGzXLqQ1gOltnqMkMAQzhCqWFZmKxNJeaB4FlXOtxqzcso0eeKsPzRjZvTamgU5TvGUZmQ4LTKTkoYzB3kjvCspVpZAbdVR01RlmzfTJB0tVIc0ioMk1YZHUx27TPN1W8Nw1AaAYmV9URaf2fgz2Ja3y_Lj8hmXuQAcPOGBmoVWX0QkW4DTSAPW0xiPHqpjw9Q"
+ }
+ ]
+}
+
+------------------------------------------------------------------
+== Resolved endpoint settings, read back from AuthorizationServerSettings
+------------------------------------------------------------------
+{
+ "settings.authorization-server.client-registration-endpoint": "/oauth2/register",
+ "settings.authorization-server.authorization-endpoint": "/oauth2/authorize",
+ "settings.authorization-server.token-endpoint": "/oauth2/token",
+ "settings.authorization-server.device-verification-endpoint": "/oauth2/device_verification",
+ "settings.authorization-server.oidc-user-info-endpoint": "/userinfo",
+ "settings.authorization-server.pushed-authorization-request-endpoint": "/oauth2/par",
+ "settings.authorization-server.oidc-client-registration-endpoint": "/connect/register",
+ "settings.authorization-server.oidc-logout-endpoint": "/connect/logout",
+ "settings.authorization-server.issuer": "http://localhost:9000",
+ "settings.authorization-server.multiple-issuers-allowed": false,
+ "settings.authorization-server.device-authorization-endpoint": "/oauth2/device_authorization",
+ "settings.authorization-server.jwk-set-endpoint": "/oauth2/jwks",
+ "settings.authorization-server.token-revocation-endpoint": "/oauth2/revoke",
+ "settings.authorization-server.token-introspection-endpoint": "/oauth2/introspect"
+}
+
+------------------------------------------------------------------
+== Registered clients, as the server actually holds them
+------------------------------------------------------------------
+[
+ {
+ "clientId": "demo-web",
+ "authenticationMethods": [
+ "client_secret_basic"
+ ],
+ "grantTypes": [
+ "refresh_token",
+ "authorization_code"
+ ],
+ "redirectUris": [
+ "http://127.0.0.1:8080/login/oauth2/code/demo-web"
+ ],
+ "scopes": [
+ "orders.write",
+ "openid",
+ "profile",
+ "orders.read"
+ ],
+ "requireProofKey": true,
+ "requireAuthorizationConsent": true,
+ "accessTokenFormat": "self-contained",
+ "accessTokenTtlSeconds": 300,
+ "reuseRefreshTokens": false
+ },
+ {
+ "clientId": "demo-spa",
+ "authenticationMethods": [
+ "none"
+ ],
+ "grantTypes": [
+ "refresh_token",
+ "authorization_code"
+ ],
+ "redirectUris": [
+ "http://127.0.0.1:8080/authorized"
+ ],
+ "scopes": [
+ "openid",
+ "orders.read"
+ ],
+ "requireProofKey": true,
+ "requireAuthorizationConsent": true,
+ "accessTokenFormat": "self-contained",
+ "accessTokenTtlSeconds": 300,
+ "reuseRefreshTokens": true
+ },
+ {
+ "clientId": "demo-service",
+ "authenticationMethods": [
+ "client_secret_basic"
+ ],
+ "grantTypes": [
+ "client_credentials"
+ ],
+ "redirectUris": [],
+ "scopes": [
+ "orders.read"
+ ],
+ "requireProofKey": true,
+ "requireAuthorizationConsent": false,
+ "accessTokenFormat": "self-contained",
+ "accessTokenTtlSeconds": 600,
+ "reuseRefreshTokens": true
+ }
+]
diff --git a/docs/output/as-entrypoint-accept.txt b/docs/output/as-entrypoint-accept.txt
new file mode 100644
index 0000000..a053151
--- /dev/null
+++ b/docs/output/as-entrypoint-accept.txt
@@ -0,0 +1,65 @@
+
+------------------------------------------------------------------
+== Public client, failed authentication at the token endpoint [acceptall profile: setIgnoredMediaTypes NOT called]
+------------------------------------------------------------------
+A public client authenticates at /oauth2/token by presenting a code_verifier.
+With no verifier there is nothing to authenticate with, so the request falls
+through to the AuthenticationEntryPoint - and which entry point runs depends on
+the Accept header.
+
+--- Accept: */* (curl's default, and most HTTP clients')
+$ curl -H 'Accept: */*' -d grant_type=authorization_code -d code=bogus \
+ -d client_id=demo-spa http://localhost:9000/oauth2/token
+HTTP/1.1 302
+Location: http://localhost:9000/login
+
+--- Accept: application/json
+$ curl -H 'Accept: application/json' -d grant_type=authorization_code -d code=bogus \
+ -d client_id=demo-spa http://localhost:9000/oauth2/token
+HTTP/1.1 401
+
+--- Accept: text/html (a browser)
+$ curl -H 'Accept: text/html' -d grant_type=authorization_code -d code=bogus \
+ -d client_id=demo-spa http://localhost:9000/oauth2/token
+HTTP/1.1 302
+Location: http://localhost:9000/login
+
+
+------------------------------------------------------------------
+== Confidential client with a wrong secret, for contrast
+------------------------------------------------------------------
+This never reaches the entry point: OAuth2ClientAuthenticationFilter writes the
+error itself, so the Accept header makes no difference.
+HTTP/1.1 401
+
+------------------------------------------------------------------
+== Public client, failed authentication at the token endpoint [default profile: setIgnoredMediaTypes(ALL) called]
+------------------------------------------------------------------
+A public client authenticates at /oauth2/token by presenting a code_verifier.
+With no verifier there is nothing to authenticate with, so the request falls
+through to the AuthenticationEntryPoint - and which entry point runs depends on
+the Accept header.
+
+--- Accept: */* (curl's default, and most HTTP clients')
+$ curl -H 'Accept: */*' -d grant_type=authorization_code -d code=bogus \
+ -d client_id=demo-spa http://localhost:9000/oauth2/token
+HTTP/1.1 401
+
+--- Accept: application/json
+$ curl -H 'Accept: application/json' -d grant_type=authorization_code -d code=bogus \
+ -d client_id=demo-spa http://localhost:9000/oauth2/token
+HTTP/1.1 401
+
+--- Accept: text/html (a browser)
+$ curl -H 'Accept: text/html' -d grant_type=authorization_code -d code=bogus \
+ -d client_id=demo-spa http://localhost:9000/oauth2/token
+HTTP/1.1 302
+Location: http://localhost:9000/login
+
+
+------------------------------------------------------------------
+== Confidential client with a wrong secret, for contrast
+------------------------------------------------------------------
+This never reaches the entry point: OAuth2ClientAuthenticationFilter writes the
+error itself, so the Accept header makes no difference.
+HTTP/1.1 401
diff --git a/docs/output/as-legacy-compile-failure.txt b/docs/output/as-legacy-compile-failure.txt
new file mode 100644
index 0000000..7e2ab9c
--- /dev/null
+++ b/docs/output/as-legacy-compile-failure.txt
@@ -0,0 +1,24 @@
+# The SAS 1.x configuration, compiled against Spring Boot 4.1.1 / Spring Security 7.1.1.
+# Source: src-broken/LegacySasConfig.java.txt
+
+$ javac -cp LegacySasConfig.java
+
+./com/ankurm/authserver/legacy/LegacySasConfig.java:13: error: package org.springframework.security.oauth2.server.authorization.config.annotation.web.configuration does not exist
+import org.springframework.security.oauth2.server.authorization.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration;
+ ^
+./com/ankurm/authserver/legacy/LegacySasConfig.java:14: error: package org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers does not exist
+import org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers.OAuth2AuthorizationServerConfigurer;
+ ^
+./com/ankurm/authserver/legacy/LegacySasConfig.java:33: error: cannot find symbol
+ OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
+ ^
+ symbol: variable OAuth2AuthorizationServerConfiguration
+ location: class LegacySasConfig
+./com/ankurm/authserver/legacy/LegacySasConfig.java:35: error: cannot find symbol
+ http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
+ ^
+ symbol: class OAuth2AuthorizationServerConfigurer
+ location: class LegacySasConfig
+4 errors
+
+javac exit status: 1
diff --git a/docs/output/as-missing-consent-service.txt b/docs/output/as-missing-consent-service.txt
new file mode 100644
index 0000000..5458f1d
--- /dev/null
+++ b/docs/output/as-missing-consent-service.txt
@@ -0,0 +1,5 @@
+# Starting the authorization server with a ConsentController that constructor-injects
+# OAuth2AuthorizationConsentService, without declaring that bean.
+# Spring Authorization Server 7.1.1 / Spring Boot 4.1.1.
+
+org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'consentController' defined in file [authorization-server/auth-server/target/classes/com/ankurm/authserver/web/ConsentController.class]: Unsatisfied dependency expressed through constructor parameter 1: No qualifying bean of type 'org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsentService' available: expected at least 1 bean which qualifies as autowire candidate. Dependency annotations: {}
diff --git a/docs/output/as-pkce-applier.txt b/docs/output/as-pkce-applier.txt
new file mode 100644
index 0000000..3e120d3
--- /dev/null
+++ b/docs/output/as-pkce-applier.txt
@@ -0,0 +1,15 @@
+# From spring-security-oauth2-client-7.1.1.jar
+#
+# The resolver applies its default PKCE customizer only when the registration's
+# client authentication method is NONE - that is, only for public clients.
+# A registration that has a client secret gets no code_challenge.
+
+private static final java.util.function.Consumer DEFAULT_PKCE_APPLIER;
+57: getstatic #209 // Field org/springframework/security/oauth2/core/ClientAuthenticationMethod.NONE:Lorg/springframework/security/oauth2/core/ClientAuthenticationMethod;
+80: getstatic #230 // Field DEFAULT_PKCE_APPLIER:Ljava/util/function/Consumer;
+31: invokestatic #427 // Method org/springframework/security/oauth2/client/web/OAuth2AuthorizationRequestCustomizers.withPkce:()Ljava/util/function/Consumer;
+34: putstatic #230 // Field DEFAULT_PKCE_APPLIER:Ljava/util/function/Consumer;
+
+# The fields and the opt-in setter:
+private static final java.util.function.Consumer DEFAULT_PKCE_APPLIER;
+public void setAuthorizationRequestCustomizer(java.util.function.Consumer);
diff --git a/docs/output/as-rs-startup-failure.txt b/docs/output/as-rs-startup-failure.txt
new file mode 100644
index 0000000..c93a1f7
--- /dev/null
+++ b/docs/output/as-rs-startup-failure.txt
@@ -0,0 +1,11 @@
+# resource-server started with spring.security.oauth2.resourceserver.jwt.issuer-uri
+# pointing at an authorization server that is not running.
+
+2026-08-24T08:03:18.544+05:30 WARN 3087 --- [resource-server] [ main] ConfigServletWebServerApplicationContext : Exception encountered during context initialization - cancelling refresh attempt: org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'api' defined in class path resource [com/ankurm/rs/SecurityConfig.class]: Failed to instantiate [org.springframework.security.web.SecurityFilterChain]: Factory method 'api' threw exception with message: Error creating bean with name 'jwtDecoder' defined in class path resource [com/ankurm/rs/SecurityConfig.class]: Failed to instantiate [org.springframework.security.oauth2.jwt.JwtDecoder]: Factory method 'jwtDecoder' threw exception with message: Unable to resolve the Configuration with the provided Issuer of "http://localhost:9000"
+org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'api' defined in class path resource [com/ankurm/rs/SecurityConfig.class]: Failed to instantiate [org.springframework.security.web.SecurityFilterChain]: Factory method 'api' threw exception with message: Error creating bean with name 'jwtDecoder' defined in class path resource [com/ankurm/rs/SecurityConfig.class]: Failed to instantiate [org.springframework.security.oauth2.jwt.JwtDecoder]: Factory method 'jwtDecoder' threw exception with message: Unable to resolve the Configuration with the provided Issuer of "http://localhost:9000"
+org.springframework.beans.BeanInstantiationException: Failed to instantiate [org.springframework.security.web.SecurityFilterChain]: Factory method 'api' threw exception with message: Error creating bean with name 'jwtDecoder' defined in class path resource [com/ankurm/rs/SecurityConfig.class]: Failed to instantiate [org.springframework.security.oauth2.jwt.JwtDecoder]: Factory method 'jwtDecoder' threw exception with message: Unable to resolve the Configuration with the provided Issuer of "http://localhost:9000"
+org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'jwtDecoder' defined in class path resource [com/ankurm/rs/SecurityConfig.class]: Failed to instantiate [org.springframework.security.oauth2.jwt.JwtDecoder]: Factory method 'jwtDecoder' threw exception with message: Unable to resolve the Configuration with the provided Issuer of "http://localhost:9000"
+org.springframework.beans.BeanInstantiationException: Failed to instantiate [org.springframework.security.oauth2.jwt.JwtDecoder]: Factory method 'jwtDecoder' threw exception with message: Unable to resolve the Configuration with the provided Issuer of "http://localhost:9000"
+java.lang.IllegalArgumentException: Unable to resolve the Configuration with the provided Issuer of "http://localhost:9000"
+org.springframework.web.client.ResourceAccessException: I/O error on GET request for "http://localhost:9000/.well-known/openid-configuration": Connection refused (connect failed)
+java.net.ConnectException: Connection refused (connect failed)
diff --git a/docs/output/as-settings-defaults.txt b/docs/output/as-settings-defaults.txt
new file mode 100644
index 0000000..249fbc0
--- /dev/null
+++ b/docs/output/as-settings-defaults.txt
@@ -0,0 +1,32 @@
+# Defaults of ClientSettings.builder().build() and TokenSettings.builder().build(),
+# read out of the jars themselves rather than from documentation.
+# Source: tools/SettingsDefaults.java
+
+=== Spring Authorization Server 1.5.8 (last release of the standalone project) ===
+requireProofKey = false
+requireAuthorizationConsent= false
+accessTokenTimeToLive = PT5M
+accessTokenFormat = self-contained
+refreshTokenTimeToLive = PT1H
+reuseRefreshTokens = true
+authorizationCodeTTL = PT5M
+
+=== Spring Authorization Server 7.1.1 (inside Spring Security, Boot 4.1.1 BOM) ===
+requireProofKey = true
+requireAuthorizationConsent= false
+accessTokenTimeToLive = PT5M
+accessTokenFormat = self-contained
+refreshTokenTimeToLive = PT1H
+reuseRefreshTokens = true
+authorizationCodeTTL = PT5M
+
+# The same question on the CLIENT side. Source: tools/ClientPkceDefault.java
+
+=== spring-security-oauth2-client 6.5.1 ===
+ClientRegistration.ClientSettings.requireProofKey = false
+
+=== spring-security-oauth2-client 7.1.1 (Boot 4.1.1 BOM) ===
+ClientRegistration.ClientSettings.requireProofKey = true
+
+# Both sides flipped in the 7.x line. Spring-to-Spring therefore still works;
+# a 7.1 authorization server in front of a 6.x or hand-rolled client does not.
diff --git a/docs/output/as-test-run.txt b/docs/output/as-test-run.txt
new file mode 100644
index 0000000..006a369
--- /dev/null
+++ b/docs/output/as-test-run.txt
@@ -0,0 +1,3 @@
+[INFO] Running com.ankurm.authserver.ProviderContractTests
+[INFO] Tests run: 7, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 3.631 s -- in com.ankurm.authserver.ProviderContractTests
+[INFO] Tests run: 7, Failures: 0, Errors: 0, Skipped: 0