Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ Integration tests require the following env vars. In CI they are repository secr
- `PLUGGY_CLIENT_ID`
- `PLUGGY_CLIENT_SECRET`
- `PLUGGY_BASE_URL`
- `PLUGGY_RSA_PUBLIC_KEY` (optional; only needed when exercising `EncryptedParametersInterceptor`. Without it, `CreateItemTest#createItem_withEncryptedParameters_responseOk` still passes but sends the parameters unencrypted)
- `PLUGGY_RSA_PUBLIC_KEY` (optional; only needed when exercising `EncryptedParametersInterceptor`. Without it, `CreateItemTest#createItem_withEncryptedParameters_responseOk` is skipped, since `rsaPublicKey(null)` throws instead of silently disabling encryption)

Missing any of the first three causes `BaseApiIntegrationTest` to fail-fast in `@BeforeEach`. Each test that creates items appends the new id to `itemsIdCreated`; the base class's `@AfterEach` deletes them, so integration tests are responsible for tracking what they create.

Expand All @@ -62,7 +62,7 @@ The payments surface is Smart Transfers (`/smart-transfers/*`) and Pix por biome

**3. Two interceptors handle cross-cutting concerns**

- `ApiKeyAuthInterceptor` (`client/auth/`): transparently fetches the `x-api-key` JWT via `POST /auth` on first use, caches it in `TokenProvider`, decodes the JWT `exp` claim to detect expiry, and on a 401/403 with an expired key refreshes once and retries the original request. An `x-api-key` already on the request is the caller's credential: it is sent as-is and never refreshed or replaced with the team key. Also sets `User-Agent: PluggyJava/<version> (Java <java.version>)` via `ai.pluggy.utils.Version`, which reads `project.version` from the filtered `pluggy.properties`, so bumping `pom.xml` is all a release needs.
- `ApiKeyAuthInterceptor` (`client/auth/`): transparently fetches the `x-api-key` JWT via `POST /auth` on first use, caches it in `TokenProvider`, decodes the JWT `exp` claim to detect expiry, and on a 401/403 with an expired key refreshes once and retries the original request. An `x-api-key` already on the request is the caller's credential: it is sent as-is and never refreshed or replaced with the team key. An issued key whose `exp` can't be read is reused until the API rejects it, then refreshed once; if the refresh fails, the original 401/403 is returned with its body still readable. Also sets `User-Agent: PluggyJava/<version> (Java <java.version>)` via `ai.pluggy.utils.Version`, which reads `project.version` from the filtered `pluggy.properties`, so bumping `pom.xml` is all a release needs.
- `EncryptedParametersInterceptor` (`client/auth/`): only attached when `rsaPublicKey(...)` is set on the builder. RSA/ECB/OAEPPadding-encrypts the `parameters` JSON field on POST/PATCH `/items` requests before they leave OkHttp. All three conditions are required: Smart Transfer preauthorizations also send a `parameters` field, which must go out as plain JSON, and item requests without one (MFA) must pass through.

### JSON / Gson conventions — important gotcha
Expand Down
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

<groupId>ai.pluggy</groupId>
<artifactId>pluggy-java</artifactId>
<version>1.17.2</version>
<version>1.17.3</version>

<packaging>jar</packaging>

Expand Down
9 changes: 7 additions & 2 deletions src/main/java/ai/pluggy/client/PluggyClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,8 @@ public PluggyClientBuilder baseUrl(String baseUrl) {
}

public PluggyClientBuilder rsaPublicKey(String rsaPublicKey) {
// a key read from an unset variable must not silently disable encryption
assertNotNull(rsaPublicKey, "rsaPublicKey");
this.rsaPublicKey = rsaPublicKey;
return this;
}
Expand Down Expand Up @@ -316,8 +318,11 @@ public String authenticate() throws IOException, PluggyException {
// unexpected IO exception
throw e;
} catch (Exception e) {
// non-successful or malformed JSON response
String pluggyResponseErrorMessage = buildResponseErrorMessage(response);
// non-successful or malformed JSON response. A successful /auth body carries the api
// key, so it is only included when the response is an error.
String pluggyResponseErrorMessage = response.isSuccessful()
? buildResponseErrorMessage(response, null)
: buildResponseErrorMessage(response);

throw new PluggyException(pluggyResponseErrorMessage, response, e);
}
Expand Down
53 changes: 33 additions & 20 deletions src/main/java/ai/pluggy/client/auth/ApiKeyAuthInterceptor.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,18 @@

import static ai.pluggy.utils.Asserts.assertNotNull;

import ai.pluggy.client.PluggyClient;
import ai.pluggy.utils.Version;
import com.google.gson.Gson;
import com.google.gson.JsonParser;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;

import com.google.gson.reflect.TypeToken;
import okhttp3.Interceptor;
import okhttp3.MediaType;
import okhttp3.Request;
Expand Down Expand Up @@ -50,22 +52,24 @@ public ApiKeyAuthInterceptor(String authUrl, String clientId, String clientSecre
}

/**
* Helper to check if JWT token apiKey is expired
* Reads the exp claim of a JWT apiKey. The signature isn't verified: the value only decides when
* to ask the API for a new key.
*
* @param token
* @return the expiry in epoch seconds, or null if the key isn't a JWT with a numeric exp claim
*/
private boolean isApiKeyExpired(String token) {
Base64.Decoder decoder = Base64.getUrlDecoder();
String[] parts = token.split("\\."); // Splitting header, payload and signature
String jwtHeader = new String(decoder.decode(parts[1])); // extracting payload part

Map<String, String> parsedJwt = new Gson().fromJson(jwtHeader,
new TypeToken<Map<String, String>>() {
}.getType());
@Nullable
private static Long readExpiresAt(String apiKey) {
try {
String payload = new String(Base64.getUrlDecoder().decode(apiKey.split("\\.")[1]),
StandardCharsets.UTF_8);
return JsonParser.parseString(payload).getAsJsonObject().get("exp").getAsLong();
} catch (RuntimeException e) {
return null;
}
}

long expiresAtInSeconds = Long.parseLong(parsedJwt.get("exp"));
private static boolean hasExpired(long expiresAtInSeconds) {
long currentTimeInSeconds = (long) Math.floor((double) (new Date().getTime() / 1000));

return currentTimeInSeconds >= expiresAtInSeconds;
}

Expand All @@ -92,9 +96,12 @@ public Response intercept(@NotNull Chain chain) throws IOException {
}

String apiKey;
// No 'api key' in header -> get one from token provider.
if (tokenProvider.getApiKey() != null && !isApiKeyExpired(tokenProvider.getApiKey())) {
apiKey = tokenProvider.getApiKey();
// No 'api key' in header -> get one from token provider. A stored key whose expiry can't be
// read is reused until the API rejects it.
String storedApiKey = tokenProvider.getApiKey();
Long storedExpiresAt = storedApiKey == null ? null : readExpiresAt(storedApiKey);
if (storedApiKey != null && (storedExpiresAt == null || !hasExpired(storedExpiresAt))) {
apiKey = storedApiKey;
logger.info("Using api key from stored tokenProvider.");
} else {
logger.info("Requesting new api key...");
Expand Down Expand Up @@ -136,14 +143,20 @@ private Response proceedWithAuthRetry(@NotNull Chain chain, Request originalRequ

// auth error response, check if it was due to expired token
String apiKey = originalRequest.header(X_API_KEY_HEADER);
boolean isApiKeyExpired = apiKey != null && this.isApiKeyExpired(apiKey);
Long expiresAt = apiKey == null ? null : readExpiresAt(apiKey);
// a key whose expiry can't be read may have expired too
boolean mayHaveExpired = apiKey != null && (expiresAt == null || hasExpired(expiresAt));

if (!isApiKeyExpired) {
if (!mayHaveExpired) {
// apiKey not expired, auth error is unrelated to it -> return error response
return response;
}

// apiKey expired, try to refresh API key, and retry original request.
// apiKey expired, try to refresh API key, and retry original request. The response must be
// closed before re-authenticating, so keep a readable copy in case the refresh fails.
Response rejection = response.newBuilder()
.body(response.peekBody(PluggyClient.ONE_MIB_BYTES))
.build();
response.close();

logger.info("ApiKey expired, attempting to request a new one and retry original request...");
Expand All @@ -152,7 +165,7 @@ private Response proceedWithAuthRetry(@NotNull Chain chain, Request originalRequ

if (newApiKey == null) {
logger.info("ApiKey could not be refreshed.");
return response;
return rejection;
}

logger.info("ApiKey refreshed OK. Retrying original request...");
Expand Down
6 changes: 4 additions & 2 deletions src/main/java/ai/pluggy/client/auth/AuthenticationHelper.java
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,11 @@ public static String extractApiKeyFromResponse(Response response)
try {
authResponse = Utils.parseJsonResponse(responseBodyString, AuthResponse.class);
} catch (JsonSyntaxException exception) {
// response didn't contain a valid JSON body, nothing to parse
// response didn't contain a valid JSON body, nothing to parse. The body is left out of the
// message: a successful /auth response carries the api key.
throw new JsonSyntaxException(
String.format("/auth response is not a valid JSON body: '%s'", responseBodyString),
String.format("/auth response is not a valid JSON body (%d characters)",
responseBodyString.length()),
exception);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import static ai.pluggy.utils.Asserts.assertNotNull;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.KeyFactory;
import java.security.NoSuchAlgorithmException;
Expand Down Expand Up @@ -35,7 +36,7 @@ public class EncryptedParametersInterceptor implements Interceptor {
private String[] methods = { "PATCH", "POST" };

public EncryptedParametersInterceptor(String rsaPublicKey) {
assertNotNull(rsaPublicKey, rsaPublicKey);
assertNotNull(rsaPublicKey, "rsaPublicKey");
this.rsaPublicKey = rsaPublicKey;
}

Expand All @@ -51,9 +52,11 @@ public Response intercept(@NotNull Chain chain) throws IOException {
JsonObject jsonBody = this.transformBodyToJsonObject(originalBody);

// only item credentials are encrypted: other bodies with a "parameters" field (e.g. Smart
// Transfer preauthorizations) are sent as-is, and item requests without one (e.g. MFA) too
// Transfer preauthorizations) are sent as-is, and item requests without one (e.g. MFA, or
// an update with an empty body) too
if (!Arrays.asList(methods).contains(method)
|| !originalRequest.url().encodedPath().contains(path) || !jsonBody.has("parameters")) {
|| !originalRequest.url().encodedPath().contains(path)
|| jsonBody == null || !jsonBody.has("parameters")) {
return chain.proceed(originalRequest);
}

Expand Down Expand Up @@ -85,7 +88,9 @@ private String encryptParameters(String parameters) throws IOException {
PublicKey publicKey = keyFactory.generatePublic(keySpec);
Cipher cipher = Cipher.getInstance("RSA/ECB/OAEPPadding");
cipher.init(Cipher.ENCRYPT_MODE, publicKey);
byte[] encryptedPayloadBytes = cipher.doFinal(parameters.getBytes());
// explicit charset: the platform default differs between JVMs and would alter
// non-ASCII credentials before encryption
byte[] encryptedPayloadBytes = cipher.doFinal(parameters.getBytes(StandardCharsets.UTF_8));
String encryptedPayload = Base64.getEncoder().encodeToString(encryptedPayloadBytes);
return encryptedPayload;

Expand Down
2 changes: 2 additions & 0 deletions src/main/java/ai/pluggy/client/request/CreateItemRequest.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.ToString;
import lombok.Value;

@Value
@AllArgsConstructor
@Builder
public class CreateItemRequest {
Integer connectorId;
@ToString.Exclude
ParametersMap parameters;
String webhookUrl;
String clientUserId;
Expand Down
2 changes: 2 additions & 0 deletions src/main/java/ai/pluggy/client/request/UpdateItemRequest.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.ToString;

@Builder
@Data
@AllArgsConstructor
@NoArgsConstructor
public class UpdateItemRequest {
Integer connectorId;
@ToString.Exclude
ParametersMap parameters;
String webhookUrl;
String clientUserId;
Expand Down
2 changes: 2 additions & 0 deletions src/main/java/ai/pluggy/client/response/AuthResponse.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.ToString;

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AuthResponse {

@ToString.Exclude
String apiKey;
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.ToString;

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ConnectTokenResponse {

@ToString.Exclude
String accessToken;
}
Empty file.
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assumptions.assumeTrue;

import ai.pluggy.client.PluggyClient;
import ai.pluggy.client.request.CreateItemRequest;
Expand Down Expand Up @@ -107,6 +108,9 @@ void createItem_withInvalidParams_responseError400() {
@SneakyThrows
@Test
void createItem_withEncryptedParameters_responseOk() {
assumeTrue(BaseApiIntegrationTest.RSA_PUBLIC_KEY != null,
"PLUGGY_RSA_PUBLIC_KEY is not set");

// create item params
ParametersMap parametersMap = ParametersMap.map("user", "user-ok")
.with("password", "password-ok");
Expand Down
99 changes: 99 additions & 0 deletions src/test/java/ai/pluggy/client/unit/ApiKeyRefreshTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package ai.pluggy.client.unit;

import static org.junit.jupiter.api.Assertions.assertEquals;

import ai.pluggy.client.PluggyClient;
import ai.pluggy.client.response.ConnectorsResponse;
import ai.pluggy.client.response.ErrorResponse;
import ai.pluggy.client.unit.FakePluggyServer.Reply;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Base64;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import retrofit2.Response;

/**
* The interceptor reads an issued key's exp claim only to decide when to ask for a new one, so a
* key it can't read must not break the client, and a failed refresh must leave the API's answer
* readable.
*/
public class ApiKeyRefreshTest {

private static final String OK = "{\"results\":[]}";

private FakePluggyServer api;
private String acceptedKey;

@BeforeEach
void setUp() throws Exception {
api = new FakePluggyServer();
api.on("GET", "/connectors", request -> request.apiKey() != null
&& request.apiKey().equals(acceptedKey)
? Reply.json(200, OK)
: Reply.json(401, "{\"message\":\"api key expired\",\"code\":401}"));
}

@AfterEach
void tearDown() {
api.close();
}

private PluggyClient client() {
return PluggyClient.builder()
.baseUrl(api.url())
.clientIdAndSecret("client-id", "client-secret")
.build();
}

private static String tokenWithPayload(String payloadJson) {
return "header." + Base64.getUrlEncoder().withoutPadding()
.encodeToString(payloadJson.getBytes(StandardCharsets.UTF_8)) + ".signature";
}

@Test
void keyWithNonStringClaims_isReusedUntilItExpires() throws Exception {
acceptedKey = tokenWithPayload("{\"exp\":4102444800,\"scopes\":[\"read\"]}");
api.on("POST", "/auth", request -> Reply.apiKey(acceptedKey));
PluggyClient client = client();

assertEquals(200, client.service().getConnectors().execute().code());
assertEquals(200, client.service().getConnectors().execute().code());

assertEquals(1, api.received("/auth").size());
}

@Test
void keyThatIsNotAJwt_isReusedUntilRejected_thenRefreshedOnce() throws Exception {
AtomicInteger authCalls = new AtomicInteger();
api.on("POST", "/auth", request -> Reply.apiKey("opaque-" + authCalls.incrementAndGet()));
acceptedKey = "opaque-1";
PluggyClient client = client();

assertEquals(200, client.service().getConnectors().execute().code());
assertEquals(200, client.service().getConnectors().execute().code());
acceptedKey = "opaque-2";
assertEquals(200, client.service().getConnectors().execute().code());

assertEquals(2, authCalls.get());
assertEquals(Arrays.asList("opaque-1", "opaque-1", "opaque-1", "opaque-2"),
api.apiKeysSentTo("/connectors"));
}

@Test
void failedRefresh_returnsTheRejectionReadable() throws Exception {
AtomicInteger authCalls = new AtomicInteger();
api.on("POST", "/auth", request -> authCalls.incrementAndGet() == 1
? Reply.apiKey(FakePluggyServer.jwt("team", 1L))
: Reply.json(500, "{}"));
PluggyClient client = client();

Response<ConnectorsResponse> response = client.service().getConnectors().execute();

assertEquals(401, response.code());
ErrorResponse error = client.parseError(response);
assertEquals("api key expired", error.getMessage());
}
}
Loading
Loading