From eec2b9f7728ce5b2e498100c2f17e55aea92cab4 Mon Sep 17 00:00:00 2001 From: Dylan Sather Date: Wed, 7 Oct 2026 12:31:01 -0700 Subject: [PATCH 1/2] feat: authenticate with a private key (private_key_jwt) instead of a client secret Clients registered with a public key set `privateKey` (PKCS#8 PEM or JWK) instead of `clientSecret`, with an optional `keyId`: PipedreamClient.builder().clientId(id).privateKey(pem).projectId(p).build() The SDK signs a short-lived client assertion (RFC 7523) for each token request: ES256 or RS256 inferred from the key, iss/sub = client ID, aud = the API's issuer (the base URL's origin), a 60s lifetime and a random single-use jti. Token caching is unchanged. Token requests bypass the retry interceptor (which would resend the same assertion) and are retried with a newly signed assertion instead. Works for PipedreamClient and AsyncPipedreamClient. clientSecret and privateKey can't be combined; PipedreamClient.builder() also reads PIPEDREAM_PRIVATE_KEY and PIPEDREAM_KEY_ID, and explicitly set credentials win over environment ones. JDK only (Java 8 compatible), no new dependencies. The token request is built by the custom supplier, so it doesn't depend on the generated CreateOAuthTokenOpts staged builder, which changes when Fern regenerates from the updated spec. Version 2.2.0. Co-Authored-By: Claude Opus 5.5 (1M context) --- .fern/metadata.json | 2 +- .fernignore | 6 + README.md | 4 +- build.gradle | 4 +- .../pipedream/api/AsyncPipedreamClient.java | 5 +- .../api/AsyncPipedreamClientBuilder.java | 74 +++- .../com/pipedream/api/PipedreamClient.java | 5 +- .../pipedream/api/PipedreamClientBuilder.java | 74 +++- .../api/core/ClientAssertionSigner.java | 205 ++++++++++ .../com/pipedream/api/core/ClientOptions.java | 4 +- .../api/core/PrivateKeyJwtTokenSupplier.java | 150 ++++++++ .../com/pipedream/api/PrivateKeyJwtTest.java | 354 ++++++++++++++++++ .../core/ClientAssertionSignerDerTest.java | 35 ++ 13 files changed, 909 insertions(+), 13 deletions(-) create mode 100644 src/main/java/com/pipedream/api/core/ClientAssertionSigner.java create mode 100644 src/main/java/com/pipedream/api/core/PrivateKeyJwtTokenSupplier.java create mode 100644 src/test/java/com/pipedream/api/PrivateKeyJwtTest.java create mode 100644 src/test/java/com/pipedream/api/core/ClientAssertionSignerDerTest.java diff --git a/.fern/metadata.json b/.fern/metadata.json index 4d20afc..ba6e54a 100644 --- a/.fern/metadata.json +++ b/.fern/metadata.json @@ -14,5 +14,5 @@ "originGitCommitIsDirty": false, "invokedBy": "ci", "ciProvider": "github", - "sdkVersion": "2.1.4" + "sdkVersion": "2.2.0" } \ No newline at end of file diff --git a/.fernignore b/.fernignore index a53bd4d..6a46004 100644 --- a/.fernignore +++ b/.fernignore @@ -23,6 +23,10 @@ src/main/java/com/pipedream/api/BaseClientBuilder.java # Custom Connect path normalization (strips the empty project-ID segment) src/main/java/com/pipedream/api/core/ConnectPathNormalizationInterceptor.java +# Custom private key (private_key_jwt) client authentication +src/main/java/com/pipedream/api/core/ClientAssertionSigner.java +src/main/java/com/pipedream/api/core/PrivateKeyJwtTokenSupplier.java + # Custom Pipedream client files src/main/java/com/pipedream/api/AsyncPipedreamClient.java src/main/java/com/pipedream/api/AsyncPipedreamClientBuilder.java @@ -56,6 +60,8 @@ src/test/java/com/pipedream/api/ConnectTokenProjectIdWireTest.java src/test/java/com/pipedream/api/OauthTokensWireTest.java src/test/java/com/pipedream/api/ProxyClientWireTest.java src/test/java/com/pipedream/api/TokensWireTest.java +src/test/java/com/pipedream/api/PrivateKeyJwtTest.java +src/test/java/com/pipedream/api/core/ClientAssertionSignerDerTest.java # Test utilities (JDK-only mock-server shim used by wire tests) src/test/java/com/pipedream/api/testutil/MockResponse.java diff --git a/README.md b/README.md index d09c55c..7a953a0 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ Add the dependency in your `build.gradle` file: ```groovy dependencies { - implementation 'com.pipedream:pipedream:2.1.4' + implementation 'com.pipedream:pipedream:2.2.0' } ``` @@ -42,7 +42,7 @@ Add the dependency in your `pom.xml` file: com.pipedream pipedream - 2.1.4 + 2.2.0 ``` diff --git a/build.gradle b/build.gradle index 11350e3..001ca7b 100644 --- a/build.gradle +++ b/build.gradle @@ -48,7 +48,7 @@ java { group = 'com.pipedream' -version = '2.1.4' +version = '2.2.0' jar { dependsOn(":generatePomFileForMavenPublication") @@ -79,7 +79,7 @@ publishing { maven(MavenPublication) { groupId = 'com.pipedream' artifactId = 'pipedream' - version = '2.1.4' + version = '2.2.0' from components.java pom { name = 'pipedream' diff --git a/src/main/java/com/pipedream/api/AsyncPipedreamClient.java b/src/main/java/com/pipedream/api/AsyncPipedreamClient.java index 25eefd5..b98571a 100644 --- a/src/main/java/com/pipedream/api/AsyncPipedreamClient.java +++ b/src/main/java/com/pipedream/api/AsyncPipedreamClient.java @@ -31,7 +31,10 @@ public static AsyncPipedreamClientBuilder builder() { : Environment.PROD.getUrl(); return new AsyncPipedreamClientBuilder() .clientId(System.getenv("PIPEDREAM_CLIENT_ID")) - .clientSecret(System.getenv("PIPEDREAM_CLIENT_SECRET")) + .environmentCredentials( + System.getenv("PIPEDREAM_CLIENT_SECRET"), + System.getenv("PIPEDREAM_PRIVATE_KEY"), + System.getenv("PIPEDREAM_KEY_ID")) .url(baseUrl) .projectEnvironment(System.getenv("PIPEDREAM_PROJECT_ENVIRONMENT")) .projectId(System.getenv("PIPEDREAM_PROJECT_ID")); diff --git a/src/main/java/com/pipedream/api/AsyncPipedreamClientBuilder.java b/src/main/java/com/pipedream/api/AsyncPipedreamClientBuilder.java index 0e7264b..e995a42 100644 --- a/src/main/java/com/pipedream/api/AsyncPipedreamClientBuilder.java +++ b/src/main/java/com/pipedream/api/AsyncPipedreamClientBuilder.java @@ -1,8 +1,10 @@ package com.pipedream.api; +import com.pipedream.api.core.ClientAssertionSigner; import com.pipedream.api.core.ClientOptions; import com.pipedream.api.core.ConnectPathNormalizationInterceptor; import com.pipedream.api.core.OAuthTokenSupplier; +import com.pipedream.api.core.PrivateKeyJwtTokenSupplier; import com.pipedream.api.resources.oauthtokens.OauthTokensClient; import java.util.function.Supplier; import okhttp3.OkHttpClient; @@ -15,8 +17,15 @@ public final class AsyncPipedreamClientBuilder extends AsyncBaseClientBuilder 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); + return new OAuthTokenSupplier(this.clientId, secret, this.scope, authClient); } return () -> ""; @@ -82,6 +120,38 @@ public AsyncPipedreamClientBuilder 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 + * AsyncPipedreamClientbuilder()}. + */ + public AsyncPipedreamClientBuilder 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 + * AsyncPipedreamClientbuilder()}. + */ + public AsyncPipedreamClientBuilder keyId(final String keyId) { + this.keyId = keyId; + return this; + } + + /** Credentials from the environment, used only when none are set explicitly. */ + AsyncPipedreamClientBuilder 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/PipedreamClient.java b/src/main/java/com/pipedream/api/PipedreamClient.java index 7ed33c6..44b66a9 100644 --- a/src/main/java/com/pipedream/api/PipedreamClient.java +++ b/src/main/java/com/pipedream/api/PipedreamClient.java @@ -31,7 +31,10 @@ public static PipedreamClientBuilder builder() { : Environment.PROD.getUrl(); return new PipedreamClientBuilder() .clientId(System.getenv("PIPEDREAM_CLIENT_ID")) - .clientSecret(System.getenv("PIPEDREAM_CLIENT_SECRET")) + .environmentCredentials( + System.getenv("PIPEDREAM_CLIENT_SECRET"), + System.getenv("PIPEDREAM_PRIVATE_KEY"), + System.getenv("PIPEDREAM_KEY_ID")) .url(baseUrl) .projectEnvironment(System.getenv("PIPEDREAM_PROJECT_ENVIRONMENT")) .projectId(System.getenv("PIPEDREAM_PROJECT_ID")); diff --git a/src/main/java/com/pipedream/api/PipedreamClientBuilder.java b/src/main/java/com/pipedream/api/PipedreamClientBuilder.java index abfe9c0..c210198 100644 --- a/src/main/java/com/pipedream/api/PipedreamClientBuilder.java +++ b/src/main/java/com/pipedream/api/PipedreamClientBuilder.java @@ -1,8 +1,10 @@ package com.pipedream.api; +import com.pipedream.api.core.ClientAssertionSigner; import com.pipedream.api.core.ClientOptions; import com.pipedream.api.core.ConnectPathNormalizationInterceptor; import com.pipedream.api.core.OAuthTokenSupplier; +import com.pipedream.api.core.PrivateKeyJwtTokenSupplier; import com.pipedream.api.resources.oauthtokens.OauthTokensClient; import java.util.function.Supplier; import okhttp3.OkHttpClient; @@ -15,8 +17,15 @@ public final class PipedreamClientBuilder extends BaseClientBuilder 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); + return new OAuthTokenSupplier(this.clientId, secret, this.scope, authClient); } return () -> ""; @@ -82,6 +120,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/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/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]); + } +} From 50b9f537eb8fa6204b13b8f03deb267bbbafdf4d Mon Sep 17 00:00:00 2001 From: Dylan Sather Date: Wed, 7 Oct 2026 13:40:29 -0700 Subject: [PATCH 2/2] fix: match the regenerated OAuthTokenSupplier and CreateOAuthTokenOpts With client_assertion fields in the token request spec, Fern regenerates OAuthTokenSupplier with a six-argument constructor (adding clientAssertionType and clientAssertion), which broke compilation of the custom builders that call it. Take the generated OAuthTokenSupplier and CreateOAuthTokenOpts now (identical to a local Fern generation from the updated spec) and pass null assertion fields from the client-secret call sites, so the SDK builds both before and after the regeneration. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../pipedream/api/AsyncBaseClientBuilder.java | 2 +- .../api/AsyncPipedreamClientBuilder.java | 3 +- .../com/pipedream/api/BaseClientBuilder.java | 2 +- .../pipedream/api/PipedreamClientBuilder.java | 3 +- .../api/core/OAuthTokenSupplier.java | 16 +- .../requests/CreateOAuthTokenOpts.java | 156 +++++++++++++++--- 6 files changed, 155 insertions(+), 27 deletions(-) diff --git a/src/main/java/com/pipedream/api/AsyncBaseClientBuilder.java b/src/main/java/com/pipedream/api/AsyncBaseClientBuilder.java index e6ad3c8..2920c1e 100644 --- a/src/main/java/com/pipedream/api/AsyncBaseClientBuilder.java +++ b/src/main/java/com/pipedream/api/AsyncBaseClientBuilder.java @@ -331,7 +331,7 @@ public AsyncBaseClient build() { ClientOptions baseOptions = buildClientOptions(); OauthTokensClient authClient = new OauthTokensClient(baseOptions); OAuthTokenSupplier oAuthTokenSupplier = - new OAuthTokenSupplier(this.clientId, this.clientSecret, this.scope, authClient); + new OAuthTokenSupplier(this.clientId, this.clientSecret, null, null, this.scope, authClient); ClientOptions finalOptions = ClientOptions.Builder.from(baseOptions) .addHeader("Authorization", oAuthTokenSupplier) .build(); diff --git a/src/main/java/com/pipedream/api/AsyncPipedreamClientBuilder.java b/src/main/java/com/pipedream/api/AsyncPipedreamClientBuilder.java index e995a42..c493b36 100644 --- a/src/main/java/com/pipedream/api/AsyncPipedreamClientBuilder.java +++ b/src/main/java/com/pipedream/api/AsyncPipedreamClientBuilder.java @@ -76,7 +76,8 @@ private Supplier getAuthHeaderSupplier(final ClientOptions baseOptions) if (this.clientId != null && secret != null) { final OauthTokensClient authClient = new OauthTokensClient(baseOptions); - return new OAuthTokenSupplier(this.clientId, secret, this.scope, authClient); + // The client-secret flow sends no client assertion. + return new OAuthTokenSupplier(this.clientId, secret, null, null, this.scope, authClient); } return () -> ""; diff --git a/src/main/java/com/pipedream/api/BaseClientBuilder.java b/src/main/java/com/pipedream/api/BaseClientBuilder.java index 74601de..5fb2550 100644 --- a/src/main/java/com/pipedream/api/BaseClientBuilder.java +++ b/src/main/java/com/pipedream/api/BaseClientBuilder.java @@ -331,7 +331,7 @@ public BaseClient build() { ClientOptions baseOptions = buildClientOptions(); OauthTokensClient authClient = new OauthTokensClient(baseOptions); OAuthTokenSupplier oAuthTokenSupplier = - new OAuthTokenSupplier(this.clientId, this.clientSecret, this.scope, authClient); + new OAuthTokenSupplier(this.clientId, this.clientSecret, null, null, this.scope, authClient); ClientOptions finalOptions = ClientOptions.Builder.from(baseOptions) .addHeader("Authorization", oAuthTokenSupplier) .build(); diff --git a/src/main/java/com/pipedream/api/PipedreamClientBuilder.java b/src/main/java/com/pipedream/api/PipedreamClientBuilder.java index c210198..c253941 100644 --- a/src/main/java/com/pipedream/api/PipedreamClientBuilder.java +++ b/src/main/java/com/pipedream/api/PipedreamClientBuilder.java @@ -76,7 +76,8 @@ private Supplier getAuthHeaderSupplier(final ClientOptions baseOptions) if (this.clientId != null && secret != null) { final OauthTokensClient authClient = new OauthTokensClient(baseOptions); - return new OAuthTokenSupplier(this.clientId, secret, this.scope, authClient); + // The client-secret flow sends no client assertion. + return new OAuthTokenSupplier(this.clientId, secret, null, null, this.scope, authClient); } return () -> ""; 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/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