Skip to content

Commit

Permalink
Merge pull request #246 from cryptomator/refresh-license
Browse files Browse the repository at this point in the history
Refresh hub license daily at 01:00 UTC
  • Loading branch information
SailReal authored Nov 28, 2023
2 parents bb7b860 + afe4fa7 commit 665b013
Show file tree
Hide file tree
Showing 4 changed files with 282 additions and 0 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import com.auth0.jwt.exceptions.JWTVerificationException;
import com.auth0.jwt.interfaces.Claim;
import com.auth0.jwt.interfaces.DecodedJWT;
import io.quarkus.scheduler.Scheduled;
import jakarta.annotation.PostConstruct;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
Expand All @@ -11,9 +12,19 @@
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.jboss.logging.Logger;

import java.io.IOException;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.stream.Collectors;

@ApplicationScoped
public class LicenseHolder {
Expand Down Expand Up @@ -75,6 +86,44 @@ public void set(String token) throws JWTVerificationException {
settings.persist();
}

/**
* Attempts to refresh the Hub licence every day at 01:00 UTC if claim refreshURL is present.
*/
@Scheduled(cron = "0 1 * * * ?", timeZone = "UTC")
void refreshLicenseScheduler() throws InterruptedException {
if (license != null) {
var refreshUrl = licenseValidator.refreshUrl(license.getToken());
if (refreshUrl.isPresent()) {
var client = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NORMAL).build();
refreshLicense(refreshUrl.get(), license.getToken(), client);
}
}
}

//visible for testing
void refreshLicense(String refreshUrl, String license, HttpClient client) throws InterruptedException {
var parameters = Map.of("token", license);
var body = parameters.entrySet() //
.stream() //
.map(e -> e.getKey() + "=" + URLEncoder.encode(e.getValue(), StandardCharsets.UTF_8)) //
.collect(Collectors.joining("&"));
var request = HttpRequest.newBuilder() //
.uri(URI.create(refreshUrl)) //
.headers("Content-Type", "application/x-www-form-urlencoded") //
.POST(HttpRequest.BodyPublishers.ofString(body)) //
.build();
try {
var response = client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 200 && !response.body().isEmpty()) {
set(response.body());
} else {
LOG.error("Failed to refresh license token with response code: " + response.statusCode());
}
} catch (IOException | JWTVerificationException e) {
LOG.error("Failed to refresh license token", e);
}
}

public DecodedJWT get() {
return license;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import java.security.spec.X509EncodedKeySpec;
import java.time.Instant;
import java.util.Objects;
import java.util.Optional;

@ApplicationScoped
public class LicenseValidator {
Expand Down Expand Up @@ -66,4 +67,9 @@ public DecodedJWT validate(String token, String expectedHubId) throws JWTVerific
return jwt;
}

public Optional<String> refreshUrl(String token) throws JWTVerificationException {
var jwt = verifier.verify(token);
return Optional.ofNullable(jwt.getClaim("refreshUrl").asString());
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,22 @@
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.mockito.MockedStatic;
import org.mockito.Mockito;

import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.Flow;

@QuarkusTest
public class LicenseHolderTest {
Expand Down Expand Up @@ -170,6 +182,198 @@ public void testSetInvalidToken() {
}
}

@Nested
@DisplayName("Testing refreshLicense() method of LicenseHolder")
class TestRefreshLicense {

private final String refreshURL = "https://foo.bar.baz/";

private final HttpRequest refreshRequst = HttpRequest.newBuilder() //
.uri(URI.create(refreshURL)) //
.headers("Content-Type", "application/x-www-form-urlencoded") //
.POST(HttpRequest.BodyPublishers.ofString("token=token")) //
.build();

@InjectMock
Session session;

@InjectMock
LicenseValidator validator;

MockedStatic<Settings> settingsClass;

@BeforeEach
public void setup() {
Query mockQuery = Mockito.mock(Query.class);
Mockito.doNothing().when(session).persist(Mockito.any());
Mockito.when(session.createQuery(Mockito.anyString())).thenReturn(mockQuery);
Mockito.when(mockQuery.getSingleResult()).thenReturn(0l);

settingsClass = Mockito.mockStatic(Settings.class);
}

@AfterEach
public void teardown() {
settingsClass.close();
Arc.container().instance(LicenseHolder.class).destroy();
}

@Test
@DisplayName("Refreshing a valid token validates and persists it to db")
public void testRefreshingExistingValidTokenInculdingRefreshURL() throws IOException, InterruptedException {
var existingJWT = Mockito.mock(DecodedJWT.class);
var receivedJWT = Mockito.mock(DecodedJWT.class);
Mockito.when(existingJWT.getToken()).thenReturn("token&foo=bar");
Mockito.when(validator.validate("token", "42")).thenReturn(receivedJWT);
Mockito.when(validator.validate("token&foo=bar", "42")).thenReturn(existingJWT);
Settings settingsMock = new Settings();
settingsMock.hubId = "42";
settingsMock.licenseKey = "token&foo=bar";
settingsClass.when(Settings::get).thenReturn(settingsMock);

var refreshTokenContainingSpecialChars = HttpRequest.newBuilder() //
.uri(URI.create(refreshURL)) //
.headers("Content-Type", "application/x-www-form-urlencoded") //
.POST(HttpRequest.BodyPublishers.ofString("token=token%26foo%3Dbar")) //
.build();

var httpClient = Mockito.mock(HttpClient.class);
var response = Mockito.mock(HttpResponse.class);
Mockito.doAnswer(invocation -> {
HttpRequest httpRequest = invocation.getArgument(0);
Assertions.assertEquals(refreshTokenContainingSpecialChars, httpRequest);
Assertions.assertEquals("token=token%26foo%3Dbar", getResponseFromRequest(httpRequest));
return response;
}).when(httpClient).send(Mockito.any(), Mockito.eq(HttpResponse.BodyHandlers.ofString()));
Mockito.when(response.body()).thenReturn("token");
Mockito.when(response.statusCode()).thenReturn(200);

holder.refreshLicense(refreshURL, existingJWT.getToken(), httpClient);

Mockito.verify(validator, Mockito.times(1)).validate("token", "42");
Mockito.verify(session, Mockito.times(1)).persist(Mockito.eq(settingsMock));
Assertions.assertEquals(receivedJWT, holder.get());
}

@ParameterizedTest(name = "Refreshing a valid token but receiving \"{0}\" with status code does \"{1}\" not persists it to db")
@CsvSource(value = {"invalidToken,200", "'',200", "validToken,500"})
public void testInvalidTokenReceivedLeadsToNoOp(String receivedToken, int receivedCode) throws IOException, InterruptedException {
var existingJWT = Mockito.mock(DecodedJWT.class);
var receivedJWT = Mockito.mock(DecodedJWT.class);
Mockito.when(existingJWT.getToken()).thenReturn("token");
Mockito.when(validator.validate("token", "42")).thenReturn(existingJWT);
if (receivedToken.equals("validToken")) {
Mockito.when(validator.validate(receivedToken, "42")).thenReturn(receivedJWT);
} else {
Mockito.when(validator.validate(receivedToken, "42")).thenAnswer(invocationOnMock -> {
throw new JWTVerificationException("");
});
}
Settings settingsMock = new Settings();
settingsMock.hubId = "42";
settingsMock.licenseKey = "token";
settingsClass.when(Settings::get).thenReturn(settingsMock);

var httpClient = Mockito.mock(HttpClient.class);
var response = Mockito.mock(HttpResponse.class);
Mockito.doAnswer(invocation -> {
HttpRequest httpRequest = invocation.getArgument(0);
Assertions.assertEquals(refreshRequst, httpRequest);
Assertions.assertEquals("token=token", getResponseFromRequest(httpRequest));
return response;
}).when(httpClient).send(Mockito.any(), Mockito.eq(HttpResponse.BodyHandlers.ofString()));
Mockito.when(response.body()).thenReturn(receivedToken);
Mockito.when(response.statusCode()).thenReturn(receivedCode);

holder.refreshLicense(refreshURL, existingJWT.getToken(), httpClient);

// init implicitly called due to @PostConstruct which increases the times to verify by 1
// See https://github.com/cryptomator/hub/pull/229#discussion_r1374694626 for further information
Mockito.verify(validator, Mockito.times(1)).validate("token", "42");
Mockito.verify(session, Mockito.never()).persist(Mockito.any());
Assertions.assertEquals(existingJWT, holder.get());
}

@Test
@DisplayName("Refreshing a valid token but IOException thrown does not persists it to db")
public void testCommunicationProblemLeadsToNoOp() throws IOException, InterruptedException {
var existingJWT = Mockito.mock(DecodedJWT.class);
Mockito.when(existingJWT.getToken()).thenReturn("token");
Mockito.when(validator.validate("token", "42")).thenReturn(existingJWT);
Settings settingsMock = new Settings();
settingsMock.hubId = "42";
settingsMock.licenseKey = "token";
settingsClass.when(Settings::get).thenReturn(settingsMock);

var httpClient = Mockito.mock(HttpClient.class);
Mockito.doAnswer(invocation -> {
HttpRequest httpRequest = invocation.getArgument(0);
Assertions.assertEquals(refreshRequst, httpRequest);
throw new IOException("Problem during communication");
}).when(httpClient).send(Mockito.any(), Mockito.eq(HttpResponse.BodyHandlers.ofString()));

holder.refreshLicense(refreshURL, existingJWT.getToken(), httpClient);

// init implicitly called due to @PostConstruct which increases the times to verify by 1
// See https://github.com/cryptomator/hub/pull/229#discussion_r1374694626 for further information
Mockito.verify(validator, Mockito.times(1)).validate("token", "42");
Mockito.verify(session, Mockito.never()).persist(Mockito.any());
Assertions.assertEquals(existingJWT, holder.get());
}

@Test
@DisplayName("Refreshing a valid token without refresh URL does not execute refreshLicense")
public void testNoOpExistingValidTokenExculdingRefreshURL() throws InterruptedException {
var existingJWT = Mockito.mock(DecodedJWT.class);
Mockito.when(validator.validate("token", "42")).thenReturn(existingJWT);
Mockito.when(validator.refreshUrl(existingJWT.getToken())).thenReturn(Optional.empty());
Settings settingsMock = new Settings();
settingsMock.hubId = "42";
settingsMock.licenseKey = "token";
settingsClass.when(Settings::get).thenReturn(settingsMock);

holder.refreshLicenseScheduler();

// init implicitly called due to @PostConstruct which increases the times to verify by 1
// See https://github.com/cryptomator/hub/pull/229#discussion_r1374694626 for further information
Mockito.verify(validator, Mockito.times(1)).validate(Mockito.any(), Mockito.any());
Mockito.verify(session, Mockito.never()).persist(Mockito.any());
Assertions.assertEquals(existingJWT, holder.get());
}

private String getResponseFromRequest(HttpRequest httpRequest) {
return httpRequest.bodyPublisher().map(p -> {
var bodySubscriber = HttpResponse.BodySubscribers.ofString(StandardCharsets.UTF_8);
var flowSubscriber = new StringSubscriber(bodySubscriber);
p.subscribe(flowSubscriber);
return bodySubscriber.getBody().toCompletableFuture().join();
}).get();
}

private record StringSubscriber(HttpResponse.BodySubscriber<String> wrapped) implements Flow.Subscriber<ByteBuffer> {

@Override
public void onSubscribe(Flow.Subscription subscription) {
wrapped.onSubscribe(subscription);
}

@Override
public void onNext(ByteBuffer item) {
wrapped.onNext(List.of(item));
}

@Override
public void onError(Throwable throwable) {
wrapped.onError(throwable);
}

@Override
public void onComplete() {
wrapped.onComplete();
}
}
}

@Nested
@TestProfile(LicenseHolderInitPropsTest.ValidInitPropsInstanceTestProfile.class)
@DisplayName("Testing LicenseHolder methods using InitProps")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import java.util.Optional;

public class LicenseValidatorTest {

private static final String VALID_TOKEN = "eyJhbGciOiJFUzUxMiJ9.eyJqdGkiOiI0MiIsImlhdCI6MTY0ODA0OTM2MCwiaXNzIjoiU2t5bWF0aWMiLCJhdWQiOiJDcnlwdG9tYXRvciBIdWIiLCJzdWIiOiJodWJAY3J5cHRvbWF0b3Iub3JnIiwic2VhdHMiOjUsImV4cCI6MjUzNDAyMjE0NDAwLCJyZWZyZXNoVXJsIjoiaHR0cDovL2xvY2FsaG9zdDo4Nzg3L2h1Yi9zdWJzY3JpcHRpb24_aHViX2lkPTQyIn0.AKyoZ0WQ8xhs8vPymWPHCsc6ch6pZpfxBcrF5QjVLSQVnYz2s5QF3nnkwn4AGR7V14TuhkJMZLUZxMdQAYLyL95sAV2Fu0E4-e1v3IVKlNKtze89eqYvEs6Ak9jWjtecOgPWNWjz2itI4MfJBDmbFtTnehOtqRqUdsDoC9NFik2C7tHm";
Expand Down Expand Up @@ -61,4 +63,25 @@ public void testValidateMalformedToken() {
});
}

@Test
@DisplayName("validate token's refreshURL")
public void testGetTokensRefreshUrl() {
Assertions.assertEquals(Optional.of("http://localhost:8787/hub/subscription?hub_id=42"), validator.refreshUrl(VALID_TOKEN));
}

@Test
@DisplayName("validate expired token's refreshURL")
public void testGetExpiredTokensRefreshUrl() {
// this should not throw an exception and return a JWT with an expired date
Assertions.assertEquals(Optional.of("http://localhost:8787/hub/subscription?hub_id=42"), validator.refreshUrl(EXPIRED_TOKEN));
}

@Test
@DisplayName("validate expired token's refreshURL with invalid signature")
public void testInvalidSignatureTokensRefreshUrl() {
Assertions.assertThrows(SignatureVerificationException.class, () -> {
validator.refreshUrl(TOKEN_WITH_INVALID_SIGNATURE);
});
}

}

0 comments on commit 665b013

Please sign in to comment.