From 1209a64626475dd40d8c31a14f64ebf101aee4a7 Mon Sep 17 00:00:00 2001 From: Robert Carosi Date: Fri, 12 Nov 2021 12:03:56 +0100 Subject: [PATCH] Check auth validity on every sync --- README.md | 2 +- build.gradle | 2 +- nl.carosi.remarkable-pocket.plist | 2 +- .../carosi/remarkablepocket/AuthService.java | 77 +++++++++++++++++++ .../remarkablepocket/SyncApplication.java | 2 +- .../carosi/remarkablepocket/SyncCommand.java | 4 +- .../carosi/remarkablepocket/SyncService.java | 4 + .../remarkablepocket/TokenRefresher.java | 73 ------------------ 8 files changed, 87 insertions(+), 79 deletions(-) create mode 100644 src/main/java/nl/carosi/remarkablepocket/AuthService.java delete mode 100644 src/main/java/nl/carosi/remarkablepocket/TokenRefresher.java diff --git a/README.md b/README.md index dc5a2f4..7d625aa 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ An example run of the program can be found below: The easiest way to run the application is using Docker. First install Docker for your platform from https://docs.docker.com/get-docker/. Then run the following command to start the application on Linux or Mac (I have not tested it on Windows yet): ``` -touch ~/.remarkable-pocket && docker run -it --env TZ=$(date +%Z) -p 65112:65112 -v ~/.remarkable-pocket:/root/.remarkable-pocket ghcr.io/nov1n/remarkable-pocket:0.0.2 +touch ~/.remarkable-pocket && docker run -it --env TZ=$(date +%Z) -p 65112:65112 -v ~/.remarkable-pocket:/root/.remarkable-pocket ghcr.io/nov1n/remarkable-pocket:0.0.3 ``` The first time you run the application, you will be asked to authorize Pocket and Remarkable Cloud. Once you have done this subsequent runs will read the credentials from the `~/.remarkable-pocket` file. diff --git a/build.gradle b/build.gradle index 1c20d60..ba4a1fd 100644 --- a/build.gradle +++ b/build.gradle @@ -7,7 +7,7 @@ plugins { } group = "nl.carosi" -version = "0.0.2" +version = "0.0.3" java { toolchain { diff --git a/nl.carosi.remarkable-pocket.plist b/nl.carosi.remarkable-pocket.plist index feb2681..2d59eea 100644 --- a/nl.carosi.remarkable-pocket.plist +++ b/nl.carosi.remarkable-pocket.plist @@ -8,7 +8,7 @@ /bin/sh -c - while ! /usr/local/bin/docker version > /dev/null 2>&1; do sleep 5; done && /usr/local/bin/docker run --env TZ=$(date +%Z) -v ~/.remarkable-pocket:/root/.remarkable-pocket ghcr.io/nov1n/remarkable-pocket:0.0.2 1>>$HOME/.remarkable-pocket.log 2>&1 + while ! /usr/local/bin/docker version > /dev/null 2>&1; do sleep 5; done && /usr/local/bin/docker run --env TZ=$(date +%Z) -v ~/.remarkable-pocket:/root/.remarkable-pocket ghcr.io/nov1n/remarkable-pocket:0.0.3 1>>$HOME/.remarkable-pocket.log 2>&1 RunAtLoad diff --git a/src/main/java/nl/carosi/remarkablepocket/AuthService.java b/src/main/java/nl/carosi/remarkablepocket/AuthService.java new file mode 100644 index 0000000..b5c7a6a --- /dev/null +++ b/src/main/java/nl/carosi/remarkablepocket/AuthService.java @@ -0,0 +1,77 @@ +package nl.carosi.remarkablepocket; + +import static java.time.temporal.ChronoUnit.MINUTES; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import es.jlarriba.jrmapi.Jrmapi; +import java.lang.reflect.Field; +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.time.format.FormatStyle; +import java.util.Base64; +import java.util.concurrent.atomic.AtomicReference; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; + +// Hacky, but unfortunately jrmapi does not refresh bearer tokens. +final class AuthService { + private static final Logger LOG = LoggerFactory.getLogger(AuthService.class); + private static final Base64.Decoder B64_DECODER = Base64.getDecoder(); + private static final DateTimeFormatter TEMPORAL_FORMATTER = + DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT) + .withZone(ZoneId.systemDefault()); + + private final ObjectMapper objectMapper; + private final AtomicReference rmapi; + private final String deviceToken; + private Instant nextRefresh; + + public AuthService( + ObjectMapper objectMapper, + AtomicReference rmapi, + @Value("${rm.device-token}") String deviceToken) { + this.objectMapper = objectMapper; + this.rmapi = rmapi; + this.deviceToken = deviceToken; + } + + void ensureValid() { + if (nextRefresh != null && Instant.now().isBefore(nextRefresh)) { + return; + } + + try { + refreshToken(); + } catch (NoSuchFieldException | IllegalAccessException | JsonProcessingException e) { + LOG.debug("Exception occurred while refreshing auth token:", e); + throw new RuntimeException("Could not refresh auth token."); + } + } + + private void refreshToken() + throws NoSuchFieldException, IllegalAccessException, JsonProcessingException { + Field tokenField = Jrmapi.class.getDeclaredField("userToken"); + tokenField.setAccessible(true); + String token = (String) tokenField.get(rmapi.get()); + Instant exp = getExpiration(token); + nextRefresh = exp.minus(30, MINUTES); + rmapi.set(new Jrmapi(deviceToken)); + LOG.debug( + "Refreshed jrmapi token. Valid until: {}, refreshing at {}.", + TEMPORAL_FORMATTER.format(exp), + TEMPORAL_FORMATTER.format(nextRefresh)); + } + + private Instant getExpiration(String token) throws JsonProcessingException { + String[] chunks = token.split("\\."); + String payload = new String(B64_DECODER.decode(chunks[1])); + return objectMapper.readValue(payload, JWT.class).exp(); + } + + @JsonIgnoreProperties(ignoreUnknown = true) + private record JWT(Instant exp) {} +} diff --git a/src/main/java/nl/carosi/remarkablepocket/SyncApplication.java b/src/main/java/nl/carosi/remarkablepocket/SyncApplication.java index 7fe449f..e653730 100644 --- a/src/main/java/nl/carosi/remarkablepocket/SyncApplication.java +++ b/src/main/java/nl/carosi/remarkablepocket/SyncApplication.java @@ -19,6 +19,7 @@ import org.springframework.scheduling.annotation.EnableScheduling; @EnableRetry @Import({ ArticleDownloader.class, + AuthService.class, DownloadService.class, EpubReader.class, EpubWriter.class, @@ -26,7 +27,6 @@ import org.springframework.scheduling.annotation.EnableScheduling; PocketService.class, RemarkableService.class, SyncService.class, - TokenRefresher.class }) public class SyncApplication { @Bean diff --git a/src/main/java/nl/carosi/remarkablepocket/SyncCommand.java b/src/main/java/nl/carosi/remarkablepocket/SyncCommand.java index 72a5e8e..5c2edad 100644 --- a/src/main/java/nl/carosi/remarkablepocket/SyncCommand.java +++ b/src/main/java/nl/carosi/remarkablepocket/SyncCommand.java @@ -39,7 +39,7 @@ import picocli.CommandLine.Option; sortOptions = false, usageHelpAutoWidth = true, // TODO: Read from gradle.properties - version = "0.0.2", + version = "0.0.3", mixinStandardHelpOptions = true) class SyncCommand implements Callable { @Option( @@ -131,7 +131,7 @@ class SyncCommand implements Callable { entry("sync.interval", "PT" + interval), entry("sync.run-once", Boolean.toString(runOnce)), entry("pocket.tag-filter", tagFilter), - entry("logging.level" + this.getClass().getPackageName(), verbose ? "DEBUG" : "INFO") + entry("logging.level." + this.getClass().getPackageName(), verbose ? "DEBUG" : "INFO") ); new SpringApplicationBuilder(SyncApplication.class) diff --git a/src/main/java/nl/carosi/remarkablepocket/SyncService.java b/src/main/java/nl/carosi/remarkablepocket/SyncService.java index a2323c9..0319953 100644 --- a/src/main/java/nl/carosi/remarkablepocket/SyncService.java +++ b/src/main/java/nl/carosi/remarkablepocket/SyncService.java @@ -24,6 +24,7 @@ final class SyncService { private final PocketService pocketService; private final DownloadService downloadService; private final RemarkableService remarkableService; + private final AuthService authService; private final ApplicationContext appContext; private final int articleLimit; private final boolean archiveRead; @@ -34,6 +35,7 @@ final class SyncService { PocketService pocketService, DownloadService downloadService, RemarkableService remarkableService, + AuthService authService, ApplicationContext appContext, @Value("${rm.article-limit}") int articleLimit, @Value("${pocket.archive-read}") boolean archiveRead, @@ -42,6 +44,7 @@ final class SyncService { this.pocketService = pocketService; this.downloadService = downloadService; this.remarkableService = remarkableService; + this.authService = authService; this.appContext = appContext; this.articleLimit = articleLimit; this.archiveRead = archiveRead; @@ -60,6 +63,7 @@ final class SyncService { @Scheduled(fixedDelayString = "${sync.interval}") void sync() { ensureConnected(LOG::error); + authService.ensureValid(); try { syncImpl(); diff --git a/src/main/java/nl/carosi/remarkablepocket/TokenRefresher.java b/src/main/java/nl/carosi/remarkablepocket/TokenRefresher.java deleted file mode 100644 index 7467875..0000000 --- a/src/main/java/nl/carosi/remarkablepocket/TokenRefresher.java +++ /dev/null @@ -1,73 +0,0 @@ -package nl.carosi.remarkablepocket; - -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import es.jlarriba.jrmapi.Jrmapi; -import java.lang.reflect.Field; -import java.time.Instant; -import java.util.Base64; -import java.util.concurrent.atomic.AtomicReference; -import javax.annotation.PostConstruct; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.scheduling.TaskScheduler; - -// Hacky, but unfortunately jrmapi does not refresh bearer tokens. -final class TokenRefresher { - private static final Logger LOG = LoggerFactory.getLogger(TokenRefresher.class); - - private final ObjectMapper objectMapper; - private final TaskScheduler scheduler; - private final AtomicReference rmapi; - private final String deviceToken; - private final Base64.Decoder decoder = Base64.getDecoder(); - - public TokenRefresher( - ObjectMapper objectMapper, - TaskScheduler scheduler, - AtomicReference rmapi, - @Value("${rm.device-token}") String deviceToken) { - this.objectMapper = objectMapper; - this.scheduler = scheduler; - this.rmapi = rmapi; - this.deviceToken = deviceToken; - } - - @PostConstruct - void scheduleRefresh() { - try { - scheduleRefreshImpl(); - } catch (NoSuchFieldException | IllegalAccessException | JsonProcessingException e) { - throw new RuntimeException("Could not refresh jrmapi token."); - } - } - - private void scheduleRefreshImpl() - throws NoSuchFieldException, IllegalAccessException, JsonProcessingException { - Field tokenField = Jrmapi.class.getDeclaredField("userToken"); - tokenField.setAccessible(true); - String token = (String) tokenField.get(rmapi.get()); - int exp = getExpiration(token); - Instant nextRefresh = Instant.ofEpochSecond(exp - 60 * 10); // 10 min margin. - scheduler.schedule(this::refresh, nextRefresh); - LOG.debug("Next token refresh scheduled at: {}.", nextRefresh); - } - - private void refresh() { - rmapi.set(new Jrmapi(deviceToken)); - LOG.debug("Refreshed jrmapi token."); - scheduleRefresh(); - } - - private int getExpiration(String jwt) throws JsonProcessingException { - String[] chunks = jwt.split("\\."); - String payload = new String(decoder.decode(chunks[1])); - JWT exp = objectMapper.readValue(payload, JWT.class); - return exp.exp(); - } - - @JsonIgnoreProperties(ignoreUnknown = true) - private record JWT(int exp) {} -}