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,23 @@
|
||||
package com.ankurm.rsdemo;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
/**
|
||||
* The resource server. It never mints a token — 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);
|
||||
}
|
||||
}
|
||||
@@ -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<>(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’s own
|
||||
* five-minute cache <em>off</em>, leaving your cache’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’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’s {@code JwtDecoderConfiguration} collects every {@code OAuth2TokenValidator<Jwt>}
|
||||
* 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’s own
|
||||
* {@code DefaultResourceRetriever}, not Spring’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’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;
|
||||
}
|
||||
}
|
||||
@@ -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’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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 — 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’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;
|
||||
}
|
||||
}
|
||||
@@ -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> — the new key appears in the JWK Set, nothing signs with it yet</li>
|
||||
* <li><b>activate</b> — the issuer starts signing with the new key</li>
|
||||
* <li><b>retire</b> — 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);
|
||||
}
|
||||
}
|
||||
@@ -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’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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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]"
|
||||
@@ -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]"
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
# The stub authorization server itself. Nothing here is a resource server.
|
||||
server:
|
||||
port: 9000
|
||||
stub:
|
||||
issuer: http://localhost:9000
|
||||
@@ -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
|
||||
@@ -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
|
||||
16
oauth2-resource-server/src/main/resources/application.yaml
Normal file
16
oauth2-resource-server/src/main/resources/application.yaml
Normal 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
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user