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
131 lines
5.5 KiB
Java
131 lines
5.5 KiB
Java
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();
|
|
}
|
|
}
|