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/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/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..c493b36 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); + // 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 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/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/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..c253941 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); + // 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]); + } +}