getAuthHeaderSupplier(final ClientOptions baseOptions)
return () -> "Bearer " + this.token;
}
- if (this.clientId != null && this.clientSecret != null) {
+ if (this.clientSecret != null && this.privateKey != null) {
+ throw new IllegalStateException("Pass either clientSecret or privateKey, not both");
+ }
+ String secret = this.clientSecret;
+ String key = this.privateKey;
+ String kid = this.keyId;
+ if (secret == null && key == null) {
+ if (this.envClientSecret != null && this.envPrivateKey != null) {
+ throw new IllegalStateException(
+ "Both PIPEDREAM_CLIENT_SECRET and PIPEDREAM_PRIVATE_KEY are set; set only the one your client uses");
+ }
+ secret = this.envClientSecret;
+ key = this.envPrivateKey;
+ }
+ if (kid == null) {
+ kid = this.envKeyId;
+ }
+
+ if (this.clientId != null && key != null) {
+ // The assertion audience is the API's issuer identifier: the base URL's origin
+ // (https://api.pipedream.com by default).
+ final ClientAssertionSigner signer = new ClientAssertionSigner(
+ this.clientId,
+ key,
+ kid,
+ PrivateKeyJwtTokenSupplier.issuer(baseOptions.environment().getUrl()));
+ return new PrivateKeyJwtTokenSupplier(this.clientId, this.scope, signer, baseOptions);
+ }
+
+ if (this.clientId != null && secret != null) {
final OauthTokensClient authClient = new OauthTokensClient(baseOptions);
- return new OAuthTokenSupplier(this.clientId, this.clientSecret, this.scope, authClient);
+ // The client-secret flow sends no client assertion.
+ return new OAuthTokenSupplier(this.clientId, secret, null, null, this.scope, authClient);
}
return () -> "";
@@ -82,6 +121,38 @@ public PipedreamClientBuilder clientSecret(final String clientSecret) {
return this;
}
+ /**
+ * For clients that authenticate with a public key instead of a client secret: the matching
+ * private key, as an unencrypted PKCS#8 PEM ({@code -----BEGIN PRIVATE KEY-----}) or a JWK
+ * (JSON). The SDK signs a short-lived client assertion with it for each token request: ES256 for
+ * EC P-256 keys, RS256 for RSA keys. Can't be combined with {@link #clientSecret(String)}.
+ * Defaults to {@code PIPEDREAM_PRIVATE_KEY} when the builder is created via {@link
+ * PipedreamClientbuilder()}.
+ */
+ public PipedreamClientBuilder privateKey(final String privateKey) {
+ this.privateKey = privateKey;
+ return this;
+ }
+
+ /**
+ * Optional. The key ID shown in the Pipedream UI, sent as the client assertion's {@code kid}
+ * header. Defaults to {@code PIPEDREAM_KEY_ID} when the builder is created via {@link
+ * PipedreamClientbuilder()}.
+ */
+ public PipedreamClientBuilder keyId(final String keyId) {
+ this.keyId = keyId;
+ return this;
+ }
+
+ /** Credentials from the environment, used only when none are set explicitly. */
+ PipedreamClientBuilder environmentCredentials(
+ final String clientSecret, final String privateKey, final String keyId) {
+ this.envClientSecret = clientSecret;
+ this.envPrivateKey = privateKey;
+ this.envKeyId = keyId;
+ return this;
+ }
+
/**
* Sets a pre-generated access token. When set, OAuth client-credentials are bypassed
* and the token is sent directly in the Authorization header.
diff --git a/src/main/java/com/pipedream/api/core/ClientAssertionSigner.java b/src/main/java/com/pipedream/api/core/ClientAssertionSigner.java
new file mode 100644
index 0000000..14f9fb7
--- /dev/null
+++ b/src/main/java/com/pipedream/api/core/ClientAssertionSigner.java
@@ -0,0 +1,205 @@
+package com.pipedream.api.core;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.JsonNode;
+import java.math.BigInteger;
+import java.nio.charset.StandardCharsets;
+import java.security.AlgorithmParameters;
+import java.security.GeneralSecurityException;
+import java.security.KeyFactory;
+import java.security.PrivateKey;
+import java.security.Signature;
+import java.security.interfaces.ECPrivateKey;
+import java.security.interfaces.RSAPrivateKey;
+import java.security.spec.ECGenParameterSpec;
+import java.security.spec.ECParameterSpec;
+import java.security.spec.ECPrivateKeySpec;
+import java.security.spec.PKCS8EncodedKeySpec;
+import java.security.spec.RSAPrivateCrtKeySpec;
+import java.security.spec.RSAPrivateKeySpec;
+import java.util.Base64;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.UUID;
+
+/**
+ * Signs client assertions for private_key_jwt client authentication (RFC 7523): an OAuth client
+ * that registered a public key authenticates at the token endpoint with a short-lived JWT signed by
+ * the matching private key, instead of a client secret.
+ *
+ * Each call to {@link #sign()} returns a new JWT with {@code iss} and {@code sub} set to the
+ * client ID, the given {@code aud}, a 60-second lifetime, and a random single-use {@code jti}. The
+ * private key may be an unencrypted PKCS#8 PEM ({@code -----BEGIN PRIVATE KEY-----}) or a JWK as a
+ * JSON string; EC P-256 keys sign with ES256 and RSA keys with RS256. The key ID is sent as the
+ * {@code kid} header and defaults to the JWK's {@code kid}, if any. Uses only the JDK.
+ */
+public final class ClientAssertionSigner {
+ public static final String CLIENT_ASSERTION_TYPE = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer";
+
+ // Assertions are single-use and only need to outlive one token request.
+ private static final long ASSERTION_LIFETIME_SECONDS = 60;
+
+ private static final int P256_COORDINATE_BYTES = 32;
+
+ private final String clientId;
+ private final String audience;
+ private final PrivateKey key;
+ private final String alg;
+ private final String keyId;
+
+ /**
+ * @throws IllegalArgumentException if the key can't be read or isn't an EC P-256 or RSA private
+ * key
+ */
+ public ClientAssertionSigner(String clientId, String privateKey, String keyId, String audience) {
+ this.clientId = clientId;
+ this.audience = audience;
+ String text = privateKey.trim();
+ String jwkKid = null;
+ try {
+ if (text.startsWith("{")) {
+ JsonNode jwk = ObjectMappers.JSON_MAPPER.readTree(text);
+ this.key = keyFromJwk(jwk);
+ jwkKid = jwk.hasNonNull("kid") ? jwk.get("kid").asText() : null;
+ } else {
+ this.key = keyFromPem(text);
+ }
+ } catch (IllegalArgumentException e) {
+ throw e;
+ } catch (Exception e) {
+ throw new IllegalArgumentException(
+ "privateKey must be an unencrypted PKCS#8 PEM private key or a JWK (JSON). If you passed a"
+ + " public key, pass the private key instead.",
+ e);
+ }
+ if (key instanceof ECPrivateKey && isP256(((ECPrivateKey) key).getParams())) {
+ this.alg = "ES256";
+ } else if (key instanceof RSAPrivateKey) {
+ this.alg = "RS256";
+ } else {
+ throw new IllegalArgumentException("privateKey must be an EC P-256 key (ES256) or an RSA key (RS256)");
+ }
+ this.keyId = keyId != null ? keyId : jwkKid;
+ }
+
+ /** Signs a new client assertion. */
+ public String sign() {
+ long now = System.currentTimeMillis() / 1000;
+ Map header = new LinkedHashMap<>();
+ header.put("alg", alg);
+ header.put("typ", "client-authentication+jwt");
+ if (keyId != null) {
+ header.put("kid", keyId);
+ }
+ Map claims = new LinkedHashMap<>();
+ claims.put("iss", clientId);
+ claims.put("sub", clientId);
+ claims.put("aud", audience);
+ claims.put("iat", now);
+ claims.put("exp", now + ASSERTION_LIFETIME_SECONDS);
+ claims.put("jti", UUID.randomUUID().toString());
+ try {
+ String signingInput = base64Url(ObjectMappers.JSON_MAPPER.writeValueAsBytes(header)) + "."
+ + base64Url(ObjectMappers.JSON_MAPPER.writeValueAsBytes(claims));
+ Signature signature = Signature.getInstance("ES256".equals(alg) ? "SHA256withECDSA" : "SHA256withRSA");
+ signature.initSign(key);
+ signature.update(signingInput.getBytes(StandardCharsets.US_ASCII));
+ byte[] sig = signature.sign();
+ // JWS ES256 signatures are the raw r||s pair, not the DER encoding the JDK produces.
+ return signingInput + "." + base64Url("ES256".equals(alg) ? derToRaw(sig) : sig);
+ } catch (GeneralSecurityException | JsonProcessingException e) {
+ throw new BaseClientException("Failed to sign client assertion", e);
+ }
+ }
+
+ private static PrivateKey keyFromPem(String pem) throws GeneralSecurityException {
+ if (!pem.contains("-----BEGIN PRIVATE KEY-----")) {
+ throw new IllegalArgumentException(
+ "privateKey must be an unencrypted PKCS#8 PEM private key (-----BEGIN PRIVATE KEY-----) or a"
+ + " JWK. If you passed a public key, pass the private key instead. Convert other PEM"
+ + " formats with: openssl pkcs8 -topk8 -nocrypt -in key.pem");
+ }
+ String body = pem.replaceAll("-----(BEGIN|END) PRIVATE KEY-----", "").replaceAll("\\s", "");
+ PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(Base64.getDecoder().decode(body));
+ try {
+ return KeyFactory.getInstance("EC").generatePrivate(spec);
+ } catch (GeneralSecurityException notEc) {
+ return KeyFactory.getInstance("RSA").generatePrivate(spec);
+ }
+ }
+
+ private static PrivateKey keyFromJwk(JsonNode jwk) throws GeneralSecurityException {
+ String kty = jwk.path("kty").asText();
+ if ("EC".equals(kty)) {
+ if (!"P-256".equals(jwk.path("crv").asText())) {
+ throw new IllegalArgumentException("privateKey must be an EC P-256 key (ES256) or an RSA key (RS256)");
+ }
+ return KeyFactory.getInstance("EC").generatePrivate(new ECPrivateKeySpec(member(jwk, "d"), p256()));
+ }
+ if ("RSA".equals(kty)) {
+ BigInteger n = member(jwk, "n");
+ BigInteger d = member(jwk, "d");
+ KeyFactory rsa = KeyFactory.getInstance("RSA");
+ if (jwk.hasNonNull("p")) {
+ return rsa.generatePrivate(new RSAPrivateCrtKeySpec(
+ n,
+ member(jwk, "e"),
+ d,
+ member(jwk, "p"),
+ member(jwk, "q"),
+ member(jwk, "dp"),
+ member(jwk, "dq"),
+ member(jwk, "qi")));
+ }
+ return rsa.generatePrivate(new RSAPrivateKeySpec(n, d));
+ }
+ throw new IllegalArgumentException("privateKey must be an EC P-256 key (ES256) or an RSA key (RS256)");
+ }
+
+ private static BigInteger member(JsonNode jwk, String name) {
+ if (!jwk.hasNonNull(name)) {
+ throw new IllegalArgumentException("privateKey JWK is missing \"" + name
+ + "\". If you passed a public key, pass the private key instead.");
+ }
+ return new BigInteger(1, Base64.getUrlDecoder().decode(jwk.get(name).asText()));
+ }
+
+ private static ECParameterSpec p256() throws GeneralSecurityException {
+ AlgorithmParameters parameters = AlgorithmParameters.getInstance("EC");
+ parameters.init(new ECGenParameterSpec("secp256r1"));
+ return parameters.getParameterSpec(ECParameterSpec.class);
+ }
+
+ private static boolean isP256(ECParameterSpec params) {
+ try {
+ ECParameterSpec p256 = p256();
+ return params.getOrder().equals(p256.getOrder())
+ && params.getGenerator().equals(p256.getGenerator());
+ } catch (GeneralSecurityException e) {
+ return false;
+ }
+ }
+
+ // Converts a DER-encoded ECDSA signature (SEQUENCE { INTEGER r, INTEGER s }) to the fixed-length
+ // r||s form JWS uses.
+ static byte[] derToRaw(byte[] der) {
+ int offset = 2;
+ if ((der[1] & 0xff) == 0x81) {
+ offset = 3; // long-form sequence length
+ }
+ byte[] raw = new byte[2 * P256_COORDINATE_BYTES];
+ for (int part = 0; part < 2; part++) {
+ int length = der[offset + 1];
+ int start = offset + 2;
+ int copy = Math.min(length, P256_COORDINATE_BYTES);
+ // INTEGERs are big-endian and may carry a leading 0x00 sign byte, or be shorter than 32 bytes.
+ System.arraycopy(der, start + length - copy, raw, (part + 1) * P256_COORDINATE_BYTES - copy, copy);
+ offset = start + length;
+ }
+ return raw;
+ }
+
+ private static String base64Url(byte[] bytes) {
+ return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
+ }
+}
diff --git a/src/main/java/com/pipedream/api/core/ClientOptions.java b/src/main/java/com/pipedream/api/core/ClientOptions.java
index 438ec5a..346d523 100644
--- a/src/main/java/com/pipedream/api/core/ClientOptions.java
+++ b/src/main/java/com/pipedream/api/core/ClientOptions.java
@@ -41,10 +41,10 @@ private ClientOptions(
this.headers.putAll(headers);
this.headers.putAll(new HashMap() {
{
- put("User-Agent", "com.pipedream:pipedream/2.1.4");
+ put("User-Agent", "com.pipedream:pipedream/2.2.0");
put("X-Fern-Language", "JAVA");
put("X-Fern-SDK-Name", "com.pipedream.fern:api-sdk");
- put("X-Fern-SDK-Version", "2.1.4");
+ put("X-Fern-SDK-Version", "2.2.0");
}
});
this.headerSuppliers = headerSuppliers;
diff --git a/src/main/java/com/pipedream/api/core/OAuthTokenSupplier.java b/src/main/java/com/pipedream/api/core/OAuthTokenSupplier.java
index 66c8adf..a6f3f6a 100644
--- a/src/main/java/com/pipedream/api/core/OAuthTokenSupplier.java
+++ b/src/main/java/com/pipedream/api/core/OAuthTokenSupplier.java
@@ -17,6 +17,10 @@ public final class OAuthTokenSupplier implements Supplier {
private final String clientSecret;
+ private final String clientAssertionType;
+
+ private final String clientAssertion;
+
private final String scope;
private final OauthTokensClient authClient;
@@ -25,9 +29,17 @@ public final class OAuthTokenSupplier implements Supplier {
private Instant expiresAt;
- public OAuthTokenSupplier(String clientId, String clientSecret, String scope, OauthTokensClient authClient) {
+ public OAuthTokenSupplier(
+ String clientId,
+ String clientSecret,
+ String clientAssertionType,
+ String clientAssertion,
+ String scope,
+ OauthTokensClient authClient) {
this.clientId = clientId;
this.clientSecret = clientSecret;
+ this.clientAssertionType = clientAssertionType;
+ this.clientAssertion = clientAssertion;
this.scope = scope;
this.authClient = authClient;
this.expiresAt = Instant.now();
@@ -37,6 +49,8 @@ public CreateOAuthTokenResponse fetchToken() {
CreateOAuthTokenOpts getTokenRequest = CreateOAuthTokenOpts.builder()
.clientId(clientId)
.clientSecret(clientSecret)
+ .clientAssertionType(clientAssertionType)
+ .clientAssertion(clientAssertion)
.scope(scope)
.build();
return authClient.create(getTokenRequest);
diff --git a/src/main/java/com/pipedream/api/core/PrivateKeyJwtTokenSupplier.java b/src/main/java/com/pipedream/api/core/PrivateKeyJwtTokenSupplier.java
new file mode 100644
index 0000000..16f9480
--- /dev/null
+++ b/src/main/java/com/pipedream/api/core/PrivateKeyJwtTokenSupplier.java
@@ -0,0 +1,150 @@
+package com.pipedream.api.core;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.pipedream.api.errors.TooManyRequestsError;
+import com.pipedream.api.types.CreateOAuthTokenResponse;
+import java.io.IOException;
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.function.Supplier;
+import okhttp3.Headers;
+import okhttp3.HttpUrl;
+import okhttp3.OkHttpClient;
+import okhttp3.Request;
+import okhttp3.RequestBody;
+import okhttp3.Response;
+import okhttp3.ResponseBody;
+
+/**
+ * Supplies an {@code Authorization} header value ({@code Bearer }) for a client that
+ * authenticates with a private key. Access tokens are cached and refreshed like {@link
+ * OAuthTokenSupplier}, but each token request carries a newly signed client assertion.
+ *
+ * Token requests bypass the HTTP client's retry interceptor, which would resend the same
+ * assertion: the API rejects a reused one. Transient failures (408, 429, 5xx, network errors) are
+ * retried here instead, each attempt with a fresh assertion. Thread-safe.
+ */
+public final class PrivateKeyJwtTokenSupplier implements Supplier {
+ private static final long BUFFER_IN_MINUTES = 2;
+ private static final int MAX_RETRIES = 2;
+
+ private final String clientId;
+ private final String scope;
+ private final ClientAssertionSigner signer;
+ private final ClientOptions clientOptions;
+ private final OkHttpClient httpClient;
+
+ private String accessToken;
+ private Instant expiresAt = Instant.now();
+
+ /**
+ * @param clientOptions options for the token request (base URL, headers, HTTP client), without
+ * an {@code Authorization} header
+ */
+ public PrivateKeyJwtTokenSupplier(
+ String clientId, String scope, ClientAssertionSigner signer, ClientOptions clientOptions) {
+ this.clientId = clientId;
+ this.scope = scope;
+ this.signer = signer;
+ this.clientOptions = clientOptions;
+ OkHttpClient.Builder builder = clientOptions.httpClient().newBuilder();
+ builder.interceptors().removeIf(interceptor -> interceptor instanceof RetryInterceptor);
+ this.httpClient = builder.build();
+ }
+
+ /**
+ * The API's issuer identifier, sent as the assertion's {@code aud}: the origin of the base URL,
+ * without a trailing slash.
+ */
+ public static String issuer(String baseUrl) {
+ HttpUrl url = HttpUrl.parse(baseUrl);
+ if (url == null) {
+ throw new IllegalArgumentException("Invalid base URL: " + baseUrl);
+ }
+ boolean defaultPort = url.port() == HttpUrl.defaultPort(url.scheme());
+ return url.scheme() + "://" + url.host() + (defaultPort ? "" : ":" + url.port());
+ }
+
+ @Override
+ public synchronized String get() {
+ if (accessToken == null || expiresAt.isBefore(Instant.now())) {
+ CreateOAuthTokenResponse response = fetchToken();
+ this.accessToken = response.getAccessToken();
+ this.expiresAt = Instant.now()
+ .plus(response.getExpiresIn(), ChronoUnit.SECONDS)
+ .minus(BUFFER_IN_MINUTES, ChronoUnit.MINUTES);
+ }
+ return "Bearer " + accessToken;
+ }
+
+ private CreateOAuthTokenResponse fetchToken() {
+ for (int attempt = 0; ; attempt++) {
+ try {
+ return requestToken();
+ } catch (RuntimeException e) {
+ if (attempt >= MAX_RETRIES || !isRetryable(e)) {
+ throw e;
+ }
+ try {
+ Thread.sleep(500L << attempt);
+ } catch (InterruptedException interrupted) {
+ Thread.currentThread().interrupt();
+ throw e;
+ }
+ }
+ }
+ }
+
+ private CreateOAuthTokenResponse requestToken() {
+ Map body = new LinkedHashMap<>();
+ body.put("grant_type", "client_credentials");
+ body.put("client_id", clientId);
+ body.put("client_assertion_type", ClientAssertionSigner.CLIENT_ASSERTION_TYPE);
+ body.put("client_assertion", signer.sign());
+ if (scope != null) {
+ body.put("scope", scope);
+ }
+ Request request;
+ try {
+ request = new Request.Builder()
+ .url(HttpUrl.parse(clientOptions.environment().getUrl())
+ .newBuilder()
+ .addPathSegments("v1/oauth/token")
+ .build())
+ .post(RequestBody.create(
+ ObjectMappers.JSON_MAPPER.writeValueAsBytes(body), MediaTypes.APPLICATION_JSON))
+ .headers(Headers.of(clientOptions.headers(null)))
+ .addHeader("Content-Type", "application/json")
+ .addHeader("Accept", "application/json")
+ .build();
+ } catch (JsonProcessingException e) {
+ throw new BaseClientException("Failed to serialize request", e);
+ }
+ try (Response response = httpClient.newCall(request).execute()) {
+ ResponseBody responseBody = response.body();
+ String responseBodyString = responseBody != null ? responseBody.string() : "{}";
+ if (response.isSuccessful()) {
+ return ObjectMappers.JSON_MAPPER.readValue(responseBodyString, CreateOAuthTokenResponse.class);
+ }
+ Object errorBody = ObjectMappers.parseErrorBody(responseBodyString);
+ if (response.code() == 429) {
+ throw new TooManyRequestsError(errorBody, response);
+ }
+ throw new BaseClientApiException(
+ "Error with status code " + response.code(), response.code(), errorBody, response);
+ } catch (IOException e) {
+ throw new BaseClientException("Network error executing HTTP request", e);
+ }
+ }
+
+ private static boolean isRetryable(RuntimeException e) {
+ if (e instanceof BaseClientApiException) {
+ int status = ((BaseClientApiException) e).statusCode();
+ return status == 408 || status == 429 || status >= 500;
+ }
+ // Network errors; serialization errors don't recover on retry.
+ return e instanceof BaseClientException && e.getCause() instanceof IOException;
+ }
+}
diff --git a/src/main/java/com/pipedream/api/resources/oauthtokens/requests/CreateOAuthTokenOpts.java b/src/main/java/com/pipedream/api/resources/oauthtokens/requests/CreateOAuthTokenOpts.java
index da2395f..c0770ce 100644
--- a/src/main/java/com/pipedream/api/resources/oauthtokens/requests/CreateOAuthTokenOpts.java
+++ b/src/main/java/com/pipedream/api/resources/oauthtokens/requests/CreateOAuthTokenOpts.java
@@ -23,16 +23,27 @@
public final class CreateOAuthTokenOpts {
private final String clientId;
- private final String clientSecret;
+ private final Optional clientSecret;
+
+ private final Optional clientAssertionType;
+
+ private final Optional clientAssertion;
private final Optional scope;
private final Map additionalProperties;
private CreateOAuthTokenOpts(
- String clientId, String clientSecret, Optional scope, Map additionalProperties) {
+ String clientId,
+ Optional clientSecret,
+ Optional clientAssertionType,
+ Optional clientAssertion,
+ Optional scope,
+ Map additionalProperties) {
this.clientId = clientId;
this.clientSecret = clientSecret;
+ this.clientAssertionType = clientAssertionType;
+ this.clientAssertion = clientAssertion;
this.scope = scope;
this.additionalProperties = additionalProperties;
}
@@ -47,11 +58,30 @@ public String getClientId() {
return clientId;
}
+ /**
+ * @return The client secret, for clients that authenticate with a client secret.
+ */
@JsonProperty("client_secret")
- public String getClientSecret() {
+ public Optional getClientSecret() {
return clientSecret;
}
+ /**
+ * @return Required with client_assertion.
+ */
+ @JsonProperty("client_assertion_type")
+ public Optional getClientAssertionType() {
+ return clientAssertionType;
+ }
+
+ /**
+ * @return For clients that authenticate with a public key: a JWT signed with the client's private key (ES256 or RS256). Claims: iss and sub set to the client ID, aud set to https://api.pipedream.com, exp at most 1 hour ahead, and a unique jti. Sign a new assertion for every request, including retries.
+ */
+ @JsonProperty("client_assertion")
+ public Optional getClientAssertion() {
+ return clientAssertion;
+ }
+
/**
* @return Optional space-separated scopes for the access token. Defaults to *.
*/
@@ -72,12 +102,17 @@ public Map getAdditionalProperties() {
}
private boolean equalTo(CreateOAuthTokenOpts other) {
- return clientId.equals(other.clientId) && clientSecret.equals(other.clientSecret) && scope.equals(other.scope);
+ return clientId.equals(other.clientId)
+ && clientSecret.equals(other.clientSecret)
+ && clientAssertionType.equals(other.clientAssertionType)
+ && clientAssertion.equals(other.clientAssertion)
+ && scope.equals(other.scope);
}
@java.lang.Override
public int hashCode() {
- return Objects.hash(this.clientId, this.clientSecret, this.scope);
+ return Objects.hash(
+ this.clientId, this.clientSecret, this.clientAssertionType, this.clientAssertion, this.scope);
}
@java.lang.Override
@@ -90,15 +125,11 @@ public static ClientIdStage builder() {
}
public interface ClientIdStage {
- ClientSecretStage clientId(@NotNull String clientId);
+ _FinalStage clientId(@NotNull String clientId);
Builder from(CreateOAuthTokenOpts other);
}
- public interface ClientSecretStage {
- _FinalStage clientSecret(@NotNull String clientSecret);
- }
-
public interface _FinalStage {
CreateOAuthTokenOpts build();
@@ -106,6 +137,27 @@ public interface _FinalStage {
_FinalStage additionalProperties(Map additionalProperties);
+ /**
+ * The client secret, for clients that authenticate with a client secret.
+ */
+ _FinalStage clientSecret(Optional clientSecret);
+
+ _FinalStage clientSecret(String clientSecret);
+
+ /**
+ * Required with client_assertion.
+ */
+ _FinalStage clientAssertionType(Optional clientAssertionType);
+
+ _FinalStage clientAssertionType(String clientAssertionType);
+
+ /**
+ * For clients that authenticate with a public key: a JWT signed with the client's private key (ES256 or RS256). Claims: iss and sub set to the client ID, aud set to https://api.pipedream.com, exp at most 1 hour ahead, and a unique jti. Sign a new assertion for every request, including retries.
+ */
+ _FinalStage clientAssertion(Optional clientAssertion);
+
+ _FinalStage clientAssertion(String clientAssertion);
+
/**
* Optional space-separated scopes for the access token. Defaults to *.
*/
@@ -115,13 +167,17 @@ public interface _FinalStage {
}
@JsonIgnoreProperties(ignoreUnknown = true)
- public static final class Builder implements ClientIdStage, ClientSecretStage, _FinalStage {
+ public static final class Builder implements ClientIdStage, _FinalStage {
private String clientId;
- private String clientSecret;
-
private Optional scope = Optional.empty();
+ private Optional clientAssertion = Optional.empty();
+
+ private Optional clientAssertionType = Optional.empty();
+
+ private Optional clientSecret = Optional.empty();
+
@JsonAnySetter
private Map additionalProperties = new HashMap<>();
@@ -131,24 +187,19 @@ private Builder() {}
public Builder from(CreateOAuthTokenOpts other) {
clientId(other.getClientId());
clientSecret(other.getClientSecret());
+ clientAssertionType(other.getClientAssertionType());
+ clientAssertion(other.getClientAssertion());
scope(other.getScope());
return this;
}
@java.lang.Override
@JsonSetter("client_id")
- public ClientSecretStage clientId(@NotNull String clientId) {
+ public _FinalStage clientId(@NotNull String clientId) {
this.clientId = Objects.requireNonNull(clientId, "clientId must not be null");
return this;
}
- @java.lang.Override
- @JsonSetter("client_secret")
- public _FinalStage clientSecret(@NotNull String clientSecret) {
- this.clientSecret = Objects.requireNonNull(clientSecret, "clientSecret must not be null");
- return this;
- }
-
/**
* Optional space-separated scopes for the access token. Defaults to *.
* @return Reference to {@code this} so that method calls can be chained together.
@@ -169,9 +220,70 @@ public _FinalStage scope(Optional scope) {
return this;
}
+ /**
+ * For clients that authenticate with a public key: a JWT signed with the client's private key (ES256 or RS256). Claims: iss and sub set to the client ID, aud set to https://api.pipedream.com, exp at most 1 hour ahead, and a unique jti. Sign a new assertion for every request, including retries.
+ * @return Reference to {@code this} so that method calls can be chained together.
+ */
+ @java.lang.Override
+ public _FinalStage clientAssertion(String clientAssertion) {
+ this.clientAssertion = Optional.ofNullable(clientAssertion);
+ return this;
+ }
+
+ /**
+ * For clients that authenticate with a public key: a JWT signed with the client's private key (ES256 or RS256). Claims: iss and sub set to the client ID, aud set to https://api.pipedream.com, exp at most 1 hour ahead, and a unique jti. Sign a new assertion for every request, including retries.
+ */
+ @java.lang.Override
+ @JsonSetter(value = "client_assertion", nulls = Nulls.SKIP)
+ public _FinalStage clientAssertion(Optional clientAssertion) {
+ this.clientAssertion = clientAssertion;
+ return this;
+ }
+
+ /**
+ * Required with client_assertion.
+ * @return Reference to {@code this} so that method calls can be chained together.
+ */
+ @java.lang.Override
+ public _FinalStage clientAssertionType(String clientAssertionType) {
+ this.clientAssertionType = Optional.ofNullable(clientAssertionType);
+ return this;
+ }
+
+ /**
+ * Required with client_assertion.
+ */
+ @java.lang.Override
+ @JsonSetter(value = "client_assertion_type", nulls = Nulls.SKIP)
+ public _FinalStage clientAssertionType(Optional clientAssertionType) {
+ this.clientAssertionType = clientAssertionType;
+ return this;
+ }
+
+ /**
+ * The client secret, for clients that authenticate with a client secret.
+ * @return Reference to {@code this} so that method calls can be chained together.
+ */
+ @java.lang.Override
+ public _FinalStage clientSecret(String clientSecret) {
+ this.clientSecret = Optional.ofNullable(clientSecret);
+ return this;
+ }
+
+ /**
+ * The client secret, for clients that authenticate with a client secret.
+ */
+ @java.lang.Override
+ @JsonSetter(value = "client_secret", nulls = Nulls.SKIP)
+ public _FinalStage clientSecret(Optional clientSecret) {
+ this.clientSecret = clientSecret;
+ return this;
+ }
+
@java.lang.Override
public CreateOAuthTokenOpts build() {
- return new CreateOAuthTokenOpts(clientId, clientSecret, scope, additionalProperties);
+ return new CreateOAuthTokenOpts(
+ clientId, clientSecret, clientAssertionType, clientAssertion, scope, additionalProperties);
}
@java.lang.Override
diff --git a/src/test/java/com/pipedream/api/PrivateKeyJwtTest.java b/src/test/java/com/pipedream/api/PrivateKeyJwtTest.java
new file mode 100644
index 0000000..0a96ba5
--- /dev/null
+++ b/src/test/java/com/pipedream/api/PrivateKeyJwtTest.java
@@ -0,0 +1,354 @@
+package com.pipedream.api;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.pipedream.api.core.BaseClientApiException;
+import com.pipedream.api.core.ClientAssertionSigner;
+import com.pipedream.api.testutil.MockResponse;
+import com.pipedream.api.testutil.MockWebServer;
+import com.pipedream.api.testutil.RecordedRequest;
+import java.math.BigInteger;
+import java.nio.charset.StandardCharsets;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.PrivateKey;
+import java.security.PublicKey;
+import java.security.Signature;
+import java.security.interfaces.ECPrivateKey;
+import java.security.spec.ECGenParameterSpec;
+import java.util.Base64;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/** Private key (private_key_jwt) client authentication. */
+public class PrivateKeyJwtTest {
+ private static final String ASSERTION_TYPE = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer";
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+
+ private MockWebServer server;
+
+ @BeforeEach
+ public void setup() throws Exception {
+ server = new MockWebServer();
+ server.start();
+ }
+
+ @AfterEach
+ public void teardown() throws Exception {
+ server.shutdown();
+ }
+
+ // ── Signer ────────────────────────────────────────────────────────────────
+
+ @Test
+ public void signsEs256WithTheClientsClaimsAndAFreshJti() throws Exception {
+ // Arrange
+ KeyPair pair = ecKeyPair("secp256r1");
+ ClientAssertionSigner signer =
+ new ClientAssertionSigner("client_123", pem(pair.getPrivate()), null, "https://api.pipedream.com");
+
+ // Act
+ String first = signer.sign();
+ String second = signer.sign();
+
+ // Assert
+ Assertions.assertEquals("{\"alg\":\"ES256\",\"typ\":\"client-authentication+jwt\"}", decodePart(first, 0));
+ JsonNode claims = MAPPER.readTree(decodePart(first, 1));
+ Assertions.assertEquals("client_123", claims.get("iss").asText());
+ Assertions.assertEquals("client_123", claims.get("sub").asText());
+ Assertions.assertEquals("https://api.pipedream.com", claims.get("aud").asText());
+ Assertions.assertEquals(
+ 60, claims.get("exp").asLong() - claims.get("iat").asLong());
+ Assertions.assertNotEquals(
+ claims.get("jti").asText(),
+ MAPPER.readTree(decodePart(second, 1)).get("jti").asText());
+ Assertions.assertTrue(verifies(first, pair.getPublic()));
+ }
+
+ @Test
+ public void es256SignaturesAlwaysVerify() throws Exception {
+ // Many signatures, so r and s values with leading zero bytes (shorter DER INTEGERs) and with
+ // a high bit (a leading 0x00 sign byte) are both exercised by the DER-to-raw conversion.
+ KeyPair pair = ecKeyPair("secp256r1");
+ ClientAssertionSigner signer = new ClientAssertionSigner("c", pem(pair.getPrivate()), null, "aud");
+ for (int i = 0; i < 300; i++) {
+ Assertions.assertTrue(verifies(signer.sign(), pair.getPublic()));
+ }
+ }
+
+ @Test
+ public void signsRs256WithAnRsaKeyAndSendsTheKeyId() throws Exception {
+ // Arrange
+ KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
+ generator.initialize(2048);
+ KeyPair pair = generator.generateKeyPair();
+ ClientAssertionSigner signer =
+ new ClientAssertionSigner("c", pem(pair.getPrivate()), "key-2026", "https://api.pipedream.com");
+
+ // Act
+ String assertion = signer.sign();
+
+ // Assert
+ JsonNode header = MAPPER.readTree(decodePart(assertion, 0));
+ Assertions.assertEquals("RS256", header.get("alg").asText());
+ Assertions.assertEquals("key-2026", header.get("kid").asText());
+ Assertions.assertTrue(verifies(assertion, pair.getPublic()));
+ }
+
+ @Test
+ public void acceptsAJwkPrivateKeyAndUsesItsKid() throws Exception {
+ // Arrange
+ KeyPair pair = ecKeyPair("secp256r1");
+ String d = Base64.getUrlEncoder()
+ .withoutPadding()
+ .encodeToString(unsigned32(((ECPrivateKey) pair.getPrivate()).getS()));
+ String jwk = "{\"kty\":\"EC\",\"crv\":\"P-256\",\"d\":\"" + d + "\",\"kid\":\"jwk-kid\"}";
+ ClientAssertionSigner signer = new ClientAssertionSigner("c", jwk, null, "https://api.pipedream.com");
+
+ // Act
+ String assertion = signer.sign();
+
+ // Assert
+ Assertions.assertEquals(
+ "jwk-kid", MAPPER.readTree(decodePart(assertion, 0)).get("kid").asText());
+ Assertions.assertTrue(verifies(assertion, pair.getPublic()));
+ }
+
+ @Test
+ public void rejectsKeysItCannotUse() throws Exception {
+ String publicKey = "-----BEGIN PUBLIC KEY-----\n"
+ + Base64.getMimeEncoder()
+ .encodeToString(ecKeyPair("secp256r1").getPublic().getEncoded())
+ + "\n-----END PUBLIC KEY-----";
+ String[] keys = {
+ publicKey,
+ pem(ecKeyPair("secp384r1").getPrivate()),
+ "-----BEGIN EC PRIVATE KEY-----\nMHcCAQEE\n-----END EC PRIVATE KEY-----",
+ "not a key",
+ };
+ for (String key : keys) {
+ IllegalArgumentException e = Assertions.assertThrows(
+ IllegalArgumentException.class, () -> new ClientAssertionSigner("c", key, null, "aud"));
+ Assertions.assertTrue(e.getMessage().startsWith("privateKey must be"), e.getMessage());
+ }
+ }
+
+ // ── Client ────────────────────────────────────────────────────────────────
+
+ @Test
+ public void authenticatesWithAClientAssertionAndCachesTheToken() throws Exception {
+ // Arrange
+ KeyPair pair = ecKeyPair("secp256r1");
+ enqueueToken("token-1");
+ enqueueAccounts();
+ enqueueAccounts();
+ PipedreamClient client = keyClient(pem(pair.getPrivate()));
+
+ // Act
+ client.accounts().list();
+ client.accounts().list();
+
+ // Assert: one token request, then two API calls.
+ RecordedRequest tokenRequest = server.takeRequest();
+ Assertions.assertEquals("/v1/oauth/token", tokenRequest.getPath());
+ JsonNode body = MAPPER.readTree(tokenRequest.getBody().readUtf8());
+ Assertions.assertEquals("client_credentials", body.get("grant_type").asText());
+ Assertions.assertEquals("client_123", body.get("client_id").asText());
+ Assertions.assertEquals(
+ ASSERTION_TYPE, body.get("client_assertion_type").asText());
+ Assertions.assertFalse(body.has("client_secret"));
+ String assertion = body.get("client_assertion").asText();
+ Assertions.assertTrue(verifies(assertion, pair.getPublic()));
+ Assertions.assertEquals(
+ "kid-1", MAPPER.readTree(decodePart(assertion, 0)).get("kid").asText());
+ // The audience is the API's issuer: the base URL's origin.
+ Assertions.assertEquals(
+ issuerOf(server.url("/").toString()),
+ MAPPER.readTree(decodePart(assertion, 1)).get("aud").asText());
+ Assertions.assertTrue(server.takeRequest().getPath().contains("/accounts"));
+ Assertions.assertTrue(server.takeRequest().getPath().contains("/accounts"));
+ }
+
+ @Test
+ public void retriesATransientFailureWithANewlySignedAssertion() throws Exception {
+ // Arrange
+ server.enqueue(new MockResponse().setResponseCode(503).setBody("{\"error\":\"temporarily_unavailable\"}"));
+ enqueueToken("token-2");
+ enqueueAccounts();
+ PipedreamClient client = keyClient(pem(ecKeyPair("secp256r1").getPrivate()));
+
+ // Act
+ client.accounts().list();
+
+ // Assert
+ String firstJti = jtiOf(server.takeRequest());
+ String secondJti = jtiOf(server.takeRequest());
+ Assertions.assertNotEquals(firstJti, secondJti);
+ }
+
+ @Test
+ public void doesNotRetryARejectedAssertion() throws Exception {
+ // Arrange
+ server.enqueue(new MockResponse().setResponseCode(401).setBody("{\"error\":\"invalid_client\"}"));
+ // A retry would get this token and move on, so the 401 surfacing proves there was none.
+ enqueueToken("unused");
+ PipedreamClient client = keyClient(pem(ecKeyPair("secp256r1").getPrivate()));
+
+ // Act
+ BaseClientApiException e = Assertions.assertThrows(
+ BaseClientApiException.class, () -> client.accounts().list());
+
+ // Assert
+ Assertions.assertEquals(401, e.statusCode());
+ Assertions.assertEquals("/v1/oauth/token", server.takeRequest().getPath());
+ }
+
+ @Test
+ public void refusesAClientSecretAndAPrivateKeyTogether() throws Exception {
+ String key = pem(ecKeyPair("secp256r1").getPrivate());
+ IllegalStateException e =
+ Assertions.assertThrows(IllegalStateException.class, () -> new PipedreamClientBuilder()
+ .url(server.url("/").toString())
+ .clientId("c")
+ .clientSecret("s")
+ .privateKey(key)
+ .build());
+ Assertions.assertTrue(e.getMessage().contains("not both"));
+ }
+
+ @Test
+ public void anExplicitPrivateKeyOverridesASecretFromTheEnvironment() throws Exception {
+ // Arrange
+ enqueueToken("token-1");
+ enqueueAccounts();
+ PipedreamClient client = new PipedreamClientBuilder()
+ .url(server.url("/").toString())
+ .clientId("client_123")
+ .environmentCredentials("env-secret", null, null)
+ .privateKey(pem(ecKeyPair("secp256r1").getPrivate()))
+ .projectId("proj_123")
+ .build();
+
+ // Act
+ client.accounts().list();
+
+ // Assert
+ JsonNode body = MAPPER.readTree(server.takeRequest().getBody().readUtf8());
+ Assertions.assertTrue(body.has("client_assertion"));
+ Assertions.assertFalse(body.has("client_secret"));
+ }
+
+ @Test
+ public void refusesBothCredentialsFromTheEnvironment() throws Exception {
+ String key = pem(ecKeyPair("secp256r1").getPrivate());
+ IllegalStateException e =
+ Assertions.assertThrows(IllegalStateException.class, () -> new PipedreamClientBuilder()
+ .url(server.url("/").toString())
+ .clientId("c")
+ .environmentCredentials("env-secret", key, null)
+ .build());
+ Assertions.assertTrue(e.getMessage().contains("set only the one your client uses"));
+ }
+
+ @Test
+ public void asyncClientAuthenticatesWithAClientAssertion() throws Exception {
+ // Arrange
+ enqueueToken("token-1");
+ enqueueAccounts();
+ AsyncPipedreamClient client = new AsyncPipedreamClientBuilder()
+ .url(server.url("/").toString())
+ .clientId("client_123")
+ .privateKey(pem(ecKeyPair("secp256r1").getPrivate()))
+ .projectId("proj_123")
+ .build();
+
+ // Act
+ client.accounts().list().get();
+
+ // Assert
+ JsonNode body = MAPPER.readTree(server.takeRequest().getBody().readUtf8());
+ Assertions.assertEquals(
+ ASSERTION_TYPE, body.get("client_assertion_type").asText());
+ Assertions.assertFalse(body.has("client_secret"));
+ }
+
+ // ── Helpers ───────────────────────────────────────────────────────────────
+
+ private PipedreamClient keyClient(String privateKey) {
+ // new PipedreamClientBuilder() (vs PipedreamClient.builder()) keeps PIPEDREAM_* environment
+ // variables out of the test.
+ return new PipedreamClientBuilder()
+ .url(server.url("/").toString())
+ .clientId("client_123")
+ .privateKey(privateKey)
+ .keyId("kid-1")
+ .projectId("proj_123")
+ .maxRetries(0)
+ .build();
+ }
+
+ private void enqueueToken(String accessToken) {
+ server.enqueue(new MockResponse()
+ .setResponseCode(200)
+ .setHeader("Content-Type", "application/json")
+ .setBody("{\"access_token\":\"" + accessToken + "\",\"token_type\":\"bearer\",\"expires_in\":3600}"));
+ }
+
+ private void enqueueAccounts() {
+ server.enqueue(new MockResponse()
+ .setResponseCode(200)
+ .setHeader("Content-Type", "application/json")
+ .setBody("{\"data\":[],\"page_info\":{}}"));
+ }
+
+ private static String jtiOf(RecordedRequest request) throws Exception {
+ String assertion = MAPPER.readTree(request.getBody().readUtf8())
+ .get("client_assertion")
+ .asText();
+ return MAPPER.readTree(decodePart(assertion, 1)).get("jti").asText();
+ }
+
+ private static String issuerOf(String url) {
+ okhttp3.HttpUrl parsed = okhttp3.HttpUrl.parse(url);
+ return parsed.scheme() + "://" + parsed.host() + ":" + parsed.port();
+ }
+
+ private static KeyPair ecKeyPair(String curve) throws Exception {
+ KeyPairGenerator generator = KeyPairGenerator.getInstance("EC");
+ generator.initialize(new ECGenParameterSpec(curve));
+ return generator.generateKeyPair();
+ }
+
+ private static String pem(PrivateKey key) {
+ return "-----BEGIN PRIVATE KEY-----\n" + Base64.getMimeEncoder().encodeToString(key.getEncoded())
+ + "\n-----END PRIVATE KEY-----\n";
+ }
+
+ private static byte[] unsigned32(BigInteger value) {
+ byte[] bytes = value.toByteArray();
+ if (bytes.length == 32) {
+ return bytes;
+ }
+ byte[] out = new byte[32];
+ int copy = Math.min(bytes.length, 32);
+ System.arraycopy(bytes, bytes.length - copy, out, 32 - copy, copy);
+ return out;
+ }
+
+ private static String decodePart(String jwt, int index) {
+ return new String(Base64.getUrlDecoder().decode(jwt.split("\\.")[index]), StandardCharsets.UTF_8);
+ }
+
+ private static boolean verifies(String jwt, PublicKey publicKey) throws Exception {
+ String[] parts = jwt.split("\\.");
+ String alg = MAPPER.readTree(decodePart(jwt, 0)).get("alg").asText();
+ // Tests run on JDK 11+, which verifies the raw r||s (IEEE P1363) form JWS uses directly.
+ Signature signature =
+ Signature.getInstance("ES256".equals(alg) ? "SHA256withECDSAinP1363Format" : "SHA256withRSA");
+ signature.initVerify(publicKey);
+ signature.update((parts[0] + "." + parts[1]).getBytes(StandardCharsets.US_ASCII));
+ return signature.verify(Base64.getUrlDecoder().decode(parts[2]));
+ }
+}
diff --git a/src/test/java/com/pipedream/api/core/ClientAssertionSignerDerTest.java b/src/test/java/com/pipedream/api/core/ClientAssertionSignerDerTest.java
new file mode 100644
index 0000000..9f0d366
--- /dev/null
+++ b/src/test/java/com/pipedream/api/core/ClientAssertionSignerDerTest.java
@@ -0,0 +1,35 @@
+package com.pipedream.api.core;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+/** The DER-to-raw ECDSA signature conversion used for ES256. */
+public class ClientAssertionSignerDerTest {
+ @Test
+ public void padsShortIntegersAndDropsSignBytes() {
+ // Arrange: r is 33 bytes (a 0x00 sign byte, then 0xff...), s is 31 bytes (one leading zero byte omitted).
+ byte[] r = new byte[33];
+ r[0] = 0x00;
+ for (int i = 1; i < 33; i++) r[i] = (byte) 0xff;
+ byte[] s = new byte[31];
+ for (int i = 0; i < 31; i++) s[i] = 0x11;
+ byte[] der = new byte[2 + 2 + r.length + 2 + s.length];
+ der[0] = 0x30;
+ der[1] = (byte) (der.length - 2);
+ der[2] = 0x02;
+ der[3] = (byte) r.length;
+ System.arraycopy(r, 0, der, 4, r.length);
+ der[4 + r.length] = 0x02;
+ der[5 + r.length] = (byte) s.length;
+ System.arraycopy(s, 0, der, 6 + r.length, s.length);
+
+ // Act
+ byte[] raw = ClientAssertionSigner.derToRaw(der);
+
+ // Assert
+ Assertions.assertEquals(64, raw.length);
+ for (int i = 0; i < 32; i++) Assertions.assertEquals((byte) 0xff, raw[i]);
+ Assertions.assertEquals(0x00, raw[32]);
+ for (int i = 33; i < 64; i++) Assertions.assertEquals(0x11, raw[i]);
+ }
+}