commit 4a8dab6739a63c6c4fe9db070d21f5b9a1a33248 Author: asmhatre Date: Sat Aug 22 06:22:25 2026 +0000 Spring Security 7.1 JWT authentication on Spring Boot 4.1 Runnable companion for https://ankurm.com/spring-security-7-1-jwt-authentication-guide/ - login -> token issue -> OncePerRequestFilter -> SecurityContext, end to end - HS256 and RS256 variants (RS256 publishes a real JWKS endpoint) - the same API secured by the built-in oauth2ResourceServer().jwt(), for comparison - 11 documentation chapters under docs/, interlinked with the code - docs/output/ is real captured output, regenerated by scripts/run-all.sh - 13 passing tests pinning the 401-vs-403 contract and the CSRF failure Verified against Spring Boot 4.1.1, Spring Security 7.1.1, JDK 25.0.4.1. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9193b9d --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +target/ +.mvn/ +!.mvn/wrapper/maven-wrapper.properties +*.iml +.idea/ +.vscode/ +.classpath +.project +.settings/ +*.log diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..aa5473f --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Ankur Mhatre + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..75fec8a --- /dev/null +++ b/README.md @@ -0,0 +1,132 @@ +# jwt-auth-demo + +Runnable companion code for **[Spring Security 7.1 JWT Authentication: The Complete Guide (Spring Boot 4.1)](https://ankurm.com/spring-security-7-1-jwt-authentication-guide/)** on ankurm.com. + +Everything here was compiled and executed. Every file under [`docs/output/`](docs/output) +is real program output, regenerated by [`scripts/run-all.sh`](scripts/run-all.sh) — 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`) | + +--- + +## Quickstart + +```bash +git clone https://ankurm.com/git.app/asmhatre/jwt-auth-demo.git +cd jwt-auth-demo +./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 +``` + +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 + +The same application demonstrates four axes. Combine them freely. + +| 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`. | + +```bash +./scripts/run.sh rs256 +./scripts/run.sh hs256,resourceserver,strict +./scripts/run.sh hs256,csrfon,trace +``` + +--- + +## 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 | + +--- + +## Documentation + +Start with [`docs/01-architecture.md`](docs/01-architecture.md) and follow the trail. + +| 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 | + +--- + +## Captured output + +| 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 | + +Regenerate all of it: + +```bash +./scripts/run-all.sh +``` + +--- + +## Security note + +The keys in `src/main/resources/` and the HMAC secret in `application.yaml` 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). + +## License + +MIT. diff --git a/docs/01-architecture.md b/docs/01-architecture.md new file mode 100644 index 0000000..21aa916 --- /dev/null +++ b/docs/01-architecture.md @@ -0,0 +1,97 @@ +# 01 — Architecture + +[← README](../README.md) · [next: filter chain and ordering →](02-filter-chain-and-ordering.md) + +There are two paths through this application, and confusing them is the source of most +JWT bugs. The **login path** runs once and is stateful in the only sense that matters: +it sees a password. The **request path** runs on every subsequent call and sees nothing +but a string. + +## The login path + +``` +POST /api/auth/login {"username":"alice","password":"..."} + | + v + AuthController <-- the ONLY place a password is read + | + | authenticationManager.authenticate( + | UsernamePasswordAuthenticationToken.unauthenticated(user, pass)) + v + ProviderManager + | + v + DaoAuthenticationProvider + | loadUserByUsername -> UserDetails + | passwordEncoder.matches(raw, encoded) + v + Authentication (authenticated=true, authorities=[ROLE_USER, SCOPE_profile:read]) + | + v + TokenService.issueAccessToken(authentication) + | JwtClaimsSet: iss aud sub jti iat nbf exp scope roles token_type + | NimbusJwtEncoder.encode(...) + v + 200 {"accessToken":"eyJ...","refreshToken":"eyJ...","tokenType":"Bearer",...} +``` + +Note what does **not** happen: no session is created, no `SecurityContext` is saved, no +cookie is set. The `Authentication` object built here is used to fill in claims and is +then discarded. + +## The request path + +``` +GET /api/me +Authorization: Bearer eyJ... + | + v + FilterChainProxy ---------------------------------------------+ + | | + | 1 DisableEncodeUrlFilter | + | 2 WebAsyncManagerIntegrationFilter | + | 3 SecurityContextHolderFilter loads context | + | 4 HeaderWriterFilter | + | 5 JwtAuthenticationFilter <-- ours | + | resolve Bearer token | + | jwtDecoder.decode(token) | + | verify signature | + | exp / nbf (+/- 60s skew), iss, aud | + | token_type == "access", jti not revoked | + | JwtAuthenticationToken -> SecurityContext | + | 6 RequestCacheAwareFilter | + | 7 SecurityContextHolderAwareRequestFilter | + | 8 AnonymousAuthenticationFilter | + | 9 SessionManagementFilter | + | 10 ExceptionTranslationFilter catches what follows | + | 11 AuthorizationFilter permitAll / hasRole | + | | + +--------------------------------------------------------+ + | + v + DispatcherServlet -> @PreAuthorize -> controller +``` + +That list is not from memory. It is printed by `GET /api/public/filters`, which reads +`FilterChainProxy.getFilterChains()` at runtime — see +[`FilterChainReport`](../src/main/java/com/ankurm/jwtauth/diag/FilterChainReport.java) +and step 19 of [`curl-transcript-hs256.txt`](output/curl-transcript-hs256.txt). + +## Where each concern lives + +| concern | class | doc | +|---|---|---| +| password check | [`AppUsers`](../src/main/java/com/ankurm/jwtauth/config/AppUsers.java) + `DaoAuthenticationProvider` | — | +| token minting | [`TokenService`](../src/main/java/com/ankurm/jwtauth/auth/TokenService.java) | [05](05-hs256-vs-rs256.md) | +| token verifying | [`JwtAuthenticationFilter`](../src/main/java/com/ankurm/jwtauth/auth/JwtAuthenticationFilter.java) | [02](02-filter-chain-and-ordering.md) | +| claim validation | [`JwtValidatorFactory`](../src/main/java/com/ankurm/jwtauth/config/JwtValidatorFactory.java) | [07](07-edge-cases.md) | +| key material | [`Hs256KeyConfig`](../src/main/java/com/ankurm/jwtauth/config/Hs256KeyConfig.java) / [`Rs256KeyConfig`](../src/main/java/com/ankurm/jwtauth/config/Rs256KeyConfig.java) | [05](05-hs256-vs-rs256.md) | +| authorization rules | [`SecurityConfig`](../src/main/java/com/ankurm/jwtauth/config/SecurityConfig.java) | [03](03-401-vs-403.md) | +| revocation | [`RevokedTokenStore`](../src/main/java/com/ankurm/jwtauth/auth/RevokedTokenStore.java) | [07](07-edge-cases.md) | + +## The one-sentence version + +A JWT deployment is an **issuer** that trades a password for a signed claims set, and a +**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. diff --git a/docs/02-filter-chain-and-ordering.md b/docs/02-filter-chain-and-ordering.md new file mode 100644 index 0000000..10761fd --- /dev/null +++ b/docs/02-filter-chain-and-ordering.md @@ -0,0 +1,157 @@ +# 02 — Filter chain and ordering + +[← architecture](01-architecture.md) · [next: 401 vs 403 →](03-401-vs-403.md) + +## The rule + +`FilterChainProxy` runs a fixed, sorted list. Your filter has to sit **after** the +context is loaded and **before** the decision is made. That leaves exactly one useful +region, and `UsernamePasswordAuthenticationFilter` is the conventional landmark for it: + +```java +.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class); +``` + +Spring Security does not care that the landmark filter is absent from your chain +(`formLogin` is disabled here). The ordering is by *position in a registry*, not by the +presence of a neighbour. + +## The live chain + +From `GET /api/public/filters` on the `hs256` profile — this is +[real output](output/curl-transcript-hs256.txt), step 19: + +``` + 1 DisableEncodeUrlFilter + 2 WebAsyncManagerIntegrationFilter + 3 SecurityContextHolderFilter + 4 HeaderWriterFilter + 5 JwtAuthenticationFilter <-- ours + 6 RequestCacheAwareFilter + 7 SecurityContextHolderAwareRequestFilter + 8 AnonymousAuthenticationFilter + 9 SessionManagementFilter +10 ExceptionTranslationFilter +11 AuthorizationFilter +``` + +With the `resourceserver` profile the same slot is held by the framework's own filter, +and two more appear: + +``` + 5 LogoutFilter + 6 OAuth2ProtectedResourceMetadataFilter <-- new in Spring Security 7 + 7 BearerTokenAuthenticationFilter <-- theirs +``` + +`OAuth2ProtectedResourceMetadataFilter` is why every 401 in this repository carries +`resource_metadata="…/.well-known/oauth-protected-resource"` — RFC 9728. See +[doc 11](11-spring-security-7-changes.md). + +## Four ways to place it wrong + +### 1. After `AuthorizationFilter` + +```java +.addFilterAfter(jwtFilter, AuthorizationFilter.class); // wrong +``` + +Authorization has already run and already answered 401. Your filter authenticates a +request whose response is committed. Symptom: **every protected endpoint 401s no matter +how good the token is.** + +### 2. Before `SecurityContextHolderFilter` + +The context holder has not been initialised for this request yet. Whatever you write is +either overwritten or leaks into the next request on the same pooled thread. Symptom: +**intermittent wrong-principal bugs under load** — the worst kind. + +### 3. Registered twice + +A `OncePerRequestFilter` that is also a `@Component` gets picked up by Boot's servlet +auto-registration *and* inserted into the security chain. It then runs on every request, +including ones no `SecurityFilterChain` matches. + +```java +// If the filter must be a bean, suppress the servlet registration: +@Bean +FilterRegistrationBean disableAutoRegistration( + JwtAuthenticationFilter filter) { + FilterRegistrationBean reg = new FilterRegistrationBean<>(filter); + reg.setEnabled(false); + return reg; +} +``` + +This repository sidesteps it: `JwtAuthenticationFilter` is constructed with `new` inside +[`SecurityConfig`](../src/main/java/com/ankurm/jwtauth/config/SecurityConfig.java) and is +never a bean. + +### 4. Extending `GenericFilterBean` instead of `OncePerRequestFilter` + +`OncePerRequestFilter` guards against re-entry via a request attribute. Without it, a +`FORWARD` to an error page, an async dispatch, or a nested `RequestDispatcher` runs +authentication a second time. Symptom: **`/error` responses lose the principal**, or +authentication side effects fire twice. + +## The five details inside the filter + +From +[`JwtAuthenticationFilter`](../src/main/java/com/ankurm/jwtauth/auth/JwtAuthenticationFilter.java): + +**1. No token is not an error.** Continue the chain. This is what keeps `permitAll()` +endpoints reachable. + +```java +if (token == null) { + filterChain.doFilter(request, response); + return; +} +``` + +**2. A bad token *is* an error, and the chain stops.** The tempting alternative — +catching the exception and continuing anonymously — means a forged token produces a 403 +on a protected endpoint and a silent 200 on a public one. Neither says "your token is +invalid", so the client retries forever with the same bad token. + +**3. Use `SecurityContextHolderStrategy`, not the static setters.** + +```java +private final SecurityContextHolderStrategy contextHolderStrategy = + SecurityContextHolder.getContextHolderStrategy(); +``` + +Straight `SecurityContextHolder.setContext(...)` bypasses a strategy the application may +have swapped in — the usual reason is `DelegatingSecurityContextHolderStrategy` for +observability or virtual-thread propagation. + +**4. Clear the context on failure.** Servlet containers pool threads. A `ThreadLocal` +left populated is a cross-request principal leak. + +**5. Wrap decode failures in something that carries a `BearerTokenError`.** + +```java +return new InvalidBearerTokenException(ex.getMessage(), ex); +``` + +`BearerTokenAuthenticationEntryPoint` only writes `error="invalid_token"` into +`WWW-Authenticate` when the exception carries a `BearerTokenError`. Wrap a `JwtException` +in a plain `AuthenticationServiceException` and the client gets a bare +`WWW-Authenticate: Bearer realm="…"` with no reason at all. Compare steps 11 and 13 of +the [transcript](output/curl-transcript-hs256.txt). + +## `shouldNotFilter` + +Skipping work for paths that can never carry a token is fine; skipping *authentication* +is not. + +```java +@Override +protected boolean shouldNotFilter(HttpServletRequest request) { + return request.getServletPath().startsWith("/actuator/health"); +} +``` + +Do not use this to "make an endpoint public" — that belongs in `authorizeHttpRequests`. +A `shouldNotFilter` exemption also skips the filter for a request that legitimately +*carries* a token, so the endpoint silently loses the principal. diff --git a/docs/03-401-vs-403.md b/docs/03-401-vs-403.md new file mode 100644 index 0000000..9289c8d --- /dev/null +++ b/docs/03-401-vs-403.md @@ -0,0 +1,148 @@ +# 03 — 401 vs 403 + +[← filter chain](02-filter-chain-and-ordering.md) · [next: CSRF vs permitAll →](04-csrf-permitall-403.md) + +## The one-line rule + +> **401** — I do not know who you are. +> **403** — I know who you are, and you may not do this. + +Everything else follows from that. The confusion comes from the fact that Spring +Security decides which one to send in a filter your token never reaches, using an +`Authentication` your filter may or may not have installed. + +## The actual decision + +`ExceptionTranslationFilter` wraps the rest of the chain and catches exactly two +exception types: + +```java +try { + filterChain.doFilter(request, response); // AuthorizationFilter runs in here +} +catch (AccessDeniedException | AuthenticationException ex) { + if (!authenticated || ex instanceof AuthenticationException) { + startAuthentication(); // -> AuthenticationEntryPoint -> 401 + } + else { + accessDenied(); // -> AccessDeniedHandler -> 403 + } +} +``` + +Read the condition carefully. `AuthorizationFilter` throws `AccessDeniedException` for +*both* "no credentials" and "wrong credentials". The 401/403 split is decided by +`authenticated` — which is false when the current `Authentication` is anonymous or +`null`. So: + +| you sent | context holds | `AuthorizationFilter` | translated to | +|---|---|---|---| +| nothing | `AnonymousAuthenticationToken` | `AccessDeniedException` | **401** | +| a valid token, insufficient authority | `JwtAuthenticationToken` | `AccessDeniedException` | **403** | +| an invalid token | *(filter cleared it and stopped)* | never reached | **401** | + +The third row is the one people get wrong. An invalid token must not be allowed to fall +through to anonymous — otherwise a forged token on an admin endpoint yields 403, which +tells the caller "your token is fine, your role is not". It is not fine. + +## What the wire looks like + +All from [`curl-transcript-hs256.txt`](output/curl-transcript-hs256.txt). + +**No token** — bare challenge, no error code, because there is nothing wrong with a +token that was never presented: + +``` +HTTP 401 +WWW-Authenticate: Bearer realm="jwt-auth-demo", + resource_metadata="http://localhost:8080/.well-known/oauth-protected-resource" +``` + +**Tampered token** — `invalid_token`, per RFC 6750 §3.1: + +``` +HTTP 401 +WWW-Authenticate: Bearer realm="jwt-auth-demo", error="invalid_token", + error_description="An error occurred while attempting to decode the Jwt: + Signed JWT rejected: Invalid signature", + error_uri="https://tools.ietf.org/html/rfc6750#section-3.1", ... +``` + +**Valid token, missing role** — note this is a **403** that still carries a +`WWW-Authenticate` header: + +``` +HTTP 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" +``` + +## Wiring it + +```java +.exceptionHandling(ex -> ex + .authenticationEntryPoint(bearerTokenEntryPoint()) // 401 + .accessDeniedHandler(bearerTokenAccessDeniedHandler()) // 403 +); + +@Bean +AuthenticationEntryPoint bearerTokenEntryPoint() { + BearerTokenAuthenticationEntryPoint entryPoint = new BearerTokenAuthenticationEntryPoint(); + entryPoint.setRealmName("jwt-auth-demo"); + return entryPoint; +} + +@Bean +AccessDeniedHandler bearerTokenAccessDeniedHandler() { + return new BearerTokenAccessDeniedHandler(); +} +``` + +> **Package trap.** `BearerTokenAuthenticationEntryPoint` is in +> `org.springframework.security.oauth2.server.resource.web`, while +> `BearerTokenAccessDeniedHandler` is one level deeper in `…resource.web.access` and +> `BearerTokenAuthenticationFilter` is in `…resource.web.authentication`. Three siblings, +> three packages. Auto-import will pick the wrong one. + +The same `AuthenticationEntryPoint` bean is passed to `JwtAuthenticationFilter`, so a +401 looks identical whether it came from the filter or from `ExceptionTranslationFilter`. +Two different 401 shapes for the same logical failure is a needless client bug. + +## Login failures are a third path + +`POST /api/auth/login` calls `AuthenticationManager` **from a controller**, so a +`BadCredentialsException` is an ordinary MVC exception by the time anything +security-shaped could see it. `AuthenticationEntryPoint` is never invoked. It needs its +own `@RestControllerAdvice` — see +[`ApiExceptionHandler`](../src/main/java/com/ankurm/jwtauth/config/ApiExceptionHandler.java): + +```java +@ExceptionHandler({BadCredentialsException.class, LockedException.class, DisabledException.class}) +public ProblemDetail onAuthenticationFailure(AuthenticationException ex) { + ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.UNAUTHORIZED); + problem.setTitle("Authentication failed"); + problem.setDetail("Invalid username or password"); + return problem; +} +``` + +Every branch answers with the **same body**. `LockedException` and +`BadCredentialsException` producing different messages is a user-enumeration oracle: +"account locked" confirms the username exists. `AuthenticationFlowTests` pins this by +byte-comparing the two responses. + +Without this handler the default is a 500 or a 403, depending on your error handling — +neither of which is what a client should see for a wrong password. + +## Symptom → cause + +| symptom | cause | +|---|---| +| 403 on every endpoint, even with a good token | CSRF — see [doc 04](04-csrf-permitall-403.md) | +| 401 on every endpoint, even with a good token | filter after `AuthorizationFilter`, or a decoder pinned to the wrong algorithm | +| 403 where you expected 401 | invalid token silently falling through to anonymous | +| 401 where you expected 403 | filter cleared the context on a *valid* token — usually a validator throwing | +| 500 on a wrong password | no `@RestControllerAdvice` for `AuthenticationException` | +| 403 with `WWW-Authenticate: Bearer` and no error code | not a token problem — `CsrfFilter` delegating to your bearer handler | diff --git a/docs/04-csrf-permitall-403.md b/docs/04-csrf-permitall-403.md new file mode 100644 index 0000000..890aa80 --- /dev/null +++ b/docs/04-csrf-permitall-403.md @@ -0,0 +1,145 @@ +# 04 — Why `permitAll()` still returns 403 + +[← 401 vs 403](03-401-vs-403.md) · [next: HS256 vs RS256 →](05-hs256-vs-rs256.md) + +This is the single most reported "Spring Security is broken" bug, and it is not a bug. + +## The symptom + +```java +.authorizeHttpRequests(auth -> auth + .requestMatchers("/api/auth/login").permitAll() + .anyRequest().authenticated()) +``` + +``` +POST /api/auth/login +Content-Type: application/json +{"username":"alice","password":"alice-password"} + +HTTP 403 +WWW-Authenticate: Bearer +``` + +Reproduce it: `./scripts/run.sh hs256,csrfon` then `./scripts/csrf-demo.sh`. Captured in +[`csrf-vs-permitall.txt`](output/csrf-vs-permitall.txt). + +Note the response body is empty and the header mentions `Bearer` — which sends people +hunting for a token problem. There is no token problem. + +## The cause, in one number + +`CsrfFilter` is filter **5**. `AuthorizationFilter` — the only filter in the entire chain +that has ever heard the word `permitAll` — is filter **12**. + +From [`csrf-trace.txt`](output/csrf-trace.txt), a real `TRACE` log of that exact request: + +``` +DEBUG FilterChainProxy : Securing POST /api/auth/login +TRACE FilterChainProxy : Invoking DisableEncodeUrlFilter (1/12) +TRACE FilterChainProxy : Invoking WebAsyncManagerIntegrationFilter (2/12) +TRACE FilterChainProxy : Invoking SecurityContextHolderFilter (3/12) +TRACE FilterChainProxy : Invoking HeaderWriterFilter (4/12) +TRACE FilterChainProxy : Invoking CsrfFilter (5/12) +TRACE CsrfTokenRequestHandler : Did not find a CSRF token in the [X-XSRF-TOKEN] request header +TRACE CsrfTokenRequestHandler : Did not find a CSRF token in the [_csrf] request parameter +DEBUG CsrfFilter : Invalid CSRF token found for http://localhost:8080/api/auth/login +``` + +The chain stops at 5 of 12. Filters 6 through 12 never run. `permitAll()` is a statement +about filter 12, and filter 12 is not reached, so `permitAll()` is not a statement about +this request at all. + +`CsrfFilter` throws an `AccessDeniedException` subtype — `MissingCsrfTokenException` when +the repository had no stored token (the case above, with a fresh client), or +`InvalidCsrfTokenException` when one existed and did not match. Both log the same +"Invalid CSRF token found" line, so the message does not distinguish them. Neither +reaches `ExceptionTranslationFilter`, which sits at position 11 — downstream of the +filter that threw. `CsrfFilter` has its own `AccessDeniedHandler` and answers directly. + +``` + 1 2 3 4 5 6 7 8 9 10 11 12 + |----|----|----|----|----X . + | | + CsrfFilter AuthorizationFilter + 403, chain stops knows about permitAll() + never invoked +``` + +## Why the header says `Bearer` + +`CsrfConfigurer` reuses the `AccessDeniedHandler` you configured under +`exceptionHandling()`. Configure `BearerTokenAccessDeniedHandler` for your API — correct +for real authorization failures — and a CSRF rejection is rendered by it too. You get a +403 with `WWW-Authenticate: Bearer` and, because there is no OAuth 2.0 error in context, +no `error=` parameter. A bare `WWW-Authenticate: Bearer` on a 403 is the fingerprint of a +CSRF rejection, not a scope problem. + +## Three fixes, in order of preference + +### 1. Turn CSRF off — correct for a bearer-token API + +```java +.csrf(csrf -> csrf.disable()) +.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) +``` + +CSRF exists because browsers attach **ambient credentials** — cookies, HTTP Basic, +client certificates — to cross-origin requests automatically. A browser does not +automatically attach an `Authorization: Bearer` header. Your JavaScript has to read the +token out of memory and set it, and same-origin policy stops another site's script from +doing that. No ambient credential, nothing to forge. + +The condition is strict, and both halves matter: + +- the token is **never** in a cookie, and +- **no** cookie- or session-based authentication remains on any chain. + +If you store the JWT in a cookie "for convenience", it *is* an ambient credential, and +you have re-created CSRF exactly. Disabling CSRF at that point is a real vulnerability. +See [doc 07 — token storage](07-edge-cases.md#token-storage). + +### 2. Exempt the API, keep it for the browser chain + +For a mixed application — a server-rendered admin UI plus a token API: + +```java +.csrf(csrf -> csrf.ignoringRequestMatchers("/api/**")) +``` + +Better still, split into two `SecurityFilterChain` beans with `securityMatcher()`, so the +API chain has no `CsrfFilter` at all rather than one that is told to look away. + +### 3. Actually send the token + +If the client is a browser SPA that keeps a session: + +```java +.csrf(csrf -> csrf.spa()) // Spring Security 7.0+ +``` + +`spa()` bundles `CookieCsrfTokenRepository`, BREACH protection via +`XorCsrfTokenRequestAttributeHandler`, and correct deferred-token loading. The client +reads `XSRF-TOKEN` and echoes it in `X-XSRF-TOKEN`. + +Verified: the method does **not** exist in 6.4.7 or 6.5.1 and does exist in 7.0.0, so on +6.x you configure those three pieces individually. + +## Related traps + +**Only unsafe methods break.** `CsrfFilter` ignores `GET`, `HEAD`, `OPTIONS`, `TRACE`. +A `GET` on a `permitAll()` path works fine, which is why the failure looks intermittent +and endpoint-specific. Step B of the [demo output](output/csrf-vs-permitall.txt) shows +the same profile answering 200 to a `GET`. + +**A 403 with an empty body on `POST` only** is CSRF until proven otherwise. + +**`MockMvc` hides it.** `spring-security-test`'s `.with(csrf())` post-processor makes the +test pass while production fails. `CsrfBreaksPermitAllTests` deliberately has both: one +test asserting the 403 without it, one asserting the 200 with it. + +**CORS is not CSRF.** A preflight `OPTIONS` failing is a `CorsFilter` problem. Spring +Security 7.1 added `PreFlightRequestFilter` CORS support ([gh-18926]); if preflight +requests are being rejected, look there, not at CSRF. + +[gh-18926]: https://github.com/spring-projects/spring-security/issues/18926 diff --git a/docs/05-hs256-vs-rs256.md b/docs/05-hs256-vs-rs256.md new file mode 100644 index 0000000..d422482 --- /dev/null +++ b/docs/05-hs256-vs-rs256.md @@ -0,0 +1,180 @@ +# 05 — HS256 vs RS256 + +[← CSRF vs permitAll](04-csrf-permitall-403.md) · [next: SecurityContext →](06-securitycontext-and-statelessness.md) + +## The distinction that matters + +| | HS256 | RS256 | +|---|---|---| +| key | one shared secret | private/public pair | +| who can **verify** | anyone who can sign | anyone at all | +| who can **sign** | anyone who can verify | only the private-key holder | +| signature size | 32 bytes | 256 bytes (RSA-2048) | +| sign cost | ~microseconds | ~100× HMAC | +| verify cost | ~microseconds | ~10× HMAC | +| key distribution | copy the secret everywhere | publish a JWKS URL | + +The performance column is not the deciding one. **The deciding question is whether the +set of services that verify tokens is the same as the set you trust to mint them.** + +With HS256 the answer is forced: verifying requires the signing secret, so every +verifier is also an issuer. One compromised read-only reporting service can mint an +admin token. If the answer is "no", you need RS256 (or ES256), and no amount of secret +rotation substitutes. + +## HS256 + +```java +this.secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"); + +@Bean JwtEncoder jwtEncoder() { + return NimbusJwtEncoder.withSecretKey(this.secretKey) + .algorithm(MacAlgorithm.HS256) + .build(); +} + +@Bean JwtDecoder jwtDecoder() { + return NimbusJwtDecoder.withSecretKey(this.secretKey) + .macAlgorithm(MacAlgorithm.HS256) + .build(); +} +``` + +Three things to notice. + +**The builder method is `algorithm(..)`, not `jwsAlgorithm(..)`.** `NimbusJwtEncoder`'s +`SecretKeyJwtEncoderBuilder` (added in Spring Security 7.0) exposes exactly two methods: +`algorithm(MacAlgorithm)` and `jwkPostProcessor(Consumer)`. +The decoder side, confusingly, *does* use `macAlgorithm(..)` / `signatureAlgorithm(..)`. + +**The secret must be ≥ 256 bits.** Nimbus enforces the JWA rule that an HMAC key is at +least as long as its digest; a shorter one throws `KeyLengthException` at encoder +construction, not at first request. +[`Hs256KeyConfig`](../src/main/java/com/ankurm/jwtauth/config/Hs256KeyConfig.java) fails +fast with a clearer message. A short secret is also brute-forceable offline — the +attacker has the ciphertext, the plaintext, and unlimited attempts. + +**A passphrase is not a key.** `"changeit-changeit-changeit-change"` is 32 bytes and +passes the length check while having perhaps 40 bits of entropy. Generate it: + +```bash +openssl rand -base64 48 +``` + +## RS256 + +```java +@Bean JwtEncoder jwtEncoder() { + return NimbusJwtEncoder.withKeyPair(this.publicKey, this.privateKey) + .algorithm(SignatureAlgorithm.RS256) + .jwkPostProcessor(jwk -> jwk.keyID("demo-rsa-2026-08")) + .build(); +} + +@Bean JwtDecoder jwtDecoder() { + return NimbusJwtDecoder.withPublicKey(this.publicKey) + .signatureAlgorithm(SignatureAlgorithm.RS256) + .build(); +} +``` + +There is **no `keyId(..)` method** on the builder. The `kid` is set by post-processing +the Nimbus JWK builder — `jwkPostProcessor(jwk -> jwk.keyID(...))`. Without a `kid`, +key rotation is impossible: the verifier cannot tell which of two published keys to try. + +### Publishing the public half + +[`Rs256KeyConfig.JwkSetEndpoint`](../src/main/java/com/ankurm/jwtauth/config/Rs256KeyConfig.java) +serves a real JWK Set. From [`rs256-demo.txt`](output/rs256-demo.txt): + +```json +{ + "keys": [ + { + "kty": "RSA", + "e": "AQAB", + "kid": "demo-rsa-2026-08", + "n": "5NEDQPQW0Gz6iR5-UNl7J7660_Psd5q1f5VamK9KTS9f6YhPPIG8mfi6zWe8Xmxx..." + } + ] +} +``` + +`n` and `e` only — the public modulus and exponent. A private key would additionally +carry `d`, `p`, `q`. **Audit for those letters** before exposing a JWKS endpoint: leaking +`d` hands over the signing key. + +A separate resource server then needs no key material at all: + +```java +@Bean JwtDecoder jwtDecoder() { + return NimbusJwtDecoder.withJwkSetUri("https://issuer.example.com/.well-known/jwks.json") + .build(); +} +``` + +or, in `application.yaml`: + +```yaml +spring: + security: + oauth2: + resourceserver: + jwt: + issuer-uri: https://issuer.example.com +``` + +`issuer-uri` fetches OIDC discovery **at startup** and fails the context if the issuer is +unreachable. `jwk-set-uri` fetches lazily. In an environment where the issuer boots +alongside the resource server, `issuer-uri` produces a startup-ordering dependency that +`jwk-set-uri` does not. + +## Rotation + +RS256 rotates without downtime because the verifier can hold several keys: + +1. Generate a new pair with a new `kid`. +2. Publish **both** public keys in the JWK Set. +3. Wait for caches to refresh (`NimbusJwtDecoder` caches, and honours `Cache-Control`). +4. Switch the issuer to sign with the new `kid`. +5. Wait one full access-token TTL, so no live token references the old key. +6. Remove the old key from the JWK Set. + +HS256 has no equivalent. The secret is symmetric, so steps 2 and 4 are the same step, and +every token signed with the old secret is invalid the moment you rotate. The workarounds +are a decoder that tries both secrets during a window, or a hard cutover that logs +everyone out. + +## Algorithm confusion — pin the algorithm + +The classic JWT attack: take an RS256 token, change the header to `alg: HS256`, and sign +it with the **public key as the HMAC secret**. A verifier that reads `alg` from the token +and looks up "the key" will verify it, because the public key is public. + +Spring Security is not vulnerable by default — `NimbusJwtDecoder.withPublicKey(...)` +defaults to RS256 and will not switch families. But pin it anyway, because the intent +should be in the code rather than in a default: + +```java +NimbusJwtDecoder.withPublicKey(publicKey) + .signatureAlgorithm(SignatureAlgorithm.RS256) + .build(); +``` + +The related `alg: none` attack is a non-issue here — Nimbus refuses unsigned JWTs for a +configured verifier — but the same principle applies: never let the token choose how it +is verified. + +## Which to pick + +**HS256** — one service issues and consumes its own tokens; the secret never leaves that +deployment unit; you want the smallest tokens and the cheapest verification. A monolith. + +**RS256 / ES256** — more than one service verifies; a third party verifies; you need +rotation without a flag day; compliance requires the signing key in an HSM or KMS. Any +real microservice estate. + +ES256 deserves a mention: same asymmetric properties as RS256 with 64-byte signatures +instead of 256, and Spring Security supports it out of the box via +`NimbusJwtEncoder.withKeyPair(ECPublicKey, ECPrivateKey)`. If you are choosing today and +your clients can handle EC, it is the better default. diff --git a/docs/06-securitycontext-and-statelessness.md b/docs/06-securitycontext-and-statelessness.md new file mode 100644 index 0000000..57113c2 --- /dev/null +++ b/docs/06-securitycontext-and-statelessness.md @@ -0,0 +1,130 @@ +# 06 — SecurityContext and statelessness + +[← HS256 vs RS256](05-hs256-vs-rs256.md) · [next: edge cases →](07-edge-cases.md) + +## What "stateless" actually requires + +Three separate settings, and setting only one of them is the usual mistake. + +```java +.sessionManagement(session -> session + .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) +.securityContext(context -> context + .securityContextRepository(new NullSecurityContextRepository())) +.csrf(csrf -> csrf.disable()) +``` + +`SessionCreationPolicy.STATELESS` stops **Spring Security** from creating or using a +session. It does not stop your application: any `request.getSession()`, any +`@SessionAttributes`, any Spring Session integration still creates one. And it does not +stop the `SecurityContextRepository` from being consulted. + +`NullSecurityContextRepository` closes the second half. Without it the default is +`DelegatingSecurityContextRepository(RequestAttributeSecurityContextRepository, +HttpSessionSecurityContextRepository)` — so a `SecurityContext` you save goes into an +`HttpSession`, and a session cookie appears in a response you believed was stateless. + +Verify rather than assume: the transcript prints `Set-Cookie` if one appears. In +[`curl-transcript-hs256.txt`](output/curl-transcript-hs256.txt), none does. + +## `SecurityContextHolderFilter` and explicit save + +Spring Security 6 replaced `SecurityContextPersistenceFilter` with +`SecurityContextHolderFilter`. The difference is one line of behaviour: + +| | loads context | saves context | +|---|---|---| +| `SecurityContextPersistenceFilter` (legacy) | yes | **automatically**, at the end of the request | +| `SecurityContextHolderFilter` (6.0+ default) | yes | **no — you must call `saveContext`** | + +Anything that authenticates a request must now say so explicitly: + +```java +SecurityContext context = this.contextHolderStrategy.createEmptyContext(); +context.setAuthentication(authentication); +this.contextHolderStrategy.setContext(context); +this.contextRepository.saveContext(context, request, response); // <-- easy to forget +``` + +For a genuinely stateless API `saveContext` on a `NullSecurityContextRepository` is a +no-op, so omitting it appears to work — until an `ERROR` dispatch, a `FORWARD`, or an +async re-dispatch clears the `ThreadLocal` and the principal vanishes on `/error`. +[`JwtAuthenticationFilter`](../src/main/java/com/ankurm/jwtauth/auth/JwtAuthenticationFilter.java) +uses `RequestAttributeSecurityContextRepository`, which survives a dispatch without ever +touching a session — the right middle ground. + +## Always create the context, never mutate the shared one + +```java +// wrong - mutates a context that may be shared +SecurityContextHolder.getContext().setAuthentication(auth); + +// right +SecurityContext context = SecurityContextHolder.createEmptyContext(); +context.setAuthentication(auth); +SecurityContextHolder.setContext(context); +``` + +The first form has been discouraged since 5.7 and is a real race in multi-threaded +handling. + +## Use the strategy, not the static methods + +```java +private final SecurityContextHolderStrategy contextHolderStrategy = + SecurityContextHolder.getContextHolderStrategy(); +``` + +`SecurityContextHolder`'s static methods delegate to whatever strategy is installed, but +capturing the strategy once is what the framework's own filters do, and it is the only +form that keeps working when the application swaps in a delegating strategy — the usual +reasons being observability, tenant propagation, or structured concurrency. + +## The thread boundary + +`SecurityContextHolder` is a `ThreadLocal`. It does not cross threads. `GET +/api/async-demo` proves it — from the [transcript](output/curl-transcript-hs256.txt), +step 20: + +```json +{ + "onRequestThread" : "root", + "onPlainExecutor" : "null (context did not cross the thread)", + "onDelegatingExecutor" : "root" +} +``` + +Same request, same instant, three answers. The middle one is what a `@Async` method, a +plain `CompletableFuture.supplyAsync`, or a raw executor sees. + +Fixes, in order of scope: + +```java +// one executor +new DelegatingSecurityContextExecutorService(Executors.newVirtualThreadPerTaskExecutor()); + +// one task +new DelegatingSecurityContextRunnable(task); +new DelegatingSecurityContextCallable<>(task); + +// the whole application - context inherited by child threads +SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_INHERITABLETHREADLOCAL); +``` + +`MODE_INHERITABLETHREADLOCAL` is the tempting one and the wrong one for a servlet +container: threads are **pooled**, so "child" is whatever thread the pool happens to +spawn, and a context can be inherited by a task belonging to a different request. Wrap +executors instead. + +For `@Async` specifically, Spring Security's +`DelegatingSecurityContextAsyncTaskExecutor` wraps the task executor; ankurm.com has a +[dedicated guide to context propagation](https://ankurm.com/spring-security-context-propagation-complete-guide/). + +## Virtual threads + +Boot 4.1 on JDK 25 makes `spring.threads.virtual.enabled=true` unremarkable. `ThreadLocal` +works on a virtual thread exactly as on a platform thread, so the `SecurityContext` +behaves identically. The one thing that changes: virtual threads are *not* pooled, so +the cross-request leak from a stale `ThreadLocal` is far less likely — which is a reason +to be *more* careful, not less, because the bug becomes rarer and harder to reproduce +rather than absent. Clear the context on the failure path regardless. diff --git a/docs/07-edge-cases.md b/docs/07-edge-cases.md new file mode 100644 index 0000000..c5eec17 --- /dev/null +++ b/docs/07-edge-cases.md @@ -0,0 +1,286 @@ +# 07 — Edge cases + +[← SecurityContext](06-securitycontext-and-statelessness.md) · [next: testing →](08-testing.md) + +Eighteen things that bite. Each is stated as the surprise, then the cause, then the fix. + +--- + +## 1. `aud` is not validated by default {#audience} + +`JwtValidators.createDefaultWithIssuer(issuer)` validates `exp`, `nbf` and `iss`. It does +**not** validate `aud`. In an estate where every service trusts the same issuer, a token +minted for the reporting API is accepted by the payments API without complaint. That is a +confused-deputy vulnerability arriving by default. + +```java +OAuth2TokenValidator audience = + new JwtClaimValidator>(JwtClaimNames.AUD, aud -> aud.contains("payments-api")); + +decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>( + JwtValidators.createDefaultWithIssuer(issuer), audience)); +``` + +See [`AudienceValidator`](../src/main/java/com/ankurm/jwtauth/edge/AudienceValidator.java) +and [`JwtValidatorFactory`](../src/main/java/com/ankurm/jwtauth/config/JwtValidatorFactory.java). + +--- + +## 2. A refresh token is a valid access token {#refresh-token-as-access-token} + +Both are signed by the same key. Both have a valid `exp`, `iss`, `aud`. Every default +validator passes. If the only difference is the TTL, a stolen refresh token is a +*long-lived* access token. + +Proof, from two runs of the same code — +[loose](output/resource-server-loose.txt) vs [strict](output/resource-server-strict.txt): + +``` +# 4. REFRESH token presented as an access token. +HTTP 200 <-- profiles: hs256,resourceserver +HTTP 401 <-- profiles: hs256,resourceserver,strict +``` + +The 200 is worth reading closely: authorities come back as `["FACTOR_BEARER"]` — no roles, +no scopes. The caller is authenticated as alice with no privileges, so `/api/me` succeeds +while `/api/admin/stats` does not. A partial compromise is still a compromise. + +Fix: a `token_type` claim and a validator that checks it — +[`AccessTokenTypeValidator`](../src/main/java/com/ankurm/jwtauth/edge/AccessTokenTypeValidator.java). + +--- + +## 3. Sixty seconds of clock skew + +`JwtTimestampValidator` allows **60 seconds** of clock skew by default, so a token is +still accepted a minute after `exp`. From +[`expiry-and-clock-skew.txt`](output/expiry-and-clock-skew.txt), with a 2-second TTL: + +``` +# T+0s - fresh token HTTP 200 +# T+5s - exp has passed, still within the skew window HTTP 200 +# T+65s - past exp + 60s HTTP 401 +``` + +This is correct behaviour and usually what you want. It matters in two places: a test +that sleeps past `exp` and asserts 401 will fail, and a "revoke by shortening TTL" +strategy has a minute of lag. To tighten it: + +```java +new DelegatingOAuth2TokenValidator<>( + new JwtTimestampValidator(Duration.ofSeconds(5)), + new JwtIssuerValidator(issuerUri)); +``` + +--- + +## 4. A JWT cannot be revoked {#logout-and-revocation} + +"Logout" that deletes the token client-side is not revocation — the token stays valid +until `exp` and works from anywhere it was copied. The minimum viable fix is a `jti` +claim plus a denylist checked on every request: +[`RevokedTokenStore`](../src/main/java/com/ankurm/jwtauth/auth/RevokedTokenStore.java). + +```java +if (this.revokedTokens.isRevoked(jwt.getId())) { + throw invalidToken("Token has been revoked"); +} +``` + +Entries need only outlive the token's own `exp`, so the store self-prunes; in production +this is Redis with a TTL. Steps 17–18 of the +[transcript](output/curl-transcript-hs256.txt) show a cryptographically valid token +refused after logout. + +Accept the trade-off honestly: you have reintroduced a per-request lookup on shared +state, which is the thing JWTs were supposed to avoid. Short access-token TTLs (5–15 +minutes) plus a denylist only for high-value events (password change, logout-all, +compromise) is the usual compromise. + +--- + +## 5. Rotate refresh tokens, or replay is undetectable + +If a refresh token is reusable, a stolen one is usable until it expires and you will +never know. Rotation — issue a new refresh token and revoke the presented one — turns +replay into a signal. + +```java +this.revokedTokens.revoke(jwt.getId(), jwt.getExpiresAt()); // spend it +``` + +Steps 15–16 of the [transcript](output/curl-transcript-hs256.txt): the second use of the +same refresh token is a 401. In production, a replay should invalidate the **whole +token family** for that user, since either the client or the attacker is now holding a +stale token and you cannot tell which. + +--- + +## 6. Token storage: `localStorage` vs cookies {#token-storage} + +| | `localStorage` | `httpOnly` cookie | +|---|---|---| +| XSS | readable by any injected script | not readable | +| CSRF | immune (not ambient) | vulnerable — needs CSRF protection back on | +| mobile / non-browser | fine | awkward | + +There is no free option. `localStorage` trades XSS exposure for CSRF immunity; cookies +do the reverse. If you pick cookies, **you must re-enable CSRF** — see +[doc 04](04-csrf-permitall-403.md). The failure mode is picking cookies for XSS safety +and keeping `csrf.disable()` from the tutorial you started with. + +The strongest common pattern: short-lived access token in memory only (never persisted), +refresh token in an `httpOnly`, `Secure`, `SameSite=Strict` cookie scoped to the refresh +endpoint, with CSRF protection on that one endpoint. + +--- + +## 7. JWTs are signed, not encrypted + +Base64url is not encryption. Step 6 of the +[transcript](output/curl-transcript-hs256.txt) decodes a token with `base64 -d` and no +key. Anything in the claims is readable by the holder, by proxies that log the header, and +by anything that ends up with the string. + +Never put in claims: email addresses, phone numbers, internal user IDs you would not +publish, permission structures that describe your authorization model, PII of any kind. +If the payload must be confidential, that is JWE (`nimbus-jose-jwt` supports it), not JWS — +and the usual right answer is to put an opaque identifier in the token and look the rest up. + +--- + +## 8. Bigger tokens are a real cost + +`Authorization` headers travel on **every** request. From +[`rs256-demo.txt`](output/rs256-demo.txt), a modest RS256 token is 758 characters; +the signature alone is 342. Add a `permissions` array with 200 entries and you are near +common proxy header limits (nginx `large_client_header_buffers` defaults to 8 KB; some +API gateways are stricter). The failure is a **431** or a silent truncation, not a +security error, and it appears only for your most privileged users — who have the most +permissions and complain the loudest. + +Put roles in the token, not permissions. Resolve permissions server-side. + +--- + +## 9. Authority prefixes: `ROLE_` vs `SCOPE_` + +`JwtGrantedAuthoritiesConverter` defaults to reading the `scope` (or `scp`) claim and +prefixing each value with `SCOPE_`. Meanwhile `hasRole("ADMIN")` looks for `ROLE_ADMIN` +and `hasAuthority("ADMIN")` looks for exactly `ADMIN`. Three conventions, easily crossed: + +```java +.requestMatchers("/api/admin/**").hasRole("ADMIN") // needs ROLE_ADMIN +.requestMatchers("/api/reports").hasAuthority("SCOPE_admin:read") +``` + +This repository carries both families and maps them separately — +`scope` → `SCOPE_x`, `roles` → `ROLE_x` — with +`DelegatingJwtGrantedAuthoritiesConverter` in the resource-server profile. Spring Boot 4.1 +also added `spring.security.oauth2.resourceserver.jwt.authorities-claim-expressions`, +a SpEL-based extractor for claims nested deeper than the top level (mutually exclusive +with `authorities-claim-name`). + +--- + +## 10. `@PreAuthorize` on a non-public method silently does nothing + +Method security is proxy-based. A `@PreAuthorize` on a `private`, `final`, or +package-private method, or on a method called from **within the same class**, is not +intercepted. There is no warning. The endpoint is simply unprotected. + +Keep authorization on public methods invoked through the proxy, and prefer +`authorizeHttpRequests` for coarse URL rules. + +--- + +## 11. `permitAll()` does not mean "no authentication" + +It means "authorization always grants". If a token *is* present, it is still decoded, and +a **bad** token on a `permitAll()` endpoint still fails — the filter rejects it before +authorization runs. This is correct: a caller sending a broken token deserves to be told, +not silently downgraded to anonymous. + +Where it surprises people: health checks that pass through an expired token from a +sidecar start failing on an endpoint that is supposedly public. + +--- + +## 12. Ordering inside `authorizeHttpRequests` is first-match + +```java +.anyRequest().authenticated() +.requestMatchers("/api/public/**").permitAll() // unreachable +``` + +Rules are evaluated top to bottom and the first match wins. `anyRequest()` must be last. +Spring Security 7 throws at startup for an unreachable matcher in many cases, but not all +— put the specific rules first regardless. + +--- + +## 13. The `Authorization` header can be stripped in transit + +Some proxies, load balancers and CDN configurations drop or rewrite `Authorization`. +Symptom: works locally, 401 everywhere else, and the application log shows no token at +all. Check the edge before the application. `DefaultBearerTokenResolver` also supports a +query parameter, but do **not** enable it: + +```java +resolver.setAllowUriQueryParameter(true); // don't +``` + +URLs land in access logs, browser history, and `Referer` headers. + +--- + +## 14. Two tokens in one request is an error, not a preference + +`DefaultBearerTokenResolver` throws `OAuth2AuthenticationException` when a token appears +in both the header and a parameter, rather than picking one. Correct — but it means a +client that "helpfully" adds both gets a 401 with `invalid_request` and no obvious cause. + +--- + +## 15. `WWW-Authenticate` needs a `BearerTokenError` to say anything + +Wrap a `JwtException` in a plain `AuthenticationServiceException` and the 401 carries a +bare `WWW-Authenticate: Bearer realm="…"`. Wrap it in `InvalidBearerTokenException` and it +carries `error="invalid_token"` with a description. Same status code, very different +debuggability. Compare steps 11 and 13 of the +[transcript](output/curl-transcript-hs256.txt). + +--- + +## 16. `error_description` leaks + +The flip side: `"Jwt expired at 2026-08-22T06:01:43Z"` tells a caller exactly when the +token expired, and issuer/audience mismatches name your internal URLs. Useful in +development, informative to an attacker in production. Consider a production +`AuthenticationEntryPoint` that logs the detail and returns a generic body. + +--- + +## 17. The `SecurityContext` does not cross threads + +Covered in [doc 06](06-securitycontext-and-statelessness.md#the-thread-boundary), listed +here because it is the edge case that most often reaches production: it only manifests +under `@Async`, `CompletableFuture`, or a `parallelStream()`, none of which are on the +happy path. `GET /api/async-demo` demonstrates it live. {#async} + +--- + +## 18. `FACTOR_BEARER` appears in your authorities + +New in Spring Security 7: authenticating with a bearer token adds a `FACTOR_BEARER` +authority alongside your own. Visible in every `/api/me` response in the +[transcript](output/curl-transcript-hs256.txt): + +```json +"authorities": ["FACTOR_BEARER", "ROLE_USER", "SCOPE_profile:read"] +``` + +It exists to support the new multi-factor authorization support +(`AuthorizationManagerFactories.multiFactor()`, `@EnableMultiFactorAuthentication`). It +is harmless — until a test asserts on the exact authority set, or code assumes every +authority starts with `ROLE_` or `SCOPE_`. See [doc 11](11-spring-security-7-changes.md). diff --git a/docs/08-testing.md b/docs/08-testing.md new file mode 100644 index 0000000..52321a5 --- /dev/null +++ b/docs/08-testing.md @@ -0,0 +1,132 @@ +# 08 — Testing + +[← edge cases](07-edge-cases.md) · [next: manual filter vs resource server →](09-manual-filter-vs-resource-server.md) + +## The Boot 4 test-slice split + +On Spring Boot 3, `spring-boot-starter-test` alone gave you `@AutoConfigureMockMvc`. On +Boot 4 it does not — the test slices were moved into their own modules: + +```xml + + org.springframework.boot + spring-boot-starter-webmvc-test + test + + + org.springframework.boot + spring-boot-starter-security-test + test + +``` + +The package moved with it: + +```java +// Boot 3 +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +// Boot 4 +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +``` + +The compiler error is `package org.springframework.boot.test.autoconfigure.web.servlet +does not exist`, which reads like a corrupt dependency rather than a relocation. + +## What is worth pinning + +13 tests, all passing — [`test-run.txt`](output/test-run.txt). The valuable ones assert +things that are easy to break without noticing. + +**The status-code contract.** Not "it works" but *which* failure code: + +```java +@Test +void missingTokenIs401NotA403() throws Exception { + this.mvc.perform(get("/api/me")) + .andExpect(status().isUnauthorized()) + .andExpect(header().string("WWW-Authenticate", containsString("Bearer"))); +} + +@Test +void validTokenWithoutTheRoleIs403NotA401() throws Exception { + String token = login("alice", "alice-password").get("accessToken"); + this.mvc.perform(get("/api/admin/stats").header("Authorization", "Bearer " + token)) + .andExpect(status().isForbidden()) + .andExpect(header().string("WWW-Authenticate", containsString("insufficient_scope"))); +} +``` + +**Non-disclosure.** A byte comparison, because a helpful message is a regression: + +```java +@Test +void lockedAccountIsIndistinguishableFromABadPassword() throws Exception { + // ... both requests ... + assertThat(locked.getResponse().getContentAsString()) + .isEqualTo(wrong.getResponse().getContentAsString()); +} +``` + +**Filter order.** Ordering is configuration, and configuration drifts: + +```java +@Test +void csrfFilterRunsLongBeforeAuthorizationFilter() { + List filters = this.filterChainProxy.getFilterChains().getFirst() + .getFilters().stream().map(f -> f.getClass().getSimpleName()).toList(); + + assertThat(filters.indexOf("AuthorizationFilter")).isEqualTo(filters.size() - 1); + assertThat(filters.indexOf("CsrfFilter")).isLessThan(filters.indexOf("AuthorizationFilter")); +} +``` + +**Revocation and replay**, because both are easy to regress into no-ops. + +## The `.with(csrf())` trap + +`spring-security-test` provides a post-processor that attaches a valid CSRF token: + +```java +this.mvc.perform(post("/api/auth/login").with(csrf()) ... ) +``` + +Convenient, and it will make a test pass against a configuration that 403s in production. +[`CsrfBreaksPermitAllTests`](../src/test/java/com/ankurm/jwtauth/CsrfBreaksPermitAllTests.java) +deliberately has both tests: one asserting the 403 **without** `csrf()`, one asserting the +200 with it. If you only ever write the second, you have tested your test. + +## `@WithMockUser` tests authorization, not authentication + +```java +@Test +@WithMockUser(roles = "ADMIN") +void adminCanSeeStats() { ... } +``` + +This installs an `Authentication` directly into the context and **bypasses the entire +filter chain** — decoder, validators, `token_type` check, denylist. It is the right tool +for testing `@PreAuthorize` rules and the wrong tool for testing that your JWT pipeline +works. Every test in `AuthenticationFlowTests` goes through a real `POST /api/auth/login` +and a real `Authorization` header for that reason. + +`spring-security-test` also offers `SecurityMockMvcRequestPostProcessors.jwt()`, which +constructs a `Jwt` without signing it. Same caveat: good for authorization rules, blind +to decoder configuration. + +## Testing expiry + +A token with a 2-second TTL is **not** expired 5 seconds later — `JwtTimestampValidator` +allows 60 seconds of clock skew ([doc 07 §3](07-edge-cases.md)). A test that sleeps past +`exp` and asserts 401 either sleeps 61 seconds or is flaky. + +Two better options: build the `JwtDecoder` under test with a small skew +(`new JwtTimestampValidator(Duration.ZERO)`), or inject a fixed `Clock` and issue a token +already in the past. + +## Integration testing against the real server + +`scripts/curl-transcript.sh` is the integration test that MockMvc cannot be — it exercises +a real Tomcat, a real HTTP client, real header parsing, and real base64url. Several +findings in these docs (the `resource_metadata` parameter, the `FACTOR_BEARER` authority, +the bare `WWW-Authenticate` on a wrapped `JwtException`) came from that script, not from +the test suite. diff --git a/docs/09-manual-filter-vs-resource-server.md b/docs/09-manual-filter-vs-resource-server.md new file mode 100644 index 0000000..a7d40c6 --- /dev/null +++ b/docs/09-manual-filter-vs-resource-server.md @@ -0,0 +1,123 @@ +# 09 — Manual filter vs built-in resource server + +[← testing](08-testing.md) · [next: production checklist →](10-production-checklist.md) + +Both are in this repository, behind profiles, secured identically. Run them side by side: + +```bash +./scripts/run.sh hs256 # hand-written OncePerRequestFilter +./scripts/run.sh hs256,resourceserver # oauth2ResourceServer().jwt() +``` + +## The configuration, side by side + +**Manual** — +[`SecurityConfig`](../src/main/java/com/ankurm/jwtauth/config/SecurityConfig.java): + +```java +.addFilterBefore(new JwtAuthenticationFilter(jwtDecoder, revokedTokens, entryPoint), + UsernamePasswordAuthenticationFilter.class); +``` + +plus ~120 lines of filter, plus the entry point and access-denied handler wired by hand. + +**Built-in** — +[`ResourceServerSecurityConfig`](../src/main/java/com/ankurm/jwtauth/config/ResourceServerSecurityConfig.java): + +```java +.oauth2ResourceServer(oauth2 -> oauth2 + .jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter()))); +``` + +One line. `BearerTokenAuthenticationFilter` is inserted in the right slot, +`BearerTokenAuthenticationEntryPoint` and `BearerTokenAccessDeniedHandler` are wired, +the `JwtDecoder` bean is picked up automatically, and the RFC 6750 headers are correct +on both 401 and 403. + +## The chains it produces + +Manual ([transcript](output/curl-transcript-hs256.txt) step 19): + +``` +… HeaderWriterFilter, JwtAuthenticationFilter, RequestCacheAwareFilter, … +``` + +Built-in ([transcript](output/resource-server-loose.txt) step 1): + +``` +… HeaderWriterFilter, LogoutFilter, OAuth2ProtectedResourceMetadataFilter, + BearerTokenAuthenticationFilter, RequestCacheAwareFilter, … +``` + +Same slot. The extra `OAuth2ProtectedResourceMetadataFilter` is Spring Security 7's +RFC 9728 support — see [doc 11](11-spring-security-7-changes.md). + +## What moves when you switch + +| concern | manual filter | resource server | +|---|---|---| +| resolve the header | `DefaultBearerTokenResolver` (you call it) | built in | +| decode + verify | `jwtDecoder.decode(token)` (you call it) | built in | +| `exp`/`nbf`/`iss` | `JwtValidators` on the decoder | same decoder, same validators | +| `aud` | your validator | your validator | +| `token_type` | an `if` in the filter | an `OAuth2TokenValidator` | +| denylist / `jti` | an `if` in the filter | an `OAuth2TokenValidator` | +| authority mapping | your converter | `JwtAuthenticationConverter` | +| 401 shape | you wire the entry point | built in | +| 403 shape | you wire the handler | built in | + +The two custom checks do not disappear — they move onto the decoder. That is the trap in +the next section. + +## The trap: the built-in path has no opinion about your claims + +Run the same request against both resource-server profiles: + +``` +# 4. REFRESH token presented as an access token. +HTTP 200 hs256,resourceserver (loose) +HTTP 401 hs256,resourceserver,strict (validator wired) +``` + +Files: [loose](output/resource-server-loose.txt) · [strict](output/resource-server-strict.txt). + +Switching from a hand-written filter to `oauth2ResourceServer()` **silently drops** every +custom check that lived in the filter, because the framework has never heard of your +`token_type` claim or your denylist. The build still passes. The tests still pass, if +they only test the happy path. Move them explicitly: + +```java +decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>( + JwtValidators.createDefaultWithIssuer(issuer), + AudienceValidator.forAudience(audience), + new AccessTokenTypeValidator())); +``` + +`JwtValidatorFactory` in this repository composes exactly that, and the extras are +supplied by an `ObjectProvider` so both profiles get them. + +Note also that a denylist check does not belong in an `OAuth2TokenValidator` on purity +grounds — validators are supposed to be pure functions of the token — but it is where it +has to go if you want it on the built-in path. The alternative is an +`AuthenticationSuccessHandler` or a small filter *after* +`BearerTokenAuthenticationFilter`, which puts you halfway back to the manual approach. + +## Which to pick + +**Use the built-in resource server** if your tokens are ordinary OAuth 2.0 / OIDC access +tokens, especially from a real authorization server. It is less code, it is maintained, +and it gets RFC compliance right in places you would not think to (the `charset` in +`WWW-Authenticate`, RFC 9728 metadata, `insufficient_scope` on 403). + +**Write the filter** if you need behaviour the framework has no hook for — custom header +schemes, a token bound to a device fingerprint, per-request key selection across +tenants — or if you are teaching, because the filter is where the flow becomes legible. + +**What this repository actually recommends:** start with the built-in one. If you find +yourself adding `OAuth2TokenValidator`s for things that are not claims, that is the +signal to switch. + +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. diff --git a/docs/10-production-checklist.md b/docs/10-production-checklist.md new file mode 100644 index 0000000..76cec57 --- /dev/null +++ b/docs/10-production-checklist.md @@ -0,0 +1,66 @@ +# 10 — Production checklist + +[← manual vs resource server](09-manual-filter-vs-resource-server.md) · [next: Spring Security 7 changes →](11-spring-security-7-changes.md) + +Run this list before shipping. Each item links to the section that explains it. + +## Keys and algorithms + +- [ ] Signing key comes from a secret manager or KMS, **never** from `application.yaml`, and never from an environment variable baked into an image. The values in this repository are public demo values. +- [ ] HMAC secret is ≥ 32 bytes of **random** data (`openssl rand -base64 48`), not a passphrase that happens to be long enough. [→ 05](05-hs256-vs-rs256.md) +- [ ] The algorithm is pinned on the decoder (`.macAlgorithm(..)` / `.signatureAlgorithm(..)`), not left to the token's `alg` header. [→ 05](05-hs256-vs-rs256.md) +- [ ] If more than one service verifies tokens, the algorithm is asymmetric (RS256 / ES256). With HS256 every verifier can mint admin tokens. [→ 05](05-hs256-vs-rs256.md) +- [ ] Every key has a `kid`, and a rotation procedure exists and has been rehearsed. [→ 05](05-hs256-vs-rs256.md) +- [ ] A published JWKS contains `n` and `e` only — grep it for `"d"`, `"p"`, `"q"` before exposing it. + +## Claims and validation + +- [ ] `aud` is validated. It is **not** validated by default. [→ 07 §1](07-edge-cases.md#audience) +- [ ] `iss` is validated (`JwtValidators.createDefaultWithIssuer`). +- [ ] Access and refresh tokens are distinguishable, and the distinction is enforced on every request. [→ 07 §2](07-edge-cases.md#refresh-token-as-access-token) +- [ ] Clock skew is a deliberate number, not an accepted default of 60s. [→ 07 §3](07-edge-cases.md) +- [ ] No PII in claims. A JWT is signed, not encrypted. [→ 07 §7](07-edge-cases.md) +- [ ] Token size measured against your proxy's header limit, with the most privileged user's token. [→ 07 §8](07-edge-cases.md) + +## Lifetimes and revocation + +- [ ] Access-token TTL is minutes, not hours or days. +- [ ] Refresh tokens rotate on use, and a replay invalidates the family. [→ 07 §5](07-edge-cases.md) +- [ ] Every token carries a `jti`, and a denylist exists for logout, password change, and compromise. [→ 07 §4](07-edge-cases.md#logout-and-revocation) +- [ ] The denylist is shared across instances (Redis, not a `ConcurrentHashMap`) and entries expire. +- [ ] "Log out everywhere" is possible — usually a per-user `tokensValidAfter` timestamp compared against `iat`. + +## Chain configuration + +- [ ] CSRF decision is deliberate and matches where the token lives: disabled **only** if no credential is ambient. [→ 04](04-csrf-permitall-403.md) +- [ ] `SessionCreationPolicy.STATELESS` **and** `NullSecurityContextRepository`. Verify no `Set-Cookie` appears in a response. [→ 06](06-securitycontext-and-statelessness.md) +- [ ] `formLogin`, `httpBasic` and `logout` are explicitly disabled if unused — otherwise a browser-shaped fallback exists on your API. +- [ ] Custom filter is `addFilterBefore(..., UsernamePasswordAuthenticationFilter.class)`, extends `OncePerRequestFilter`, and is **not** also registered as a servlet filter. [→ 02](02-filter-chain-and-ordering.md) +- [ ] `anyRequest()` is the last rule. [→ 07 §12](07-edge-cases.md) +- [ ] The filter clears the `SecurityContext` on every failure path. [→ 02](02-filter-chain-and-ordering.md) +- [ ] `AuthenticationEntryPoint` and `AccessDeniedHandler` are both configured, and login failures have a `@RestControllerAdvice`. [→ 03](03-401-vs-403.md) +- [ ] The filter-chain diagnostic endpoint (`/api/public/filters` here) is **removed**. + +## Responses + +- [ ] Login failures are indistinguishable across bad-password, unknown-user, locked and disabled. [→ 03](03-401-vs-403.md) +- [ ] `error_description` does not leak expiry timestamps or internal URLs in production. [→ 07 §16](07-edge-cases.md) +- [ ] 401 carries `WWW-Authenticate` with a real RFC 6750 error code, not a bare realm. [→ 07 §15](07-edge-cases.md) +- [ ] Rate limiting on `/login` and `/refresh`. Nothing in Spring Security does this for you, and an unthrottled login endpoint with bcrypt is also a CPU denial-of-service. + +## Transport and operations + +- [ ] HTTPS enforced; HSTS on. +- [ ] Tokens never in URLs, and `allowUriQueryParameter` is off. [→ 07 §13](07-edge-cases.md) +- [ ] Access logs do not record the `Authorization` header. +- [ ] Authentication failures are logged with enough context to alert on, and a spike in `invalid_token` is alertable. +- [ ] `@Async`/executor boundaries wrap the `SecurityContext`. [→ 06](06-securitycontext-and-statelessness.md) +- [ ] Dependency scanning covers `nimbus-jose-jwt` — it is where JOSE CVEs land. + +## Before you build any of this + +Ask whether you should. If you need sessions and have one server-rendered application, +a session cookie is simpler, revocable by design, and has no key management. If you need +federated identity, an authorization server (Keycloak, Auth0, Okta, Spring Authorization +Server) already implements every item on this list. A hand-rolled JWT layer is the right +answer for a stateless API you own end to end — and a lot of work everywhere else. diff --git a/docs/11-spring-security-7-changes.md b/docs/11-spring-security-7-changes.md new file mode 100644 index 0000000..827b3df --- /dev/null +++ b/docs/11-spring-security-7-changes.md @@ -0,0 +1,132 @@ +# 11 — What changed in Spring Security 7 + +[← production checklist](10-production-checklist.md) · [README](../README.md) + +Everything below was hit while building this repository against Spring Security **7.1.1** +on Spring Boot **4.1.1**, JDK **25**. Verified by compiling or by reading real responses, +not from release notes alone. + +## `FACTOR_BEARER` in your authorities + +Every bearer-token authentication now carries an extra authority: + +```json +"authorities": ["FACTOR_BEARER", "ROLE_USER", "SCOPE_profile:read"] +``` + +It backs the new multi-factor authorization support — +`AuthorizationManagerFactories.multiFactor()`, `@EnableMultiFactorAuthentication`, and in +7.1 the `when` / `withWhen` conditions and `MultiFactorCondition.WEBAUTHN_REGISTERED`. +Harmless until a test asserts an exact authority set, or code assumes every authority +starts with `ROLE_` or `SCOPE_`. + +## `resource_metadata` in every `WWW-Authenticate` + +``` +WWW-Authenticate: Bearer realm="jwt-auth-demo", + resource_metadata="http://localhost:8080/.well-known/oauth-protected-resource" +``` + +Spring Security 7 adds `OAuth2ProtectedResourceMetadataFilter` to the resource-server +chain — RFC 9728, OAuth 2.0 Protected Resource Metadata. Visible in the +[resource-server chain](output/resource-server-loose.txt) and in the entry point's output +even on the manual profile, because `BearerTokenAuthenticationEntryPoint` emits it. + +7.1 additionally includes `charset` in `WWW-Authenticate` ([gh-18755]). + +## `NimbusJwtEncoder` builders (7.0+) and their method names + +```java +NimbusJwtEncoder.withSecretKey(secretKey).algorithm(MacAlgorithm.HS256).build(); +NimbusJwtEncoder.withKeyPair(rsaPublic, rsaPrivate).algorithm(SignatureAlgorithm.RS256).build(); +NimbusJwtEncoder.withKeyPair(ecPublic, ecPrivate).build(); +``` + +Two traps: + +- the builder method is **`algorithm(..)`**, not `jwsAlgorithm(..)` — while the *decoder* + builders use `macAlgorithm(..)` and `signatureAlgorithm(..)`; +- there is **no `keyId(..)`**. Set `kid` through `jwkPostProcessor(jwk -> jwk.keyID(..))`. + +The pre-7.0 form still compiles: + +```java +new NimbusJwtEncoder(new ImmutableSecret<>(secretKey)); +``` + +`setJwkSelector(List::getFirst)` (6.5+) resolves the "multiple matching JWKs" exception. + +## Three sibling classes, three packages + +```java +org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationEntryPoint +org.springframework.security.oauth2.server.resource.web.access.BearerTokenAccessDeniedHandler +org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter +``` + +Auto-import will confidently pick the wrong one. This cost a compile cycle here. + +## Jackson 3 + +Spring Security 7 moves to Jackson 3 (`tools.jackson.*`). `SecurityJackson2Modules` is +replaced by `SecurityJacksonModules` with `JsonMapper.Builder`. Boot 4.1.1 resolves +`tools.jackson.core:jackson-databind:3.1.5`. If you serialise a `SecurityContext` — into +a session store, a cache, a Redis-backed denylist — that code changes. See the +[Jackson 2 to 3 migration guide](https://ankurm.com/jackson-3-migration-guide/). + +## Boot 4 test slices moved + +`@AutoConfigureMockMvc` is now `org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc`, +in `spring-boot-starter-webmvc-test`. Security test support is in +`spring-boot-starter-security-test`. `spring-boot-starter-test` alone no longer suffices. +[→ doc 08](08-testing.md) + +## Boot 4.1: SpEL authority extraction + +```yaml +spring: + security: + oauth2: + resourceserver: + jwt: + authorities-claim-expressions: "['realm_access']['roles']" + authority-prefix: "ROLE_" +``` + +Mutually exclusive with `authorities-claim-name` / `authorities-claim-delimiter`. This is +the property-only answer to Keycloak-style nested role claims, which previously needed a +custom converter. + +## `csrf.spa()` is new in 7.0 + +```java +.csrf(csrf -> csrf.spa()) +``` + +One call for `CookieCsrfTokenRepository` + `XorCsrfTokenRequestAttributeHandler` + +deferred token loading. Checked against the jars: absent from `spring-security-config` +6.4.7 and 6.5.1, present in 7.0.0. Several guides describe it as a 6.x feature. + +## Other 7.1 additions worth knowing + +- `RestClientOpaqueTokenIntrospector` ([gh-18745]) — the `RestClient`-based replacement for the `RestTemplate` introspector, for opaque rather than JWT tokens. +- `ConditionalAuthorizationManager` and `AllRequiredFactorsAuthorizationManager.anyOf` ([gh-18960]). +- `PreFlightRequestFilter` CORS support ([gh-18926]). +- `InetAddressMatcher` ([gh-18634]). +- WebAuthn now publishes authentication events ([gh-18113]). + +## Migrating from 6.x + +Spring Security's own advice: go to **6.5** first, use its opt-in switches to adopt the +7.0 behaviours one at a time, then upgrade. The 6.5 preparation steps exist precisely so +that the 7.0 jump is a version bump rather than a rewrite. + +ankurm.com has a dedicated +[Spring Security 5 → 6 → 7 migration guide](https://ankurm.com/spring-security-5-to-6-to-7-migration-guide/). + +[gh-18755]: https://github.com/spring-projects/spring-security/issues/18755 +[gh-18745]: https://github.com/spring-projects/spring-security/issues/18745 +[gh-18960]: https://github.com/spring-projects/spring-security/issues/18960 +[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 diff --git a/docs/output/csrf-trace.txt b/docs/output/csrf-trace.txt new file mode 100644 index 0000000..7dec6b5 --- /dev/null +++ b/docs/output/csrf-trace.txt @@ -0,0 +1,19 @@ +========================================================================== + Spring Security TRACE log: why a permitAll() endpoint answers 403 + profiles: hs256,csrfon,trace request: POST /api/auth/login +========================================================================== + +DEBUG [nio-8080-exec-2] o.s.security.web.FilterChainProxy : Securing POST /api/auth/login +TRACE [nio-8080-exec-2] o.s.security.web.FilterChainProxy : Invoking DisableEncodeUrlFilter (1/12) +TRACE [nio-8080-exec-2] o.s.security.web.FilterChainProxy : Invoking WebAsyncManagerIntegrationFilter (2/12) +TRACE [nio-8080-exec-2] o.s.security.web.FilterChainProxy : Invoking SecurityContextHolderFilter (3/12) +TRACE [nio-8080-exec-2] o.s.security.web.FilterChainProxy : Invoking HeaderWriterFilter (4/12) +TRACE [nio-8080-exec-2] o.s.security.web.FilterChainProxy : Invoking CsrfFilter (5/12) +TRACE [nio-8080-exec-2] s.s.w.c.CsrfTokenRequestAttributeHandler : Wrote a CSRF token to the following request attributes: [_csrf, org.springframework.security.web.csrf.CsrfToken] +TRACE [nio-8080-exec-2] o.s.s.web.csrf.CsrfTokenRequestHandler : Did not find a CSRF token in the [X-XSRF-TOKEN] request header +TRACE [nio-8080-exec-2] o.s.s.web.csrf.CsrfTokenRequestHandler : Did not find a CSRF token in the [_csrf] request parameter +DEBUG [nio-8080-exec-2] o.s.security.web.csrf.CsrfFilter : Invalid CSRF token found for http://localhost:8080/api/auth/login +TRACE [nio-8080-exec-2] o.s.s.w.header.writers.HstsHeaderWriter : Not injecting HSTS header since it did not match request to [Is Secure] + +The chain stops at filter 5 of 12. AuthorizationFilter - the only filter that +has ever heard of permitAll() - is number 12. It is never invoked. diff --git a/docs/output/csrf-vs-permitall.txt b/docs/output/csrf-vs-permitall.txt new file mode 100644 index 0000000..03b15cc --- /dev/null +++ b/docs/output/csrf-vs-permitall.txt @@ -0,0 +1,54 @@ +========================================================================== + jwt-auth-demo - CSRF vs permitAll() + app started with: --spring.profiles.active=hs256,csrfon +========================================================================== + +-------------------------------------------------------------------------- +# A. The login endpoint is permitAll(). POST it anyway. +# 403 - and nothing in the authorization rules explains why. + +HTTP 403 +WWW-Authenticate: Bearer + +-------------------------------------------------------------------------- +# B. GET on the same permitAll() path family works. Only unsafe methods break. + +HTTP 200 +Content-Type: application/json +{ + "authenticationRequired": false, + "status": "up" +} + + +-------------------------------------------------------------------------- +# C. The filter chain, with CsrfFilter present. Count the positions: +# CsrfFilter is 5th, AuthorizationFilter is last. The request never +# reaches the filter that knows about permitAll(). + +HTTP 200 +Content-Type: application/json +[ + { + "matchesThisRequest": true, + "filters": [ + "DisableEncodeUrlFilter", + "WebAsyncManagerIntegrationFilter", + "SecurityContextHolderFilter", + "HeaderWriterFilter", + "CsrfFilter", + "JwtAuthenticationFilter", + "RequestCacheAwareFilter", + "SecurityContextHolderAwareRequestFilter", + "AnonymousAuthenticationFilter", + "SessionManagementFilter", + "ExceptionTranslationFilter", + "AuthorizationFilter" + ], + "chain": "DefaultSecurityFilterChain defined as 'apiFilterChain' in [class path resource [com/ankurm/jwtauth/config/SecurityConfig.class]] matching [any request] and having filters [DisableEncodeUrl, WebAsyncManagerIntegration, SecurityContextHolder, HeaderWriter, Csrf, JwtAuthentication, RequestCacheAware, SecurityContextHolderAwareRequest, AnonymousAuthentication, SessionManagement, ExceptionTranslation, Authorization]" + } +] + + +-------------------------------------------------------------------------- +# end diff --git a/docs/output/curl-transcript-hs256.txt b/docs/output/curl-transcript-hs256.txt new file mode 100644 index 0000000..801366c --- /dev/null +++ b/docs/output/curl-transcript-hs256.txt @@ -0,0 +1,253 @@ +========================================================================== + jwt-auth-demo - curl transcript + target : http://localhost:8080 +========================================================================== + +-------------------------------------------------------------------------- +# 1. Public endpoint, no token. permitAll() means the filter chain lets it through. + +HTTP 200 +Content-Type: application/json +{ + "status": "up", + "authenticationRequired": false +} + + +-------------------------------------------------------------------------- +# 2. Protected endpoint, no token. 401 - we do not know who you are. + +HTTP 401 +WWW-Authenticate: Bearer realm="jwt-auth-demo", resource_metadata="http://localhost:8080/.well-known/oauth-protected-resource" + +-------------------------------------------------------------------------- +# 3. Wrong password. Still 401, and the body says nothing about which half was wrong. + +HTTP 401 +Content-Type: application/problem+json +{ + "detail": "Invalid username or password", + "instance": "/api/auth/login", + "status": 401, + "title": "Authentication failed", + "type": "https://ankurm.com/problems/invalid-credentials" +} + + +-------------------------------------------------------------------------- +# 4. Locked account. 401 with the identical body - no account-state oracle. + +HTTP 401 +Content-Type: application/problem+json +{ + "detail": "Invalid username or password", + "instance": "/api/auth/login", + "status": 401, + "title": "Authentication failed", + "type": "https://ankurm.com/problems/invalid-credentials" +} + + +-------------------------------------------------------------------------- +# 5. Login as alice (ROLE_USER, SCOPE_profile:read). + +HTTP 200 +Content-Type: application/json +{ + "accessToken": "eyJraWQiOiIxMDNZZDJyWjlMQkh2cUEwSTA5aWY5RWVBMXd1Nm5iejBrVlBRWTR4M1hvIiwidHlwIjoiSldUIiwiYWxnIjoiSFMyNTYifQ.eyJzdWIiOiJhbGljZSIsImF1ZCI6Imp3dC1hdXRoLWRlbW8tYXBpIiwibmJmIjoxNzg3Mzc5MzgxLCJzY29wZSI6InByb2ZpbGU6cmVhZCIsInJvbGVzIjpbIlVTRVIiXSwiaXNzIjoiaHR0cHM6Ly9qd3QtYXV0aC1kZW1vLmFua3VybS5jb20iLCJleHAiOjE3ODczODAyODEsInRva2VuX3R5cGUiOiJhY2Nlc3MiLCJpYXQiOjE3ODczNzkzODEsImp0aSI6IjkzMGNiNWQ3LWZiNzctNDIxZC05MmViLWU3NzYxMWZiODQxOCJ9.vIbXeXd7_VKM7qYmoUTaYwePWX1x0yGbeuzPmrCvC00", + "refreshToken": "eyJraWQiOiIxMDNZZDJyWjlMQkh2cUEwSTA5aWY5RWVBMXd1Nm5iejBrVlBRWTR4M1hvIiwidHlwIjoiSldUIiwiYWxnIjoiSFMyNTYifQ.eyJzdWIiOiJhbGljZSIsImF1ZCI6Imp3dC1hdXRoLWRlbW8tYXBpIiwibmJmIjoxNzg3Mzc5MzgxLCJpc3MiOiJodHRwczovL2p3dC1hdXRoLWRlbW8uYW5rdXJtLmNvbSIsImV4cCI6MTc4NzQwODE4MSwidG9rZW5fdHlwZSI6InJlZnJlc2giLCJpYXQiOjE3ODczNzkzODEsImp0aSI6ImM5ODQzZGYyLWM2YTMtNDA4Ni05NDlmLTg0OTBkYTE1ZjI3NCJ9.om1nFZhD-5psTyKXLtW88gfaKdqmDDy5_n3PvBn2spI", + "tokenType": "Bearer", + "expiresIn": 900 +} + + +-------------------------------------------------------------------------- +# 6. What is actually inside that token (base64url decode - no signature check). + +--- JOSE header --- +{ + "kid": "103Yd2rZ9LBHvqA0I09if9EeA1wu6nbz0kVPQY4x3Xo", + "typ": "JWT", + "alg": "HS256" +} +--- claims --- +{ + "sub": "alice", + "aud": "jwt-auth-demo-api", + "nbf": 1787379381, + "scope": "profile:read", + "roles": [ + "USER" + ], + "iss": "https://jwt-auth-demo.ankurm.com", + "exp": 1787380281, + "token_type": "access", + "iat": 1787379381, + "jti": "930cb5d7-fb77-421d-92eb-e77611fb8418" +} + +-------------------------------------------------------------------------- +# 7. The same protected endpoint, now with the token. 200. + +HTTP 200 +Content-Type: application/json +{ + "name": "alice", + "authorities": [ + "FACTOR_BEARER", + "ROLE_USER", + "SCOPE_profile:read" + ], + "authenticationType": "JwtAuthenticationToken", + "jti": "930cb5d7-fb77-421d-92eb-e77611fb8418", + "issuer": "https://jwt-auth-demo.ankurm.com", + "audience": [ + "jwt-auth-demo-api" + ], + "issuedAt": "2026-08-22T06:16:21Z", + "expiresAt": "2026-08-22T06:31:21Z", + "algorithm": "HS256", + "keyId": "103Yd2rZ9LBHvqA0I09if9EeA1wu6nbz0kVPQY4x3Xo" +} + + +-------------------------------------------------------------------------- +# 8. alice hits an ADMIN endpoint. 403, not 401 - we know who she is, she may not. + +HTTP 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" + +-------------------------------------------------------------------------- +# 9. Same request with a scope-based rule (@PreAuthorize). Also 403. + +HTTP 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" + +-------------------------------------------------------------------------- +# 10. Login as root and repeat. 200. + +HTTP 200 +Content-Type: application/json +{ + "accessToken": "eyJraWQiOiIxMDNZZDJyWjlMQkh2cUEwSTA5aWY5RWVBMXd1Nm5iejBrVlBRWTR4M1hvIiwidHlwIjoiSldUIiwiYWxnIjoiSFMyNTYifQ.eyJzdWIiOiJyb290IiwiYXVkIjoiand0LWF1dGgtZGVtby1hcGkiLCJuYmYiOjE3ODczNzkzODIsInNjb3BlIjoiYWRtaW46cmVhZCBwcm9maWxlOnJlYWQiLCJyb2xlcyI6WyJBRE1JTiIsIlVTRVIiXSwiaXNzIjoiaHR0cHM6Ly9qd3QtYXV0aC1kZW1vLmFua3VybS5jb20iLCJleHAiOjE3ODczODAyODIsInRva2VuX3R5cGUiOiJhY2Nlc3MiLCJpYXQiOjE3ODczNzkzODIsImp0aSI6ImI0NzRlOGU4LWI1NzUtNDBlOS04MDMyLTEwZjk3ODMwNWE3NiJ9.8bE2iAn5XecooZMSD8AGHl3PqTzw_KAOUugTi-_VgqA", + "refreshToken": "eyJraWQiOiIxMDNZZDJyWjlMQkh2cUEwSTA5aWY5RWVBMXd1Nm5iejBrVlBRWTR4M1hvIiwidHlwIjoiSldUIiwiYWxnIjoiSFMyNTYifQ.eyJzdWIiOiJyb290IiwiYXVkIjoiand0LWF1dGgtZGVtby1hcGkiLCJuYmYiOjE3ODczNzkzODIsImlzcyI6Imh0dHBzOi8vand0LWF1dGgtZGVtby5hbmt1cm0uY29tIiwiZXhwIjoxNzg3NDA4MTgyLCJ0b2tlbl90eXBlIjoicmVmcmVzaCIsImlhdCI6MTc4NzM3OTM4MiwianRpIjoiOWM3NTRjMmEtMDU0Zi00N2ZmLTkzZjktNTAxNTY3ZDI5NjFiIn0.FSR9SUvWqBZZSQMcKJwnUSgF9-B57wmptF-msvpgR8M", + "tokenType": "Bearer", + "expiresIn": 900 +} + +HTTP 200 +Content-Type: application/json +{ + "requiredRole": "ROLE_ADMIN", + "activeUsers": 3 +} + + +-------------------------------------------------------------------------- +# 11. Tampered payload, original signature. 401 invalid_token. + +HTTP 401 +WWW-Authenticate: Bearer realm="jwt-auth-demo", error="invalid_token", error_description="An error occurred while attempting to decode the Jwt: Signed JWT rejected: Invalid signature", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1", resource_metadata="http://localhost:8080/.well-known/oauth-protected-resource" + +-------------------------------------------------------------------------- +# 12. Garbage where a token should be. 401, and note it is NOT 400. + +HTTP 401 +WWW-Authenticate: Bearer realm="jwt-auth-demo", 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:8080/.well-known/oauth-protected-resource" + +-------------------------------------------------------------------------- +# 13. Authorization header with no Bearer scheme. The resolver sees no token at all,\n# so this is an authorization failure, not a token failure - note the bare realm. + +HTTP 401 +WWW-Authenticate: Bearer realm="jwt-auth-demo", resource_metadata="http://localhost:8080/.well-known/oauth-protected-resource" + +-------------------------------------------------------------------------- +# 14. A refresh token presented as an access token. 401 - the token_type claim. + +HTTP 401 +WWW-Authenticate: Bearer realm="jwt-auth-demo", error="invalid_token", error_description="This endpoint accepts access tokens only", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1", resource_metadata="http://localhost:8080/.well-known/oauth-protected-resource" + +-------------------------------------------------------------------------- +# 15. Refresh with rotation. New access token, new refresh token. + +HTTP 200 +Content-Type: application/json +{ + "accessToken": "eyJraWQiOiIxMDNZZDJyWjlMQkh2cUEwSTA5aWY5RWVBMXd1Nm5iejBrVlBRWTR4M1hvIiwidHlwIjoiSldUIiwiYWxnIjoiSFMyNTYifQ.eyJzdWIiOiJhbGljZSIsImF1ZCI6Imp3dC1hdXRoLWRlbW8tYXBpIiwibmJmIjoxNzg3Mzc5MzgyLCJzY29wZSI6InByb2ZpbGU6cmVhZCIsInJvbGVzIjpbIlVTRVIiXSwiaXNzIjoiaHR0cHM6Ly9qd3QtYXV0aC1kZW1vLmFua3VybS5jb20iLCJleHAiOjE3ODczODAyODIsInRva2VuX3R5cGUiOiJhY2Nlc3MiLCJpYXQiOjE3ODczNzkzODIsImp0aSI6IjkyZjU3ZWMzLTk5OGYtNDkzNC05NjgxLTVkMmM2MWM3OThhOCJ9.pw4kOzw-ZcpcP9VyjgvKG6s5TtSTMrBwU255vG3BJ6g", + "refreshToken": "eyJraWQiOiIxMDNZZDJyWjlMQkh2cUEwSTA5aWY5RWVBMXd1Nm5iejBrVlBRWTR4M1hvIiwidHlwIjoiSldUIiwiYWxnIjoiSFMyNTYifQ.eyJzdWIiOiJhbGljZSIsImF1ZCI6Imp3dC1hdXRoLWRlbW8tYXBpIiwibmJmIjoxNzg3Mzc5MzgyLCJpc3MiOiJodHRwczovL2p3dC1hdXRoLWRlbW8uYW5rdXJtLmNvbSIsImV4cCI6MTc4NzQwODE4MiwidG9rZW5fdHlwZSI6InJlZnJlc2giLCJpYXQiOjE3ODczNzkzODIsImp0aSI6IjBmNWRjOGVkLWQ0MjItNDUwMS04OTJiLTRlY2Q2MWMxNDA3NyJ9.K4gemuQ8g3DJUmgb61sCWlTKsg3ImSljet4ogd6PJaM", + "tokenType": "Bearer", + "expiresIn": 900 +} + + +-------------------------------------------------------------------------- +# 16. Replay the spent refresh token. 401 - rotation makes replay detectable. + +HTTP 401 +Content-Type: application/problem+json +{ + "detail": "Invalid username or password", + "instance": "/api/auth/refresh", + "status": 401, + "title": "Authentication failed", + "type": "https://ankurm.com/problems/invalid-credentials" +} + + +-------------------------------------------------------------------------- +# 17. Logout revokes the presented access token by its jti. + +HTTP 204 +HTTP 200 +Content-Type: application/json +{ + "revokedTokens": 2 +} + + +-------------------------------------------------------------------------- +# 18. The revoked token, still cryptographically valid, is now refused. + +HTTP 401 +WWW-Authenticate: Bearer realm="jwt-auth-demo", error="invalid_token", error_description="Token has been revoked", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1", resource_metadata="http://localhost:8080/.well-known/oauth-protected-resource" + +-------------------------------------------------------------------------- +# 19. The real filter order, read from FilterChainProxy at runtime. + +HTTP 200 +Content-Type: application/json +[ + { + "filters": [ + "DisableEncodeUrlFilter", + "WebAsyncManagerIntegrationFilter", + "SecurityContextHolderFilter", + "HeaderWriterFilter", + "JwtAuthenticationFilter", + "RequestCacheAwareFilter", + "SecurityContextHolderAwareRequestFilter", + "AnonymousAuthenticationFilter", + "SessionManagementFilter", + "ExceptionTranslationFilter", + "AuthorizationFilter" + ], + "matchesThisRequest": true, + "chain": "DefaultSecurityFilterChain defined as 'apiFilterChain' in [class path resource [com/ankurm/jwtauth/config/SecurityConfig.class]] matching [any request] and having filters [DisableEncodeUrl, WebAsyncManagerIntegration, SecurityContextHolder, HeaderWriter, JwtAuthentication, RequestCacheAware, SecurityContextHolderAwareRequest, AnonymousAuthentication, SessionManagement, ExceptionTranslation, Authorization]" + } +] + + +-------------------------------------------------------------------------- +# 20. SecurityContext across a thread boundary. + +HTTP 200 +Content-Type: application/json +{ + "onRequestThread": "root", + "onPlainExecutor": "null (context did not cross the thread)", + "onDelegatingExecutor": "root" +} + + +-------------------------------------------------------------------------- +# end of transcript diff --git a/docs/output/expiry-and-clock-skew.txt b/docs/output/expiry-and-clock-skew.txt new file mode 100644 index 0000000..4f9529e --- /dev/null +++ b/docs/output/expiry-and-clock-skew.txt @@ -0,0 +1,40 @@ +========================================================================== + jwt-auth-demo - token expiry and the 60-second clock skew + profiles: hs256,shortlived (access-token-ttl = 2s) +========================================================================== + +-------------------------------------------------------------------------- +# T+0s - fresh token + +HTTP 200 +{ + "name": "alice", + "authorities": [ + "FACTOR_BEARER", + "ROLE_USER", + "SCOPE_profile:read" + + +-------------------------------------------------------------------------- +# T+5s - exp has passed, but JwtTimestampValidator allows 60s of clock skew +# by default, so the token is STILL accepted. This surprises people +# who write a test that sleeps past exp and expects a 401. + +HTTP 200 +{ + "name": "alice", + "authorities": [ + "FACTOR_BEARER", + "ROLE_USER", + "SCOPE_profile:read" + + +-------------------------------------------------------------------------- +# T+65s - past exp + the 60s skew window. Now it is refused. + +HTTP 401 +WWW-Authenticate: Bearer realm="jwt-auth-demo", error="invalid_token", error_description="An error occurred while attempting to decode the Jwt: Jwt expired at 2026-08-22T06:16:50Z", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1", resource_metadata="http://localhost:8080/.well-known/oauth-protected-resource" + + +-------------------------------------------------------------------------- +# end diff --git a/docs/output/resource-server-loose.txt b/docs/output/resource-server-loose.txt new file mode 100644 index 0000000..dc5459f --- /dev/null +++ b/docs/output/resource-server-loose.txt @@ -0,0 +1,63 @@ +========================================================================== + jwt-auth-demo - built-in resource server + profiles: hs256,resourceserver +========================================================================== + +-------------------------------------------------------------------------- +# 1. The filter chain. Note BearerTokenAuthenticationFilter in place of our +# hand-written JwtAuthenticationFilter - same slot, framework-owned. + +HTTP 200 +[ + { + "matchesThisRequest": true, + "filters": [ + "DisableEncodeUrlFilter", + "WebAsyncManagerIntegrationFilter", + "SecurityContextHolderFilter", + "HeaderWriterFilter", + "LogoutFilter", + "OAuth2ProtectedResourceMetadataFilter", + + +-------------------------------------------------------------------------- +# 2. Access token -> 200. + +HTTP 200 +{ + "name": "alice", + "authorities": [ + "FACTOR_BEARER", + "ROLE_USER", + "SCOPE_profile:read" + ], + "authenticationType": "JwtAuthenticationToken", + "jti": "5dc2e769-9413-44e4-9bcb-6ab9fe1f6b2e", + "issuer": "https://jwt-auth-demo.ankurm.com", + + +-------------------------------------------------------------------------- +# 3. Non-admin on an admin route -> 403 insufficient_scope. + +HTTP 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" + +-------------------------------------------------------------------------- +# 4. REFRESH token presented as an access token. +# This is the line to watch when comparing the two runs. + +HTTP 200 +{ + "name": "alice", + "authorities": [ + "FACTOR_BEARER" + ], + "authenticationType": "JwtAuthenticationToken", + "jti": "8e069702-70a3-4029-89e8-d03c8b3e01ce", + "issuer": "https://jwt-auth-demo.ankurm.com", + "audience": [ + "jwt-auth-demo-api" + + +-------------------------------------------------------------------------- +# end diff --git a/docs/output/resource-server-strict.txt b/docs/output/resource-server-strict.txt new file mode 100644 index 0000000..2f77577 --- /dev/null +++ b/docs/output/resource-server-strict.txt @@ -0,0 +1,53 @@ +========================================================================== + jwt-auth-demo - built-in resource server + profiles: hs256,resourceserver,strict +========================================================================== + +-------------------------------------------------------------------------- +# 1. The filter chain. Note BearerTokenAuthenticationFilter in place of our +# hand-written JwtAuthenticationFilter - same slot, framework-owned. + +HTTP 200 +[ + { + "chain": "DefaultSecurityFilterChain defined as 'apiFilterChain' in [class path resource [com/ankurm/jwtauth/config/ResourceServerSecurityConfig.class]] matching [any request] and having filters [DisableEncodeUrl, WebAsyncManagerIntegration, SecurityContextHolder, HeaderWriter, Logout, OAuth2ProtectedResourceMetadata, BearerTokenAuthentication, RequestCacheAware, SecurityContextHolderAwareRequest, AnonymousAuthentication, SessionManagement, ExceptionTranslation, Authorization]", + "filters": [ + "DisableEncodeUrlFilter", + "WebAsyncManagerIntegrationFilter", + "SecurityContextHolderFilter", + "HeaderWriterFilter", + "LogoutFilter", + "OAuth2ProtectedResourceMetadataFilter", + + +-------------------------------------------------------------------------- +# 2. Access token -> 200. + +HTTP 200 +{ + "name": "alice", + "authorities": [ + "FACTOR_BEARER", + "ROLE_USER", + "SCOPE_profile:read" + ], + "authenticationType": "JwtAuthenticationToken", + "jti": "c140fc47-8b48-41e9-bd01-071d398b6c8b", + "issuer": "https://jwt-auth-demo.ankurm.com", + + +-------------------------------------------------------------------------- +# 3. Non-admin on an admin route -> 403 insufficient_scope. + +HTTP 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" + +-------------------------------------------------------------------------- +# 4. REFRESH token presented as an access token. +# This is the line to watch when comparing the two runs. + +HTTP 401 +WWW-Authenticate: Bearer error="invalid_token", error_description="An error occurred while attempting to decode the Jwt: Expected a token with token_type=access", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1", resource_metadata="http://localhost:8080/.well-known/oauth-protected-resource" + +-------------------------------------------------------------------------- +# end diff --git a/docs/output/rs256-demo.txt b/docs/output/rs256-demo.txt new file mode 100644 index 0000000..79a9504 --- /dev/null +++ b/docs/output/rs256-demo.txt @@ -0,0 +1,83 @@ +========================================================================== + jwt-auth-demo - RS256 variant + profiles: rs256 (private key signs, public key / JWKS verifies) +========================================================================== + +-------------------------------------------------------------------------- +# 1. The public half, published as a JWK Set. No private material here - +# n and e only. Any number of resource servers can poll this. + +HTTP 200 +{ + "keys": [ + { + "kty": "RSA", + "e": "AQAB", + "kid": "demo-rsa-2026-08", + "n": "5NEDQPQW0Gz6iR5-UNl7J7660_Psd5q1f5VamK9KTS9f6YhPPIG8mfi6zWe8XmxxdR_Bd2yaX-v_Wz6MgeFLbBVkRFfve_zVnq4-kgjhn8UaRK1iU0C1j-7SahD73hHqGaOjAlFNro5ygjGAcVL8RGVMxRMy4aaTAm3KB4EdG2hJFxyfCBqtkwsHTM_DXcoFTLTZ2bI-hPhN6uBxk7ykFaCnQ47yrKSM6kn0ul0dp22AK_4mP1SRDnnr3Da5GFhMKtBqy_GgXcJ9WTpxIYhlr8B5modtb5S34900VPScpoXiJdUhghxEpXbb1W_PlpVIElISHzldnwWOogdPncO0zQ" + } + ] +} + + +-------------------------------------------------------------------------- +# 2. Login. Same endpoint, same request, different signature algorithm. + +HTTP 200 +{ + "accessToken": "eyJraWQiOiJkZW1vLXJzYS0yMDI2LTA4IiwidHlwIjoiSldUIiwiYWxnIjoiUlMyNTYifQ.eyJzdWIiOiJyb290IiwiYXVkIjoiand0LWF1dGgtZGVtby1hcGkiLCJuYmYiOjE3ODczNzkzOTEsInNjb3BlIjoiYWRtaW46cmVhZCBwcm9maWxlOnJlYWQiLCJyb2xlcyI6WyJBRE1JTiIsIlVTRVIiXSwiaXNzIjoiaHR0cHM6Ly9qd3QtYXV0aC1kZW1vLmFua3VybS5jb20iLCJleHAiOjE3ODczODAyOTEsInRva2VuX3R5cGUiOiJhY2Nlc3MiLCJpYXQiOjE3ODczNzkzOTEsImp0aSI6IjIwZWZiMmRhLTkwYWEtNGZjYy04ZTU1LWI0NjhkNDAxMWUwYSJ9.0sPi-ArszI-wKbMuZes2bUCQbi3b68hyNngUPohzrKhWgYlHThu_JIq6gIFcYhq6qK1UYrL2c2lI6uxflSHjdtl5vRhvOUHlDc63eDtzQFIMnC9-kitwwi_x5pV09IxYBVRo38K5WkD9uiIYnNoAenNLhoAfAV424464oi2X_XtsPlXdrhUjCFl8nLghAZSsDaVvTFW9PKHtWfdJUZht2vcZI54TaNFFQKIjAOjTObAwFg9kkzXzcoNiH3wBYKXe4hy3IRDxi62Zl0-eVA67a-ODGqbsPn3BVMbaaLN8iX3OKZtBAUTpYNQQ9StkBfPds5PrxbL_i9EI-j2GpXzA8Q", + "refreshToken": "eyJraWQiOiJkZW1vLXJzYS0yMDI2LTA4IiwidHlwIjoiSldUIiwiYWxnIjoiUlMyNTYifQ.eyJzdWIiOiJyb290IiwiYXVkIjoiand0LWF1dGgtZGVtby1hcGkiLCJuYmYiOjE3ODczNzkzOTEsImlzcyI6Imh0dHBzOi8vand0LWF1dGgtZGVtby5hbmt1cm0uY29tIiwiZXhwIjoxNzg3NDA4MTkxLCJ0b2tlbl90eXBlIjoicmVmcmVzaCIsImlhdCI6MTc4NzM3OTM5MSwianRpIjoiMjdiOGVhZmMtMGY1NS00YTQ3LWJlYjMtNjJlZDlkOTFmYzgzIn0.pnT-xsAMa-4a1VdUA3_98dGYD-6A7wYKOGUCdmnWbBL44JT2D159w4LASgAAr41Vd7onzlbCgK-cIXlscLmsDSm3Qo0xTO9VjeLYkmFByfSHM7YnG1ecZCuv6PXcqGvpTY_VT35oou0J5qpAl3pBIbkI9mtlI-mBj4ecnlZAEewki_gatloopkDUhg_5xitCeYZfZhB1nvEyCHcv42hpvGHUDBVtebNo6m7rVSpgr7BfVSyAi057qZR0m0Ddm-oJk252vYIQWu9ZH3QE15bMbP7wKOPHfeX1VfuaaUh_o9EgIEblaW1rD9MhupjokUhd2G5OfEbBA_4EDEIB3SpHWg", + "tokenType": "Bearer", + "expiresIn": 900 +} + + +-------------------------------------------------------------------------- +# 3. The JOSE header now carries alg=RS256 and the kid that selects the key. + +{ + "kid": "demo-rsa-2026-08", + "typ": "JWT", + "alg": "RS256" +} + +-------------------------------------------------------------------------- +# 4. Token length. RS256 signatures are 256 bytes; HS256 signatures are 32. + + RS256 access token: 758 characters + signature segment : 343 characters + +-------------------------------------------------------------------------- +# 5. It works exactly the same from the caller's side. + +HTTP 200 +{ + "name": "root", + "authorities": [ + "FACTOR_BEARER", + "ROLE_ADMIN", + "ROLE_USER", + "SCOPE_admin:read", + "SCOPE_profile:read" + ], + "authenticationType": "JwtAuthenticationToken", + "jti": "20efb2da-90aa-4fcc-8e55-b468d4011e0a", + "issuer": "https://jwt-auth-demo.ankurm.com", + "audience": [ + "jwt-auth-demo-api" + ], + "issuedAt": "2026-08-22T06:16:31Z", + "expiresAt": "2026-08-22T06:31:31Z", + "algorithm": "RS256", + "keyId": "demo-rsa-2026-08" +} + + +-------------------------------------------------------------------------- +# 6. Tampered payload, original signature -> 401, same as HS256. + +HTTP 401 +WWW-Authenticate: Bearer realm="jwt-auth-demo", error="invalid_token", error_description="An error occurred while attempting to decode the Jwt: Signed JWT rejected: Invalid signature", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1", resource_metadata="http://localhost:8080/.well-known/oauth-protected-resource" + +-------------------------------------------------------------------------- +# end diff --git a/docs/output/test-run.txt b/docs/output/test-run.txt new file mode 100644 index 0000000..6b17340 --- /dev/null +++ b/docs/output/test-run.txt @@ -0,0 +1,14 @@ +========================================================================== + jwt-auth-demo - test run +========================================================================== + +Running com.ankurm.jwtauth.AuthenticationFlowTests +Tests run: 10, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 6.134 s -- in com.ankurm.jwtauth.AuthenticationFlowTests +Running com.ankurm.jwtauth.CsrfBreaksPermitAllTests +Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.014 s -- in com.ankurm.jwtauth.CsrfBreaksPermitAllTests +Tests run: 13, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS + +JDK : openjdk version "25.0.4.1" 2026-08-18 LTS (Temurin 25.0.4.1+1) +Boot : 4.1.1 +Security : 7.1.1 diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..6be991b --- /dev/null +++ b/pom.xml @@ -0,0 +1,74 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 4.1.1 + + + + com.ankurm + jwt-auth-demo + 1.0.0 + jwt-auth-demo + Spring Security 7.1 JWT authentication on Spring Boot 4.1 - runnable companion for ankurm.com + + + 25 + UTF-8 + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.boot + spring-boot-starter-oauth2-resource-server + + + org.springframework.boot + spring-boot-starter-validation + + + + 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/scripts/csrf-demo.sh b/scripts/csrf-demo.sh new file mode 100755 index 0000000..4912d80 --- /dev/null +++ b/scripts/csrf-demo.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# Reproduces the "permitAll() endpoint still returns 403" failure. +# Requires: --spring.profiles.active=hs256,csrfon +set -u +BASE="${1:-http://localhost:8080}" +show() { + local out + out=$(curl -sS -D /tmp/.h -o /tmp/.b -w '%{http_code}' "$@") + printf 'HTTP %s\n' "$out" + grep -iE '^(www-authenticate|content-type):' /tmp/.h | sed 's/\r$//' + [ -s /tmp/.b ] && { python3 -m json.tool < /tmp/.b 2>/dev/null || cat /tmp/.b; echo; } +} +hr(){ printf '\n%s\n' "--------------------------------------------------------------------------"; } + +echo "==========================================================================" +echo " jwt-auth-demo - CSRF vs permitAll()" +echo " app started with: --spring.profiles.active=hs256,csrfon" +echo "==========================================================================" + +hr; echo "# A. The login endpoint is permitAll(). POST it anyway." +echo "# 403 - and nothing in the authorization rules explains why." +echo +show -X POST "$BASE/api/auth/login" -H 'Content-Type: application/json' \ + -d '{"username":"alice","password":"alice-password"}' + +hr; echo "# B. GET on the same permitAll() path family works. Only unsafe methods break." +echo +show "$BASE/api/public/ping" + +hr; echo "# C. The filter chain, with CsrfFilter present. Count the positions:" +echo "# CsrfFilter is 5th, AuthorizationFilter is last. The request never" +echo "# reaches the filter that knows about permitAll()." +echo +show "$BASE/api/public/filters" + +hr; echo "# end" diff --git a/scripts/curl-transcript.sh b/scripts/curl-transcript.sh new file mode 100755 index 0000000..9acd1f9 --- /dev/null +++ b/scripts/curl-transcript.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +# Regenerates docs/output/curl-transcript.txt against a running instance. +# Usage: ./scripts/curl-transcript.sh [base-url] +set -u +BASE="${1:-http://localhost:8080}" + +hr() { printf '\n%s\n' "--------------------------------------------------------------------------"; } +step(){ hr; printf '# %s\n\n' "$1"; } + +# Print status line, the security-relevant headers, and the body. +show() { + local out + out=$(curl -sS -D /tmp/.h -o /tmp/.b -w '%{http_code}' "$@") + printf 'HTTP %s\n' "$out" + grep -iE '^(www-authenticate|content-type|set-cookie):' /tmp/.h | sed 's/\r$//' + if [ -s /tmp/.b ]; then + python3 -m json.tool < /tmp/.b 2>/dev/null || cat /tmp/.b + echo + fi +} + +jwt_part() { # $1=token $2=0|1 -> pretty-print header or payload + echo "$1" | cut -d. -f$(( $2 + 1 )) \ + | tr '_-' '/+' | sed 's/$/==/' | base64 -d 2>/dev/null \ + | python3 -m json.tool 2>/dev/null +} + +echo "==========================================================================" +echo " jwt-auth-demo - curl transcript" +echo " target : $BASE" +echo "==========================================================================" + +step "1. Public endpoint, no token. permitAll() means the filter chain lets it through." +show "$BASE/api/public/ping" + +step "2. Protected endpoint, no token. 401 - we do not know who you are." +show "$BASE/api/me" + +step "3. Wrong password. Still 401, and the body says nothing about which half was wrong." +show -X POST "$BASE/api/auth/login" -H 'Content-Type: application/json' \ + -d '{"username":"alice","password":"wrong-password"}' + +step "4. Locked account. 401 with the identical body - no account-state oracle." +show -X POST "$BASE/api/auth/login" -H 'Content-Type: application/json' \ + -d '{"username":"locked","password":"locked-password"}' + +step "5. Login as alice (ROLE_USER, SCOPE_profile:read)." +show -X POST "$BASE/api/auth/login" -H 'Content-Type: application/json' \ + -d '{"username":"alice","password":"alice-password"}' +ALICE=$(python3 -c "import json;print(json.load(open('/tmp/.b'))['accessToken'])") +ALICE_REFRESH=$(python3 -c "import json;print(json.load(open('/tmp/.b'))['refreshToken'])") + +step "6. What is actually inside that token (base64url decode - no signature check)." +echo "--- JOSE header ---" +jwt_part "$ALICE" 0 +echo "--- claims ---" +jwt_part "$ALICE" 1 + +step "7. The same protected endpoint, now with the token. 200." +show "$BASE/api/me" -H "Authorization: Bearer $ALICE" + +step "8. alice hits an ADMIN endpoint. 403, not 401 - we know who she is, she may not." +show "$BASE/api/admin/stats" -H "Authorization: Bearer $ALICE" + +step "9. Same request with a scope-based rule (@PreAuthorize). Also 403." +show "$BASE/api/reports" -H "Authorization: Bearer $ALICE" + +step "10. Login as root and repeat. 200." +show -X POST "$BASE/api/auth/login" -H 'Content-Type: application/json' \ + -d '{"username":"root","password":"root-password"}' +ROOT=$(python3 -c "import json;print(json.load(open('/tmp/.b'))['accessToken'])") +show "$BASE/api/admin/stats" -H "Authorization: Bearer $ROOT" + +step "11. Tampered payload, original signature. 401 invalid_token." +HDR=$(echo "$ALICE" | cut -d. -f1); SIG=$(echo "$ALICE" | cut -d. -f3) +FORGED_PAYLOAD=$(echo "$ALICE" | cut -d. -f2 | tr '_-' '/+' | sed 's/$/==/' | base64 -d 2>/dev/null \ + | sed 's/"roles":\["USER"\]/"roles":["ADMIN"]/' | base64 -w0 | tr '/+' '_-' | tr -d '=') +show "$BASE/api/admin/stats" -H "Authorization: Bearer $HDR.$FORGED_PAYLOAD.$SIG" + +step "12. Garbage where a token should be. 401, and note it is NOT 400." +show "$BASE/api/me" -H "Authorization: Bearer not-a-jwt" + +step "13. Authorization header with no Bearer scheme. The resolver sees no token at all,\n# so this is an authorization failure, not a token failure - note the bare realm." +show "$BASE/api/me" -H "Authorization: $ALICE" + +step "14. A refresh token presented as an access token. 401 - the token_type claim." +show "$BASE/api/me" -H "Authorization: Bearer $ALICE_REFRESH" + +step "15. Refresh with rotation. New access token, new refresh token." +show -X POST "$BASE/api/auth/refresh" -H 'Content-Type: application/json' \ + -d "{\"refreshToken\":\"$ALICE_REFRESH\"}" +ALICE2=$(python3 -c "import json;print(json.load(open('/tmp/.b'))['accessToken'])") + +step "16. Replay the spent refresh token. 401 - rotation makes replay detectable." +show -X POST "$BASE/api/auth/refresh" -H 'Content-Type: application/json' \ + -d "{\"refreshToken\":\"$ALICE_REFRESH\"}" + +step "17. Logout revokes the presented access token by its jti." +show -X POST "$BASE/api/auth/logout" -H "Authorization: Bearer $ALICE2" +show "$BASE/api/auth/revocations" -H "Authorization: Bearer $ROOT" + +step "18. The revoked token, still cryptographically valid, is now refused." +show "$BASE/api/me" -H "Authorization: Bearer $ALICE2" + +step "19. The real filter order, read from FilterChainProxy at runtime." +show "$BASE/api/public/filters" + +step "20. SecurityContext across a thread boundary." +show "$BASE/api/async-demo" -H "Authorization: Bearer $ROOT" + +hr +echo "# end of transcript" diff --git a/scripts/expiry-demo.sh b/scripts/expiry-demo.sh new file mode 100755 index 0000000..e82e8ff --- /dev/null +++ b/scripts/expiry-demo.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# Requires: --spring.profiles.active=hs256,shortlived (2-second access tokens) +set -u +BASE="${1:-http://localhost:8080}" +show() { + local out; out=$(curl -sS -D /tmp/.h -o /tmp/.b -w '%{http_code}' "$@") + printf 'HTTP %s\n' "$out" + grep -iE '^www-authenticate:' /tmp/.h | sed 's/\r$//' + [ -s /tmp/.b ] && { python3 -m json.tool < /tmp/.b 2>/dev/null | head -6 || cat /tmp/.b; } + echo +} +hr(){ printf '\n%s\n' "--------------------------------------------------------------------------"; } + +echo "==========================================================================" +echo " jwt-auth-demo - token expiry and the 60-second clock skew" +echo " profiles: hs256,shortlived (access-token-ttl = 2s)" +echo "==========================================================================" + +TOK=$(curl -sS -X POST "$BASE/api/auth/login" -H 'Content-Type: application/json' \ + -d '{"username":"alice","password":"alice-password"}' | python3 -c 'import json,sys;print(json.load(sys.stdin)["accessToken"])') + +hr; echo "# T+0s - fresh token"; echo; show "$BASE/api/me" -H "Authorization: Bearer $TOK" + +hr; echo "# T+5s - exp has passed, but JwtTimestampValidator allows 60s of clock skew" +echo "# by default, so the token is STILL accepted. This surprises people" +echo "# who write a test that sleeps past exp and expects a 401." +echo; sleep 5; show "$BASE/api/me" -H "Authorization: Bearer $TOK" + +hr; echo "# T+65s - past exp + the 60s skew window. Now it is refused." +echo; sleep 60; show "$BASE/api/me" -H "Authorization: Bearer $TOK" + +hr; echo "# end" diff --git a/scripts/resource-server-demo.sh b/scripts/resource-server-demo.sh new file mode 100755 index 0000000..3393141 --- /dev/null +++ b/scripts/resource-server-demo.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Compares the built-in resource server with and without a token_type validator. +# Requires two runs; see docs/09-manual-filter-vs-resource-server.md +set -u +BASE="${1:-http://localhost:8080}" +LABEL="${2:-resourceserver}" +show() { + local out; out=$(curl -sS -D /tmp/.h -o /tmp/.b -w '%{http_code}' "$@") + printf 'HTTP %s\n' "$out" + grep -iE '^www-authenticate:' /tmp/.h | sed 's/\r$//' + [ -s /tmp/.b ] && { python3 -m json.tool < /tmp/.b 2>/dev/null | head -10 || cat /tmp/.b; echo; } +} +hr(){ printf '\n%s\n' "--------------------------------------------------------------------------"; } + +echo "==========================================================================" +echo " jwt-auth-demo - built-in resource server" +echo " profiles: $LABEL" +echo "==========================================================================" + +R=$(curl -sS -X POST "$BASE/api/auth/login" -H 'Content-Type: application/json' \ + -d '{"username":"alice","password":"alice-password"}') +ACCESS=$(echo "$R" | python3 -c 'import json,sys;print(json.load(sys.stdin)["accessToken"])') +REFRESH=$(echo "$R" | python3 -c 'import json,sys;print(json.load(sys.stdin)["refreshToken"])') + +hr; echo "# 1. The filter chain. Note BearerTokenAuthenticationFilter in place of our" +echo "# hand-written JwtAuthenticationFilter - same slot, framework-owned." +echo; show "$BASE/api/public/filters" + +hr; echo "# 2. Access token -> 200."; echo; show "$BASE/api/me" -H "Authorization: Bearer $ACCESS" + +hr; echo "# 3. Non-admin on an admin route -> 403 insufficient_scope." +echo; show "$BASE/api/admin/stats" -H "Authorization: Bearer $ACCESS" + +hr; echo "# 4. REFRESH token presented as an access token." +echo "# This is the line to watch when comparing the two runs." +echo; show "$BASE/api/me" -H "Authorization: Bearer $REFRESH" + +hr; echo "# end" diff --git a/scripts/rs256-demo.sh b/scripts/rs256-demo.sh new file mode 100755 index 0000000..cc61eab --- /dev/null +++ b/scripts/rs256-demo.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# Requires: --spring.profiles.active=rs256 +set -u +BASE="${1:-http://localhost:8080}" +show() { + local out; out=$(curl -sS -D /tmp/.h -o /tmp/.b -w '%{http_code}' "$@") + printf 'HTTP %s\n' "$out" + grep -iE '^www-authenticate:' /tmp/.h | sed 's/\r$//' + [ -s /tmp/.b ] && { python3 -m json.tool < /tmp/.b 2>/dev/null || cat /tmp/.b; echo; } +} +jwt_part(){ echo "$1" | cut -d. -f$(( $2 + 1 )) | tr '_-' '/+' | sed 's/$/==/' | base64 -d 2>/dev/null | python3 -m json.tool 2>/dev/null; } +hr(){ printf '\n%s\n' "--------------------------------------------------------------------------"; } + +echo "==========================================================================" +echo " jwt-auth-demo - RS256 variant" +echo " profiles: rs256 (private key signs, public key / JWKS verifies)" +echo "==========================================================================" + +hr; echo "# 1. The public half, published as a JWK Set. No private material here -" +echo "# n and e only. Any number of resource servers can poll this." +echo; show "$BASE/.well-known/jwks.json" + +hr; echo "# 2. Login. Same endpoint, same request, different signature algorithm." +echo; show -X POST "$BASE/api/auth/login" -H 'Content-Type: application/json' \ + -d '{"username":"root","password":"root-password"}' +TOK=$(python3 -c "import json;print(json.load(open('/tmp/.b'))['accessToken'])") + +hr; echo "# 3. The JOSE header now carries alg=RS256 and the kid that selects the key." +echo; jwt_part "$TOK" 0 + +hr; echo "# 4. Token length. RS256 signatures are 256 bytes; HS256 signatures are 32." +echo +printf ' RS256 access token: %s characters\n' "${#TOK}" +printf ' signature segment : %s characters\n' "$(echo "$TOK" | cut -d. -f3 | wc -c)" + +hr; echo "# 5. It works exactly the same from the caller's side." +echo; show "$BASE/api/me" -H "Authorization: Bearer $TOK" + +hr; echo "# 6. Tampered payload, original signature -> 401, same as HS256." +HDR=$(echo "$TOK" | cut -d. -f1); SIG=$(echo "$TOK" | cut -d. -f3) +FORGED=$(echo "$TOK" | cut -d. -f2 | tr '_-' '/+' | sed 's/$/==/' | base64 -d 2>/dev/null \ + | sed 's/"sub":"root"/"sub":"mallory"/' | base64 -w0 | tr '/+' '_-' | tr -d '=') +echo; show "$BASE/api/me" -H "Authorization: Bearer $HDR.$FORGED.$SIG" + +hr; echo "# end" diff --git a/scripts/run-all.sh b/scripts/run-all.sh new file mode 100755 index 0000000..05ede0f --- /dev/null +++ b/scripts/run-all.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# Regenerates every file under docs/output/ from a real run. +# Usage: ./scripts/run-all.sh +set -eu +cd "$(dirname "$0")/.." + +echo "==> mvn test" +{ + echo "==========================================================================" + echo " jwt-auth-demo - test run" + echo "==========================================================================" + echo + mvn -B test 2>&1 | grep -E '^\[INFO\] (Running|Tests run)|^\[ERROR\]|BUILD (SUCCESS|FAILURE)' \ + | sed 's/^\[INFO\] //' + echo + echo "JDK : $(java -version 2>&1 | grep -v JAVA_TOOL | head -1)" + echo "Boot : 4.1.1" + echo "Security : 7.1.1" +} > docs/output/test-run.txt + +echo "==> hs256 (manual filter) transcript" +./scripts/run.sh hs256 >/dev/null && ./scripts/curl-transcript.sh > docs/output/curl-transcript-hs256.txt 2>&1 + +echo "==> rs256 transcript" +./scripts/run.sh rs256 >/dev/null && ./scripts/rs256-demo.sh > docs/output/rs256-demo.txt 2>&1 + +echo "==> csrf vs permitAll" +./scripts/run.sh hs256,csrfon >/dev/null && ./scripts/csrf-demo.sh > docs/output/csrf-vs-permitall.txt 2>&1 + +echo "==> expiry and clock skew (takes ~70s)" +./scripts/run.sh hs256,shortlived >/dev/null && ./scripts/expiry-demo.sh > docs/output/expiry-and-clock-skew.txt 2>&1 + +echo "==> built-in resource server, without and with the token_type validator" +./scripts/run.sh hs256,resourceserver >/dev/null \ + && ./scripts/resource-server-demo.sh http://localhost:8080 "hs256,resourceserver" \ + > docs/output/resource-server-loose.txt 2>&1 +./scripts/run.sh hs256,resourceserver,strict >/dev/null \ + && ./scripts/resource-server-demo.sh http://localhost:8080 "hs256,resourceserver,strict" \ + > docs/output/resource-server-strict.txt 2>&1 + +for p in $(ps -eo pid,cmd | grep '[J]wtAuthDemoApplication' | awk '{print $1}'); do kill -9 "$p" || true; done +echo "==> done. docs/output/ regenerated." diff --git a/scripts/run.sh b/scripts/run.sh new file mode 100755 index 0000000..a20446b --- /dev/null +++ b/scripts/run.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# Starts the app on a clean port 8080 with the given profiles. +# Usage: ./scripts/run.sh [profiles] e.g. ./scripts/run.sh rs256,resourceserver +set -eu +PROFILES="${1:-hs256}" +for p in $(ps -eo pid,cmd | grep '[J]wtAuthDemoApplication' | awk '{print $1}'); do kill -9 "$p" || true; done +sleep 2 +setsid nohup mvn -B -o org.springframework.boot:spring-boot-maven-plugin:run \ + -Dspring-boot.run.profiles="$PROFILES" > "/tmp/app-${PROFILES//,/-}.log" 2>&1 < /dev/null & +for i in $(seq 1 60); do + if curl -s -o /dev/null http://localhost:8080/api/public/ping 2>/dev/null; then + echo "started with profiles: $PROFILES"; exit 0 + fi + sleep 2 +done +echo "failed to start; see /tmp/app-${PROFILES//,/-}.log" >&2; exit 1 diff --git a/src/main/java/com/ankurm/jwtauth/JwtAuthDemoApplication.java b/src/main/java/com/ankurm/jwtauth/JwtAuthDemoApplication.java new file mode 100644 index 0000000..1446fb1 --- /dev/null +++ b/src/main/java/com/ankurm/jwtauth/JwtAuthDemoApplication.java @@ -0,0 +1,31 @@ +package com.ankurm.jwtauth; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * Runnable companion for the ankurm.com article + * "Spring Security 7.1 JWT Authentication: The Complete Guide (Spring Boot 4.1)". + * + *

Two signing variants are wired as Spring profiles: + *

    + *
  • {@code hs256} (default) - symmetric HMAC, one shared secret.
  • + *
  • {@code rs256} - asymmetric RSA, private key signs, public key (JWKS) verifies.
  • + *
+ * + *

Two validation styles are wired as Spring profiles too: + *

    + *
  • {@code manual} (default) - a hand-written {@code OncePerRequestFilter}.
  • + *
  • {@code resourceserver} - Spring Security's built-in + * {@code oauth2ResourceServer().jwt()} support.
  • + *
+ * + * @see docs/01-architecture.md + */ +@SpringBootApplication +public class JwtAuthDemoApplication { + + public static void main(String[] args) { + SpringApplication.run(JwtAuthDemoApplication.class, args); + } +} diff --git a/src/main/java/com/ankurm/jwtauth/auth/AuthController.java b/src/main/java/com/ankurm/jwtauth/auth/AuthController.java new file mode 100644 index 0000000..5994921 --- /dev/null +++ b/src/main/java/com/ankurm/jwtauth/auth/AuthController.java @@ -0,0 +1,121 @@ +package com.ankurm.jwtauth.auth; + +import jakarta.validation.Valid; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtException; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.ankurm.jwtauth.auth.AuthDtos.LoginRequest; +import com.ankurm.jwtauth.auth.AuthDtos.RefreshRequest; +import com.ankurm.jwtauth.auth.AuthDtos.TokenResponse; + +/** + * The login endpoint. It is the only place that sees a password, and the only + * place that calls the {@code AuthenticationManager}. + * + *

Everything after this point in the system is stateless: no session is created, + * and the {@code SecurityContext} written here is deliberately NOT persisted. + */ +@RestController +@RequestMapping("/api/auth") +public class AuthController { + + private final AuthenticationManager authenticationManager; + private final TokenService tokenService; + private final JwtDecoder jwtDecoder; + private final UserDetailsService userDetailsService; + private final RevokedTokenStore revokedTokens; + + public AuthController(AuthenticationManager authenticationManager, + TokenService tokenService, + JwtDecoder jwtDecoder, + UserDetailsService userDetailsService, + RevokedTokenStore revokedTokens) { + this.authenticationManager = authenticationManager; + this.tokenService = tokenService; + this.jwtDecoder = jwtDecoder; + this.userDetailsService = userDetailsService; + this.revokedTokens = revokedTokens; + } + + @PostMapping("/login") + public TokenResponse login(@Valid @RequestBody LoginRequest request) { + // Throws BadCredentialsException / LockedException / DisabledException, + // all AuthenticationException subtypes -> 401 via the exception handler. + Authentication authentication = this.authenticationManager.authenticate( + UsernamePasswordAuthenticationToken.unauthenticated( + request.username(), request.password())); + + TokenService.IssuedToken access = this.tokenService.issueAccessToken(authentication); + TokenService.IssuedToken refresh = this.tokenService.issueRefreshToken(authentication.getName()); + + return new TokenResponse(access.value(), refresh.value(), "Bearer", access.expiresInSeconds()); + } + + /** + * Refresh with rotation: the presented refresh token is revoked as it is spent. + * Replaying it is then a detectable event, not a silent success. + */ + @PostMapping("/refresh") + public TokenResponse refresh(@Valid @RequestBody RefreshRequest request) { + Jwt jwt; + try { + jwt = this.jwtDecoder.decode(request.refreshToken()); + } + catch (JwtException ex) { + throw new BadCredentialsException("Refresh token is not valid", ex); + } + + if (!TokenService.REFRESH.equals(jwt.getClaimAsString("token_type"))) { + throw new BadCredentialsException("Not a refresh token"); + } + if (this.revokedTokens.isRevoked(jwt.getId())) { + throw new BadCredentialsException("Refresh token already used or revoked"); + } + this.revokedTokens.revoke(jwt.getId(), jwt.getExpiresAt()); + + UserDetails user = this.userDetailsService.loadUserByUsername(jwt.getSubject()); + Authentication authentication = UsernamePasswordAuthenticationToken.authenticated( + user, null, user.getAuthorities()); + + TokenService.IssuedToken access = this.tokenService.issueAccessToken(authentication); + TokenService.IssuedToken newRefresh = this.tokenService.issueRefreshToken(user.getUsername()); + + return new TokenResponse(access.value(), newRefresh.value(), "Bearer", access.expiresInSeconds()); + } + + /** Revokes the presented access token by jti. Requires a valid token to call. */ + @PostMapping("/logout") + public ResponseEntity logout(Authentication authentication) { + if (authentication instanceof + org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken jwtAuth) { + Jwt jwt = jwtAuth.getToken(); + this.revokedTokens.revoke(jwt.getId(), jwt.getExpiresAt()); + } + return ResponseEntity.noContent().build(); + } + + @GetMapping("/revocations") + public java.util.Map revocations() { + return java.util.Map.of("revokedTokens", this.revokedTokens.size()); + } + + static class LoginFailed extends RuntimeException { + LoginFailed(AuthenticationException cause) { + super(cause); + } + } +} diff --git a/src/main/java/com/ankurm/jwtauth/auth/AuthDtos.java b/src/main/java/com/ankurm/jwtauth/auth/AuthDtos.java new file mode 100644 index 0000000..d8739e9 --- /dev/null +++ b/src/main/java/com/ankurm/jwtauth/auth/AuthDtos.java @@ -0,0 +1,17 @@ +package com.ankurm.jwtauth.auth; + +import jakarta.validation.constraints.NotBlank; + +public final class AuthDtos { + + private AuthDtos() { } + + public record LoginRequest(@NotBlank String username, @NotBlank String password) { } + + public record RefreshRequest(@NotBlank String refreshToken) { } + + public record TokenResponse(String accessToken, + String refreshToken, + String tokenType, + long expiresIn) { } +} diff --git a/src/main/java/com/ankurm/jwtauth/auth/JwtAuthenticationFilter.java b/src/main/java/com/ankurm/jwtauth/auth/JwtAuthenticationFilter.java new file mode 100644 index 0000000..ad4d068 --- /dev/null +++ b/src/main/java/com/ankurm/jwtauth/auth/JwtAuthenticationFilter.java @@ -0,0 +1,178 @@ +package com.ankurm.jwtauth.auth; + +import java.io.IOException; +import java.util.List; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.oauth2.server.resource.InvalidBearerTokenException; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.context.SecurityContextHolderStrategy; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtException; +import org.springframework.security.oauth2.server.resource.BearerTokenErrorCodes; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter; +import org.springframework.security.oauth2.server.resource.web.BearerTokenResolver; +import org.springframework.security.oauth2.server.resource.web.DefaultBearerTokenResolver; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.security.web.context.RequestAttributeSecurityContextRepository; +import org.springframework.security.web.context.SecurityContextRepository; +import org.springframework.web.filter.OncePerRequestFilter; + +/** + * The hand-written half of the flow: read the bearer token, verify it, put an + * {@code Authentication} in the {@code SecurityContext}, continue the chain. + * + *

Five details separate a filter that works from one that only appears to: + *

    + *
  1. It extends {@link OncePerRequestFilter}, so a {@code FORWARD} to an error + * page or a {@code @Async} dispatch does not run authentication twice.
  2. + *
  3. No token present is not an error. The filter continues the chain and + * lets {@code AuthorizationFilter} decide - that is what makes {@code permitAll()} + * endpoints reachable without a token.
  4. + *
  5. A token that is present but bad is an error, and the chain stops. The + * alternative - continuing anonymously - turns a forged token into a 403 on a + * protected endpoint and a silent 200 on a public one.
  6. + *
  7. The context is written through {@link SecurityContextHolderStrategy}, not the + * static {@code SecurityContextHolder} setters, and also saved into the + * {@link SecurityContextRepository} so it survives a dispatch.
  8. + *
  9. The context is cleared on failure, so a pooled thread cannot leak a previous + * request's principal.
  10. + *
+ * + * @see docs/02-filter-chain-and-ordering.md + */ +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtDecoder jwtDecoder; + private final RevokedTokenStore revokedTokens; + private final AuthenticationEntryPoint entryPoint; + + private final BearerTokenResolver bearerTokenResolver = new DefaultBearerTokenResolver(); + private final JwtAuthenticationConverter authenticationConverter = defaultConverter(); + private final SecurityContextHolderStrategy contextHolderStrategy = + SecurityContextHolder.getContextHolderStrategy(); + private final SecurityContextRepository contextRepository = + new RequestAttributeSecurityContextRepository(); + + public JwtAuthenticationFilter(JwtDecoder jwtDecoder, + RevokedTokenStore revokedTokens, + AuthenticationEntryPoint entryPoint) { + this.jwtDecoder = jwtDecoder; + this.revokedTokens = revokedTokens; + this.entryPoint = entryPoint; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + + String token; + try { + token = this.bearerTokenResolver.resolve(request); + } + catch (OAuth2AuthenticationException ex) { + // Malformed Authorization header, or a token in two places at once. + this.contextHolderStrategy.clearContext(); + this.entryPoint.commence(request, response, ex); + return; + } + + if (token == null) { + // (2) No credentials offered. Not our business - let authorization decide. + filterChain.doFilter(request, response); + return; + } + + try { + Jwt jwt = this.jwtDecoder.decode(token); + assertIsAccessToken(jwt); + assertNotRevoked(jwt); + + SecurityContext context = this.contextHolderStrategy.createEmptyContext(); + context.setAuthentication(this.authenticationConverter.convert(jwt)); + this.contextHolderStrategy.setContext(context); + this.contextRepository.saveContext(context, request, response); + } + catch (JwtException | OAuth2AuthenticationException ex) { + // (3) and (5): stop the chain, clear the context, answer 401. + this.contextHolderStrategy.clearContext(); + this.entryPoint.commence(request, response, asAuthenticationException(ex)); + return; + } + + filterChain.doFilter(request, response); + } + + private void assertIsAccessToken(Jwt jwt) { + if (!TokenService.ACCESS.equals(jwt.getClaimAsString("token_type"))) { + throw invalidToken("This endpoint accepts access tokens only"); + } + } + + private void assertNotRevoked(Jwt jwt) { + if (this.revokedTokens.isRevoked(jwt.getId())) { + throw invalidToken("Token has been revoked"); + } + } + + private static OAuth2AuthenticationException invalidToken(String description) { + OAuth2Error error = new OAuth2Error( + BearerTokenErrorCodes.INVALID_TOKEN, + description, + "https://tools.ietf.org/html/rfc6750#section-3.1"); + return new OAuth2AuthenticationException(error, description); + } + + /** + * Detail that decides what the client sees. {@code BearerTokenAuthenticationEntryPoint} + * only writes {@code error="invalid_token"} into WWW-Authenticate when the exception + * carries a {@code BearerTokenError}. Wrap a {@code JwtException} in a plain + * {@code AuthenticationServiceException} and the client gets a bare + * {@code WWW-Authenticate: Bearer realm="..."} with no reason at all. + */ + private static org.springframework.security.core.AuthenticationException asAuthenticationException( + Exception ex) { + if (ex instanceof org.springframework.security.core.AuthenticationException authEx) { + return authEx; + } + return new InvalidBearerTokenException(ex.getMessage(), ex); + } + + private static JwtAuthenticationConverter defaultConverter() { + JwtAuthenticationConverter converter = new JwtAuthenticationConverter(); + converter.setJwtGrantedAuthoritiesConverter(JwtAuthenticationFilter::authorities); + return converter; + } + + /** + * "scope" -> SCOPE_x (Spring's default) plus "roles" -> ROLE_x, so + * {@code hasRole("ADMIN")} and {@code hasAuthority("SCOPE_admin:read")} both work. + */ + private static List authorities(Jwt jwt) { + List result = new java.util.ArrayList<>(); + String scope = jwt.getClaimAsString("scope"); + if (scope != null && !scope.isBlank()) { + for (String s : scope.split("\\s+")) { + result.add(new org.springframework.security.core.authority.SimpleGrantedAuthority( + "SCOPE_" + s)); + } + } + List roles = jwt.getClaimAsStringList("roles"); + if (roles != null) { + for (String role : roles) { + result.add(new org.springframework.security.core.authority.SimpleGrantedAuthority( + "ROLE_" + role)); + } + } + return result; + } +} diff --git a/src/main/java/com/ankurm/jwtauth/auth/RevokedTokenStore.java b/src/main/java/com/ankurm/jwtauth/auth/RevokedTokenStore.java new file mode 100644 index 0000000..adfe271 --- /dev/null +++ b/src/main/java/com/ankurm/jwtauth/auth/RevokedTokenStore.java @@ -0,0 +1,40 @@ +package com.ankurm.jwtauth.auth; + +import java.time.Instant; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import org.springframework.stereotype.Component; + +/** + * The smallest thing that makes a stateless token revocable: a jti denylist. + * + *

Entries only need to outlive the token's own expiry, so the map self-prunes. + * In production this is Redis with a TTL, not a map - but the shape is identical. + * See docs/07-edge-cases.md#logout-and-revocation. + */ +@Component +public class RevokedTokenStore { + + private final Map revoked = new ConcurrentHashMap<>(); + + public void revoke(String jti, Instant expiresAt) { + prune(); + this.revoked.put(jti, expiresAt); + } + + public boolean isRevoked(String jti) { + prune(); + return jti != null && this.revoked.containsKey(jti); + } + + public int size() { + prune(); + return this.revoked.size(); + } + + private void prune() { + Instant now = Instant.now(); + this.revoked.entrySet().removeIf(e -> e.getValue() != null && e.getValue().isBefore(now)); + } +} diff --git a/src/main/java/com/ankurm/jwtauth/auth/TokenService.java b/src/main/java/com/ankurm/jwtauth/auth/TokenService.java new file mode 100644 index 0000000..f6c1a35 --- /dev/null +++ b/src/main/java/com/ankurm/jwtauth/auth/TokenService.java @@ -0,0 +1,112 @@ +package com.ankurm.jwtauth.auth; + +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.jwt.JwsHeader; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtClaimsSet; +import org.springframework.security.oauth2.jwt.JwtEncoder; +import org.springframework.security.oauth2.jwt.JwtEncoderParameters; +import org.springframework.stereotype.Service; + +/** + * Mints the tokens. This is the "token issue" step of the flow. + * + *

Two claims here are not decoration: + *

    + *
  • {@code token_type} - separates access tokens from refresh tokens. Without it, + * a refresh token is a perfectly valid access token, because both are signed + * by the same key. See docs/07-edge-cases.md#refresh-token-as-access-token.
  • + *
  • {@code jti} - a per-token id, which is what a denylist keys on. A JWT is not + * revocable without one.
  • + *
+ */ +@Service +public class TokenService { + + public static final String ACCESS = "access"; + public static final String REFRESH = "refresh"; + + private final JwtEncoder encoder; + private final String issuer; + private final String audience; + private final Duration accessTtl; + private final Duration refreshTtl; + + public TokenService(JwtEncoder encoder, + @Value("${demo.jwt.issuer}") String issuer, + @Value("${demo.jwt.audience}") String audience, + @Value("${demo.jwt.access-token-ttl}") Duration accessTtl, + @Value("${demo.jwt.refresh-token-ttl}") Duration refreshTtl) { + this.encoder = encoder; + this.issuer = issuer; + this.audience = audience; + this.accessTtl = accessTtl; + this.refreshTtl = refreshTtl; + } + + public IssuedToken issueAccessToken(Authentication authentication) { + List authorities = authentication.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .sorted() + .toList(); + + // Spring Security's default JwtGrantedAuthoritiesConverter reads the "scope" + // claim and prefixes each value with SCOPE_. We keep roles in a separate + // "roles" claim so the two authority families stay distinguishable. + String scope = authorities.stream() + .filter(a -> a.startsWith("SCOPE_")) + .map(a -> a.substring("SCOPE_".length())) + .collect(Collectors.joining(" ")); + List roles = authorities.stream() + .filter(a -> a.startsWith("ROLE_")) + .map(a -> a.substring("ROLE_".length())) + .toList(); + + return encode(authentication.getName(), ACCESS, this.accessTtl, claims -> claims + .claim("scope", scope) + .claim("roles", roles)); + } + + public IssuedToken issueRefreshToken(String subject) { + return encode(subject, REFRESH, this.refreshTtl, claims -> { }); + } + + private IssuedToken encode(String subject, String tokenType, Duration ttl, + java.util.function.Consumer extra) { + Instant now = Instant.now(); + String jti = UUID.randomUUID().toString(); + + JwtClaimsSet.Builder claims = JwtClaimsSet.builder() + .issuer(this.issuer) + .audience(List.of(this.audience)) + .subject(subject) + .id(jti) // -> "jti" + .issuedAt(now) // -> "iat" + .notBefore(now) // -> "nbf" + .expiresAt(now.plus(ttl)) // -> "exp" + .claim("token_type", tokenType); + extra.accept(claims); + + // Passing JwsHeader explicitly is optional - the encoder derives the algorithm + // from the key - but being explicit documents intent and fails loudly on a + // key/algorithm mismatch. + Jwt jwt = this.encoder.encode(JwtEncoderParameters.from(claims.build())); + return new IssuedToken(jwt.getTokenValue(), jti, jwt.getExpiresAt(), ttl.toSeconds()); + } + + /** Unused overload kept to show the explicit-header form. */ + @SuppressWarnings("unused") + private Jwt encodeWithExplicitHeader(JwsHeader header, JwtClaimsSet claims) { + return this.encoder.encode(JwtEncoderParameters.from(header, claims)); + } + + public record IssuedToken(String value, String jti, Instant expiresAt, long expiresInSeconds) { } +} diff --git a/src/main/java/com/ankurm/jwtauth/config/ApiExceptionHandler.java b/src/main/java/com/ankurm/jwtauth/config/ApiExceptionHandler.java new file mode 100644 index 0000000..cff2e9e --- /dev/null +++ b/src/main/java/com/ankurm/jwtauth/config/ApiExceptionHandler.java @@ -0,0 +1,41 @@ +package com.ankurm.jwtauth.config; + +import java.net.URI; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ProblemDetail; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.DisabledException; +import org.springframework.security.authentication.LockedException; +import org.springframework.security.core.AuthenticationException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +/** + * Login failures arrive here, not at the {@code AuthenticationEntryPoint} - the + * controller calls {@code AuthenticationManager} itself, so the exception is a plain + * MVC exception by the time anything security-shaped could see it. + * + *

Note every branch answers 401 with the same body. Telling a caller that the + * username exists but the password is wrong is a user-enumeration oracle. + */ +@RestControllerAdvice +public class ApiExceptionHandler { + + @ExceptionHandler({BadCredentialsException.class, LockedException.class, DisabledException.class}) + public ProblemDetail onAuthenticationFailure(AuthenticationException ex) { + ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.UNAUTHORIZED); + problem.setType(URI.create("https://ankurm.com/problems/invalid-credentials")); + problem.setTitle("Authentication failed"); + problem.setDetail("Invalid username or password"); + return problem; + } + + @ExceptionHandler(AuthenticationException.class) + public ProblemDetail onAuthentication(AuthenticationException ex) { + ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.UNAUTHORIZED); + problem.setTitle("Authentication failed"); + problem.setDetail("Invalid username or password"); + return problem; + } +} diff --git a/src/main/java/com/ankurm/jwtauth/config/AppUsers.java b/src/main/java/com/ankurm/jwtauth/config/AppUsers.java new file mode 100644 index 0000000..828d91b --- /dev/null +++ b/src/main/java/com/ankurm/jwtauth/config/AppUsers.java @@ -0,0 +1,46 @@ +package com.ankurm.jwtauth.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +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; + +/** + * Demo user store. In-memory on purpose: this repository is about the token + * pipeline, not about where users live. + * + *

Note there is deliberately NO {@code AuthenticationManager} bean exposed by + * auto-configuration once a {@code SecurityFilterChain} bean exists - we build one + * explicitly in {@link SecurityConfig} so the login endpoint can call it. + */ +@Configuration +public class AppUsers { + + @Bean + public PasswordEncoder passwordEncoder() { + // DelegatingPasswordEncoder: stores {bcrypt}$2a$... so the hash format is upgradable. + return PasswordEncoderFactories.createDelegatingPasswordEncoder(); + } + + @Bean + public UserDetailsService userDetailsService(PasswordEncoder encoder) { + UserDetails alice = User.withUsername("alice") + .password(encoder.encode("alice-password")) + .authorities("ROLE_USER", "SCOPE_profile:read") + .build(); + UserDetails root = User.withUsername("root") + .password(encoder.encode("root-password")) + .authorities("ROLE_USER", "ROLE_ADMIN", "SCOPE_profile:read", "SCOPE_admin:read") + .build(); + UserDetails locked = User.withUsername("locked") + .password(encoder.encode("locked-password")) + .authorities("ROLE_USER") + .accountLocked(true) + .build(); + return new InMemoryUserDetailsManager(alice, root, locked); + } +} diff --git a/src/main/java/com/ankurm/jwtauth/config/Hs256KeyConfig.java b/src/main/java/com/ankurm/jwtauth/config/Hs256KeyConfig.java new file mode 100644 index 0000000..14266f1 --- /dev/null +++ b/src/main/java/com/ankurm/jwtauth/config/Hs256KeyConfig.java @@ -0,0 +1,74 @@ +package com.ankurm.jwtauth.config; + +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; + +import com.nimbusds.jose.jwk.source.ImmutableSecret; +import com.nimbusds.jose.proc.SecurityContext; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.security.oauth2.jose.jws.MacAlgorithm; +import org.springframework.security.oauth2.core.OAuth2TokenValidator; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtEncoder; +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; +import org.springframework.security.oauth2.jwt.NimbusJwtEncoder; + +/** + * HS256 variant: one symmetric secret both signs and verifies. + * + *

The secret must be at least 256 bits (32 bytes) for HS256 - Nimbus enforces + * this and throws {@code KeyLengthException} otherwise. See docs/05-hs256-vs-rs256.md. + */ +@Configuration +@Profile("hs256") +public class Hs256KeyConfig { + + private final SecretKey secretKey; + + private final String issuer; + private final String audience; + + public Hs256KeyConfig(@Value("${demo.jwt.hmac-secret}") String secret, + @Value("${demo.jwt.issuer}") String issuer, + @Value("${demo.jwt.audience}") String audience) { + this.issuer = issuer; + this.audience = audience; + byte[] bytes = secret.getBytes(StandardCharsets.UTF_8); + if (bytes.length < 32) { + throw new IllegalStateException( + "demo.jwt.hmac-secret must be >= 32 bytes for HS256, got " + bytes.length); + } + this.secretKey = new SecretKeySpec(bytes, "HmacSHA256"); + } + + @Bean + public JwtEncoder jwtEncoder() { + // Spring Security 7.0 added the withSecretKey builder; the older + // new NimbusJwtEncoder(new ImmutableSecret<>(key)) form still works. + return NimbusJwtEncoder.withSecretKey(this.secretKey) + .algorithm(MacAlgorithm.HS256) + .build(); + } + + @Bean + public JwtDecoder jwtDecoder(ObjectProvider> extraValidators) { + NimbusJwtDecoder decoder = NimbusJwtDecoder.withSecretKey(this.secretKey) + .macAlgorithm(MacAlgorithm.HS256) // pin the algorithm - see docs/07-edge-cases.md + .build(); + decoder.setJwtValidator(JwtValidatorFactory.compose(this.issuer, this.audience, extraValidators)); + return decoder; + } + + /** Kept only to show the pre-7.0 constructor still compiles. Not a bean. */ + @SuppressWarnings("unused") + private JwtEncoder legacyStyleEncoder() { + ImmutableSecret jwkSource = new ImmutableSecret<>(this.secretKey); + return new NimbusJwtEncoder(jwkSource); + } +} diff --git a/src/main/java/com/ankurm/jwtauth/config/JwtValidatorFactory.java b/src/main/java/com/ankurm/jwtauth/config/JwtValidatorFactory.java new file mode 100644 index 0000000..49c3bfe --- /dev/null +++ b/src/main/java/com/ankurm/jwtauth/config/JwtValidatorFactory.java @@ -0,0 +1,37 @@ +package com.ankurm.jwtauth.config; + +import java.util.ArrayList; +import java.util.List; + +import com.ankurm.jwtauth.edge.AudienceValidator; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator; +import org.springframework.security.oauth2.core.OAuth2TokenValidator; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtValidators; + +/** + * Builds the validator stack applied to every decoded token. + * + *

What {@code JwtValidators.createDefaultWithIssuer(issuer)} gives you: + * {@code exp} and {@code nbf} (with 60 seconds of clock skew) plus {@code iss}. + * What it does NOT give you: {@code aud}. That one is added here explicitly, + * because a token minted for another service in the same estate is otherwise + * accepted without complaint. + * + * @see docs/07-edge-cases.md + */ +final class JwtValidatorFactory { + + private JwtValidatorFactory() { } + + static OAuth2TokenValidator compose(String issuer, + String audience, + ObjectProvider> extras) { + List> validators = new ArrayList<>(); + validators.add(JwtValidators.createDefaultWithIssuer(issuer)); // exp, nbf, iss + validators.add(AudienceValidator.forAudience(audience)); // aud - not default + extras.orderedStream().forEach(validators::add); // e.g. token_type + return new DelegatingOAuth2TokenValidator<>(validators); + } +} diff --git a/src/main/java/com/ankurm/jwtauth/config/ResourceServerSecurityConfig.java b/src/main/java/com/ankurm/jwtauth/config/ResourceServerSecurityConfig.java new file mode 100644 index 0000000..317c7e9 --- /dev/null +++ b/src/main/java/com/ankurm/jwtauth/config/ResourceServerSecurityConfig.java @@ -0,0 +1,117 @@ +package com.ankurm.jwtauth.config; + +import java.util.ArrayList; +import java.util.List; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.core.convert.converter.Converter; +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.ProviderManager; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.oauth2.jwt.Jwt; +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 org.springframework.security.web.context.NullSecurityContextRepository; + +/** + * The same API secured by Spring Security's built-in resource server instead of a + * hand-written filter. Run with {@code --spring.profiles.active=hs256,resourceserver}. + * + *

What you give up: the {@code token_type} check and the jti denylist have to move + * into an {@code OAuth2TokenValidator} (see + * {@link com.ankurm.jwtauth.edge.AccessTokenTypeValidator}). + * What you gain: {@code BearerTokenAuthenticationFilter}, + * {@code BearerTokenAuthenticationEntryPoint} and {@code BearerTokenAccessDeniedHandler} + * are wired for you, with the correct WWW-Authenticate headers on both 401 and 403. + * + * @see docs/09-manual-filter-vs-resource-server.md + */ +@Configuration +@EnableWebSecurity +@EnableMethodSecurity +@Profile("resourceserver") +public class ResourceServerSecurityConfig { + + @Bean + public SecurityFilterChain apiFilterChain(HttpSecurity http) throws Exception { + http + .sessionManagement(session -> session + .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .securityContext(context -> context + .securityContextRepository(new NullSecurityContextRepository())) + .csrf(csrf -> csrf.disable()) + .formLogin(form -> form.disable()) + .httpBasic(basic -> basic.disable()) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/api/public/**", "/api/auth/login", "/api/auth/refresh").permitAll() + .requestMatchers("/.well-known/**", "/actuator/health").permitAll() + .requestMatchers("/api/admin/**").hasRole("ADMIN") + .anyRequest().authenticated()) + // One line replaces the whole custom filter. The JwtDecoder bean is picked + // up automatically; BearerTokenAuthenticationFilter is inserted in the + // right place; 401 and 403 handlers come with it. + .oauth2ResourceServer(oauth2 -> oauth2 + .jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter()))); + + return http.build(); + } + + /** Same authority mapping as the manual filter, expressed the framework's way. */ + private Converter jwtAuthenticationConverter() { + JwtGrantedAuthoritiesConverter scopes = new JwtGrantedAuthoritiesConverter(); + scopes.setAuthoritiesClaimName("scope"); + scopes.setAuthorityPrefix("SCOPE_"); + + JwtGrantedAuthoritiesConverter roles = new JwtGrantedAuthoritiesConverter(); + roles.setAuthoritiesClaimName("roles"); + roles.setAuthorityPrefix("ROLE_"); + + JwtAuthenticationConverter converter = new JwtAuthenticationConverter(); + converter.setJwtGrantedAuthoritiesConverter(jwt -> { + List all = new ArrayList<>(scopes.convert(jwt)); + all.addAll(roles.convert(jwt)); + return all; + }); + return converter; + } + + /** + * Only registered under the {@code strict} profile - on purpose. Run without it + * and a refresh token authenticates as an access token; run with it and the same + * request is a 401. The built-in resource server has no opinion about your + * private claims until you give it one. + */ + @Bean + @org.springframework.boot.autoconfigure.condition.ConditionalOnProperty( + name = "demo.validate-token-type", havingValue = "true") + public org.springframework.security.oauth2.core.OAuth2TokenValidator< + org.springframework.security.oauth2.jwt.Jwt> accessTokenTypeValidator() { + return new com.ankurm.jwtauth.edge.AccessTokenTypeValidator(); + } + + @Bean + public AuthenticationManager authenticationManager(UserDetailsService users, + PasswordEncoder encoder) { + DaoAuthenticationProvider provider = new DaoAuthenticationProvider(users); + provider.setPasswordEncoder(encoder); + return new ProviderManager(provider); + } + + /** Unused, kept as documentation of the alternative authority mapping. */ + @SuppressWarnings("unused") + private GrantedAuthority example() { + return new SimpleGrantedAuthority("SCOPE_profile:read"); + } +} diff --git a/src/main/java/com/ankurm/jwtauth/config/Rs256KeyConfig.java b/src/main/java/com/ankurm/jwtauth/config/Rs256KeyConfig.java new file mode 100644 index 0000000..00d7717 --- /dev/null +++ b/src/main/java/com/ankurm/jwtauth/config/Rs256KeyConfig.java @@ -0,0 +1,120 @@ +package com.ankurm.jwtauth.config; + +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.security.KeyFactory; +import java.security.interfaces.RSAPrivateKey; +import java.security.interfaces.RSAPublicKey; +import java.security.spec.PKCS8EncodedKeySpec; +import java.security.spec.X509EncodedKeySpec; +import java.util.Base64; +import java.util.Map; + +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.RSAKey; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.core.io.ClassPathResource; +import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm; +import org.springframework.security.oauth2.core.OAuth2TokenValidator; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtEncoder; +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; +import org.springframework.security.oauth2.jwt.NimbusJwtEncoder; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * RS256 variant: the private key signs, the public key verifies. + * + *

The public half is also published at {@code /.well-known/jwks.json} so a + * separate resource server could verify tokens without ever seeing the private key. + */ +@Configuration +@Profile("rs256") +public class Rs256KeyConfig { + + private final RSAPublicKey publicKey; + private final RSAPrivateKey privateKey; + private final String keyId = "demo-rsa-2026-08"; + private final String issuer; + private final String audience; + + public Rs256KeyConfig(@Value("${demo.jwt.issuer}") String issuer, + @Value("${demo.jwt.audience}") String audience) throws Exception { + this.issuer = issuer; + this.audience = audience; + this.privateKey = readPrivateKey("demo-private-key.pem"); + this.publicKey = readPublicKey("demo-public-key.pem"); + } + + @Bean + public JwtEncoder jwtEncoder() { + // Note: the builder method is algorithm(..), not jwsAlgorithm(..), and there is + // no keyId(..) - the key id is set by post-processing the Nimbus JWK builder. + return NimbusJwtEncoder.withKeyPair(this.publicKey, this.privateKey) + .algorithm(SignatureAlgorithm.RS256) + .jwkPostProcessor(jwk -> jwk.keyID(this.keyId)) + .build(); + } + + @Bean + public JwtDecoder jwtDecoder(ObjectProvider> extraValidators) { + NimbusJwtDecoder decoder = NimbusJwtDecoder.withPublicKey(this.publicKey) + .signatureAlgorithm(SignatureAlgorithm.RS256) // pin it - see docs/07-edge-cases.md + .build(); + decoder.setJwtValidator(JwtValidatorFactory.compose(this.issuer, this.audience, extraValidators)); + return decoder; + } + + /** + * A minimal JWKS endpoint. A real authorization server publishes this and the + * resource server points at it with {@code NimbusJwtDecoder.withJwkSetUri(...)}, + * which then rotates keys automatically by {@code kid}. + */ + @RestController + @Profile("rs256") + public static class JwkSetEndpoint { + + private final JWKSet jwkSet; + + public JwkSetEndpoint(Rs256KeyConfig keys) { + RSAKey rsaKey = new RSAKey.Builder(keys.publicKey) + .keyID(keys.keyId) + .build(); + this.jwkSet = new JWKSet(rsaKey); + } + + @GetMapping("/.well-known/jwks.json") + public Map keys() { + return this.jwkSet.toJSONObject(); // public parameters only + } + } + + private static RSAPrivateKey readPrivateKey(String path) throws Exception { + byte[] der = pemBody(path, "PRIVATE KEY"); + return (RSAPrivateKey) KeyFactory.getInstance("RSA") + .generatePrivate(new PKCS8EncodedKeySpec(der)); + } + + private static RSAPublicKey readPublicKey(String path) throws Exception { + byte[] der = pemBody(path, "PUBLIC KEY"); + return (RSAPublicKey) KeyFactory.getInstance("RSA") + .generatePublic(new X509EncodedKeySpec(der)); + } + + private static byte[] pemBody(String path, String label) throws Exception { + try (InputStream in = new ClassPathResource(path).getInputStream()) { + String pem = new String(in.readAllBytes(), StandardCharsets.UTF_8); + String base64 = pem + .replace("-----BEGIN " + label + "-----", "") + .replace("-----END " + label + "-----", "") + .replaceAll("\\s", ""); + return Base64.getDecoder().decode(base64); + } + } +} diff --git a/src/main/java/com/ankurm/jwtauth/config/SecurityConfig.java b/src/main/java/com/ankurm/jwtauth/config/SecurityConfig.java new file mode 100644 index 0000000..d9ec8ea --- /dev/null +++ b/src/main/java/com/ankurm/jwtauth/config/SecurityConfig.java @@ -0,0 +1,140 @@ +package com.ankurm.jwtauth.config; + +import com.ankurm.jwtauth.auth.JwtAuthenticationFilter; +import com.ankurm.jwtauth.auth.RevokedTokenStore; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.ProviderManager; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.server.resource.web.access.BearerTokenAccessDeniedHandler; +import org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationEntryPoint; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.security.web.context.NullSecurityContextRepository; + +/** + * The default chain: our own {@link JwtAuthenticationFilter} does the verifying. + * + *

Active unless the {@code resourceserver} profile is on. Compare with + * {@link ResourceServerSecurityConfig}, which deletes most of this file. + * + * @see docs/02-filter-chain-and-ordering.md + * @see docs/03-401-vs-403.md + */ +@Configuration +@EnableWebSecurity +@EnableMethodSecurity // enables @PreAuthorize on controller methods +@Profile("!resourceserver") +public class SecurityConfig { + + /** + * CSRF is off by default here (stateless bearer-token API). Flip + * {@code demo.csrf.enabled=true} to reproduce the "permitAll() still 403s" failure + * described in docs/04-csrf-permitall-403.md. + */ + @Value("${demo.csrf.enabled:false}") + private boolean csrfEnabled; + + @Bean + public SecurityFilterChain apiFilterChain(HttpSecurity http, + JwtDecoder jwtDecoder, + RevokedTokenStore revokedTokens, + AuthenticationEntryPoint entryPoint, + AccessDeniedHandler accessDeniedHandler) throws Exception { + + JwtAuthenticationFilter jwtFilter = + new JwtAuthenticationFilter(jwtDecoder, revokedTokens, entryPoint); + + http + // 1. No sessions, no session cookie, nothing to fix on the server. + .sessionManagement(session -> session + .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .securityContext(context -> context + .securityContextRepository(new NullSecurityContextRepository())) + + // 2. CSRF. See the callout in docs/04 before you copy the disable() line. + .csrf(csrf -> { + if (this.csrfEnabled) { + // Left at defaults on purpose: this is the failure, not the fix. + // Every unsafe method now needs a CSRF token - including the + // permitAll() login endpoint. See docs/04-csrf-permitall-403.md. + csrf.csrfTokenRepository( + org.springframework.security.web.csrf.CookieCsrfTokenRepository + .withHttpOnlyFalse()); + } + else { + // Correct for a bearer-token API: the browser never attaches + // credentials automatically, so there is nothing to forge. + csrf.disable(); + } + }) + + // 3. Nothing browser-shaped: no login page, no basic auth popup. + .formLogin(form -> form.disable()) + .httpBasic(basic -> basic.disable()) + .logout(logout -> logout.disable()) + + // 4. Authorization. Evaluated by AuthorizationFilter, the LAST filter. + .authorizeHttpRequests(auth -> auth + .requestMatchers("/api/public/**", "/api/auth/login", "/api/auth/refresh").permitAll() + .requestMatchers("/.well-known/**", "/actuator/health").permitAll() + .requestMatchers("/api/admin/**").hasRole("ADMIN") + .anyRequest().authenticated()) + + // 5. What an authentication failure and an authorization failure look like. + .exceptionHandling(ex -> ex + .authenticationEntryPoint(entryPoint) + .accessDeniedHandler(accessDeniedHandler)) + + // 6. Position matters. Before UsernamePasswordAuthenticationFilter puts us + // ahead of AnonymousAuthenticationFilter and ExceptionTranslationFilter, + // which is what we want. addFilterAfter(..., AuthorizationFilter.class) + // would run after the decision has already been made. + .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } + + /** + * 401, with a {@code WWW-Authenticate: Bearer ...} header carrying the + * RFC 6750 error code. Reused by the filter so unauthenticated responses + * are identical whether they come from the filter or from + * {@code ExceptionTranslationFilter}. + */ + @Bean + public AuthenticationEntryPoint bearerTokenEntryPoint() { + BearerTokenAuthenticationEntryPoint entryPoint = new BearerTokenAuthenticationEntryPoint(); + entryPoint.setRealmName("jwt-auth-demo"); + return entryPoint; + } + + /** 403, with {@code error="insufficient_scope"} in WWW-Authenticate. */ + @Bean + public AccessDeniedHandler bearerTokenAccessDeniedHandler() { + return new BearerTokenAccessDeniedHandler(); + } + + /** + * The login endpoint needs this. Once you define a SecurityFilterChain bean, + * Boot no longer auto-configures an AuthenticationManager, so build one. + */ + @Bean + public AuthenticationManager authenticationManager(UserDetailsService users, + PasswordEncoder encoder) { + DaoAuthenticationProvider provider = new DaoAuthenticationProvider(users); + provider.setPasswordEncoder(encoder); + return new ProviderManager(provider); + } +} diff --git a/src/main/java/com/ankurm/jwtauth/diag/FilterChainReport.java b/src/main/java/com/ankurm/jwtauth/diag/FilterChainReport.java new file mode 100644 index 0000000..fc2e6b3 --- /dev/null +++ b/src/main/java/com/ankurm/jwtauth/diag/FilterChainReport.java @@ -0,0 +1,55 @@ +package com.ankurm.jwtauth.diag; + +import java.util.List; +import java.util.Map; + +import jakarta.servlet.Filter; +import jakarta.servlet.http.HttpServletRequest; +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.RestController; + +/** + * Prints the real filter order at runtime instead of the one you remember. + * + *

{@code GET /api/public/filters} - deliberately under {@code /api/public} so it is + * reachable without a token. Delete this class before shipping: it tells an attacker + * exactly which filters guard the application. + */ +@RestController +public class FilterChainReport { + + private final FilterChainProxy filterChainProxy; + + public FilterChainReport(FilterChainProxy filterChainProxy) { + this.filterChainProxy = filterChainProxy; + } + + @GetMapping("/api/public/filters") + public List> filters(HttpServletRequest request) { + return this.filterChainProxy.getFilterChains().stream() + .map(chain -> Map.of( + "chain", chain.toString(), + "matchesThisRequest", matches(chain, request), + "filters", chain.getFilters().stream() + .map(f -> f.getClass().getSimpleName()) + .toList())) + .toList(); + } + + private boolean matches(SecurityFilterChain chain, HttpServletRequest request) { + try { + return chain.matches(request); + } + catch (RuntimeException ex) { + return false; + } + } + + /** Unused; shows the type the list actually holds. */ + @SuppressWarnings("unused") + private Class filterType(Filter filter) { + return filter.getClass(); + } +} diff --git a/src/main/java/com/ankurm/jwtauth/edge/AccessTokenTypeValidator.java b/src/main/java/com/ankurm/jwtauth/edge/AccessTokenTypeValidator.java new file mode 100644 index 0000000..266c5d3 --- /dev/null +++ b/src/main/java/com/ankurm/jwtauth/edge/AccessTokenTypeValidator.java @@ -0,0 +1,39 @@ +package com.ankurm.jwtauth.edge; + +import com.ankurm.jwtauth.auth.TokenService; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.OAuth2TokenValidator; +import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult; +import org.springframework.security.oauth2.jwt.Jwt; + +/** + * Edge case: a refresh token is a valid access token unless you say otherwise. + * + *

Both are signed by the same key, both carry a valid {@code exp}, both pass every + * default validator. If the only difference is the TTL, a stolen refresh token is a + * long-lived access token. This validator is the resource-server equivalent of the + * {@code token_type} check inside {@code JwtAuthenticationFilter}. + * + *

+ * NimbusJwtDecoder decoder = NimbusJwtDecoder.withSecretKey(key).build();
+ * decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
+ *         JwtValidators.createDefaultWithIssuer(issuer),
+ *         new AccessTokenTypeValidator()));
+ * 
+ * + * @see docs/07-edge-cases.md + */ +public class AccessTokenTypeValidator implements OAuth2TokenValidator { + + private static final OAuth2Error ERROR = new OAuth2Error( + "invalid_token", + "Expected a token with token_type=access", + "https://ankurm.com/spring-security-7-1-jwt-authentication-guide/"); + + @Override + public OAuth2TokenValidatorResult validate(Jwt jwt) { + return TokenService.ACCESS.equals(jwt.getClaimAsString("token_type")) + ? OAuth2TokenValidatorResult.success() + : OAuth2TokenValidatorResult.failure(ERROR); + } +} diff --git a/src/main/java/com/ankurm/jwtauth/edge/AsyncPropagationDemo.java b/src/main/java/com/ankurm/jwtauth/edge/AsyncPropagationDemo.java new file mode 100644 index 0000000..dfda1aa --- /dev/null +++ b/src/main/java/com/ankurm/jwtauth/edge/AsyncPropagationDemo.java @@ -0,0 +1,50 @@ +package com.ankurm.jwtauth.edge; + +import java.util.Map; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import org.springframework.security.concurrent.DelegatingSecurityContextExecutorService; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * Edge case: the {@code SecurityContext} lives in a {@code ThreadLocal}, so it does + * not cross a thread boundary on its own. + * + *

{@code GET /api/async-demo} runs the same lookup on a plain executor and on a + * {@code DelegatingSecurityContextExecutorService}, and returns both answers. The + * plain one is {@code null} - and {@code null} here means an authenticated user's + * background work runs unauthenticated, which usually surfaces as a + * {@code AuthenticationCredentialsNotFoundException} far from the cause. + * + * @see docs/07-edge-cases.md#async + */ +@RestController +public class AsyncPropagationDemo { + + @GetMapping("/api/async-demo") + public Map asyncDemo() throws Exception { + Callable readPrincipal = () -> { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + return auth == null ? "null (context did not cross the thread)" : auth.getName(); + }; + + ExecutorService plain = Executors.newVirtualThreadPerTaskExecutor(); + ExecutorService wrapped = new DelegatingSecurityContextExecutorService( + Executors.newVirtualThreadPerTaskExecutor()); + try { + return Map.of( + "onRequestThread", readPrincipal.call(), + "onPlainExecutor", plain.submit(readPrincipal).get(), + "onDelegatingExecutor", wrapped.submit(readPrincipal).get()); + } + finally { + plain.shutdown(); + wrapped.shutdown(); + } + } +} diff --git a/src/main/java/com/ankurm/jwtauth/edge/AudienceValidator.java b/src/main/java/com/ankurm/jwtauth/edge/AudienceValidator.java new file mode 100644 index 0000000..87accff --- /dev/null +++ b/src/main/java/com/ankurm/jwtauth/edge/AudienceValidator.java @@ -0,0 +1,39 @@ +package com.ankurm.jwtauth.edge; + +import java.util.List; + +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.OAuth2TokenValidator; +import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtClaimNames; +import org.springframework.security.oauth2.jwt.JwtClaimValidator; + +/** + * Edge case: {@code aud} is NOT validated by default. + * + *

{@code JwtValidators.createDefaultWithIssuer(issuer)} checks {@code exp}, + * {@code nbf} and {@code iss} - not {@code aud}. In a multi-service estate where every + * service trusts the same issuer, that means a token minted for the reporting API is + * accepted by the payments API. Confused-deputy, by default. + * + * @see docs/07-edge-cases.md#audience + */ +public final class AudienceValidator { + + private AudienceValidator() { } + + public static OAuth2TokenValidator forAudience(String expected) { + return new JwtClaimValidator>(JwtClaimNames.AUD, + aud -> aud != null && aud.contains(expected)); + } + + /** The long-hand equivalent, for readers who prefer to see the shape. */ + public static OAuth2TokenValidator explicit(String expected) { + OAuth2Error error = new OAuth2Error("invalid_token", + "The required audience " + expected + " is missing", null); + return jwt -> jwt.getAudience() != null && jwt.getAudience().contains(expected) + ? OAuth2TokenValidatorResult.success() + : OAuth2TokenValidatorResult.failure(error); + } +} diff --git a/src/main/java/com/ankurm/jwtauth/web/ApiControllers.java b/src/main/java/com/ankurm/jwtauth/web/ApiControllers.java new file mode 100644 index 0000000..544d193 --- /dev/null +++ b/src/main/java/com/ankurm/jwtauth/web/ApiControllers.java @@ -0,0 +1,62 @@ +package com.ankurm.jwtauth.web; + +import java.util.List; +import java.util.Map; + +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * Four endpoints, chosen so every authorization outcome is reachable from curl: + *

    + *
  • {@code /api/public/ping} - permitAll, works with no token.
  • + *
  • {@code /api/me} - authenticated, 401 without a token.
  • + *
  • {@code /api/admin/stats} - hasRole('ADMIN'), 403 for a valid non-admin token.
  • + *
  • {@code /api/reports} - hasAuthority('SCOPE_admin:read'), the scope-based twin.
  • + *
+ */ +@RestController +public class ApiControllers { + + @GetMapping("/api/public/ping") + public Map ping() { + return Map.of("status", "up", "authenticationRequired", false); + } + + @GetMapping("/api/me") + public Map me(Authentication authentication) { + Map body = new java.util.LinkedHashMap<>(); + body.put("name", authentication.getName()); + body.put("authorities", authentication.getAuthorities().stream() + .map(GrantedAuthority::getAuthority).sorted().toList()); + body.put("authenticationType", authentication.getClass().getSimpleName()); + + if (authentication instanceof JwtAuthenticationToken jwtAuth) { + Jwt jwt = jwtAuth.getToken(); + body.put("jti", jwt.getId()); + body.put("issuer", jwt.getIssuer() == null ? null : jwt.getIssuer().toString()); + body.put("audience", jwt.getAudience()); + body.put("issuedAt", String.valueOf(jwt.getIssuedAt())); + body.put("expiresAt", String.valueOf(jwt.getExpiresAt())); + body.put("algorithm", jwt.getHeaders().get("alg")); + body.put("keyId", jwt.getHeaders().get("kid")); + } + return body; + } + + @GetMapping("/api/admin/stats") + public Map adminStats() { + return Map.of("activeUsers", 3, "requiredRole", "ROLE_ADMIN"); + } + + @GetMapping("/api/reports") + @PreAuthorize("hasAuthority('SCOPE_admin:read')") + public List reports() { + return List.of("q1-revenue", "q2-revenue"); + } +} diff --git a/src/main/resources/application-csrfon.yaml b/src/main/resources/application-csrfon.yaml new file mode 100644 index 0000000..2b4b1f3 --- /dev/null +++ b/src/main/resources/application-csrfon.yaml @@ -0,0 +1,5 @@ +# Run with --spring.profiles.active=hs256,csrfon to reproduce the failure in +# docs/04-csrf-permitall-403.md: a permitAll() login endpoint answering 403. +demo: + csrf: + enabled: true diff --git a/src/main/resources/application-shortlived.yaml b/src/main/resources/application-shortlived.yaml new file mode 100644 index 0000000..14177d1 --- /dev/null +++ b/src/main/resources/application-shortlived.yaml @@ -0,0 +1,6 @@ +# Run with --spring.profiles.active=hs256,shortlived to observe expiry and +# clock-skew behaviour without waiting 15 minutes. +demo: + jwt: + access-token-ttl: 2s + refresh-token-ttl: 30s diff --git a/src/main/resources/application-strict.yaml b/src/main/resources/application-strict.yaml new file mode 100644 index 0000000..6d92de8 --- /dev/null +++ b/src/main/resources/application-strict.yaml @@ -0,0 +1,5 @@ +# Run with --spring.profiles.active=hs256,resourceserver,strict to wire the +# AccessTokenTypeValidator into the JwtDecoder. Without it, the built-in resource +# server happily accepts a refresh token as an access token - see docs/07. +demo: + validate-token-type: true diff --git a/src/main/resources/application-trace.yaml b/src/main/resources/application-trace.yaml new file mode 100644 index 0000000..3b4ab08 --- /dev/null +++ b/src/main/resources/application-trace.yaml @@ -0,0 +1,5 @@ +# Run with --spring.profiles.active=hs256,trace to see every filter decision. +logging: + level: + org.springframework.security: TRACE + org.springframework.security.web.FilterChainProxy: TRACE diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml new file mode 100644 index 0000000..d33b638 --- /dev/null +++ b/src/main/resources/application.yaml @@ -0,0 +1,28 @@ +spring: + application: + name: jwt-auth-demo + profiles: + # hs256 = symmetric signing, manual = hand-written OncePerRequestFilter. + # Override with: --spring.profiles.active=rs256,resourceserver + default: hs256 + +server: + port: 8080 + error: + include-message: always + +demo: + csrf: + # true reproduces the "permitAll() still 403s" failure from docs/04. + enabled: false + jwt: + issuer: https://jwt-auth-demo.ankurm.com + audience: jwt-auth-demo-api + access-token-ttl: 15m + refresh-token-ttl: 8h + # 32+ bytes. Demo value only - a real deployment reads this from a secret manager. + hmac-secret: ankurm-demo-hmac-secret-key-please-rotate-me-32b + +logging: + level: + org.springframework.security: INFO diff --git a/src/main/resources/demo-private-key.pem b/src/main/resources/demo-private-key.pem new file mode 100644 index 0000000..b620e6c --- /dev/null +++ b/src/main/resources/demo-private-key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDk0QNA9BbQbPqJ +Hn5Q2XsnvrrT8+x3mrV/lVqYr0pNL1/piE88gbyZ+LrNZ7xebHF1H8F3bJpf6/9b +PoyB4UtsFWREV+97/NWerj6SCOGfxRpErWJTQLWP7tJqEPveEeoZo6MCUU2ujnKC +MYBxUvxEZUzFEzLhppMCbcoHgR0baEkXHJ8IGq2TCwdMz8NdygVMtNnZsj6E+E3q +4HGTvKQVoKdDjvKspIzqSfS6XR2nbYAr/iY/VJEOeevcNrkYWEwq0GrL8aBdwn1Z +OnEhiGWvwHmah21vlLfj3TRU9JymheIl1SGCHESldtvVb8+WlUgSUhIfOV2fBY6i +B0+dw7TNAgMBAAECggEAHlP/0OepcHXJXUxP5Mp2suVqYPaHRLEaVm9G4070k7dw +SIVbL0No6qWXqOsTghZwkVwkqf4YlhczMPZg7EQe2ZQaRp67LN1tuQsSWwvXT/Rx +j2HF0xAUIKBAfnOC1sPcGgrg68k3+SeDUPNbuWmM60nb+5EYYOVRvfQsX4NDBuMz +B3gtuccd+L+wbCQPKmTQtu0FBtSzROKS39QJ77TmvNclQdVHu9tu805hezfbwXzG +usR64CJRzJE/mxmgoAJX+Tws+G4VRY5KAEBojVpAlN2bxpIBYMfyX6UEOOIBKp0E +WWv6bcFqlKQnSW9mWf4vt+ePtjlR4aLQcw9NWo2w5wKBgQD0N0s+X2+WX1h6Oqzd +sVFjGkrhwWrCwrdepsnLTQoAj0FuwMpgvfQDGvNrnyOSz4jf3rDe63WDRBQuxdaa +wU234jDBZ33VzUPQT4nrprKa1F3V8ZISpQdPNDRKyth56yyDeLQ8UnfhIlfN0/+b +hQ9ns9uv4ASMH53Q4/TAI5OBywKBgQDv236HXs3JEED6vM2zwE5H0SNQUhwCv4/K +m16Umd6rjHJJHkzQBhWsfYCCMUYZB+K7z9Zq9GivfJev2DqcKZkSdibpse4XDWfs +MWtMvYxtHvTt5Ghs/vE5A5x6uz8DXwaWOx/8bo13EIQvxtvw5HXtKB/WtTygBGMv +dcmT+dNwxwKBgDvm0Cr1Z75/loksmTgrhSYEzfc/5PruneG2kWqvc9OdT9Rlr345 +OYAFfU2ZlDUveIhI7CNRp9pRuY2bcz80SObgsUrPIrtthMO0rsTBd6+ohXezsDuo +hPl1eZoa1Sxademtkq/1Hnh3XwgahujTo2qxYCJslVD1dFVHhMIYN9cvAoGAZO6e +beSNAADg9yIgBXX0+u+cxp3mv5lQrtd2k120f8fYB8DCXf9Re4ZMX3zQnJPe611o +QxWaP85UHmEFONWgXk5tzYVcRUMU6iVZm69fukN+meS1tLgLVgyY+mR0/bwtD2bN +7PGwgdvnZBtwTgw1O5jY3Qbi/gsamcwdCTHlsd0CgYAc+gUTmRlzZDLiUHWOg4Hi ++cllYIQPfQJYd4woiZu3er1Qo/tNYAI5hGDrUGI5cM4Qt3j9xA55eoigzy7ra+y2 +ZkfOUUHAtgJrOOkbUtqBCtqbhNTW2HnjMXwt8TaozrjjoHW6lLiq3PvQ+wjwKx54 +6g0Y6BKcZz2zYcpmrDXM6Q== +-----END PRIVATE KEY----- diff --git a/src/main/resources/demo-public-key.pem b/src/main/resources/demo-public-key.pem new file mode 100644 index 0000000..d881d8a --- /dev/null +++ b/src/main/resources/demo-public-key.pem @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA5NEDQPQW0Gz6iR5+UNl7 +J7660/Psd5q1f5VamK9KTS9f6YhPPIG8mfi6zWe8XmxxdR/Bd2yaX+v/Wz6MgeFL +bBVkRFfve/zVnq4+kgjhn8UaRK1iU0C1j+7SahD73hHqGaOjAlFNro5ygjGAcVL8 +RGVMxRMy4aaTAm3KB4EdG2hJFxyfCBqtkwsHTM/DXcoFTLTZ2bI+hPhN6uBxk7yk +FaCnQ47yrKSM6kn0ul0dp22AK/4mP1SRDnnr3Da5GFhMKtBqy/GgXcJ9WTpxIYhl +r8B5modtb5S34900VPScpoXiJdUhghxEpXbb1W/PlpVIElISHzldnwWOogdPncO0 +zQIDAQAB +-----END PUBLIC KEY----- diff --git a/src/test/java/com/ankurm/jwtauth/AuthenticationFlowTests.java b/src/test/java/com/ankurm/jwtauth/AuthenticationFlowTests.java new file mode 100644 index 0000000..7e1a973 --- /dev/null +++ b/src/test/java/com/ankurm/jwtauth/AuthenticationFlowTests.java @@ -0,0 +1,142 @@ +package com.ankurm.jwtauth; + +import com.ankurm.jwtauth.auth.RevokedTokenStore; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +import java.util.Map; + +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.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * The 401-vs-403 contract, pinned as tests. + * + * @see docs/03-401-vs-403.md + */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("hs256") +class AuthenticationFlowTests { + + @Autowired MockMvc mvc; + @Autowired RevokedTokenStore revokedTokens; + + private static final tools.jackson.databind.ObjectMapper JSON = + new tools.jackson.databind.ObjectMapper(); + + private Map login(String user, String password) throws Exception { + MvcResult result = this.mvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"username\":\"%s\",\"password\":\"%s\"}".formatted(user, password))) + .andExpect(status().isOk()) + .andReturn(); + return JSON.readValue(result.getResponse().getContentAsString(), + new tools.jackson.core.type.TypeReference>() { }); + } + + @Test + void publicEndpointNeedsNoToken() throws Exception { + this.mvc.perform(get("/api/public/ping")).andExpect(status().isOk()); + } + + @Test + void missingTokenIs401NotA403() throws Exception { + this.mvc.perform(get("/api/me")) + .andExpect(status().isUnauthorized()) + .andExpect(header().string("WWW-Authenticate", + org.hamcrest.Matchers.containsString("Bearer"))); + } + + @Test + void validTokenWithoutTheRoleIs403NotA401() throws Exception { + String token = login("alice", "alice-password").get("accessToken"); + this.mvc.perform(get("/api/admin/stats").header("Authorization", "Bearer " + token)) + .andExpect(status().isForbidden()) + .andExpect(header().string("WWW-Authenticate", + org.hamcrest.Matchers.containsString("insufficient_scope"))); + } + + @Test + void adminTokenReachesAdminEndpoint() throws Exception { + String token = login("root", "root-password").get("accessToken"); + this.mvc.perform(get("/api/admin/stats").header("Authorization", "Bearer " + token)) + .andExpect(status().isOk()); + } + + @Test + void tamperedSignatureIs401WithInvalidToken() throws Exception { + String token = login("alice", "alice-password").get("accessToken"); + String tampered = token.substring(0, token.length() - 4) + "AAAA"; + this.mvc.perform(get("/api/me").header("Authorization", "Bearer " + tampered)) + .andExpect(status().isUnauthorized()) + .andExpect(header().string("WWW-Authenticate", + org.hamcrest.Matchers.containsString("invalid_token"))); + } + + @Test + void refreshTokenIsNotAnAccessToken() throws Exception { + String refresh = login("alice", "alice-password").get("refreshToken"); + this.mvc.perform(get("/api/me").header("Authorization", "Bearer " + refresh)) + .andExpect(status().isUnauthorized()); + } + + @Test + void badPasswordIs401AndSaysNothingUseful() throws Exception { + this.mvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"username\":\"alice\",\"password\":\"nope\"}")) + .andExpect(status().isUnauthorized()); + } + + @Test + void lockedAccountIsIndistinguishableFromABadPassword() throws Exception { + MvcResult locked = this.mvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"username\":\"locked\",\"password\":\"locked-password\"}")) + .andExpect(status().isUnauthorized()).andReturn(); + MvcResult wrong = this.mvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"username\":\"alice\",\"password\":\"nope\"}")) + .andExpect(status().isUnauthorized()).andReturn(); + + assertThat(locked.getResponse().getContentAsString()) + .isEqualTo(wrong.getResponse().getContentAsString()); + } + + @Test + void revokedTokenIsRefusedEvenThoughTheSignatureIsStillValid() throws Exception { + String token = login("alice", "alice-password").get("accessToken"); + this.mvc.perform(get("/api/me").header("Authorization", "Bearer " + token)) + .andExpect(status().isOk()); + + this.mvc.perform(post("/api/auth/logout").header("Authorization", "Bearer " + token)) + .andExpect(status().isNoContent()); + + this.mvc.perform(get("/api/me").header("Authorization", "Bearer " + token)) + .andExpect(status().isUnauthorized()); + } + + @Test + void spentRefreshTokenCannotBeReplayed() throws Exception { + String refresh = login("alice", "alice-password").get("refreshToken"); + this.mvc.perform(post("/api/auth/refresh") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"refreshToken\":\"%s\"}".formatted(refresh))) + .andExpect(status().isOk()); + this.mvc.perform(post("/api/auth/refresh") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"refreshToken\":\"%s\"}".formatted(refresh))) + .andExpect(status().isUnauthorized()); + } + +} diff --git a/src/test/java/com/ankurm/jwtauth/CsrfBreaksPermitAllTests.java b/src/test/java/com/ankurm/jwtauth/CsrfBreaksPermitAllTests.java new file mode 100644 index 0000000..0b36b69 --- /dev/null +++ b/src/test/java/com/ankurm/jwtauth/CsrfBreaksPermitAllTests.java @@ -0,0 +1,59 @@ +package com.ankurm.jwtauth; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.security.web.FilterChainProxy; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Pins the failure described in docs/04-csrf-permitall-403.md, and the two + * facts that explain it. + */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles({"hs256", "csrfon"}) +class CsrfBreaksPermitAllTests { + + @Autowired MockMvc mvc; + @Autowired FilterChainProxy filterChainProxy; + + @Test + void permitAllLoginStillReturns403WhenCsrfIsOn() throws Exception { + this.mvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"username\":\"alice\",\"password\":\"alice-password\"}")) + .andExpect(status().isForbidden()); + } + + @Test + void theSameRequestWithACsrfTokenSucceeds() throws Exception { + this.mvc.perform(post("/api/auth/login").with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"username\":\"alice\",\"password\":\"alice-password\"}")) + .andExpect(status().isOk()); + } + + @Test + void csrfFilterRunsLongBeforeAuthorizationFilter() { + List filters = this.filterChainProxy.getFilterChains().getFirst() + .getFilters().stream().map(f -> f.getClass().getSimpleName()).toList(); + + int csrf = filters.indexOf("CsrfFilter"); + int authorization = filters.indexOf("AuthorizationFilter"); + + assertThat(csrf).isNotNegative(); + assertThat(authorization).isEqualTo(filters.size() - 1); + assertThat(csrf).isLessThan(authorization); + } +}