Check auth validity on every sync

This commit is contained in:
Robert Carosi
2021-11-12 12:03:56 +01:00
parent 2b1b3a3235
commit 1209a64626
8 changed files with 87 additions and 79 deletions

View File

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

View File

@@ -7,7 +7,7 @@ plugins {
}
group = "nl.carosi"
version = "0.0.2"
version = "0.0.3"
java {
toolchain {

View File

@@ -8,7 +8,7 @@
<array>
<string>/bin/sh</string>
<string>-c</string>
<string>while ! /usr/local/bin/docker version > /dev/null 2>&amp;1; do sleep 5; done &amp;&amp; /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>&amp;1</string>
<string>while ! /usr/local/bin/docker version > /dev/null 2>&amp;1; do sleep 5; done &amp;&amp; /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>&amp;1</string>
</array>
<key>RunAtLoad</key>
<true/>

View File

@@ -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<Jrmapi> rmapi;
private final String deviceToken;
private Instant nextRefresh;
public AuthService(
ObjectMapper objectMapper,
AtomicReference<Jrmapi> 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) {}
}

View File

@@ -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

View File

@@ -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<Integer> {
@Option(
@@ -131,7 +131,7 @@ class SyncCommand implements Callable<Integer> {
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)

View File

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

View File

@@ -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<Jrmapi> rmapi;
private final String deviceToken;
private final Base64.Decoder decoder = Base64.getDecoder();
public TokenRefresher(
ObjectMapper objectMapper,
TaskScheduler scheduler,
AtomicReference<Jrmapi> 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) {}
}