1
0
Files
spring-auth-demo/README.md
Ankur Mhatre e9381dc5be Add Spring Authorization Server project: OAuth2/OIDC provider, client and resource server
Three modules on Spring Boot 4.1.1 with Spring Authorization Server 7.1.1: the provider
itself, a relying party, and an API that trusts its tokens. Client registration, PKCE,
a custom consent page and token customisation, with profiles that make each failure
reproducible.

Every claim is backed by captured output in docs/output/as-*.txt, regenerated by
authorization-server/scripts/run-all.sh. Notable findings, verified against the jars:

  - OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(HttpSecurity) was deleted
    in 7.0, and both configuration classes moved into spring-security-config
  - ClientSettings.requireProofKey flipped from false to true, on the authorization server
    (1.5.8 -> 7.1.1) and on the OAuth2 client (6.5.1 -> 7.1.1)
  - requireProofKey(false) does not make PKCE optional for a public client; the code
    verifier is that client's only authentication at the token endpoint
  - MediaTypeRequestMatcher(TEXT_HTML) matches Accept: */*, so the token endpoint answers
    API callers with 302 -> /login unless setIgnoredMediaTypes(ALL) is called

Also renames the repository to spring-auth-demo and cross-links the new chapter set from
the existing documentation.
2026-08-24 08:20:38 +05:30

372 lines
20 KiB
Markdown

# spring-auth-demo
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) |
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.
| | |
|---|---|
| JDK | Temurin **25.0.4.1+1** (current LTS) |
| Spring Boot | **4.1.1** |
| Spring Framework | **7.0.9** |
| Spring Security | **7.1.1** |
| 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) |
---
## Quickstart
### Project 1 — JWT authentication with a hand-written filter
```bash
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
./scripts/curl-transcript.sh # the whole flow, end to end
```
### Project 2 — OAuth2 resource server, JWKS and rotation
```bash
cd spring-auth-demo/oauth2-resource-server
# a stub issuer whose JWK Set can be mutated on command
./scripts/run-stub-issuer.sh
./scripts/run-rs.sh stub,roles,audience
./scripts/issuer-audience-demo.sh "stub,roles,audience"
# or a real Keycloak
docker compose -f docker/compose.yaml up -d
./scripts/run-rs.sh keycloak,roles
./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/`
Three demo users:
| username | password | authorities |
|---|---|---|
| `alice` | `alice-password` | `ROLE_USER`, `SCOPE_profile:read` |
| `root` | `root-password` | `ROLE_USER`, `ROLE_ADMIN`, `SCOPE_profile:read`, `SCOPE_admin:read` |
| `locked` | `locked-password` | locked account — always fails login |
### Profiles
| profile | what it changes |
|---|---|
| `hs256` *(default)* | Symmetric HMAC signing. One secret signs and verifies. |
| `rs256` | RSA signing, plus a real `/.well-known/jwks.json` endpoint. |
| *(none)* | Validation by a hand-written `OncePerRequestFilter`. |
| `resourceserver` | Validation by Spring Security's built-in `oauth2ResourceServer().jwt()`. |
| `strict` | Adds the `token_type` validator to the resource-server chain. |
| `csrfon` | Turns CSRF on, reproducing the "permitAll() returns 403" failure. |
| `shortlived` | 2-second access tokens, for observing expiry and clock skew. |
| `trace` | `TRACE` logging for `org.springframework.security`. |
### Endpoints
| method | path | rule | why it exists |
|---|---|---|---|
| `POST` | `/api/auth/login` | `permitAll()` | issues an access + refresh token pair |
| `POST` | `/api/auth/refresh` | `permitAll()` | rotates the refresh token |
| `POST` | `/api/auth/logout` | authenticated | revokes the presented token by `jti` |
| `GET` | `/api/public/ping` | `permitAll()` | reachable with no token at all |
| `GET` | `/api/me` | authenticated | **401** without a token |
| `GET` | `/api/admin/stats` | `hasRole('ADMIN')` | **403** with a valid non-admin token |
| `GET` | `/api/reports` | `@PreAuthorize` scope | the method-security twin of the above |
| `GET` | `/api/public/filters` | `permitAll()` | prints the live filter chain |
| `GET` | `/api/async-demo` | authenticated | `SecurityContext` across a thread boundary |
| `GET` | `/.well-known/jwks.json` | `permitAll()` | `rs256` profile only |
Runs on **:8080**. Regenerate its captured output with `./jwt-authentication/scripts/run-all.sh`.
---
## Project 2 — `oauth2-resource-server/`
Two applications in one Maven module, started by main class:
| application | port | what it is |
|---|---|---|
| `ResourceServerApplication` | 8081 | the resource server. Validates only; never mints. |
| `StubIssuerApplication` | 9000 | an authorization server whose JWK Set can be mutated on command |
The stub exists because Keycloak will not rotate its signing key at a chosen second, will not
report how many times its JWKS endpoint was fetched, and will not drop a key from the
published set on request — and every measurement about caching and rotation timing needs
all three. The Keycloak run confirms the same code path against a real issuer.
### Profiles
| profile | what it changes |
|---|---|
| `stub` | issuer is the in-repo stub on :9000 |
| `keycloak` | issuer is the Keycloak in `docker/compose.yaml` |
| `roles` | a Java `JwtAuthenticationConverter` mapping Keycloak's nested roles |
| `propsroles` | the same mapping in configuration only, with the SpEL indexer quoted |
| `propsroles-broken` | the same, unquoted — fails silently. See [docs/14](docs/14-authentication-converter.md) |
| `audience` | adds a `JwtAudienceValidator` bean |
| `props` | audience validation by property instead |
| `attyp` | a type validator that accepts RFC 9068 `at+jwt` |
| `springcache` | a Caffeine JWKS cache with a 5-minute TTL |
| `nottlcache` | a `ConcurrentMapCache` with no TTL — the trap in [docs/15](docs/15-jwks-caching-and-rotation.md) |
| `hardened` | the `JWKSource` built directly, with rate limiting and outage tolerance restored |
| `trace` | `TRACE` logging for `org.springframework.security` |
| `tracespel` | just enough logging to see a claim expression fail |
```bash
./scripts/run-rs.sh stub,roles,audience
./scripts/run-rs.sh keycloak,propsroles
./scripts/run-rs.sh stub,roles,nottlcache
```
### Endpoints
| method | path | rule | why it exists |
|---|---|---|---|
| `GET` | `/api/public/ping` | `permitAll()` | reachable with no token |
| `GET` | `/api/me` | authenticated | prints the authorities the converter produced |
| `GET` | `/api/admin/stats` | `hasRole('ADMIN')` | realm role, from `realm_access.roles` |
| `GET` | `/api/reports` | `@PreAuthorize` | client role, from `resource_access.reports-api.roles` |
| `GET` | `/api/public/decoder` | `permitAll()` | **prints the live JWK source chain.** Delete before shipping |
| `GET` | `/.well-known/oauth-protected-resource` | — | published by Spring Security 7 itself |
Stub issuer admin endpoints, for driving a rotation:
| method | path | what it does |
|---|---|---|
| `POST` | `/admin/publish` | generate a key and add it to the JWK Set |
| `POST` | `/admin/activate?kid=` | start signing with that key |
| `POST` | `/admin/retire?kid=` | remove it from the JWK Set. It can still sign |
| `POST` | `/admin/reset-counter` | zero the JWKS fetch counter |
| `GET` | `/admin/state` | active kid, published kids, fetch count |
| `POST` | `/token?sub=&aud=&roles=&expiresInSeconds=&issuedAgoSeconds=&typ=&kid=&issuerOverride=` | mint anything, correct or not |
| `POST` | `/token/unknown-kid` | a token whose `kid` never existed |
Regenerate its captured output with `./oauth2-resource-server/scripts/run-all.sh`
(needs Docker; takes roughly twenty-five minutes, most of it waiting out cache lifetimes).
---
## 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; a few minutes).
---
## Documentation
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 |
|---|---|
| [01 — Architecture](docs/01-architecture.md) | the whole request path, drawn |
| [02 — Filter chain and ordering](docs/02-filter-chain-and-ordering.md) | where a custom filter goes, and the four ways to place it wrong |
| [03 — 401 vs 403](docs/03-401-vs-403.md) | `ExceptionTranslationFilter`'s actual decision, and RFC 6750 headers |
| [04 — CSRF vs permitAll](docs/04-csrf-permitall-403.md) | why `permitAll()` still returns 403, and when to disable CSRF |
| [05 — HS256 vs RS256](docs/05-hs256-vs-rs256.md) | key handling, JWKS, rotation, algorithm confusion |
| [06 — SecurityContext and statelessness](docs/06-securitycontext-and-statelessness.md) | explicit save, repositories, thread boundaries |
| [07 — Edge cases](docs/07-edge-cases.md) | 18 things that bite, each with the fix |
| [08 — Testing](docs/08-testing.md) | what to pin, and the Boot 4 test-slice split |
| [09 — Manual filter vs resource server](docs/09-manual-filter-vs-resource-server.md) | a side-by-side, and which to pick |
| [10 — Production checklist](docs/10-production-checklist.md) | the list to run before you ship |
| [11 — What changed in Spring Security 7](docs/11-spring-security-7-changes.md) | the 7.x-specific surprises this repo hit |
| [12 — Issuer and audience](docs/12-issuer-and-audience.md) | the two claims that make a token yours, and why `aud` is unchecked by default |
| [13 — The validator stack](docs/13-validator-stack.md) | what is in it, how to add to it without losing it |
| [14 — The authentication converter](docs/14-authentication-converter.md) | claims to authorities, and Keycloak's invisible roles |
| [15 — JWKS caching and key rotation](docs/15-jwks-caching-and-rotation.md) | what the cache really does, and how long a retired key lives |
| [16 — What an unknown `kid` costs](docs/16-jwks-amplification.md) | rate limiting is off, measured 1:1 |
| [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
### Project 1
| file | what it shows |
|---|---|
| [`curl-transcript-hs256.txt`](docs/output/curl-transcript-hs256.txt) | 20 steps: login → token → 401 → 403 → tamper → refresh → revoke |
| [`rs256-demo.txt`](docs/output/rs256-demo.txt) | JWKS, `alg=RS256`, signature sizes, tamper rejection |
| [`csrf-vs-permitall.txt`](docs/output/csrf-vs-permitall.txt) | the 403 on a `permitAll()` endpoint |
| [`csrf-trace.txt`](docs/output/csrf-trace.txt) | the TRACE log proving the chain stops at filter 5 of 12 |
| [`expiry-and-clock-skew.txt`](docs/output/expiry-and-clock-skew.txt) | a token still accepted 5s after `exp` |
| [`resource-server-loose.txt`](docs/output/resource-server-loose.txt) | a refresh token accepted as an access token |
| [`resource-server-strict.txt`](docs/output/resource-server-strict.txt) | the same request, refused |
| [`test-run.txt`](docs/output/test-run.txt) | 13 passing tests |
### Project 2
| file | what it shows |
|---|---|
| [`rs-issuer-audience.txt`](docs/output/rs-issuer-audience.txt) | wrong `iss`, wrong `aud`, expiry either side of the clock skew, `at+jwt` refused |
| [`rs-issuer-audience-attyp.txt`](docs/output/rs-issuer-audience-attyp.txt) | the same run with a type validator that accepts `at+jwt` |
| [`rs-converter-default.txt`](docs/output/rs-converter-default.txt) | Keycloak-shaped roles, and the 403 they produce untouched |
| [`rs-converter-java.txt`](docs/output/rs-converter-java.txt) | the same token through a custom converter |
| [`rs-converter-properties.txt`](docs/output/rs-converter-properties.txt) | the same mapping in configuration only |
| [`rs-converter-properties-broken.txt`](docs/output/rs-converter-properties-broken.txt) | one unquoted SpEL indexer, and the silence it produces |
| [`rs-decoder-chain.txt`](docs/output/rs-decoder-chain.txt) | the live JWK source chain under three cache configurations |
| [`rs-rotation.txt`](docs/output/rs-rotation.txt) | publish, activate and retire, watched from the other side |
| [`rs-jwks-amplification.txt`](docs/output/rs-jwks-amplification.txt) | 25 bad tokens, 25 JWKS fetches |
| [`rs-retired-key-default.txt`](docs/output/rs-retired-key-default.txt) | how long a retired key lives with the default cache |
| [`rs-retired-key-nottlcache.txt`](docs/output/rs-retired-key-nottlcache.txt) | the same, with a Spring cache that has no TTL |
| [`rs-keycloak.txt`](docs/output/rs-keycloak.txt) | the same code against a real Keycloak 26.7.2 |
| [`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
The keys in `jwt-authentication/src/main/resources/`, the HMAC secret in its
`application.yaml`, and the Keycloak credentials in
[`docker/realm-demo.json`](oauth2-resource-server/docker/realm-demo.json) are **demo values
committed on purpose** so the repository runs with no setup. They are public. Never point
them at anything you care about — see
[docs/10-production-checklist.md](docs/10-production-checklist.md) and
[docs/18-resource-server-checklist.md](docs/18-resource-server-checklist.md).
`/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.