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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.ankurm.jwtauth.auth;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public final class AuthDtos {
|
||||
|
||||
private AuthDtos() { }
|
||||
|
||||
public record LoginRequest(@NotBlank String username, @NotBlank String password) { }
|
||||
|
||||
public record RefreshRequest(@NotBlank String refreshToken) { }
|
||||
|
||||
public record TokenResponse(String accessToken,
|
||||
String refreshToken,
|
||||
String tokenType,
|
||||
long expiresIn) { }
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package com.ankurm.jwtauth.auth;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.security.oauth2.server.resource.InvalidBearerTokenException;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContext;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.core.context.SecurityContextHolderStrategy;
|
||||
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
|
||||
import org.springframework.security.oauth2.core.OAuth2Error;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
import org.springframework.security.oauth2.jwt.JwtDecoder;
|
||||
import org.springframework.security.oauth2.jwt.JwtException;
|
||||
import org.springframework.security.oauth2.server.resource.BearerTokenErrorCodes;
|
||||
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
|
||||
import org.springframework.security.oauth2.server.resource.web.BearerTokenResolver;
|
||||
import org.springframework.security.oauth2.server.resource.web.DefaultBearerTokenResolver;
|
||||
import org.springframework.security.web.AuthenticationEntryPoint;
|
||||
import org.springframework.security.web.context.RequestAttributeSecurityContextRepository;
|
||||
import org.springframework.security.web.context.SecurityContextRepository;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
/**
|
||||
* The hand-written half of the flow: read the bearer token, verify it, put an
|
||||
* {@code Authentication} in the {@code SecurityContext}, continue the chain.
|
||||
*
|
||||
* <p>Five details separate a filter that works from one that only appears to:
|
||||
* <ol>
|
||||
* <li>It extends {@link OncePerRequestFilter}, so a {@code FORWARD} to an error
|
||||
* page or a {@code @Async} dispatch does not run authentication twice.</li>
|
||||
* <li>No token present is <em>not</em> an error. The filter continues the chain and
|
||||
* lets {@code AuthorizationFilter} decide - that is what makes {@code permitAll()}
|
||||
* endpoints reachable without a token.</li>
|
||||
* <li>A token that <em>is</em> present but bad is an error, and the chain stops. The
|
||||
* alternative - continuing anonymously - turns a forged token into a 403 on a
|
||||
* protected endpoint and a silent 200 on a public one.</li>
|
||||
* <li>The context is written through {@link SecurityContextHolderStrategy}, not the
|
||||
* static {@code SecurityContextHolder} setters, and also saved into the
|
||||
* {@link SecurityContextRepository} so it survives a dispatch.</li>
|
||||
* <li>The context is cleared on failure, so a pooled thread cannot leak a previous
|
||||
* request's principal.</li>
|
||||
* </ol>
|
||||
*
|
||||
* @see docs/02-filter-chain-and-ordering.md
|
||||
*/
|
||||
public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
|
||||
private final JwtDecoder jwtDecoder;
|
||||
private final RevokedTokenStore revokedTokens;
|
||||
private final AuthenticationEntryPoint entryPoint;
|
||||
|
||||
private final BearerTokenResolver bearerTokenResolver = new DefaultBearerTokenResolver();
|
||||
private final JwtAuthenticationConverter authenticationConverter = defaultConverter();
|
||||
private final SecurityContextHolderStrategy contextHolderStrategy =
|
||||
SecurityContextHolder.getContextHolderStrategy();
|
||||
private final SecurityContextRepository contextRepository =
|
||||
new RequestAttributeSecurityContextRepository();
|
||||
|
||||
public JwtAuthenticationFilter(JwtDecoder jwtDecoder,
|
||||
RevokedTokenStore revokedTokens,
|
||||
AuthenticationEntryPoint entryPoint) {
|
||||
this.jwtDecoder = jwtDecoder;
|
||||
this.revokedTokens = revokedTokens;
|
||||
this.entryPoint = entryPoint;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
FilterChain filterChain) throws ServletException, IOException {
|
||||
|
||||
String token;
|
||||
try {
|
||||
token = this.bearerTokenResolver.resolve(request);
|
||||
}
|
||||
catch (OAuth2AuthenticationException ex) {
|
||||
// Malformed Authorization header, or a token in two places at once.
|
||||
this.contextHolderStrategy.clearContext();
|
||||
this.entryPoint.commence(request, response, ex);
|
||||
return;
|
||||
}
|
||||
|
||||
if (token == null) {
|
||||
// (2) No credentials offered. Not our business - let authorization decide.
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
Jwt jwt = this.jwtDecoder.decode(token);
|
||||
assertIsAccessToken(jwt);
|
||||
assertNotRevoked(jwt);
|
||||
|
||||
SecurityContext context = this.contextHolderStrategy.createEmptyContext();
|
||||
context.setAuthentication(this.authenticationConverter.convert(jwt));
|
||||
this.contextHolderStrategy.setContext(context);
|
||||
this.contextRepository.saveContext(context, request, response);
|
||||
}
|
||||
catch (JwtException | OAuth2AuthenticationException ex) {
|
||||
// (3) and (5): stop the chain, clear the context, answer 401.
|
||||
this.contextHolderStrategy.clearContext();
|
||||
this.entryPoint.commence(request, response, asAuthenticationException(ex));
|
||||
return;
|
||||
}
|
||||
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
|
||||
private void assertIsAccessToken(Jwt jwt) {
|
||||
if (!TokenService.ACCESS.equals(jwt.getClaimAsString("token_type"))) {
|
||||
throw invalidToken("This endpoint accepts access tokens only");
|
||||
}
|
||||
}
|
||||
|
||||
private void assertNotRevoked(Jwt jwt) {
|
||||
if (this.revokedTokens.isRevoked(jwt.getId())) {
|
||||
throw invalidToken("Token has been revoked");
|
||||
}
|
||||
}
|
||||
|
||||
private static OAuth2AuthenticationException invalidToken(String description) {
|
||||
OAuth2Error error = new OAuth2Error(
|
||||
BearerTokenErrorCodes.INVALID_TOKEN,
|
||||
description,
|
||||
"https://tools.ietf.org/html/rfc6750#section-3.1");
|
||||
return new OAuth2AuthenticationException(error, description);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detail that decides what the client sees. {@code BearerTokenAuthenticationEntryPoint}
|
||||
* only writes {@code error="invalid_token"} into WWW-Authenticate when the exception
|
||||
* carries a {@code BearerTokenError}. Wrap a {@code JwtException} in a plain
|
||||
* {@code AuthenticationServiceException} and the client gets a bare
|
||||
* {@code WWW-Authenticate: Bearer realm="..."} with no reason at all.
|
||||
*/
|
||||
private static org.springframework.security.core.AuthenticationException asAuthenticationException(
|
||||
Exception ex) {
|
||||
if (ex instanceof org.springframework.security.core.AuthenticationException authEx) {
|
||||
return authEx;
|
||||
}
|
||||
return new InvalidBearerTokenException(ex.getMessage(), ex);
|
||||
}
|
||||
|
||||
private static JwtAuthenticationConverter defaultConverter() {
|
||||
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
|
||||
converter.setJwtGrantedAuthoritiesConverter(JwtAuthenticationFilter::authorities);
|
||||
return converter;
|
||||
}
|
||||
|
||||
/**
|
||||
* "scope" -> SCOPE_x (Spring's default) plus "roles" -> ROLE_x, so
|
||||
* {@code hasRole("ADMIN")} and {@code hasAuthority("SCOPE_admin:read")} both work.
|
||||
*/
|
||||
private static List<GrantedAuthority> authorities(Jwt jwt) {
|
||||
List<GrantedAuthority> result = new java.util.ArrayList<>();
|
||||
String scope = jwt.getClaimAsString("scope");
|
||||
if (scope != null && !scope.isBlank()) {
|
||||
for (String s : scope.split("\\s+")) {
|
||||
result.add(new org.springframework.security.core.authority.SimpleGrantedAuthority(
|
||||
"SCOPE_" + s));
|
||||
}
|
||||
}
|
||||
List<String> roles = jwt.getClaimAsStringList("roles");
|
||||
if (roles != null) {
|
||||
for (String role : roles) {
|
||||
result.add(new org.springframework.security.core.authority.SimpleGrantedAuthority(
|
||||
"ROLE_" + role));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package com.ankurm.jwtauth.auth;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
import org.springframework.security.oauth2.jwt.JwsHeader;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
import org.springframework.security.oauth2.jwt.JwtClaimsSet;
|
||||
import org.springframework.security.oauth2.jwt.JwtEncoder;
|
||||
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* Mints the tokens. This is the "token issue" step of the flow.
|
||||
*
|
||||
* <p>Two claims here are not decoration:
|
||||
* <ul>
|
||||
* <li>{@code token_type} - separates access tokens from refresh tokens. Without it,
|
||||
* a refresh token is a perfectly valid access token, because both are signed
|
||||
* by the same key. See docs/07-edge-cases.md#refresh-token-as-access-token.</li>
|
||||
* <li>{@code jti} - a per-token id, which is what a denylist keys on. A JWT is not
|
||||
* revocable without one.</li>
|
||||
* </ul>
|
||||
*/
|
||||
@Service
|
||||
public class TokenService {
|
||||
|
||||
public static final String ACCESS = "access";
|
||||
public static final String REFRESH = "refresh";
|
||||
|
||||
private final JwtEncoder encoder;
|
||||
private final String issuer;
|
||||
private final String audience;
|
||||
private final Duration accessTtl;
|
||||
private final Duration refreshTtl;
|
||||
|
||||
public TokenService(JwtEncoder encoder,
|
||||
@Value("${demo.jwt.issuer}") String issuer,
|
||||
@Value("${demo.jwt.audience}") String audience,
|
||||
@Value("${demo.jwt.access-token-ttl}") Duration accessTtl,
|
||||
@Value("${demo.jwt.refresh-token-ttl}") Duration refreshTtl) {
|
||||
this.encoder = encoder;
|
||||
this.issuer = issuer;
|
||||
this.audience = audience;
|
||||
this.accessTtl = accessTtl;
|
||||
this.refreshTtl = refreshTtl;
|
||||
}
|
||||
|
||||
public IssuedToken issueAccessToken(Authentication authentication) {
|
||||
List<String> authorities = authentication.getAuthorities().stream()
|
||||
.map(GrantedAuthority::getAuthority)
|
||||
.sorted()
|
||||
.toList();
|
||||
|
||||
// Spring Security's default JwtGrantedAuthoritiesConverter reads the "scope"
|
||||
// claim and prefixes each value with SCOPE_. We keep roles in a separate
|
||||
// "roles" claim so the two authority families stay distinguishable.
|
||||
String scope = authorities.stream()
|
||||
.filter(a -> a.startsWith("SCOPE_"))
|
||||
.map(a -> a.substring("SCOPE_".length()))
|
||||
.collect(Collectors.joining(" "));
|
||||
List<String> roles = authorities.stream()
|
||||
.filter(a -> a.startsWith("ROLE_"))
|
||||
.map(a -> a.substring("ROLE_".length()))
|
||||
.toList();
|
||||
|
||||
return encode(authentication.getName(), ACCESS, this.accessTtl, claims -> claims
|
||||
.claim("scope", scope)
|
||||
.claim("roles", roles));
|
||||
}
|
||||
|
||||
public IssuedToken issueRefreshToken(String subject) {
|
||||
return encode(subject, REFRESH, this.refreshTtl, claims -> { });
|
||||
}
|
||||
|
||||
private IssuedToken encode(String subject, String tokenType, Duration ttl,
|
||||
java.util.function.Consumer<JwtClaimsSet.Builder> extra) {
|
||||
Instant now = Instant.now();
|
||||
String jti = UUID.randomUUID().toString();
|
||||
|
||||
JwtClaimsSet.Builder claims = JwtClaimsSet.builder()
|
||||
.issuer(this.issuer)
|
||||
.audience(List.of(this.audience))
|
||||
.subject(subject)
|
||||
.id(jti) // -> "jti"
|
||||
.issuedAt(now) // -> "iat"
|
||||
.notBefore(now) // -> "nbf"
|
||||
.expiresAt(now.plus(ttl)) // -> "exp"
|
||||
.claim("token_type", tokenType);
|
||||
extra.accept(claims);
|
||||
|
||||
// Passing JwsHeader explicitly is optional - the encoder derives the algorithm
|
||||
// from the key - but being explicit documents intent and fails loudly on a
|
||||
// key/algorithm mismatch.
|
||||
Jwt jwt = this.encoder.encode(JwtEncoderParameters.from(claims.build()));
|
||||
return new IssuedToken(jwt.getTokenValue(), jti, jwt.getExpiresAt(), ttl.toSeconds());
|
||||
}
|
||||
|
||||
/** Unused overload kept to show the explicit-header form. */
|
||||
@SuppressWarnings("unused")
|
||||
private Jwt encodeWithExplicitHeader(JwsHeader header, JwtClaimsSet claims) {
|
||||
return this.encoder.encode(JwtEncoderParameters.from(header, claims));
|
||||
}
|
||||
|
||||
public record IssuedToken(String value, String jti, Instant expiresAt, long expiresInSeconds) { }
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.ankurm.jwtauth.config;
|
||||
|
||||
import java.net.URI;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ProblemDetail;
|
||||
import org.springframework.security.authentication.BadCredentialsException;
|
||||
import org.springframework.security.authentication.DisabledException;
|
||||
import org.springframework.security.authentication.LockedException;
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
|
||||
/**
|
||||
* Login failures arrive here, not at the {@code AuthenticationEntryPoint} - the
|
||||
* controller calls {@code AuthenticationManager} itself, so the exception is a plain
|
||||
* MVC exception by the time anything security-shaped could see it.
|
||||
*
|
||||
* <p>Note every branch answers 401 with the same body. Telling a caller that the
|
||||
* username exists but the password is wrong is a user-enumeration oracle.
|
||||
*/
|
||||
@RestControllerAdvice
|
||||
public class ApiExceptionHandler {
|
||||
|
||||
@ExceptionHandler({BadCredentialsException.class, LockedException.class, DisabledException.class})
|
||||
public ProblemDetail onAuthenticationFailure(AuthenticationException ex) {
|
||||
ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.UNAUTHORIZED);
|
||||
problem.setType(URI.create("https://ankurm.com/problems/invalid-credentials"));
|
||||
problem.setTitle("Authentication failed");
|
||||
problem.setDetail("Invalid username or password");
|
||||
return problem;
|
||||
}
|
||||
|
||||
@ExceptionHandler(AuthenticationException.class)
|
||||
public ProblemDetail onAuthentication(AuthenticationException ex) {
|
||||
ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.UNAUTHORIZED);
|
||||
problem.setTitle("Authentication failed");
|
||||
problem.setDetail("Invalid username or password");
|
||||
return problem;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.ankurm.jwtauth.config;
|
||||
|
||||
import javax.crypto.SecretKey;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import com.nimbusds.jose.jwk.source.ImmutableSecret;
|
||||
import com.nimbusds.jose.proc.SecurityContext;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.security.oauth2.jose.jws.MacAlgorithm;
|
||||
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
import org.springframework.security.oauth2.jwt.JwtDecoder;
|
||||
import org.springframework.security.oauth2.jwt.JwtEncoder;
|
||||
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
|
||||
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
|
||||
|
||||
/**
|
||||
* HS256 variant: one symmetric secret both signs and verifies.
|
||||
*
|
||||
* <p>The secret must be at least 256 bits (32 bytes) for HS256 - Nimbus enforces
|
||||
* this and throws {@code KeyLengthException} otherwise. See docs/05-hs256-vs-rs256.md.
|
||||
*/
|
||||
@Configuration
|
||||
@Profile("hs256")
|
||||
public class Hs256KeyConfig {
|
||||
|
||||
private final SecretKey secretKey;
|
||||
|
||||
private final String issuer;
|
||||
private final String audience;
|
||||
|
||||
public Hs256KeyConfig(@Value("${demo.jwt.hmac-secret}") String secret,
|
||||
@Value("${demo.jwt.issuer}") String issuer,
|
||||
@Value("${demo.jwt.audience}") String audience) {
|
||||
this.issuer = issuer;
|
||||
this.audience = audience;
|
||||
byte[] bytes = secret.getBytes(StandardCharsets.UTF_8);
|
||||
if (bytes.length < 32) {
|
||||
throw new IllegalStateException(
|
||||
"demo.jwt.hmac-secret must be >= 32 bytes for HS256, got " + bytes.length);
|
||||
}
|
||||
this.secretKey = new SecretKeySpec(bytes, "HmacSHA256");
|
||||
}
|
||||
|
||||
@Bean
|
||||
public JwtEncoder jwtEncoder() {
|
||||
// Spring Security 7.0 added the withSecretKey builder; the older
|
||||
// new NimbusJwtEncoder(new ImmutableSecret<>(key)) form still works.
|
||||
return NimbusJwtEncoder.withSecretKey(this.secretKey)
|
||||
.algorithm(MacAlgorithm.HS256)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public JwtDecoder jwtDecoder(ObjectProvider<OAuth2TokenValidator<Jwt>> extraValidators) {
|
||||
NimbusJwtDecoder decoder = NimbusJwtDecoder.withSecretKey(this.secretKey)
|
||||
.macAlgorithm(MacAlgorithm.HS256) // pin the algorithm - see docs/07-edge-cases.md
|
||||
.build();
|
||||
decoder.setJwtValidator(JwtValidatorFactory.compose(this.issuer, this.audience, extraValidators));
|
||||
return decoder;
|
||||
}
|
||||
|
||||
/** Kept only to show the pre-7.0 constructor still compiles. Not a bean. */
|
||||
@SuppressWarnings("unused")
|
||||
private JwtEncoder legacyStyleEncoder() {
|
||||
ImmutableSecret<SecurityContext> jwkSource = new ImmutableSecret<>(this.secretKey);
|
||||
return new NimbusJwtEncoder(jwkSource);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.ankurm.jwtauth.config;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import com.ankurm.jwtauth.edge.AudienceValidator;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
|
||||
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
import org.springframework.security.oauth2.jwt.JwtValidators;
|
||||
|
||||
/**
|
||||
* Builds the validator stack applied to every decoded token.
|
||||
*
|
||||
* <p>What {@code JwtValidators.createDefaultWithIssuer(issuer)} gives you:
|
||||
* {@code exp} and {@code nbf} (with 60 seconds of clock skew) plus {@code iss}.
|
||||
* What it does NOT give you: {@code aud}. That one is added here explicitly,
|
||||
* because a token minted for another service in the same estate is otherwise
|
||||
* accepted without complaint.
|
||||
*
|
||||
* @see docs/07-edge-cases.md
|
||||
*/
|
||||
final class JwtValidatorFactory {
|
||||
|
||||
private JwtValidatorFactory() { }
|
||||
|
||||
static OAuth2TokenValidator<Jwt> compose(String issuer,
|
||||
String audience,
|
||||
ObjectProvider<OAuth2TokenValidator<Jwt>> extras) {
|
||||
List<OAuth2TokenValidator<Jwt>> validators = new ArrayList<>();
|
||||
validators.add(JwtValidators.createDefaultWithIssuer(issuer)); // exp, nbf, iss
|
||||
validators.add(AudienceValidator.forAudience(audience)); // aud - not default
|
||||
extras.orderedStream().forEach(validators::add); // e.g. token_type
|
||||
return new DelegatingOAuth2TokenValidator<>(validators);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package com.ankurm.jwtauth.config;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.core.convert.converter.Converter;
|
||||
import org.springframework.security.authentication.AbstractAuthenticationToken;
|
||||
import org.springframework.security.authentication.AuthenticationManager;
|
||||
import org.springframework.security.authentication.ProviderManager;
|
||||
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
|
||||
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
|
||||
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.context.NullSecurityContextRepository;
|
||||
|
||||
/**
|
||||
* The same API secured by Spring Security's built-in resource server instead of a
|
||||
* hand-written filter. Run with {@code --spring.profiles.active=hs256,resourceserver}.
|
||||
*
|
||||
* <p>What you give up: the {@code token_type} check and the jti denylist have to move
|
||||
* into an {@code OAuth2TokenValidator} (see
|
||||
* {@link com.ankurm.jwtauth.edge.AccessTokenTypeValidator}).
|
||||
* What you gain: {@code BearerTokenAuthenticationFilter},
|
||||
* {@code BearerTokenAuthenticationEntryPoint} and {@code BearerTokenAccessDeniedHandler}
|
||||
* are wired for you, with the correct WWW-Authenticate headers on both 401 and 403.
|
||||
*
|
||||
* @see docs/09-manual-filter-vs-resource-server.md
|
||||
*/
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
@EnableMethodSecurity
|
||||
@Profile("resourceserver")
|
||||
public class ResourceServerSecurityConfig {
|
||||
|
||||
@Bean
|
||||
public SecurityFilterChain apiFilterChain(HttpSecurity http) throws Exception {
|
||||
http
|
||||
.sessionManagement(session -> session
|
||||
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.securityContext(context -> context
|
||||
.securityContextRepository(new NullSecurityContextRepository()))
|
||||
.csrf(csrf -> csrf.disable())
|
||||
.formLogin(form -> form.disable())
|
||||
.httpBasic(basic -> basic.disable())
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers("/api/public/**", "/api/auth/login", "/api/auth/refresh").permitAll()
|
||||
.requestMatchers("/.well-known/**", "/actuator/health").permitAll()
|
||||
.requestMatchers("/api/admin/**").hasRole("ADMIN")
|
||||
.anyRequest().authenticated())
|
||||
// One line replaces the whole custom filter. The JwtDecoder bean is picked
|
||||
// up automatically; BearerTokenAuthenticationFilter is inserted in the
|
||||
// right place; 401 and 403 handlers come with it.
|
||||
.oauth2ResourceServer(oauth2 -> oauth2
|
||||
.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())));
|
||||
|
||||
return http.build();
|
||||
}
|
||||
|
||||
/** Same authority mapping as the manual filter, expressed the framework's way. */
|
||||
private Converter<Jwt, AbstractAuthenticationToken> jwtAuthenticationConverter() {
|
||||
JwtGrantedAuthoritiesConverter scopes = new JwtGrantedAuthoritiesConverter();
|
||||
scopes.setAuthoritiesClaimName("scope");
|
||||
scopes.setAuthorityPrefix("SCOPE_");
|
||||
|
||||
JwtGrantedAuthoritiesConverter roles = new JwtGrantedAuthoritiesConverter();
|
||||
roles.setAuthoritiesClaimName("roles");
|
||||
roles.setAuthorityPrefix("ROLE_");
|
||||
|
||||
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
|
||||
converter.setJwtGrantedAuthoritiesConverter(jwt -> {
|
||||
List<GrantedAuthority> all = new ArrayList<>(scopes.convert(jwt));
|
||||
all.addAll(roles.convert(jwt));
|
||||
return all;
|
||||
});
|
||||
return converter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Only registered under the {@code strict} profile - on purpose. Run without it
|
||||
* and a refresh token authenticates as an access token; run with it and the same
|
||||
* request is a 401. The built-in resource server has no opinion about your
|
||||
* private claims until you give it one.
|
||||
*/
|
||||
@Bean
|
||||
@org.springframework.boot.autoconfigure.condition.ConditionalOnProperty(
|
||||
name = "demo.validate-token-type", havingValue = "true")
|
||||
public org.springframework.security.oauth2.core.OAuth2TokenValidator<
|
||||
org.springframework.security.oauth2.jwt.Jwt> accessTokenTypeValidator() {
|
||||
return new com.ankurm.jwtauth.edge.AccessTokenTypeValidator();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public AuthenticationManager authenticationManager(UserDetailsService users,
|
||||
PasswordEncoder encoder) {
|
||||
DaoAuthenticationProvider provider = new DaoAuthenticationProvider(users);
|
||||
provider.setPasswordEncoder(encoder);
|
||||
return new ProviderManager(provider);
|
||||
}
|
||||
|
||||
/** Unused, kept as documentation of the alternative authority mapping. */
|
||||
@SuppressWarnings("unused")
|
||||
private GrantedAuthority example() {
|
||||
return new SimpleGrantedAuthority("SCOPE_profile:read");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.ankurm.jwtauth.diag;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import jakarta.servlet.Filter;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.springframework.security.web.FilterChainProxy;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* Prints the real filter order at runtime instead of the one you remember.
|
||||
*
|
||||
* <p>{@code GET /api/public/filters} - deliberately under {@code /api/public} so it is
|
||||
* reachable without a token. Delete this class before shipping: it tells an attacker
|
||||
* exactly which filters guard the application.
|
||||
*/
|
||||
@RestController
|
||||
public class FilterChainReport {
|
||||
|
||||
private final FilterChainProxy filterChainProxy;
|
||||
|
||||
public FilterChainReport(FilterChainProxy filterChainProxy) {
|
||||
this.filterChainProxy = filterChainProxy;
|
||||
}
|
||||
|
||||
@GetMapping("/api/public/filters")
|
||||
public List<Map<String, Object>> filters(HttpServletRequest request) {
|
||||
return this.filterChainProxy.getFilterChains().stream()
|
||||
.map(chain -> Map.<String, Object>of(
|
||||
"chain", chain.toString(),
|
||||
"matchesThisRequest", matches(chain, request),
|
||||
"filters", chain.getFilters().stream()
|
||||
.map(f -> f.getClass().getSimpleName())
|
||||
.toList()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private boolean matches(SecurityFilterChain chain, HttpServletRequest request) {
|
||||
try {
|
||||
return chain.matches(request);
|
||||
}
|
||||
catch (RuntimeException ex) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Unused; shows the type the list actually holds. */
|
||||
@SuppressWarnings("unused")
|
||||
private Class<?> filterType(Filter filter) {
|
||||
return filter.getClass();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.ankurm.jwtauth.edge;
|
||||
|
||||
import com.ankurm.jwtauth.auth.TokenService;
|
||||
import org.springframework.security.oauth2.core.OAuth2Error;
|
||||
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
|
||||
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
|
||||
/**
|
||||
* Edge case: a refresh token is a valid access token unless you say otherwise.
|
||||
*
|
||||
* <p>Both are signed by the same key, both carry a valid {@code exp}, both pass every
|
||||
* default validator. If the only difference is the TTL, a stolen refresh token is a
|
||||
* long-lived access token. This validator is the resource-server equivalent of the
|
||||
* {@code token_type} check inside {@code JwtAuthenticationFilter}.
|
||||
*
|
||||
* <pre>
|
||||
* NimbusJwtDecoder decoder = NimbusJwtDecoder.withSecretKey(key).build();
|
||||
* decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
|
||||
* JwtValidators.createDefaultWithIssuer(issuer),
|
||||
* new AccessTokenTypeValidator()));
|
||||
* </pre>
|
||||
*
|
||||
* @see docs/07-edge-cases.md
|
||||
*/
|
||||
public class AccessTokenTypeValidator implements OAuth2TokenValidator<Jwt> {
|
||||
|
||||
private static final OAuth2Error ERROR = new OAuth2Error(
|
||||
"invalid_token",
|
||||
"Expected a token with token_type=access",
|
||||
"https://ankurm.com/spring-security-7-1-jwt-authentication-guide/");
|
||||
|
||||
@Override
|
||||
public OAuth2TokenValidatorResult validate(Jwt jwt) {
|
||||
return TokenService.ACCESS.equals(jwt.getClaimAsString("token_type"))
|
||||
? OAuth2TokenValidatorResult.success()
|
||||
: OAuth2TokenValidatorResult.failure(ERROR);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.ankurm.jwtauth.edge;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
import org.springframework.security.concurrent.DelegatingSecurityContextExecutorService;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* Edge case: the {@code SecurityContext} lives in a {@code ThreadLocal}, so it does
|
||||
* not cross a thread boundary on its own.
|
||||
*
|
||||
* <p>{@code GET /api/async-demo} runs the same lookup on a plain executor and on a
|
||||
* {@code DelegatingSecurityContextExecutorService}, and returns both answers. The
|
||||
* plain one is {@code null} - and {@code null} here means an authenticated user's
|
||||
* background work runs unauthenticated, which usually surfaces as a
|
||||
* {@code AuthenticationCredentialsNotFoundException} far from the cause.
|
||||
*
|
||||
* @see docs/07-edge-cases.md#async
|
||||
*/
|
||||
@RestController
|
||||
public class AsyncPropagationDemo {
|
||||
|
||||
@GetMapping("/api/async-demo")
|
||||
public Map<String, Object> asyncDemo() throws Exception {
|
||||
Callable<String> readPrincipal = () -> {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
return auth == null ? "null (context did not cross the thread)" : auth.getName();
|
||||
};
|
||||
|
||||
ExecutorService plain = Executors.newVirtualThreadPerTaskExecutor();
|
||||
ExecutorService wrapped = new DelegatingSecurityContextExecutorService(
|
||||
Executors.newVirtualThreadPerTaskExecutor());
|
||||
try {
|
||||
return Map.of(
|
||||
"onRequestThread", readPrincipal.call(),
|
||||
"onPlainExecutor", plain.submit(readPrincipal).get(),
|
||||
"onDelegatingExecutor", wrapped.submit(readPrincipal).get());
|
||||
}
|
||||
finally {
|
||||
plain.shutdown();
|
||||
wrapped.shutdown();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
# Run with --spring.profiles.active=hs256,trace to see every filter decision.
|
||||
logging:
|
||||
level:
|
||||
org.springframework.security: TRACE
|
||||
org.springframework.security.web.FilterChainProxy: TRACE
|
||||
28
jwt-authentication/src/main/resources/application.yaml
Normal file
28
jwt-authentication/src/main/resources/application.yaml
Normal file
@@ -0,0 +1,28 @@
|
||||
spring:
|
||||
application:
|
||||
name: jwt-auth-demo
|
||||
profiles:
|
||||
# hs256 = symmetric signing, manual = hand-written OncePerRequestFilter.
|
||||
# Override with: --spring.profiles.active=rs256,resourceserver
|
||||
default: hs256
|
||||
|
||||
server:
|
||||
port: 8080
|
||||
error:
|
||||
include-message: always
|
||||
|
||||
demo:
|
||||
csrf:
|
||||
# true reproduces the "permitAll() still 403s" failure from docs/04.
|
||||
enabled: false
|
||||
jwt:
|
||||
issuer: https://jwt-auth-demo.ankurm.com
|
||||
audience: jwt-auth-demo-api
|
||||
access-token-ttl: 15m
|
||||
refresh-token-ttl: 8h
|
||||
# 32+ bytes. Demo value only - a real deployment reads this from a secret manager.
|
||||
hmac-secret: ankurm-demo-hmac-secret-key-please-rotate-me-32b
|
||||
|
||||
logging:
|
||||
level:
|
||||
org.springframework.security: INFO
|
||||
28
jwt-authentication/src/main/resources/demo-private-key.pem
Normal file
28
jwt-authentication/src/main/resources/demo-private-key.pem
Normal file
@@ -0,0 +1,28 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDk0QNA9BbQbPqJ
|
||||
Hn5Q2XsnvrrT8+x3mrV/lVqYr0pNL1/piE88gbyZ+LrNZ7xebHF1H8F3bJpf6/9b
|
||||
PoyB4UtsFWREV+97/NWerj6SCOGfxRpErWJTQLWP7tJqEPveEeoZo6MCUU2ujnKC
|
||||
MYBxUvxEZUzFEzLhppMCbcoHgR0baEkXHJ8IGq2TCwdMz8NdygVMtNnZsj6E+E3q
|
||||
4HGTvKQVoKdDjvKspIzqSfS6XR2nbYAr/iY/VJEOeevcNrkYWEwq0GrL8aBdwn1Z
|
||||
OnEhiGWvwHmah21vlLfj3TRU9JymheIl1SGCHESldtvVb8+WlUgSUhIfOV2fBY6i
|
||||
B0+dw7TNAgMBAAECggEAHlP/0OepcHXJXUxP5Mp2suVqYPaHRLEaVm9G4070k7dw
|
||||
SIVbL0No6qWXqOsTghZwkVwkqf4YlhczMPZg7EQe2ZQaRp67LN1tuQsSWwvXT/Rx
|
||||
j2HF0xAUIKBAfnOC1sPcGgrg68k3+SeDUPNbuWmM60nb+5EYYOVRvfQsX4NDBuMz
|
||||
B3gtuccd+L+wbCQPKmTQtu0FBtSzROKS39QJ77TmvNclQdVHu9tu805hezfbwXzG
|
||||
usR64CJRzJE/mxmgoAJX+Tws+G4VRY5KAEBojVpAlN2bxpIBYMfyX6UEOOIBKp0E
|
||||
WWv6bcFqlKQnSW9mWf4vt+ePtjlR4aLQcw9NWo2w5wKBgQD0N0s+X2+WX1h6Oqzd
|
||||
sVFjGkrhwWrCwrdepsnLTQoAj0FuwMpgvfQDGvNrnyOSz4jf3rDe63WDRBQuxdaa
|
||||
wU234jDBZ33VzUPQT4nrprKa1F3V8ZISpQdPNDRKyth56yyDeLQ8UnfhIlfN0/+b
|
||||
hQ9ns9uv4ASMH53Q4/TAI5OBywKBgQDv236HXs3JEED6vM2zwE5H0SNQUhwCv4/K
|
||||
m16Umd6rjHJJHkzQBhWsfYCCMUYZB+K7z9Zq9GivfJev2DqcKZkSdibpse4XDWfs
|
||||
MWtMvYxtHvTt5Ghs/vE5A5x6uz8DXwaWOx/8bo13EIQvxtvw5HXtKB/WtTygBGMv
|
||||
dcmT+dNwxwKBgDvm0Cr1Z75/loksmTgrhSYEzfc/5PruneG2kWqvc9OdT9Rlr345
|
||||
OYAFfU2ZlDUveIhI7CNRp9pRuY2bcz80SObgsUrPIrtthMO0rsTBd6+ohXezsDuo
|
||||
hPl1eZoa1Sxademtkq/1Hnh3XwgahujTo2qxYCJslVD1dFVHhMIYN9cvAoGAZO6e
|
||||
beSNAADg9yIgBXX0+u+cxp3mv5lQrtd2k120f8fYB8DCXf9Re4ZMX3zQnJPe611o
|
||||
QxWaP85UHmEFONWgXk5tzYVcRUMU6iVZm69fukN+meS1tLgLVgyY+mR0/bwtD2bN
|
||||
7PGwgdvnZBtwTgw1O5jY3Qbi/gsamcwdCTHlsd0CgYAc+gUTmRlzZDLiUHWOg4Hi
|
||||
+cllYIQPfQJYd4woiZu3er1Qo/tNYAI5hGDrUGI5cM4Qt3j9xA55eoigzy7ra+y2
|
||||
ZkfOUUHAtgJrOOkbUtqBCtqbhNTW2HnjMXwt8TaozrjjoHW6lLiq3PvQ+wjwKx54
|
||||
6g0Y6BKcZz2zYcpmrDXM6Q==
|
||||
-----END PRIVATE KEY-----
|
||||
@@ -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-----
|
||||
@@ -0,0 +1,142 @@
|
||||
package com.ankurm.jwtauth;
|
||||
|
||||
import com.ankurm.jwtauth.auth.RevokedTokenStore;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.MvcResult;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
/**
|
||||
* The 401-vs-403 contract, pinned as tests.
|
||||
*
|
||||
* @see docs/03-401-vs-403.md
|
||||
*/
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@ActiveProfiles("hs256")
|
||||
class AuthenticationFlowTests {
|
||||
|
||||
@Autowired MockMvc mvc;
|
||||
@Autowired RevokedTokenStore revokedTokens;
|
||||
|
||||
private static final tools.jackson.databind.ObjectMapper JSON =
|
||||
new tools.jackson.databind.ObjectMapper();
|
||||
|
||||
private Map<String, String> login(String user, String password) throws Exception {
|
||||
MvcResult result = this.mvc.perform(post("/api/auth/login")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"username\":\"%s\",\"password\":\"%s\"}".formatted(user, password)))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn();
|
||||
return JSON.readValue(result.getResponse().getContentAsString(),
|
||||
new tools.jackson.core.type.TypeReference<Map<String, String>>() { });
|
||||
}
|
||||
|
||||
@Test
|
||||
void publicEndpointNeedsNoToken() throws Exception {
|
||||
this.mvc.perform(get("/api/public/ping")).andExpect(status().isOk());
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingTokenIs401NotA403() throws Exception {
|
||||
this.mvc.perform(get("/api/me"))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(header().string("WWW-Authenticate",
|
||||
org.hamcrest.Matchers.containsString("Bearer")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void validTokenWithoutTheRoleIs403NotA401() throws Exception {
|
||||
String token = login("alice", "alice-password").get("accessToken");
|
||||
this.mvc.perform(get("/api/admin/stats").header("Authorization", "Bearer " + token))
|
||||
.andExpect(status().isForbidden())
|
||||
.andExpect(header().string("WWW-Authenticate",
|
||||
org.hamcrest.Matchers.containsString("insufficient_scope")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void adminTokenReachesAdminEndpoint() throws Exception {
|
||||
String token = login("root", "root-password").get("accessToken");
|
||||
this.mvc.perform(get("/api/admin/stats").header("Authorization", "Bearer " + token))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
|
||||
@Test
|
||||
void tamperedSignatureIs401WithInvalidToken() throws Exception {
|
||||
String token = login("alice", "alice-password").get("accessToken");
|
||||
String tampered = token.substring(0, token.length() - 4) + "AAAA";
|
||||
this.mvc.perform(get("/api/me").header("Authorization", "Bearer " + tampered))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(header().string("WWW-Authenticate",
|
||||
org.hamcrest.Matchers.containsString("invalid_token")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void refreshTokenIsNotAnAccessToken() throws Exception {
|
||||
String refresh = login("alice", "alice-password").get("refreshToken");
|
||||
this.mvc.perform(get("/api/me").header("Authorization", "Bearer " + refresh))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
void badPasswordIs401AndSaysNothingUseful() throws Exception {
|
||||
this.mvc.perform(post("/api/auth/login")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"username\":\"alice\",\"password\":\"nope\"}"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
void lockedAccountIsIndistinguishableFromABadPassword() throws Exception {
|
||||
MvcResult locked = this.mvc.perform(post("/api/auth/login")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"username\":\"locked\",\"password\":\"locked-password\"}"))
|
||||
.andExpect(status().isUnauthorized()).andReturn();
|
||||
MvcResult wrong = this.mvc.perform(post("/api/auth/login")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"username\":\"alice\",\"password\":\"nope\"}"))
|
||||
.andExpect(status().isUnauthorized()).andReturn();
|
||||
|
||||
assertThat(locked.getResponse().getContentAsString())
|
||||
.isEqualTo(wrong.getResponse().getContentAsString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void revokedTokenIsRefusedEvenThoughTheSignatureIsStillValid() throws Exception {
|
||||
String token = login("alice", "alice-password").get("accessToken");
|
||||
this.mvc.perform(get("/api/me").header("Authorization", "Bearer " + token))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
this.mvc.perform(post("/api/auth/logout").header("Authorization", "Bearer " + token))
|
||||
.andExpect(status().isNoContent());
|
||||
|
||||
this.mvc.perform(get("/api/me").header("Authorization", "Bearer " + token))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
void spentRefreshTokenCannotBeReplayed() throws Exception {
|
||||
String refresh = login("alice", "alice-password").get("refreshToken");
|
||||
this.mvc.perform(post("/api/auth/refresh")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"refreshToken\":\"%s\"}".formatted(refresh)))
|
||||
.andExpect(status().isOk());
|
||||
this.mvc.perform(post("/api/auth/refresh")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"refreshToken\":\"%s\"}".formatted(refresh)))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.ankurm.jwtauth;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.web.FilterChainProxy;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
/**
|
||||
* Pins the failure described in docs/04-csrf-permitall-403.md, and the two
|
||||
* facts that explain it.
|
||||
*/
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@ActiveProfiles({"hs256", "csrfon"})
|
||||
class CsrfBreaksPermitAllTests {
|
||||
|
||||
@Autowired MockMvc mvc;
|
||||
@Autowired FilterChainProxy filterChainProxy;
|
||||
|
||||
@Test
|
||||
void permitAllLoginStillReturns403WhenCsrfIsOn() throws Exception {
|
||||
this.mvc.perform(post("/api/auth/login")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"username\":\"alice\",\"password\":\"alice-password\"}"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
void theSameRequestWithACsrfTokenSucceeds() throws Exception {
|
||||
this.mvc.perform(post("/api/auth/login").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"username\":\"alice\",\"password\":\"alice-password\"}"))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
|
||||
@Test
|
||||
void csrfFilterRunsLongBeforeAuthorizationFilter() {
|
||||
List<String> filters = this.filterChainProxy.getFilterChains().getFirst()
|
||||
.getFilters().stream().map(f -> f.getClass().getSimpleName()).toList();
|
||||
|
||||
int csrf = filters.indexOf("CsrfFilter");
|
||||
int authorization = filters.indexOf("AuthorizationFilter");
|
||||
|
||||
assertThat(csrf).isNotNegative();
|
||||
assertThat(authorization).isEqualTo(filters.size() - 1);
|
||||
assertThat(csrf).isLessThan(authorization);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user