From 93888025990d159bbb359a7605dc4115359fd4f9 Mon Sep 17 00:00:00 2001 From: Rain Ramm Date: Fri, 10 Apr 2026 07:58:07 +0000 Subject: [PATCH 1/2] Add JWT authentication for Montonio Stargate API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement token generation and request signing using com.auth0:java-jwt. GET requests attach a cached Bearer token; POST requests wrap the payload as signed JWT claims in {"data":""} per the Montonio API spec. MontonioTokenProvider is thread-safe and supports injectable Clock for testing. Closes #12 Related: #27 (webhook/return JWT validation — follow-up) Co-Authored-By: Claude Opus 4.6 (1M context) --- build.gradle | 1 + .../2026-04-10-jwt-authentication-design.md | 165 ++++++++ .../sdk/auth/MontonioTokenProvider.java | 119 ++++++ .../montonio/sdk/http/MontonioHttpClient.java | 18 +- .../sdk/auth/MontonioTokenProviderTest.java | 370 ++++++++++++++++++ .../sdk/http/MontonioHttpClientTest.java | 104 ++++- 6 files changed, 758 insertions(+), 19 deletions(-) create mode 100644 docs/plans/2026-04-10-jwt-authentication-design.md create mode 100644 src/main/java/ee/bitweb/montonio/sdk/auth/MontonioTokenProvider.java create mode 100644 src/test/java/ee/bitweb/montonio/sdk/auth/MontonioTokenProviderTest.java diff --git a/build.gradle b/build.gradle index 88f2c57..c030e2c 100644 --- a/build.gradle +++ b/build.gradle @@ -30,6 +30,7 @@ repositories { dependencies { implementation 'tools.jackson.core:jackson-databind:3.0.1' + implementation 'com.auth0:java-jwt:4.4.0' testImplementation platform('org.junit:junit-bom:5.14.3') testImplementation 'org.junit.jupiter:junit-jupiter' diff --git a/docs/plans/2026-04-10-jwt-authentication-design.md b/docs/plans/2026-04-10-jwt-authentication-design.md new file mode 100644 index 0000000..da57dda --- /dev/null +++ b/docs/plans/2026-04-10-jwt-authentication-design.md @@ -0,0 +1,165 @@ +# JWT Authentication Design + +**Issue:** #12 — JWT authentication: token generation and request signing +**Date:** 2026-04-10 +**Status:** Accepted + +## Overview + +Implement JWT (HS256) authentication for the Montonio Stargate API. The API uses two distinct authentication modes: + +- **GET requests** — JWT sent as a Bearer token in the `Authorization` header. Token contains only `accessKey`, `iat`, and `exp`. Cacheable and reusable across requests. +- **POST requests** — the request payload is serialized into JWT claims alongside `accessKey`, `iat`, and `exp`. The signed JWT is sent as the HTTP body wrapped in `{"data": ""}`. Unique per request. + +## Dependencies + +**New dependency:** + +```groovy +implementation 'com.auth0:java-jwt:4.4.0' +``` + +Chosen over JJWT because it is a single artifact with no Jackson version conflicts (the SDK uses Jackson 3.x; JJWT's Jackson module requires Jackson 2.x). + +## Architecture + +### New class: `MontonioTokenProvider` + +**Package:** `ee.bitweb.montonio.sdk.auth` + +Responsible for all JWT generation. Thread-safe via `synchronized` blocks. Injected into `MontonioHttpClient`. + +```java +package ee.bitweb.montonio.sdk.auth; + +public class MontonioTokenProvider { + + public MontonioTokenProvider( + MontonioSdkConfiguration configuration, + ObjectMapper objectMapper + ); + + // For testing — injectable clock + public MontonioTokenProvider( + MontonioSdkConfiguration configuration, + ObjectMapper objectMapper, + Clock clock + ); + + // GET requests — cached, regenerates when expiring within 30s + public String getAuthToken(); + + // POST requests — serializes body into JWT claims, no caching + public String getDataToken(Object body); +} +``` + +**Internal state (guarded by `synchronized`):** +- `cachedToken` — current GET auth token string +- `cachedTokenExpiry` — `Instant` when the cached token expires + +**`getAuthToken()` flow:** +1. Acquire lock (`synchronized(this)`) +2. If `cachedToken` is non-null and `cachedTokenExpiry` is more than 30 seconds from now, return `cachedToken` +3. Otherwise: compute `iat` (now), `exp` (now + `tokenExpirationTime`), sign with HS256 using `secretKey` +4. Cache token and expiry, return + +**`getDataToken(Object body)` flow:** +1. Use `objectMapper.convertValue(body, Map)` to flatten the request object +2. Add `accessKey`, `iat`, `exp` to the claims map +3. Sign with HS256 using `secretKey` +4. Return the JWT string (no caching) + +### Changes to `MontonioHttpClient` + +**Constructors:** + +```java +// Public — creates its own TokenProvider +public MontonioHttpClient(MontonioSdkConfiguration configuration); + +// Package-private — for testing with injected dependencies +MontonioHttpClient( + MontonioSdkConfiguration configuration, + HttpClient httpClient, + MontonioTokenProvider tokenProvider +); +``` + +The `ObjectMapper` is created once in `MontonioHttpClient` and shared with `MontonioTokenProvider`. + +**GET flow:** + +```java +public T get(String path, Class responseType) { + HttpRequest request = newRequestBuilder(path) + .header("Authorization", "Bearer " + tokenProvider.getAuthToken()) + .GET() + .build(); + return execute(request, responseType); +} +``` + +**POST flow:** + +```java +public T post(String path, Object body, Class responseType) { + String token = tokenProvider.getDataToken(body); + String json = serialize(Map.of("data", token)); + + HttpRequest request = newRequestBuilder(path) + .POST(HttpRequest.BodyPublishers.ofString(json)) + .build(); + return execute(request, responseType); +} +``` + +The public API (`get()` and `post()`) remains unchanged for SDK consumers — JWT handling is fully internal. + +## Error Handling + +Token generation failures throw `MontonioAuthenticationException` (already exists): + +- `getAuthToken()` — signing failure: `"Failed to generate auth token"` +- `getDataToken()` — body conversion failure: `"Failed to serialize request body for signing"`, signing failure: `"Failed to generate data token"` + +No retry logic — these are local CPU operations, so failure indicates a configuration problem (bad key), not a transient issue. No new exception classes needed. + +## Thread Safety + +- `synchronized(this)` guards all reads/writes to `cachedToken` and `cachedTokenExpiry` +- Token generation (HMAC-SHA256) takes microseconds, so lock contention is negligible +- `getDataToken()` does not touch cached state, so it only synchronizes if sharing mutable resources + +## Testing Strategy + +### `MontonioTokenProviderTest` + +- **Token structure** — decode generated JWT, verify `alg: HS256`, `typ: JWT` headers +- **Auth token claims** — verify `accessKey`, `iat`, `exp` present and correct +- **Data token claims** — verify request body fields as top-level claims alongside `accessKey`, `iat`, `exp` +- **Nested objects** — verify complex bodies are correctly represented in claims +- **Expiration** — use injected `Clock` to verify `exp` = `iat` + configured `tokenExpirationTime` +- **Caching** — call `getAuthToken()` twice quickly, verify same string returned +- **Cache renewal** — advance `Clock` past buffer window, verify new token generated +- **Thread safety** — concurrent threads calling `getAuthToken()`, verify no exceptions and all receive valid tokens +- **Invalid secret key** — verify `MontonioAuthenticationException` thrown +- **Signature verification** — decode token with same secret using auth0 verifier, confirm valid + +### `MontonioHttpClientTest` updates + +- **GET requests** — verify `Authorization: Bearer ` header present +- **POST requests** — verify body is `{"data": ""}`, decode JWT and confirm original request fields plus `accessKey`/`exp`/`iat` +- Uses injected `MontonioTokenProvider` with test `Clock` for deterministic assertions + +No integration tests in this scope — JWT generation is entirely local. + +## Decisions log + +| Decision | Choice | Alternatives considered | +|---|---|---| +| JWT library | `com.auth0:java-jwt` | JJWT (Jackson 3.x conflict), manual JDK implementation (maintenance burden) | +| Architecture | Dedicated `MontonioTokenProvider` in `auth` package | Inline in HttpClient (mixed concerns), interceptor pattern (over-engineered) | +| POST body serialization | Jackson `convertValue` to flat map, merge auth claims | Nested `data` wrapper claim (doesn't match API expectations) | +| GET token caching | Lazy with 30s buffer | Background `ScheduledExecutorService` renewal (unnecessary complexity) | +| Thread safety | `synchronized` block | `ReadWriteLock`, volatile + DCL, `AtomicReference` + CAS (all overkill for microsecond operations) | diff --git a/src/main/java/ee/bitweb/montonio/sdk/auth/MontonioTokenProvider.java b/src/main/java/ee/bitweb/montonio/sdk/auth/MontonioTokenProvider.java new file mode 100644 index 0000000..80d87a2 --- /dev/null +++ b/src/main/java/ee/bitweb/montonio/sdk/auth/MontonioTokenProvider.java @@ -0,0 +1,119 @@ +package ee.bitweb.montonio.sdk.auth; + +import com.auth0.jwt.JWT; +import com.auth0.jwt.algorithms.Algorithm; +import com.auth0.jwt.exceptions.JWTCreationException; +import ee.bitweb.montonio.sdk.MontonioSdkConfiguration; +import ee.bitweb.montonio.sdk.exception.MontonioAuthenticationException; +import tools.jackson.databind.ObjectMapper; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Map; + +public class MontonioTokenProvider { + + private static final Duration RENEWAL_BUFFER = Duration.ofSeconds(30); + + private final MontonioSdkConfiguration configuration; + private final ObjectMapper objectMapper; + private final Clock clock; + private final Algorithm algorithm; + + private String cachedToken; + private Instant cachedTokenExpiry; + + public MontonioTokenProvider(MontonioSdkConfiguration configuration, ObjectMapper objectMapper) { + this(configuration, objectMapper, Clock.systemUTC()); + } + + public MontonioTokenProvider(MontonioSdkConfiguration configuration, ObjectMapper objectMapper, Clock clock) { + this.configuration = configuration; + this.objectMapper = objectMapper; + this.clock = clock; + this.algorithm = Algorithm.HMAC256(configuration.getSecretKey()); + } + + public synchronized String getAuthToken() { + Instant now = clock.instant(); + + if (cachedToken != null && cachedTokenExpiry != null + && now.plus(RENEWAL_BUFFER).isBefore(cachedTokenExpiry)) { + return cachedToken; + } + + Instant exp = now.plus(configuration.getTokenExpirationTime()); + + try { + cachedToken = JWT.create() + .withClaim("accessKey", configuration.getAccessKey()) + .withIssuedAt(now) + .withExpiresAt(exp) + .sign(algorithm); + cachedTokenExpiry = exp; + } catch (JWTCreationException e) { + throw new MontonioAuthenticationException("Failed to generate auth token", e); + } + + return cachedToken; + } + + public String getDataToken(Object body) { + Map claims = convertToClaimsMap(body); + + Instant now = clock.instant(); + Instant exp = now.plus(configuration.getTokenExpirationTime()); + + try { + var builder = JWT.create() + .withClaim("accessKey", configuration.getAccessKey()) + .withIssuedAt(now) + .withExpiresAt(exp); + + addClaimsFromMap(builder, claims); + + return builder.sign(algorithm); + } catch (JWTCreationException e) { + throw new MontonioAuthenticationException("Failed to generate data token", e); + } + } + + @SuppressWarnings("unchecked") + private Map convertToClaimsMap(Object body) { + try { + return objectMapper.convertValue(body, Map.class); + } catch (Exception e) { + throw new MontonioAuthenticationException("Failed to serialize request body for signing", e); + } + } + + @SuppressWarnings("unchecked") + private void addClaimsFromMap(com.auth0.jwt.JWTCreator.Builder builder, Map claims) { + for (Map.Entry entry : claims.entrySet()) { + String key = entry.getKey(); + Object value = entry.getValue(); + + if (value == null) { + builder.withNullClaim(key); + } else if (value instanceof String s) { + builder.withClaim(key, s); + } else if (value instanceof Integer i) { + builder.withClaim(key, i); + } else if (value instanceof Long l) { + builder.withClaim(key, l); + } else if (value instanceof Double d) { + builder.withClaim(key, d); + } else if (value instanceof Boolean b) { + builder.withClaim(key, b); + } else if (value instanceof Map) { + builder.withClaim(key, (Map) value); + } else if (value instanceof List) { + builder.withClaim(key, (List) value); + } else { + builder.withClaim(key, value.toString()); + } + } + } +} diff --git a/src/main/java/ee/bitweb/montonio/sdk/http/MontonioHttpClient.java b/src/main/java/ee/bitweb/montonio/sdk/http/MontonioHttpClient.java index 6438aeb..3dda401 100644 --- a/src/main/java/ee/bitweb/montonio/sdk/http/MontonioHttpClient.java +++ b/src/main/java/ee/bitweb/montonio/sdk/http/MontonioHttpClient.java @@ -1,6 +1,7 @@ package ee.bitweb.montonio.sdk.http; import ee.bitweb.montonio.sdk.MontonioSdkConfiguration; +import ee.bitweb.montonio.sdk.auth.MontonioTokenProvider; import ee.bitweb.montonio.sdk.exception.MontonioApiException; import ee.bitweb.montonio.sdk.exception.MontonioException; import ee.bitweb.montonio.sdk.exception.MontonioNetworkException; @@ -14,12 +15,14 @@ import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; +import java.util.Map; public class MontonioHttpClient { private final HttpClient httpClient; private final ObjectMapper objectMapper; private final MontonioSdkConfiguration configuration; + private final MontonioTokenProvider tokenProvider; public MontonioHttpClient(MontonioSdkConfiguration configuration) { this( @@ -36,10 +39,22 @@ public MontonioHttpClient(MontonioSdkConfiguration configuration) { this.objectMapper = JsonMapper.builder() .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) .build(); + this.tokenProvider = new MontonioTokenProvider(configuration, objectMapper); + } + + MontonioHttpClient(MontonioSdkConfiguration configuration, HttpClient httpClient, + MontonioTokenProvider tokenProvider) { + this.configuration = configuration; + this.httpClient = httpClient; + this.objectMapper = JsonMapper.builder() + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .build(); + this.tokenProvider = tokenProvider; } public T get(String path, Class responseType) { HttpRequest request = newRequestBuilder(path) + .header("Authorization", "Bearer " + tokenProvider.getAuthToken()) .GET() .build(); @@ -47,7 +62,8 @@ public T get(String path, Class responseType) { } public T post(String path, Object body, Class responseType) { - String json = serialize(body); + String token = tokenProvider.getDataToken(body); + String json = serialize(Map.of("data", token)); HttpRequest request = newRequestBuilder(path) .POST(HttpRequest.BodyPublishers.ofString(json)) diff --git a/src/test/java/ee/bitweb/montonio/sdk/auth/MontonioTokenProviderTest.java b/src/test/java/ee/bitweb/montonio/sdk/auth/MontonioTokenProviderTest.java new file mode 100644 index 0000000..593e6c6 --- /dev/null +++ b/src/test/java/ee/bitweb/montonio/sdk/auth/MontonioTokenProviderTest.java @@ -0,0 +1,370 @@ +package ee.bitweb.montonio.sdk.auth; + +import com.auth0.jwt.JWT; +import com.auth0.jwt.algorithms.Algorithm; +import com.auth0.jwt.interfaces.DecodedJWT; +import ee.bitweb.montonio.sdk.MontonioSdkConfiguration; +import ee.bitweb.montonio.sdk.exception.MontonioAuthenticationException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import tools.jackson.databind.DeserializationFeature; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import static org.junit.jupiter.api.Assertions.*; + +class MontonioTokenProviderTest { + + private static final String ACCESS_KEY = "test-access-key"; + private static final String SECRET_KEY = "test-secret-key-that-is-long-enough"; + private static final Instant FIXED_NOW = Instant.parse("2026-01-15T10:00:00Z"); + private static final Duration TOKEN_EXPIRATION = Duration.ofMinutes(5); + + private MontonioSdkConfiguration configuration; + private ObjectMapper objectMapper; + private Clock fixedClock; + + @BeforeEach + void setUp() { + configuration = MontonioSdkConfiguration.builder() + .accessKey(ACCESS_KEY) + .secretKey(SECRET_KEY) + .tokenExpirationTime(TOKEN_EXPIRATION) + .build(); + + objectMapper = JsonMapper.builder() + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .build(); + + fixedClock = Clock.fixed(FIXED_NOW, ZoneOffset.UTC); + } + + @Test + void authTokenHasCorrectJwtHeaders() { + MontonioTokenProvider provider = new MontonioTokenProvider(configuration, objectMapper, fixedClock); + + String token = provider.getAuthToken(); + + DecodedJWT decoded = JWT.decode(token); + assertEquals("HS256", decoded.getAlgorithm()); + assertEquals("JWT", decoded.getType()); + } + + @Test + void authTokenContainsAccessKeyClaim() { + MontonioTokenProvider provider = new MontonioTokenProvider(configuration, objectMapper, fixedClock); + + String token = provider.getAuthToken(); + + DecodedJWT decoded = JWT.decode(token); + assertEquals(ACCESS_KEY, decoded.getClaim("accessKey").asString()); + } + + @Test + void authTokenHasCorrectIssuedAtAndExpiration() { + MontonioTokenProvider provider = new MontonioTokenProvider(configuration, objectMapper, fixedClock); + + String token = provider.getAuthToken(); + + DecodedJWT decoded = JWT.decode(token); + assertEquals(FIXED_NOW.getEpochSecond(), decoded.getIssuedAt().toInstant().getEpochSecond()); + assertEquals( + FIXED_NOW.plus(TOKEN_EXPIRATION).getEpochSecond(), + decoded.getExpiresAt().toInstant().getEpochSecond() + ); + } + + @Test + void authTokenSignatureIsVerifiable() { + MontonioTokenProvider provider = new MontonioTokenProvider(configuration, objectMapper, fixedClock); + + String token = provider.getAuthToken(); + + Algorithm algorithm = Algorithm.HMAC256(SECRET_KEY); + DecodedJWT verified = JWT.require(algorithm) + .acceptExpiresAt(365 * 24 * 3600) + .build() + .verify(token); + assertEquals(ACCESS_KEY, verified.getClaim("accessKey").asString()); + } + + @Test + void authTokenIsCachedWhenNotExpired() { + MontonioTokenProvider provider = new MontonioTokenProvider(configuration, objectMapper, fixedClock); + + String first = provider.getAuthToken(); + String second = provider.getAuthToken(); + + assertSame(first, second); + } + + @Test + void authTokenIsRenewedWhenWithinBufferWindow() { + Instant initialTime = FIXED_NOW; + // Advance clock to 4 minutes 35 seconds later — within 30s of the 5-minute expiry + Instant nearExpiry = initialTime.plus(Duration.ofMinutes(4)).plus(Duration.ofSeconds(35)); + + MutableClock clock = new MutableClock(initialTime); + MontonioTokenProvider provider = new MontonioTokenProvider(configuration, objectMapper, clock); + + String first = provider.getAuthToken(); + + clock.setInstant(nearExpiry); + String second = provider.getAuthToken(); + + assertNotEquals(first, second); + } + + @Test + void authTokenIsNotRenewedWhenOutsideBufferWindow() { + Instant initialTime = FIXED_NOW; + // Advance clock to 4 minutes — still well before the 30s buffer + Instant withinValidity = initialTime.plus(Duration.ofMinutes(4)); + + MutableClock clock = new MutableClock(initialTime); + MontonioTokenProvider provider = new MontonioTokenProvider(configuration, objectMapper, clock); + + String first = provider.getAuthToken(); + + clock.setInstant(withinValidity); + String second = provider.getAuthToken(); + + assertSame(first, second); + } + + @Test + void dataTokenContainsRequestBodyFieldsAsTopLevelClaims() { + MontonioTokenProvider provider = new MontonioTokenProvider(configuration, objectMapper, fixedClock); + + Map body = Map.of( + "merchantReference", "ORDER-123", + "grandTotal", 99.99, + "currency", "EUR" + ); + + String token = provider.getDataToken(body); + + DecodedJWT decoded = JWT.decode(token); + assertEquals("ORDER-123", decoded.getClaim("merchantReference").asString()); + assertEquals(99.99, decoded.getClaim("grandTotal").asDouble()); + assertEquals("EUR", decoded.getClaim("currency").asString()); + } + + @Test + void dataTokenContainsAccessKeyAndTimestamps() { + MontonioTokenProvider provider = new MontonioTokenProvider(configuration, objectMapper, fixedClock); + + String token = provider.getDataToken(Map.of("key", "value")); + + DecodedJWT decoded = JWT.decode(token); + assertEquals(ACCESS_KEY, decoded.getClaim("accessKey").asString()); + assertEquals(FIXED_NOW.getEpochSecond(), decoded.getIssuedAt().toInstant().getEpochSecond()); + assertEquals( + FIXED_NOW.plus(TOKEN_EXPIRATION).getEpochSecond(), + decoded.getExpiresAt().toInstant().getEpochSecond() + ); + } + + @Test + void dataTokenHandlesNestedObjects() { + MontonioTokenProvider provider = new MontonioTokenProvider(configuration, objectMapper, fixedClock); + + Map body = Map.of( + "merchantReference", "ORDER-123", + "billingAddress", Map.of( + "firstName", "John", + "lastName", "Doe", + "country", "EE" + ) + ); + + String token = provider.getDataToken(body); + + DecodedJWT decoded = JWT.decode(token); + assertEquals("ORDER-123", decoded.getClaim("merchantReference").asString()); + Map address = decoded.getClaim("billingAddress").asMap(); + assertEquals("John", address.get("firstName")); + assertEquals("Doe", address.get("lastName")); + assertEquals("EE", address.get("country")); + } + + @Test + void dataTokenHandlesListClaims() { + MontonioTokenProvider provider = new MontonioTokenProvider(configuration, objectMapper, fixedClock); + + Map body = Map.of( + "lineItems", List.of( + Map.of("name", "Hoverboard", "quantity", 1, "finalPrice", 99.99) + ) + ); + + String token = provider.getDataToken(body); + + DecodedJWT decoded = JWT.decode(token); + List items = decoded.getClaim("lineItems").asList(Object.class); + assertNotNull(items); + assertEquals(1, items.size()); + } + + @Test + void dataTokenSignatureIsVerifiable() { + MontonioTokenProvider provider = new MontonioTokenProvider(configuration, objectMapper, fixedClock); + + String token = provider.getDataToken(Map.of("key", "value")); + + Algorithm algorithm = Algorithm.HMAC256(SECRET_KEY); + assertDoesNotThrow(() -> JWT.require(algorithm) + .acceptExpiresAt(365 * 24 * 3600) + .build() + .verify(token)); + } + + @Test + void dataTokenIsNotCached() { + MontonioTokenProvider provider = new MontonioTokenProvider(configuration, objectMapper, fixedClock); + + String first = provider.getDataToken(Map.of("key", "value1")); + String second = provider.getDataToken(Map.of("key", "value2")); + + assertNotEquals(first, second); + } + + @Test + void dataTokenWithTypedObjectConvertsToClaimsCorrectly() { + MontonioTokenProvider provider = new MontonioTokenProvider(configuration, objectMapper, fixedClock); + + TestOrderRequest order = new TestOrderRequest(); + order.merchantReference = "ORDER-456"; + order.grandTotal = 150.0; + order.currency = "EUR"; + + String token = provider.getDataToken(order); + + DecodedJWT decoded = JWT.decode(token); + assertEquals("ORDER-456", decoded.getClaim("merchantReference").asString()); + assertEquals(150.0, decoded.getClaim("grandTotal").asDouble()); + assertEquals("EUR", decoded.getClaim("currency").asString()); + assertEquals(ACCESS_KEY, decoded.getClaim("accessKey").asString()); + } + + @Test + void nonSerializableBodyThrowsAuthenticationException() { + MontonioTokenProvider provider = new MontonioTokenProvider(configuration, objectMapper, fixedClock); + + Object nonSerializable = new Object() { + @SuppressWarnings("unused") + public Object getSelf() { return this; } + }; + + MontonioAuthenticationException exception = assertThrows( + MontonioAuthenticationException.class, + () -> provider.getDataToken(nonSerializable) + ); + + assertTrue(exception.getMessage().contains("Failed to serialize request body for signing")); + } + + @Test + void concurrentAuthTokenAccessProducesValidTokens() throws InterruptedException { + MontonioTokenProvider provider = new MontonioTokenProvider(configuration, objectMapper, fixedClock); + int threadCount = 20; + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch doneLatch = new CountDownLatch(threadCount); + CopyOnWriteArrayList tokens = new CopyOnWriteArrayList<>(); + List errors = new CopyOnWriteArrayList<>(); + + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + for (int i = 0; i < threadCount; i++) { + executor.submit(() -> { + try { + startLatch.await(); + String token = provider.getAuthToken(); + tokens.add(token); + } catch (Throwable e) { + errors.add(e); + } finally { + doneLatch.countDown(); + } + }); + } + + startLatch.countDown(); + doneLatch.await(); + executor.shutdown(); + + assertTrue(errors.isEmpty(), "Concurrent access produced errors: " + errors); + assertEquals(threadCount, tokens.size()); + + // All threads should get the same cached token + String firstToken = tokens.get(0); + for (String token : tokens) { + assertEquals(firstToken, token); + } + + // All tokens should be verifiable + Algorithm algorithm = Algorithm.HMAC256(SECRET_KEY); + assertDoesNotThrow(() -> JWT.require(algorithm) + .acceptExpiresAt(365 * 24 * 3600) + .build() + .verify(firstToken)); + } + + @Test + void constructorWithoutClockUsesSystemClock() { + MontonioTokenProvider provider = new MontonioTokenProvider(configuration, objectMapper); + + String token = provider.getAuthToken(); + + DecodedJWT decoded = JWT.decode(token); + long iat = decoded.getIssuedAt().toInstant().getEpochSecond(); + long now = Instant.now().getEpochSecond(); + assertTrue(Math.abs(now - iat) < 5, "Token iat should be close to current time"); + } + + // --- Helpers --- + + static class TestOrderRequest { + public String merchantReference; + public Double grandTotal; + public String currency; + } + + private static class MutableClock extends Clock { + private Instant instant; + + MutableClock(Instant instant) { + this.instant = instant; + } + + void setInstant(Instant instant) { + this.instant = instant; + } + + @Override + public java.time.ZoneId getZone() { + return ZoneOffset.UTC; + } + + @Override + public Clock withZone(java.time.ZoneId zone) { + return this; + } + + @Override + public Instant instant() { + return instant; + } + } +} diff --git a/src/test/java/ee/bitweb/montonio/sdk/http/MontonioHttpClientTest.java b/src/test/java/ee/bitweb/montonio/sdk/http/MontonioHttpClientTest.java index 7af0c2e..4777e4a 100644 --- a/src/test/java/ee/bitweb/montonio/sdk/http/MontonioHttpClientTest.java +++ b/src/test/java/ee/bitweb/montonio/sdk/http/MontonioHttpClientTest.java @@ -1,11 +1,18 @@ package ee.bitweb.montonio.sdk.http; +import com.auth0.jwt.JWT; +import com.auth0.jwt.algorithms.Algorithm; +import com.auth0.jwt.interfaces.DecodedJWT; import ee.bitweb.montonio.sdk.MontonioSdkConfiguration; +import ee.bitweb.montonio.sdk.auth.MontonioTokenProvider; import ee.bitweb.montonio.sdk.exception.MontonioApiException; import ee.bitweb.montonio.sdk.exception.MontonioException; import ee.bitweb.montonio.sdk.exception.MontonioNetworkException; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import tools.jackson.databind.DeserializationFeature; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; import javax.net.ssl.SSLSession; import java.io.IOException; @@ -14,7 +21,10 @@ import java.net.http.HttpHeaders; import java.net.http.HttpRequest; import java.net.http.HttpResponse; +import java.time.Clock; import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; import java.util.List; import java.util.Map; import java.util.Optional; @@ -24,24 +34,36 @@ class MontonioHttpClientTest { private static final String BASE_URL = "https://api.example.com"; + private static final String ACCESS_KEY = "test-access-key"; + private static final String SECRET_KEY = "test-secret-key-that-is-long-enough"; + private static final Instant FIXED_NOW = Instant.parse("2026-01-15T10:00:00Z"); private MontonioSdkConfiguration configuration; + private MontonioTokenProvider tokenProvider; @BeforeEach void setUp() { configuration = MontonioSdkConfiguration.builder() - .accessKey("test-access-key") - .secretKey("test-secret-key") + .accessKey(ACCESS_KEY) + .secretKey(SECRET_KEY) .baseUrl(BASE_URL) .connectTimeout(Duration.ofSeconds(5)) .requestTimeout(Duration.ofSeconds(10)) .build(); + + ObjectMapper objectMapper = JsonMapper.builder() + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .build(); + + tokenProvider = new MontonioTokenProvider( + configuration, objectMapper, Clock.fixed(FIXED_NOW, ZoneOffset.UTC) + ); } @Test void getDeserializesSuccessfulResponse() { HttpClient stubClient = new StubHttpClient(200, "{\"name\":\"test\",\"value\":42}"); - MontonioHttpClient client = new MontonioHttpClient(configuration, stubClient); + MontonioHttpClient client = new MontonioHttpClient(configuration, stubClient, tokenProvider); TestResponse response = client.get("/test", TestResponse.class); @@ -52,7 +74,7 @@ void getDeserializesSuccessfulResponse() { @Test void getBuildsCorrectUri() { StubHttpClient stubClient = new StubHttpClient(200, "{\"name\":\"test\",\"value\":1}"); - MontonioHttpClient client = new MontonioHttpClient(configuration, stubClient); + MontonioHttpClient client = new MontonioHttpClient(configuration, stubClient, tokenProvider); client.get("/orders/123", TestResponse.class); @@ -62,7 +84,7 @@ void getBuildsCorrectUri() { @Test void getSetsJsonHeaders() { StubHttpClient stubClient = new StubHttpClient(200, "{\"name\":\"test\",\"value\":1}"); - MontonioHttpClient client = new MontonioHttpClient(configuration, stubClient); + MontonioHttpClient client = new MontonioHttpClient(configuration, stubClient, tokenProvider); client.get("/test", TestResponse.class); @@ -71,10 +93,43 @@ void getSetsJsonHeaders() { assertEquals(List.of("application/json"), headers.allValues("Accept")); } + @Test + void getSetsBearerAuthorizationHeader() { + StubHttpClient stubClient = new StubHttpClient(200, "{\"name\":\"test\",\"value\":1}"); + MontonioHttpClient client = new MontonioHttpClient(configuration, stubClient, tokenProvider); + + client.get("/test", TestResponse.class); + + HttpHeaders headers = stubClient.capturedRequest.headers(); + List authHeaders = headers.allValues("Authorization"); + assertEquals(1, authHeaders.size()); + assertTrue(authHeaders.get(0).startsWith("Bearer ")); + + // Verify the JWT in the header + String jwt = authHeaders.get(0).substring("Bearer ".length()); + Algorithm algorithm = Algorithm.HMAC256(SECRET_KEY); + DecodedJWT decoded = JWT.require(algorithm) + .acceptExpiresAt(365 * 24 * 3600) + .build() + .verify(jwt); + assertEquals(ACCESS_KEY, decoded.getClaim("accessKey").asString()); + } + + @Test + void postDoesNotSetAuthorizationHeader() { + StubHttpClient stubClient = new StubHttpClient(200, "{\"name\":\"test\",\"value\":1}"); + MontonioHttpClient client = new MontonioHttpClient(configuration, stubClient, tokenProvider); + + client.post("/test", new TestRequest("hello"), TestResponse.class); + + HttpHeaders headers = stubClient.capturedRequest.headers(); + assertTrue(headers.allValues("Authorization").isEmpty()); + } + @Test void getSetsRequestTimeout() { StubHttpClient stubClient = new StubHttpClient(200, "{\"name\":\"test\",\"value\":1}"); - MontonioHttpClient client = new MontonioHttpClient(configuration, stubClient); + MontonioHttpClient client = new MontonioHttpClient(configuration, stubClient, tokenProvider); client.get("/test", TestResponse.class); @@ -87,7 +142,7 @@ void getSetsRequestTimeout() { @Test void getUsesGetMethod() { StubHttpClient stubClient = new StubHttpClient(200, "{\"name\":\"test\",\"value\":1}"); - MontonioHttpClient client = new MontonioHttpClient(configuration, stubClient); + MontonioHttpClient client = new MontonioHttpClient(configuration, stubClient, tokenProvider); client.get("/test", TestResponse.class); @@ -95,21 +150,34 @@ void getUsesGetMethod() { } @Test - void postSerializesBodyAndDeserializesResponse() { + void postWrapsBodyAsJwtInDataField() { StubHttpClient stubClient = new StubHttpClient(200, "{\"name\":\"created\",\"value\":99}"); - MontonioHttpClient client = new MontonioHttpClient(configuration, stubClient); + MontonioHttpClient client = new MontonioHttpClient(configuration, stubClient, tokenProvider); TestResponse response = client.post("/test", new TestRequest("hello"), TestResponse.class); assertEquals("created", response.name); assertEquals(99, response.value); - assertEquals("{\"data\":\"hello\"}", extractRequestBody(stubClient.capturedRequest)); + + String requestBody = extractRequestBody(stubClient.capturedRequest); + assertTrue(requestBody.startsWith("{\"data\":\""), "POST body should be {\"data\":\"\"}"); + assertTrue(requestBody.endsWith("\"}"), "POST body should end with \"}"); + + // Extract and verify the JWT + String jwt = requestBody.substring("{\"data\":\"".length(), requestBody.length() - 2); + Algorithm algorithm = Algorithm.HMAC256(SECRET_KEY); + DecodedJWT decoded = JWT.require(algorithm) + .acceptExpiresAt(365 * 24 * 3600) + .build() + .verify(jwt); + assertEquals(ACCESS_KEY, decoded.getClaim("accessKey").asString()); + assertEquals("hello", decoded.getClaim("data").asString()); } @Test void postUsesPostMethod() { StubHttpClient stubClient = new StubHttpClient(200, "{\"name\":\"test\",\"value\":1}"); - MontonioHttpClient client = new MontonioHttpClient(configuration, stubClient); + MontonioHttpClient client = new MontonioHttpClient(configuration, stubClient, tokenProvider); client.post("/test", new TestRequest("hello"), TestResponse.class); @@ -120,7 +188,7 @@ void postUsesPostMethod() { void errorResponseWithJsonBodyThrowsApiExceptionWithParsedFields() { String errorBody = "{\"errorCode\":\"ORDER_NOT_FOUND\",\"message\":\"Order does not exist\"}"; HttpClient stubClient = new StubHttpClient(404, errorBody); - MontonioHttpClient client = new MontonioHttpClient(configuration, stubClient); + MontonioHttpClient client = new MontonioHttpClient(configuration, stubClient, tokenProvider); MontonioApiException exception = assertThrows( MontonioApiException.class, @@ -135,7 +203,7 @@ void errorResponseWithJsonBodyThrowsApiExceptionWithParsedFields() { @Test void errorResponseWithNonJsonBodyThrowsApiExceptionWithRawBody() { HttpClient stubClient = new StubHttpClient(500, "Internal Server Error"); - MontonioHttpClient client = new MontonioHttpClient(configuration, stubClient); + MontonioHttpClient client = new MontonioHttpClient(configuration, stubClient, tokenProvider); MontonioApiException exception = assertThrows( MontonioApiException.class, @@ -151,7 +219,7 @@ void errorResponseWithNonJsonBodyThrowsApiExceptionWithRawBody() { void errorResponseWithPartialJsonFieldsThrowsApiExceptionWithAvailableFields() { String errorBody = "{\"message\":\"Something went wrong\"}"; HttpClient stubClient = new StubHttpClient(400, errorBody); - MontonioHttpClient client = new MontonioHttpClient(configuration, stubClient); + MontonioHttpClient client = new MontonioHttpClient(configuration, stubClient, tokenProvider); MontonioApiException exception = assertThrows( MontonioApiException.class, @@ -166,7 +234,7 @@ void errorResponseWithPartialJsonFieldsThrowsApiExceptionWithAvailableFields() { @Test void connectionFailureThrowsNetworkException() { HttpClient stubClient = new IoExceptionHttpClient(new IOException("Connection refused")); - MontonioHttpClient client = new MontonioHttpClient(configuration, stubClient); + MontonioHttpClient client = new MontonioHttpClient(configuration, stubClient, tokenProvider); MontonioNetworkException exception = assertThrows( MontonioNetworkException.class, @@ -180,7 +248,7 @@ void connectionFailureThrowsNetworkException() { @Test void interruptedRequestThrowsNetworkExceptionAndRestoresInterruptFlag() { HttpClient stubClient = new InterruptedHttpClient(); - MontonioHttpClient client = new MontonioHttpClient(configuration, stubClient); + MontonioHttpClient client = new MontonioHttpClient(configuration, stubClient, tokenProvider); MontonioNetworkException exception = assertThrows( MontonioNetworkException.class, @@ -198,7 +266,7 @@ void interruptedRequestThrowsNetworkExceptionAndRestoresInterruptFlag() { @Test void malformedJsonResponseOnSuccessThrowsMontonioException() { HttpClient stubClient = new StubHttpClient(200, "not json at all"); - MontonioHttpClient client = new MontonioHttpClient(configuration, stubClient); + MontonioHttpClient client = new MontonioHttpClient(configuration, stubClient, tokenProvider); MontonioException exception = assertThrows( MontonioException.class, @@ -212,7 +280,7 @@ void malformedJsonResponseOnSuccessThrowsMontonioException() { void errorResponseWithNullJsonFieldsThrowsApiExceptionWithNulls() { String errorBody = "{\"errorCode\":null,\"message\":null}"; HttpClient stubClient = new StubHttpClient(422, errorBody); - MontonioHttpClient client = new MontonioHttpClient(configuration, stubClient); + MontonioHttpClient client = new MontonioHttpClient(configuration, stubClient, tokenProvider); MontonioApiException exception = assertThrows( MontonioApiException.class, From 575354c754aaac47e238fed3bab919301ab3ab99 Mon Sep 17 00:00:00 2001 From: Rain Ramm Date: Fri, 10 Apr 2026 08:03:25 +0000 Subject: [PATCH 2/2] Address CodeRabbit review feedback Upgrade java-jwt to 4.5.0, extract ObjectMapper creation to static helper, add awaitTermination to concurrent test, clarify getDataToken synchronisation wording in design doc. Co-Authored-By: Claude Opus 4.6 (1M context) --- build.gradle | 2 +- docs/plans/2026-04-10-jwt-authentication-design.md | 2 +- .../bitweb/montonio/sdk/http/MontonioHttpClient.java | 12 +++++++----- .../montonio/sdk/auth/MontonioTokenProviderTest.java | 1 + 4 files changed, 10 insertions(+), 7 deletions(-) diff --git a/build.gradle b/build.gradle index c030e2c..9de8ba1 100644 --- a/build.gradle +++ b/build.gradle @@ -30,7 +30,7 @@ repositories { dependencies { implementation 'tools.jackson.core:jackson-databind:3.0.1' - implementation 'com.auth0:java-jwt:4.4.0' + implementation 'com.auth0:java-jwt:4.5.0' testImplementation platform('org.junit:junit-bom:5.14.3') testImplementation 'org.junit.jupiter:junit-jupiter' diff --git a/docs/plans/2026-04-10-jwt-authentication-design.md b/docs/plans/2026-04-10-jwt-authentication-design.md index da57dda..f2c5ce2 100644 --- a/docs/plans/2026-04-10-jwt-authentication-design.md +++ b/docs/plans/2026-04-10-jwt-authentication-design.md @@ -129,7 +129,7 @@ No retry logic — these are local CPU operations, so failure indicates a config - `synchronized(this)` guards all reads/writes to `cachedToken` and `cachedTokenExpiry` - Token generation (HMAC-SHA256) takes microseconds, so lock contention is negligible -- `getDataToken()` does not touch cached state, so it only synchronizes if sharing mutable resources +- `getDataToken()` requires no synchronisation as it does not access shared state ## Testing Strategy diff --git a/src/main/java/ee/bitweb/montonio/sdk/http/MontonioHttpClient.java b/src/main/java/ee/bitweb/montonio/sdk/http/MontonioHttpClient.java index 3dda401..b066d39 100644 --- a/src/main/java/ee/bitweb/montonio/sdk/http/MontonioHttpClient.java +++ b/src/main/java/ee/bitweb/montonio/sdk/http/MontonioHttpClient.java @@ -36,9 +36,7 @@ public MontonioHttpClient(MontonioSdkConfiguration configuration) { MontonioHttpClient(MontonioSdkConfiguration configuration, HttpClient httpClient) { this.configuration = configuration; this.httpClient = httpClient; - this.objectMapper = JsonMapper.builder() - .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) - .build(); + this.objectMapper = createObjectMapper(); this.tokenProvider = new MontonioTokenProvider(configuration, objectMapper); } @@ -46,10 +44,14 @@ public MontonioHttpClient(MontonioSdkConfiguration configuration) { MontonioTokenProvider tokenProvider) { this.configuration = configuration; this.httpClient = httpClient; - this.objectMapper = JsonMapper.builder() + this.objectMapper = createObjectMapper(); + this.tokenProvider = tokenProvider; + } + + private static ObjectMapper createObjectMapper() { + return JsonMapper.builder() .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) .build(); - this.tokenProvider = tokenProvider; } public T get(String path, Class responseType) { diff --git a/src/test/java/ee/bitweb/montonio/sdk/auth/MontonioTokenProviderTest.java b/src/test/java/ee/bitweb/montonio/sdk/auth/MontonioTokenProviderTest.java index 593e6c6..1353028 100644 --- a/src/test/java/ee/bitweb/montonio/sdk/auth/MontonioTokenProviderTest.java +++ b/src/test/java/ee/bitweb/montonio/sdk/auth/MontonioTokenProviderTest.java @@ -303,6 +303,7 @@ void concurrentAuthTokenAccessProducesValidTokens() throws InterruptedException startLatch.countDown(); doneLatch.await(); executor.shutdown(); + assertTrue(executor.awaitTermination(5, java.util.concurrent.TimeUnit.SECONDS)); assertTrue(errors.isEmpty(), "Concurrent access produced errors: " + errors); assertEquals(threadCount, tokens.size());