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. * *

These tests deliberately assert the defaults 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. * *

Explained in docs/13. */ 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 validator = JwtValidators.createDefaultWithIssuer(ISSUER); assertThat(validator.validate(token().build()).hasErrors()).isFalse(); } @Test void defaultStackDoesNotCheckAudience() { OAuth2TokenValidator 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 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 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 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 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 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 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 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(); } }