// Java 11+ with Jackson 2.x databind. Server-side only. // Keep ONE instance per credential/audience for the application's lifetime. import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; 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.time.Clock; import java.time.Duration; import java.time.OffsetDateTime; import java.time.format.DateTimeParseException; import java.util.Map; import java.util.Objects; public final class VinqueryTokenCache { private final HttpClient http; private final ObjectMapper json = new ObjectMapper(); private final Map credentials; private final Clock clock; private String token; private long refreshAt; public VinqueryTokenCache(HttpClient http, String clientId, String clientSecret, String audience) { this(http, clientId, clientSecret, audience, Clock.systemUTC()); } // Package-private clock overload for offline tests. VinqueryTokenCache(HttpClient http, String clientId, String clientSecret, String audience, Clock clock) { for (String value : new String[] { clientId, clientSecret, audience }) { if (value == null || value.isBlank()) throw new IllegalArgumentException("Client ID, client secret and audience are required."); } this.http = Objects.requireNonNull(http); this.clock = Objects.requireNonNull(clock); this.credentials = Map.of("clientId", clientId, "clientSecret", clientSecret, "audience", audience); } public synchronized String getToken() throws IOException, InterruptedException { // Check under the same lock used for renewal and invalidation. if (token != null && clock.millis() < refreshAt) return token; HttpRequest request = HttpRequest.newBuilder(URI.create("https://identity.vinquery.com/connect/token")) .timeout(Duration.ofSeconds(30)) .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(json.writeValueAsString(credentials))) .build(); HttpResponse response = http.send(request, HttpResponse.BodyHandlers.ofString()); if (response.statusCode() < 200 || response.statusCode() >= 300) throw new IOException("VINquery token request failed (HTTP " + response.statusCode() + ")."); try { JsonNode result = json.readTree(response.body()); if (result == null || !result.path("jwtToken").isTextual() || !result.path("expiresUtc").isTextual()) throw new IllegalArgumentException(); String replacement = result.path("jwtToken").textValue(); long expiresAt = OffsetDateTime.parse(result.path("expiresUtc").textValue()).toInstant().toEpochMilli(); long now = clock.millis(); if (replacement.isBlank() || expiresAt <= now) throw new IllegalArgumentException(); long buffer = Math.min(60_000L, (expiresAt - now) / 10); token = replacement; refreshAt = expiresAt - buffer; return token; } catch (IOException | IllegalArgumentException | DateTimeParseException | ArithmeticException error) { // Do not include response bodies (which can contain tokens) in errors. throw new IOException("VINquery returned an empty token or invalid expiry."); } } public synchronized void invalidate(String rejectedToken) { if (token != null && token.equals(rejectedToken)) { token = null; refreshAt = 0; } } }