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,23 @@
package com.ankurm.rsdemo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* The resource server. It never mints a token &mdash; it only validates the ones it is given.
*
* <p>Run it against either issuer:
* <pre>
* ./scripts/run-rs.sh stub # the in-repo stub issuer on :9000
* ./scripts/run-rs.sh keycloak # real Keycloak on :8080
* </pre>
*
* <p>Explained in <a href="../../../../../../../docs/12-resource-server-vs-manual-filter.md">docs/12</a>.
*/
@SpringBootApplication
public class ResourceServerApplication {
public static void main(String[] args) {
SpringApplication.run(ResourceServerApplication.class, args);
}
}

View File

@@ -0,0 +1,171 @@
package com.ankurm.rsdemo.config;
import java.time.Duration;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.nimbusds.jose.jwk.source.JWKSource;
import com.nimbusds.jose.jwk.source.JWKSourceBuilder;
import com.nimbusds.jose.proc.SecurityContext;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cache.Cache;
import org.springframework.cache.caffeine.CaffeineCache;
import org.springframework.cache.concurrent.ConcurrentMapCache;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtAudienceValidator;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtIssuerValidator;
import org.springframework.security.oauth2.jwt.JwtTypeValidator;
import org.springframework.security.oauth2.jwt.JwtValidators;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
/**
* Decoder variants that differ only in how the JWK Set is cached.
*
* <p>Read {@code NimbusJwtDecoder$JwkSetUriJwtDecoderBuilder.jwkSource()} in the Spring
* Security 7.1.1 sources before assuming any of this is obvious:
*
* <pre>
* JWKSourceBuilder.create(new SpringJWKSource&lt;&gt;(restOperations, cache, jwkSetUri))
* .refreshAheadCache(false)
* .rateLimited(false)
* .cache(this.cache instanceof NoOpCache)
* .build();
* </pre>
*
* Nimbus enables all three by default. Spring Security switches two off outright, and the
* third line means that <em>supplying</em> a Spring cache switches Nimbus&rsquo;s own
* five-minute cache <em>off</em>, leaving your cache&rsquo;s TTL as the only expiry in the
* system. Measured in
* <a href="../../../../../../../docs/15-jwks-caching-and-rotation.md">docs/15</a>.
*
* <p>With no profile active this class contributes nothing and Spring Boot&rsquo;s own
* auto-configured decoder is used, which is the configuration most applications run.
*/
@Configuration
public class JwtDecoderConfig {
@Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri}")
private String issuerUri;
@Value("${demo.audience:reports-api}")
private String audience;
/** Only used by the hardened profile, which cannot discover it. */
@Value("${demo.jwk-set-uri:}")
private String jwkSetUri;
/**
* Boot&rsquo;s {@code JwtDecoderConfiguration} collects every {@code OAuth2TokenValidator&lt;Jwt&gt;}
* bean in the context and appends it to the validator stack, so audience validation does
* not require replacing the decoder. This bean and the
* {@code spring.security.oauth2.resourceserver.jwt.audiences} property do the same job;
* the property builds a {@code JwtClaimValidator} on {@code aud}, this builds the
* purpose-made {@code JwtAudienceValidator}.
*/
@Bean
@Profile("audience")
OAuth2TokenValidator<Jwt> audienceValidator() {
return new JwtAudienceValidator(this.audience);
}
/**
* Accepts RFC 9068 access tokens. The default stack contains {@code JwtTypeValidator.jwt()},
* which accepts only an absent {@code typ} or {@code typ=JWT}; a token carrying
* {@code typ=at+jwt} - which RFC 9068 says an access token SHOULD carry - is refused by it.
*/
@Bean
@Profile("attyp")
OAuth2TokenValidator<Jwt> accessTokenTypeValidator() {
JwtTypeValidator validator = new JwtTypeValidator("JWT", "at+jwt", "application/at+jwt", "Bearer");
validator.setAllowEmpty(true);
return validator;
}
// ------------------------------------------------------------------ cache variants
private OAuth2TokenValidator<Jwt> validators() {
return JwtValidators.createDefaultWithValidators(new JwtIssuerValidator(this.issuerUri),
permissiveTypeValidator());
}
private JwtTypeValidator permissiveTypeValidator() {
JwtTypeValidator validator = new JwtTypeValidator("JWT", "at+jwt", "application/at+jwt", "Bearer");
validator.setAllowEmpty(true);
return validator;
}
/**
* The shared-cache configuration the reference documentation recommends, done correctly:
* an explicit TTL. Nimbus caching is off, so this TTL is the only expiry that exists.
*/
@Bean
@Profile("springcache")
JwtDecoder caffeineCachedDecoder() {
Cache cache = new CaffeineCache("jwks",
Caffeine.newBuilder().expireAfterWrite(Duration.ofMinutes(5)).build());
NimbusJwtDecoder decoder = NimbusJwtDecoder.withIssuerLocation(this.issuerUri).cache(cache).build();
decoder.setJwtValidator(validators());
return decoder;
}
/**
* Every protective layer Nimbus offers, restored.
*
* <p>{@code JwkSetUriJwtDecoderBuilder} exposes no way to re-enable rate limiting,
* refresh-ahead or outage tolerance, so the {@code JWKSource} is built directly and handed
* to {@code NimbusJwtDecoder.withJwkSource(..)}. What that costs:
*
* <ul>
* <li>issuer discovery is gone - the JWK Set URI has to be configured explicitly</li>
* <li>the validator stack is no longer supplied for you, so it is set here in full</li>
* <li>{@code JWKSourceBuilder.create(URL)} fetches with Nimbus&rsquo;s own
* {@code DefaultResourceRetriever}, not Spring&rsquo;s {@code RestOperations}, so
* any client customisation, proxy configuration or observability you had wired into
* the Spring HTTP client does not apply</li>
* </ul>
*
* <p>The rate limit also means that during a genuine rotation, tokens signed with the new
* key are refused for up to the interval after the first miss. That is the trade, and it
* is discussed in <a href="../../../../../../../docs/16-jwks-amplification.md">docs/16</a>.
*/
@Bean
@Profile("hardened")
JwtDecoder hardenedDecoder() throws java.net.MalformedURLException, java.net.URISyntaxException {
JWKSource<SecurityContext> source = JWKSourceBuilder
.<SecurityContext>create(new java.net.URI(this.jwkSetUri).toURL())
.cache(Duration.ofMinutes(5).toMillis(), Duration.ofSeconds(15).toMillis())
.refreshAheadCache(true)
.rateLimited(Duration.ofSeconds(30).toMillis())
.outageTolerant(Duration.ofMinutes(30).toMillis())
.retrying(true)
.build();
NimbusJwtDecoder decoder = NimbusJwtDecoder.withJwkSource(source).build();
decoder.setJwtValidator(validators());
return decoder;
}
/**
* The same configuration with the cache most people reach for first. A
* {@code ConcurrentMapCache} - which is also what {@code ConcurrentMapCacheManager},
* Boot&rsquo;s fallback cache manager, hands out - has no TTL at all, so the JWK Set is
* cached until something forces a refresh.
*
* <p>The only thing that forces a refresh is a token whose {@code kid} is missing from the
* cached set. A key that has been <em>removed</em> from the JWK Set is still present in the
* stale cache and still matches, so tokens signed with a retired - or compromised - key keep
* being accepted. Demonstrated by {@code scripts/retired-key-demo.sh}.
*/
@Bean
@Profile("nottlcache")
JwtDecoder noTtlCachedDecoder() {
Cache cache = new ConcurrentMapCache("jwks");
NimbusJwtDecoder decoder = NimbusJwtDecoder.withIssuerLocation(this.issuerUri).cache(cache).build();
decoder.setJwtValidator(validators());
return decoder;
}
}

View File

@@ -0,0 +1,109 @@
package com.ankurm.rsdemo.config;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Map;
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.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
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;
/**
* Mapping a Keycloak token onto Spring Security authorities.
*
* <p>The default {@link JwtGrantedAuthoritiesConverter} reads the {@code scope} or {@code scp}
* claim, splits it on spaces, and prefixes each value with {@code SCOPE_}. Keycloak does emit
* {@code scope}, so scopes work out of the box. Roles do not: Keycloak nests realm roles under
* {@code realm_access.roles} and client roles under {@code resource_access.<client>.roles},
* and the default converter looks at neither. The symptom is a token that authenticates
* perfectly and then gets 403 from every {@code hasRole(..)} rule.
*
* <p>Two ways out. This class is the Java one, active under the {@code roles} profile;
* {@code application-propsroles.yaml} is the configuration-only one. They produce the same
* authorities, and the configuration route has one limitation the Java route does not - see
* <a href="../../../../../../../docs/14-authentication-converter.md">docs/14</a>.
*
* <p><b>Defining this bean silently disables the properties.</b> Boot&rsquo;s
* {@code JwtConverterConfiguration} is annotated
* {@code @ConditionalOnMissingBean(JwtAuthenticationConverter.class)}, so the moment a
* {@code JwtAuthenticationConverter} bean exists, every
* {@code spring.security.oauth2.resourceserver.jwt.authorities-*} and {@code principal-claim-name}
* property stops having any effect. No warning is logged.
*/
@Configuration
public class KeycloakAuthoritiesConfig {
@Bean
@Profile("roles")
JwtAuthenticationConverter keycloakJwtAuthenticationConverter() {
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setPrincipalClaimName("preferred_username");
converter.setJwtGrantedAuthoritiesConverter(new KeycloakGrantedAuthoritiesConverter("reports-api"));
return converter;
}
/**
* Scopes keep the {@code SCOPE_} prefix, realm and client roles get {@code ROLE_}.
* A mixed mapping like this is the one thing the property-only route cannot express,
* because {@code authority-prefix} is a single value applied to every expression.
*/
static final class KeycloakGrantedAuthoritiesConverter implements Converter<Jwt, Collection<GrantedAuthority>> {
private final JwtGrantedAuthoritiesConverter scopes = new JwtGrantedAuthoritiesConverter();
private final String clientId;
KeycloakGrantedAuthoritiesConverter(String clientId) {
this.clientId = clientId;
}
@Override
public Collection<GrantedAuthority> convert(Jwt jwt) {
Collection<GrantedAuthority> authorities = new ArrayList<>(this.scopes.convert(jwt));
addPrefixed(authorities, realmRoles(jwt));
addPrefixed(authorities, clientRoles(jwt));
return authorities;
}
@SuppressWarnings("unchecked")
private List<String> realmRoles(Jwt jwt) {
Map<String, Object> realmAccess = jwt.getClaimAsMap("realm_access");
if (realmAccess == null) {
return List.of();
}
Object roles = realmAccess.get("roles");
return (roles instanceof List<?> list) ? (List<String>) list : List.of();
}
@SuppressWarnings("unchecked")
private List<String> clientRoles(Jwt jwt) {
Map<String, Object> resourceAccess = jwt.getClaimAsMap("resource_access");
if (resourceAccess == null) {
return List.of();
}
Object client = resourceAccess.get(this.clientId);
if (!(client instanceof Map<?, ?> clientMap)) {
return List.of();
}
Object roles = clientMap.get("roles");
return (roles instanceof List<?> list) ? (List<String>) list : List.of();
}
private void addPrefixed(Collection<GrantedAuthority> target, List<String> roles) {
for (String role : roles) {
// Realm roles and client roles are flattened into one ROLE_ namespace here.
// If two clients in your realm both define a role named "admin", this
// collapses them onto the same authority. Prefix by client if that is a
// risk for you.
target.add(new SimpleGrantedAuthority("ROLE_" + role));
}
}
}
}

View File

@@ -0,0 +1,42 @@
package com.ankurm.rsdemo.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
/**
* The whole resource server, in one chain.
*
* <p>Note what is <em>not</em> here: no login endpoint, no user store, no password encoder,
* no token minting. A resource server only ever verifies. Compare with the hand-written
* filter in {@code ../../../jwt-authentication/} and
* <a href="../../../../../../../docs/09-manual-filter-vs-resource-server.md">docs/09</a>.
*
* <p>Explained in <a href="../../../../../../../docs/12-issuer-and-audience.md">docs/12</a>.
*/
@Configuration
@EnableMethodSecurity
public class ResourceServerSecurityConfig {
@Bean
SecurityFilterChain api(HttpSecurity http) throws Exception {
return http
// A resource server authenticates every request from the token alone, so there is
// no session to protect and nothing for CSRF to defend. This is the one place the
// blanket "never disable CSRF" advice genuinely does not apply - see docs/04.
.csrf((csrf) -> csrf.disable())
.sessionManagement((s) -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests((auth) -> auth
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated())
// Everything interesting about this application is inside these two lines.
// The JwtDecoder bean decides which tokens are genuine; the JwtAuthenticationConverter
// bean decides what a genuine token is allowed to do.
.oauth2ResourceServer((oauth2) -> oauth2.jwt((jwt) -> { }))
.build();
}
}

View File

@@ -0,0 +1,61 @@
package com.ankurm.rsdemo.web;
import java.util.LinkedHashMap;
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.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* Four endpoints, each testing one layer of the chain.
*
* <p>Explained in <a href="../../../../../../../docs/12-issuer-and-audience.md">docs/12</a>.
*/
@RestController
public class ApiControllers {
/** Reachable with no token at all. If this 401s, the problem is not your token. */
@GetMapping("/api/public/ping")
public Map<String, Object> ping() {
return Map.of("status", "up");
}
/**
* 401 without a valid token. The response body is where every claim-validation failure
* shows up - in the {@code WWW-Authenticate} header, not the body.
*/
@GetMapping("/api/me")
public Map<String, Object> me(Authentication authentication) {
Jwt jwt = (Jwt) authentication.getPrincipal();
Map<String, Object> out = new LinkedHashMap<>();
out.put("name", authentication.getName());
out.put("authorities", authentication.getAuthorities().stream().map(GrantedAuthority::getAuthority).sorted().toList());
out.put("iss", jwt.getClaimAsString("iss"));
out.put("aud", jwt.getAudience());
out.put("typ", jwt.getHeaders().get("typ"));
out.put("kid", jwt.getHeaders().get("kid"));
out.put("exp", jwt.getExpiresAt());
return out;
}
/** 403 with a valid token that lacks ROLE_ADMIN. This is where the converter shows up. */
@GetMapping("/api/admin/stats")
public Map<String, Object> adminStats() {
return Map.of("secret", "only ROLE_ADMIN sees this");
}
/**
* The method-security twin. This one needs a Keycloak <em>client</em> role, which lives
* two levels down in {@code resource_access.reports-api.roles} - the claim the default
* converter is least likely to find.
*/
@GetMapping("/api/reports")
@PreAuthorize("hasAuthority('ROLE_reports-reader')")
public Map<String, Object> reports() {
return Map.of("reports", 3);
}
}

View File

@@ -0,0 +1,128 @@
package com.ankurm.rsdemo.web;
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.springframework.http.MediaType;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* Prints the JWK source chain that the running {@code JwtDecoder} actually has.
*
* <p>Every claim in the post about caching, rate limiting and refresh-ahead can be read out
* of the Spring Security sources, but reading sources tells you what <em>a</em> decoder looks
* like, not what <em>yours</em> looks like after auto-configuration, your profiles, your
* {@code JwkSetUriJwtDecoderBuilderCustomizer} beans and your cache have all had a turn.
* This endpoint walks the live object graph and reports the layers it finds, with the
* timings each layer was constructed with.
*
* <p>It reads private fields by reflection, which is the price of asking a question the API
* does not answer. It is a diagnostic, not a feature: <b>delete it before you ship.</b>
* It reveals your JWK Set URI and cache timings to anyone who can reach it.
*
* <p>Explained in <a href="../../../../../../../docs/15-jwks-caching-and-rotation.md">docs/15</a>.
*/
@RestController
public class DecoderDiagnosticsController {
private final JwtDecoder decoder;
public DecoderDiagnosticsController(JwtDecoder decoder) {
this.decoder = decoder;
}
@GetMapping(path = "/api/public/decoder", produces = MediaType.APPLICATION_JSON_VALUE)
public Map<String, Object> decoder() {
Map<String, Object> report = new LinkedHashMap<>();
report.put("decoderClass", this.decoder.getClass().getName());
Object cursor = this.decoder;
// An issuer-uri produces a SupplierJwtDecoder, whose `delegate` field is a
// Supplier<JwtDecoder> rather than the decoder - the real NimbusJwtDecoder does not
// exist until the first token is decoded. That laziness is deliberate: it decouples
// startup from the authorization server being reachable.
Object delegate = field(cursor, "delegate");
if (delegate instanceof java.util.function.Supplier<?> supplier) {
report.put("note", "SupplierJwtDecoder: built lazily on first decode, then cached");
cursor = supplier.get();
report.put("resolvedDecoderClass", className(cursor));
}
Object processor = field(cursor, "jwtProcessor");
Object keySelector = (processor != null) ? field(processor, "jwsKeySelector") : null;
Object jwkSource = (keySelector != null) ? field(keySelector, "jwkSource") : null;
report.put("processor", className(processor));
report.put("keySelector", className(keySelector));
List<Map<String, Object>> chain = new ArrayList<>();
Object node = jwkSource;
int guard = 0;
while (node != null && guard++ < 12) {
Map<String, Object> layer = new LinkedHashMap<>();
layer.put("class", node.getClass().getName());
describe(node, layer);
chain.add(layer);
node = field(node, "source");
}
report.put("jwkSourceChain", chain);
report.put("readMe", "Each entry wraps the next. A layer that is absent was switched off.");
return report;
}
/** Pulls out the timings that decide when a rotated key becomes visible. */
private void describe(Object node, Map<String, Object> layer) {
String name = node.getClass().getSimpleName();
switch (name) {
case "CachingJWKSetSource", "RefreshAheadCachingJWKSetSource" -> {
layer.put("timeToLiveMs", field(node, "timeToLive"));
layer.put("cacheRefreshTimeoutMs", field(node, "cacheRefreshTimeout"));
layer.put("meaning", "the JWK Set is re-fetched no more often than timeToLive");
}
case "RateLimitedJWKSetSource" -> {
layer.put("minTimeIntervalMs", field(node, "minTimeInterval"));
layer.put("meaning", "forced refreshes are throttled to this interval");
}
case "OutageTolerantJWKSetSource" ->
layer.put("meaning", "a stale JWK Set is served if the issuer is unreachable");
case "SpringJWKSource" -> {
layer.put("jwkSetUri", field(node, "jwkSetUri"));
Object cache = field(node, "cache");
layer.put("springCache", className(cache));
layer.put("meaning", (cache != null && cache.getClass().getSimpleName().equals("NoOpCache"))
? "no Spring cache supplied, so Nimbus's own cache layer is enabled above"
: "a Spring cache was supplied, so Nimbus's cache layer was disabled and this "
+ "cache's TTL is the only expiry");
}
default -> {
}
}
}
private static String className(Object o) {
return (o != null) ? o.getClass().getName() : null;
}
private static Object field(Object target, String name) {
Class<?> type = target.getClass();
while (type != null && type != Object.class) {
try {
Field f = type.getDeclaredField(name);
f.setAccessible(true);
return f.get(target);
}
catch (NoSuchFieldException ex) {
type = type.getSuperclass();
}
catch (Exception ex) {
return null;
}
}
return null;
}
}

View File

@@ -0,0 +1,25 @@
package com.ankurm.stubissuer;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* A deliberately minimal OAuth2 authorization server whose JWKS I can mutate on command.
*
* <p>Keycloak is the realistic issuer and this repository runs against it too
* (see {@code docker/compose.yaml}). But Keycloak will not rotate its signing key at a
* chosen second, will not tell you how many times its JWKS endpoint was fetched, and
* will not drop a key from the published set on request. Every claim in the post about
* <em>caching</em> and <em>rotation timing</em> needs exactly those three things, so they
* are measured here and the Keycloak run confirms the same code path end to end.
*
* <p>Runs on :9000. Explained in
* <a href="../../../../../../../docs/15-jwks-caching-and-rotation.md">docs/15</a>.
*/
@SpringBootApplication
public class StubIssuerApplication {
public static void main(String[] args) {
SpringApplication.run(StubIssuerApplication.class, args);
}
}

View File

@@ -0,0 +1,181 @@
package com.ankurm.stubissuer;
import java.time.Instant;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import com.nimbusds.jose.JOSEObjectType;
import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.JWSHeader;
import com.nimbusds.jose.crypto.RSASSASigner;
import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.SignedJWT;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* The endpoints a resource server discovers, plus admin endpoints a real issuer would
* never expose.
*
* <p>Discovery and JWKS are deliberately shaped like Keycloak's so the resource server
* configuration is byte-identical between the two issuers.
*/
@RestController
public class StubIssuerController {
private final StubKeyStore keys;
private final String issuer;
public StubIssuerController(StubKeyStore keys, @Value("${stub.issuer}") String issuer) {
this.keys = keys;
this.issuer = issuer;
}
// ---------------------------------------------------------------- discovery
@GetMapping(path = "/.well-known/openid-configuration", produces = MediaType.APPLICATION_JSON_VALUE)
public Map<String, Object> discovery() {
Map<String, Object> doc = new LinkedHashMap<>();
doc.put("issuer", this.issuer);
doc.put("jwks_uri", this.issuer + "/jwks.json");
doc.put("token_endpoint", this.issuer + "/token");
doc.put("id_token_signing_alg_values_supported", List.of("RS256"));
doc.put("response_types_supported", List.of("code"));
doc.put("subject_types_supported", List.of("public"));
return doc;
}
/**
* Every fetch of this endpoint is counted. A resource server that is behaving itself
* hits this roughly once per cache lifetime; one that is not can hit it once per request.
*/
@GetMapping(path = "/jwks.json", produces = "application/jwk-set+json")
public String jwks() {
this.keys.recordJwksFetch();
return this.keys.publishedJwkSet().toString();
}
// ---------------------------------------------------------------- token minting
/**
* Mints an access token. Everything is a query parameter because the point is to be able
* to produce a deliberately wrong token as easily as a correct one.
*
* @param kid sign with a specific key rather than the active one. A retired key still
* signs perfectly well &mdash; that is the whole problem with retiring keys.
*/
@PostMapping(path = "/token", produces = MediaType.TEXT_PLAIN_VALUE)
public String token(@RequestParam(defaultValue = "alice") String sub,
@RequestParam(defaultValue = "reports-api") String aud,
@RequestParam(defaultValue = "profile:read reports:read") String scope,
@RequestParam(defaultValue = "USER") String roles,
@RequestParam(defaultValue = "300") long expiresInSeconds,
@RequestParam(defaultValue = "0") long issuedAgoSeconds,
@RequestParam(required = false) String kid,
@RequestParam(required = false) String issuerOverride,
@RequestParam(defaultValue = "JWT") String typ) throws Exception {
RSAKey key = (kid != null) ? this.keys.key(kid) : this.keys.signingKey();
Instant issuedAt = Instant.now().minusSeconds(issuedAgoSeconds);
Map<String, Object> realmAccess = Map.of("roles", Arrays.asList(roles.split(" ")));
Map<String, Object> resourceAccess = Map.of("reports-api", Map.of("roles", List.of("reports-reader")));
JWTClaimsSet claims = new JWTClaimsSet.Builder()
.issuer((issuerOverride != null) ? issuerOverride : this.issuer)
.subject(sub)
.audience(Arrays.asList(aud.split(" ")))
.claim("scope", scope)
// Keycloak puts realm roles here, nested one level down. Spring Security's
// default converter reads a flat "scope"/"scp" claim and will not find these.
.claim("realm_access", realmAccess)
.claim("resource_access", resourceAccess)
.claim("preferred_username", sub)
.issueTime(java.util.Date.from(issuedAt))
.expirationTime(java.util.Date.from(issuedAt.plusSeconds(expiresInSeconds)))
.jwtID(java.util.UUID.randomUUID().toString())
.build();
JWSHeader header = new JWSHeader.Builder(JWSAlgorithm.RS256)
.keyID(key.getKeyID())
.type(new JOSEObjectType(typ))
.build();
SignedJWT jwt = new SignedJWT(header, claims);
jwt.sign(new RSASSASigner(key));
return jwt.serialize();
}
/**
* Mints a token whose {@code kid} header names a key that has never existed. This is what
* an attacker&rsquo;s traffic looks like, and it is the input to the amplification demo.
*/
@PostMapping(path = "/token/unknown-kid", produces = MediaType.TEXT_PLAIN_VALUE)
public String tokenWithUnknownKid(@RequestParam(defaultValue = "alice") String sub) throws Exception {
RSAKey key = this.keys.signingKey();
Instant now = Instant.now();
JWTClaimsSet claims = new JWTClaimsSet.Builder()
.issuer(this.issuer)
.subject(sub)
.audience("reports-api")
.issueTime(java.util.Date.from(now))
.expirationTime(java.util.Date.from(now.plusSeconds(300)))
.build();
JWSHeader header = new JWSHeader.Builder(JWSAlgorithm.RS256)
.keyID("kid-" + java.util.UUID.randomUUID())
.type(new JOSEObjectType("at+jwt"))
.build();
SignedJWT jwt = new SignedJWT(header, claims);
jwt.sign(new RSASSASigner(key));
return jwt.serialize();
}
// ---------------------------------------------------------------- admin
@PostMapping(path = "/admin/publish", produces = MediaType.APPLICATION_JSON_VALUE)
public Map<String, Object> publish() {
String kid = this.keys.generate();
return state("published " + kid);
}
@PostMapping(path = "/admin/activate", produces = MediaType.APPLICATION_JSON_VALUE)
public Map<String, Object> activate(@RequestParam String kid) {
this.keys.activate(kid);
return state("signing with " + kid);
}
@PostMapping(path = "/admin/retire", produces = MediaType.APPLICATION_JSON_VALUE)
public Map<String, Object> retire(@RequestParam String kid) {
this.keys.retire(kid);
return state("retired " + kid + " from the published set");
}
@PostMapping(path = "/admin/reset-counter", produces = MediaType.APPLICATION_JSON_VALUE)
public Map<String, Object> resetCounter() {
this.keys.resetJwksFetches();
return state("jwks fetch counter reset");
}
@GetMapping(path = "/admin/state", produces = MediaType.APPLICATION_JSON_VALUE)
public Map<String, Object> state() {
return state("ok");
}
private Map<String, Object> state(String message) {
Map<String, Object> out = new LinkedHashMap<>();
out.put("message", message);
out.put("activeKid", this.keys.activeKid());
out.put("publishedKids", this.keys.publishedKids());
out.put("jwksFetches", this.keys.jwksFetches());
return out;
}
}

View File

@@ -0,0 +1,115 @@
package com.ankurm.stubissuer;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.KeyUse;
import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jose.jwk.gen.RSAKeyGenerator;
import org.springframework.stereotype.Component;
/**
* Holds every key this issuer has ever minted, plus which of them are currently
* <em>published</em> in the JWK Set and which one is currently <em>active</em> for signing.
*
* <p>Rotation in the real world is three separate events, and conflating them is where
* most rotation incidents come from:
* <ol>
* <li><b>publish</b> &mdash; the new key appears in the JWK Set, nothing signs with it yet</li>
* <li><b>activate</b> &mdash; the issuer starts signing with the new key</li>
* <li><b>retire</b> &mdash; the old key is removed from the JWK Set</li>
* </ol>
* This class lets a script fire them independently and at a chosen moment, which is the
* only way to show what a resource server does in between.
*/
@Component
public class StubKeyStore {
private final Map<String, RSAKey> allKeys = new LinkedHashMap<>();
private final List<String> published = new ArrayList<>();
private final AtomicInteger keyCounter = new AtomicInteger();
/** Counts every GET of /jwks.json. This number is the point of the whole class. */
private final AtomicLong jwksFetches = new AtomicLong();
private volatile String activeKid;
public StubKeyStore() {
String kid = generate();
this.activeKid = kid;
}
/** Creates a key, publishes it, and returns its kid. Does not make it active. */
public synchronized String generate() {
String kid = "stub-key-" + this.keyCounter.incrementAndGet();
try {
RSAKey key = new RSAKeyGenerator(2048).keyID(kid).keyUse(KeyUse.SIGNATURE).generate();
this.allKeys.put(kid, key);
this.published.add(kid);
return kid;
}
catch (Exception ex) {
throw new IllegalStateException("could not generate RSA key", ex);
}
}
public synchronized void activate(String kid) {
if (!this.allKeys.containsKey(kid)) {
throw new IllegalArgumentException("no such kid: " + kid);
}
this.activeKid = kid;
}
/** Removes a key from the published JWK Set. The key still exists and can still sign. */
public synchronized void retire(String kid) {
this.published.remove(kid);
}
public synchronized RSAKey signingKey() {
return this.allKeys.get(this.activeKid);
}
public synchronized RSAKey key(String kid) {
RSAKey key = this.allKeys.get(kid);
if (key == null) {
throw new IllegalArgumentException("no such kid: " + kid);
}
return key;
}
public synchronized JWKSet publishedJwkSet() {
List<com.nimbusds.jose.jwk.JWK> keys = new ArrayList<>();
for (String kid : this.published) {
keys.add(this.allKeys.get(kid).toPublicJWK());
}
return new JWKSet(keys);
}
public long recordJwksFetch() {
return this.jwksFetches.incrementAndGet();
}
public long jwksFetches() {
return this.jwksFetches.get();
}
public void resetJwksFetches() {
this.jwksFetches.set(0);
}
public String activeKid() {
return this.activeKid;
}
public synchronized List<String> publishedKids() {
return List.copyOf(this.published);
}
}

View File

@@ -0,0 +1,21 @@
package com.ankurm.stubissuer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
/**
* The stub issuer authenticates nobody. Defining any {@code SecurityFilterChain} bean makes
* Boot&rsquo;s default chain back off, which is the whole purpose of this class.
*/
@Configuration
public class StubSecurityConfig {
@Bean
SecurityFilterChain open(HttpSecurity http) throws Exception {
return http.csrf((csrf) -> csrf.disable())
.authorizeHttpRequests((auth) -> auth.anyRequest().permitAll())
.build();
}
}

View File

@@ -0,0 +1,11 @@
# Points the resource server at the Keycloak in docker/compose.yaml.
# Identical shape to application-stub.yaml - one property.
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: http://localhost:8080/realms/demo
demo:
jwk-set-uri: http://localhost:8080/realms/demo/protocol/openid-connect/certs

View File

@@ -0,0 +1,8 @@
# Audience validation with no Java at all. Boot turns this into a JwtClaimValidator on
# `aud` and appends it to the default validator stack.
spring:
security:
oauth2:
resourceserver:
jwt:
audiences: reports-api

View File

@@ -0,0 +1,15 @@
# The same configuration with the hyphenated client id unquoted. This is what most people
# write first, and it fails silently: no error, no WARN, just an authority that never
# appears and a 403 nobody can explain.
#
# The `trace` profile makes the swallowed message visible.
spring:
security:
oauth2:
resourceserver:
jwt:
principal-claim-name: preferred_username
authority-prefix: "ROLE_"
authorities-claim-expressions:
- "[realm_access][roles]"
- "[resource_access][reports-api][roles]"

View File

@@ -0,0 +1,28 @@
# Keycloak's nested roles, mapped with configuration only.
#
# `authorities-claim-expressions` is a Spring Boot 4 property. Each entry is a SpEL
# expression evaluated against the claim map, so a nested claim needs no Java.
#
# NOTE THE QUOTES around 'reports-api'. Inside a SpEL indexer the contents are an
# expression, not a literal key, so [resource_access][reports-api][roles] parses as
# `reports` MINUS `api` and blows up with EL1008E. ExpressionJwtGrantedAuthoritiesConverter
# catches ExpressionException, substitutes an empty authority list, and logs the reason at
# TRACE only - so the failure surfaces as a 403 with nothing in the log to explain it.
# See application-propsroles-broken.yaml for the other spelling, and docs/14.
#
# Two more consequences of taking this route:
# * `authority-prefix` is ONE value applied to every expression. A mixed mapping -
# SCOPE_ for scopes, ROLE_ for roles - cannot be expressed here.
# * naming expressions REPLACES the default JwtGrantedAuthoritiesConverter, so the
# SCOPE_* authorities it produced from the `scope` claim disappear unless you add
# `[scope]` as an expression too - and then it gets the ROLE_ prefix as well.
spring:
security:
oauth2:
resourceserver:
jwt:
principal-claim-name: preferred_username
authority-prefix: "ROLE_"
authorities-claim-expressions:
- "[realm_access][roles]"
- "[resource_access]['reports-api'][roles]"

View File

@@ -0,0 +1,14 @@
# Points the resource server at the in-repo stub issuer on :9000.
# Discovery is used, exactly as with Keycloak: Spring reads
# /.well-known/openid-configuration, takes jwks_uri from it, and validates `iss`
# against this value.
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: http://localhost:9000
# The hardened profile builds the JWKSource itself and therefore cannot discover this.
demo:
jwk-set-uri: http://localhost:9000/jwks.json

View File

@@ -0,0 +1,5 @@
# The stub authorization server itself. Nothing here is a resource server.
server:
port: 9000
stub:
issuer: http://localhost:9000

View File

@@ -0,0 +1,6 @@
# Everything the resource server does to a token, logged. The line worth waiting for is
# the one from BearerTokenAuthenticationFilter naming the validator that refused.
logging:
level:
org.springframework.security: TRACE
org.springframework.web.client: DEBUG

View File

@@ -0,0 +1,4 @@
# Just enough logging to see a claim expression fail, and nothing else.
logging:
level:
org.springframework.security.oauth2.server.resource.authentication.ExpressionJwtGrantedAuthoritiesConverter: TRACE

View File

@@ -0,0 +1,16 @@
# Shared defaults. The issuer is deliberately NOT set here - it arrives with the
# `stub` or `keycloak` profile, so that the same application code demonstrably runs
# against a toy issuer and against a real one with no source difference at all.
spring:
application:
name: oauth2-resource-server-demo
server:
port: 8081
demo:
audience: reports-api
logging:
level:
org.springframework.security.oauth2: INFO

View File

@@ -0,0 +1,130 @@
package com.ankurm.rsdemo;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
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.JwtAudienceValidator;
import org.springframework.security.oauth2.jwt.JwtIssuerValidator;
import org.springframework.security.oauth2.jwt.JwtTypeValidator;
import org.springframework.security.oauth2.jwt.JwtValidators;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Pins the behaviour of the default validator stack, because it is the part of a resource
* server that changes underneath you between versions and fails closed when it does.
*
* <p>These tests deliberately assert the <em>defaults</em> rather than this application's
* configuration. If a Spring Security upgrade changes what
* {@code JwtValidators.createDefaultWithIssuer} puts in the stack, this file goes red and
* the post that describes it is wrong.
*
* <p>Explained in <a href="../../../../../../docs/13-validator-stack.md">docs/13</a>.
*/
class JwtValidationContractTests {
private static final String ISSUER = "https://issuer.example.com";
private Jwt.Builder token() {
Instant now = Instant.now();
return Jwt.withTokenValue("token")
.header("alg", "RS256")
.header("typ", "JWT")
.issuer(ISSUER)
.subject("alice")
.audience(List.of("reports-api"))
.issuedAt(now)
.expiresAt(now.plusSeconds(300))
.claim("jti", "id");
}
@Test
void defaultStackAcceptsAWellFormedToken() {
OAuth2TokenValidator<Jwt> validator = JwtValidators.createDefaultWithIssuer(ISSUER);
assertThat(validator.validate(token().build()).hasErrors()).isFalse();
}
@Test
void defaultStackDoesNotCheckAudience() {
OAuth2TokenValidator<Jwt> validator = JwtValidators.createDefaultWithIssuer(ISSUER);
Jwt wrongAudience = token().audience(List.of("billing-api")).build();
// This is the whole reason the audience check has to be added deliberately.
assertThat(validator.validate(wrongAudience).hasErrors()).isFalse();
}
@Test
void addingJwtAudienceValidatorRefusesTheSameToken() {
OAuth2TokenValidator<Jwt> validator = JwtValidators
.createDefaultWithValidators(new JwtIssuerValidator(ISSUER), new JwtAudienceValidator("reports-api"));
Jwt wrongAudience = token().audience(List.of("billing-api")).build();
OAuth2TokenValidatorResult result = validator.validate(wrongAudience);
assertThat(result.hasErrors()).isTrue();
assertThat(result.getErrors()).anySatisfy((error) -> assertThat(error.getDescription()).contains("aud"));
}
@Test
void defaultStackRefusesRfc9068AccessTokens() {
OAuth2TokenValidator<Jwt> validator = JwtValidators.createDefaultWithIssuer(ISSUER);
Jwt atJwt = token().headers((h) -> h.put("typ", "at+jwt")).build();
// JwtTypeValidator.jwt() accepts an absent typ or typ=JWT and nothing else, so the
// media type RFC 9068 defines for access tokens is refused by the default stack.
assertThat(validator.validate(atJwt).hasErrors()).isTrue();
}
@Test
void aPermissiveTypeValidatorAcceptsThem() {
JwtTypeValidator types = new JwtTypeValidator("JWT", "at+jwt", "application/at+jwt");
types.setAllowEmpty(true);
OAuth2TokenValidator<Jwt> validator = JwtValidators
.createDefaultWithValidators(new JwtIssuerValidator(ISSUER), types);
Jwt atJwt = token().headers((h) -> h.put("typ", "at+jwt")).build();
assertThat(validator.validate(atJwt).hasErrors()).isFalse();
}
@Test
void issuerComparisonIsExactStringEquality() {
OAuth2TokenValidator<Jwt> validator = JwtValidators.createDefaultWithIssuer(ISSUER);
// A trailing slash is a different issuer. This is the single most common cause of
// "the token is signed correctly but the iss claim is not valid".
Jwt trailingSlash = token().issuer(ISSUER + "/").build();
assertThat(validator.validate(trailingSlash).hasErrors()).isTrue();
}
@Test
void defaultClockSkewIsSixtySeconds() {
OAuth2TokenValidator<Jwt> validator = JwtValidators.createDefaultWithIssuer(ISSUER);
Instant now = Instant.now();
Jwt expired30sAgo = token().issuedAt(now.minusSeconds(60)).expiresAt(now.minusSeconds(30)).build();
Jwt expired90sAgo = token().issuedAt(now.minusSeconds(120)).expiresAt(now.minusSeconds(90)).build();
assertThat(validator.validate(expired30sAgo).hasErrors()).isFalse();
assertThat(validator.validate(expired90sAgo).hasErrors()).isTrue();
}
@Test
void audienceValidatorMatchesAnyEntryNotAllOfThem() {
OAuth2TokenValidator<Jwt> validator = new JwtAudienceValidator("reports-api");
Jwt multipleAudiences = token().audience(List.of("billing-api", "reports-api")).build();
assertThat(validator.validate(multipleAudiences).hasErrors()).isFalse();
}
@Test
void aMissingAudienceClaimIsRefusedNotIgnored() {
OAuth2TokenValidator<Jwt> validator = new JwtAudienceValidator("reports-api");
Jwt noAudience = token().claims((c) -> c.remove("aud")).build();
assertThat(validator.validate(noAudience).hasErrors()).isTrue();
}
@Test
void nestedKeycloakRolesAreInvisibleToTheDefaultAuthoritiesConverter() {
var converter = new org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter();
Jwt keycloakish = token().claim("realm_access", Map.of("roles", List.of("ADMIN"))).build();
// No scope claim, roles one level down: the default converter finds nothing at all.
assertThat(converter.convert(keycloakish)).isEmpty();
}
}