1
0

Add OAuth2 resource server project: JWT validation, JWKS and key rotation

Companion code for the follow-up article. The repository now holds two Maven
projects sharing one docs/ tree:

  jwt-authentication/       the hand-written filter application (unchanged, moved)
  oauth2-resource-server/   a resource server, a Keycloak compose, and a stub
                            issuer whose JWK Set can be mutated on command

The stub exists because Keycloak will not rotate a signing key at a chosen
second, report how many times its JWKS endpoint was fetched, or drop a key from
the published set on request - and the caching and rotation measurements need
all three. The Keycloak run confirms the same code path against a real issuer.

Findings captured under docs/output/, all from real runs:

  * The default validator stack does not check aud. A token minted for another
    service in the same realm is accepted.
  * Spring Security builds its JWKSource with refreshAheadCache(false) and
    rateLimited(false), overriding two of Nimbus's protective defaults, and
    enables Nimbus caching only when NO Spring cache was supplied - so
    supplying one removes the five-minute expiry.
  * A key retired from the JWK Set stops being accepted at t+300s with the
    default cache, and never with a Spring cache that has no TTL.
  * 25 tokens carrying an unknown kid produce 25 JWKS fetches at the issuer,
    through permitAll() endpoints included.
  * A hyphenated client id in an authorities-claim-expression parses as
    subtraction; the SpelEvaluationException is swallowed and logged at TRACE.
  * A clientScopes key in a Keycloak realm import replaces the built-in scopes
    rather than adding to them.

New docs chapters 12-18. README covers both projects. Existing docs and scripts
updated for the new paths; no docs/output/ file from the first article moved, so
links in the published article still resolve.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013f7f2XZXrQ6gW3RtZE187t
This commit is contained in:
2026-08-23 11:00:56 +00:00
parent 4a8dab6739
commit 4dc45d5e00
101 changed files with 4087 additions and 64 deletions

View File

@@ -0,0 +1,74 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/>
</parent>
<groupId>com.ankurm</groupId>
<artifactId>jwt-auth-demo</artifactId>
<version>1.0.0</version>
<name>jwt-auth-demo</name>
<description>Spring Security 7.1 JWT authentication on Spring Boot 4.1 - runnable companion for ankurm.com</description>
<properties>
<java.version>25</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!--
Spring Boot 4 split the test slices into their own modules. @AutoConfigureMockMvc
and @WebMvcTest now live in spring-boot-starter-webmvc-test, and the Spring
Security test support arrives via spring-boot-starter-security-test. On Boot 3
spring-boot-starter-test alone was enough; on Boot 4 it is not.
-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,36 @@
#!/usr/bin/env bash
# Reproduces the "permitAll() endpoint still returns 403" failure.
# Requires: --spring.profiles.active=hs256,csrfon
set -u
BASE="${1:-http://localhost:8080}"
show() {
local out
out=$(curl -sS -D /tmp/.h -o /tmp/.b -w '%{http_code}' "$@")
printf 'HTTP %s\n' "$out"
grep -iE '^(www-authenticate|content-type):' /tmp/.h | sed 's/\r$//'
[ -s /tmp/.b ] && { python3 -m json.tool < /tmp/.b 2>/dev/null || cat /tmp/.b; echo; }
}
hr(){ printf '\n%s\n' "--------------------------------------------------------------------------"; }
echo "=========================================================================="
echo " jwt-auth-demo - CSRF vs permitAll()"
echo " app started with: --spring.profiles.active=hs256,csrfon"
echo "=========================================================================="
hr; echo "# A. The login endpoint is permitAll(). POST it anyway."
echo "# 403 - and nothing in the authorization rules explains why."
echo
show -X POST "$BASE/api/auth/login" -H 'Content-Type: application/json' \
-d '{"username":"alice","password":"alice-password"}'
hr; echo "# B. GET on the same permitAll() path family works. Only unsafe methods break."
echo
show "$BASE/api/public/ping"
hr; echo "# C. The filter chain, with CsrfFilter present. Count the positions:"
echo "# CsrfFilter is 5th, AuthorizationFilter is last. The request never"
echo "# reaches the filter that knows about permitAll()."
echo
show "$BASE/api/public/filters"
hr; echo "# end"

View File

@@ -0,0 +1,112 @@
#!/usr/bin/env bash
# Regenerates ../docs/output/curl-transcript-hs256.txt against a running instance.
# Usage: ./scripts/curl-transcript.sh [base-url]
set -u
BASE="${1:-http://localhost:8080}"
hr() { printf '\n%s\n' "--------------------------------------------------------------------------"; }
step(){ hr; printf '# %s\n\n' "$1"; }
# Print status line, the security-relevant headers, and the body.
show() {
local out
out=$(curl -sS -D /tmp/.h -o /tmp/.b -w '%{http_code}' "$@")
printf 'HTTP %s\n' "$out"
grep -iE '^(www-authenticate|content-type|set-cookie):' /tmp/.h | sed 's/\r$//'
if [ -s /tmp/.b ]; then
python3 -m json.tool < /tmp/.b 2>/dev/null || cat /tmp/.b
echo
fi
}
jwt_part() { # $1=token $2=0|1 -> pretty-print header or payload
echo "$1" | cut -d. -f$(( $2 + 1 )) \
| tr '_-' '/+' | sed 's/$/==/' | base64 -d 2>/dev/null \
| python3 -m json.tool 2>/dev/null
}
echo "=========================================================================="
echo " jwt-auth-demo - curl transcript"
echo " target : $BASE"
echo "=========================================================================="
step "1. Public endpoint, no token. permitAll() means the filter chain lets it through."
show "$BASE/api/public/ping"
step "2. Protected endpoint, no token. 401 - we do not know who you are."
show "$BASE/api/me"
step "3. Wrong password. Still 401, and the body says nothing about which half was wrong."
show -X POST "$BASE/api/auth/login" -H 'Content-Type: application/json' \
-d '{"username":"alice","password":"wrong-password"}'
step "4. Locked account. 401 with the identical body - no account-state oracle."
show -X POST "$BASE/api/auth/login" -H 'Content-Type: application/json' \
-d '{"username":"locked","password":"locked-password"}'
step "5. Login as alice (ROLE_USER, SCOPE_profile:read)."
show -X POST "$BASE/api/auth/login" -H 'Content-Type: application/json' \
-d '{"username":"alice","password":"alice-password"}'
ALICE=$(python3 -c "import json;print(json.load(open('/tmp/.b'))['accessToken'])")
ALICE_REFRESH=$(python3 -c "import json;print(json.load(open('/tmp/.b'))['refreshToken'])")
step "6. What is actually inside that token (base64url decode - no signature check)."
echo "--- JOSE header ---"
jwt_part "$ALICE" 0
echo "--- claims ---"
jwt_part "$ALICE" 1
step "7. The same protected endpoint, now with the token. 200."
show "$BASE/api/me" -H "Authorization: Bearer $ALICE"
step "8. alice hits an ADMIN endpoint. 403, not 401 - we know who she is, she may not."
show "$BASE/api/admin/stats" -H "Authorization: Bearer $ALICE"
step "9. Same request with a scope-based rule (@PreAuthorize). Also 403."
show "$BASE/api/reports" -H "Authorization: Bearer $ALICE"
step "10. Login as root and repeat. 200."
show -X POST "$BASE/api/auth/login" -H 'Content-Type: application/json' \
-d '{"username":"root","password":"root-password"}'
ROOT=$(python3 -c "import json;print(json.load(open('/tmp/.b'))['accessToken'])")
show "$BASE/api/admin/stats" -H "Authorization: Bearer $ROOT"
step "11. Tampered payload, original signature. 401 invalid_token."
HDR=$(echo "$ALICE" | cut -d. -f1); SIG=$(echo "$ALICE" | cut -d. -f3)
FORGED_PAYLOAD=$(echo "$ALICE" | cut -d. -f2 | tr '_-' '/+' | sed 's/$/==/' | base64 -d 2>/dev/null \
| sed 's/"roles":\["USER"\]/"roles":["ADMIN"]/' | base64 -w0 | tr '/+' '_-' | tr -d '=')
show "$BASE/api/admin/stats" -H "Authorization: Bearer $HDR.$FORGED_PAYLOAD.$SIG"
step "12. Garbage where a token should be. 401, and note it is NOT 400."
show "$BASE/api/me" -H "Authorization: Bearer not-a-jwt"
step "13. Authorization header with no Bearer scheme. The resolver sees no token at all,\n# so this is an authorization failure, not a token failure - note the bare realm."
show "$BASE/api/me" -H "Authorization: $ALICE"
step "14. A refresh token presented as an access token. 401 - the token_type claim."
show "$BASE/api/me" -H "Authorization: Bearer $ALICE_REFRESH"
step "15. Refresh with rotation. New access token, new refresh token."
show -X POST "$BASE/api/auth/refresh" -H 'Content-Type: application/json' \
-d "{\"refreshToken\":\"$ALICE_REFRESH\"}"
ALICE2=$(python3 -c "import json;print(json.load(open('/tmp/.b'))['accessToken'])")
step "16. Replay the spent refresh token. 401 - rotation makes replay detectable."
show -X POST "$BASE/api/auth/refresh" -H 'Content-Type: application/json' \
-d "{\"refreshToken\":\"$ALICE_REFRESH\"}"
step "17. Logout revokes the presented access token by its jti."
show -X POST "$BASE/api/auth/logout" -H "Authorization: Bearer $ALICE2"
show "$BASE/api/auth/revocations" -H "Authorization: Bearer $ROOT"
step "18. The revoked token, still cryptographically valid, is now refused."
show "$BASE/api/me" -H "Authorization: Bearer $ALICE2"
step "19. The real filter order, read from FilterChainProxy at runtime."
show "$BASE/api/public/filters"
step "20. SecurityContext across a thread boundary."
show "$BASE/api/async-demo" -H "Authorization: Bearer $ROOT"
hr
echo "# end of transcript"

View File

@@ -0,0 +1,32 @@
#!/usr/bin/env bash
# Requires: --spring.profiles.active=hs256,shortlived (2-second access tokens)
set -u
BASE="${1:-http://localhost:8080}"
show() {
local out; out=$(curl -sS -D /tmp/.h -o /tmp/.b -w '%{http_code}' "$@")
printf 'HTTP %s\n' "$out"
grep -iE '^www-authenticate:' /tmp/.h | sed 's/\r$//'
[ -s /tmp/.b ] && { python3 -m json.tool < /tmp/.b 2>/dev/null | head -6 || cat /tmp/.b; }
echo
}
hr(){ printf '\n%s\n' "--------------------------------------------------------------------------"; }
echo "=========================================================================="
echo " jwt-auth-demo - token expiry and the 60-second clock skew"
echo " profiles: hs256,shortlived (access-token-ttl = 2s)"
echo "=========================================================================="
TOK=$(curl -sS -X POST "$BASE/api/auth/login" -H 'Content-Type: application/json' \
-d '{"username":"alice","password":"alice-password"}' | python3 -c 'import json,sys;print(json.load(sys.stdin)["accessToken"])')
hr; echo "# T+0s - fresh token"; echo; show "$BASE/api/me" -H "Authorization: Bearer $TOK"
hr; echo "# T+5s - exp has passed, but JwtTimestampValidator allows 60s of clock skew"
echo "# by default, so the token is STILL accepted. This surprises people"
echo "# who write a test that sleeps past exp and expects a 401."
echo; sleep 5; show "$BASE/api/me" -H "Authorization: Bearer $TOK"
hr; echo "# T+65s - past exp + the 60s skew window. Now it is refused."
echo; sleep 60; show "$BASE/api/me" -H "Authorization: Bearer $TOK"
hr; echo "# end"

View File

@@ -0,0 +1,38 @@
#!/usr/bin/env bash
# Compares the built-in resource server with and without a token_type validator.
# Requires two runs; see docs/09-manual-filter-vs-resource-server.md
set -u
BASE="${1:-http://localhost:8080}"
LABEL="${2:-resourceserver}"
show() {
local out; out=$(curl -sS -D /tmp/.h -o /tmp/.b -w '%{http_code}' "$@")
printf 'HTTP %s\n' "$out"
grep -iE '^www-authenticate:' /tmp/.h | sed 's/\r$//'
[ -s /tmp/.b ] && { python3 -m json.tool < /tmp/.b 2>/dev/null | head -10 || cat /tmp/.b; echo; }
}
hr(){ printf '\n%s\n' "--------------------------------------------------------------------------"; }
echo "=========================================================================="
echo " jwt-auth-demo - built-in resource server"
echo " profiles: $LABEL"
echo "=========================================================================="
R=$(curl -sS -X POST "$BASE/api/auth/login" -H 'Content-Type: application/json' \
-d '{"username":"alice","password":"alice-password"}')
ACCESS=$(echo "$R" | python3 -c 'import json,sys;print(json.load(sys.stdin)["accessToken"])')
REFRESH=$(echo "$R" | python3 -c 'import json,sys;print(json.load(sys.stdin)["refreshToken"])')
hr; echo "# 1. The filter chain. Note BearerTokenAuthenticationFilter in place of our"
echo "# hand-written JwtAuthenticationFilter - same slot, framework-owned."
echo; show "$BASE/api/public/filters"
hr; echo "# 2. Access token -> 200."; echo; show "$BASE/api/me" -H "Authorization: Bearer $ACCESS"
hr; echo "# 3. Non-admin on an admin route -> 403 insufficient_scope."
echo; show "$BASE/api/admin/stats" -H "Authorization: Bearer $ACCESS"
hr; echo "# 4. REFRESH token presented as an access token."
echo "# This is the line to watch when comparing the two runs."
echo; show "$BASE/api/me" -H "Authorization: Bearer $REFRESH"
hr; echo "# end"

View File

@@ -0,0 +1,45 @@
#!/usr/bin/env bash
# Requires: --spring.profiles.active=rs256
set -u
BASE="${1:-http://localhost:8080}"
show() {
local out; out=$(curl -sS -D /tmp/.h -o /tmp/.b -w '%{http_code}' "$@")
printf 'HTTP %s\n' "$out"
grep -iE '^www-authenticate:' /tmp/.h | sed 's/\r$//'
[ -s /tmp/.b ] && { python3 -m json.tool < /tmp/.b 2>/dev/null || cat /tmp/.b; echo; }
}
jwt_part(){ echo "$1" | cut -d. -f$(( $2 + 1 )) | tr '_-' '/+' | sed 's/$/==/' | base64 -d 2>/dev/null | python3 -m json.tool 2>/dev/null; }
hr(){ printf '\n%s\n' "--------------------------------------------------------------------------"; }
echo "=========================================================================="
echo " jwt-auth-demo - RS256 variant"
echo " profiles: rs256 (private key signs, public key / JWKS verifies)"
echo "=========================================================================="
hr; echo "# 1. The public half, published as a JWK Set. No private material here -"
echo "# n and e only. Any number of resource servers can poll this."
echo; show "$BASE/.well-known/jwks.json"
hr; echo "# 2. Login. Same endpoint, same request, different signature algorithm."
echo; show -X POST "$BASE/api/auth/login" -H 'Content-Type: application/json' \
-d '{"username":"root","password":"root-password"}'
TOK=$(python3 -c "import json;print(json.load(open('/tmp/.b'))['accessToken'])")
hr; echo "# 3. The JOSE header now carries alg=RS256 and the kid that selects the key."
echo; jwt_part "$TOK" 0
hr; echo "# 4. Token length. RS256 signatures are 256 bytes; HS256 signatures are 32."
echo
printf ' RS256 access token: %s characters\n' "${#TOK}"
printf ' signature segment : %s characters\n' "$(echo "$TOK" | cut -d. -f3 | wc -c)"
hr; echo "# 5. It works exactly the same from the caller's side."
echo; show "$BASE/api/me" -H "Authorization: Bearer $TOK"
hr; echo "# 6. Tampered payload, original signature -> 401, same as HS256."
HDR=$(echo "$TOK" | cut -d. -f1); SIG=$(echo "$TOK" | cut -d. -f3)
FORGED=$(echo "$TOK" | cut -d. -f2 | tr '_-' '/+' | sed 's/$/==/' | base64 -d 2>/dev/null \
| sed 's/"sub":"root"/"sub":"mallory"/' | base64 -w0 | tr '/+' '_-' | tr -d '=')
echo; show "$BASE/api/me" -H "Authorization: Bearer $HDR.$FORGED.$SIG"
hr; echo "# end"

View File

@@ -0,0 +1,42 @@
#!/usr/bin/env bash
# Regenerates every file under ../docs/output/ from a real run.
# Usage: ./scripts/run-all.sh
set -eu
cd "$(dirname "$0")/.."
echo "==> mvn test"
{
echo "=========================================================================="
echo " jwt-auth-demo - test run"
echo "=========================================================================="
echo
mvn -B test 2>&1 | grep -E '^\[INFO\] (Running|Tests run)|^\[ERROR\]|BUILD (SUCCESS|FAILURE)' \
| sed 's/^\[INFO\] //'
echo
echo "JDK : $(java -version 2>&1 | grep -v JAVA_TOOL | head -1)"
echo "Boot : 4.1.1"
echo "Security : 7.1.1"
} > ../docs/output/test-run.txt
echo "==> hs256 (manual filter) transcript"
./scripts/run.sh hs256 >/dev/null && ./scripts/curl-transcript.sh > ../docs/output/curl-transcript-hs256.txt 2>&1
echo "==> rs256 transcript"
./scripts/run.sh rs256 >/dev/null && ./scripts/rs256-demo.sh > ../docs/output/rs256-demo.txt 2>&1
echo "==> csrf vs permitAll"
./scripts/run.sh hs256,csrfon >/dev/null && ./scripts/csrf-demo.sh > ../docs/output/csrf-vs-permitall.txt 2>&1
echo "==> expiry and clock skew (takes ~70s)"
./scripts/run.sh hs256,shortlived >/dev/null && ./scripts/expiry-demo.sh > ../docs/output/expiry-and-clock-skew.txt 2>&1
echo "==> built-in resource server, without and with the token_type validator"
./scripts/run.sh hs256,resourceserver >/dev/null \
&& ./scripts/resource-server-demo.sh http://localhost:8080 "hs256,resourceserver" \
> ../docs/output/resource-server-loose.txt 2>&1
./scripts/run.sh hs256,resourceserver,strict >/dev/null \
&& ./scripts/resource-server-demo.sh http://localhost:8080 "hs256,resourceserver,strict" \
> ../docs/output/resource-server-strict.txt 2>&1
for p in $(ps -eo pid,cmd | grep '[J]wtAuthDemoApplication' | awk '{print $1}'); do kill -9 "$p" || true; done
echo "==> done. docs/output/ regenerated."

View File

@@ -0,0 +1,16 @@
#!/usr/bin/env bash
# Starts the app on a clean port 8080 with the given profiles.
# Usage: ./scripts/run.sh [profiles] e.g. ./scripts/run.sh rs256,resourceserver
set -eu
PROFILES="${1:-hs256}"
for p in $(ps -eo pid,cmd | grep '[J]wtAuthDemoApplication' | awk '{print $1}'); do kill -9 "$p" || true; done
sleep 2
setsid nohup mvn -B -o org.springframework.boot:spring-boot-maven-plugin:run \
-Dspring-boot.run.profiles="$PROFILES" > "/tmp/app-${PROFILES//,/-}.log" 2>&1 < /dev/null &
for i in $(seq 1 60); do
if curl -s -o /dev/null http://localhost:8080/api/public/ping 2>/dev/null; then
echo "started with profiles: $PROFILES"; exit 0
fi
sleep 2
done
echo "failed to start; see /tmp/app-${PROFILES//,/-}.log" >&2; exit 1

View File

@@ -0,0 +1,31 @@
package com.ankurm.jwtauth;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* Runnable companion for the ankurm.com article
* "Spring Security 7.1 JWT Authentication: The Complete Guide (Spring Boot 4.1)".
*
* <p>Two signing variants are wired as Spring profiles:
* <ul>
* <li>{@code hs256} (default) - symmetric HMAC, one shared secret.</li>
* <li>{@code rs256} - asymmetric RSA, private key signs, public key (JWKS) verifies.</li>
* </ul>
*
* <p>Two validation styles are wired as Spring profiles too:
* <ul>
* <li>{@code manual} (default) - a hand-written {@code OncePerRequestFilter}.</li>
* <li>{@code resourceserver} - Spring Security's built-in
* {@code oauth2ResourceServer().jwt()} support.</li>
* </ul>
*
* @see docs/01-architecture.md
*/
@SpringBootApplication
public class JwtAuthDemoApplication {
public static void main(String[] args) {
SpringApplication.run(JwtAuthDemoApplication.class, args);
}
}

View File

@@ -0,0 +1,121 @@
package com.ankurm.jwtauth.auth;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtException;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.ankurm.jwtauth.auth.AuthDtos.LoginRequest;
import com.ankurm.jwtauth.auth.AuthDtos.RefreshRequest;
import com.ankurm.jwtauth.auth.AuthDtos.TokenResponse;
/**
* The login endpoint. It is the only place that sees a password, and the only
* place that calls the {@code AuthenticationManager}.
*
* <p>Everything after this point in the system is stateless: no session is created,
* and the {@code SecurityContext} written here is deliberately NOT persisted.
*/
@RestController
@RequestMapping("/api/auth")
public class AuthController {
private final AuthenticationManager authenticationManager;
private final TokenService tokenService;
private final JwtDecoder jwtDecoder;
private final UserDetailsService userDetailsService;
private final RevokedTokenStore revokedTokens;
public AuthController(AuthenticationManager authenticationManager,
TokenService tokenService,
JwtDecoder jwtDecoder,
UserDetailsService userDetailsService,
RevokedTokenStore revokedTokens) {
this.authenticationManager = authenticationManager;
this.tokenService = tokenService;
this.jwtDecoder = jwtDecoder;
this.userDetailsService = userDetailsService;
this.revokedTokens = revokedTokens;
}
@PostMapping("/login")
public TokenResponse login(@Valid @RequestBody LoginRequest request) {
// Throws BadCredentialsException / LockedException / DisabledException,
// all AuthenticationException subtypes -> 401 via the exception handler.
Authentication authentication = this.authenticationManager.authenticate(
UsernamePasswordAuthenticationToken.unauthenticated(
request.username(), request.password()));
TokenService.IssuedToken access = this.tokenService.issueAccessToken(authentication);
TokenService.IssuedToken refresh = this.tokenService.issueRefreshToken(authentication.getName());
return new TokenResponse(access.value(), refresh.value(), "Bearer", access.expiresInSeconds());
}
/**
* Refresh with rotation: the presented refresh token is revoked as it is spent.
* Replaying it is then a detectable event, not a silent success.
*/
@PostMapping("/refresh")
public TokenResponse refresh(@Valid @RequestBody RefreshRequest request) {
Jwt jwt;
try {
jwt = this.jwtDecoder.decode(request.refreshToken());
}
catch (JwtException ex) {
throw new BadCredentialsException("Refresh token is not valid", ex);
}
if (!TokenService.REFRESH.equals(jwt.getClaimAsString("token_type"))) {
throw new BadCredentialsException("Not a refresh token");
}
if (this.revokedTokens.isRevoked(jwt.getId())) {
throw new BadCredentialsException("Refresh token already used or revoked");
}
this.revokedTokens.revoke(jwt.getId(), jwt.getExpiresAt());
UserDetails user = this.userDetailsService.loadUserByUsername(jwt.getSubject());
Authentication authentication = UsernamePasswordAuthenticationToken.authenticated(
user, null, user.getAuthorities());
TokenService.IssuedToken access = this.tokenService.issueAccessToken(authentication);
TokenService.IssuedToken newRefresh = this.tokenService.issueRefreshToken(user.getUsername());
return new TokenResponse(access.value(), newRefresh.value(), "Bearer", access.expiresInSeconds());
}
/** Revokes the presented access token by jti. Requires a valid token to call. */
@PostMapping("/logout")
public ResponseEntity<Void> logout(Authentication authentication) {
if (authentication instanceof
org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken jwtAuth) {
Jwt jwt = jwtAuth.getToken();
this.revokedTokens.revoke(jwt.getId(), jwt.getExpiresAt());
}
return ResponseEntity.noContent().build();
}
@GetMapping("/revocations")
public java.util.Map<String, Integer> revocations() {
return java.util.Map.of("revokedTokens", this.revokedTokens.size());
}
static class LoginFailed extends RuntimeException {
LoginFailed(AuthenticationException cause) {
super(cause);
}
}
}

View File

@@ -0,0 +1,17 @@
package com.ankurm.jwtauth.auth;
import jakarta.validation.constraints.NotBlank;
public final class AuthDtos {
private AuthDtos() { }
public record LoginRequest(@NotBlank String username, @NotBlank String password) { }
public record RefreshRequest(@NotBlank String refreshToken) { }
public record TokenResponse(String accessToken,
String refreshToken,
String tokenType,
long expiresIn) { }
}

View File

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

View File

@@ -0,0 +1,40 @@
package com.ankurm.jwtauth.auth;
import java.time.Instant;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.stereotype.Component;
/**
* The smallest thing that makes a stateless token revocable: a jti denylist.
*
* <p>Entries only need to outlive the token's own expiry, so the map self-prunes.
* In production this is Redis with a TTL, not a map - but the shape is identical.
* See docs/07-edge-cases.md#logout-and-revocation.
*/
@Component
public class RevokedTokenStore {
private final Map<String, Instant> revoked = new ConcurrentHashMap<>();
public void revoke(String jti, Instant expiresAt) {
prune();
this.revoked.put(jti, expiresAt);
}
public boolean isRevoked(String jti) {
prune();
return jti != null && this.revoked.containsKey(jti);
}
public int size() {
prune();
return this.revoked.size();
}
private void prune() {
Instant now = Instant.now();
this.revoked.entrySet().removeIf(e -> e.getValue() != null && e.getValue().isBefore(now));
}
}

View File

@@ -0,0 +1,112 @@
package com.ankurm.jwtauth.auth;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.oauth2.jwt.JwsHeader;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtClaimsSet;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
import org.springframework.stereotype.Service;
/**
* Mints the tokens. This is the "token issue" step of the flow.
*
* <p>Two claims here are not decoration:
* <ul>
* <li>{@code token_type} - separates access tokens from refresh tokens. Without it,
* a refresh token is a perfectly valid access token, because both are signed
* by the same key. See docs/07-edge-cases.md#refresh-token-as-access-token.</li>
* <li>{@code jti} - a per-token id, which is what a denylist keys on. A JWT is not
* revocable without one.</li>
* </ul>
*/
@Service
public class TokenService {
public static final String ACCESS = "access";
public static final String REFRESH = "refresh";
private final JwtEncoder encoder;
private final String issuer;
private final String audience;
private final Duration accessTtl;
private final Duration refreshTtl;
public TokenService(JwtEncoder encoder,
@Value("${demo.jwt.issuer}") String issuer,
@Value("${demo.jwt.audience}") String audience,
@Value("${demo.jwt.access-token-ttl}") Duration accessTtl,
@Value("${demo.jwt.refresh-token-ttl}") Duration refreshTtl) {
this.encoder = encoder;
this.issuer = issuer;
this.audience = audience;
this.accessTtl = accessTtl;
this.refreshTtl = refreshTtl;
}
public IssuedToken issueAccessToken(Authentication authentication) {
List<String> authorities = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.sorted()
.toList();
// Spring Security's default JwtGrantedAuthoritiesConverter reads the "scope"
// claim and prefixes each value with SCOPE_. We keep roles in a separate
// "roles" claim so the two authority families stay distinguishable.
String scope = authorities.stream()
.filter(a -> a.startsWith("SCOPE_"))
.map(a -> a.substring("SCOPE_".length()))
.collect(Collectors.joining(" "));
List<String> roles = authorities.stream()
.filter(a -> a.startsWith("ROLE_"))
.map(a -> a.substring("ROLE_".length()))
.toList();
return encode(authentication.getName(), ACCESS, this.accessTtl, claims -> claims
.claim("scope", scope)
.claim("roles", roles));
}
public IssuedToken issueRefreshToken(String subject) {
return encode(subject, REFRESH, this.refreshTtl, claims -> { });
}
private IssuedToken encode(String subject, String tokenType, Duration ttl,
java.util.function.Consumer<JwtClaimsSet.Builder> extra) {
Instant now = Instant.now();
String jti = UUID.randomUUID().toString();
JwtClaimsSet.Builder claims = JwtClaimsSet.builder()
.issuer(this.issuer)
.audience(List.of(this.audience))
.subject(subject)
.id(jti) // -> "jti"
.issuedAt(now) // -> "iat"
.notBefore(now) // -> "nbf"
.expiresAt(now.plus(ttl)) // -> "exp"
.claim("token_type", tokenType);
extra.accept(claims);
// Passing JwsHeader explicitly is optional - the encoder derives the algorithm
// from the key - but being explicit documents intent and fails loudly on a
// key/algorithm mismatch.
Jwt jwt = this.encoder.encode(JwtEncoderParameters.from(claims.build()));
return new IssuedToken(jwt.getTokenValue(), jti, jwt.getExpiresAt(), ttl.toSeconds());
}
/** Unused overload kept to show the explicit-header form. */
@SuppressWarnings("unused")
private Jwt encodeWithExplicitHeader(JwsHeader header, JwtClaimsSet claims) {
return this.encoder.encode(JwtEncoderParameters.from(header, claims));
}
public record IssuedToken(String value, String jti, Instant expiresAt, long expiresInSeconds) { }
}

View File

@@ -0,0 +1,41 @@
package com.ankurm.jwtauth.config;
import java.net.URI;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.DisabledException;
import org.springframework.security.authentication.LockedException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
/**
* Login failures arrive here, not at the {@code AuthenticationEntryPoint} - the
* controller calls {@code AuthenticationManager} itself, so the exception is a plain
* MVC exception by the time anything security-shaped could see it.
*
* <p>Note every branch answers 401 with the same body. Telling a caller that the
* username exists but the password is wrong is a user-enumeration oracle.
*/
@RestControllerAdvice
public class ApiExceptionHandler {
@ExceptionHandler({BadCredentialsException.class, LockedException.class, DisabledException.class})
public ProblemDetail onAuthenticationFailure(AuthenticationException ex) {
ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.UNAUTHORIZED);
problem.setType(URI.create("https://ankurm.com/problems/invalid-credentials"));
problem.setTitle("Authentication failed");
problem.setDetail("Invalid username or password");
return problem;
}
@ExceptionHandler(AuthenticationException.class)
public ProblemDetail onAuthentication(AuthenticationException ex) {
ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.UNAUTHORIZED);
problem.setTitle("Authentication failed");
problem.setDetail("Invalid username or password");
return problem;
}
}

View File

@@ -0,0 +1,46 @@
package com.ankurm.jwtauth.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
/**
* Demo user store. In-memory on purpose: this repository is about the token
* pipeline, not about where users live.
*
* <p>Note there is deliberately NO {@code AuthenticationManager} bean exposed by
* auto-configuration once a {@code SecurityFilterChain} bean exists - we build one
* explicitly in {@link SecurityConfig} so the login endpoint can call it.
*/
@Configuration
public class AppUsers {
@Bean
public PasswordEncoder passwordEncoder() {
// DelegatingPasswordEncoder: stores {bcrypt}$2a$... so the hash format is upgradable.
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
@Bean
public UserDetailsService userDetailsService(PasswordEncoder encoder) {
UserDetails alice = User.withUsername("alice")
.password(encoder.encode("alice-password"))
.authorities("ROLE_USER", "SCOPE_profile:read")
.build();
UserDetails root = User.withUsername("root")
.password(encoder.encode("root-password"))
.authorities("ROLE_USER", "ROLE_ADMIN", "SCOPE_profile:read", "SCOPE_admin:read")
.build();
UserDetails locked = User.withUsername("locked")
.password(encoder.encode("locked-password"))
.authorities("ROLE_USER")
.accountLocked(true)
.build();
return new InMemoryUserDetailsManager(alice, root, locked);
}
}

View File

@@ -0,0 +1,74 @@
package com.ankurm.jwtauth.config;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import com.nimbusds.jose.jwk.source.ImmutableSecret;
import com.nimbusds.jose.proc.SecurityContext;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.security.oauth2.jose.jws.MacAlgorithm;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
/**
* HS256 variant: one symmetric secret both signs and verifies.
*
* <p>The secret must be at least 256 bits (32 bytes) for HS256 - Nimbus enforces
* this and throws {@code KeyLengthException} otherwise. See docs/05-hs256-vs-rs256.md.
*/
@Configuration
@Profile("hs256")
public class Hs256KeyConfig {
private final SecretKey secretKey;
private final String issuer;
private final String audience;
public Hs256KeyConfig(@Value("${demo.jwt.hmac-secret}") String secret,
@Value("${demo.jwt.issuer}") String issuer,
@Value("${demo.jwt.audience}") String audience) {
this.issuer = issuer;
this.audience = audience;
byte[] bytes = secret.getBytes(StandardCharsets.UTF_8);
if (bytes.length < 32) {
throw new IllegalStateException(
"demo.jwt.hmac-secret must be >= 32 bytes for HS256, got " + bytes.length);
}
this.secretKey = new SecretKeySpec(bytes, "HmacSHA256");
}
@Bean
public JwtEncoder jwtEncoder() {
// Spring Security 7.0 added the withSecretKey builder; the older
// new NimbusJwtEncoder(new ImmutableSecret<>(key)) form still works.
return NimbusJwtEncoder.withSecretKey(this.secretKey)
.algorithm(MacAlgorithm.HS256)
.build();
}
@Bean
public JwtDecoder jwtDecoder(ObjectProvider<OAuth2TokenValidator<Jwt>> extraValidators) {
NimbusJwtDecoder decoder = NimbusJwtDecoder.withSecretKey(this.secretKey)
.macAlgorithm(MacAlgorithm.HS256) // pin the algorithm - see docs/07-edge-cases.md
.build();
decoder.setJwtValidator(JwtValidatorFactory.compose(this.issuer, this.audience, extraValidators));
return decoder;
}
/** Kept only to show the pre-7.0 constructor still compiles. Not a bean. */
@SuppressWarnings("unused")
private JwtEncoder legacyStyleEncoder() {
ImmutableSecret<SecurityContext> jwkSource = new ImmutableSecret<>(this.secretKey);
return new NimbusJwtEncoder(jwkSource);
}
}

View File

@@ -0,0 +1,37 @@
package com.ankurm.jwtauth.config;
import java.util.ArrayList;
import java.util.List;
import com.ankurm.jwtauth.edge.AudienceValidator;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtValidators;
/**
* Builds the validator stack applied to every decoded token.
*
* <p>What {@code JwtValidators.createDefaultWithIssuer(issuer)} gives you:
* {@code exp} and {@code nbf} (with 60 seconds of clock skew) plus {@code iss}.
* What it does NOT give you: {@code aud}. That one is added here explicitly,
* because a token minted for another service in the same estate is otherwise
* accepted without complaint.
*
* @see docs/07-edge-cases.md
*/
final class JwtValidatorFactory {
private JwtValidatorFactory() { }
static OAuth2TokenValidator<Jwt> compose(String issuer,
String audience,
ObjectProvider<OAuth2TokenValidator<Jwt>> extras) {
List<OAuth2TokenValidator<Jwt>> validators = new ArrayList<>();
validators.add(JwtValidators.createDefaultWithIssuer(issuer)); // exp, nbf, iss
validators.add(AudienceValidator.forAudience(audience)); // aud - not default
extras.orderedStream().forEach(validators::add); // e.g. token_type
return new DelegatingOAuth2TokenValidator<>(validators);
}
}

View File

@@ -0,0 +1,117 @@
package com.ankurm.jwtauth.config;
import java.util.ArrayList;
import java.util.List;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.core.convert.converter.Converter;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.context.NullSecurityContextRepository;
/**
* The same API secured by Spring Security's built-in resource server instead of a
* hand-written filter. Run with {@code --spring.profiles.active=hs256,resourceserver}.
*
* <p>What you give up: the {@code token_type} check and the jti denylist have to move
* into an {@code OAuth2TokenValidator} (see
* {@link com.ankurm.jwtauth.edge.AccessTokenTypeValidator}).
* What you gain: {@code BearerTokenAuthenticationFilter},
* {@code BearerTokenAuthenticationEntryPoint} and {@code BearerTokenAccessDeniedHandler}
* are wired for you, with the correct WWW-Authenticate headers on both 401 and 403.
*
* @see docs/09-manual-filter-vs-resource-server.md
*/
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@Profile("resourceserver")
public class ResourceServerSecurityConfig {
@Bean
public SecurityFilterChain apiFilterChain(HttpSecurity http) throws Exception {
http
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.securityContext(context -> context
.securityContextRepository(new NullSecurityContextRepository()))
.csrf(csrf -> csrf.disable())
.formLogin(form -> form.disable())
.httpBasic(basic -> basic.disable())
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/public/**", "/api/auth/login", "/api/auth/refresh").permitAll()
.requestMatchers("/.well-known/**", "/actuator/health").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated())
// One line replaces the whole custom filter. The JwtDecoder bean is picked
// up automatically; BearerTokenAuthenticationFilter is inserted in the
// right place; 401 and 403 handlers come with it.
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())));
return http.build();
}
/** Same authority mapping as the manual filter, expressed the framework's way. */
private Converter<Jwt, AbstractAuthenticationToken> jwtAuthenticationConverter() {
JwtGrantedAuthoritiesConverter scopes = new JwtGrantedAuthoritiesConverter();
scopes.setAuthoritiesClaimName("scope");
scopes.setAuthorityPrefix("SCOPE_");
JwtGrantedAuthoritiesConverter roles = new JwtGrantedAuthoritiesConverter();
roles.setAuthoritiesClaimName("roles");
roles.setAuthorityPrefix("ROLE_");
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(jwt -> {
List<GrantedAuthority> all = new ArrayList<>(scopes.convert(jwt));
all.addAll(roles.convert(jwt));
return all;
});
return converter;
}
/**
* Only registered under the {@code strict} profile - on purpose. Run without it
* and a refresh token authenticates as an access token; run with it and the same
* request is a 401. The built-in resource server has no opinion about your
* private claims until you give it one.
*/
@Bean
@org.springframework.boot.autoconfigure.condition.ConditionalOnProperty(
name = "demo.validate-token-type", havingValue = "true")
public org.springframework.security.oauth2.core.OAuth2TokenValidator<
org.springframework.security.oauth2.jwt.Jwt> accessTokenTypeValidator() {
return new com.ankurm.jwtauth.edge.AccessTokenTypeValidator();
}
@Bean
public AuthenticationManager authenticationManager(UserDetailsService users,
PasswordEncoder encoder) {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider(users);
provider.setPasswordEncoder(encoder);
return new ProviderManager(provider);
}
/** Unused, kept as documentation of the alternative authority mapping. */
@SuppressWarnings("unused")
private GrantedAuthority example() {
return new SimpleGrantedAuthority("SCOPE_profile:read");
}
}

View File

@@ -0,0 +1,120 @@
package com.ankurm.jwtauth.config;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.security.KeyFactory;
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;
import java.util.Map;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.RSAKey;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.core.io.ClassPathResource;
import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* RS256 variant: the private key signs, the public key verifies.
*
* <p>The public half is also published at {@code /.well-known/jwks.json} so a
* separate resource server could verify tokens without ever seeing the private key.
*/
@Configuration
@Profile("rs256")
public class Rs256KeyConfig {
private final RSAPublicKey publicKey;
private final RSAPrivateKey privateKey;
private final String keyId = "demo-rsa-2026-08";
private final String issuer;
private final String audience;
public Rs256KeyConfig(@Value("${demo.jwt.issuer}") String issuer,
@Value("${demo.jwt.audience}") String audience) throws Exception {
this.issuer = issuer;
this.audience = audience;
this.privateKey = readPrivateKey("demo-private-key.pem");
this.publicKey = readPublicKey("demo-public-key.pem");
}
@Bean
public JwtEncoder jwtEncoder() {
// Note: the builder method is algorithm(..), not jwsAlgorithm(..), and there is
// no keyId(..) - the key id is set by post-processing the Nimbus JWK builder.
return NimbusJwtEncoder.withKeyPair(this.publicKey, this.privateKey)
.algorithm(SignatureAlgorithm.RS256)
.jwkPostProcessor(jwk -> jwk.keyID(this.keyId))
.build();
}
@Bean
public JwtDecoder jwtDecoder(ObjectProvider<OAuth2TokenValidator<Jwt>> extraValidators) {
NimbusJwtDecoder decoder = NimbusJwtDecoder.withPublicKey(this.publicKey)
.signatureAlgorithm(SignatureAlgorithm.RS256) // pin it - see docs/07-edge-cases.md
.build();
decoder.setJwtValidator(JwtValidatorFactory.compose(this.issuer, this.audience, extraValidators));
return decoder;
}
/**
* A minimal JWKS endpoint. A real authorization server publishes this and the
* resource server points at it with {@code NimbusJwtDecoder.withJwkSetUri(...)},
* which then rotates keys automatically by {@code kid}.
*/
@RestController
@Profile("rs256")
public static class JwkSetEndpoint {
private final JWKSet jwkSet;
public JwkSetEndpoint(Rs256KeyConfig keys) {
RSAKey rsaKey = new RSAKey.Builder(keys.publicKey)
.keyID(keys.keyId)
.build();
this.jwkSet = new JWKSet(rsaKey);
}
@GetMapping("/.well-known/jwks.json")
public Map<String, Object> keys() {
return this.jwkSet.toJSONObject(); // public parameters only
}
}
private static RSAPrivateKey readPrivateKey(String path) throws Exception {
byte[] der = pemBody(path, "PRIVATE KEY");
return (RSAPrivateKey) KeyFactory.getInstance("RSA")
.generatePrivate(new PKCS8EncodedKeySpec(der));
}
private static RSAPublicKey readPublicKey(String path) throws Exception {
byte[] der = pemBody(path, "PUBLIC KEY");
return (RSAPublicKey) KeyFactory.getInstance("RSA")
.generatePublic(new X509EncodedKeySpec(der));
}
private static byte[] pemBody(String path, String label) throws Exception {
try (InputStream in = new ClassPathResource(path).getInputStream()) {
String pem = new String(in.readAllBytes(), StandardCharsets.UTF_8);
String base64 = pem
.replace("-----BEGIN " + label + "-----", "")
.replace("-----END " + label + "-----", "")
.replaceAll("\\s", "");
return Base64.getDecoder().decode(base64);
}
}
}

View File

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

View File

@@ -0,0 +1,55 @@
package com.ankurm.jwtauth.diag;
import java.util.List;
import java.util.Map;
import jakarta.servlet.Filter;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.security.web.FilterChainProxy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* Prints the real filter order at runtime instead of the one you remember.
*
* <p>{@code GET /api/public/filters} - deliberately under {@code /api/public} so it is
* reachable without a token. Delete this class before shipping: it tells an attacker
* exactly which filters guard the application.
*/
@RestController
public class FilterChainReport {
private final FilterChainProxy filterChainProxy;
public FilterChainReport(FilterChainProxy filterChainProxy) {
this.filterChainProxy = filterChainProxy;
}
@GetMapping("/api/public/filters")
public List<Map<String, Object>> filters(HttpServletRequest request) {
return this.filterChainProxy.getFilterChains().stream()
.map(chain -> Map.<String, Object>of(
"chain", chain.toString(),
"matchesThisRequest", matches(chain, request),
"filters", chain.getFilters().stream()
.map(f -> f.getClass().getSimpleName())
.toList()))
.toList();
}
private boolean matches(SecurityFilterChain chain, HttpServletRequest request) {
try {
return chain.matches(request);
}
catch (RuntimeException ex) {
return false;
}
}
/** Unused; shows the type the list actually holds. */
@SuppressWarnings("unused")
private Class<?> filterType(Filter filter) {
return filter.getClass();
}
}

View File

@@ -0,0 +1,39 @@
package com.ankurm.jwtauth.edge;
import com.ankurm.jwtauth.auth.TokenService;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
import org.springframework.security.oauth2.jwt.Jwt;
/**
* Edge case: a refresh token is a valid access token unless you say otherwise.
*
* <p>Both are signed by the same key, both carry a valid {@code exp}, both pass every
* default validator. If the only difference is the TTL, a stolen refresh token is a
* long-lived access token. This validator is the resource-server equivalent of the
* {@code token_type} check inside {@code JwtAuthenticationFilter}.
*
* <pre>
* NimbusJwtDecoder decoder = NimbusJwtDecoder.withSecretKey(key).build();
* decoder.setJwtValidator(new DelegatingOAuth2TokenValidator&lt;&gt;(
* JwtValidators.createDefaultWithIssuer(issuer),
* new AccessTokenTypeValidator()));
* </pre>
*
* @see docs/07-edge-cases.md
*/
public class AccessTokenTypeValidator implements OAuth2TokenValidator<Jwt> {
private static final OAuth2Error ERROR = new OAuth2Error(
"invalid_token",
"Expected a token with token_type=access",
"https://ankurm.com/spring-security-7-1-jwt-authentication-guide/");
@Override
public OAuth2TokenValidatorResult validate(Jwt jwt) {
return TokenService.ACCESS.equals(jwt.getClaimAsString("token_type"))
? OAuth2TokenValidatorResult.success()
: OAuth2TokenValidatorResult.failure(ERROR);
}
}

View File

@@ -0,0 +1,50 @@
package com.ankurm.jwtauth.edge;
import java.util.Map;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import org.springframework.security.concurrent.DelegatingSecurityContextExecutorService;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* Edge case: the {@code SecurityContext} lives in a {@code ThreadLocal}, so it does
* not cross a thread boundary on its own.
*
* <p>{@code GET /api/async-demo} runs the same lookup on a plain executor and on a
* {@code DelegatingSecurityContextExecutorService}, and returns both answers. The
* plain one is {@code null} - and {@code null} here means an authenticated user's
* background work runs unauthenticated, which usually surfaces as a
* {@code AuthenticationCredentialsNotFoundException} far from the cause.
*
* @see docs/07-edge-cases.md#async
*/
@RestController
public class AsyncPropagationDemo {
@GetMapping("/api/async-demo")
public Map<String, Object> asyncDemo() throws Exception {
Callable<String> readPrincipal = () -> {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
return auth == null ? "null (context did not cross the thread)" : auth.getName();
};
ExecutorService plain = Executors.newVirtualThreadPerTaskExecutor();
ExecutorService wrapped = new DelegatingSecurityContextExecutorService(
Executors.newVirtualThreadPerTaskExecutor());
try {
return Map.of(
"onRequestThread", readPrincipal.call(),
"onPlainExecutor", plain.submit(readPrincipal).get(),
"onDelegatingExecutor", wrapped.submit(readPrincipal).get());
}
finally {
plain.shutdown();
wrapped.shutdown();
}
}
}

View File

@@ -0,0 +1,39 @@
package com.ankurm.jwtauth.edge;
import java.util.List;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtClaimNames;
import org.springframework.security.oauth2.jwt.JwtClaimValidator;
/**
* Edge case: {@code aud} is NOT validated by default.
*
* <p>{@code JwtValidators.createDefaultWithIssuer(issuer)} checks {@code exp},
* {@code nbf} and {@code iss} - not {@code aud}. In a multi-service estate where every
* service trusts the same issuer, that means a token minted for the reporting API is
* accepted by the payments API. Confused-deputy, by default.
*
* @see docs/07-edge-cases.md#audience
*/
public final class AudienceValidator {
private AudienceValidator() { }
public static OAuth2TokenValidator<Jwt> forAudience(String expected) {
return new JwtClaimValidator<List<String>>(JwtClaimNames.AUD,
aud -> aud != null && aud.contains(expected));
}
/** The long-hand equivalent, for readers who prefer to see the shape. */
public static OAuth2TokenValidator<Jwt> explicit(String expected) {
OAuth2Error error = new OAuth2Error("invalid_token",
"The required audience " + expected + " is missing", null);
return jwt -> jwt.getAudience() != null && jwt.getAudience().contains(expected)
? OAuth2TokenValidatorResult.success()
: OAuth2TokenValidatorResult.failure(error);
}
}

View File

@@ -0,0 +1,62 @@
package com.ankurm.jwtauth.web;
import java.util.List;
import java.util.Map;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* Four endpoints, chosen so every authorization outcome is reachable from curl:
* <ul>
* <li>{@code /api/public/ping} - permitAll, works with no token.</li>
* <li>{@code /api/me} - authenticated, 401 without a token.</li>
* <li>{@code /api/admin/stats} - hasRole('ADMIN'), 403 for a valid non-admin token.</li>
* <li>{@code /api/reports} - hasAuthority('SCOPE_admin:read'), the scope-based twin.</li>
* </ul>
*/
@RestController
public class ApiControllers {
@GetMapping("/api/public/ping")
public Map<String, Object> ping() {
return Map.of("status", "up", "authenticationRequired", false);
}
@GetMapping("/api/me")
public Map<String, Object> me(Authentication authentication) {
Map<String, Object> body = new java.util.LinkedHashMap<>();
body.put("name", authentication.getName());
body.put("authorities", authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority).sorted().toList());
body.put("authenticationType", authentication.getClass().getSimpleName());
if (authentication instanceof JwtAuthenticationToken jwtAuth) {
Jwt jwt = jwtAuth.getToken();
body.put("jti", jwt.getId());
body.put("issuer", jwt.getIssuer() == null ? null : jwt.getIssuer().toString());
body.put("audience", jwt.getAudience());
body.put("issuedAt", String.valueOf(jwt.getIssuedAt()));
body.put("expiresAt", String.valueOf(jwt.getExpiresAt()));
body.put("algorithm", jwt.getHeaders().get("alg"));
body.put("keyId", jwt.getHeaders().get("kid"));
}
return body;
}
@GetMapping("/api/admin/stats")
public Map<String, Object> adminStats() {
return Map.of("activeUsers", 3, "requiredRole", "ROLE_ADMIN");
}
@GetMapping("/api/reports")
@PreAuthorize("hasAuthority('SCOPE_admin:read')")
public List<String> reports() {
return List.of("q1-revenue", "q2-revenue");
}
}

View File

@@ -0,0 +1,5 @@
# Run with --spring.profiles.active=hs256,csrfon to reproduce the failure in
# docs/04-csrf-permitall-403.md: a permitAll() login endpoint answering 403.
demo:
csrf:
enabled: true

View File

@@ -0,0 +1,6 @@
# Run with --spring.profiles.active=hs256,shortlived to observe expiry and
# clock-skew behaviour without waiting 15 minutes.
demo:
jwt:
access-token-ttl: 2s
refresh-token-ttl: 30s

View File

@@ -0,0 +1,5 @@
# Run with --spring.profiles.active=hs256,resourceserver,strict to wire the
# AccessTokenTypeValidator into the JwtDecoder. Without it, the built-in resource
# server happily accepts a refresh token as an access token - see docs/07.
demo:
validate-token-type: true

View File

@@ -0,0 +1,5 @@
# Run with --spring.profiles.active=hs256,trace to see every filter decision.
logging:
level:
org.springframework.security: TRACE
org.springframework.security.web.FilterChainProxy: TRACE

View File

@@ -0,0 +1,28 @@
spring:
application:
name: jwt-auth-demo
profiles:
# hs256 = symmetric signing, manual = hand-written OncePerRequestFilter.
# Override with: --spring.profiles.active=rs256,resourceserver
default: hs256
server:
port: 8080
error:
include-message: always
demo:
csrf:
# true reproduces the "permitAll() still 403s" failure from docs/04.
enabled: false
jwt:
issuer: https://jwt-auth-demo.ankurm.com
audience: jwt-auth-demo-api
access-token-ttl: 15m
refresh-token-ttl: 8h
# 32+ bytes. Demo value only - a real deployment reads this from a secret manager.
hmac-secret: ankurm-demo-hmac-secret-key-please-rotate-me-32b
logging:
level:
org.springframework.security: INFO

View File

@@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDk0QNA9BbQbPqJ
Hn5Q2XsnvrrT8+x3mrV/lVqYr0pNL1/piE88gbyZ+LrNZ7xebHF1H8F3bJpf6/9b
PoyB4UtsFWREV+97/NWerj6SCOGfxRpErWJTQLWP7tJqEPveEeoZo6MCUU2ujnKC
MYBxUvxEZUzFEzLhppMCbcoHgR0baEkXHJ8IGq2TCwdMz8NdygVMtNnZsj6E+E3q
4HGTvKQVoKdDjvKspIzqSfS6XR2nbYAr/iY/VJEOeevcNrkYWEwq0GrL8aBdwn1Z
OnEhiGWvwHmah21vlLfj3TRU9JymheIl1SGCHESldtvVb8+WlUgSUhIfOV2fBY6i
B0+dw7TNAgMBAAECggEAHlP/0OepcHXJXUxP5Mp2suVqYPaHRLEaVm9G4070k7dw
SIVbL0No6qWXqOsTghZwkVwkqf4YlhczMPZg7EQe2ZQaRp67LN1tuQsSWwvXT/Rx
j2HF0xAUIKBAfnOC1sPcGgrg68k3+SeDUPNbuWmM60nb+5EYYOVRvfQsX4NDBuMz
B3gtuccd+L+wbCQPKmTQtu0FBtSzROKS39QJ77TmvNclQdVHu9tu805hezfbwXzG
usR64CJRzJE/mxmgoAJX+Tws+G4VRY5KAEBojVpAlN2bxpIBYMfyX6UEOOIBKp0E
WWv6bcFqlKQnSW9mWf4vt+ePtjlR4aLQcw9NWo2w5wKBgQD0N0s+X2+WX1h6Oqzd
sVFjGkrhwWrCwrdepsnLTQoAj0FuwMpgvfQDGvNrnyOSz4jf3rDe63WDRBQuxdaa
wU234jDBZ33VzUPQT4nrprKa1F3V8ZISpQdPNDRKyth56yyDeLQ8UnfhIlfN0/+b
hQ9ns9uv4ASMH53Q4/TAI5OBywKBgQDv236HXs3JEED6vM2zwE5H0SNQUhwCv4/K
m16Umd6rjHJJHkzQBhWsfYCCMUYZB+K7z9Zq9GivfJev2DqcKZkSdibpse4XDWfs
MWtMvYxtHvTt5Ghs/vE5A5x6uz8DXwaWOx/8bo13EIQvxtvw5HXtKB/WtTygBGMv
dcmT+dNwxwKBgDvm0Cr1Z75/loksmTgrhSYEzfc/5PruneG2kWqvc9OdT9Rlr345
OYAFfU2ZlDUveIhI7CNRp9pRuY2bcz80SObgsUrPIrtthMO0rsTBd6+ohXezsDuo
hPl1eZoa1Sxademtkq/1Hnh3XwgahujTo2qxYCJslVD1dFVHhMIYN9cvAoGAZO6e
beSNAADg9yIgBXX0+u+cxp3mv5lQrtd2k120f8fYB8DCXf9Re4ZMX3zQnJPe611o
QxWaP85UHmEFONWgXk5tzYVcRUMU6iVZm69fukN+meS1tLgLVgyY+mR0/bwtD2bN
7PGwgdvnZBtwTgw1O5jY3Qbi/gsamcwdCTHlsd0CgYAc+gUTmRlzZDLiUHWOg4Hi
+cllYIQPfQJYd4woiZu3er1Qo/tNYAI5hGDrUGI5cM4Qt3j9xA55eoigzy7ra+y2
ZkfOUUHAtgJrOOkbUtqBCtqbhNTW2HnjMXwt8TaozrjjoHW6lLiq3PvQ+wjwKx54
6g0Y6BKcZz2zYcpmrDXM6Q==
-----END PRIVATE KEY-----

View File

@@ -0,0 +1,9 @@
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA5NEDQPQW0Gz6iR5+UNl7
J7660/Psd5q1f5VamK9KTS9f6YhPPIG8mfi6zWe8XmxxdR/Bd2yaX+v/Wz6MgeFL
bBVkRFfve/zVnq4+kgjhn8UaRK1iU0C1j+7SahD73hHqGaOjAlFNro5ygjGAcVL8
RGVMxRMy4aaTAm3KB4EdG2hJFxyfCBqtkwsHTM/DXcoFTLTZ2bI+hPhN6uBxk7yk
FaCnQ47yrKSM6kn0ul0dp22AK/4mP1SRDnnr3Da5GFhMKtBqy/GgXcJ9WTpxIYhl
r8B5modtb5S34900VPScpoXiJdUhghxEpXbb1W/PlpVIElISHzldnwWOogdPncO0
zQIDAQAB
-----END PUBLIC KEY-----

View File

@@ -0,0 +1,142 @@
package com.ankurm.jwtauth;
import com.ankurm.jwtauth.auth.RevokedTokenStore;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* The 401-vs-403 contract, pinned as tests.
*
* @see docs/03-401-vs-403.md
*/
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("hs256")
class AuthenticationFlowTests {
@Autowired MockMvc mvc;
@Autowired RevokedTokenStore revokedTokens;
private static final tools.jackson.databind.ObjectMapper JSON =
new tools.jackson.databind.ObjectMapper();
private Map<String, String> login(String user, String password) throws Exception {
MvcResult result = this.mvc.perform(post("/api/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"username\":\"%s\",\"password\":\"%s\"}".formatted(user, password)))
.andExpect(status().isOk())
.andReturn();
return JSON.readValue(result.getResponse().getContentAsString(),
new tools.jackson.core.type.TypeReference<Map<String, String>>() { });
}
@Test
void publicEndpointNeedsNoToken() throws Exception {
this.mvc.perform(get("/api/public/ping")).andExpect(status().isOk());
}
@Test
void missingTokenIs401NotA403() throws Exception {
this.mvc.perform(get("/api/me"))
.andExpect(status().isUnauthorized())
.andExpect(header().string("WWW-Authenticate",
org.hamcrest.Matchers.containsString("Bearer")));
}
@Test
void validTokenWithoutTheRoleIs403NotA401() throws Exception {
String token = login("alice", "alice-password").get("accessToken");
this.mvc.perform(get("/api/admin/stats").header("Authorization", "Bearer " + token))
.andExpect(status().isForbidden())
.andExpect(header().string("WWW-Authenticate",
org.hamcrest.Matchers.containsString("insufficient_scope")));
}
@Test
void adminTokenReachesAdminEndpoint() throws Exception {
String token = login("root", "root-password").get("accessToken");
this.mvc.perform(get("/api/admin/stats").header("Authorization", "Bearer " + token))
.andExpect(status().isOk());
}
@Test
void tamperedSignatureIs401WithInvalidToken() throws Exception {
String token = login("alice", "alice-password").get("accessToken");
String tampered = token.substring(0, token.length() - 4) + "AAAA";
this.mvc.perform(get("/api/me").header("Authorization", "Bearer " + tampered))
.andExpect(status().isUnauthorized())
.andExpect(header().string("WWW-Authenticate",
org.hamcrest.Matchers.containsString("invalid_token")));
}
@Test
void refreshTokenIsNotAnAccessToken() throws Exception {
String refresh = login("alice", "alice-password").get("refreshToken");
this.mvc.perform(get("/api/me").header("Authorization", "Bearer " + refresh))
.andExpect(status().isUnauthorized());
}
@Test
void badPasswordIs401AndSaysNothingUseful() throws Exception {
this.mvc.perform(post("/api/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"username\":\"alice\",\"password\":\"nope\"}"))
.andExpect(status().isUnauthorized());
}
@Test
void lockedAccountIsIndistinguishableFromABadPassword() throws Exception {
MvcResult locked = this.mvc.perform(post("/api/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"username\":\"locked\",\"password\":\"locked-password\"}"))
.andExpect(status().isUnauthorized()).andReturn();
MvcResult wrong = this.mvc.perform(post("/api/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"username\":\"alice\",\"password\":\"nope\"}"))
.andExpect(status().isUnauthorized()).andReturn();
assertThat(locked.getResponse().getContentAsString())
.isEqualTo(wrong.getResponse().getContentAsString());
}
@Test
void revokedTokenIsRefusedEvenThoughTheSignatureIsStillValid() throws Exception {
String token = login("alice", "alice-password").get("accessToken");
this.mvc.perform(get("/api/me").header("Authorization", "Bearer " + token))
.andExpect(status().isOk());
this.mvc.perform(post("/api/auth/logout").header("Authorization", "Bearer " + token))
.andExpect(status().isNoContent());
this.mvc.perform(get("/api/me").header("Authorization", "Bearer " + token))
.andExpect(status().isUnauthorized());
}
@Test
void spentRefreshTokenCannotBeReplayed() throws Exception {
String refresh = login("alice", "alice-password").get("refreshToken");
this.mvc.perform(post("/api/auth/refresh")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"refreshToken\":\"%s\"}".formatted(refresh)))
.andExpect(status().isOk());
this.mvc.perform(post("/api/auth/refresh")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"refreshToken\":\"%s\"}".formatted(refresh)))
.andExpect(status().isUnauthorized());
}
}

View File

@@ -0,0 +1,59 @@
package com.ankurm.jwtauth;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.security.web.FilterChainProxy;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* Pins the failure described in docs/04-csrf-permitall-403.md, and the two
* facts that explain it.
*/
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles({"hs256", "csrfon"})
class CsrfBreaksPermitAllTests {
@Autowired MockMvc mvc;
@Autowired FilterChainProxy filterChainProxy;
@Test
void permitAllLoginStillReturns403WhenCsrfIsOn() throws Exception {
this.mvc.perform(post("/api/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"username\":\"alice\",\"password\":\"alice-password\"}"))
.andExpect(status().isForbidden());
}
@Test
void theSameRequestWithACsrfTokenSucceeds() throws Exception {
this.mvc.perform(post("/api/auth/login").with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"username\":\"alice\",\"password\":\"alice-password\"}"))
.andExpect(status().isOk());
}
@Test
void csrfFilterRunsLongBeforeAuthorizationFilter() {
List<String> filters = this.filterChainProxy.getFilterChains().getFirst()
.getFilters().stream().map(f -> f.getClass().getSimpleName()).toList();
int csrf = filters.indexOf("CsrfFilter");
int authorization = filters.indexOf("AuthorizationFilter");
assertThat(csrf).isNotNegative();
assertThat(authorization).isEqualTo(filters.size() - 1);
assertThat(csrf).isLessThan(authorization);
}
}