diff --git a/README.md b/README.md index 068323f5..1ad8d6aa 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,16 @@ To use the services provided by this clients, they need to be instantiated in sp @Value("${idam.s2s-auth.tokenTimeToLiveInSeconds:14400}") final int ttl) { return new CachedServiceAuthTokenGenerator(serviceAuthTokenGenerator, ttl); } + + @Bean + public AuthTokenGenerator autorefreshingJwtAuthTokenGenerator( + @Qualifier("serviceAuthTokenGenerator") final AuthTokenGenerator serviceAuthTokenGenerator, + @Value("${idam.s2s-auth.refreshTimeDeltaInSeconds}") final int refreshDeltaInSeconds) { + return new AutorefreshingJwtAuthTokenGenerator( + serviceAuthTokenGenerator, + Duration.of(refreshDeltaInSeconds, SECONDS) + ); + } } ``` diff --git a/build.gradle b/build.gradle index 29aef61f..83b7fde3 100644 --- a/build.gradle +++ b/build.gradle @@ -9,7 +9,7 @@ plugins { } group 'uk.gov.hmcts.reform' -version '0.0.8' +version '0.1.0' checkstyle { maxWarnings = 0 @@ -124,6 +124,7 @@ dependencies { compile group: 'com.netflix.feign', name: 'feign-jackson', version: '8.18.0' compile group: 'com.google.guava', name: 'guava', version: '23.2-jre' compile group: 'com.warrenstrange', name: 'googleauth', version: '1.1.2' + compile group: 'com.auth0', name: 'java-jwt', version: '3.3.0' testCompile group: 'junit', name: 'junit', version: '4.12' testCompile group: 'org.assertj', name: 'assertj-core', version: '3.8.0' diff --git a/src/main/java/uk/gov/hmcts/reform/authorisation/exceptions/JwtDecodingException.java b/src/main/java/uk/gov/hmcts/reform/authorisation/exceptions/JwtDecodingException.java new file mode 100644 index 00000000..cfcb284f --- /dev/null +++ b/src/main/java/uk/gov/hmcts/reform/authorisation/exceptions/JwtDecodingException.java @@ -0,0 +1,8 @@ +package uk.gov.hmcts.reform.authorisation.exceptions; + +public class JwtDecodingException extends RuntimeException { + + public JwtDecodingException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/uk/gov/hmcts/reform/authorisation/generators/AutorefreshingJwtAuthTokenGenerator.java b/src/main/java/uk/gov/hmcts/reform/authorisation/generators/AutorefreshingJwtAuthTokenGenerator.java new file mode 100644 index 00000000..9d2166a4 --- /dev/null +++ b/src/main/java/uk/gov/hmcts/reform/authorisation/generators/AutorefreshingJwtAuthTokenGenerator.java @@ -0,0 +1,58 @@ +package uk.gov.hmcts.reform.authorisation.generators; + +import com.auth0.jwt.JWT; +import com.auth0.jwt.exceptions.JWTDecodeException; +import com.auth0.jwt.interfaces.DecodedJWT; +import uk.gov.hmcts.reform.authorisation.exceptions.JwtDecodingException; + +import java.time.Duration; +import java.util.Date; + +import static java.time.Instant.now; + +/** + * Caches the JWT token and refreshes it once it expired. + */ +public class AutorefreshingJwtAuthTokenGenerator implements AuthTokenGenerator { + + private final ServiceAuthTokenGenerator generator; + private final Duration refreshTimeDelta; + + private DecodedJWT jwt = null; + + /** + * Constructor. + * + * @param refreshTimeDelta time before actual expiry date in JWT when a new token should be requested. + */ + public AutorefreshingJwtAuthTokenGenerator( + ServiceAuthTokenGenerator generator, + Duration refreshTimeDelta + ) { + this.generator = generator; + this.refreshTimeDelta = refreshTimeDelta; + } + + public AutorefreshingJwtAuthTokenGenerator(ServiceAuthTokenGenerator generator) { + this(generator, Duration.ZERO); + } + + @Override + public String generate() { + if (jwt == null || needToRefresh(jwt.getExpiresAt())) { + String newToken = generator.generate(); + + try { + jwt = JWT.decode(newToken); + } catch (JWTDecodeException exc) { + throw new JwtDecodingException(exc.getMessage(), exc); + } + } + + return jwt.getToken(); + } + + private boolean needToRefresh(Date expDate) { + return expDate != null && Date.from(now().plus(refreshTimeDelta)).after(expDate); + } +} diff --git a/src/test/java/uk/gov/hmcts/reform/authorisation/generators/AutorefreshingJwtAuthTokenGeneratorTest.java b/src/test/java/uk/gov/hmcts/reform/authorisation/generators/AutorefreshingJwtAuthTokenGeneratorTest.java new file mode 100644 index 00000000..e31a988b --- /dev/null +++ b/src/test/java/uk/gov/hmcts/reform/authorisation/generators/AutorefreshingJwtAuthTokenGeneratorTest.java @@ -0,0 +1,125 @@ +package uk.gov.hmcts.reform.authorisation.generators; + +import com.auth0.jwt.JWT; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.runners.MockitoJUnitRunner; +import uk.gov.hmcts.reform.authorisation.exceptions.JwtDecodingException; + +import java.time.Duration; +import java.time.Instant; +import java.util.Date; + +import static com.auth0.jwt.algorithms.Algorithm.HMAC256; +import static java.time.Instant.now; +import static java.time.temporal.ChronoUnit.HOURS; +import static java.time.temporal.ChronoUnit.MINUTES; +import static java.util.stream.IntStream.range; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowable; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +@RunWith(MockitoJUnitRunner.class) +public class AutorefreshingJwtAuthTokenGeneratorTest { + + @Mock private ServiceAuthTokenGenerator generator; + + private AutorefreshingJwtAuthTokenGenerator jwtAuthTokenGenerator; + + @Before + public void setUp() { + this.jwtAuthTokenGenerator = new AutorefreshingJwtAuthTokenGenerator(generator); + } + + @Test + public void should_request_new_token_on_from_passed_generator_on_first_usage() throws Exception { + // given + String tokenFromS2S = jwtTokenWithExpDate(now()); + + given(generator.generate()) + .willReturn(tokenFromS2S); + + // when + String token = jwtAuthTokenGenerator.generate(); + + // then + assertThat(token).isEqualTo(tokenFromS2S); + } + + @Test + public void should_not_request_new_token_if_cached_token_is_still_valid() throws Exception { + // given + given(generator.generate()) + .willReturn(jwtTokenWithExpDate(now().plus(2, HOURS))); + + // when + repeat(5, () -> jwtAuthTokenGenerator.generate()); + + // then + verify(generator, times(1)).generate(); + } + + @Test + public void should_request_new_token_once_it_expires() throws Exception { + // given + given(generator.generate()) + .willReturn(jwtTokenWithExpDate(now().minus(2, HOURS))); + + // when + repeat(3, () -> jwtAuthTokenGenerator.generate()); + + // then + verify(generator, times(3)).generate(); + } + + @Test + public void should_throw_an_exception_if_s2s_token_is_not_a_jwt_token() { + // given + given(generator.generate()) + .willReturn("clearly not a valid JWT token"); + + // when + Throwable exc = catchThrowable(() -> jwtAuthTokenGenerator.generate()); + + // then + assertThat(exc) + .isNotNull() + .isInstanceOf(JwtDecodingException.class); + } + + @Test + public void should_request_a_new_token_if_delta_is_larger_than_time_left_to_expiry_date() throws Exception { + // given + // retrieved token is valid for one more minute + given(generator.generate()) + .willReturn(jwtTokenWithExpDate(now().plus(1, MINUTES))); + + // but we want to refresh 2 minutes before it expires + AutorefreshingJwtAuthTokenGenerator jwtGenerator = new AutorefreshingJwtAuthTokenGenerator( + generator, + Duration.of(2, MINUTES) + ); + + // when + repeat(2, () -> jwtGenerator.generate()); + + // then + // it should request a new token + verify(generator, times(2)).generate(); + } + + private String jwtTokenWithExpDate(Instant expAtDate) throws Exception { + return JWT + .create() + .withExpiresAt(Date.from(expAtDate)) + .sign(HMAC256("secret")); + } + + private void repeat(int times, Runnable action) { + range(0, times).forEach(i -> action.run()); + } +}