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

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

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

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

spring-auth-demo

Runnable companion code for three articles on ankurm.com:

article code
1 Spring Security 7.1 JWT Authentication: The Complete Guide (Spring Boot 4.1) jwt-authentication/
2 Spring Security OAuth2 Resource Server: JWT Validation, JWKS and Key Rotation oauth2-resource-server/
3 Spring Authorization Server: Running Your Own OAuth2 / OIDC Provider authorization-server/

Three Maven projects, one shared 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/ 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

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

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

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
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
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
./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/ 9000 the provider: clients, PKCE, consent, token customisation
resource-server/ 8090 an API that trusts its tokens
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
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.

doc covers
01 — Architecture the whole request path, drawn
02 — Filter chain and ordering where a custom filter goes, and the four ways to place it wrong
03 — 401 vs 403 ExceptionTranslationFilter's actual decision, and RFC 6750 headers
04 — CSRF vs permitAll why permitAll() still returns 403, and when to disable CSRF
05 — HS256 vs RS256 key handling, JWKS, rotation, algorithm confusion
06 — SecurityContext and statelessness explicit save, repositories, thread boundaries
07 — Edge cases 18 things that bite, each with the fix
08 — Testing what to pin, and the Boot 4 test-slice split
09 — Manual filter vs resource server a side-by-side, and which to pick
10 — Production checklist the list to run before you ship
11 — What changed in Spring Security 7 the 7.x-specific surprises this repo hit
12 — Issuer and audience the two claims that make a token yours, and why aud is unchecked by default
13 — The validator stack what is in it, how to add to it without losing it
14 — The authentication converter claims to authorities, and Keycloak's invisible roles
15 — JWKS caching and key rotation what the cache really does, and how long a retired key lives
16 — What an unknown kid costs rate limiting is off, measured 1:1
17 — Keycloak setup compose, realm import, and three ways it bites
18 — Resource server checklist the list for the resource-server side

Project 3 — running your own provider

A separate chapter set, indexed at docs/authorization-server/.

doc covers
01 — Versions and the 7.0 move why there is no SAS version to pin, and which starter to use
02 — The minimum working provider two chains, and the API that replaced applyDefaultSecurity
03 — Clients, PKCE and the defaults that moved requireProofKey flipped to true on both sides
04 — The consent page the form contract, and the redirect loop
05 — Token customisation the bean the JWT generator looks for, and the one it ignores
06 — The resource server side what issuer-uri does and does not validate
07 — Diagnostics reading the effective configuration back out
08 — The relying party a real browser flow, and the client-side PKCE default
09 — Entry point and the Accept header why the token endpoint 302s to a login page
10 — Should you run one at all the honest answer

Captured output

Project 1

file what it shows
curl-transcript-hs256.txt 20 steps: login → token → 401 → 403 → tamper → refresh → revoke
rs256-demo.txt JWKS, alg=RS256, signature sizes, tamper rejection
csrf-vs-permitall.txt the 403 on a permitAll() endpoint
csrf-trace.txt the TRACE log proving the chain stops at filter 5 of 12
expiry-and-clock-skew.txt a token still accepted 5s after exp
resource-server-loose.txt a refresh token accepted as an access token
resource-server-strict.txt the same request, refused
test-run.txt 13 passing tests

Project 2

file what it shows
rs-issuer-audience.txt wrong iss, wrong aud, expiry either side of the clock skew, at+jwt refused
rs-issuer-audience-attyp.txt the same run with a type validator that accepts at+jwt
rs-converter-default.txt Keycloak-shaped roles, and the 403 they produce untouched
rs-converter-java.txt the same token through a custom converter
rs-converter-properties.txt the same mapping in configuration only
rs-converter-properties-broken.txt one unquoted SpEL indexer, and the silence it produces
rs-decoder-chain.txt the live JWK source chain under three cache configurations
rs-rotation.txt publish, activate and retire, watched from the other side
rs-jwks-amplification.txt 25 bad tokens, 25 JWKS fetches
rs-retired-key-default.txt how long a retired key lives with the default cache
rs-retired-key-nottlcache.txt the same, with a Spring cache that has no TTL
rs-keycloak.txt the same code against a real Keycloak 26.7.2
rs-keycloak-default-converter.txt real Keycloak, roles unmapped
rs-test-run.txt 10 tests pinning the default validator stack

Project 3

Indexed in full at docs/authorization-server/README.md. The ones worth opening first:

file what it shows
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 the pre-7.0 configuration, and the four compiler errors it now produces
as-authcode-pkce.txt the whole authorization-code + PKCE flow, every parameter visible
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 302 vs 401 from the token endpoint, decided by the Accept header
as-client-credentials-opaque.txt a reference token, and what introspection returns for it
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 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 and 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 before taking any of it near production.

License

MIT.

Description
Runnable companion code for three ankurm.com articles: Spring Security 7.1 JWT authentication, OAuth2 resource server with JWKS and key rotation, and running your own OAuth2 / OIDC provider with Spring Authorization Server 7.1 on Spring Boot 4.1.
Readme MIT 470 KiB
Languages
Java 68.3%
Shell 30%
HTML 1.7%