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