# 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; roughly three 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.