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.
This commit is contained in:
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
target/
|
||||
.mvn/
|
||||
!.mvn/wrapper/maven-wrapper.properties
|
||||
*.iml
|
||||
.idea/
|
||||
.vscode/
|
||||
.classpath
|
||||
.project
|
||||
.settings/
|
||||
*.log
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -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.
|
||||
132
README.md
Normal file
132
README.md
Normal file
@@ -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.
|
||||
97
docs/01-architecture.md
Normal file
97
docs/01-architecture.md
Normal file
@@ -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.
|
||||
157
docs/02-filter-chain-and-ordering.md
Normal file
157
docs/02-filter-chain-and-ordering.md
Normal file
@@ -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<JwtAuthenticationFilter> disableAutoRegistration(
|
||||
JwtAuthenticationFilter filter) {
|
||||
FilterRegistrationBean<JwtAuthenticationFilter> 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.
|
||||
148
docs/03-401-vs-403.md
Normal file
148
docs/03-401-vs-403.md
Normal file
@@ -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 |
|
||||
145
docs/04-csrf-permitall-403.md
Normal file
145
docs/04-csrf-permitall-403.md
Normal file
@@ -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
|
||||
180
docs/05-hs256-vs-rs256.md
Normal file
180
docs/05-hs256-vs-rs256.md
Normal file
@@ -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<OctetSequenceKey.Builder>)`.
|
||||
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.
|
||||
130
docs/06-securitycontext-and-statelessness.md
Normal file
130
docs/06-securitycontext-and-statelessness.md
Normal file
@@ -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.
|
||||
286
docs/07-edge-cases.md
Normal file
286
docs/07-edge-cases.md
Normal file
@@ -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<Jwt> audience =
|
||||
new JwtClaimValidator<List<String>>(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).
|
||||
132
docs/08-testing.md
Normal file
132
docs/08-testing.md
Normal file
@@ -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
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-webmvc-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-security-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
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<String> 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.
|
||||
123
docs/09-manual-filter-vs-resource-server.md
Normal file
123
docs/09-manual-filter-vs-resource-server.md
Normal file
@@ -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.
|
||||
66
docs/10-production-checklist.md
Normal file
66
docs/10-production-checklist.md
Normal file
@@ -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.
|
||||
132
docs/11-spring-security-7-changes.md
Normal file
132
docs/11-spring-security-7-changes.md
Normal file
@@ -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
|
||||
19
docs/output/csrf-trace.txt
Normal file
19
docs/output/csrf-trace.txt
Normal file
@@ -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.
|
||||
54
docs/output/csrf-vs-permitall.txt
Normal file
54
docs/output/csrf-vs-permitall.txt
Normal file
@@ -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
|
||||
253
docs/output/curl-transcript-hs256.txt
Normal file
253
docs/output/curl-transcript-hs256.txt
Normal file
@@ -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
|
||||
40
docs/output/expiry-and-clock-skew.txt
Normal file
40
docs/output/expiry-and-clock-skew.txt
Normal file
@@ -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
|
||||
63
docs/output/resource-server-loose.txt
Normal file
63
docs/output/resource-server-loose.txt
Normal file
@@ -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
|
||||
53
docs/output/resource-server-strict.txt
Normal file
53
docs/output/resource-server-strict.txt
Normal file
@@ -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
|
||||
83
docs/output/rs256-demo.txt
Normal file
83
docs/output/rs256-demo.txt
Normal file
@@ -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
|
||||
14
docs/output/test-run.txt
Normal file
14
docs/output/test-run.txt
Normal file
@@ -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
|
||||
74
pom.xml
Normal file
74
pom.xml
Normal file
@@ -0,0 +1,74 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>4.1.1</version>
|
||||
<relativePath/>
|
||||
</parent>
|
||||
|
||||
<groupId>com.ankurm</groupId>
|
||||
<artifactId>jwt-auth-demo</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<name>jwt-auth-demo</name>
|
||||
<description>Spring Security 7.1 JWT authentication on Spring Boot 4.1 - runnable companion for ankurm.com</description>
|
||||
|
||||
<properties>
|
||||
<java.version>25</java.version>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-security</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-validation</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<!--
|
||||
Spring Boot 4 split the test slices into their own modules. @AutoConfigureMockMvc
|
||||
and @WebMvcTest now live in spring-boot-starter-webmvc-test, and the Spring
|
||||
Security test support arrives via spring-boot-starter-security-test. On Boot 3
|
||||
spring-boot-starter-test alone was enough; on Boot 4 it is not.
|
||||
-->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-webmvc-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-security-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
36
scripts/csrf-demo.sh
Executable file
36
scripts/csrf-demo.sh
Executable file
@@ -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"
|
||||
112
scripts/curl-transcript.sh
Executable file
112
scripts/curl-transcript.sh
Executable file
@@ -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"
|
||||
32
scripts/expiry-demo.sh
Executable file
32
scripts/expiry-demo.sh
Executable file
@@ -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"
|
||||
38
scripts/resource-server-demo.sh
Executable file
38
scripts/resource-server-demo.sh
Executable file
@@ -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"
|
||||
45
scripts/rs256-demo.sh
Executable file
45
scripts/rs256-demo.sh
Executable file
@@ -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"
|
||||
42
scripts/run-all.sh
Executable file
42
scripts/run-all.sh
Executable file
@@ -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."
|
||||
16
scripts/run.sh
Executable file
16
scripts/run.sh
Executable file
@@ -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
|
||||
31
src/main/java/com/ankurm/jwtauth/JwtAuthDemoApplication.java
Normal file
31
src/main/java/com/ankurm/jwtauth/JwtAuthDemoApplication.java
Normal file
@@ -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)".
|
||||
*
|
||||
* <p>Two signing variants are wired as Spring profiles:
|
||||
* <ul>
|
||||
* <li>{@code hs256} (default) - symmetric HMAC, one shared secret.</li>
|
||||
* <li>{@code rs256} - asymmetric RSA, private key signs, public key (JWKS) verifies.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Two validation styles are wired as Spring profiles too:
|
||||
* <ul>
|
||||
* <li>{@code manual} (default) - a hand-written {@code OncePerRequestFilter}.</li>
|
||||
* <li>{@code resourceserver} - Spring Security's built-in
|
||||
* {@code oauth2ResourceServer().jwt()} support.</li>
|
||||
* </ul>
|
||||
*
|
||||
* @see docs/01-architecture.md
|
||||
*/
|
||||
@SpringBootApplication
|
||||
public class JwtAuthDemoApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(JwtAuthDemoApplication.class, args);
|
||||
}
|
||||
}
|
||||
121
src/main/java/com/ankurm/jwtauth/auth/AuthController.java
Normal file
121
src/main/java/com/ankurm/jwtauth/auth/AuthController.java
Normal file
@@ -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}.
|
||||
*
|
||||
* <p>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<Void> 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<String, Integer> revocations() {
|
||||
return java.util.Map.of("revokedTokens", this.revokedTokens.size());
|
||||
}
|
||||
|
||||
static class LoginFailed extends RuntimeException {
|
||||
LoginFailed(AuthenticationException cause) {
|
||||
super(cause);
|
||||
}
|
||||
}
|
||||
}
|
||||
17
src/main/java/com/ankurm/jwtauth/auth/AuthDtos.java
Normal file
17
src/main/java/com/ankurm/jwtauth/auth/AuthDtos.java
Normal file
@@ -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) { }
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <p>Five details separate a filter that works from one that only appears to:
|
||||
* <ol>
|
||||
* <li>It extends {@link OncePerRequestFilter}, so a {@code FORWARD} to an error
|
||||
* page or a {@code @Async} dispatch does not run authentication twice.</li>
|
||||
* <li>No token present is <em>not</em> an error. The filter continues the chain and
|
||||
* lets {@code AuthorizationFilter} decide - that is what makes {@code permitAll()}
|
||||
* endpoints reachable without a token.</li>
|
||||
* <li>A token that <em>is</em> 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.</li>
|
||||
* <li>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.</li>
|
||||
* <li>The context is cleared on failure, so a pooled thread cannot leak a previous
|
||||
* request's principal.</li>
|
||||
* </ol>
|
||||
*
|
||||
* @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<GrantedAuthority> authorities(Jwt jwt) {
|
||||
List<GrantedAuthority> 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<String> roles = jwt.getClaimAsStringList("roles");
|
||||
if (roles != null) {
|
||||
for (String role : roles) {
|
||||
result.add(new org.springframework.security.core.authority.SimpleGrantedAuthority(
|
||||
"ROLE_" + role));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
40
src/main/java/com/ankurm/jwtauth/auth/RevokedTokenStore.java
Normal file
40
src/main/java/com/ankurm/jwtauth/auth/RevokedTokenStore.java
Normal file
@@ -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.
|
||||
*
|
||||
* <p>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<String, Instant> 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));
|
||||
}
|
||||
}
|
||||
112
src/main/java/com/ankurm/jwtauth/auth/TokenService.java
Normal file
112
src/main/java/com/ankurm/jwtauth/auth/TokenService.java
Normal file
@@ -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.
|
||||
*
|
||||
* <p>Two claims here are not decoration:
|
||||
* <ul>
|
||||
* <li>{@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.</li>
|
||||
* <li>{@code jti} - a per-token id, which is what a denylist keys on. A JWT is not
|
||||
* revocable without one.</li>
|
||||
* </ul>
|
||||
*/
|
||||
@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<String> 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<String> 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<JwtClaimsSet.Builder> 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) { }
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <p>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;
|
||||
}
|
||||
}
|
||||
46
src/main/java/com/ankurm/jwtauth/config/AppUsers.java
Normal file
46
src/main/java/com/ankurm/jwtauth/config/AppUsers.java
Normal file
@@ -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.
|
||||
*
|
||||
* <p>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);
|
||||
}
|
||||
}
|
||||
74
src/main/java/com/ankurm/jwtauth/config/Hs256KeyConfig.java
Normal file
74
src/main/java/com/ankurm/jwtauth/config/Hs256KeyConfig.java
Normal file
@@ -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.
|
||||
*
|
||||
* <p>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<OAuth2TokenValidator<Jwt>> 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<SecurityContext> jwkSource = new ImmutableSecret<>(this.secretKey);
|
||||
return new NimbusJwtEncoder(jwkSource);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <p>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<Jwt> compose(String issuer,
|
||||
String audience,
|
||||
ObjectProvider<OAuth2TokenValidator<Jwt>> extras) {
|
||||
List<OAuth2TokenValidator<Jwt>> 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);
|
||||
}
|
||||
}
|
||||
@@ -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}.
|
||||
*
|
||||
* <p>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<Jwt, AbstractAuthenticationToken> 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<GrantedAuthority> 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");
|
||||
}
|
||||
}
|
||||
120
src/main/java/com/ankurm/jwtauth/config/Rs256KeyConfig.java
Normal file
120
src/main/java/com/ankurm/jwtauth/config/Rs256KeyConfig.java
Normal file
@@ -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.
|
||||
*
|
||||
* <p>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<OAuth2TokenValidator<Jwt>> 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<String, Object> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
140
src/main/java/com/ankurm/jwtauth/config/SecurityConfig.java
Normal file
140
src/main/java/com/ankurm/jwtauth/config/SecurityConfig.java
Normal file
@@ -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.
|
||||
*
|
||||
* <p>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);
|
||||
}
|
||||
}
|
||||
55
src/main/java/com/ankurm/jwtauth/diag/FilterChainReport.java
Normal file
55
src/main/java/com/ankurm/jwtauth/diag/FilterChainReport.java
Normal file
@@ -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.
|
||||
*
|
||||
* <p>{@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<Map<String, Object>> filters(HttpServletRequest request) {
|
||||
return this.filterChainProxy.getFilterChains().stream()
|
||||
.map(chain -> Map.<String, Object>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();
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <p>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}.
|
||||
*
|
||||
* <pre>
|
||||
* NimbusJwtDecoder decoder = NimbusJwtDecoder.withSecretKey(key).build();
|
||||
* decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
|
||||
* JwtValidators.createDefaultWithIssuer(issuer),
|
||||
* new AccessTokenTypeValidator()));
|
||||
* </pre>
|
||||
*
|
||||
* @see docs/07-edge-cases.md
|
||||
*/
|
||||
public class AccessTokenTypeValidator implements OAuth2TokenValidator<Jwt> {
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <p>{@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<String, Object> asyncDemo() throws Exception {
|
||||
Callable<String> 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
39
src/main/java/com/ankurm/jwtauth/edge/AudienceValidator.java
Normal file
39
src/main/java/com/ankurm/jwtauth/edge/AudienceValidator.java
Normal file
@@ -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.
|
||||
*
|
||||
* <p>{@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<Jwt> forAudience(String expected) {
|
||||
return new JwtClaimValidator<List<String>>(JwtClaimNames.AUD,
|
||||
aud -> aud != null && aud.contains(expected));
|
||||
}
|
||||
|
||||
/** The long-hand equivalent, for readers who prefer to see the shape. */
|
||||
public static OAuth2TokenValidator<Jwt> 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);
|
||||
}
|
||||
}
|
||||
62
src/main/java/com/ankurm/jwtauth/web/ApiControllers.java
Normal file
62
src/main/java/com/ankurm/jwtauth/web/ApiControllers.java
Normal file
@@ -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:
|
||||
* <ul>
|
||||
* <li>{@code /api/public/ping} - permitAll, works with no token.</li>
|
||||
* <li>{@code /api/me} - authenticated, 401 without a token.</li>
|
||||
* <li>{@code /api/admin/stats} - hasRole('ADMIN'), 403 for a valid non-admin token.</li>
|
||||
* <li>{@code /api/reports} - hasAuthority('SCOPE_admin:read'), the scope-based twin.</li>
|
||||
* </ul>
|
||||
*/
|
||||
@RestController
|
||||
public class ApiControllers {
|
||||
|
||||
@GetMapping("/api/public/ping")
|
||||
public Map<String, Object> ping() {
|
||||
return Map.of("status", "up", "authenticationRequired", false);
|
||||
}
|
||||
|
||||
@GetMapping("/api/me")
|
||||
public Map<String, Object> me(Authentication authentication) {
|
||||
Map<String, Object> 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<String, Object> adminStats() {
|
||||
return Map.of("activeUsers", 3, "requiredRole", "ROLE_ADMIN");
|
||||
}
|
||||
|
||||
@GetMapping("/api/reports")
|
||||
@PreAuthorize("hasAuthority('SCOPE_admin:read')")
|
||||
public List<String> reports() {
|
||||
return List.of("q1-revenue", "q2-revenue");
|
||||
}
|
||||
}
|
||||
5
src/main/resources/application-csrfon.yaml
Normal file
5
src/main/resources/application-csrfon.yaml
Normal file
@@ -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
|
||||
6
src/main/resources/application-shortlived.yaml
Normal file
6
src/main/resources/application-shortlived.yaml
Normal file
@@ -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
|
||||
5
src/main/resources/application-strict.yaml
Normal file
5
src/main/resources/application-strict.yaml
Normal file
@@ -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
|
||||
5
src/main/resources/application-trace.yaml
Normal file
5
src/main/resources/application-trace.yaml
Normal file
@@ -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
|
||||
28
src/main/resources/application.yaml
Normal file
28
src/main/resources/application.yaml
Normal file
@@ -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
|
||||
28
src/main/resources/demo-private-key.pem
Normal file
28
src/main/resources/demo-private-key.pem
Normal file
@@ -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-----
|
||||
9
src/main/resources/demo-public-key.pem
Normal file
9
src/main/resources/demo-public-key.pem
Normal file
@@ -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-----
|
||||
142
src/test/java/com/ankurm/jwtauth/AuthenticationFlowTests.java
Normal file
142
src/test/java/com/ankurm/jwtauth/AuthenticationFlowTests.java
Normal file
@@ -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<String, String> 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<Map<String, String>>() { });
|
||||
}
|
||||
|
||||
@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());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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<String> 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user