From ca4c24f91059f2a2e81c7611a9897bbe787620f6 Mon Sep 17 00:00:00 2001 From: Dylan Sather Date: Wed, 7 Oct 2026 12:17:18 -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 pass `private_key` (PEM or JWK) instead of `client_secret`, with an optional `key_id`: Pipedream(client_id=..., private_key=..., project_id=..., project_environment=...) 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 authenticated with an assertion are retried with a newly signed assertion, since the API rejects a reused one. Works for Pipedream and AsyncPipedream. client_secret and private_key can't be combined; PIPEDREAM_PRIVATE_KEY and PIPEDREAM_KEY_ID are read from the environment. Signing needs the new optional extra: pip install "pipedream[private-key]" (pyjwt[crypto]). Client-secret users need nothing new. CI installs the extra. The oauth_tokens create methods match the API spec change (optional client_secret, client_assertion_type, client_assertion) that the next Fern regeneration produces. Version 2.2.0. Co-Authored-By: Claude Opus 5.5 (1M context) --- .fern/metadata.json | 2 +- .fernignore | 2 + .github/workflows/ci.yml | 6 +- CLAUDE.md | 21 +- poetry.lock | 219 +++++++++++++++++- pyproject.toml | 4 +- src/pipedream/core/client_wrapper.py | 4 +- src/pipedream/oauth_tokens/client.py | 44 +++- src/pipedream/oauth_tokens/raw_client.py | 34 ++- src/pipedream/pipedream.py | 103 +++++++++ src/pipedream/private_key_jwt.py | 204 +++++++++++++++++ tests/custom/test_private_key_jwt.py | 276 +++++++++++++++++++++++ 12 files changed, 900 insertions(+), 19 deletions(-) create mode 100644 src/pipedream/private_key_jwt.py create mode 100644 tests/custom/test_private_key_jwt.py diff --git a/.fern/metadata.json b/.fern/metadata.json index 5900364..e6a3cfa 100644 --- a/.fern/metadata.json +++ b/.fern/metadata.json @@ -14,5 +14,5 @@ "originGitCommitIsDirty": false, "invokedBy": "ci", "ciProvider": "github", - "sdkVersion": "2.1.20" + "sdkVersion": "2.2.0" } \ No newline at end of file diff --git a/.fernignore b/.fernignore index bca5c43..d8b3340 100644 --- a/.fernignore +++ b/.fernignore @@ -19,7 +19,9 @@ MIGRATE.md # Custom Pipedream client files src/pipedream/__init__.py src/pipedream/pipedream.py +src/pipedream/private_key_jwt.py tests/custom/test_connect_token_project_id.py +tests/custom/test_private_key_jwt.py # Custom Proxy files src/pipedream/proxy/client.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bf9c2ad..9aba437 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,7 @@ jobs: run: | curl -sSL https://install.python-poetry.org | python - -y --version 1.5.1 - name: Install dependencies - run: poetry install + run: poetry install --extras private-key - name: Compile run: poetry run mypy . test: @@ -35,13 +35,13 @@ jobs: run: | curl -sSL https://install.python-poetry.org | python - -y --version 1.5.1 - name: Install dependencies - run: poetry install + run: poetry install --extras private-key - name: Test run: poetry run pytest -rP -n auto . - name: Install aiohttp extra - run: poetry install --extras aiohttp + run: poetry install --extras "aiohttp private-key" - name: Test (aiohttp) run: poetry run pytest -rP -n auto -m aiohttp . diff --git a/CLAUDE.md b/CLAUDE.md index d5ca6e5..f483f6e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,7 +36,8 @@ Two layers of clients exist: pipedream import Pipedream`). The wrapper adds: env-var resolution (`PIPEDREAM_ACCESS_TOKEN`, -`PIPEDREAM_CLIENT_ID`, `PIPEDREAM_CLIENT_SECRET`, `PIPEDREAM_PROJECT_ID`, +`PIPEDREAM_CLIENT_ID`, `PIPEDREAM_CLIENT_SECRET`, `PIPEDREAM_PRIVATE_KEY`, +`PIPEDREAM_KEY_ID`, `PIPEDREAM_PROJECT_ID`, `PIPEDREAM_PROJECT_ENVIRONMENT`, `PIPEDREAM_BASE_URL`, `PIPEDREAM_WORKFLOW_DOMAIN`), the `access_token` shorthand (turns into a `token=lambda: …` callable when calling `super().__init__`), `project_id` @@ -87,6 +88,24 @@ generated base — `Client` constructs an `OAuthTokenProvider` and threads its `get_token` into a `SyncClientWrapper`. The wrapper does **not** override this; it just decides which branch to take. +**Private key (`private_key`, optional `key_id`)**: the client authenticates +with RFC 7523 client assertions instead of a secret. This path is custom +(`src/pipedream/private_key_jwt.py`, in `.fernignore`): `ClientAssertionSigner` +infers ES256/RS256 from the key, sets `aud` to the base URL's origin, and signs +a new single-use `jti` per token request. `PrivateKeyJwtTokenProvider` / +`AsyncPrivateKeyJwtTokenProvider` cache the token like the generated provider, +but send token requests with `max_retries: 0` and retry them themselves with a +**newly signed** assertion, because the API rejects a reused `jti`. They use +their own token-less client wrapper over the same `httpx` client. The sync +wrapper passes `token=provider.get_token`. The async wrapper sets +`_async_token`, mirroring the generated async OAuth wiring, so +`AsyncPipedream.raw_access_token` is `None` there, as it is for async client +secrets. Signing needs the optional `private-key` extra (`pyjwt[crypto]`), +imported lazily. Install it in CI, and declare it in Fern's `generators.yml` +`extras` so a regen doesn't drop it from `pyproject.toml`. `client_secret` and +`private_key` are mutually exclusive (`_resolve_credentials` in +`pipedream.py`). + `raw_access_token` reaches into `self._client_wrapper._get_token()` to expose the current bearer token regardless of which branch was taken. Don't make `_get_token` private at the wrapper level. diff --git a/poetry.lock b/poetry.lock index 90c82c6..da19cab 100644 --- a/poetry.lock +++ b/poetry.lock @@ -242,6 +242,118 @@ files = [ {file = "certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55"}, ] +[[package]] +name = "cffi" +version = "2.1.1" +description = "Foreign Function Interface for Python calling C code." +optional = true +python-versions = ">=3.10" +files = [ + {file = "cffi-2.1.1-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:baed1e86cc735622097354b9d1281406caf42ff42a886d29faa8e8d1630333be"}, + {file = "cffi-2.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ca82be1a1d406ecfe1d25dc16cb33488e5a16bf4438c9fb590484ea29d92478b"}, + {file = "cffi-2.1.1-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:42e2f76b9455f5a9a844f770bf3e200ed3da0e15f5df3db9c31fe80b04b3d004"}, + {file = "cffi-2.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5a59cc1c4442bc3d5c703bf720b51138d0bfc173618807c9ee2490a7541dd3d9"}, + {file = "cffi-2.1.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:9f8d177621de5cb38ee3e731eda45d421db093ec0739f46a5594babda7987a98"}, + {file = "cffi-2.1.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:75f80557d1389eddbd0de2681f6a390a0c5338c31ddaa821381c203fc3fd50d9"}, + {file = "cffi-2.1.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:194cffa889098ced9976c3fc6340305e43f6303657d298da55366907c05c22d6"}, + {file = "cffi-2.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5bb4e7ea95dcd6a014a6fef62e62467d67d8e582326443f3d68e71d6320a9fcf"}, + {file = "cffi-2.1.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3d22a20b1fb1632cc72c22f95f7b0d2961c3e1c235f245ba4c606c4771035659"}, + {file = "cffi-2.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1dea0e4d7d4f11f619fe8c1d76caf49e24405b4b5743c0e3be16a500ecd930c9"}, + {file = "cffi-2.1.1-cp310-cp310-win32.whl", hash = "sha256:7ce713ace7c0e4520535b42b77eaa742c16dab813978064913e5a3cf82973b41"}, + {file = "cffi-2.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:a48d62ab9d6f4f98c983223a547af44be6ca3691074c31cecced6facd3ba2dc1"}, + {file = "cffi-2.1.1-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12"}, + {file = "cffi-2.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1"}, + {file = "cffi-2.1.1-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0"}, + {file = "cffi-2.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813"}, + {file = "cffi-2.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990"}, + {file = "cffi-2.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af"}, + {file = "cffi-2.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632"}, + {file = "cffi-2.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd"}, + {file = "cffi-2.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a"}, + {file = "cffi-2.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa"}, + {file = "cffi-2.1.1-cp311-cp311-win32.whl", hash = "sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3"}, + {file = "cffi-2.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0"}, + {file = "cffi-2.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455"}, + {file = "cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0"}, + {file = "cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf"}, + {file = "cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a"}, + {file = "cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890"}, + {file = "cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50"}, + {file = "cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e"}, + {file = "cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf"}, + {file = "cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517"}, + {file = "cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735"}, + {file = "cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e"}, + {file = "cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a"}, + {file = "cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80"}, + {file = "cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e"}, + {file = "cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c"}, + {file = "cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6"}, + {file = "cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971"}, + {file = "cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c"}, + {file = "cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125"}, + {file = "cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264"}, + {file = "cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3"}, + {file = "cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2"}, + {file = "cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b"}, + {file = "cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7"}, + {file = "cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac"}, + {file = "cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d"}, + {file = "cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973"}, + {file = "cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c"}, + {file = "cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb"}, + {file = "cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54"}, + {file = "cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72"}, + {file = "cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1"}, + {file = "cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062"}, + {file = "cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03"}, + {file = "cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96"}, + {file = "cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527"}, + {file = "cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13"}, + {file = "cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c"}, + {file = "cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48"}, + {file = "cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836"}, + {file = "cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3"}, + {file = "cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2"}, + {file = "cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94"}, + {file = "cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc"}, + {file = "cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29"}, + {file = "cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676"}, + {file = "cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e"}, + {file = "cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f"}, + {file = "cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4"}, + {file = "cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e"}, + {file = "cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5"}, + {file = "cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d"}, + {file = "cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b"}, + {file = "cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4"}, + {file = "cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8"}, + {file = "cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6"}, + {file = "cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80"}, + {file = "cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779"}, + {file = "cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399"}, + {file = "cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688"}, + {file = "cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7"}, + {file = "cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac"}, + {file = "cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960"}, + {file = "cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1"}, + {file = "cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc"}, + {file = "cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab"}, + {file = "cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e"}, + {file = "cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358"}, + {file = "cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231"}, + {file = "cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6"}, + {file = "cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94"}, + {file = "cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5"}, + {file = "cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66"}, + {file = "cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3"}, + {file = "cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692"}, + {file = "cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be"}, +] + +[package.dependencies] +pycparser = {version = "*", markers = "implementation_name != \"PyPy\""} + [[package]] name = "colorama" version = "0.4.6" @@ -253,6 +365,81 @@ files = [ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +[[package]] +name = "cryptography" +version = "50.0.2" +description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." +optional = true +python-versions = "!=3.9.0,!=3.9.1,>=3.9" +files = [ + {file = "cryptography-50.0.2-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:fa8f5efb344d6908a1ce62f4a24e2e5780f825d6f53f5f50ec5ffacac72936cb"}, + {file = "cryptography-50.0.2-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:79def8d059362e7831389ed3be0ecdf58a89386e1271e35dd9f5af84e81bffd0"}, + {file = "cryptography-50.0.2-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:630ebfea3bf689d075f82316324ff7433dc447fe6bc1bfc76524b74b4a9567d2"}, + {file = "cryptography-50.0.2-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f9f6143a8c75945eb960d9eb98905a441394abfa24afaae239d514ffb2586480"}, + {file = "cryptography-50.0.2-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:a582ab2ae1d34f67112cadc86702774c9ea4374df6bca6afe672817203c99134"}, + {file = "cryptography-50.0.2-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:4061c0079120205fb760c58acab6443e217307dcf05e3702cf970e0689972856"}, + {file = "cryptography-50.0.2-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:ac9ed99d81760c62fe89d5f0815cdfa1ba9a35141cf30f1c2d044f04b4803d2e"}, + {file = "cryptography-50.0.2-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:87e9ce85beb6b328ba370cc6e6aea483c92617b4c95b1d33a49297eb662bfb04"}, + {file = "cryptography-50.0.2-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f265528741e048bce55c3463ed721fb0aa45a5888d8add8cfeccb3035451bbdc"}, + {file = "cryptography-50.0.2-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dab55f57c74c3cad24c323bacbbd04be4705ba6eb0d92e920b1fc4837ed5079"}, + {file = "cryptography-50.0.2-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:25784ce8b9621c90c643efb9e1e2162ab3b0224cae446ad5e70e7fcb1ce18b51"}, + {file = "cryptography-50.0.2-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:85d0d9a31b9098e98534226d5686b47264b95e62ce459dc2e62fdfc809f9fe93"}, + {file = "cryptography-50.0.2-cp311-abi3-win_amd64.whl", hash = "sha256:7afa5a6602a9f29af1f3a2965f831bae7c9d5d597b7cbb716d41ab3b7d89879c"}, + {file = "cryptography-50.0.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f785f6161f202ab04d8ca194158968798e480ca058943907972da5f12e2881e8"}, + {file = "cryptography-50.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0ecbc5652bdb6fc9eaf89a7d196e20941adfe812f43bc4ca05d9150496821047"}, + {file = "cryptography-50.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ab50ee449bf968271e820086f10a33d101dd060370abc10bcd22279be2656539"}, + {file = "cryptography-50.0.2-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a9f7355e6fab51f6c369b86fb7571cffa05edee2c2121e0380a37fb9ac1cd5c1"}, + {file = "cryptography-50.0.2-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:94e5e9f108ee10471288214d3d233fbfbb492840a8457eb85178d643ddeb32c7"}, + {file = "cryptography-50.0.2-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:241449bf940a5d27309bd317e6f9a2af6932113818bb2b8f5c59ddc7ef16da18"}, + {file = "cryptography-50.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d8947001be83df1394050758ce0e745dd74fb134eef0a4b5124208dfc3a68c37"}, + {file = "cryptography-50.0.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4a20ce1e5cb4284a86692fdcba7cb8754185c6b2e5c56fcef3751cf451d3cdc2"}, + {file = "cryptography-50.0.2-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:84f964e537f916e2cc85199e5a88742e964939b575ac8598b3f9d6cc416cdaf1"}, + {file = "cryptography-50.0.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:828d49b0ff5a0e3975865571c5d91dbbdd0d38d8289b249a163e9425413a5e05"}, + {file = "cryptography-50.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:deb9fde5c60e437ee4821bc9bc39ff31b42135c27e1dc61ef0a629389c1de62e"}, + {file = "cryptography-50.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8c71ba2cd31fc93748c38e1b613200ff1c2665cbfd5341fe3a61cfde35a1430e"}, + {file = "cryptography-50.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:78198641e5be9521beea5aa782bb551a58068d10e6eb04c9c680c1b69f2e7d45"}, + {file = "cryptography-50.0.2-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:edc3342adf8f697fc5f59c887a304356f147b397809440ed64e2fa6af2f50f37"}, + {file = "cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d370b8d1dfcdf7130178137f6fbee6140774a1acc6cacefc4b42643ec11d0a3a"}, + {file = "cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f2f9bd7f90c64fe89253f0a2c05e3c4856072660429ce8831b4235bf29403a67"}, + {file = "cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_aarch64.whl", hash = "sha256:e275096ea1e60cc595cda2836fd4a6c725d1125108b868be17f53684d164e2cc"}, + {file = "cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_ppc64le.whl", hash = "sha256:b13478603dcd0a2479ff8e87e2c19a7d525734686fe3c49542472293a204212d"}, + {file = "cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_x86_64.whl", hash = "sha256:58a0c478eeca76fe5e07993c5a0703def34a6dc6a0cda4f5564639b33112ffe7"}, + {file = "cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_31_armv7l.whl", hash = "sha256:d38cdff612d06fa6a32840d5e1b1f7a27cee4a349aa9085d94a67789d6bfd408"}, + {file = "cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_aarch64.whl", hash = "sha256:fdd28f912fccfec1846a94e2e1e8f9b0012f557f0c46fe4f3eb0d7a87afcf90b"}, + {file = "cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_ppc64le.whl", hash = "sha256:cbc8738fd8526d80f35cb3a40d41f41a2e7030bb3b18b09a6778ef63d291c2fd"}, + {file = "cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_x86_64.whl", hash = "sha256:e105ab60406787da31fccc883fc0f733af1efd78f0136a4599692c4083a73d0c"}, + {file = "cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:6f8700550aa1474a91e5dc07049c46f98b423b5b1ddd0483e0b51362eeeaf5be"}, + {file = "cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:c71be1cbfa5cd9a41ee452acf1eccd82b2c05950358b106ec8ceb83411d1a020"}, + {file = "cryptography-50.0.2-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:c423ab384a46c4dff7217b2ea5ba2e11cffdeab6441acd04cf65a369caf0366c"}, + {file = "cryptography-50.0.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:0ec5f09541743261e66e291b4a0cbf0fb2997aeaab6d9e9c740b9dba1b58d1c2"}, + {file = "cryptography-50.0.2-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c5e67125c7dca78d199ec4e116aa93dbb83494808ecbb8211a2cb09b1bf41dbd"}, + {file = "cryptography-50.0.2-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ee247f5c245c9a2fe7c8e2214e295918838e44e00a45a6718451e4004219e767"}, + {file = "cryptography-50.0.2-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:dfe9763530994147d9af1def057a5b9658b00e8f8fe8743d144d1e0911c2e454"}, + {file = "cryptography-50.0.2-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:58ddb5a8e3179d12f19e4ea34d2d32e9d63a4baa142c875c1eb59f41b7243acd"}, + {file = "cryptography-50.0.2-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f21e8a22c8605750c7af886bab299a363721264061b4ac0a30efb73cfd58efc5"}, + {file = "cryptography-50.0.2-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:9c8402a82ea0dc4ceeab793db05f0fafa8ca139ca34fcde5df0f596103c74107"}, + {file = "cryptography-50.0.2-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:0ddc924c04591c2811ca024d62ecad4f7f6f08af8939c211438f48a16bd23602"}, + {file = "cryptography-50.0.2-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a6557e5f38e065ca9fbdaf7cfc7435ecb1d113aa81a022d1b51921ee7432e227"}, + {file = "cryptography-50.0.2-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:1981f1db4630889b9ef7803fadef12b056f428cb6b85c27ba57b774793b6093c"}, + {file = "cryptography-50.0.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a8701d6b584d76e909e3d305b7d126b41439876a5aaf76cddc67fc230eafa2e"}, + {file = "cryptography-50.0.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ce47f66801c20ec6c6632453bb5960fe38939e9306970b48b3a5a26de7745d94"}, + {file = "cryptography-50.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:4e81d95e5bafc2d6e34e4bed780e53e4d5b9a2f928573428aa4d35fbec1eb0de"}, + {file = "cryptography-50.0.2-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:92e665960f25fcdc73725b9cec7a3824f279ba97a98653afe9ffac2e43668f67"}, + {file = "cryptography-50.0.2-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:eef4c2f3423810b3070ab391f85436d2f8bbfcb286ac15cbc73190b3563b1f1a"}, + {file = "cryptography-50.0.2-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:7c6d0330c472d96f6a6afe24d80dfdf15176c33096f0a4397ae4c60f3dd3be48"}, + {file = "cryptography-50.0.2-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:1ba34f04897fcdaa73f74145c25f3ec146fbd56593853e88adc2e811303c5f42"}, + {file = "cryptography-50.0.2-pp311-pypy311_pp80-macosx_11_0_arm64.whl", hash = "sha256:3dc4fd8058cea1644971207d530e1a03a184a805ffc8ebdddf0599d78a331b81"}, + {file = "cryptography-50.0.2-pp311-pypy311_pp80-win_amd64.whl", hash = "sha256:7b75de3c8b3be1cdb1052747c929440c3eea46c1bc2cb8a6e3a48388e9b7b452"}, + {file = "cryptography-50.0.2.tar.gz", hash = "sha256:7b46165bb56eb4704e2eaaf86f3c940d19154535d9b0ca7d6d590b04060e00d5"}, +] + +[package.dependencies] +cffi = {version = ">=2.0.0", markers = "platform_python_implementation != \"PyPy\""} +typing-extensions = {version = ">=4.13.2", markers = "python_full_version < \"3.11\""} + +[package.extras] +ssh = ["bcrypt (>=3.1.5)"] + [[package]] name = "exceptiongroup" version = "1.3.1" @@ -897,6 +1084,17 @@ files = [ {file = "propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427"}, ] +[[package]] +name = "pycparser" +version = "3.0" +description = "C parser in Python" +optional = true +python-versions = ">=3.10" +files = [ + {file = "pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992"}, + {file = "pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29"}, +] + [[package]] name = "pydantic" version = "2.13.5" @@ -1064,6 +1262,24 @@ files = [ [package.extras] windows-terminal = ["colorama (>=0.4.6)"] +[[package]] +name = "pyjwt" +version = "2.15.1" +description = "JSON Web Token implementation in Python" +optional = true +python-versions = ">=3.9" +files = [ + {file = "pyjwt-2.15.1-py3-none-any.whl", hash = "sha256:42d59d631f7768a1028a64c7ff581a9bf7519804daf91fc5b6c56e30eec5e193"}, + {file = "pyjwt-2.15.1.tar.gz", hash = "sha256:4f259e80cdfb6b3fc18a7de51fd1ef9ec79652f25019bae68975ca2468a34df8"}, +] + +[package.dependencies] +cryptography = {version = ">=3.4.0", optional = true, markers = "extra == \"crypto\""} +typing_extensions = {version = ">=4.0", markers = "python_version < \"3.11\""} + +[package.extras] +crypto = ["cryptography (>=3.4.0)"] + [[package]] name = "pytest" version = "9.1.1" @@ -1408,8 +1624,9 @@ propcache = ">=0.2.1" [extras] aiohttp = ["aiohttp", "httpx-aiohttp"] +private-key = ["pyjwt"] [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "747cf6806d0d2a32dda32c0f4beb23d48047dee5fab394a6dbe55935c60b949a" +content-hash = "6d9c4c1a1e9d6521731fbebde2c56142bd0a81dc7031967ddd6ddfc12ea205dd" diff --git a/pyproject.toml b/pyproject.toml index dffbd81..2ab0ac6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ dynamic = ["version"] [tool.poetry] name = "pipedream" -version = "2.1.20" +version = "2.2.0" description = "" readme = "README.md" authors = [] @@ -43,6 +43,7 @@ httpx-aiohttp = { version = "0.1.8", optional = true, python = ">=3.9"} pydantic = ">= 1.9.2" pydantic-core = ">=2.18.2,<3.0.0" typing_extensions = ">= 4.0.0" +pyjwt = { version = ">=2.8.0,<3", optional = true, extras = ["crypto"] } [tool.poetry.group.dev.dependencies] mypy = "==1.13.0" @@ -94,3 +95,4 @@ build-backend = "poetry.core.masonry.api" [tool.poetry.extras] aiohttp=["aiohttp", "httpx-aiohttp"] +private-key=["pyjwt"] diff --git a/src/pipedream/core/client_wrapper.py b/src/pipedream/core/client_wrapper.py index ae305cf..074d9ca 100644 --- a/src/pipedream/core/client_wrapper.py +++ b/src/pipedream/core/client_wrapper.py @@ -34,12 +34,12 @@ def get_headers(self) -> typing.Dict[str, str]: import platform headers: typing.Dict[str, str] = { - "User-Agent": "pipedream/2.1.20", + "User-Agent": "pipedream/2.2.0", "X-Fern-Language": "Python", "X-Fern-Runtime": f"python/{platform.python_version()}", "X-Fern-Platform": f"{platform.system().lower()}/{platform.release()}", "X-Fern-SDK-Name": "pipedream", - "X-Fern-SDK-Version": "2.1.20", + "X-Fern-SDK-Version": "2.2.0", **(self.get_custom_headers() or {}), } if self._project_environment is not None: diff --git a/src/pipedream/oauth_tokens/client.py b/src/pipedream/oauth_tokens/client.py index 128ce57..94e10ae 100644 --- a/src/pipedream/oauth_tokens/client.py +++ b/src/pipedream/oauth_tokens/client.py @@ -30,7 +30,11 @@ def create( self, *, client_id: str, - client_secret: str, + client_secret: typing.Optional[str] = OMIT, + client_assertion_type: typing.Optional[ + typing.Literal["urn:ietf:params:oauth:client-assertion-type:jwt-bearer"] + ] = OMIT, + client_assertion: typing.Optional[str] = OMIT, scope: typing.Optional[str] = OMIT, request_options: typing.Optional[RequestOptions] = None, ) -> CreateOAuthTokenResponse: @@ -41,7 +45,14 @@ def create( ---------- client_id : str - client_secret : str + client_secret : typing.Optional[str] + The client secret, for clients that authenticate with a client secret. + + client_assertion_type : typing.Optional[typing.Literal["urn:ietf:params:oauth:client-assertion-type:jwt-bearer"]] + Required with `client_assertion`. + + client_assertion : typing.Optional[str] + 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. scope : typing.Optional[str] Optional space-separated scopes for the access token. Defaults to `*`. @@ -69,7 +80,12 @@ def create( ) """ _response = self._raw_client.create( - client_id=client_id, client_secret=client_secret, scope=scope, request_options=request_options + client_id=client_id, + client_secret=client_secret, + client_assertion_type=client_assertion_type, + client_assertion=client_assertion, + scope=scope, + request_options=request_options, ) return _response.data @@ -93,7 +109,11 @@ async def create( self, *, client_id: str, - client_secret: str, + client_secret: typing.Optional[str] = OMIT, + client_assertion_type: typing.Optional[ + typing.Literal["urn:ietf:params:oauth:client-assertion-type:jwt-bearer"] + ] = OMIT, + client_assertion: typing.Optional[str] = OMIT, scope: typing.Optional[str] = OMIT, request_options: typing.Optional[RequestOptions] = None, ) -> CreateOAuthTokenResponse: @@ -104,7 +124,14 @@ async def create( ---------- client_id : str - client_secret : str + client_secret : typing.Optional[str] + The client secret, for clients that authenticate with a client secret. + + client_assertion_type : typing.Optional[typing.Literal["urn:ietf:params:oauth:client-assertion-type:jwt-bearer"]] + Required with `client_assertion`. + + client_assertion : typing.Optional[str] + 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. scope : typing.Optional[str] Optional space-separated scopes for the access token. Defaults to `*`. @@ -140,6 +167,11 @@ async def main() -> None: asyncio.run(main()) """ _response = await self._raw_client.create( - client_id=client_id, client_secret=client_secret, scope=scope, request_options=request_options + client_id=client_id, + client_secret=client_secret, + client_assertion_type=client_assertion_type, + client_assertion=client_assertion, + scope=scope, + request_options=request_options, ) return _response.data diff --git a/src/pipedream/oauth_tokens/raw_client.py b/src/pipedream/oauth_tokens/raw_client.py index fe934fa..f487022 100644 --- a/src/pipedream/oauth_tokens/raw_client.py +++ b/src/pipedream/oauth_tokens/raw_client.py @@ -25,7 +25,11 @@ def create( self, *, client_id: str, - client_secret: str, + client_secret: typing.Optional[str] = OMIT, + client_assertion_type: typing.Optional[ + typing.Literal["urn:ietf:params:oauth:client-assertion-type:jwt-bearer"] + ] = OMIT, + client_assertion: typing.Optional[str] = OMIT, scope: typing.Optional[str] = OMIT, request_options: typing.Optional[RequestOptions] = None, ) -> HttpResponse[CreateOAuthTokenResponse]: @@ -36,7 +40,14 @@ def create( ---------- client_id : str - client_secret : str + client_secret : typing.Optional[str] + The client secret, for clients that authenticate with a client secret. + + client_assertion_type : typing.Optional[typing.Literal["urn:ietf:params:oauth:client-assertion-type:jwt-bearer"]] + Required with `client_assertion`. + + client_assertion : typing.Optional[str] + 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. scope : typing.Optional[str] Optional space-separated scopes for the access token. Defaults to `*`. @@ -55,6 +66,8 @@ def create( json={ "client_id": client_id, "client_secret": client_secret, + "client_assertion_type": client_assertion_type, + "client_assertion": client_assertion, "scope": scope, "grant_type": "client_credentials", }, @@ -103,7 +116,11 @@ async def create( self, *, client_id: str, - client_secret: str, + client_secret: typing.Optional[str] = OMIT, + client_assertion_type: typing.Optional[ + typing.Literal["urn:ietf:params:oauth:client-assertion-type:jwt-bearer"] + ] = OMIT, + client_assertion: typing.Optional[str] = OMIT, scope: typing.Optional[str] = OMIT, request_options: typing.Optional[RequestOptions] = None, ) -> AsyncHttpResponse[CreateOAuthTokenResponse]: @@ -114,7 +131,14 @@ async def create( ---------- client_id : str - client_secret : str + client_secret : typing.Optional[str] + The client secret, for clients that authenticate with a client secret. + + client_assertion_type : typing.Optional[typing.Literal["urn:ietf:params:oauth:client-assertion-type:jwt-bearer"]] + Required with `client_assertion`. + + client_assertion : typing.Optional[str] + 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. scope : typing.Optional[str] Optional space-separated scopes for the access token. Defaults to `*`. @@ -133,6 +157,8 @@ async def create( json={ "client_id": client_id, "client_secret": client_secret, + "client_assertion_type": client_assertion_type, + "client_assertion": client_assertion, "scope": scope, "grant_type": "client_credentials", }, diff --git a/src/pipedream/pipedream.py b/src/pipedream/pipedream.py index e0ffa05..db09c64 100644 --- a/src/pipedream/pipedream.py +++ b/src/pipedream/pipedream.py @@ -5,6 +5,12 @@ from .client import AsyncClient, Client from .core.client_wrapper import AsyncClientWrapper, SyncClientWrapper from .core.logging import LogConfig, Logger +from .private_key_jwt import ( + AsyncPrivateKeyJwtTokenProvider, + ClientAssertionSigner, + PrivateKeyJwtTokenProvider, + issuer_from_base_url, +) from .types.project_environment import ProjectEnvironment from .workflows.client import AsyncWorkflowsClient, WorkflowsClient @@ -31,6 +37,52 @@ def install_request_hook( ).append(hook) +def _resolve_credentials( + client_secret: typing.Optional[str], + private_key: typing.Optional[str], +) -> typing.Tuple[typing.Optional[str], typing.Optional[str]]: + """Returns (client_secret, private_key) with at most one of them set. + + An explicitly passed credential wins over the other one in the environment. + Passing both, or setting both PIPEDREAM_CLIENT_SECRET and + PIPEDREAM_PRIVATE_KEY with neither passed, raises ValueError. + """ + env_secret = os.getenv("PIPEDREAM_CLIENT_SECRET") + # client_secret defaults to the environment variable, so a value equal to + # it is treated as coming from the environment. + secret_passed = bool(client_secret) and client_secret != env_secret + if private_key and secret_passed: + raise ValueError("Pass either client_secret or private_key, not both") + if private_key: + return None, private_key + if secret_passed: + return client_secret, None + env_key = os.getenv("PIPEDREAM_PRIVATE_KEY") + if client_secret and env_key: + raise ValueError( + "Both PIPEDREAM_CLIENT_SECRET and PIPEDREAM_PRIVATE_KEY are set; set only the one your client uses" + ) + return client_secret, env_key + + +def _assertion_signer( + client_id: typing.Optional[str], + private_key: str, + key_id: typing.Optional[str], + base_url: str, +) -> ClientAssertionSigner: + if not client_id: + raise ValueError("client_id is required when authenticating with private_key") + return ClientAssertionSigner( + client_id=client_id, + private_key=private_key, + key_id=key_id or os.getenv("PIPEDREAM_KEY_ID"), + # The assertion audience is the API's issuer identifier: the base URL's + # origin (https://api.pipedream.com by default). + audience=issuer_from_base_url(base_url), + ) + + class Pipedream(Client): def __init__( @@ -39,6 +91,8 @@ def __init__( access_token: typing.Optional[str] = os.getenv("PIPEDREAM_ACCESS_TOKEN"), client_id: typing.Optional[str] = os.getenv("PIPEDREAM_CLIENT_ID"), client_secret: typing.Optional[str] = os.getenv("PIPEDREAM_CLIENT_SECRET"), + private_key: typing.Optional[str] = None, + key_id: typing.Optional[str] = None, project_id: typing.Optional[str] = os.getenv("PIPEDREAM_PROJECT_ID"), project_environment: typing.Optional[ProjectEnvironment] = os.getenv( "PIPEDREAM_PROJECT_ENVIRONMENT", @@ -73,8 +127,30 @@ def __init__( logging=logging, ) + client_secret, private_key = _resolve_credentials(client_secret, private_key) + if access_token: super().__init__(token=(lambda: access_token), **common_kwargs) + elif private_key: + signer = _assertion_signer(client_id, private_key, key_id, resolved_base_url) + super().__init__(token=lambda: self._token_provider.get_token(), **common_kwargs) + # Token requests use their own unauthenticated wrapper over the same + # HTTP client, like the generated client-secret flow. + wrapper = self._client_wrapper + self._token_provider = PrivateKeyJwtTokenProvider( + client_id=typing.cast(str, client_id), + signer=signer, + client_wrapper=SyncClientWrapper( + project_id=project_id, + project_environment=project_environment, + headers=headers, + base_url=wrapper.get_base_url(), + timeout=wrapper.get_timeout(), + max_retries=wrapper.get_max_retries(), + logging=logging, + httpx_client=wrapper.httpx_client.httpx_client, + ), + ) else: super().__init__(client_id=client_id, client_secret=client_secret, **common_kwargs) @@ -101,6 +177,8 @@ def __init__( access_token: typing.Optional[str] = os.getenv("PIPEDREAM_ACCESS_TOKEN"), client_id: typing.Optional[str] = os.getenv("PIPEDREAM_CLIENT_ID"), client_secret: typing.Optional[str] = os.getenv("PIPEDREAM_CLIENT_SECRET"), + private_key: typing.Optional[str] = None, + key_id: typing.Optional[str] = None, project_id: typing.Optional[str] = os.getenv("PIPEDREAM_PROJECT_ID"), project_environment: typing.Optional[ProjectEnvironment] = os.getenv( "PIPEDREAM_PROJECT_ENVIRONMENT", @@ -135,8 +213,33 @@ def __init__( logging=logging, ) + client_secret, private_key = _resolve_credentials(client_secret, private_key) + if access_token: super().__init__(token=(lambda: access_token), **common_kwargs) + elif private_key: + signer = _assertion_signer(client_id, private_key, key_id, resolved_base_url) + super().__init__(token=(lambda: ""), **common_kwargs) + # Wire the async token provider the way the generated client-secret + # flow does: no sync token, an async one, and token requests on + # their own unauthenticated wrapper over the same HTTP client. + wrapper = self._client_wrapper + token_provider = AsyncPrivateKeyJwtTokenProvider( + client_id=typing.cast(str, client_id), + signer=signer, + client_wrapper=AsyncClientWrapper( + project_id=project_id, + project_environment=project_environment, + headers=headers, + base_url=wrapper.get_base_url(), + timeout=wrapper.get_timeout(), + max_retries=wrapper.get_max_retries(), + logging=logging, + httpx_client=wrapper.httpx_client.httpx_client, + ), + ) + wrapper._token = None + wrapper._async_token = token_provider.get_token else: super().__init__(client_id=client_id, client_secret=client_secret, **common_kwargs) diff --git a/src/pipedream/private_key_jwt.py b/src/pipedream/private_key_jwt.py new file mode 100644 index 0000000..5c412f6 --- /dev/null +++ b/src/pipedream/private_key_jwt.py @@ -0,0 +1,204 @@ +"""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. Requires the optional ``private-key`` extra: +``pip install "pipedream[private-key]"`` (PyJWT with cryptography). +""" + +import asyncio +import datetime as dt +import json +import threading +import time +import typing +import uuid +from urllib.parse import urlsplit + +import httpx +from .core.api_error import ApiError +from .core.client_wrapper import AsyncClientWrapper, SyncClientWrapper +from .oauth_tokens.client import AsyncOauthTokensClient, OauthTokensClient +from .types.create_o_auth_token_response import CreateOAuthTokenResponse + +CLIENT_ASSERTION_TYPE: typing.Literal["urn:ietf:params:oauth:client-assertion-type:jwt-bearer"] = ( + "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" +) + +# Assertions are single-use and only need to outlive one token request. +_ASSERTION_LIFETIME_SECONDS = 60 +# Token requests authenticated with an assertion are retried here rather than +# by the HTTP client, so each attempt carries a newly signed assertion: the +# server rejects a reused one. +_MAX_RETRIES = 2 +_TOKEN_BUFFER = dt.timedelta(minutes=2) + + +def issuer_from_base_url(base_url: str) -> str: + """The API's issuer identifier: the base URL's origin, without a trailing slash.""" + parts = urlsplit(base_url) + return f"{parts.scheme}://{parts.netloc}" + + +class ClientAssertionSigner: + """Signs a new client assertion on every call to :meth:`sign`. + + The assertion is a JWT with ``iss`` and ``sub`` set to the client ID, the + given ``aud``, a 60-second lifetime, and a random single-use ``jti``. The + private key may be a PEM string (PKCS#8, PKCS#1, or SEC1) or a JWK as a JSON + string; EC P-256 keys sign with ES256 and RSA keys with RS256. ``key_id`` + is sent as the ``kid`` header and defaults to the JWK's ``kid``, if any. + The key is parsed and validated on construction. + """ + + def __init__(self, *, client_id: str, private_key: str, audience: str, key_id: typing.Optional[str] = None): + try: + import jwt # noqa: F401 (PyJWT) + from cryptography.hazmat.primitives.asymmetric import ec, rsa + except ImportError as e: + raise ImportError( + 'Authenticating with a private key requires the "private-key" extra: ' + 'pip install "pipedream[private-key]"' + ) from e + + self._client_id = client_id + self._audience = audience + self._key, jwk_kid = _load_private_key(private_key.strip()) + self._key_id = key_id or jwk_kid + + if isinstance(self._key, ec.EllipticCurvePrivateKey) and isinstance(self._key.curve, ec.SECP256R1): + self._alg = "ES256" + elif isinstance(self._key, rsa.RSAPrivateKey): + self._alg = "RS256" + else: + raise ValueError("private_key must be an EC P-256 key (ES256) or an RSA key (RS256)") + + def sign(self) -> str: + import jwt + + now = int(time.time()) + headers: typing.Dict[str, str] = {"typ": "client-authentication+jwt"} + if self._key_id: + headers["kid"] = self._key_id + claims = { + "iss": self._client_id, + "sub": self._client_id, + "aud": self._audience, + "iat": now, + "exp": now + _ASSERTION_LIFETIME_SECONDS, + "jti": str(uuid.uuid4()), + } + return jwt.encode(claims, typing.cast(typing.Any, self._key), algorithm=self._alg, headers=headers) + + +def _load_private_key(text: str) -> typing.Tuple[typing.Any, typing.Optional[str]]: + import jwt + from cryptography.hazmat.primitives.serialization import load_pem_private_key + + try: + if text.startswith("{"): + jwk = json.loads(text) + key = jwt.PyJWK(jwk).key + kid = jwk.get("kid") if isinstance(jwk.get("kid"), str) else None + if not hasattr(key, "sign"): + raise ValueError("not a private key") + return key, kid + return load_pem_private_key(text.encode(), password=None), None + except Exception as e: + raise ValueError( + "private_key must be a private key in PEM format or a JWK (JSON). " + "If you passed a public key, pass the private key instead." + ) from e + + +def _is_retryable(error: Exception) -> bool: + # Rate limits, timeouts, connection failures, and server errors are + # transient; 4xx errors such as invalid_client are not. + if isinstance(error, (httpx.TimeoutException, httpx.TransportError)): + return True + if isinstance(error, ApiError): + status = error.status_code + return status is None or status in (408, 429) or status >= 500 + return False + + +def _expires_at(token: CreateOAuthTokenResponse) -> dt.datetime: + return dt.datetime.now() + dt.timedelta(seconds=token.expires_in) - _TOKEN_BUFFER + + +class PrivateKeyJwtTokenProvider: + """Retrieves and caches an OAuth access token, authenticating with client assertions.""" + + def __init__(self, *, client_id: str, signer: ClientAssertionSigner, client_wrapper: SyncClientWrapper): + self._client_id = client_id + self._signer = signer + self._auth_client = OauthTokensClient(client_wrapper=client_wrapper) + self._access_token: typing.Optional[str] = None + self._expires_at = dt.datetime.now() + self._lock = threading.Lock() + + def get_token(self) -> str: + if self._access_token and self._expires_at > dt.datetime.now(): + return self._access_token + with self._lock: + if self._access_token and self._expires_at > dt.datetime.now(): + return self._access_token + token = self._request_token() + self._access_token = token.access_token + self._expires_at = _expires_at(token) + return self._access_token + + def _request_token(self) -> CreateOAuthTokenResponse: + attempt = 0 + while True: + try: + return self._auth_client.create( + client_id=self._client_id, + client_assertion_type=CLIENT_ASSERTION_TYPE, + client_assertion=self._signer.sign(), + request_options={"max_retries": 0}, + ) + except Exception as e: + if attempt >= _MAX_RETRIES or not _is_retryable(e): + raise + time.sleep(0.5 * 2**attempt) + attempt += 1 + + +class AsyncPrivateKeyJwtTokenProvider: + """Async variant of :class:`PrivateKeyJwtTokenProvider`.""" + + def __init__(self, *, client_id: str, signer: ClientAssertionSigner, client_wrapper: AsyncClientWrapper): + self._client_id = client_id + self._signer = signer + self._auth_client = AsyncOauthTokensClient(client_wrapper=client_wrapper) + self._access_token: typing.Optional[str] = None + self._expires_at = dt.datetime.now() + self._lock = asyncio.Lock() + + async def get_token(self) -> str: + if self._access_token and self._expires_at > dt.datetime.now(): + return self._access_token + async with self._lock: + if self._access_token and self._expires_at > dt.datetime.now(): + return self._access_token + token = await self._request_token() + self._access_token = token.access_token + self._expires_at = _expires_at(token) + return self._access_token + + async def _request_token(self) -> CreateOAuthTokenResponse: + attempt = 0 + while True: + try: + return await self._auth_client.create( + client_id=self._client_id, + client_assertion_type=CLIENT_ASSERTION_TYPE, + client_assertion=self._signer.sign(), + request_options={"max_retries": 0}, + ) + except Exception as e: + if attempt >= _MAX_RETRIES or not _is_retryable(e): + raise + await asyncio.sleep(0.5 * 2**attempt) + attempt += 1 diff --git a/tests/custom/test_private_key_jwt.py b/tests/custom/test_private_key_jwt.py new file mode 100644 index 0000000..7e879cd --- /dev/null +++ b/tests/custom/test_private_key_jwt.py @@ -0,0 +1,276 @@ +"""Private key (private_key_jwt) client authentication: the SDK signs a client +assertion for each token request instead of sending a client secret.""" + +import json +import typing + +import httpx +import jwt +import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import ec, ed25519, rsa + +from pipedream import AsyncPipedream, Pipedream +from pipedream.private_key_jwt import ClientAssertionSigner + +ASSERTION_TYPE = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" +_ACCOUNTS_BODY: dict = {"data": [], "page_info": {}} + + +def _pem(key: typing.Any) -> str: + return key.private_bytes( + serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption() + ).decode() + + +def _ec_key() -> ec.EllipticCurvePrivateKey: + return ec.generate_private_key(ec.SECP256R1()) + + +def _decode(assertion: str, public_key: typing.Any, audience: str) -> typing.Dict[str, typing.Any]: + return jwt.decode(assertion, public_key, algorithms=["ES256", "RS256"], audience=audience) + + +class TestClientAssertionSigner: + def test_signs_es256_with_client_claims_and_a_fresh_jti(self) -> None: + # Arrange + key = _ec_key() + signer = ClientAssertionSigner( + client_id="client_123", private_key=_pem(key), audience="https://api.pipedream.com" + ) + + # Act + first, second = signer.sign(), signer.sign() + + # Assert + assert jwt.get_unverified_header(first) == {"alg": "ES256", "typ": "client-authentication+jwt"} + claims = _decode(first, key.public_key(), "https://api.pipedream.com") + assert claims["iss"] == claims["sub"] == "client_123" + assert claims["exp"] - claims["iat"] == 60 + assert _decode(second, key.public_key(), "https://api.pipedream.com")["jti"] != claims["jti"] + + def test_signs_rs256_with_an_rsa_key_and_sends_key_id(self) -> None: + # Arrange + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + signer = ClientAssertionSigner( + client_id="c", private_key=_pem(key), key_id="key-2026", audience="https://api.pipedream.com" + ) + + # Act + assertion = signer.sign() + + # Assert + assert jwt.get_unverified_header(assertion)["alg"] == "RS256" + assert jwt.get_unverified_header(assertion)["kid"] == "key-2026" + _decode(assertion, key.public_key(), "https://api.pipedream.com") + + def test_accepts_a_jwk_private_key_and_uses_its_kid(self) -> None: + # Arrange + key = _ec_key() + jwk = json.loads(jwt.algorithms.ECAlgorithm.to_jwk(key)) + jwk["kid"] = "jwk-kid" + signer = ClientAssertionSigner(client_id="c", private_key=json.dumps(jwk), audience="https://api.pipedream.com") + + # Act + assertion = signer.sign() + + # Assert + assert jwt.get_unverified_header(assertion)["kid"] == "jwk-kid" + _decode(assertion, key.public_key(), "https://api.pipedream.com") + + @pytest.mark.parametrize( + "make_key", + [ + lambda: _ec_key() + .public_key() + .public_bytes(serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo) + .decode(), + lambda: _pem(ec.generate_private_key(ec.SECP384R1())), + lambda: _pem(ed25519.Ed25519PrivateKey.generate()), + lambda: "not a key", + ], + ids=["public key", "P-384 key", "Ed25519 key", "garbage"], + ) + def test_rejects_unusable_keys(self, make_key: typing.Callable[[], str]) -> None: + # Act / Assert + with pytest.raises(ValueError, match="private_key must be"): + ClientAssertionSigner(client_id="c", private_key=make_key(), audience="https://api.pipedream.com") + + def test_explains_how_to_install_the_extra_when_pyjwt_is_missing(self, monkeypatch: pytest.MonkeyPatch) -> None: + # Arrange + monkeypatch.setitem(__import__("sys").modules, "jwt", None) + + # Act / Assert + with pytest.raises(ImportError, match=r"pipedream\[private-key\]"): + ClientAssertionSigner(client_id="c", private_key=_pem(_ec_key()), audience="https://api.pipedream.com") + + +class _TokenEndpoint: + """An httpx handler for the token endpoint (answering with the given statuses + in order, the last one repeating) and a Connect accounts endpoint.""" + + def __init__(self, statuses: typing.Sequence[int] = (200,)): + self.statuses = list(statuses) + self.token_requests: typing.List[typing.Dict[str, typing.Any]] = [] + self.api_auth_headers: typing.List[typing.Optional[str]] = [] + + def respond(self, request: httpx.Request) -> httpx.Response: + if request.url.path == "/v1/oauth/token": + self.token_requests.append(json.loads(request.content)) + n = len(self.token_requests) + status = self.statuses[min(n - 1, len(self.statuses) - 1)] + if status == 200: + return httpx.Response( + 200, json={"access_token": f"token-{n}", "token_type": "bearer", "expires_in": 3600} + ) + return httpx.Response( + status, json={"error": "invalid_client" if status == 401 else "temporarily_unavailable"} + ) + self.api_auth_headers.append(request.headers.get("authorization")) + return httpx.Response(200, json=_ACCOUNTS_BODY) + + +def _client(endpoint: _TokenEndpoint, key: typing.Any) -> Pipedream: + return Pipedream( + client_id="client_123", + private_key=_pem(key), + key_id="kid-1", + project_id="proj_123", + base_url="https://api.example.com/", + httpx_client=httpx.Client(transport=httpx.MockTransport(endpoint.respond)), + ) + + +class TestSyncClient: + def test_authenticates_with_a_client_assertion_and_caches_the_token(self) -> None: + # Arrange + key = _ec_key() + endpoint = _TokenEndpoint() + client = _client(endpoint, key) + + # Act + client.accounts.list() + client.accounts.list() + + # Assert + assert endpoint.api_auth_headers == ["Bearer token-1", "Bearer token-1"] + assert len(endpoint.token_requests) == 1 + body = endpoint.token_requests[0] + assertion = body.pop("client_assertion") + assert body == { + "grant_type": "client_credentials", + "client_id": "client_123", + "client_assertion_type": ASSERTION_TYPE, + } + assert jwt.get_unverified_header(assertion)["kid"] == "kid-1" + # The audience is the API's issuer: the base URL's origin. + claims = _decode(assertion, key.public_key(), "https://api.example.com") + assert claims["iss"] == claims["sub"] == "client_123" + assert client.raw_access_token == "token-1" + + def test_retries_a_transient_failure_with_a_newly_signed_assertion(self) -> None: + # Arrange + endpoint = _TokenEndpoint(statuses=[503, 200]) + client = _client(endpoint, _ec_key()) + + # Act + token = client.raw_access_token + + # Assert + assert token == "token-2" + jtis = [ + jwt.decode(r["client_assertion"], options={"verify_signature": False})["jti"] + for r in endpoint.token_requests + ] + assert len(jtis) == 2 and jtis[0] != jtis[1] + + def test_does_not_retry_a_rejected_assertion(self) -> None: + # Arrange + endpoint = _TokenEndpoint(statuses=[401]) + client = _client(endpoint, _ec_key()) + + # Act / Assert + with pytest.raises(Exception): + client.accounts.list() + assert len(endpoint.token_requests) == 1 + + +async def test_async_client_authenticates_with_a_client_assertion() -> None: + # Arrange + endpoint = _TokenEndpoint() + + async def handler(request: httpx.Request) -> httpx.Response: + return endpoint.respond(request) + + client = AsyncPipedream( + client_id="client_123", + private_key=_pem(_ec_key()), + project_id="proj_123", + base_url="https://api.example.com", + httpx_client=httpx.AsyncClient(transport=httpx.MockTransport(handler)), + ) + + # Act + await client.accounts.list() + await client.accounts.list() + + # Assert + assert endpoint.api_auth_headers == ["Bearer token-1", "Bearer token-1"] + assert len(endpoint.token_requests) == 1 + assert "client_secret" not in endpoint.token_requests[0] + assert endpoint.token_requests[0]["client_assertion_type"] == ASSERTION_TYPE + + +class TestCredentialResolution: + @pytest.fixture(autouse=True) + def _clean_env(self, monkeypatch: pytest.MonkeyPatch) -> None: + for name in ("PIPEDREAM_CLIENT_SECRET", "PIPEDREAM_PRIVATE_KEY", "PIPEDREAM_KEY_ID", "PIPEDREAM_ACCESS_TOKEN"): + monkeypatch.delenv(name, raising=False) + + def test_refuses_a_client_secret_and_a_private_key_together(self) -> None: + # Act / Assert + with pytest.raises(ValueError, match="not both"): + Pipedream(client_id="c", client_secret="s", private_key=_pem(_ec_key()), project_id="p") + + def test_reads_pipedream_private_key_from_the_environment(self, monkeypatch: pytest.MonkeyPatch) -> None: + # Arrange + monkeypatch.setenv("PIPEDREAM_PRIVATE_KEY", _pem(_ec_key())) + + # Act + client = Pipedream(client_id="c", client_secret=None, project_id="p") + + # Assert + assert hasattr(client, "_token_provider") + + def test_refuses_both_credentials_from_the_environment(self, monkeypatch: pytest.MonkeyPatch) -> None: + # Arrange: client_secret defaults to PIPEDREAM_CLIENT_SECRET. + monkeypatch.setenv("PIPEDREAM_CLIENT_SECRET", "from-env") + monkeypatch.setenv("PIPEDREAM_PRIVATE_KEY", _pem(_ec_key())) + + # Act / Assert + with pytest.raises(ValueError, match="set only the one your client uses"): + Pipedream(client_id="c", client_secret="from-env", project_id="p") + + def test_an_explicit_private_key_overrides_a_secret_in_the_environment( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + # Arrange + monkeypatch.setenv("PIPEDREAM_CLIENT_SECRET", "from-env") + + # Act + client = Pipedream(client_id="c", client_secret="from-env", private_key=_pem(_ec_key()), project_id="p") + + # Assert + assert hasattr(client, "_token_provider") + + def test_requires_a_client_id_with_a_private_key(self) -> None: + # Act / Assert + with pytest.raises(ValueError, match="client_id is required"): + Pipedream(client_id=None, client_secret=None, private_key=_pem(_ec_key()), project_id="p") + + def test_client_secret_auth_is_unchanged(self) -> None: + # Act + client = Pipedream(client_id="c", client_secret="s", project_id="p") + + # Assert + assert not hasattr(client, "_token_provider") From 8348da20d1ac3cdbd92bf2b5a61b8acad442a1c6 Mon Sep 17 00:00:00 2001 From: Dylan Sather Date: Wed, 7 Oct 2026 13:35:09 -0700 Subject: [PATCH 2/2] fix: declare cryptography in the private-key extra, matching Fern's output Fern's Python generator drops dependency extras, so pyjwt[crypto] would regenerate as plain pyjwt without cryptography. List cryptography as its own optional dependency of the private-key extra instead, exactly as the generator emits it from fern/generators.yml (verified with a local generation). Also take the generator's oauth_tokens docstring examples. Co-Authored-By: Claude Opus 5.5 (1M context) --- poetry.lock | 5 ++--- pyproject.toml | 5 +++-- src/pipedream/oauth_tokens/client.py | 2 -- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/poetry.lock b/poetry.lock index da19cab..ccd046c 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1274,7 +1274,6 @@ files = [ ] [package.dependencies] -cryptography = {version = ">=3.4.0", optional = true, markers = "extra == \"crypto\""} typing_extensions = {version = ">=4.0", markers = "python_version < \"3.11\""} [package.extras] @@ -1624,9 +1623,9 @@ propcache = ">=0.2.1" [extras] aiohttp = ["aiohttp", "httpx-aiohttp"] -private-key = ["pyjwt"] +private-key = ["cryptography", "pyjwt"] [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "6d9c4c1a1e9d6521731fbebde2c56142bd0a81dc7031967ddd6ddfc12ea205dd" +content-hash = "6507fc8ed6e774f4c376e22ef42da17ab125fd2cdf768d5a28a57ab1da52d788" diff --git a/pyproject.toml b/pyproject.toml index 2ab0ac6..800c410 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,12 +38,13 @@ Repository = 'https://github.com/PipedreamHQ/pipedream-sdk-python' [tool.poetry.dependencies] python = "^3.10" aiohttp = { version = ">=3.13.4,<4", optional = true, python = ">=3.9"} +cryptography = { version = ">=3.4.0", optional = true} httpx = ">=0.21.2" httpx-aiohttp = { version = "0.1.8", optional = true, python = ">=3.9"} pydantic = ">= 1.9.2" pydantic-core = ">=2.18.2,<3.0.0" +pyjwt = { version = ">=2.8.0,<3", optional = true} typing_extensions = ">= 4.0.0" -pyjwt = { version = ">=2.8.0,<3", optional = true, extras = ["crypto"] } [tool.poetry.group.dev.dependencies] mypy = "==1.13.0" @@ -94,5 +95,5 @@ requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" [tool.poetry.extras] +private-key=["pyjwt", "cryptography"] aiohttp=["aiohttp", "httpx-aiohttp"] -private-key=["pyjwt"] diff --git a/src/pipedream/oauth_tokens/client.py b/src/pipedream/oauth_tokens/client.py index 94e10ae..e310855 100644 --- a/src/pipedream/oauth_tokens/client.py +++ b/src/pipedream/oauth_tokens/client.py @@ -76,7 +76,6 @@ def create( ) client.oauth_tokens.create( client_id="client_id", - client_secret="client_secret", ) """ _response = self._raw_client.create( @@ -160,7 +159,6 @@ async def create( async def main() -> None: await client.oauth_tokens.create( client_id="client_id", - client_secret="client_secret", )