1
0
Files
jwt-auth-demo/oauth2-resource-server/src/test/java/com/ankurm/rsdemo/JwtValidationContractTests.java
Ankur Mhatre 4dc45d5e00 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
2026-08-23 11:00:56 +00:00

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();
}
}