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:
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
|
||||
Reference in New Issue
Block a user