diff --git a/.github/workflows/gardener-notify-event.yml b/.github/workflows/gardener-notify-event.yml deleted file mode 100644 index f03b719..0000000 --- a/.github/workflows/gardener-notify-event.yml +++ /dev/null @@ -1,35 +0,0 @@ -name: Gardener - Notify Event -# Tiny event capturer: stashes the triggering issue/PR payload as an artifact -# for `gardener-notify-slack.yml` to pick up via workflow_run. -# -# Why two workflows? When Dependabot triggers a workflow, GitHub forces -# GITHUB_TOKEN to read-only and hides Actions secrets — so labeling and -# Slack posting from this workflow would fail on every Dependabot PR. A -# workflow_run-triggered follow-up runs in the default-branch context with -# full permissions and secret access, regardless of the upstream actor. -# -# Uses pull_request_target so fork-opened PRs still produce an artifact. -# No code is checked out here; this workflow only reads the pre-parsed -# event payload, so there is no pwn-request surface. -on: - issues: - types: [opened, labeled] - pull_request_target: - types: [opened, labeled] - -permissions: - contents: read - -jobs: - capture: - if: github.event.action == 'opened' || github.event.label.name == 'devtools-gardener' - runs-on: ubuntu-latest - steps: - - name: Stash event payload - run: cp "$GITHUB_EVENT_PATH" event.json - - - uses: actions/upload-artifact@v4 - with: - name: gardener-event - path: event.json - retention-days: 1 diff --git a/.github/workflows/gardener-notify-slack.yml b/.github/workflows/gardener-notify-slack.yml deleted file mode 100644 index 8bcb5a8..0000000 --- a/.github/workflows/gardener-notify-slack.yml +++ /dev/null @@ -1,116 +0,0 @@ -name: Gardener - Notify Slack -# Runs after `Gardener - Notify Event` completes and does the real work: -# applies the devtools-gardener label and posts a summary to Slack. -# -# The workflow_run trigger runs this job in the default-branch context with -# full GITHUB_TOKEN permissions and Actions secret access — this is what -# lets it succeed for Dependabot-opened PRs, where the upstream event -# workflow can't label or reach secrets directly. -on: - workflow_run: - workflows: ['Gardener - Notify Event'] - types: [completed] - -permissions: - contents: read - issues: write - pull-requests: write - actions: read - -jobs: - notify: - # `conclusion == success` also covers runs where the capture job was - # skipped by its `if` gate (no matching label, etc.) — in that case - # no artifact was uploaded, so the download step below no-ops. - if: github.event.workflow_run.conclusion == 'success' - runs-on: ubuntu-latest - steps: - - name: Download event payload - id: download - continue-on-error: true - uses: actions/download-artifact@v4 - with: - name: gardener-event - run-id: ${{ github.event.workflow_run.id }} - github-token: ${{ secrets.GITHUB_TOKEN }} - - - name: Add devtools-gardener label - if: steps.download.outcome == 'success' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GH_REPO: ${{ github.repository }} - run: | - ACTION=$(jq -r '.action' event.json) - # On `labeled` events the label is already there — skip. - if [ "$ACTION" != "opened" ]; then - exit 0 - fi - NUMBER=$(jq -r '(.issue // .pull_request).number' event.json) - if jq -e 'has("pull_request")' event.json > /dev/null; then - gh pr edit "$NUMBER" --add-label devtools-gardener - else - gh issue edit "$NUMBER" --add-label devtools-gardener - fi - - - name: Post to Slack - if: steps.download.outcome == 'success' - continue-on-error: true - env: - SLACK_BOT_TOKEN: ${{ secrets.SLACK_GARDENER_BOT_TOKEN }} - SLACK_CHANNEL_ID: ${{ vars.GARDENER_SLACK_CHANNEL_ID }} - run: | - KIND=$(jq -r 'if has("pull_request") then "PR" else "Issue" end' event.json) - # Pull the body out, truncate, then convert GitHub Markdown to - # Slack mrkdwn. Links and fenced code blocks are stashed before - # the HTML-escape pass so their contents survive verbatim (a `&` - # inside a URL must stay raw, and code content shouldn't be - # mangled). Blockquote `> ` markers are also stashed so the - # `>` → `>` escape doesn't break them. Everything else is - # HTML-escaped so user-supplied `<`, `>`, `&` can't collide - # with Slack link syntax or injected mentions like . - BODY=$(jq -r '(.issue // .pull_request).body // ""' event.json) - if [ ${#BODY} -gt 1000 ]; then - BODY="${BODY:0:1000}…" - fi - BODY=$(printf '%s' "$BODY" | perl -0777 -pe ' - my @u; - s{\[([^\]]+)\]\(([^)]+)\)}{push @u, $2; "\x01$#u\x02$1\x03"}ge; - my @c; - s{^```[^\n]*\n(.*?)\n```$}{push @c, $1; "\x04$#c\x05"}gems; - s/^> /\x06/gm; - s/^#{1,6}\s+(.+)$/*$1*/gm; - s/\*\*(.+?)\*\*/*$1*/g; - s/^(\s*)- \[x\]\s+/$1✓ /gm; - s/^(\s*)[-*]\s+/$1• /gm; - s/&/&/g; - s//>/g; - s/\x06/> /g; - s{\x01(\d+)\x02(.*?)\x03}{"<$u[$1]|$2>"}ge; - s{\x04(\d+)\x05}{"```\n$c[$1]\n```"}ge; - ') - jq \ - --arg channel "$SLACK_CHANNEL_ID" \ - --arg kind "$KIND" \ - --arg body "$BODY" \ - ' - def escape: gsub("&";"&") | gsub("<";"<") | gsub(">";">"); - - (.issue // .pull_request) as $i - | ([$i.labels[]?.name | select(. != "devtools-gardener")] - | map("`\(.)`") | join(" ")) as $labels - | (if $kind == "PR" - then " · \($i.changed_files) files, +\($i.additions)/-\($i.deletions)" - + (if $i.draft then " · draft" else "" end) - else "" end) as $meta - | [ "*<\($i.html_url)|\($kind) #\($i.number)>* — \(($i.title | escape))", - "_opened by \($i.user.login)\($meta)_" ] - + (if $body != "" then [$body] else [] end) - + (if $labels != "" then [$labels] else [] end) - | join("\n") as $msg - | { channel: $channel, text: "\($kind) #\($i.number): \($i.title)", - blocks: [{ type: "section", text: { type: "mrkdwn", text: $msg } }] } - ' event.json | curl -sf -X POST \ - -H "Authorization: Bearer $SLACK_BOT_TOKEN" \ - -H 'Content-type: application/json; charset=utf-8' \ - -d @- https://slack.com/api/chat.postMessage diff --git a/CHANGELOG.md b/CHANGELOG.md index 02a999c..3b63d59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +## [1.1.0] + +- Add optional `client_assertion` keyword argument to `exchange_using_client_credentials` and `exchange_using_client_credentials_async`. Pass an app-created JWT to authenticate one request with a client assertion; omit it, or pass `None`, to keep client-secret authentication. +- Client-credentials exchanges now reject redirects for both authentication methods, including with an injected client that enables redirects. This changes client-secret calls that relied on redirect following. Client defaults and other operations are unchanged. +- Add `shopify_app.assertions.create_client_assertion`, a standalone signer for one client assertion. It takes an already-loaded RSA (`RS256`) or EC P-256 (`ES256`) private key, and needs no `ShopifyApp` instance, client secret or network access. +- Add the optional `assertions` extra for signing support: `pip install "shopifyapp[assertions]"`. Base dependencies are unchanged. +- Redact `client_assertion` in logged request bodies. + +## [1.0.3] + +- Return a 401 response instead of raising `TypeError` when the HMAC header of `verify_webhook_req` or `verify_flow_action_req`, or the `signature` of `verify_app_proxy_req`, contains characters that are not ASCII. +- Validate the shop before building request URLs. `exchange_using_client_credentials`, `refresh_token_exchanged_access_token` and `admin_graphql_request` return an error when `shop` is not a valid shop name (e.g., `shop-name`). `exchange_using_token_exchange` builds the request URL from the validated shop name. +- `app_home_patch_id_token` returns a 400 response when the `shop` query parameter is not a valid shop domain (e.g., `shop-name.myshopify.com`). + ## [1.0.2] - Harden App Home Redirect URL handling. diff --git a/README.md b/README.md index 72d5f9c..b38bdc9 100644 --- a/README.md +++ b/README.md @@ -24,8 +24,10 @@ This package encodes the secure, correct way to build a Shopify app. Most of wha - [Verifying requests with exchangeable ID tokens](#verifying-requests-with-exchangeable-id-tokens) - [Navigating inside and outside the App Home iframe](#navigating-inside-and-outside-the-app-home-iframe) - [GraphQL requests](#graphql-requests) + - [Optional access scopes](#optional-access-scopes) - [Verifying requests without exchangeable ID tokens](#verifying-requests-without-exchangeable-id-tokens) - [Getting access tokens with client credentials](#getting-access-tokens-with-client-credentials) + - [Authenticating client credentials with a client assertion](#authenticating-client-credentials-with-a-client-assertion) - [Async variants](#async-variants) - [Common misuses to avoid](#common-misuses-to-avoid) - [Contributing, issues, feedback and feature requests](#contributing-issues-feedback-and-feature-requests) @@ -45,11 +47,20 @@ Before you start, make sure you have: pip install --upgrade shopifyapp ``` +To sign client assertions locally with `create_client_assertion`, install the +optional signing support as well: + +```bash +pip install --upgrade "shopifyapp[assertions]" +``` + ## Requirements - Python >= 3.8 - httpx for making HTTP requests - pyjwt for JWT token handling +- The `assertions` extra adds pyjwt's cryptography backend. It is only needed + for `create_client_assertion`; every other feature works without it. ## Features @@ -69,9 +80,13 @@ Request Verification: Exchange: - `exchange_using_token_exchange`: Use Token Exchange to exchange an ID token for an access token -- `exchange_using_client_credentials`: Get access tokens via client credentials +- `exchange_using_client_credentials`: Get access tokens via client credentials, authenticated with the client secret or with a client assertion - `refresh_token_exchanged_access_token`: Refresh an access token that was created using Token Exchange. Checks if a token refresh can and should happen. +Signing: + +- `create_client_assertion`: Create one signed JWT to authenticate a client credentials request. Standalone; needs the `assertions` extra. + GraphQL: - `admin_graphql_request`: Make Admin API GraphQL requests with automatic retry handling @@ -767,6 +782,14 @@ def process_job(shop): - `data`: The GraphQL response data (dict), or `None` if the request failed. - `extensions`: The GraphQL extensions (dict, e.g., cost information), or `None` if not present. +### Optional access scopes + +Use [`admin_graphql_request`](#graphql-requests) on your server to query granted scopes or revoke optional scopes. Check the revoke mutation's `userErrors` even when the package result is `ok`. + +To request merchant approval, use App Bridge in embedded apps or Shopify's approval redirect in standalone apps. + +See [Manage access scopes](https://shopify.dev/docs/apps/build/authentication-authorization/manage-access-scopes) for configuration, deployment, examples, and grant rules. + ### Verifying requests without exchangeable ID tokens The following requests do not provide the required information for token exchange: @@ -867,10 +890,87 @@ The `access_token` dataclass contains: | `access_mode` | Always "offline" | | `token` | The access token string | | `scope` | The granted scopes | -| `expires` | ISO 8601 datetime when the token expires (24 hours) | +| `expires` | ISO 8601 expiry calculated from the endpoint response | | `user` | Always `None` for client credentials | -Note: Client credentials tokens expire after 24 hours and do not include a refresh token. When the token expires, request a new one using `exchange_using_client_credentials` with the same credentials. +Note: Client credentials tokens do not include a refresh token. Read `expires` +to know when the token expires, and request a new one with +`exchange_using_client_credentials` when it does. + +These methods do not follow redirects with either authentication method, even +if an injected client enables them. A 307/308 can forward the credential-bearing +POST body. Redirects return a failed result using the existing error-code rules, +with the original status in the HTTP log. The per-request setting leaves the +client configuration and other operations unchanged. + +> **Upgrade note.** Client-secret requests now override redirect-enabled client +> defaults. Check the shop and intended endpoint instead of relying on a redirect. + +### Authenticating client credentials with a client assertion + +Instead of the client secret, one client credentials call can authenticate with +a client assertion: a short-lived JWT your app signs with a private key whose +public key is registered with Shopify. Assertion authentication needs an active +registered public key and an app installation on the target shop. To create a key pair and register its public key, see +[Get application-only access tokens](https://shopify.dev/docs/apps/build/authentication-authorization/application-only-access-tokens). + +`client_assertion` is a keyword argument of each call, not app configuration. +Omit it, or pass `None`, to keep client-secret authentication. The app +constructor is unchanged, and the package never signs an assertion for you. +Keep the real client secret in the existing app configuration. + +Supply an already-loaded native private key to `create_client_assertion`, then +pass the returned string to one exchange call: + +```python +from shopify_app.assertions import create_client_assertion + +assertion = create_client_assertion( + shop="shop-name", + client_id=client_id, + kid="the-registered-key-id", + private_key=private_key, + lifetime_seconds=60, # optional: default 60, maximum 300 +) + +result = shopify.exchange_using_client_credentials( + shop="shop-name", client_assertion=assertion +) +``` + +`create_client_assertion` is standalone: it needs no `ShopifyApp` instance, +client secret, ID token or network access. It accepts only an already-loaded +RSA private key of at least 2048 bits (`RS256`) or an EC private key on the +P-256 curve (`ES256`). PEM strings, JWKs, file paths, passphrases, shared +secrets, public keys and signer callbacks are rejected. Invalid input raises +`ClientAssertionArgumentError`, a missing `assertions` extra raises +`ClientAssertionSigningUnavailableError`, and a crypto-library failure raises +`ClientAssertionSigningError`. No failure makes an HTTP request. + +Load the key once, for example with `cryptography`'s +`serialization.load_pem_private_key`, and pass the key object, never the key +text. The package rejects key text without echoing it, but an error reporter +that records local variables (Sentry's Python SDK does by default) can still +capture text passed by mistake. + +An assertion authenticates only once: + +- Create a fresh assertion for every attempt, including after a timeout, a 503 + or any other failure, and even while the previous assertion is still inside + its lifetime. +- An assertion request makes one HTTP attempt and never falls back to the + client secret. Redirects are not followed on either authentication path. +- The request must carry no other credential. If an injected HTTP client is + configured with an `Authorization` or `X-Shopify-Access-Token` header, the + call returns a `configuration_error` result before sending anything. +- An injected client must be an inspectable `httpx.Client` or `AsyncClient` + without default query parameters. The package does not change client defaults. + Custom hooks and transports must honor the per-request authentication and + redirect controls and must not add retries. +- The logged request body shows `client_assertion` as `[REDACTED]`. + +If you sign with a remote signer or a KMS, skip `create_client_assertion` and +pass your own completed assertion to `client_assertion`. ### Async variants diff --git a/pyproject.toml b/pyproject.toml index 646097e..5a8e5b5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,14 @@ dependencies = [ "httpx>=0.24.0", ] +[project.optional-dependencies] +# Asymmetric signing support for shopify_app.assertions.create_client_assertion. +# Keep this optional: apps that authenticate with a client secret, or that sign +# with a remote signer or KMS, do not need a local crypto backend. +assertions = [ + "pyjwt[crypto]>=2.8.0", +] + [project.urls] homepage = "https://github.com/Shopify/shopify-app-python/" issues = "https://community.shopify.dev/c/shopify-cli-libraries/14" diff --git a/requirements-dev.txt b/requirements-dev.txt index f6e6f79..ccd5d7e 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,5 +1,5 @@ # This file was autogenerated by uv via the following command: -# uv pip compile packages/python/requirements-dev.in --generate-hashes --python-version 3.8 --python-platform x86_64-manylinux2014 --output-file packages/python/requirements-dev.txt +# uv pip compile packages/python/requirements-dev.in --generate-hashes --python-version 3.8 --universal --output-file packages/python/requirements-dev.txt anyio==4.5.2 \ --hash=sha256:23009af4ed04ce05991845451e11ef02fc7c5ed29179ac9a420e5ad0ac7ddc5b \ --hash=sha256:c011ee36bc1e8ba40e5a81cb9df91925c218fe9b778554e0b56a21e1b5d4716f @@ -38,11 +38,325 @@ certifi==2026.5.20 \ # via # httpcore # httpx +cffi==1.17.1 ; python_full_version < '3.9' and platform_python_implementation != 'PyPy' \ + --hash=sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8 \ + --hash=sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2 \ + --hash=sha256:0e2b1fac190ae3ebfe37b979cc1ce69c81f4e4fe5746bb401dca63a9062cdaf1 \ + --hash=sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15 \ + --hash=sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36 \ + --hash=sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824 \ + --hash=sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8 \ + --hash=sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36 \ + --hash=sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17 \ + --hash=sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf \ + --hash=sha256:31000ec67d4221a71bd3f67df918b1f88f676f1c3b535a7eb473255fdc0b83fc \ + --hash=sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3 \ + --hash=sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed \ + --hash=sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702 \ + --hash=sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1 \ + --hash=sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8 \ + --hash=sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903 \ + --hash=sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6 \ + --hash=sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d \ + --hash=sha256:636062ea65bd0195bc012fea9321aca499c0504409f413dc88af450b57ffd03b \ + --hash=sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e \ + --hash=sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be \ + --hash=sha256:6f17be4345073b0a7b8ea599688f692ac3ef23ce28e5df79c04de519dbc4912c \ + --hash=sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683 \ + --hash=sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9 \ + --hash=sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c \ + --hash=sha256:7596d6620d3fa590f677e9ee430df2958d2d6d6de2feeae5b20e82c00b76fbf8 \ + --hash=sha256:78122be759c3f8a014ce010908ae03364d00a1f81ab5c7f4a7a5120607ea56e1 \ + --hash=sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4 \ + --hash=sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655 \ + --hash=sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67 \ + --hash=sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595 \ + --hash=sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0 \ + --hash=sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65 \ + --hash=sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41 \ + --hash=sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6 \ + --hash=sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401 \ + --hash=sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6 \ + --hash=sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3 \ + --hash=sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16 \ + --hash=sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93 \ + --hash=sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e \ + --hash=sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4 \ + --hash=sha256:c7eac2ef9b63c79431bc4b25f1cd649d7f061a28808cbc6c47b534bd789ef964 \ + --hash=sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c \ + --hash=sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576 \ + --hash=sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0 \ + --hash=sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3 \ + --hash=sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662 \ + --hash=sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3 \ + --hash=sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff \ + --hash=sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5 \ + --hash=sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd \ + --hash=sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f \ + --hash=sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5 \ + --hash=sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14 \ + --hash=sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d \ + --hash=sha256:e221cf152cff04059d011ee126477f0d9588303eb57e88923578ace7baad17f9 \ + --hash=sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7 \ + --hash=sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382 \ + --hash=sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a \ + --hash=sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e \ + --hash=sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a \ + --hash=sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4 \ + --hash=sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99 \ + --hash=sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87 \ + --hash=sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b + # via cryptography +cffi==2.0.0 ; python_full_version == '3.9.*' and platform_python_implementation != 'PyPy' \ + --hash=sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb \ + --hash=sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b \ + --hash=sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f \ + --hash=sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9 \ + --hash=sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44 \ + --hash=sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2 \ + --hash=sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c \ + --hash=sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75 \ + --hash=sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65 \ + --hash=sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e \ + --hash=sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a \ + --hash=sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e \ + --hash=sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25 \ + --hash=sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a \ + --hash=sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe \ + --hash=sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b \ + --hash=sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91 \ + --hash=sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592 \ + --hash=sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187 \ + --hash=sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c \ + --hash=sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1 \ + --hash=sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94 \ + --hash=sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba \ + --hash=sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb \ + --hash=sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165 \ + --hash=sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529 \ + --hash=sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca \ + --hash=sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c \ + --hash=sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6 \ + --hash=sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c \ + --hash=sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0 \ + --hash=sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743 \ + --hash=sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63 \ + --hash=sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5 \ + --hash=sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5 \ + --hash=sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4 \ + --hash=sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d \ + --hash=sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b \ + --hash=sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93 \ + --hash=sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205 \ + --hash=sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27 \ + --hash=sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512 \ + --hash=sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d \ + --hash=sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c \ + --hash=sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037 \ + --hash=sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26 \ + --hash=sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322 \ + --hash=sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb \ + --hash=sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c \ + --hash=sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8 \ + --hash=sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4 \ + --hash=sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414 \ + --hash=sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9 \ + --hash=sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664 \ + --hash=sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9 \ + --hash=sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775 \ + --hash=sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739 \ + --hash=sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc \ + --hash=sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062 \ + --hash=sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe \ + --hash=sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9 \ + --hash=sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92 \ + --hash=sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5 \ + --hash=sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13 \ + --hash=sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d \ + --hash=sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26 \ + --hash=sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f \ + --hash=sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495 \ + --hash=sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b \ + --hash=sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6 \ + --hash=sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c \ + --hash=sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef \ + --hash=sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5 \ + --hash=sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18 \ + --hash=sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad \ + --hash=sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3 \ + --hash=sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7 \ + --hash=sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5 \ + --hash=sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534 \ + --hash=sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49 \ + --hash=sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2 \ + --hash=sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5 \ + --hash=sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453 \ + --hash=sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf + # via cryptography +cffi==2.1.1 ; python_full_version >= '3.10' and platform_python_implementation != 'PyPy' \ + --hash=sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e \ + --hash=sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66 \ + --hash=sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2 \ + --hash=sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0 \ + --hash=sha256:194cffa889098ced9976c3fc6340305e43f6303657d298da55366907c05c22d6 \ + --hash=sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971 \ + --hash=sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c \ + --hash=sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d \ + --hash=sha256:1dea0e4d7d4f11f619fe8c1d76caf49e24405b4b5743c0e3be16a500ecd930c9 \ + --hash=sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517 \ + --hash=sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735 \ + --hash=sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80 \ + --hash=sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f \ + --hash=sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1 \ + --hash=sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29 \ + --hash=sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8 \ + --hash=sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c \ + --hash=sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e \ + --hash=sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48 \ + --hash=sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813 \ + --hash=sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac \ + --hash=sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632 \ + --hash=sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6 \ + --hash=sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1 \ + --hash=sha256:3d22a20b1fb1632cc72c22f95f7b0d2961c3e1c235f245ba4c606c4771035659 \ + --hash=sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688 \ + --hash=sha256:42e2f76b9455f5a9a844f770bf3e200ed3da0e15f5df3db9c31fe80b04b3d004 \ + --hash=sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0 \ + --hash=sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062 \ + --hash=sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779 \ + --hash=sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94 \ + --hash=sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50 \ + --hash=sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab \ + --hash=sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac \ + --hash=sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6 \ + --hash=sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676 \ + --hash=sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1 \ + --hash=sha256:5a59cc1c4442bc3d5c703bf720b51138d0bfc173618807c9ee2490a7541dd3d9 \ + --hash=sha256:5bb4e7ea95dcd6a014a6fef62e62467d67d8e582326443f3d68e71d6320a9fcf \ + --hash=sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13 \ + --hash=sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e \ + --hash=sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e \ + --hash=sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973 \ + --hash=sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527 \ + --hash=sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72 \ + --hash=sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890 \ + --hash=sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c \ + --hash=sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990 \ + --hash=sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd \ + --hash=sha256:75f80557d1389eddbd0de2681f6a390a0c5338c31ddaa821381c203fc3fd50d9 \ + --hash=sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94 \ + --hash=sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3 \ + --hash=sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80 \ + --hash=sha256:7ce713ace7c0e4520535b42b77eaa742c16dab813978064913e5a3cf82973b41 \ + --hash=sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5 \ + --hash=sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c \ + --hash=sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a \ + --hash=sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4 \ + --hash=sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e \ + --hash=sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6 \ + --hash=sha256:9f8d177621de5cb38ee3e731eda45d421db093ec0739f46a5594babda7987a98 \ + --hash=sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b \ + --hash=sha256:a48d62ab9d6f4f98c983223a547af44be6ca3691074c31cecced6facd3ba2dc1 \ + --hash=sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03 \ + --hash=sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af \ + --hash=sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231 \ + --hash=sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2 \ + --hash=sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3 \ + --hash=sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836 \ + --hash=sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5 \ + --hash=sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399 \ + --hash=sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96 \ + --hash=sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e \ + --hash=sha256:baed1e86cc735622097354b9d1281406caf42ff42a886d29faa8e8d1630333be \ + --hash=sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf \ + --hash=sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc \ + --hash=sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455 \ + --hash=sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0 \ + --hash=sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12 \ + --hash=sha256:ca82be1a1d406ecfe1d25dc16cb33488e5a16bf4438c9fb590484ea29d92478b \ + --hash=sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7 \ + --hash=sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692 \ + --hash=sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54 \ + --hash=sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3 \ + --hash=sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b \ + --hash=sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be \ + --hash=sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d \ + --hash=sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358 \ + --hash=sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a \ + --hash=sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7 \ + --hash=sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc \ + --hash=sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960 \ + --hash=sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125 \ + --hash=sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb \ + --hash=sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a \ + --hash=sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa \ + --hash=sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf \ + --hash=sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3 \ + --hash=sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4 \ + --hash=sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264 + # via cryptography click==8.1.8 \ --hash=sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2 \ --hash=sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a # via black -exceptiongroup==1.3.1 \ +colorama==0.4.6 ; os_name == 'nt' or sys_platform == 'win32' \ + --hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \ + --hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6 + # via + # build + # click +cryptography==47.0.0 \ + --hash=sha256:0024b87d47ae2399165a6bfb20d24888881eeab83ae2566d62467c5ff0030ce7 \ + --hash=sha256:07efe86201817e7d3c18781ca9770bc0db04e1e48c994be384e4602bc38f8f27 \ + --hash=sha256:09f6d7bf6724f8db8b32f11eccf23efc8e759924bc5603800335cf8859a3ddbd \ + --hash=sha256:11438c7518132d95f354fa01a4aa2f806d172a061a7bed18cf18cbdacdb204d7 \ + --hash=sha256:11dbb9f50a0f1bb9757b3d8c27c1101780efb8f0bdecfb12439c22a74d64c001 \ + --hash=sha256:14432c8a9bcb37009784f9594a62fae211a2ae9543e96c92b2a8e4c3cd5cd0c4 \ + --hash=sha256:1581aef4219f7ca2849d0250edaa3866212fb74bf5667284f46aa92f9e65c1ca \ + --hash=sha256:160ad728f128972d362e714054f6ba0067cab7fb350c5202a9ae8ae4ce3ef1a0 \ + --hash=sha256:1a405c08857258c11016777e11c02bacbe7ef596faf259305d282272a3a05cbe \ + --hash=sha256:1e47422b5557bb82d3fff997e8d92cff4e28b9789576984f08c248d2b3535d93 \ + --hash=sha256:20fdbe3e38fb67c385d233c89371fa27f9909f6ebca1cecc20c13518dae65475 \ + --hash=sha256:2207a498b03275d0051589e326b79d4cf59985c99031b05bb292ac52631c37fe \ + --hash=sha256:256d07c78a04d6b276f5df935a9923275f53bd1522f214447fdf365494e2d515 \ + --hash=sha256:2b45761c6ec22b7c726d6a829558777e32d0f1c8be7c3f3480f9c912d5ee8a10 \ + --hash=sha256:2ebd84adf0728c039a3be2700289378e1c164afc6748df1a5ed456767bef9ba7 \ + --hash=sha256:34b4358b925a5ea3e14384ca781a2c0ef7ac219b57bb9eacc4457078e2b19f92 \ + --hash=sha256:3fb8fa48075fad7193f2e5496135c6a76ac4b2aa5a38433df0a539296b377829 \ + --hash=sha256:4e1de79e047e25d6e9f8cea71c86b4a53aced64134f0f003bbcbf3655fd172c8 \ + --hash=sha256:4f7722c97826770bab8ae92959a2e7b20a5e9e9bf4deae68fd86c3ca457bab52 \ + --hash=sha256:51c9313e90bd1690ec5a75ed047c27c0b8e6c570029712943d6116ef9a90620b \ + --hash=sha256:5d0e362ff51041b0c0d219cc7d6924d7b8996f57ce5712bdcef71eb3c65a59cc \ + --hash=sha256:6651d32eff255423503aa276739da98c30f26c40cbeffcc6048e0d54ef704c0c \ + --hash=sha256:6eebcaf0df1d21ce1f90605c9b432dd2c4f4ab665ac29a40d5e3fc68f51b5e63 \ + --hash=sha256:6f29f36582e6151d9686235e586dd35bb67491f024767d10b842e520dc6a07ac \ + --hash=sha256:7a02675e2fabd0c0fc04c868b8781863cbf1967691543c22f5470500ff840b31 \ + --hash=sha256:7f1207974a904e005f762869996cf620e9bf79ecb4622f148550bb48e0eb35a7 \ + --hash=sha256:7f68d6fbc7fbbcfb0939fea72c3b96a9f9a6edfc0e1b1d29778a2066030418b1 \ + --hash=sha256:7fda2f02c9015db3f42bb8a22324a454516ed10a8c29ca6ece6cdbb5efe2a203 \ + --hash=sha256:80887c5cbd1774683cb126f0ab4184567f080071d5acf62205acb354b4b753b7 \ + --hash=sha256:835d2d7f47cdc53b3224e90810fb1d36ca94ea29cc1801fb4c1bc43876735769 \ + --hash=sha256:8c1a736bbb3288005796c3f7ccb9453360d7fed483b13b9f468aea5171432923 \ + --hash=sha256:9af828c0d5a65c70ec729cd7495a4bf1a67ecb66417b8f02ff125ab8a6326a74 \ + --hash=sha256:9c59ab0e0fa3a180a5a9c59f3a5abe3ef90d474bc56d7fadfbe80359491b615b \ + --hash=sha256:9f8e55fe4e63613a5e1cc5819030f27b97742d720203a087802ce4ce9ceb52bb \ + --hash=sha256:9fe6b7c64926c765f9dff301f9c1b867febcda5768868ca084e18589113732ab \ + --hash=sha256:a49a3eb5341b9503fa3000a9a0db033161db90d47285291f53c2a9d2cd1b7f76 \ + --hash=sha256:a9b761f012a943b7de0e828843c5688d0de94a0578d44d6c85a1bae32f87791f \ + --hash=sha256:b1c76fca783aa7698eb21eb14f9c4aa09452248ee54a627d125025a43f83e7a7 \ + --hash=sha256:b9a8943e359b7615db1a3ba587994618e094ff3d6fa5a390c73d079ce18b3973 \ + --hash=sha256:be12cb6a204f77ed968bcefe68086eb061695b540a3dd05edac507a3111b25f0 \ + --hash=sha256:cffbba3392df0fa8629bb7f43454ee2925059ee158e23c54620b9063912b86c8 \ + --hash=sha256:ed67ea4e0cfb5faa5bc7ecb6e2b8838f3807a03758eec239d6c21c8769355310 \ + --hash=sha256:edd4da498015da5b9f26d38d3bfc2e90257bfa9cbed1f6767c282a0025ae649b \ + --hash=sha256:ef6b3634087f18d2155b1e8ce264e5345a753da2c5fa9815e7d41315c90f8318 \ + --hash=sha256:f1557695e5c2b86e204f6ce9470497848634100787935ab7adc5397c54abd7ab \ + --hash=sha256:f5c15764f261394b22aef6b00252f5195f46f2ca300bec57149474e2538b31f8 \ + --hash=sha256:f5c3296dab66202f1b18a91fa266be93d6aa0c2806ea3d67762c69f60adc71aa \ + --hash=sha256:f7db373287273d8af1414cf95dc4118b13ffdc62be521997b0f2b270771fef50 \ + --hash=sha256:f9a034b642b960767fb343766ae5ba6ad653f2e890ddd82955aef288ffea8736 + # via pyjwt +exceptiongroup==1.3.1 ; python_full_version < '3.11' \ --hash=sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219 \ --hash=sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598 # via anyio @@ -72,7 +386,7 @@ idna==3.15 \ # via # anyio # httpx -importlib-metadata==8.5.0 \ +importlib-metadata==8.5.0 ; python_full_version < '3.10.2' \ --hash=sha256:45e54197d28b7a7f1559e60b95e7c567032b602131fbd588f1497f47880aa68b \ --hash=sha256:71522656f0abace1d072b9e5481a48f07c138e00f079c38c8f883823f9c26bd7 # via build @@ -155,6 +469,10 @@ pycodestyle==2.9.1 \ --hash=sha256:2c9607871d58c76354b697b42f5d57e1ada7d261c261efac224b664affdc5785 \ --hash=sha256:d1735fc58b418fd7c5f658d28d943854f8a849b01a5d0a1e6f3f3fdd0166804b # via flake8 +pycparser==2.23 ; (python_full_version < '3.9' and platform_python_implementation != 'PyPy') or (implementation_name != 'PyPy' and platform_python_implementation != 'PyPy') \ + --hash=sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2 \ + --hash=sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934 + # via cffi pyflakes==2.5.0 \ --hash=sha256:4579f67d887f804e67edb544428f264b7b24f435b263c4614f384135cea553d2 \ --hash=sha256:491feb020dca48ccc562a8c0cbe8df07ee13078df59813b83959cbdada312ea3 @@ -171,7 +489,7 @@ sniffio==1.3.1 \ --hash=sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2 \ --hash=sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc # via anyio -tomli==2.4.1 \ +tomli==2.4.1 ; python_full_version < '3.11' \ --hash=sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853 \ --hash=sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe \ --hash=sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5 \ @@ -234,9 +552,10 @@ typing-extensions==4.13.2 \ # via # anyio # black + # cryptography # exceptiongroup # mypy -zipp==3.20.2 \ +zipp==3.20.2 ; python_full_version < '3.10.2' \ --hash=sha256:a817ac80d6cf4b23bf7f2828b7cabf326f15a001bea8b1f9b49631780ba28350 \ --hash=sha256:bc9eb26f4506fda01b81bcde0ca78103b6e62f991b381fec825435c836edbc29 # via importlib-metadata diff --git a/shopify_app/__init__.py b/shopify_app/__init__.py index 8450404..5b4ea0a 100644 --- a/shopify_app/__init__.py +++ b/shopify_app/__init__.py @@ -293,6 +293,8 @@ def exchange_using_client_credentials( self, shop: str, http_client: Optional[httpx.Client] = None, + *, + client_assertion: Optional[str] = None, ) -> ClientCredentialsExchangeResult: """ Exchange client credentials for an API access token. @@ -300,12 +302,20 @@ def exchange_using_client_credentials( Args: shop (str): The shop domain (e.g., "shop-name") http_client: Optional HTTP client for testing (undocumented) + client_assertion (str): Optional app-created JWT for this call. When + omitted or None, the request authenticates with the configured + client secret. Create one with + ``shopify_app.assertions.create_client_assertion`` or another + signer, and use a fresh assertion for every call. Returns: ClientCredentialsExchangeResult: Result with ok, shop, access_token, log, response, and http_logs """ return exchange_using_client_credentials( - shop=shop, app_config=self.config, http_client=http_client + shop=shop, + app_config=self.config, + http_client=http_client, + client_assertion=client_assertion, ) def admin_graphql_request( @@ -407,6 +417,8 @@ async def exchange_using_client_credentials_async( self, shop: str, http_client: Optional[httpx.AsyncClient] = None, + *, + client_assertion: Optional[str] = None, ) -> ClientCredentialsExchangeResult: """ Async version of exchange_using_client_credentials. @@ -416,6 +428,11 @@ async def exchange_using_client_credentials_async( Args: shop (str): The shop domain (e.g., "shop-name") http_client: Optional async HTTP client for testing (httpx.AsyncClient) + client_assertion (str): Optional app-created JWT for this call. When + omitted or None, the request authenticates with the configured + client secret. Create one with + ``shopify_app.assertions.create_client_assertion`` or another + signer, and use a fresh assertion for every call. Returns: ClientCredentialsExchangeResult: Result with ok, shop, access_token, log, response, and http_logs @@ -424,6 +441,7 @@ async def exchange_using_client_credentials_async( shop, self.config, http_client=http_client, + client_assertion=client_assertion, ) async def admin_graphql_request_async( diff --git a/shopify_app/_version.py b/shopify_app/_version.py index d162526..48f948a 100644 --- a/shopify_app/_version.py +++ b/shopify_app/_version.py @@ -2,4 +2,4 @@ from __future__ import annotations -__version__ = "1.0.2" +__version__ = "1.1.0" diff --git a/shopify_app/assertions.py b/shopify_app/assertions.py new file mode 100644 index 0000000..621071c --- /dev/null +++ b/shopify_app/assertions.py @@ -0,0 +1,203 @@ +""" +Shopify Client Assertions + +Create one signed JWT for client authentication on the ``client_credentials`` +grant. This is a standalone helper: it needs no ``ShopifyApp`` instance, no +client secret, no ID token and no network access. + +Asymmetric signing support is optional, so this module is never imported from +the package root. Import it explicitly and install the extra that provides the +signing support: + +.. code-block:: bash + + pip install "shopifyapp[assertions]" + +.. code-block:: python + + from shopify_app.assertions import create_client_assertion + + assertion = create_client_assertion( + shop="shop-name", + client_id=client_id, + kid="registered-key-id", + private_key=private_key, + ) + result = shopify.exchange_using_client_credentials( + shop="shop-name", client_assertion=assertion + ) +""" + +from __future__ import annotations + +import secrets +import time +from typing import TYPE_CHECKING, Dict, Union + +import jwt + +from .utils.shop import _is_valid_shop_name + +if TYPE_CHECKING: + from cryptography.hazmat.primitives.asymmetric import ec as _ec + from cryptography.hazmat.primitives.asymmetric import rsa as _rsa + + NativePrivateSigningKey = Union[_rsa.RSAPrivateKey, _ec.EllipticCurvePrivateKey] +else: + NativePrivateSigningKey = object + +__all__ = [ + "ClientAssertionError", + "ClientAssertionArgumentError", + "ClientAssertionSigningError", + "ClientAssertionSigningUnavailableError", + "create_client_assertion", +] + +DEFAULT_LIFETIME_SECONDS = 60 +MAX_LIFETIME_SECONDS = 300 + +_MINIMUM_RSA_KEY_SIZE = 2048 +_P256_CURVE_NAMES = frozenset({"secp256r1", "prime256v1", "p-256"}) +_AUDIENCE_TEMPLATE = "https://{shop}.myshopify.com/admin/oauth/access_token" +_JTI_BYTES = 32 +_SIGNING_EXTRA_MESSAGE = ( + "Asymmetric signing support is not installed. " + 'Install the optional signing support with: pip install "shopifyapp[assertions]"' +) + + +class ClientAssertionError(Exception): + """Base class for this package's own client-assertion failures. + + Python raises TypeError for an unsupported or missing keyword argument, + which is outside this hierarchy. + """ + + +class ClientAssertionArgumentError(ClientAssertionError, ValueError): + """An input value is not valid for signing.""" + + +class ClientAssertionSigningError(ClientAssertionError, RuntimeError): + """The signing library could not perform the selected operation.""" + + +class ClientAssertionSigningUnavailableError(ClientAssertionError, RuntimeError): + """The optional asymmetric signing support is not installed or enabled.""" + + +def create_client_assertion( + *, + shop: str, + client_id: str, + kid: str, + private_key: "NativePrivateSigningKey", + lifetime_seconds: int = DEFAULT_LIFETIME_SECONDS, +) -> str: + """Create one signed client assertion for the client-credentials grant. + + Args: + shop (str): The shop name before ``.myshopify.com`` (e.g., "shop-name"). + client_id (str): The app client ID. Used unchanged as ``iss`` and ``sub``. + kid (str): The identifier registered for the public key. + private_key: An already-loaded RSA (2048 bits or more) or EC P-256 + private key object. Serialized keys, JWKs, passphrases, shared + secrets, public keys and signer callbacks are rejected. + lifetime_seconds (int): Assertion lifetime in seconds. Default 60, + minimum 1, maximum 300. + + Returns: + str: The compact JWT to pass as ``client_assertion`` to one + client-credentials exchange call. + + Raises: + ClientAssertionArgumentError: An input or key is not valid. + ClientAssertionSigningUnavailableError: Signing support is unavailable. + ClientAssertionSigningError: The signing library failed. + TypeError: An unsupported or missing keyword argument, raised by + Python's argument binding before this function runs. + """ + if not _is_valid_shop_name(shop): + raise ClientAssertionArgumentError( + "Expected shop to be a valid shop name (e.g., 'shop-name')" + ) + + validated_client_id = _validated_identity(client_id, "clientId") + validated_kid = _validated_identity(kid, "kid") + lifetime = _validated_lifetime_seconds(lifetime_seconds) + algorithm = _algorithm_for_key(private_key) + + issued_at = int(time.time()) + claims: Dict[str, Union[str, int]] = { + "iss": validated_client_id, + "sub": validated_client_id, + "aud": _AUDIENCE_TEMPLATE.format(shop=shop.lower()), + "iat": issued_at, + "exp": issued_at + lifetime, + "jti": secrets.token_urlsafe(_JTI_BYTES), + } + + try: + return jwt.encode( + claims, + private_key, + algorithm=algorithm, + headers={"kid": validated_kid, "typ": "JWT"}, + ) + except Exception: + # Chained exceptions also appear in ordinary traceback logging. + raise ClientAssertionSigningError( + "Failed to sign the client assertion with the supplied private key" + ) from None + + +def _validated_identity(value: str, name: str) -> str: + """Return a non-blank string value unchanged, or raise.""" + if not isinstance(value, str) or not value.strip(): + raise ClientAssertionArgumentError( + "Expected {name} to be a non-empty string".format(name=name) + ) + return value + + +def _validated_lifetime_seconds(value: object) -> int: + """Return the validated lifetime in seconds, without coercing other types.""" + if isinstance(value, bool) or not isinstance(value, int): + raise ClientAssertionArgumentError( + "Expected lifetimeSeconds to be an integer from 1 to 300" + ) + if value < 1 or value > MAX_LIFETIME_SECONDS: + raise ClientAssertionArgumentError( + "Expected lifetimeSeconds to be an integer from 1 to 300" + ) + return value + + +def _algorithm_for_key(private_key: object) -> str: + """Select the algorithm after validating the native private key.""" + try: + from cryptography.hazmat.primitives.asymmetric import ec, rsa + except ImportError: + raise ClientAssertionSigningUnavailableError(_SIGNING_EXTRA_MESSAGE) from None + + if isinstance(private_key, rsa.RSAPrivateKey): + if private_key.key_size < _MINIMUM_RSA_KEY_SIZE: + raise ClientAssertionArgumentError( + "Expected privateKey to be an RSA private key of at least " "2048 bits" + ) + return "RS256" + + if isinstance(private_key, ec.EllipticCurvePrivateKey): + if not ( + isinstance(private_key.curve, ec.SECP256R1) + or private_key.curve.name.lower() in _P256_CURVE_NAMES + ): + raise ClientAssertionArgumentError( + "Expected privateKey to be an EC private key on the P-256 curve" + ) + return "ES256" + + raise ClientAssertionArgumentError( + "Expected privateKey to be a native RSA or EC P-256 private key object" + ) diff --git a/shopify_app/exchange/_validation.py b/shopify_app/exchange/_validation.py index 1c5e2bb..080cc08 100644 --- a/shopify_app/exchange/_validation.py +++ b/shopify_app/exchange/_validation.py @@ -13,6 +13,7 @@ from typing import Literal, Optional, Tuple, Union, overload from ..types import ClientCredentialsExchangeResult, Log, Res, TokenExchangeResult +from ..utils.shop import _is_valid_shop_name @overload @@ -100,7 +101,7 @@ def validate_shop( shop: str, result_type: Literal["token_exchange", "client_credentials"] = "token_exchange", ) -> Tuple[bool, Optional[Union[TokenExchangeResult, ClientCredentialsExchangeResult]]]: - """Validate shop parameter. + """Validate that shop is a non-empty, valid shop name. Used by: client_credentials.py, refresh_token.py @@ -112,10 +113,14 @@ def validate_shop( tuple: (is_valid: bool, error_response: dataclass or None) """ if not shop or not isinstance(shop, str): - log = Log( - code="configuration_error", - detail="Expected shop to be a non-empty string, but got ''", - ) + detail = "Expected shop to be a non-empty string, but got ''" + elif not _is_valid_shop_name(shop): + detail = "Expected shop to be a valid shop domain (e.g., 'shop-name')" + else: + detail = None + + if detail is not None: + log = Log(code="configuration_error", detail=detail) response = Res(status=500, body="", headers={}) if result_type == "client_credentials": diff --git a/shopify_app/exchange/client_credentials.py b/shopify_app/exchange/client_credentials.py index 23e6336..9d2be4d 100644 --- a/shopify_app/exchange/client_credentials.py +++ b/shopify_app/exchange/client_credentials.py @@ -7,9 +7,8 @@ from __future__ import annotations import json -import re from datetime import datetime, timedelta, timezone -from typing import List, Optional, Tuple +from typing import Dict, List, Optional, Tuple, TypedDict import httpx @@ -27,11 +26,26 @@ from ._response_builders import build_network_error_response from ._validation import validate_shop +# The assertion type is fixed by the OAuth JWT bearer profile. It is not a +# caller option. +CLIENT_ASSERTION_TYPE = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" + +# An assertion request authenticates with the assertion alone, so another +# credential on the same request is a configuration mistake. +_CONFLICTING_CREDENTIAL_HEADERS = frozenset({"authorization", "x-shopify-access-token"}) + + +class _RequestOptions(TypedDict, total=False): + auth: None + follow_redirects: bool + def exchange_using_client_credentials( shop: str, app_config: AppConfig, http_client: Optional[httpx.Client] = None, + *, + client_assertion: Optional[str] = None, ) -> ClientCredentialsExchangeResult: """ Exchange client credentials for an API access token. @@ -42,6 +56,8 @@ def exchange_using_client_credentials( - client_id: The Shopify app client ID - client_secret: The app's client secret http_client: Optional HTTP client for testing + client_assertion (str): Optional app-created JWT. When omitted or None, + the request authenticates with the configured client secret. Returns: ClientCredentialsExchangeResult: Result containing ok, shop, access_token, log, http_logs, and response @@ -56,17 +72,15 @@ def exchange_using_client_credentials( raise RuntimeError("validate_shop returned invalid but no error") return error - # Validate shop format - is_valid, error = _validate_shop_format(shop) - if not is_valid: - if error is None: - raise RuntimeError("_validate_shop_format returned invalid but no error") - return error + assertion_error = _validate_client_assertion(client_assertion, http_client) + if assertion_error is not None: + return assertion_error # Build request token_endpoint, request_body, request_headers, req_obj = _build_request( - client_id, client_secret, shop + client_id, client_secret, shop, client_assertion ) + request_options = _request_options(client_assertion) http_logs: List[HttpLog] = [] @@ -77,6 +91,7 @@ def exchange_using_client_credentials( token_endpoint, headers=request_headers, json=request_body, + **request_options, ) status_code = response.status_code @@ -108,7 +123,11 @@ def exchange_using_client_credentials( response_data = {} return _handle_error_response( - response_data, shop, http_logs, req_obj, res_obj + response_data, + shop, + http_logs, + req_obj, + res_obj, ) except httpx.RequestError: @@ -121,53 +140,100 @@ def exchange_using_client_credentials( ) -def _validate_shop_format( - shop: str, -) -> Tuple[bool, Optional[ClientCredentialsExchangeResult]]: - """Validate shop format (alphanumeric and hyphens only). - - Args: - shop: Shop string to validate format +def _validate_client_assertion( + client_assertion: object, + http_client: object, +) -> Optional[ClientCredentialsExchangeResult]: + """Validate an optional client assertion and the credentials around it. - Returns: - tuple: (is_valid: bool, error_response: ClientCredentialsExchangeResult or None) + Returns the configuration-error result when the assertion cannot be used, + or None when the call may proceed. No HTTP request is made first, and a + rejected assertion never falls back to client-secret authentication. """ - if not re.match(r"^[a-zA-Z0-9][a-zA-Z0-9\-]*$", shop): - return ( - False, - ClientCredentialsExchangeResult( - ok=False, - shop=None, - access_token=None, - log=Log( - code="configuration_error", - detail="Expected shop to be a valid shop domain (e.g., 'shop-name')", - ), - http_logs=[], - response=Res(status=500, body="", headers={}), - ), - ) - return (True, None) + if client_assertion is None: + return None + + if not isinstance(client_assertion, str) or not client_assertion.strip(): + return _configuration_error("Expected clientAssertion to be a non-empty string") + + if http_client is not None: + if not isinstance(http_client, (httpx.Client, httpx.AsyncClient)): + return _configuration_error( + "Client assertion requests require an inspectable HTTP client" + ) + if any( + name.lower() in _CONFLICTING_CREDENTIAL_HEADERS + for name in http_client.headers + ): + return _configuration_error( + "Client assertion requests must not include other authentication credentials" + ) + if http_client.params: + return _configuration_error( + "Client assertion requests must use the unmodified shop token endpoint" + ) + + return None -def _build_request(client_id, client_secret, shop): +def _configuration_error(detail: str) -> ClientCredentialsExchangeResult: + """Build the configuration-error result, which makes no HTTP request.""" + return ClientCredentialsExchangeResult( + ok=False, + shop=None, + access_token=None, + log=Log(code="configuration_error", detail=detail), + http_logs=[], + response=Res(status=500, body="", headers={}), + ) + + +def _request_options(client_assertion: Optional[str]) -> _RequestOptions: + """Per-call controls; secret requests retain configured HTTP authentication.""" + if client_assertion is None: + return {"follow_redirects": False} + return {"auth": None, "follow_redirects": False} + + +def _build_request( + client_id: str, + client_secret: str, + shop: str, + client_assertion: Optional[str] = None, +) -> Tuple[str, Dict[str, str], Dict[str, str], RequestInput]: """Build client credentials request components. Args: client_id: OAuth client ID client_secret: OAuth client secret shop: Shop name (e.g., "shop-name") + client_assertion: Optional app-created JWT. When supplied, the request + authenticates with the assertion instead of the client secret. Returns: tuple: (endpoint, request_body, request_headers, req_obj) """ - token_endpoint = f"https://{shop}.myshopify.com/admin/oauth/access_token" - - request_body = { - "client_id": client_id, - "client_secret": client_secret, - "grant_type": "client_credentials", - } + request_body: Dict[str, str] + if client_assertion is None: + token_endpoint = f"https://{shop}.myshopify.com/admin/oauth/access_token" + request_body = { + "client_id": client_id, + "client_secret": client_secret, + "grant_type": "client_credentials", + } + else: + # The assertion audience is the canonical lowercase shop host. + token_endpoint = ( + f"https://{shop.lower()}.myshopify.com/admin/oauth/access_token" + ) + # Exactly these four fields: no client secret, no scope, no other + # authentication method. The assertion is opaque and sent unchanged. + request_body = { + "client_id": client_id, + "grant_type": "client_credentials", + "client_assertion_type": CLIENT_ASSERTION_TYPE, + "client_assertion": client_assertion, + } request_headers = { "Content-Type": "application/json", @@ -297,6 +363,8 @@ async def exchange_using_client_credentials_async( shop: str, app_config: AppConfig, http_client: Optional[httpx.AsyncClient] = None, + *, + client_assertion: Optional[str] = None, ) -> ClientCredentialsExchangeResult: """ Async version of exchange_using_client_credentials. @@ -310,6 +378,8 @@ async def exchange_using_client_credentials_async( - client_id: The Shopify app client ID - client_secret: The app's client secret http_client: Optional async HTTP client for testing (httpx.AsyncClient) + client_assertion (str): Optional app-created JWT. When omitted or None, + the request authenticates with the configured client secret. Returns: ClientCredentialsExchangeResult: Result containing ok, shop, access_token, log, http_logs, and response @@ -327,17 +397,15 @@ async def exchange_using_client_credentials_async( raise RuntimeError("validate_shop returned invalid but no error") return error - # Validate shop format - is_valid, error = _validate_shop_format(shop) - if not is_valid: - if error is None: - raise RuntimeError("_validate_shop_format returned invalid but no error") - return error + assertion_error = _validate_client_assertion(client_assertion, http_client) + if assertion_error is not None: + return assertion_error # Build request token_endpoint, request_body, request_headers, req_obj = _build_request( - client_id, client_secret, shop + client_id, client_secret, shop, client_assertion ) + request_options = _request_options(client_assertion) http_logs: List[HttpLog] = [] @@ -348,6 +416,7 @@ async def exchange_using_client_credentials_async( token_endpoint, headers=request_headers, json=request_body, + **request_options, ) status_code = response.status_code @@ -379,7 +448,11 @@ async def exchange_using_client_credentials_async( response_data = {} return _handle_error_response( - response_data, shop, http_logs, req_obj, res_obj + response_data, + shop, + http_logs, + req_obj, + res_obj, ) except httpx.RequestError: diff --git a/shopify_app/exchange/token_exchange.py b/shopify_app/exchange/token_exchange.py index 07ee38e..dba9f18 100644 --- a/shopify_app/exchange/token_exchange.py +++ b/shopify_app/exchange/token_exchange.py @@ -8,7 +8,6 @@ import asyncio import dataclasses -import re import time from datetime import datetime, timedelta, timezone from typing import Any, Dict, List, Literal, Optional, Tuple, Union, cast @@ -34,6 +33,7 @@ redact_http_response_body, ) from ..utils.http_client import AsyncHTTPClientContext, HTTPClientContext +from ..utils.shop import _is_valid_shop_domain from ._response_builders import build_network_error_response from ._validation import validate_client_id @@ -113,15 +113,11 @@ def token_exchange( shop = claims.get("dest", "") jwt_string = _get_attr(id_token, "token", "") - # Normalize shop URL for API request - shop_url = shop if shop.startswith("https://") else f"https://{shop}" - - # Extract shop name (remove https:// and .myshopify.com) - shop_name = ( - shop.replace("https://", "") - .replace("http://", "") - .replace(".myshopify.com", "") - ) + validated_shop_name = _shop_name_from_dest(shop) + if validated_shop_name is None: + raise RuntimeError("_validate_id_token accepted an invalid dest") + shop_name = validated_shop_name + shop_url = f"https://{shop_name}.myshopify.com" # Build request token_endpoint, request_body, request_headers, req_obj = _build_request( @@ -353,23 +349,27 @@ def _validate_id_token( ), ) - # Strip protocol prefix - dest_without_protocol = shop_from_token.replace("https://", "").replace( - "http://", "" - ) - - # dest must end with .myshopify.com (not just contain it anywhere) - if not dest_without_protocol.endswith(".myshopify.com"): + if _shop_name_from_dest(shop_from_token) is None: return invalid_dest_error - # Extract shop name by removing .myshopify.com suffix - shop_name_part = dest_without_protocol[: -len(".myshopify.com")] + return (True, None) - # Validate shop name against regex - if not re.match(r"^[a-zA-Z0-9][a-zA-Z0-9\-]*$", shop_name_part): - return invalid_dest_error - return (True, None) +def _shop_name_from_dest(dest: str) -> Optional[str]: + """ + Return the shop name from a dest claim, or None if dest is not a valid shop URL. + + Accepts "https://shop-name.myshopify.com", "http://shop-name.myshopify.com" + and "shop-name.myshopify.com". + """ + domain = dest + for prefix in ("https://", "http://"): + if domain.startswith(prefix): + domain = domain[len(prefix) :] + break + if not _is_valid_shop_domain(domain): + return None + return domain[: -len(".myshopify.com")] def _build_request( @@ -764,15 +764,11 @@ async def token_exchange_async( shop = claims.get("dest", "") jwt_string = _get_attr(id_token, "token", "") - # Normalize shop URL for API request - shop_url = shop if shop.startswith("https://") else f"https://{shop}" - - # Extract shop name (remove https:// and .myshopify.com) - shop_name = ( - shop.replace("https://", "") - .replace("http://", "") - .replace(".myshopify.com", "") - ) + validated_shop_name = _shop_name_from_dest(shop) + if validated_shop_name is None: + raise RuntimeError("_validate_id_token accepted an invalid dest") + shop_name = validated_shop_name + shop_url = f"https://{shop_name}.myshopify.com" # Build request token_endpoint, request_body, request_headers, req_obj = _build_request( diff --git a/shopify_app/graphql/admin_graphql.py b/shopify_app/graphql/admin_graphql.py index 53b4e0b..6154fb8 100644 --- a/shopify_app/graphql/admin_graphql.py +++ b/shopify_app/graphql/admin_graphql.py @@ -18,6 +18,7 @@ from shopify_app.utils import _get_user_agent, _to_res, redact_http_log from ..utils.http_client import AsyncHTTPClientContext, HTTPClientContext +from ..utils.shop import _is_valid_shop_name def admin_graphql_request( @@ -320,6 +321,23 @@ def _validate_graphql_params( ), ) + if not _is_valid_shop_name(shop): + return ( + False, + GQLResult( + ok=False, + shop=None, + log=Log( + code="invalid_shop", + detail="Expected shop to be a valid shop domain (e.g., 'shop-name')", + ), + response=Res(status=400, body="", headers={}), + data=None, + extensions=None, + http_logs=[], + ), + ) + if not access_token: return ( False, diff --git a/shopify_app/helpers/app_home_patch_id_token.py b/shopify_app/helpers/app_home_patch_id_token.py index 9f57c13..72ae677 100644 --- a/shopify_app/helpers/app_home_patch_id_token.py +++ b/shopify_app/helpers/app_home_patch_id_token.py @@ -11,6 +11,7 @@ from ..types import AppConfig, LogWithReq, RequestInput, Res, ResultForReq from ..utils import redact_http_log +from ..utils.shop import _is_valid_shop_domain def app_home_patch_id_token(request: RequestInput, config: AppConfig) -> ResultForReq: @@ -81,6 +82,18 @@ def app_home_patch_id_token(request: RequestInput, config: AppConfig) -> ResultF response=Res(status=400, body="Bad Request", headers={}), ) + if not _is_valid_shop_domain(shop): + return ResultForReq( + ok=False, + shop=None, + log=LogWithReq( + code="invalid_shop", + detail="Shop parameter in request URL query string is not a valid shop domain. Respond 400 Bad Request using the provided response.", + req=req, + ), + response=Res(status=400, body="Bad Request", headers={}), + ) + # Check for missing shopify-reload if not shopify_reload: return ResultForReq( diff --git a/shopify_app/utils/compare.py b/shopify_app/utils/compare.py new file mode 100644 index 0000000..d40e4ac --- /dev/null +++ b/shopify_app/utils/compare.py @@ -0,0 +1,24 @@ +"""Constant-time comparison utilities.""" + +from __future__ import annotations + +import hmac + + +def _constant_time_equals(a: str, b: str) -> bool: + """ + Compare two strings in constant time. + + Compares the UTF-8 bytes, so values that contain characters that are not + ASCII return False instead of raising TypeError. + + Args: + a (str): The first value + b (str): The second value + + Returns: + bool: True if the values are equal + """ + return hmac.compare_digest( + a.encode("utf-8", "surrogatepass"), b.encode("utf-8", "surrogatepass") + ) diff --git a/shopify_app/utils/redact.py b/shopify_app/utils/redact.py index 12c123f..adbcc90 100644 --- a/shopify_app/utils/redact.py +++ b/shopify_app/utils/redact.py @@ -14,6 +14,8 @@ "subject_token", "refresh_token", "access_token", + # A client assertion authenticates the app, so a log sink must not keep it. + "client_assertion", } # Fields an OAuth token endpoint returns that are reusable credentials. A debug # log is a lower-trust sink than the app runtime, so these must never reach it. @@ -48,7 +50,7 @@ def redact_http_log(req: RequestInput) -> RequestInput: Redacts: - Request body fields: client_secret, subject_token, refresh_token, - access_token + access_token, client_assertion - Request headers: X-Shopify-Access-Token, Authorization (case-insensitive) - Request URL params: signature, id_token, hmac (case-insensitive) """ diff --git a/shopify_app/utils/shop.py b/shopify_app/utils/shop.py new file mode 100644 index 0000000..44bb29b --- /dev/null +++ b/shopify_app/utils/shop.py @@ -0,0 +1,39 @@ +"""Shop name and shop domain validation.""" + +from __future__ import annotations + +import re +from typing import Any + +_SHOP_NAME_PATTERN = re.compile(r"[a-zA-Z0-9][a-zA-Z0-9\-]*") +_MYSHOPIFY_SUFFIX = ".myshopify.com" + + +def _is_valid_shop_name(value: Any) -> bool: + """ + Check that a value is a valid shop name, e.g. "shop-name". + + Args: + value: The value to check + + Returns: + bool: True if the full value is a valid shop name + """ + return isinstance(value, str) and _SHOP_NAME_PATTERN.fullmatch(value) is not None + + +def _is_valid_shop_domain(value: Any) -> bool: + """ + Check that a value is a valid shop domain, e.g. "shop-name.myshopify.com". + + Args: + value: The value to check + + Returns: + bool: True if the value is a valid shop name followed by ".myshopify.com" + """ + return ( + isinstance(value, str) + and value.endswith(_MYSHOPIFY_SUFFIX) + and _is_valid_shop_name(value[: -len(_MYSHOPIFY_SUFFIX)]) + ) diff --git a/shopify_app/verify/_body_hmac_in_header.py b/shopify_app/verify/_body_hmac_in_header.py index 72aa1d3..b77f28c 100644 --- a/shopify_app/verify/_body_hmac_in_header.py +++ b/shopify_app/verify/_body_hmac_in_header.py @@ -16,6 +16,7 @@ from ..types import AppConfig, LogWithReq, RequestInput, Res, ResultForReq from ..utils import redact_http_log +from ..utils.compare import _constant_time_equals from ..utils.headers import _normalize_headers @@ -164,12 +165,12 @@ def calculate_hmac(secret: str) -> str: # Try current secret first calculated_hmac = calculate_hmac(client_secret) - hmac_valid = hmac.compare_digest(received_hmac, calculated_hmac) + hmac_valid = _constant_time_equals(received_hmac, calculated_hmac) # If current secret fails and old secret is provided, try old secret if not hmac_valid and old_client_secret: calculated_hmac_old = calculate_hmac(old_client_secret) - hmac_valid = hmac.compare_digest(received_hmac, calculated_hmac_old) + hmac_valid = _constant_time_equals(received_hmac, calculated_hmac_old) if not hmac_valid: return ResultForReq( diff --git a/shopify_app/verify/_non_exchangeable_id_token.py b/shopify_app/verify/_non_exchangeable_id_token.py index de995b2..6575f9f 100644 --- a/shopify_app/verify/_non_exchangeable_id_token.py +++ b/shopify_app/verify/_non_exchangeable_id_token.py @@ -169,7 +169,7 @@ def _verify_non_exchangeable_id_token( }, ) break # Successfully decoded - except PyJWTError as e: + except (PyJWTError, RecursionError) as e: verification_error = e continue # Try next secret diff --git a/shopify_app/verify/admin_ui_ext.py b/shopify_app/verify/admin_ui_ext.py index c91e92a..a1d2a5d 100644 --- a/shopify_app/verify/admin_ui_ext.py +++ b/shopify_app/verify/admin_ui_ext.py @@ -180,7 +180,7 @@ def verify_admin_ui_ext_req( }, ) break # Successfully decoded - except PyJWTError as e: + except (PyJWTError, RecursionError) as e: verification_error = e continue # Try next secret diff --git a/shopify_app/verify/app_home_req.py b/shopify_app/verify/app_home_req.py index 0f90dd2..04def22 100644 --- a/shopify_app/verify/app_home_req.py +++ b/shopify_app/verify/app_home_req.py @@ -292,7 +292,7 @@ def verify_app_home_req( }, ) break - except PyJWTError as e: + except (PyJWTError, RecursionError) as e: verification_error = e continue # Try next secret diff --git a/shopify_app/verify/app_proxy.py b/shopify_app/verify/app_proxy.py index c80eeff..5da3154 100644 --- a/shopify_app/verify/app_proxy.py +++ b/shopify_app/verify/app_proxy.py @@ -20,6 +20,7 @@ ResultWithLoggedInCustomerId, ) from ..utils import redact_http_log +from ..utils.compare import _constant_time_equals def verify_app_proxy_req( @@ -183,12 +184,12 @@ def calculate_hmac_hex(secret: str) -> str: # Try current secret first calculated_hmac = calculate_hmac_hex(client_secret) - signature_valid = hmac.compare_digest(received_signature, calculated_hmac) + signature_valid = _constant_time_equals(received_signature, calculated_hmac) # If current secret fails and old secret is provided, try old secret if not signature_valid and old_client_secret: calculated_hmac_old = calculate_hmac_hex(old_client_secret) - signature_valid = hmac.compare_digest(received_signature, calculated_hmac_old) + signature_valid = _constant_time_equals(received_signature, calculated_hmac_old) if not signature_valid: return ResultWithLoggedInCustomerId( diff --git a/shopify_app/verify/pos_ui_ext.py b/shopify_app/verify/pos_ui_ext.py index 0306523..b0bdb64 100644 --- a/shopify_app/verify/pos_ui_ext.py +++ b/shopify_app/verify/pos_ui_ext.py @@ -195,7 +195,7 @@ def verify_pos_ui_ext_req( }, ) break # Successfully decoded - except PyJWTError as e: + except (PyJWTError, RecursionError) as e: verification_error = e continue # Try next secret