From 7112b29386d78035b61a2f96746ed94fb4bbee0e Mon Sep 17 00:00:00 2001 From: Richard Powell Date: Thu, 8 Jan 2026 10:54:03 -0500 Subject: [PATCH 1/2] Initial commit for the package --- .github/PULL_REQUEST_TEMPLATE.md | 10 + .github/workflows/close-external-prs.yml | 68 ++ .gitignore | 2 + CHANGELOG.md | 10 + CONTRIBUTING.md | 21 + LICENSE | 7 + README.md | 752 +++++++++++++++ pyproject.toml | 60 ++ requirements.txt | 2 + shopify_app/__init__.py | 494 ++++++++++ shopify_app/_version.py | 5 + shopify_app/exchange/__init__.py | 12 + shopify_app/exchange/_response_builders.py | 97 ++ shopify_app/exchange/_validation.py | 145 +++ shopify_app/exchange/client_credentials.py | 382 ++++++++ shopify_app/exchange/refresh_token.py | 612 ++++++++++++ shopify_app/exchange/token_exchange.py | 808 ++++++++++++++++ shopify_app/graphql/__init__.py | 7 + shopify_app/graphql/admin_graphql.py | 908 ++++++++++++++++++ shopify_app/helpers/__init__.py | 9 + .../helpers/app_home_parent_redirect.py | 234 +++++ .../helpers/app_home_patch_id_token.py | 115 +++ shopify_app/helpers/app_home_redirect.py | 221 +++++ shopify_app/py.typed | 0 shopify_app/types.py | 382 ++++++++ shopify_app/utils/__init__.py | 9 + shopify_app/utils/headers.py | 22 + shopify_app/utils/http_client.py | 79 ++ shopify_app/utils/input_converters.py | 80 ++ shopify_app/utils/user_agent.py | 25 + shopify_app/verify/__init__.py | 8 + shopify_app/verify/_body_hmac_in_header.py | 178 ++++ .../verify/_non_exchangeable_id_token.py | 240 +++++ shopify_app/verify/admin_ui_ext.py | 256 +++++ shopify_app/verify/app_home_req.py | 421 ++++++++ shopify_app/verify/app_proxy.py | 252 +++++ shopify_app/verify/checkout_ui_ext.py | 26 + shopify_app/verify/customer_account_ui_ext.py | 28 + shopify_app/verify/flow_action.py | 24 + shopify_app/verify/pos_ui_ext.py | 275 ++++++ shopify_app/verify/webhook.py | 24 + 41 files changed, 7310 insertions(+) create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/workflows/close-external-prs.yml create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 pyproject.toml create mode 100644 requirements.txt create mode 100644 shopify_app/__init__.py create mode 100644 shopify_app/_version.py create mode 100644 shopify_app/exchange/__init__.py create mode 100644 shopify_app/exchange/_response_builders.py create mode 100644 shopify_app/exchange/_validation.py create mode 100644 shopify_app/exchange/client_credentials.py create mode 100644 shopify_app/exchange/refresh_token.py create mode 100644 shopify_app/exchange/token_exchange.py create mode 100644 shopify_app/graphql/__init__.py create mode 100644 shopify_app/graphql/admin_graphql.py create mode 100644 shopify_app/helpers/__init__.py create mode 100644 shopify_app/helpers/app_home_parent_redirect.py create mode 100644 shopify_app/helpers/app_home_patch_id_token.py create mode 100644 shopify_app/helpers/app_home_redirect.py create mode 100644 shopify_app/py.typed create mode 100644 shopify_app/types.py create mode 100644 shopify_app/utils/__init__.py create mode 100644 shopify_app/utils/headers.py create mode 100644 shopify_app/utils/http_client.py create mode 100644 shopify_app/utils/input_converters.py create mode 100644 shopify_app/utils/user_agent.py create mode 100644 shopify_app/verify/__init__.py create mode 100644 shopify_app/verify/_body_hmac_in_header.py create mode 100644 shopify_app/verify/_non_exchangeable_id_token.py create mode 100644 shopify_app/verify/admin_ui_ext.py create mode 100644 shopify_app/verify/app_home_req.py create mode 100644 shopify_app/verify/app_proxy.py create mode 100644 shopify_app/verify/checkout_ui_ext.py create mode 100644 shopify_app/verify/customer_account_ui_ext.py create mode 100644 shopify_app/verify/flow_action.py create mode 100644 shopify_app/verify/pos_ui_ext.py create mode 100644 shopify_app/verify/webhook.py diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..b82e233 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,10 @@ +# Before you create this pull request + +Thanks for your interest. This repository is a read-only mirror of a private repository and **we don't accept pull requests**. A workflow will close this PR automatically. + +Do this instead: + +- Cancel this PR. +- Report bugs, request features, or share feedback in the [Shopify dev community forums](https://community.shopify.dev/c/shopify-cli-libraries/14) + +For more details see [CONTRIBUTING.md](https://github.com/Shopify/shopify-app-python/blob/main/CONTRIBUTING.md). diff --git a/.github/workflows/close-external-prs.yml b/.github/workflows/close-external-prs.yml new file mode 100644 index 0000000..ad6f154 --- /dev/null +++ b/.github/workflows/close-external-prs.yml @@ -0,0 +1,68 @@ +name: Close External PRs + +on: + pull_request_target: + types: [opened, reopened] + +permissions: + pull-requests: write + +jobs: + close-external-pr: + runs-on: ubuntu-latest + steps: + - name: Check if PR author is from Shopify + id: check-author + uses: actions/github-script@v7 + with: + script: | + const author = context.payload.pull_request.user.login; + + try { + // Check if the author is a member of the Shopify organization + await github.rest.orgs.checkMembershipForUser({ + org: 'Shopify', + username: author + }); + + console.log(`${author} is a Shopify member`); + core.setOutput('is-shopify', 'true'); + } catch (error) { + if (error && error.status === 404) { + console.log(`${author} is not a Shopify member`); + } else { + console.log(`Error checking Shopify membership for ${author}: ${error}`); + } + core.setOutput('is-shopify', 'false'); + } + + - name: Close PR and comment + if: steps.check-author.outputs.is-shopify == 'false' + uses: actions/github-script@v7 + with: + script: | + const prNumber = context.payload.pull_request.number; + + // Add comment to PR + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body: `Thanks for your interest. This repository does not accept contributions, so we've closed this PR. + +To report a bug, request a feature, or share feedback, please post in the [Shopify dev community forums](https://community.shopify.dev/c/shopify-cli-libraries/14) + +We triage in the forums, not in this repo. PRs and issues here are closed without review. + +For more details see [CONTRIBUTING.md](https://github.com/Shopify/shopify-app-python/blob/main/CONTRIBUTING.md).` + }); + + // Close the PR + await github.rest.pulls.update({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber, + state: 'closed' + }); + + console.log(`Closed PR #${prNumber} from external contributor`); diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..854a5a5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +*.pyc +venv diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..37ee8e5 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,10 @@ +# Changelog + +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). + +## [0.1.0] - 2026-01-05 + +Initial release diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..a5404b8 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,21 @@ +# Contributing + +Thanks for your interest. This repository is a read-only mirror of a private repository. We don't accept contributions here. + +To report a bug, request a feature, or share feedback, create a post in the [Shopify dev community forums](https://community.shopify.dev/c/shopify-cli-libraries/14) + +## How to post in the forums + +When you start a topic, include: + +- The package you are using +- A clear summary of the problem, feature request, or feedback +- Steps to reproduce (if applicable), expected behavior, and actual behavior +- Relevant environment details (for example: API version, app type, browser, operating system) +- Screenshots or code snippets, if they help explain the issue + +We monitor the forums and use them to triage, prioritize, and discuss work. Posts there reach the right people faster, and keep the conversation in one place. + +## Pull requests and issues + +Any pull requests opened in this repository will be automatically closed. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..97c91af --- /dev/null +++ b/LICENSE @@ -0,0 +1,7 @@ +Copyright (c) 2025 Shopify Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..3fce1a8 --- /dev/null +++ b/README.md @@ -0,0 +1,752 @@ +# Shopify App Package + +Python package for building Shopify applications. + +## Installation + +```bash +pip install --upgrade shopify-app +``` + +## Requirements + +- Python >= 3.8 +- httpx for making HTTP requests +- pyjwt for JWT token handling + +## Features + +Request Verification: + +- `verify_admin_ui_ext_req`: Requests from Admin UI extensions +- `verify_app_home_req`: Requests for embedded app home that use App Bridge +- `verify_app_proxy_req`: Requests from storefronts via App Proxy +- `verify_checkout_ui_ext_req`: Requests from checkout UI extensions +- `verify_customer_account_ui_ext_req`: Requests from Customer account UI extensions +- `verify_flow_action_req`: Requests from Flow action extensions +- `verify_pos_ui_ext_req`: Requests from POS UI extensions +- `verify_webhook_req`: Webhook requests + +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 +- `refresh_token_exchanged_access_token`: Refresh an access token that was created using Token Exchange. + +GraphQL: + +- `admin_graphql_request`: Make Admin API GraphQL requests with automatic retry handling + +Helpers: + +- `app_home_patch_id_token`: Render the patch ID token page for embedded apps +- `app_home_parent_redirect`: Asks the parent (Shopify admin) to redirect to a new URL, breaking out of the iframe +- `app_home_redirect`: Redirects to a relative URL within the app home iframe + +## Principles + +1. **Built-in best practices:** This package encodes best practices for building Shopify apps as primitives. Use them correctly and you'll build secure, performant apps on the green-path. +2. **What most apps need most of the time:** This package does not intend to focus on some less common features of the Shopify app platform (e.g: Non Embedded apps). +3. **Framework agnostic:** Whether you're using Django, Flask, or FastAPI, this package won't force architectural decisions on you. We provide primitives. You compose them however you wish. We've prototyped extensively to make sure that composition can lead to idiomatic patterns. +4. **Language agnostic:** Whilst this is a Python package, its API is shared with a PHP package. This creates some interesting constraints, and sacrifices some idioms. But... the big benefit is that fixes in one community will benefit the other. As the PHP package evolves, so will the Python package (and vice-versa). + +## Setup steps + +This section will focus on steps that are universal to any web framework. We'll provide examples for Django, FastAPI and Flask. But these examples are fairly universal and can be translated to other approaches. + +### Install the Shopify CLI + +This installs Shopify CLI globally on your system, so you can run shopify commands from any directory. + +``` +npm install -g @shopify/cli@latest +``` + +Please see [this guide](https://shopify.dev/docs/api/shopify-cli#installation) for using other JavaScript package managers + +### Initialize your web framework + +- [Django quickstart](https://docs.djangoproject.com/en/stable/intro/tutorial01/) +- [Flask quickstart](https://flask.palletsprojects.com/en/latest/quickstart/) +- [FastAPI quickstart](https://fastapi.tiangolo.com/tutorial/) + +### Setup the Shopify CLI + +Inside the directory where you initialized your framework create a `shopify.app.toml` (This will be overwritten when you run `shopify app init --reset`): + +```toml +client_id = "" +name = "" +application_url = "" +embedded = true + +[access_scopes] +scopes = "write_products" + +[webhooks] +api_version = "2025-01" +``` + +Make sure there is at-least a minimal `package.json`: + +```json +{ + "name": "my-python-app", + "scripts": { + "start": "python manage.py runserver" + } +} +``` + +Create a `shopify.web.toml`: + +```toml +name = "My Python App" +roles = ["frontend", "backend"] +webhooks_path = "/webhooks/app/uninstalled" + +[commands] +dev = "[COMMAND]" +``` + +Replace `[COMMAND]` with the command to run your app in development mode. For example: + +- Django: `python manage.py runserver` +- Flask: `flask run` +- FastAPI: `uvicorn main:app --reload` + +### Configure the PORT + +The Shopify CLI needs your web framework to run on a specific port. The CLI provides an environment variable. It's important you use this. Here are some examples. + +Django (in `manage.py`): + +```python +sys.argv.append(f"0.0.0.0:{os.getenv('PORT', '8000')}") +``` + +Flask (in `app.py`): + +```python +app.run(host="0.0.0.0", port=int(os.getenv("PORT", "5000"))) +``` + +FastAPI (in `main.py`): + +```python +uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("PORT", "8000"))) +``` + +### Run your app + +With these setup steps complete you should be able to run + +```bash +shopify app dev --reset +``` + +Only use the `--reset` flag the first time. + +## Using the package + +### Initialization + +`SHOPIFY_API_KEY` and `SHOPIFY_API_SECRET` are provided by the Shopify CLI. + +```python +import os +from shopify_app import ShopifyApp + +shopify = ShopifyApp( + client_id=os.getenv("SHOPIFY_API_KEY"), + client_secret=os.getenv("SHOPIFY_API_SECRET"), +) +``` + +For secret rotation, `old_client_secret` is an optional keyword argument. Since the CLI does not provide this env var, you will need to provide it manually. Read more about [secret rotation](https://shopify.dev/docs/apps/build/authentication-authorization/client-secrets/rotate-revoke-client-credentials). + +### Converting a Request + +So that the package can support multiple frameworks, your app must convert your frameworks concept of a Request to the package's concept. + +Django Example: + +```python +# Django passes the request to views and view decorators +def request_to_shopify_req(request): + return { + "method": request.method, + "headers": dict(request.headers), + "url": request.build_absolute_uri(), + "body": request.body.decode("utf-8") if request.body else "", + } +``` + +FastAPI Example: + +```python +from fastapi import Request + +async def request_to_shopify_req(request: Request): + body = await request.body() + return { + "method": request.method, + "headers": dict(request.headers), + "url": str(request.url), + "body": body.decode("utf-8") if body else "", + } +``` + +Flask Example: + +```python +from flask import request + +def request_to_shopify_req(): + return { + "method": request.method, + "headers": dict(request.headers), + "url": request.url, + "body": request.get_data(as_text=True), + } +``` + +### Converting a Shopify response + +Your app must convert the packages concept of a Response to the frameworks concept. The Result provided by the package's function also includes a `log` attribute with these properties: + +- `code`: A short string describing the situation +- `detail`: Copy describing the state of the request and what you should do next. +- `req`: The Req that was passed to the function. + +We recommend logging this information to help you debug. + +Django example: + +```python +import logging +from django.http import HttpResponse + +logger = logging.getLogger(__name__) + +def shopify_result_to_response(result): + logger.info("%s - %s", result.log.code, result.log.detail) + + return HttpResponse( + result.response.body, + status=result.response.status, + headers=result.response.headers, + ) +``` + +FastAPI example: + +```python +import logging +from fastapi.responses import Response + +logger = logging.getLogger(__name__) + +def shopify_result_to_response(result): + logger.info("%s - %s", result.log.code, result.log.detail) + + return Response( + content=result.response.body, + status_code=result.response.status, + headers=result.response.headers, + ) +``` + +Flask Example: + +```python +import logging +from flask import Response + +logger = logging.getLogger(__name__) + +def shopify_result_to_response(result): + logger.info("%s - %s", result.log.code, result.log.detail) + + return Response( + response=result.response.body, + status=result.response.status, + headers=result.response.headers, + ) +``` + +### Verifying request result + +Verifying a request returns a result dataclass. Results are similar across all verify functions, with some differences. + +Common attributes (all verify functions): + +| Attribute | Description | Nullable | +| ---------- | ------------------------------------------------------------------------------------------------------ | -------- | +| `ok` | Boolean indicating if the request passed verification. Respond with the Response if `False` | No | +| `shop` | The shop sub domain (e.g: `test-shop`, for `test-shop.myshopify.com`). `None` when verification fails. | Yes | +| `log` | LogWithReq with `code`, `detail`, and `req` attributes for debugging and monitoring. | No | +| `response` | Res with `status`, `body`, and `headers` attributes. Return this when `ok` is `False`. | No | + +Attributes for Exchangeable ID Token Requests (`verify_app_home_req`, `verify_admin_ui_ext_req`, `verify_pos_ui_ext_req`): + +| Attribute | Description | Nullable | +| ----------------------- | ----------------------------------------------------------------------------------------- | -------- | +| `user_id` | The merchant user ID. `None` if `ok` is `False`. | Yes | +| `id_token` | IdTokenDetails with `exchangeable` (bool), `token` (str), and `claims` (dict) attributes. | Yes | +| `new_id_token_response` | Pre-built response for invalid token retry flow. | Yes | + +Attributes for App Proxy Requests (`verify_app_proxy_req`): + +| Attribute | Description | Nullable | +| ----------------------- | ----------------------------------------------------------------------------------------------------- | -------- | +| `logged_in_customer_id` | The customer ID if logged in. `None` if not logged in. This is a customer ID, not a merchant user ID. | Yes | + +### Verifying Requests with exchangeable ID Tokens + +Some requests provide exchangeable ID tokens: + +1. App home +2. Admin UI Extensions +3. POS UI Extensions + +ID tokens from these requests can be exchanged for access tokens, which can be used to access the Admin GraphQL API. These verification methods provide a user id (merchant id) so you can look up an online access token in your database. + +#### App Home + +First we verify the request: + +```python +from .shopify import shopify + +def app_home(request): + req = request_to_shopify_req(request) + + result = shopify.verify_app_home_req( + req, + app_home_patch_id_token_path="/auth/patch-id-token", + ) + + # The request should not be trusted + if not result.ok: + return shopify_result_to_response(result) + +``` + +Then we check if there is an access token in the database. If there is one we check if it needs to be refreshed. + +```python + # Your database logic here + access_token = get_access_token(shop=result.shop, mode="offline") + + if access_token: + refresh_result = shopify.refresh_token_exchanged_access_token(access_token) + + if not refresh_result.ok: + return shopify_result_to_response(refresh_result) + + if refresh_result.access_token: + # Package returned a refreshed token — save it + save_access_token(refresh_result.access_token) +``` + +You will need to write the database code to get and save access tokens. The package returns access tokens as dataclasses with these attributes: + +| Attribute | Type | Description | +| ----------------------- | ---------- | --------------------------------------------------- | +| `shop` | str | Shop domain (e.g., "test-shop.myshopify.com") | +| `access_mode` | str | Access mode: "online" or "offline" | +| `token` | str | The access token | +| `scope` | str | Granted scopes | +| `refresh_token` | str | Token used to refresh the access token | +| `expires` | str | ISO 8601 datetime when access token expires | +| `refresh_token_expires` | str | ISO 8601 datetime when refresh token expires | +| `user_id` | str | A unique identifier for the user | +| `user` | AccessUser | User details (online mode only, `None` for offline) | + +When `access_mode` is "online", the `user` dataclass contains: + +| Attribute | Type | Description | +| ---------------- | ---- | ------------------------------------------------ | +| `id` | int | A unique identifier for the user | +| `first_name` | str | User's first name | +| `last_name` | str | User's last name | +| `email` | str | User's email address | +| `email_verified` | bool | Whether the email is verified | +| `account_owner` | bool | Whether the user is the account owner | +| `locale` | str | User's locale (e.g., "en") | +| `collaborator` | bool | Whether the user is a collaborator | +| `scope` | str | User-specific scopes (may differ from app scope) | + +Note: For JSON serialization, use `dataclasses.asdict(result.access_token)` to convert to a dictionary. + +If there is no access token in the database, use token exchange to get one: + +```python + if not access_token: + exchange_result = shopify.exchange_using_token_exchange( + access_mode="offline", + id_token=result.id_token, + invalid_token_response=result.new_id_token_response, + ) + + if not exchange_result.ok: + return shopify_result_to_response(exchange_result) + + # Save the new token + save_access_token(exchange_result.access_token) +``` + +Note: + +- `exchange_using_token_exchange` receives `result.new_id_token_response` from the verify function. This allows Shopify to automatically retry this request if the id token has become stale. +- If using online access tokens, use the `user_id` provided by the `result`. +- If your app has need to access the admin API outside of requests from App Home, Admin UI Extensions or POS UI Extensions you should also exchange and save an offline token. + +App home requests require [special Response headers](https://shopify.dev/docs/apps/build/security/set-up-iframe-protection). The `result` provides a response that contains these headers. Copy them to your response: + +```python +# Copy headers from result to your response +for header, value in result.response.headers.items(): + response[header] = value +``` + +App requests should also contain [App Bridge](https://shopify.dev/docs/api/app-bridge) and [Polaris Web Components](https://shopify.dev/docs/api/app-home/using-polaris-components) script tags so they remain secure and can look like Shopify: + +```html + + +``` + +Replace `{{ client_id }}` with the `SHOPIFY_API_KEY` provided by the Shopify CLI. + +Add a special route for handling some edge cases. Adding this route ensures the merchant experience is resilient: + +```python +def patch_id_token(request): + req = request_to_shopify_req(request) + result = shopify.app_home_patch_id_token(req) + + return shopify_result_to_response(result) +``` + +This route should match the path configured here: + +```python + result = shopify.verify_app_home_req( + req, + app_home_patch_id_token_path="/auth/patch-id-token", + ) +``` + +#### Redirecting Outside the App Home Iframe + +Use `app_home_parent_redirect` when you need to redirect the merchant to an external URL, breaking out of the app iframe: + +```python +def some_handler(request): + req = request_to_shopify_req(request) + + result = shopify.verify_app_home_req(req, app_home_patch_id_token_path="/auth/patch-id-token") + if not result.ok: + return shopify_result_to_response(result) + + # Redirect to an external URL + redirect_result = shopify.app_home_parent_redirect( + req, + redirect_url="https://example.com", + shop=result.shop, + ) + + return shopify_result_to_response(redirect_result) +``` + +For navigating to admin pages, we recommend using [Admin Intents](https://shopify.dev/docs/apps/build/admin/admin-intents) as this provides the best merchant experience. However, if this is not possible, you can redirect to Shopify admin pages using the `shop` value from the verify result (e.g., `f"https://admin.shopify.com/store/{result.shop}/products"`). + +#### Redirecting Within the App Home Iframe + +Use `app_home_redirect` when you need to redirect to another route within your app, staying inside the app iframe: + +```python +def some_handler(request): + req = request_to_shopify_req(request) + + result = shopify.verify_app_home_req(req, app_home_patch_id_token_path="/auth/patch-id-token") + if not result.ok: + return shopify_result_to_response(result) + + # Redirect to another route within the app + redirect_result = shopify.app_home_redirect( + req, + redirect_url="/dashboard", + shop=result.shop, + ) + + return shopify_result_to_response(redirect_result) +``` + +Note: The redirect URL must be a relative path starting with `/`. URL parameters from the original request are automatically merged into the redirect URL. + +#### Admin UI Extensions + +Admin UI Extension are very similar to App Home. You only need change the verify method: + +```python +result = shopify.verify_admin_ui_ext_req(req) +``` + +Admin UI extensions do not need the app home patch id token route. They do not need special headers or Polaris and App Bridge + +#### POS UI Extension + +POS UI Extension are very similar to App Home. You only need change the verify method: + +```python +result = shopify.verify_pos_ui_ext_req(req) +``` + +POS UI extensions do not need the app home patch id token route. They do not need special headers or Polaris and App Bridge + +### GraphQL Requests + +The package provides a method for making Admin GraphQL requests. Note, there may be a better more performant ways to access data using Shopify's infrastructure rather than your own: + +- App Home has [Direct API](https://shopify.dev/docs/api/app-home#direct-api-access). +- Admin UI Extensions have [the Query API](https://shopify.dev/docs/api/admin-extensions/latest/api/target-apis/standard-api#standardapi-propertydetail-query) +- POS UI Extensions have [Direct API](https://shopify.dev/docs/api/pos-ui-extensions/latest#direct-api-access) +- Customer Account UI Extensions can query [the Customer Account API](https://shopify.dev/docs/api/customer-account-ui-extensions/latest/apis/customer-account-api), the [Storefront API](https://shopify.dev/docs/api/customer-account-ui-extensions/latest/apis/storefront-api) and the [Order Status API](https://shopify.dev/docs/api/customer-account-ui-extensions/latest/apis/order-status-api/addresses). +- Checkout UI Extensions can query the [Storefront API](https://shopify.dev/docs/api/checkout-ui-extensions/latest/apis/storefront-api) directly. + +If you do wish to access the Admin GraphQL API on your server, here is how: + +#### When responding to a request from Shopify + +Here is how to make a GraphQL request in the context of a request from Shopify. Important notes about this example: + +1. This example will use an app home request, but it applies to multiple verify methods +2. This example assumes the request is idempotent +3. This examples assumes, that in the event of a failure, you just want Shopify to retry the request. + +More details on points 2 & 3 after the code example. + +```python +def app_home_handler(request): + req = request_to_shopify_req(request) + + result = shopify.verify_app_home_req(req) + if not result.ok: + return shopify_result_to_response(result) + + # Your database logic here + access_token = get_access_token(shop=result.shop, mode="offline") + + graphql_result = shopify.admin_graphql_request( + """ + { + shop { + id + } + } + """, + shop=result.shop, + access_token=access_token, + api_version="2025-01", + # Passing `result.new_id_token_response` from the verify function + # tells `admin_graphql_request` in what context the GraphQL request is being made. + # This becomes important if the GraphQL request fails and you wish for Shopify to retry the request. + invalid_token_response=result.new_id_token_response, + ) + + # The GraphQL failed + if not graphql_result.ok: + + # The access_token is invalid + # In this example we take the simplest possible approach + # But depending on your logic, you may want a more complex approach + # Options are detailed below + if graphql_result.log.code == "unauthorized": + delete_access_token(shop=result.shop, mode="offline") + + return shopify_result_to_response(graphql_result) + + shop_id = graphql_result.data["shop"]["id"] +``` + +You will get an `unauthorized` log code if: + +1. The app was uninstalled (unrecoverable) +2. Your app requested additional scopes, but the users has not yet approved them and you are making a graphQL operation that requires the additional scopes. +3. Your access token has been revoked + +If 1 happens, the merchant needs to manually reinstall the app. If 2 or 3 happens there are different approaches you can take: + +| Option | Steps | Use when | +| ---------------------------------------- | ------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------- | +| 1. Delete & retry (shown above) | Delete token → return retry response | Request is idempotent. OK for Shopify to auto-retry | +| 2. Exchange & update with retry fallback | Token exchange → update token → retry GraphQL → (on fail) delete token → return retry response | Request is not idempotent. You can revert prior operations. OK for Shopify to auto-retry | +| 3. Exchange with no fallback | Token exchange → update token → retry GraphQL → (on fail) delete token → return non-retry 401 response | Request is not idempotent. It is not OK for Shopify to auto-retry | + +#### In a background job + +When making GraphQL requests in a background job (e.g., processing a webhook, scheduled task) pass `None` for `invalid_token_response`. if the access token is invalid, the request will simply fail. + +```python +def process_job(shop): + # Your database logic here + access_token = get_access_token(shop=shop, mode="offline") + + graphql_result = shopify.admin_graphql_request( + """ + { + shop { + id + } + } + """, + shop=shop, + access_token=access_token.token, + api_version="2025-01", + invalid_token_response=None, + ) + + if not graphql_result.ok: + return + + shop_id = graphql_result.data["shop"]["id"] +``` + +#### Customizing GraphQL Requests + +`admin_graphql_request` has the following options to customize the GraphQL Request: + +- `shop`: Shop domain (e.g., "test-shop"). +- `access_token`: Valid access token for the shop. +- `api_version`: API version (e.g., "2025-01") +- `variables`: Optional dictionary of GraphQL variables to pass with your query +- `headers`: Optional dictionary of additional HTTP headers to include in the request +- `max_retries`: Optional custom retry count for rate-limited or transient errors (default: 2) +- `invalid_token_response`: From verification result. If provided, enables retry response when token is invalid (Admin UI Extension or App Home with idempotent operation). If `None`, only fail response is available (requests without ID tokens, background jobs, requires user input before retry) + +#### The GraphQL Result + +`admin_graphql_request` returns a result dataclass with these attributes: + +- `ok`: Boolean indicating if the request was successful. +- `shop`: The shop domain, or `None` if the request failed. +- `log`: Log with `code` and `detail` attributes describing the result state. +- `response`: Res with `status`, `body`, and `headers` attributes. +- `http_logs`: List of HttpLog dataclasses for debugging and monitoring. +- `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. + +### Verifying requests without exchangeable id tokens + +The following requests do not provide the required information for token exchange: + +- Webhooks +- App Proxy +- Customer Account UI Extension +- Checkout UI Extension + +Webhook and App Proxy requests do not provide an id token. Customer Account and Checkout UI Extensions provide an id token, but it is not exchangeable. None of these requests provide a merchant user ID. + +If you require access to the Shopify Admin GraphQL API during these requests you must load an offline access token that was exchanged from an App Home, Admin UI or POS UI Extension request. + +#### Webhooks + +```python +def webhook_handler(request): + req = request_to_shopify_req(request) + + result = shopify.verify_webhook_req(req) + if not result.ok: + return shopify_result_to_response(result) + + # Your database logic here + access_token = get_access_token(shop=result.shop, mode="offline") +``` + +#### App Proxy + +App proxy is very similar to webhooks: + +```python +result = shopify.verify_app_proxy_req(req) +logged_in_customer_id = result.logged_in_customer_id +``` + +If the customer is not logged in, the `logged_in_customer_id` will be `None`. Do not confuse this with a `user_id` stored with an online token which are merchant IDs, not customer IDs. + +#### Customer Account UI Extension + +Customer Account UI Extensions are almost identical to webhooks: + +```python +result = shopify.verify_customer_account_ui_ext_req(req) +``` + +#### Checkout UI Extension + +Checkout UI Extensions are almost identical to webhooks: + +```python +result = shopify.verify_checkout_ui_ext_req(req) +``` + +#### Flow actions + +Flow Action requests are almost identical to webhooks: + +```python +result = shopify.verify_flow_action_req(req) +``` + +### Getting access tokens with Client Credentials + +[Client credentials exchange](https://shopify.dev/docs/apps/build/authentication-authorization/access-tokens/client-credentials-grant) allows you to obtain an access token using only your app's client ID and client secret, without requiring an ID token. This is designed for trusted, server-to-server integrations (for example, internal automation or back-office services). + +```python +def get_or_refresh_access_token(shop): + # Check if we have a valid token + existing_token = get_access_token(shop) + if existing_token and not is_expired(existing_token.expires): + return existing_token + + # Get a new token using client credentials + result = shopify.exchange_using_client_credentials(shop=shop) + + if not result.ok: + # Log the error + logger.error(f"{result.log.code} - {result.log.detail}") + return None + + # Save the new token + save_access_token(result.access_token) + return result.access_token +``` + +The `access_token` dataclass contains: + +| Attribute | Description | +| --------- | --------------------------------------------------- | +| `shop` | The shop domain | +| `token` | The access token string | +| `scope` | The granted scopes | +| `expires` | ISO 8601 datetime when the token expires (24 hours) | + +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. + +## Contributing, issues, feedback and feature requests + +This package does not accept contributions, but we'd love to hear your feedback. + +To report a bug, request a feature, or share feedback, post in the [Shopify dev community forums](https://community.shopify.dev/c/shopify-cli-libraries/14). Please don’t open pull requests or GitHub issues here; They will be closed automatically. + +We triage and discuss work in the forums. Please see [CONTRIBUTING.md](https://github.com/Shopify/shopify-app-python/blob/main/CONTRIBUTING.md) for details. + +## Created a template? + +We've confirmed that AI can scaffold an app using this README. If you create an app template and you'd like to open source it, we'd love to hear from you. Perhaps it can benefit other Python developers. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..18dc250 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,60 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "shopify-app" +dynamic = ["version"] +description = "Package for building Shopify applications. Authored and maintained by Shopify." +readme = "README.md" +license = {file = "LICENSE"} +requires-python = ">=3.8" +keywords = ["shopify", "shopify-apps", "token-exchange", "client-credentials", "admin-graphql", "webhooks", "app-proxy", "flow-action"] +authors = [ + { name = "Shopify" } +] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Software Development :: Libraries :: Python Modules", +] +dependencies = [ + "pyjwt>=2.8.0", + "httpx>=0.24.0", +] + +[project.urls] +homepage = "https://github.com/Shopify/shopify-app-python/" +issues = "https://community.shopify.dev/c/shopify-cli-libraries/14" +changelog = "https://github.com/Shopify/shopify-app-python/blob/main/CHANGELOG.md" + +[tool.hatch.version] +path = "shopify_app/_version.py" + +[tool.hatch.build.targets.wheel] +packages = ["shopify_app"] + +[tool.hatch.build.targets.sdist] +include = [ + "/shopify_app", + "/README.md", + "/LICENSE", + "/CHANGELOG.md", +] + +[tool.black] +line-length = 88 +target-version = ["py38"] + +[tool.isort] +profile = "black" +line_length = 88 + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..580483d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +pyjwt>=2.8.0 +httpx>=0.24.0 diff --git a/shopify_app/__init__.py b/shopify_app/__init__.py new file mode 100644 index 0000000..823f802 --- /dev/null +++ b/shopify_app/__init__.py @@ -0,0 +1,494 @@ +""" +Shopify App Python SDK + +This package provides Python implementations of Shopify app verification functions. +All functions return frozen dataclasses for type safety and IDE autocomplete support. +""" + +from __future__ import annotations + +from typing import Optional, Union + +import httpx + +from ._version import __version__ +from .exchange.client_credentials import ( + exchange_using_client_credentials, + exchange_using_client_credentials_async, +) +from .exchange.refresh_token import refresh_access_token, refresh_access_token_async +from .exchange.token_exchange import token_exchange, token_exchange_async +from .graphql.admin_graphql import admin_graphql_request, admin_graphql_request_async +from .helpers.app_home_parent_redirect import app_home_parent_redirect +from .helpers.app_home_patch_id_token import app_home_patch_id_token +from .helpers.app_home_redirect import app_home_redirect + +# Export all public types +from .types import ( # Configuration types; Core types; Token types; Result types; Type aliases + AccessMode, + AppConfig, + ClientCredentialsAccessToken, + ClientCredentialsExchangeResult, + GQLResult, + HttpLog, + IdTokenDetails, + Log, + LogWithReq, + RequestInput, + Res, + ResultForReq, + ResultWithExchangeableIdToken, + ResultWithLoggedInCustomerId, + ResultWithNonExchangeableIdToken, + TokenExchangeAccessToken, + TokenExchangeResult, + User, +) +from .verify.admin_ui_ext import verify_admin_ui_ext_req +from .verify.app_home_req import verify_app_home_req +from .verify.app_proxy import verify_app_proxy_req +from .verify.checkout_ui_ext import verify_checkout_ui_ext_req +from .verify.customer_account_ui_ext import verify_customer_account_ui_ext_req +from .verify.flow_action import verify_flow_action_req +from .verify.pos_ui_ext import verify_pos_ui_ext_req +from .verify.webhook import verify_webhook_req + + +class ShopifyApp: + """ + ShopifyApp class for verifying Shopify requests. + + Args: + client_id (str): The Shopify app client ID + client_secret (str): The Shopify app client secret + old_client_secret (str, optional): Previous client secret for rotation + """ + + def __init__( + self, + client_id: str, + client_secret: str, + old_client_secret: Optional[str] = None, + ): + if not client_id: + raise ValueError("client_id is required in ShopifyApp configuration") + + if not client_secret: + raise ValueError("client_secret is required in ShopifyApp configuration") + + self.config: AppConfig = { + "client_id": client_id, + "client_secret": client_secret, + "old_client_secret": old_client_secret, + } + + def verify_webhook_req(self, request: RequestInput) -> ResultForReq: + """ + Verify a webhook request from Shopify. + + Args: + request (RequestInput): A RequestInput dict with method, headers, url, and body fields + + Returns: + ResultForReq: Verification result with ok, shop, log, and response fields + """ + return verify_webhook_req(request, self.config) + + def verify_flow_action_req(self, request: RequestInput) -> ResultForReq: + """ + Verify a Flow action request from Shopify. + + Args: + request (RequestInput): Request dictionary with method, headers, url, and body + + Returns: + ResultForReq: Verification result with ok, shop, log, and response fields + """ + return verify_flow_action_req(request, self.config) + + def verify_checkout_ui_ext_req( + self, request: RequestInput + ) -> ResultWithNonExchangeableIdToken: + """ + Verify a Checkout UI Extension request from Shopify. + + Args: + request (RequestInput): A RequestInput dict with method, headers, url, and body fields + + Returns: + ResultWithNonExchangeableIdToken: Verification result with ok, shop, id_token, log, and response fields + """ + return verify_checkout_ui_ext_req(request, self.config) + + def verify_pos_ui_ext_req( + self, request: RequestInput + ) -> ResultWithExchangeableIdToken: + """ + Verify a POS UI Extension request from Shopify. + + Args: + request (RequestInput): A RequestInput dict with method, headers, url, and body fields + + Returns: + ResultWithExchangeableIdToken: Verification result with ok, shop, user_id, id_token, log, response, and new_id_token_response fields + """ + return verify_pos_ui_ext_req(request, self.config) + + def verify_customer_account_ui_ext_req( + self, request: RequestInput + ) -> ResultWithNonExchangeableIdToken: + """ + Verify a Customer Account UI Extension request from Shopify. + + Args: + request (RequestInput): A RequestInput dict with method, headers, url, and body fields + + Returns: + ResultWithNonExchangeableIdToken: Verification result with ok, shop, id_token, log, and response fields + """ + return verify_customer_account_ui_ext_req(request, self.config) + + def verify_admin_ui_ext_req( + self, request: RequestInput + ) -> ResultWithExchangeableIdToken: + """ + Verify an Admin UI Extension request from Shopify. + + Args: + request (RequestInput): A RequestInput dict with method, headers, url, and body fields + + Returns: + ResultWithExchangeableIdToken: Verification result with ok, shop, user_id, id_token, log, response, and new_id_token_response fields + """ + return verify_admin_ui_ext_req(request, self.config) + + def verify_app_home_req( + self, request: RequestInput, app_home_patch_id_token_path: str = "" + ) -> ResultWithExchangeableIdToken: + """ + Verify an App Home request from Shopify. + + Args: + request (RequestInput): A RequestInput dict with method, headers, url, and body fields + app_home_patch_id_token_path (str): Path to the patch ID token page + + Returns: + ResultWithExchangeableIdToken: Verification result with ok, shop, user_id, id_token, log, response, and new_id_token_response fields + """ + return verify_app_home_req(request, self.config, app_home_patch_id_token_path) + + def verify_app_proxy_req( + self, request: RequestInput + ) -> ResultWithLoggedInCustomerId: + """ + Verify an App Proxy request from Shopify. + + Args: + request (RequestInput): A RequestInput dict with method, headers, url, and body fields + + Returns: + ResultWithLoggedInCustomerId: Verification result with ok, shop, logged_in_customer_id, log, and response fields + """ + return verify_app_proxy_req(request, self.config) + + def app_home_patch_id_token(self, request: RequestInput) -> ResultForReq: + """ + Render the App Home Patch ID Token page. + + Args: + request (RequestInput): A RequestInput dict with method, headers, url, and body fields + + Returns: + ResultForReq: Result with ok, shop, log, and response containing HTML and headers + """ + return app_home_patch_id_token(request, self.config) + + def app_home_parent_redirect( + self, + request: RequestInput, + redirect_url: str, + shop: str, + target: Optional[str] = None, + ) -> ResultForReq: + """ + Generate a redirect response that breaks out of the app home iframe. + + Args: + request (RequestInput): A RequestInput dict with method, headers, url, and body fields + redirect_url (str): The URL to redirect to + shop (str): The shop domain (e.g., "test-shop") + target (str, optional): Target window: "_top" or "_blank" (default: "_top") + + Returns: + ResultForReq: Result with ok, shop, log, and response + """ + return app_home_parent_redirect( + request, self.config, redirect_url, shop, target + ) + + def app_home_redirect( + self, request: RequestInput, redirect_url: str, shop: str + ) -> ResultForReq: + """ + Generate a redirect response that stays within the app home iFrame. + + Args: + request (RequestInput): A RequestInput dict with method, headers, url, and body fields + redirect_url (str): The relative URL to redirect to (must start with '/') + shop (str): The shop domain (e.g., "test-shop") + + Returns: + ResultForReq: Result with ok, shop, log, and response + """ + return app_home_redirect(request, self.config, redirect_url, shop) + + def exchange_using_token_exchange( + self, + access_mode: str, + id_token: Optional[Union[IdTokenDetails, dict]] = None, + invalid_token_response: Optional[Union[Res, dict]] = None, + http_client: Optional[httpx.Client] = None, + ) -> TokenExchangeResult: + """ + Exchange a pre-validated ID token for an API access token using OAuth 2.0 Token Exchange. + + Args: + access_mode (str): Either "online" or "offline" + id_token (IdTokenDetails | dict): IdTokenDetails or dict with exchangeable, token, and claims + invalid_token_response (Res | dict): Pre-built response to return if token is invalid (or None) + http_client: Optional HTTP client for testing (undocumented) + + Returns: + TokenExchangeResult: Result with ok, shop, access_token, log, response, and http_logs + """ + return token_exchange( + access_mode, + self.config, + id_token=id_token, + invalid_token_response=invalid_token_response, + http_client=http_client, + ) + + def refresh_token_exchanged_access_token( + self, + access_token: Union[TokenExchangeAccessToken, dict], + http_client: Optional[httpx.Client] = None, + ) -> TokenExchangeResult: + """ + Refresh an expired access token using a refresh token. + + Args: + access_token (TokenExchangeAccessToken | dict): TokenExchangeAccessToken or dict with shop, refresh_token, expires, and refresh_token_expires + http_client: Optional HTTP client for testing (undocumented) + + Returns: + TokenExchangeResult: Result with ok, shop, access_token, log, response, and http_logs + """ + return refresh_access_token(access_token, self.config, http_client) + + def exchange_using_client_credentials( + self, + shop: str, + http_client: Optional[httpx.Client] = None, + ) -> ClientCredentialsExchangeResult: + """ + Exchange client credentials for an API access token. + + Args: + shop (str): The shop domain (e.g., "shop-name") + http_client: Optional HTTP client for testing (undocumented) + + 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 + ) + + def admin_graphql_request( + self, + query: str, + shop: str, + access_token: str, + api_version: str, + invalid_token_response: Optional[Union[Res, dict]] = None, + variables: Optional[dict] = None, + headers: Optional[dict] = None, + max_retries: int = 2, + http_client: Optional[httpx.Client] = None, + ) -> GQLResult: + """ + Make a GraphQL request to the Shopify Admin API. + + Args: + query (str): The GraphQL query or mutation string + shop (str): Shop domain (e.g., "example") + access_token (str): Valid access token for the shop + api_version (str): API version (e.g., "2024-01") + invalid_token_response (Res | dict): Pre-built response to return if token is invalid + variables (dict[str, Any]): Optional GraphQL variables + headers (dict[str, str]): Optional additional HTTP headers + max_retries (int): Maximum retry count (default: 2) + http_client: Optional HTTP client for testing (undocumented) + + Returns: + GQLResult: Result with ok, shop, log, response, data, extensions, and http_logs fields + """ + return admin_graphql_request( + query, + shop=shop, + access_token=access_token, + api_version=api_version, + invalid_token_response=invalid_token_response, + variables=variables, + headers=headers, + max_retries=max_retries, + app_config=self.config, + http_client=http_client, + ) + + # Async methods + + async def exchange_using_token_exchange_async( + self, + access_mode: str, + id_token: Optional[Union[IdTokenDetails, dict]] = None, + invalid_token_response: Optional[Union[Res, dict]] = None, + http_client: Optional[httpx.AsyncClient] = None, + ) -> TokenExchangeResult: + """ + Async version of exchange_using_token_exchange. + + Exchange a pre-validated ID token for an API access token using OAuth 2.0 Token Exchange. + + Args: + access_mode (str): Either "online" or "offline" + id_token (IdTokenDetails | dict): IdTokenDetails or dict with exchangeable, token, and claims + invalid_token_response (Res | dict): Pre-built response to return if token is invalid (or None) + http_client: Optional async HTTP client for testing (httpx.AsyncClient) + + Returns: + TokenExchangeResult: Result with ok, shop, access_token, log, response, and http_logs + """ + return await token_exchange_async( + access_mode, + self.config, + id_token=id_token, + invalid_token_response=invalid_token_response, + http_client=http_client, + ) + + async def refresh_token_exchanged_access_token_async( + self, + access_token: Union[TokenExchangeAccessToken, dict], + http_client: Optional[httpx.AsyncClient] = None, + ) -> TokenExchangeResult: + """ + Async version of refresh_token_exchanged_access_token. + + Refresh an expired access token using a refresh token. + + Args: + access_token (TokenExchangeAccessToken | dict): TokenExchangeAccessToken or dict with shop, refresh_token, expires, and refresh_token_expires + http_client: Optional async HTTP client for testing (httpx.AsyncClient) + + Returns: + TokenExchangeResult: Result with ok, shop, access_token, log, response, and http_logs + """ + return await refresh_access_token_async(access_token, self.config, http_client) + + async def exchange_using_client_credentials_async( + self, + shop: str, + http_client: Optional[httpx.AsyncClient] = None, + ) -> ClientCredentialsExchangeResult: + """ + Async version of exchange_using_client_credentials. + + Exchange client credentials for an API access token. + + Args: + shop (str): The shop domain (e.g., "shop-name") + http_client: Optional async HTTP client for testing (httpx.AsyncClient) + + Returns: + ClientCredentialsExchangeResult: Result with ok, shop, access_token, log, response, and http_logs + """ + return await exchange_using_client_credentials_async( + shop, + self.config, + http_client=http_client, + ) + + async def admin_graphql_request_async( + self, + query: str, + shop: str, + access_token: str, + api_version: str, + invalid_token_response: Optional[Union[Res, dict]] = None, + variables: Optional[dict] = None, + headers: Optional[dict] = None, + max_retries: int = 2, + http_client: Optional[httpx.AsyncClient] = None, + ) -> GQLResult: + """ + Async version of admin_graphql_request. + + Make an async GraphQL request to the Shopify Admin API. + + Args: + query (str): The GraphQL query or mutation string + shop (str): Shop domain (e.g., "example") + access_token (str): Valid access token for the shop + api_version (str): API version (e.g., "2024-01") + invalid_token_response (Res | dict): Pre-built response to return if token is invalid + variables (dict[str, Any]): Optional GraphQL variables + headers (dict[str, str]): Optional additional HTTP headers + max_retries (int): Maximum retry count (default: 2) + http_client: Optional async HTTP client for testing (httpx.AsyncClient) + + Returns: + GQLResult: Result with ok, shop, log, response, data, extensions, and http_logs + """ + return await admin_graphql_request_async( + query, + shop=shop, + access_token=access_token, + api_version=api_version, + invalid_token_response=invalid_token_response, + variables=variables, + headers=headers, + max_retries=max_retries, + app_config=self.config, + http_client=http_client, + ) + + +__all__ = [ + "__version__", + # Configuration types + "ShopifyApp", + "AppConfig", + # Core types + "RequestInput", + "Res", + "Log", + "LogWithReq", + "HttpLog", + # Token types + "IdTokenDetails", + "User", + "TokenExchangeAccessToken", + "ClientCredentialsAccessToken", + # Result types + "ResultForReq", + "ResultWithNonExchangeableIdToken", + "ResultWithExchangeableIdToken", + "ResultWithLoggedInCustomerId", + "TokenExchangeResult", + "ClientCredentialsExchangeResult", + "GQLResult", + # Type aliases + "AccessMode", +] diff --git a/shopify_app/_version.py b/shopify_app/_version.py new file mode 100644 index 0000000..bad0719 --- /dev/null +++ b/shopify_app/_version.py @@ -0,0 +1,5 @@ +"""Package version.""" + +from __future__ import annotations + +__version__ = "0.1.0" diff --git a/shopify_app/exchange/__init__.py b/shopify_app/exchange/__init__.py new file mode 100644 index 0000000..807b071 --- /dev/null +++ b/shopify_app/exchange/__init__.py @@ -0,0 +1,12 @@ +""" +Shopify App Exchange Module + +This module provides functions for exchanging tokens with Shopify. +""" + +from __future__ import annotations + +from .client_credentials import exchange_using_client_credentials +from .token_exchange import token_exchange + +__all__ = ["token_exchange", "exchange_using_client_credentials"] diff --git a/shopify_app/exchange/_response_builders.py b/shopify_app/exchange/_response_builders.py new file mode 100644 index 0000000..156bf14 --- /dev/null +++ b/shopify_app/exchange/_response_builders.py @@ -0,0 +1,97 @@ +"""Shared response building utilities for exchange operations. + +This module contains ONLY response builders that are used by multiple exchange modules. +File-specific response builders should be private functions in their respective files. + +Shared response builders: +- build_network_error_response: Used by all 3 exchange modules +""" + +from __future__ import annotations + +from typing import List, Literal, Optional, Union, overload + +from ..types import ( + ClientCredentialsExchangeResult, + HttpLog, + Log, + RequestInput, + Res, + TokenExchangeResult, +) + + +@overload +def build_network_error_response( + shop: Optional[str], + http_logs: List[HttpLog], + req_obj: RequestInput, + operation_type: str = ..., + result_type: Literal["token_exchange"] = ..., +) -> TokenExchangeResult: ... + + +@overload +def build_network_error_response( + shop: Optional[str], + http_logs: List[HttpLog], + req_obj: RequestInput, + operation_type: str = ..., + result_type: Literal["client_credentials"] = ..., +) -> ClientCredentialsExchangeResult: ... + + +def build_network_error_response( + shop: Optional[str], + http_logs: List[HttpLog], + req_obj: RequestInput, + operation_type: str = "token exchange", + result_type: Literal["token_exchange", "client_credentials"] = "token_exchange", +) -> Union[TokenExchangeResult, ClientCredentialsExchangeResult]: + """Build standardized network error response. + + Used by: client_credentials.py, refresh_token.py, token_exchange.py + + Args: + shop: Shop name + http_logs: List of HTTP logs to append to + req_obj: Request object for logging + operation_type: Type of operation (for error message) + result_type: "token_exchange" or "client_credentials" to determine return type + + Returns: + Dataclass: Standardized error response (TokenExchangeResult or ClientCredentialsExchangeResult) + """ + res_obj = Res(status=0, body="", headers={}) + log = Log( + code="network_error", + detail=f"Network error occurred during {operation_type}. Respond 500 Internal Server Error using the provided response.", + ) + http_logs.append( + HttpLog( + code=log.code, + detail=log.detail, + req=req_obj, + res=res_obj, + ) + ) + response = Res(status=500, body="", headers={}) + + if result_type == "client_credentials": + return ClientCredentialsExchangeResult( + ok=False, + shop=shop, + access_token=None, + log=log, + http_logs=http_logs, + response=response, + ) + else: + return TokenExchangeResult( + ok=False, + shop=shop, + access_token=None, + log=log, + http_logs=http_logs, + response=response, + ) diff --git a/shopify_app/exchange/_validation.py b/shopify_app/exchange/_validation.py new file mode 100644 index 0000000..1c5e2bb --- /dev/null +++ b/shopify_app/exchange/_validation.py @@ -0,0 +1,145 @@ +"""Shared validation utilities for exchange operations. + +This module contains ONLY validators that are used by multiple exchange modules. +File-specific validators should be private functions in their respective files. + +Shared validators: +- validate_shop: Used by client_credentials.py AND refresh_token.py +- validate_client_id: Used by token_exchange.py AND refresh_token.py +""" + +from __future__ import annotations + +from typing import Literal, Optional, Tuple, Union, overload + +from ..types import ClientCredentialsExchangeResult, Log, Res, TokenExchangeResult + + +@overload +def validate_client_id( + client_id: str, + shop: Optional[str] = ..., + result_type: Literal["token_exchange"] = ..., +) -> Tuple[bool, Optional[TokenExchangeResult]]: ... + + +@overload +def validate_client_id( + client_id: str, + shop: Optional[str] = ..., + result_type: Literal["client_credentials"] = ..., +) -> Tuple[bool, Optional[ClientCredentialsExchangeResult]]: ... + + +def validate_client_id( + client_id: str, + shop: Optional[str] = None, + result_type: Literal["token_exchange", "client_credentials"] = "token_exchange", +) -> Tuple[bool, Optional[Union[TokenExchangeResult, ClientCredentialsExchangeResult]]]: + """Validate client_id parameter. + + Used by: token_exchange.py, refresh_token.py + + Args: + client_id: Client ID to validate + shop: Optional shop name for error response + result_type: "token_exchange" or "client_credentials" to determine return type + + Returns: + tuple: (is_valid: bool, error_response: dataclass or None) + """ + if not client_id: + log = Log( + code="configuration_error", + detail="Expected clientId to be a non-empty string, but got ''", + ) + response = Res(status=500, body="", headers={}) + + if result_type == "client_credentials": + return ( + False, + ClientCredentialsExchangeResult( + ok=False, + shop=shop, + access_token=None, + log=log, + http_logs=[], + response=response, + ), + ) + else: + return ( + False, + TokenExchangeResult( + ok=False, + shop=shop, + access_token=None, + log=log, + http_logs=[], + response=response, + ), + ) + return (True, None) + + +@overload +def validate_shop( + shop: str, + result_type: Literal["token_exchange"] = ..., +) -> Tuple[bool, Optional[TokenExchangeResult]]: ... + + +@overload +def validate_shop( + shop: str, + result_type: Literal["client_credentials"] = ..., +) -> Tuple[bool, Optional[ClientCredentialsExchangeResult]]: ... + + +def validate_shop( + shop: str, + result_type: Literal["token_exchange", "client_credentials"] = "token_exchange", +) -> Tuple[bool, Optional[Union[TokenExchangeResult, ClientCredentialsExchangeResult]]]: + """Validate shop parameter. + + Used by: client_credentials.py, refresh_token.py + + Args: + shop: Shop string to validate + result_type: "token_exchange" or "client_credentials" to determine return type + + Returns: + 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 ''", + ) + response = Res(status=500, body="", headers={}) + + if result_type == "client_credentials": + return ( + False, + ClientCredentialsExchangeResult( + ok=False, + shop=None, + access_token=None, + log=log, + http_logs=[], + response=response, + ), + ) + else: + return ( + False, + TokenExchangeResult( + ok=False, + shop=None, + access_token=None, + log=log, + http_logs=[], + response=response, + ), + ) + return (True, None) diff --git a/shopify_app/exchange/client_credentials.py b/shopify_app/exchange/client_credentials.py new file mode 100644 index 0000000..4ef2f21 --- /dev/null +++ b/shopify_app/exchange/client_credentials.py @@ -0,0 +1,382 @@ +""" +Shopify Client Credentials Exchange + +This module provides functions to exchange client credentials for API access tokens. +""" + +from __future__ import annotations + +import json +import re +from datetime import datetime, timedelta, timezone +from typing import List, Optional, Tuple + +import httpx + +from ..types import ( + AppConfig, + ClientCredentialsAccessToken, + ClientCredentialsExchangeResult, + HttpLog, + Log, + RequestInput, + Res, +) +from ..utils import _get_user_agent +from ..utils.http_client import AsyncHTTPClientContext, HTTPClientContext +from ._response_builders import build_network_error_response +from ._validation import validate_shop + + +def exchange_using_client_credentials( + shop: str, + app_config: AppConfig, + http_client: Optional[httpx.Client] = None, +) -> ClientCredentialsExchangeResult: + """ + Exchange client credentials for an API access token. + + Args: + shop (str): The shop domain (e.g., "shop-name") + app_config (dict): App configuration containing: + - client_id: The Shopify app client ID + - client_secret: The app's client secret + http_client: Optional HTTP client for testing + + Returns: + ClientCredentialsExchangeResult: Result containing ok, shop, access_token, log, http_logs, and response + """ + client_id = app_config.get("client_id", "") + client_secret = app_config.get("client_secret", "") + + # Validate shop parameter + is_valid, error = validate_shop(shop, result_type="client_credentials") + if not is_valid: + if error is None: + 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 + + # Build request + token_endpoint, request_body, request_headers, req_obj = _build_request( + client_id, client_secret, shop + ) + + http_logs: List[HttpLog] = [] + + # Use HTTP client context manager + with HTTPClientContext(http_client) as client: + try: + response = client.post( + token_endpoint, + headers=request_headers, + json=request_body, + ) + + status_code = response.status_code + response_body = response.text + + # Build response object for logging + response_headers = ( + dict(response.headers) if hasattr(response, "headers") else {} + ) + res_obj = Res( + status=status_code, body=response_body, headers=response_headers + ) + + # Handle 200 success + if status_code == 200: + response_data = response.json() + return _handle_success_response( + response_data, shop, http_logs, req_obj, res_obj + ) + + # Handle error responses + try: + response_data = response.json() + except Exception: + response_data = {} + + return _handle_error_response( + response_data, shop, http_logs, req_obj, res_obj + ) + + except httpx.RequestError: + return build_network_error_response( + shop, + http_logs, + req_obj, + "client credentials exchange", + "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 + + Returns: + tuple: (is_valid: bool, error_response: ClientCredentialsExchangeResult or None) + """ + 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) + + +def _build_request(client_id, client_secret, shop): + """Build client credentials request components. + + Args: + client_id: OAuth client ID + client_secret: OAuth client secret + shop: Shop name (e.g., "shop-name") + + 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_headers = { + "Content-Type": "application/json", + "Accept": "application/json", + "User-Agent": _get_user_agent(), + } + + req_obj = { + "method": "POST", + "url": token_endpoint, + "headers": request_headers, + "body": json.dumps(request_body), + } + + return token_endpoint, request_body, request_headers, req_obj + + +def _handle_success_response( + response_data: dict, + shop: str, + http_logs: List[HttpLog], + req_obj: RequestInput, + res_obj: Res, +) -> ClientCredentialsExchangeResult: + """Handle successful client credentials exchange response.""" + access_token = response_data.get("access_token", "") + expires_in = response_data.get("expires_in") + scope = response_data.get("scope", "") + + # Calculate expiration timestamp + expires = ( + (datetime.now(timezone.utc) + timedelta(seconds=expires_in)).strftime( + "%Y-%m-%dT%H:%M:%SZ" + ) + if expires_in is not None + else None + ) + + access_token_obj = ClientCredentialsAccessToken( + access_mode="offline", + shop=shop, + token=access_token, + expires=expires, + scope=scope, + user=None, + ) + + http_logs.append( + HttpLog( + code="success", + detail="Client credentials exchange successful. Store the access token and proceed with business logic.", + req=req_obj, + res=res_obj, + ) + ) + + return ClientCredentialsExchangeResult( + ok=True, + shop=shop, + access_token=access_token_obj, + log=Log( + code="success", + detail="Client credentials exchange successful. Store the access token and proceed with business logic.", + ), + http_logs=http_logs, + response=Res(status=200, body="", headers={}), + ) + + +def _handle_error_response( + response_data: dict, + shop: str, + http_logs: List[HttpLog], + req_obj: RequestInput, + res_obj: Res, +) -> ClientCredentialsExchangeResult: + """Handle error responses from client credentials exchange.""" + error = response_data.get("error", "unknown_error") + + # Handle invalid_client + if error == "invalid_client": + http_logs.append( + HttpLog( + code="invalid_client", + detail="Client credentials are invalid or the app has been uninstalled. Respond 500 Internal Server Error using the provided response.", + req=req_obj, + res=res_obj, + ) + ) + return ClientCredentialsExchangeResult( + ok=False, + shop=shop, + access_token=None, + log=Log( + code="invalid_client", + detail="Client credentials are invalid or the app has been uninstalled. Respond 500 Internal Server Error using the provided response.", + ), + http_logs=http_logs, + response=Res(status=500, body="", headers={}), + ) + + # Fallback for other errors + http_logs.append( + HttpLog( + code="exchange_error", + detail=f"Client credentials exchange failed with error: {error}. Respond 500 Internal Server Error using the provided response.", + req=req_obj, + res=res_obj, + ) + ) + return ClientCredentialsExchangeResult( + ok=False, + shop=shop, + access_token=None, + log=Log( + code="exchange_error", + detail=f"Client credentials exchange failed with error: {error}. Respond 500 Internal Server Error using the provided response.", + ), + http_logs=http_logs, + response=Res(status=500, body="", headers={}), + ) + + +async def exchange_using_client_credentials_async( + shop: str, + app_config: AppConfig, + http_client: Optional[httpx.AsyncClient] = None, +) -> ClientCredentialsExchangeResult: + """ + Async version of exchange_using_client_credentials. + + Exchange client credentials for an API access token. + Use this when you need non-blocking client credentials exchange in async code. + + Args: + shop (str): The shop domain (e.g., "shop-name") + app_config (dict): App configuration containing: + - client_id: The Shopify app client ID + - client_secret: The app's client secret + http_client: Optional async HTTP client for testing (httpx.AsyncClient) + + Returns: + ClientCredentialsExchangeResult: Result containing ok, shop, access_token, log, http_logs, and response + """ + if app_config is None: + app_config = {} + + client_id = app_config.get("client_id", "") + client_secret = app_config.get("client_secret", "") + + # Validate shop parameter + is_valid, error = validate_shop(shop, result_type="client_credentials") + if not is_valid: + if error is None: + 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 + + # Build request + token_endpoint, request_body, request_headers, req_obj = _build_request( + client_id, client_secret, shop + ) + + http_logs: List[HttpLog] = [] + + # Use async HTTP client context manager + async with AsyncHTTPClientContext(http_client) as client: + try: + response = await client.post( + token_endpoint, + headers=request_headers, + json=request_body, + ) + + status_code = response.status_code + response_body = response.text + + # Build response object for logging + response_headers = ( + dict(response.headers) if hasattr(response, "headers") else {} + ) + res_obj = Res( + status=status_code, body=response_body, headers=response_headers + ) + + # Handle 200 success + if status_code == 200: + response_data = response.json() + return _handle_success_response( + response_data, shop, http_logs, req_obj, res_obj + ) + + # Handle error responses + try: + response_data = response.json() + except Exception: + response_data = {} + + return _handle_error_response( + response_data, shop, http_logs, req_obj, res_obj + ) + + except httpx.RequestError: + return build_network_error_response( + shop, + http_logs, + req_obj, + "client credentials exchange", + "client_credentials", + ) diff --git a/shopify_app/exchange/refresh_token.py b/shopify_app/exchange/refresh_token.py new file mode 100644 index 0000000..d9b3451 --- /dev/null +++ b/shopify_app/exchange/refresh_token.py @@ -0,0 +1,612 @@ +""" +Shopify Refresh Token + +This module provides functions to refresh expired access tokens. +""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from typing import List, Literal, Optional, Tuple, Union, cast + +import httpx + +from ..types import ( + AppConfig, + HttpLog, + Log, + RequestInput, + Res, + TokenExchangeAccessToken, + TokenExchangeResult, + User, +) +from ..utils import _get_attr, _get_user_agent +from ..utils.http_client import AsyncHTTPClientContext, HTTPClientContext +from ._response_builders import build_network_error_response +from ._validation import validate_client_id, validate_shop + + +def refresh_access_token( + access_token: Union[TokenExchangeAccessToken, dict], + app_config: AppConfig, + http_client: Optional[httpx.Client] = None, +) -> TokenExchangeResult: + """ + Refresh an expired access token using a refresh token. + + Args: + access_token (TokenExchangeAccessToken | dict): TokenExchangeAccessToken object (requires: shop, refresh_token, expires, refresh_token_expires, access_mode) + app_config (dict): App configuration containing: + - client_id: The app's client ID + - client_secret: The app's client secret + http_client: Optional HTTP client for testing + + Returns: + TokenExchangeResult: Result containing ok, shop, access_token, log, http_logs, and response + """ + shop = _get_attr(access_token, "shop", "") + refresh_token = _get_attr(access_token, "refresh_token", "") + expires = _get_attr(access_token, "expires", "") + refresh_token_expires = _get_attr(access_token, "refresh_token_expires", "") + original_access_mode = _get_attr(access_token, "access_mode", "offline") + original_user = _get_attr(access_token, "user", None) + + client_id = app_config.get("client_id", "") + client_secret = app_config.get("client_secret", "") + + # Validate token expiration (returns early if token still valid or refresh token expired) + should_continue, response = _validate_token_expiry( + expires, refresh_token_expires, shop + ) + if not should_continue: + if response is None: + raise RuntimeError( + "_validate_token_expiry returned should_continue=False but no response" + ) + return response + + # Validate required parameters + is_valid, error = validate_shop(shop, result_type="token_exchange") + if not is_valid: + if error is None: + raise RuntimeError("validate_shop returned invalid but no error") + return error + + is_valid, error = validate_client_id(client_id, shop, result_type="token_exchange") + if not is_valid: + if error is None: + raise RuntimeError("validate_client_id returned invalid but no error") + return error + + is_valid, error = _validate_refresh_token(refresh_token, shop) + if not is_valid: + if error is None: + raise RuntimeError("_validate_refresh_token returned invalid but no error") + return error + + # Build request + token_endpoint, request_body, request_headers = _build_request( + client_id, client_secret, refresh_token, shop + ) + + # Make the request with retry logic for 5xx responses + max_retries = 2 + attempt = 0 + http_logs: List[HttpLog] = [] + + # Build the request object for logging + req_log: RequestInput = { + "url": token_endpoint, + "method": "POST", + "headers": request_headers, + "body": "", # Don't log sensitive body + } + + with HTTPClientContext(http_client) as client: + while attempt <= max_retries: + try: + http_response = client.post( + token_endpoint, + headers=request_headers, + json=request_body, + ) + + status_code = http_response.status_code + response_headers = dict(http_response.headers) + + # Build response object for logging + res_log = Res(status=status_code, body="", headers=response_headers) + + # Handle 200 success + if status_code == 200: + response_data = http_response.json() + # Cast access_mode to Literal type - we've validated it's a valid value + access_mode_literal = cast( + Literal["online", "offline"], original_access_mode + ) + return _handle_success_response( + response_data, + shop, + access_mode_literal, + original_user, + http_logs, + req_log, + res_log, + ) + + # Handle 5xx server errors with retry + if 500 <= status_code <= 504: + if attempt < max_retries: + http_logs.append( + HttpLog( + code="server_error_retry", + detail=f"Server error {status_code}, retrying (attempt {attempt + 1} of {max_retries}).", + req=req_log, + res=res_log, + ) + ) + attempt += 1 + continue + + http_logs.append( + HttpLog( + code="server_error", + detail="Max retries reached after server errors. Respond 500 Internal Server Error using the provided response.", + req=req_log, + res=res_log, + ) + ) + return TokenExchangeResult( + ok=False, + shop=shop, + access_token=None, + log=Log( + code="server_error", + detail="Max retries reached after server errors. Respond 500 Internal Server Error using the provided response.", + ), + http_logs=http_logs, + response=Res(status=500, body="", headers={}), + ) + + # Handle error responses + try: + response_data = http_response.json() + except Exception: + response_data = {} + + return _handle_error_response( + response_data, shop, http_logs, req_log, res_log + ) + + except httpx.RequestError: + return build_network_error_response( + shop, http_logs, req_log, "token refresh", "token_exchange" + ) + + # Should never reach here due to while loop logic + raise AssertionError("unreachable: while loop should always return") + + +def _validate_refresh_token( + refresh_token: str, + shop: Optional[str] = None, +) -> Tuple[bool, Optional[TokenExchangeResult]]: + """Validate refresh_token parameter. + + Args: + refresh_token: Refresh token to validate + shop: Optional shop name for error response + + Returns: + tuple: (is_valid: bool, error_response: TokenExchangeResult or None) + """ + if not refresh_token: + return ( + False, + TokenExchangeResult( + ok=False, + shop=None, + access_token=None, + log=Log( + code="configuration_error", + detail="Expected refresh token to be a non-empty string, but got ''", + ), + http_logs=[], + response=Res(status=500, body="", headers={}), + ), + ) + return (True, None) + + +def _validate_token_expiry( + expires: str, + refresh_token_expires: str, + shop: str, +) -> Tuple[bool, Optional[TokenExchangeResult]]: + """Validate token expiration and determine if refresh is needed. + + Args: + expires: Access token expiration timestamp (ISO format string) + refresh_token_expires: Refresh token expiration timestamp (ISO format string) + shop: Shop name for error response + + Returns: + tuple: (should_continue: bool, response: TokenExchangeResult or None) + """ + # Check refresh token expiration + if refresh_token_expires: + try: + refresh_expiry = datetime.fromisoformat( + refresh_token_expires.replace("Z", "+00:00") + ) + if refresh_expiry <= datetime.now(timezone.utc): + return ( + False, + TokenExchangeResult( + ok=False, + shop=shop, + access_token=None, + log=Log( + code="refresh_token_expired", + detail="Refresh token has expired. User must re-authenticate. Respond 401 Unauthorized using the provided response.", + ), + http_logs=[], + response=Res(status=401, body="", headers={}), + ), + ) + except ValueError: + pass # Invalid date format, continue with refresh + + # Check if access token is still valid (with 60-second buffer) + if expires: + try: + expiry = datetime.fromisoformat(expires.replace("Z", "+00:00")) + if expiry > (datetime.now(timezone.utc) + timedelta(seconds=60)): + return ( + False, + TokenExchangeResult( + ok=True, + shop=shop, + access_token=None, + log=Log( + code="token_still_valid", + detail="Access token is still valid. No refresh needed. Proceed with business logic.", + ), + http_logs=[], + response=Res(status=200, body="", headers={}), + ), + ) + except ValueError: + pass # Invalid date format, continue with refresh + + return (True, None) + + +def _build_request(client_id, client_secret, refresh_token, shop): + """Build refresh token request components. + + Args: + client_id: OAuth client ID + client_secret: OAuth client secret + refresh_token: Refresh token to use + shop: Shop name (e.g., "shop-name") + + Returns: + tuple: (endpoint, request_body, request_headers) + """ + shop_url = f"https://{shop}.myshopify.com" + + request_body = { + "client_id": client_id, + "client_secret": client_secret, + "grant_type": "refresh_token", + "refresh_token": refresh_token, + } + + token_endpoint = f"{shop_url}/admin/oauth/access_token" + + request_headers = { + "Content-Type": "application/json", + "Accept": "application/json", + "User-Agent": _get_user_agent(), + } + + return token_endpoint, request_body, request_headers + + +def _handle_success_response( + response_data: dict, + shop: str, + original_access_mode: Literal["online", "offline"], + original_user: Optional[User], + http_logs: List[HttpLog], + req_log: RequestInput, + res_log: Res, +) -> TokenExchangeResult: + """Handle successful token refresh response.""" + access_token = response_data.get("access_token", "") + expires_in = response_data.get("expires_in", 0) + scope = response_data.get("scope", "") + refresh_token = response_data.get("refresh_token", "") + refresh_token_expires_in = response_data.get("refresh_token_expires_in", 0) + + # Calculate expiration timestamps + expires = (datetime.now(timezone.utc) + timedelta(seconds=expires_in)).strftime( + "%Y-%m-%dT%H:%M:%SZ" + ) + refresh_token_expires = ( + datetime.now(timezone.utc) + timedelta(seconds=refresh_token_expires_in) + ).strftime("%Y-%m-%dT%H:%M:%SZ") + + access_token_obj = TokenExchangeAccessToken( + access_mode=original_access_mode, + shop=shop, + token=access_token, + expires=expires, + scope=scope, + refresh_token=refresh_token, + refresh_token_expires=refresh_token_expires, + user=original_user, + ) + + http_logs.append( + HttpLog( + code="success", + detail="Token refresh successful. Store the new access and refresh token then proceed with business logic.", + req=req_log, + res=res_log, + ) + ) + + return TokenExchangeResult( + ok=True, + shop=shop, + access_token=access_token_obj, + log=Log( + code="success", + detail="Token refresh successful. Store the new access and refresh token then proceed with business logic.", + ), + http_logs=http_logs, + response=Res(status=200, body="", headers={}), + ) + + +def _handle_error_response( + response_data: dict, + shop: str, + http_logs: List[HttpLog], + req_log: RequestInput, + res_log: Res, +) -> TokenExchangeResult: + """Handle error responses from token refresh.""" + error = response_data.get("error", "unknown_error") + + # Handle invalid_grant + if error == "invalid_grant": + http_logs.append( + HttpLog( + code="invalid_grant", + detail="Refresh token is invalid, expired, or has been revoked. User must re-authenticate. Respond 401 Unauthorized using the provided response.", + req=req_log, + res=res_log, + ) + ) + return TokenExchangeResult( + ok=False, + shop=shop, + access_token=None, + log=Log( + code="invalid_grant", + detail="Refresh token is invalid, expired, or has been revoked. User must re-authenticate. Respond 401 Unauthorized using the provided response.", + ), + http_logs=http_logs, + response=Res(status=401, body="", headers={}), + ) + + # Handle invalid_client + if error == "invalid_client": + http_logs.append( + HttpLog( + code="invalid_client", + detail="Client credentials are invalid or app has been uninstalled. Respond 500 Internal Server Error using the provided response.", + req=req_log, + res=res_log, + ) + ) + return TokenExchangeResult( + ok=False, + shop=shop, + access_token=None, + log=Log( + code="invalid_client", + detail="Client credentials are invalid or app has been uninstalled. Respond 500 Internal Server Error using the provided response.", + ), + http_logs=http_logs, + response=Res(status=500, body="", headers={}), + ) + + # Fallback for other errors + http_logs.append( + HttpLog( + code="refresh_error", + detail=f"Token refresh failed with error: {error}. Respond 500 Internal Server Error using the provided response.", + req=req_log, + res=res_log, + ) + ) + return TokenExchangeResult( + ok=False, + shop=shop, + access_token=None, + log=Log( + code="refresh_error", + detail=f"Token refresh failed with error: {error}. Respond 500 Internal Server Error using the provided response.", + ), + http_logs=http_logs, + response=Res(status=500, body="", headers={}), + ) + + +async def refresh_access_token_async( + access_token: Union[TokenExchangeAccessToken, dict], + app_config: AppConfig, + http_client: Optional[httpx.AsyncClient] = None, +) -> TokenExchangeResult: + """ + Async version of refresh_access_token. + + Refresh an expired access token using a refresh token. + Use this when you need non-blocking token refresh in async code. + + Args: + access_token (TokenExchangeAccessToken | dict): TokenExchangeAccessToken object (requires: shop, refresh_token, expires, refresh_token_expires, access_mode) + app_config (dict): App configuration containing: + - client_id: The app's client ID + - client_secret: The app's client secret + http_client: Optional async HTTP client for testing (httpx.AsyncClient) + + Returns: + TokenExchangeResult: Result containing ok, shop, access_token, log, http_logs, and response + """ + shop = _get_attr(access_token, "shop", "") + refresh_token = _get_attr(access_token, "refresh_token", "") + expires = _get_attr(access_token, "expires", "") + refresh_token_expires = _get_attr(access_token, "refresh_token_expires", "") + original_access_mode = _get_attr(access_token, "access_mode", "offline") + original_user = _get_attr(access_token, "user", None) + + client_id = app_config.get("client_id", "") + client_secret = app_config.get("client_secret", "") + + # Validate token expiration (returns early if token still valid or refresh token expired) + should_continue, response = _validate_token_expiry( + expires, refresh_token_expires, shop + ) + if not should_continue: + if response is None: + raise RuntimeError( + "_validate_token_expiry returned should_continue=False but no response" + ) + return response + + # Validate required parameters + is_valid, error = validate_shop(shop, result_type="token_exchange") + if not is_valid: + if error is None: + raise RuntimeError("validate_shop returned invalid but no error") + return error + + is_valid, error = validate_client_id(client_id, shop, result_type="token_exchange") + if not is_valid: + if error is None: + raise RuntimeError("validate_client_id returned invalid but no error") + return error + + is_valid, error = _validate_refresh_token(refresh_token, shop) + if not is_valid: + if error is None: + raise RuntimeError("_validate_refresh_token returned invalid but no error") + return error + + # Build request + token_endpoint, request_body, request_headers = _build_request( + client_id, client_secret, refresh_token, shop + ) + + # Make the request with retry logic for 5xx responses + max_retries = 2 + attempt = 0 + http_logs: List[HttpLog] = [] + + # Build the request object for logging + req_log: RequestInput = { + "url": token_endpoint, + "method": "POST", + "headers": request_headers, + "body": "", # Don't log sensitive body + } + + async with AsyncHTTPClientContext(http_client) as client: + while attempt <= max_retries: + try: + http_response = await client.post( + token_endpoint, + headers=request_headers, + json=request_body, + ) + + status_code = http_response.status_code + response_headers = dict(http_response.headers) + + # Build response object for logging + res_log = Res(status=status_code, body="", headers=response_headers) + + # Handle 200 success + if status_code == 200: + response_data = http_response.json() + # Cast access_mode to Literal type - we've validated it's a valid value + access_mode_literal = cast( + Literal["online", "offline"], original_access_mode + ) + return _handle_success_response( + response_data, + shop, + access_mode_literal, + original_user, + http_logs, + req_log, + res_log, + ) + + # Handle 5xx server errors with retry + if 500 <= status_code <= 504: + if attempt < max_retries: + http_logs.append( + HttpLog( + code="server_error_retry", + detail=f"Server error {status_code}, retrying (attempt {attempt + 1} of {max_retries}).", + req=req_log, + res=res_log, + ) + ) + attempt += 1 + continue + + http_logs.append( + HttpLog( + code="server_error", + detail="Max retries reached after server errors. Respond 500 Internal Server Error using the provided response.", + req=req_log, + res=res_log, + ) + ) + return TokenExchangeResult( + ok=False, + shop=shop, + access_token=None, + log=Log( + code="server_error", + detail="Max retries reached after server errors. Respond 500 Internal Server Error using the provided response.", + ), + http_logs=http_logs, + response=Res(status=500, body="", headers={}), + ) + + # Handle error responses + try: + response_data = http_response.json() + except Exception: + response_data = {} + + return _handle_error_response( + response_data, shop, http_logs, req_log, res_log + ) + + except httpx.RequestError: + return build_network_error_response( + shop, http_logs, req_log, "token refresh", "token_exchange" + ) + + # Should never reach here due to while loop logic + raise AssertionError("unreachable: while loop should always return") diff --git a/shopify_app/exchange/token_exchange.py b/shopify_app/exchange/token_exchange.py new file mode 100644 index 0000000..bdd481f --- /dev/null +++ b/shopify_app/exchange/token_exchange.py @@ -0,0 +1,808 @@ +""" +Shopify Token Exchange + +This module provides functions to exchange tokens for API access tokens. +""" + +from __future__ import annotations + +import asyncio +import dataclasses +import time +from datetime import datetime, timedelta, timezone +from typing import Any, Dict, List, Literal, Optional, Tuple, Union, cast + +import httpx + +from ..types import ( + AppConfig, + HttpLog, + IdTokenDetails, + Log, + RequestInput, + Res, + TokenExchangeAccessToken, + TokenExchangeResult, + User, +) +from ..utils import _get_attr, _get_user_agent, _to_res +from ..utils.http_client import AsyncHTTPClientContext, HTTPClientContext +from ._response_builders import build_network_error_response +from ._validation import validate_client_id + + +def _is_valid_id_token(obj: Any) -> bool: + """Check if obj is a valid IdTokenDetails (dataclass or dict).""" + if obj is None: + return False + if dataclasses.is_dataclass(obj) and not isinstance(obj, type): + return True + if isinstance(obj, dict): + return True + return False + + +def token_exchange( + access_mode: str, + app_config: AppConfig, + id_token: Optional[Union[IdTokenDetails, dict]] = None, + invalid_token_response: Optional[Union[Res, dict]] = None, + http_client: Optional[httpx.Client] = None, +) -> TokenExchangeResult: + """ + Exchange a pre-validated ID token for an API access token using OAuth 2.0 Token Exchange. + + Args: + access_mode (str): Either "online" or "offline" + app_config (AppConfig): App configuration containing: + - client_id: The Shopify app client ID + - client_secret: The app's client secret + id_token (IdTokenDetails | dict): IdTokenDetails object or dict with exchangeable, token, claims + invalid_token_response (Res | dict): Pre-built response to return if token is invalid + http_client: Optional HTTP client for testing + + Returns: + TokenExchangeResult: Result containing ok, shop, access_token, log, http_logs, and response + """ + client_id = app_config.get("client_id", "") + client_secret = app_config.get("client_secret", "") + + # Validate required parameters + is_valid, error = validate_client_id(client_id, result_type="token_exchange") + if not is_valid: + if error is None: + raise RuntimeError("validate_client_id returned invalid but no error") + return error + + is_valid, error = _validate_access_mode(access_mode) + if not is_valid: + if error is None: + raise RuntimeError("_validate_access_mode returned invalid but no error") + return error + + is_valid, error = _validate_id_token(id_token) + if not is_valid: + if error is None: + raise RuntimeError("_validate_id_token returned invalid but no error") + return error + + # Extract shop and JWT from validated id_token + # Support both dict and dataclass + claims: Dict[str, Any] = _get_attr(id_token, "claims", {}) + 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", "") + ) + + # Build request + token_endpoint, request_body, request_headers, req_obj = _build_request( + client_id, client_secret, jwt_string, access_mode, shop_url + ) + + # Make the request with retry logic for 429 responses + max_retries = 2 + attempt = 0 + http_logs: List[HttpLog] = [] + + with HTTPClientContext(http_client) as client: + while attempt <= max_retries: + try: + response = client.post( + token_endpoint, + headers=request_headers, + json=request_body, + ) + + status_code = response.status_code + response_body = response.text + + # Build response object for logging + response_headers = ( + dict(response.headers) if hasattr(response, "headers") else {} + ) + res_obj = Res( + status=status_code, body=response_body, headers=response_headers + ) + + # Handle 200 success + if status_code == 200: + response_data = response.json() + http_logs.append( + HttpLog( + code="success", + detail="Token exchange successful. Store the access token and proceed with business logic.", + req=req_obj, + res=res_obj, + ) + ) + # Cast access_mode to Literal type + access_mode_literal = cast( + Literal["online", "offline"], access_mode + ) + return _handle_success_response( + response_data, shop_name, access_mode_literal, http_logs + ) + + # Handle 429 rate limit with retry helper + should_retry, should_return, return_value = _handle_retry_after_sync( + status_code, + attempt, + max_retries, + response_headers, + http_logs, + req_obj, + res_obj, + shop_name, + ) + if should_return: + if return_value is None: + raise RuntimeError( + "_handle_retry_after_sync returned should_return=True but no return_value" + ) + return return_value + if should_retry: + attempt += 1 + continue + + # Handle error responses + try: + response_data = response.json() + except Exception: + response_data = {} + + return _handle_error_response( + response_data, + shop_name, + invalid_token_response, + http_logs, + req_obj, + res_obj, + ) + + except httpx.RequestError: + return build_network_error_response( + shop_name, http_logs, req_obj, "token exchange", "token_exchange" + ) + + # Should never reach here due to while loop logic + raise AssertionError("unreachable: while loop should always return") + + +def _validate_access_mode( + access_mode: str, + shop: Optional[str] = None, +) -> Tuple[bool, Optional[TokenExchangeResult]]: + """Validate access_mode parameter.""" + if not access_mode: + return ( + False, + TokenExchangeResult( + ok=False, + shop=shop, + access_token=None, + log=Log( + code="configuration_error", + detail="Expected access mode to be 'online' or 'offline', but got ''", + ), + http_logs=[], + response=Res(status=500, body="", headers={}), + ), + ) + + if access_mode not in ["online", "offline"]: + return ( + False, + TokenExchangeResult( + ok=False, + shop=shop, + access_token=None, + log=Log( + code="configuration_error", + detail=f"Expected access mode to be 'online' or 'offline', but got '{access_mode}'", + ), + http_logs=[], + response=Res(status=500, body="", headers={}), + ), + ) + + return (True, None) + + +def _validate_id_token( + id_token: Optional[Union[IdTokenDetails, dict]], + shop: Optional[str] = None, +) -> Tuple[bool, Optional[TokenExchangeResult]]: + """Validate id_token structure and contents.""" + if not _is_valid_id_token(id_token): + return ( + False, + TokenExchangeResult( + ok=False, + shop=shop, + access_token=None, + log=Log( + code="configuration_error", + detail="Expected idToken to be an object with exchangeable, token, and claims properties", + ), + http_logs=[], + response=Res(status=500, body="", headers={}), + ), + ) + + exchangeable = _get_attr(id_token, "exchangeable", False) + if not exchangeable: + return ( + False, + TokenExchangeResult( + ok=False, + shop=shop, + access_token=None, + log=Log( + code="configuration_error", + detail="ID token is not exchangeable. Only App Home, Admin UI extension & POS UI Extension Id tokens can be exchanged.", + ), + http_logs=[], + response=Res(status=500, body="", headers={}), + ), + ) + + jwt_string = _get_attr(id_token, "token", None) + if not jwt_string or not isinstance(jwt_string, str): + return ( + False, + TokenExchangeResult( + ok=False, + shop=shop, + access_token=None, + log=Log( + code="configuration_error", + detail="Expected idToken.token to be a non-empty string", + ), + http_logs=[], + response=Res(status=500, body="", headers={}), + ), + ) + + claims: Dict[str, Any] = _get_attr(id_token, "claims", {}) + shop_from_token = claims.get("dest", "") + + if not shop_from_token or not isinstance(shop_from_token, str): + return ( + False, + TokenExchangeResult( + ok=False, + shop=shop, + access_token=None, + log=Log( + code="configuration_error", + detail="Expected idToken.claims.dest to be a non-empty string", + ), + http_logs=[], + response=Res(status=500, body="", headers={}), + ), + ) + + if ".myshopify.com" not in shop_from_token: + return ( + False, + TokenExchangeResult( + ok=False, + shop=shop, + access_token=None, + log=Log( + code="configuration_error", + detail="Expected idToken.claims.dest to be a valid shop URL (e.g., 'https://shop.myshopify.com' or 'shop.myshopify.com')", + ), + http_logs=[], + response=Res(status=500, body="", headers={}), + ), + ) + + return (True, None) + + +def _build_request(client_id, client_secret, jwt_string, access_mode, shop_url): + """Build token exchange request components.""" + import json + + requested_token_type = ( + f"urn:shopify:params:oauth:token-type:{access_mode}-access-token" + ) + + request_body = { + "client_id": client_id, + "client_secret": client_secret, + "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", + "subject_token": jwt_string, + "subject_token_type": "urn:ietf:params:oauth:token-type:id_token", + "requested_token_type": requested_token_type, + "expiring": 1, + } + + token_endpoint = f"{shop_url}/admin/oauth/access_token" + + request_headers = { + "Content-Type": "application/json", + "Accept": "application/json", + "User-Agent": _get_user_agent(), + } + + req_obj = { + "method": "POST", + "url": token_endpoint, + "headers": request_headers, + "body": json.dumps(request_body), + } + + return token_endpoint, request_body, request_headers, req_obj + + +def _handle_retry_after_sync( + status_code: int, + attempt: int, + max_retries: int, + response_headers: dict, + http_logs: List[HttpLog], + req_obj: RequestInput, + res_obj: Res, + shop_name: str, +) -> Tuple[bool, bool, Optional[TokenExchangeResult]]: + """Handle 429 rate limit retry logic for sync requests.""" + if status_code == 429 and attempt < max_retries: + retry_after = int(response_headers.get("Retry-After", 1)) + http_logs.append( + HttpLog( + code="rate_limited_retry", + detail=f"Rate limited. Retrying after {retry_after} seconds.", + req=req_obj, + res=res_obj, + ) + ) + time.sleep(retry_after) + return (True, False, None) # should retry + + if status_code == 429 and attempt == max_retries: + http_logs.append( + HttpLog( + code="rate_limit_exceeded", + detail="Max retries reached after rate limiting. Respond 429 Too Many Requests using the provided response.", + req=req_obj, + res=res_obj, + ) + ) + return ( + False, + True, + TokenExchangeResult( + ok=False, + shop=shop_name, + access_token=None, + log=Log( + code="rate_limit_exceeded", + detail="Max retries reached after rate limiting. Respond 429 Too Many Requests using the provided response.", + ), + http_logs=http_logs, + response=Res( + status=429, + body='{"error":"Too many requests"}', + headers={"Content-Type": "application/json"}, + ), + ), + ) + + return (False, False, None) + + +async def _handle_retry_after_async( + status_code: int, + attempt: int, + max_retries: int, + response_headers: dict, + http_logs: List[HttpLog], + req_obj: RequestInput, + res_obj: Res, + shop_name: str, +) -> Tuple[bool, bool, Optional[TokenExchangeResult]]: + """Handle 429 rate limit retry logic for async requests.""" + if status_code == 429 and attempt < max_retries: + retry_after = int(response_headers.get("Retry-After", 1)) + http_logs.append( + HttpLog( + code="rate_limited_retry", + detail=f"Rate limited. Retrying after {retry_after} seconds.", + req=req_obj, + res=res_obj, + ) + ) + await asyncio.sleep(retry_after) + return (True, False, None) + + if status_code == 429 and attempt == max_retries: + http_logs.append( + HttpLog( + code="rate_limit_exceeded", + detail="Max retries reached after rate limiting. Respond 429 Too Many Requests using the provided response.", + req=req_obj, + res=res_obj, + ) + ) + return ( + False, + True, + TokenExchangeResult( + ok=False, + shop=shop_name, + access_token=None, + log=Log( + code="rate_limit_exceeded", + detail="Max retries reached after rate limiting. Respond 429 Too Many Requests using the provided response.", + ), + http_logs=http_logs, + response=Res( + status=429, + body='{"error":"Too many requests"}', + headers={"Content-Type": "application/json"}, + ), + ), + ) + + return (False, False, None) + + +def _handle_success_response( + response_data: dict, + shop: str, + access_mode: Literal["online", "offline"], + http_logs: List[HttpLog], +) -> TokenExchangeResult: + """Handle successful token exchange response.""" + access_token = response_data.get("access_token", "") + expires_in = response_data.get("expires_in") + scope = response_data.get("scope", "") + refresh_token = response_data.get("refresh_token", "") + refresh_token_expires_in = response_data.get("refresh_token_expires_in") + associated_user = response_data.get("associated_user") + associated_user_scope = response_data.get("associated_user_scope", "") + + # Calculate expiration timestamps + # If expires_in is None, the token doesn't expire + expires = ( + (datetime.now(timezone.utc) + timedelta(seconds=expires_in)).strftime( + "%Y-%m-%dT%H:%M:%SZ" + ) + if expires_in is not None + else None + ) + refresh_token_expires = ( + ( + datetime.now(timezone.utc) + timedelta(seconds=refresh_token_expires_in) + ).strftime("%Y-%m-%dT%H:%M:%SZ") + if refresh_token_expires_in is not None + else None + ) + + # Build user object for online tokens + user: Optional[User] = None + if access_mode == "online" and associated_user: + user = User( + id=associated_user.get("id", 0), + first_name=associated_user.get("first_name", ""), + last_name=associated_user.get("last_name", ""), + scope=associated_user_scope, + email=associated_user.get("email", ""), + account_owner=associated_user.get("account_owner", False), + locale=associated_user.get("locale", ""), + collaborator=associated_user.get("collaborator", False), + email_verified=associated_user.get("email_verified", False), + ) + + access_token_obj = TokenExchangeAccessToken( + access_mode=access_mode, + shop=shop, + token=access_token, + expires=expires, + scope=scope, + refresh_token=refresh_token, + refresh_token_expires=refresh_token_expires, + user=user, + ) + + return TokenExchangeResult( + ok=True, + shop=shop, + access_token=access_token_obj, + log=Log( + code="success", + detail="Token exchange successful. Store the access token and proceed with business logic.", + ), + http_logs=http_logs, + response=Res( + status=200, + body="", + headers={}, + ), + ) + + +def _handle_error_response( + response_data: dict, + shop: str, + invalid_token_response: Optional[Union[Res, dict]], + http_logs: List[HttpLog], + req_obj: RequestInput, + res_obj: Res, +) -> TokenExchangeResult: + """Handle error responses from token exchange.""" + error = response_data.get("error", "unknown_error") + + # Handle invalid_subject_token + if error == "invalid_subject_token": + log = Log( + code="invalid_subject_token", + detail="The ID token is invalid. Respond 401 Unauthorized using the provided response.", + ) + http_logs.append( + HttpLog( + code=log.code, + detail=log.detail, + req=req_obj, + res=res_obj, + ) + ) + + # Convert invalid_token_response to Res (handles both dict and dataclass) + response = _to_res(invalid_token_response) or Res( + status=401, body="", headers={} + ) + + return TokenExchangeResult( + ok=False, + shop=shop, + access_token=None, + log=log, + http_logs=http_logs, + response=response, + ) + + # Handle invalid_client + if error == "invalid_client": + log = Log( + code="invalid_client", + detail="Client credentials are invalid or the app has been uninstalled. Respond 500 Internal Server Error using the provided response.", + ) + http_logs.append( + HttpLog( + code=log.code, + detail=log.detail, + req=req_obj, + res=res_obj, + ) + ) + return TokenExchangeResult( + ok=False, + shop=shop, + access_token=None, + log=log, + http_logs=http_logs, + response=Res( + status=500, + body="", + headers={}, + ), + ) + + # Fallback for other errors + log = Log( + code="exchange_error", + detail=f"Token exchange failed with error: {error}. Respond 500 Internal Server Error using the provided response.", + ) + http_logs.append( + HttpLog( + code=log.code, + detail=log.detail, + req=req_obj, + res=res_obj, + ) + ) + return TokenExchangeResult( + ok=False, + shop=shop, + access_token=None, + log=log, + http_logs=http_logs, + response=Res( + status=500, + body="", + headers={}, + ), + ) + + +async def token_exchange_async( + access_mode: str, + app_config: AppConfig, + id_token: Optional[Union[IdTokenDetails, dict]] = None, + invalid_token_response: Optional[Union[Res, dict]] = None, + http_client: Optional[httpx.AsyncClient] = None, +) -> TokenExchangeResult: + """ + Async version of token_exchange. + + Exchange a pre-validated ID token for an API access token using OAuth 2.0 Token Exchange. + Use this when you need non-blocking token exchange in async code. + + Args: + access_mode (str): Either "online" or "offline" + app_config (AppConfig): App configuration containing: + - client_id: The Shopify app client ID + - client_secret: The app's client secret + id_token (IdTokenDetails | dict): IdTokenDetails object with: + - exchangeable: bool (must be True) + - token: str (the JWT string) + - claims: dict (decoded JWT claims) + invalid_token_response (Res | dict): Pre-built response to return if token is invalid + http_client: Optional async HTTP client for testing (httpx.AsyncClient) + + Returns: + TokenExchangeResult: Result containing ok, shop, access_token, log, http_logs, and response + """ + client_id = app_config.get("client_id", "") + client_secret = app_config.get("client_secret", "") + + # Validate required parameters + is_valid, error = validate_client_id(client_id, result_type="token_exchange") + if not is_valid: + if error is None: + raise RuntimeError("validate_client_id returned invalid but no error") + return error + + is_valid, error = _validate_access_mode(access_mode) + if not is_valid: + if error is None: + raise RuntimeError("_validate_access_mode returned invalid but no error") + return error + + is_valid, error = _validate_id_token(id_token) + if not is_valid: + if error is None: + raise RuntimeError("_validate_id_token returned invalid but no error") + return error + + # Extract shop and JWT from validated id_token + # Support both dict and dataclass + claims: Dict[str, Any] = _get_attr(id_token, "claims", {}) + 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", "") + ) + + # Build request + token_endpoint, request_body, request_headers, req_obj = _build_request( + client_id, client_secret, jwt_string, access_mode, shop_url + ) + + # Make the request with retry logic for 429 responses + max_retries = 2 + attempt = 0 + http_logs: List[HttpLog] = [] + + async with AsyncHTTPClientContext(http_client) as client: + while attempt <= max_retries: + try: + response = await client.post( + token_endpoint, + headers=request_headers, + json=request_body, + ) + + status_code = response.status_code + response_body = response.text + + # Build response object for logging + response_headers = ( + dict(response.headers) if hasattr(response, "headers") else {} + ) + res_obj = Res( + status=status_code, body=response_body, headers=response_headers + ) + + # Handle 200 success + if status_code == 200: + response_data = response.json() + http_logs.append( + HttpLog( + code="success", + detail="Token exchange successful. Store the access token and proceed with business logic.", + req=req_obj, + res=res_obj, + ) + ) + # Cast access_mode to Literal type + access_mode_literal = cast( + Literal["online", "offline"], access_mode + ) + return _handle_success_response( + response_data, shop_name, access_mode_literal, http_logs + ) + + # Handle 429 rate limit with retry helper + should_retry, should_return, return_value = ( + await _handle_retry_after_async( + status_code, + attempt, + max_retries, + response_headers, + http_logs, + req_obj, + res_obj, + shop_name, + ) + ) + if should_return: + if return_value is None: + raise RuntimeError( + "_handle_retry_after_async returned should_return=True but no return_value" + ) + return return_value + if should_retry: + attempt += 1 + continue + + # Handle error responses + try: + response_data = response.json() + except Exception: + response_data = {} + + return _handle_error_response( + response_data, + shop_name, + invalid_token_response, + http_logs, + req_obj, + res_obj, + ) + + except httpx.RequestError: + return build_network_error_response( + shop_name, http_logs, req_obj, "token exchange", "token_exchange" + ) + + # Should never reach here due to while loop logic + raise AssertionError("unreachable: while loop should always return") diff --git a/shopify_app/graphql/__init__.py b/shopify_app/graphql/__init__.py new file mode 100644 index 0000000..d2bcbd7 --- /dev/null +++ b/shopify_app/graphql/__init__.py @@ -0,0 +1,7 @@ +"""GraphQL module for Shopify Admin API requests.""" + +from __future__ import annotations + +from .admin_graphql import admin_graphql_request + +__all__ = ["admin_graphql_request"] diff --git a/shopify_app/graphql/admin_graphql.py b/shopify_app/graphql/admin_graphql.py new file mode 100644 index 0000000..aa20509 --- /dev/null +++ b/shopify_app/graphql/admin_graphql.py @@ -0,0 +1,908 @@ +""" +Shopify Admin GraphQL + +This module provides functions to make GraphQL requests to the Shopify Admin API. +""" + +from __future__ import annotations + +import asyncio +import json +import random +import time +from typing import Any, Dict, List, Optional, Tuple, Union + +import httpx + +from shopify_app.types import AppConfig, GQLResult, HttpLog, Log, RequestInput, Res +from shopify_app.utils import _get_user_agent, _to_res + +from ..utils.http_client import AsyncHTTPClientContext, HTTPClientContext + + +def admin_graphql_request( + query: str, + shop: str, + access_token: str, + api_version: str, + invalid_token_response: Optional[Union[Res, dict]] = None, + variables: Optional[Dict[str, Any]] = None, + headers: Optional[Dict[str, str]] = None, + max_retries: int = 2, + app_config: Optional[AppConfig] = None, + http_client: Optional[httpx.Client] = None, +) -> GQLResult: + """ + Make a GraphQL request to the Shopify Admin API. + + Args: + query (str): The GraphQL query or mutation string + app_config (dict): App configuration (not currently used) + http_client: Optional HTTP client for testing + shop (str): Shop domain (e.g., "example") + access_token (str): Valid access token for the shop + api_version (str): API version (e.g., "2024-01") + invalid_token_response (Res | dict): Pre-built response to return if token is invalid (or None) + variables (dict[str, Any]): Optional GraphQL variables + headers (dict[str, str]): Optional additional HTTP headers + max_retries (int): Optional custom retry count (default: 2) + + Returns: + GQLResult: Result containing ok, shop, log, response, data, extensions, and http_logs + """ + # Use parameters directly (already extracted from named args) + if headers is None: + headers = {} + + # Validate required parameters + is_valid, error = _validate_graphql_params(shop, access_token, api_version, query) + if not is_valid: + if error is None: + raise RuntimeError("_validate_graphql_params returned invalid but no error") + return error + + # Store original shop for return value + original_shop = shop + + endpoint = f"https://{shop}.myshopify.com/admin/api/{api_version}/graphql.json" + + request_headers = { + "Content-Type": "application/json", + "X-Shopify-Access-Token": access_token, + "User-Agent": _get_user_agent(), + **headers, + } + + request_body: Dict[str, Any] = {"query": query} + if variables: + request_body["variables"] = variables + + # Execute request with retry logic + attempt = 0 + logs: List[HttpLog] = [] + + # Use injected client or create one via context manager + if http_client is not None: + # Testing path - use injected client directly + context_manager = HTTPClientContext(http_client) + else: + # Production path - context manager will create and cleanup client + context_manager = HTTPClientContext() + + with context_manager as client: + while attempt <= max_retries: + try: + response = client.post( + endpoint, + headers=request_headers, + json=request_body, + ) + + status_code = response.status_code + response_body = response.text + response_headers = dict(response.headers) + + req: RequestInput = { + "url": endpoint, + "method": "POST", + "headers": request_headers, + "body": json.dumps(request_body), + } + + res = Res( + status=status_code, body=response_body, headers=response_headers + ) + + # Handle 200 success (but check for GraphQL errors) + if status_code == 200: + try: + response_data = response.json() + except json.JSONDecodeError: + response_data = {} + + # Check for GraphQL errors + if response_data.get("errors"): + logs.append( + HttpLog( + code="graphql_errors", + detail="GraphQL request returned errors", + req=req, + res=res, + ) + ) + return GQLResult( + ok=False, + shop=None, + log=Log( + code="graphql_errors", + detail="GraphQL request returned errors", + ), + http_logs=logs, + response=res, + data=None, + extensions=None, + ) + + # Success + logs.append( + HttpLog( + code="success", + detail="GraphQL request successful. Proceed with business logic.", + req=req, + res=res, + ) + ) + return GQLResult( + ok=True, + shop=original_shop, + log=Log( + code="success", + detail="GraphQL request successful. Proceed with business logic.", + ), + http_logs=logs, + response=res, + data=response_data.get("data"), + extensions=response_data.get("extensions"), + ) + + # Handle 401 unauthorized + if status_code == 401: + return _handle_401_response(invalid_token_response, req, res) + + # Handle 429 rate limit with retry helper + should_retry, should_return, return_value = _handle_429_retry_sync( + status_code, attempt, max_retries, response_headers, logs, req, res + ) + if should_return: + if return_value is None: + raise RuntimeError( + "_handle_429_retry_sync returned should_return=True but no return_value" + ) + return return_value + if should_retry: + attempt += 1 + continue + + # Handle 5xx errors with retry helper + should_retry, should_return, return_value = _handle_5xx_retry_sync( + status_code, attempt, max_retries, logs, req, res + ) + if should_return: + if return_value is None: + raise RuntimeError( + "_handle_5xx_retry_sync returned should_return=True but no return_value" + ) + return return_value + if should_retry: + attempt += 1 + continue + + # Handle non-retryable errors + if status_code == 400: + return GQLResult( + ok=False, + shop=None, + log=Log( + code="http_error_400", + detail="GraphQL query syntax is invalid. Do not retry.", + ), + http_logs=[ + HttpLog( + code="http_error_400", + detail="GraphQL query syntax is invalid. Do not retry.", + req=req, + res=res, + ) + ], + response=res, + data=None, + extensions=None, + ) + + if status_code == 403: + return GQLResult( + ok=False, + shop=None, + log=Log( + code="http_error_403", + detail="Access token lacks required permissions. Do not retry.", + ), + http_logs=[ + HttpLog( + code="http_error_403", + detail="Access token lacks required permissions. Do not retry.", + req=req, + res=res, + ) + ], + response=res, + data=None, + extensions=None, + ) + + # Other HTTP errors + return GQLResult( + ok=False, + shop=None, + log=Log( + code=f"http_error_{status_code}", + detail=f"HTTP error {status_code}", + ), + http_logs=[ + HttpLog( + code=f"http_error_{status_code}", + detail=f"HTTP error {status_code}", + req=req, + res=res, + ) + ], + response=res, + data=None, + extensions=None, + ) + + except (httpx.RequestError, httpx.ConnectError, httpx.TimeoutException): + # Network/connection errors - return immediately without retry + # Use the req already defined above, create new res for error + error_req: RequestInput = { + "url": endpoint, + "method": "POST", + "headers": request_headers, + "body": json.dumps(request_body), + } + error_res = Res(status=0, body="", headers={}) + return GQLResult( + ok=False, + shop=None, + log=Log( + code="network_error", + detail="Network error occurred during GraphQL request", + ), + http_logs=[ + HttpLog( + code="network_error", + detail="Network error occurred during GraphQL request", + req=error_req, + res=error_res, + ) + ], + response=error_res, + data=None, + extensions=None, + ) + + # Should never reach here due to while loop logic + raise AssertionError("unreachable: while loop should always return") + + +def _validate_graphql_params( + shop: str, + access_token: str, + api_version: str, + query: str, +) -> Tuple[bool, Optional[GQLResult]]: + """Validate all required GraphQL parameters. + + Args: + shop: Shop domain + access_token: Access token for authentication + api_version: API version string + query: GraphQL query string + + Returns: + tuple: (is_valid: bool, error_response: GQLResult or None) + """ + if not shop: + return ( + False, + GQLResult( + ok=False, + shop=None, + log=Log(code="missing_shop", detail="Shop domain is required"), + response=Res(status=400, body="", headers={}), + data=None, + extensions=None, + http_logs=[], + ), + ) + + if not access_token: + return ( + False, + GQLResult( + ok=False, + shop=None, + log=Log(code="missing_access_token", detail="Access token is required"), + response=Res(status=400, body="", headers={}), + data=None, + extensions=None, + http_logs=[], + ), + ) + + if not api_version: + return ( + False, + GQLResult( + ok=False, + shop=None, + log=Log(code="missing_api_version", detail="API version is required"), + response=Res(status=400, body="", headers={}), + data=None, + extensions=None, + http_logs=[], + ), + ) + + if not query: + return ( + False, + GQLResult( + ok=False, + shop=None, + log=Log(code="missing_query", detail="GraphQL query is required"), + response=Res(status=400, body="", headers={}), + data=None, + extensions=None, + http_logs=[], + ), + ) + + return (True, None) + + +def _get_status_text(status_code): + """Get status text for HTTP status code.""" + status_texts = { + 502: "Bad Gateway", + 503: "Service Unavailable", + 504: "Gateway Timeout", + } + return status_texts.get(status_code, "Error") + + +def _handle_429_retry_sync( + status_code: int, + attempt: int, + max_retries: int, + response_headers: Dict[str, Any], + logs: List[HttpLog], + req: RequestInput, + res: Res, +) -> Tuple[bool, bool, Optional[GQLResult]]: + """Handle 429 rate limit retry for sync GraphQL requests.""" + if status_code == 429 and attempt < max_retries: + retry_after = response_headers.get("Retry-After", "1") + logs.append( + HttpLog( + code="rate_limited_retry", + detail=f"Rate limited. Retrying after {retry_after} seconds (attempt {attempt + 1} of {max_retries + 1}).", + req=req, + res=res, + ) + ) + time.sleep(int(retry_after)) + return (True, False, None) + + if status_code == 429 and attempt == max_retries: + logs.append( + HttpLog( + code="rate_limited", + detail="Max retries reached after rate limiting. Return 429 Too Many Requests.", + req=req, + res=res, + ) + ) + return ( + False, + True, + GQLResult( + ok=False, + shop=None, + log=Log( + code="rate_limited", + detail="Max retries reached after rate limiting. Return 429 Too Many Requests.", + ), + http_logs=logs, + response=Res( + status=429, + body='{"error":"Too many requests"}', + headers={"Content-Type": "application/json"}, + ), + data=None, + extensions=None, + ), + ) + + return (False, False, None) + + +async def _handle_429_retry_async( + status_code: int, + attempt: int, + max_retries: int, + response_headers: Dict[str, Any], + logs: List[HttpLog], + req: RequestInput, + res: Res, +) -> Tuple[bool, bool, Optional[GQLResult]]: + """Handle 429 rate limit retry for async GraphQL requests.""" + if status_code == 429 and attempt < max_retries: + retry_after = response_headers.get("Retry-After", "1") + logs.append( + HttpLog( + code="rate_limited_retry", + detail=f"Rate limited. Retrying after {retry_after} seconds (attempt {attempt + 1} of {max_retries + 1}).", + req=req, + res=res, + ) + ) + await asyncio.sleep(int(retry_after)) + return (True, False, None) + + if status_code == 429 and attempt == max_retries: + logs.append( + HttpLog( + code="rate_limited", + detail="Max retries reached after rate limiting. Return 429 Too Many Requests.", + req=req, + res=res, + ) + ) + return ( + False, + True, + GQLResult( + ok=False, + shop=None, + log=Log( + code="rate_limited", + detail="Max retries reached after rate limiting. Return 429 Too Many Requests.", + ), + http_logs=logs, + response=Res( + status=429, + body='{"error":"Too many requests"}', + headers={"Content-Type": "application/json"}, + ), + data=None, + extensions=None, + ), + ) + + return (False, False, None) + + +def _handle_5xx_retry_sync( + status_code: int, + attempt: int, + max_retries: int, + logs: List[HttpLog], + req: RequestInput, + res: Res, +) -> Tuple[bool, bool, Optional[GQLResult]]: + """Handle 5xx retry with exponential backoff for sync requests.""" + if status_code in [502, 503, 504] and attempt < max_retries: + base_delay = 1 + delay = base_delay * (2**attempt) + (random.randint(0, 100) / 1000) + logs.append( + HttpLog( + code=f"http_error_{status_code}_retry", + detail=f"HTTP {status_code} error. Retrying with exponential backoff (attempt {attempt + 1} of {max_retries + 1}).", + req=req, + res=res, + ) + ) + time.sleep(delay) + return (True, False, None) + + if status_code in [502, 503, 504] and attempt == max_retries: + logs.append( + HttpLog( + code=f"http_error_{status_code}", + detail=f"Max retries reached for transient error. Return {status_code} {_get_status_text(status_code)}.", + req=req, + res=res, + ) + ) + return ( + False, + True, + GQLResult( + ok=False, + shop=None, + log=Log( + code=f"http_error_{status_code}", + detail=f"Max retries reached for transient error. Return {status_code} {_get_status_text(status_code)}.", + ), + http_logs=logs, + response=Res(status=status_code, body="", headers={}), + data=None, + extensions=None, + ), + ) + + return (False, False, None) + + +async def _handle_5xx_retry_async( + status_code: int, + attempt: int, + max_retries: int, + logs: List[HttpLog], + req: RequestInput, + res: Res, +) -> Tuple[bool, bool, Optional[GQLResult]]: + """Handle 5xx retry with exponential backoff for async requests.""" + if status_code in [502, 503, 504] and attempt < max_retries: + base_delay = 1 + delay = base_delay * (2**attempt) + (random.randint(0, 100) / 1000) + logs.append( + HttpLog( + code=f"http_error_{status_code}_retry", + detail=f"HTTP {status_code} error. Retrying with exponential backoff (attempt {attempt + 1} of {max_retries + 1}).", + req=req, + res=res, + ) + ) + await asyncio.sleep(delay) + return (True, False, None) + + if status_code in [502, 503, 504] and attempt == max_retries: + logs.append( + HttpLog( + code=f"http_error_{status_code}", + detail=f"Max retries reached for transient error. Return {status_code} {_get_status_text(status_code)}.", + req=req, + res=res, + ) + ) + return ( + False, + True, + GQLResult( + ok=False, + shop=None, + log=Log( + code=f"http_error_{status_code}", + detail=f"Max retries reached for transient error. Return {status_code} {_get_status_text(status_code)}.", + ), + http_logs=logs, + response=Res(status=status_code, body="", headers={}), + data=None, + extensions=None, + ), + ) + + return (False, False, None) + + +def _handle_401_response( + invalid_token_response: Optional[Union[Res, dict]], + req: RequestInput, + res: Res, +) -> GQLResult: + """Handle 401 unauthorized responses.""" + # If invalidTokenResponse provided, return it (convert dict to Res if needed) + response = _to_res(invalid_token_response) + if response is None: + # No retry mechanism, return plain 401 + response = Res(status=401, body="", headers={}) + + return GQLResult( + ok=False, + shop=None, + log=Log( + code="unauthorized", detail="Access token is invalid or has been revoked." + ), + response=response, + data=None, + extensions=None, + http_logs=[ + HttpLog( + code="unauthorized", + detail="Access token is invalid or has been revoked.", + req=req, + res=res, + ) + ], + ) + + +async def admin_graphql_request_async( + query: str, + shop: str, + access_token: str, + api_version: str, + invalid_token_response: Optional[Union[Res, dict]] = None, + variables: Optional[Dict[str, Any]] = None, + headers: Optional[Dict[str, str]] = None, + max_retries: int = 2, + app_config: Optional[AppConfig] = None, + http_client: Optional[httpx.AsyncClient] = None, +) -> GQLResult: + """ + Make an async GraphQL request to the Shopify Admin API. + + This is the async version of admin_graphql_request. Use this when you need + to make non-blocking GraphQL requests in async code. + + Args: + query (str): The GraphQL query or mutation string + app_config (dict): App configuration (not currently used) + http_client: Optional async HTTP client for testing (httpx.AsyncClient) + shop (str): Shop domain (e.g., "example") + access_token (str): Valid access token for the shop + api_version (str): API version (e.g., "2024-01") + invalid_token_response (Res | dict): Pre-built response to return if token is invalid (or None) + variables (dict[str, Any]): Optional GraphQL variables + headers (dict[str, str]): Optional additional HTTP headers + max_retries (int): Optional custom retry count (default: 2) + + Returns: + GQLResult: Result containing ok, logs, response, data, and extensions + """ + if headers is None: + headers = {} + + # Validate required parameters + is_valid, error = _validate_graphql_params(shop, access_token, api_version, query) + if not is_valid: + if error is None: + raise RuntimeError("_validate_graphql_params returned invalid but no error") + return error + + # Store original shop for return value + original_shop = shop + + endpoint = f"https://{shop}.myshopify.com/admin/api/{api_version}/graphql.json" + + request_headers = { + "Content-Type": "application/json", + "X-Shopify-Access-Token": access_token, + "User-Agent": _get_user_agent(), + **headers, + } + + request_body: Dict[str, Any] = {"query": query} + if variables: + request_body["variables"] = variables + + # Execute request with retry logic + attempt = 0 + logs: List[HttpLog] = [] + + async with AsyncHTTPClientContext(http_client) as client: + while attempt <= max_retries: + try: + response = await client.post( + endpoint, + headers=request_headers, + json=request_body, + ) + + status_code = response.status_code + response_body = response.text + response_headers = dict(response.headers) + + req: RequestInput = { + "url": endpoint, + "method": "POST", + "headers": request_headers, + "body": json.dumps(request_body), + } + + res = Res( + status=status_code, body=response_body, headers=response_headers + ) + + # Handle 200 success (but check for GraphQL errors) + if status_code == 200: + try: + response_data = response.json() + except json.JSONDecodeError: + response_data = {} + + # Check for GraphQL errors + if response_data.get("errors"): + logs.append( + HttpLog( + code="graphql_errors", + detail="GraphQL request returned errors", + req=req, + res=res, + ) + ) + return GQLResult( + ok=False, + shop=None, + log=Log( + code="graphql_errors", + detail="GraphQL request returned errors", + ), + http_logs=logs, + response=res, + data=None, + extensions=None, + ) + + # Success + logs.append( + HttpLog( + code="success", + detail="GraphQL request successful. Proceed with business logic.", + req=req, + res=res, + ) + ) + return GQLResult( + ok=True, + shop=original_shop, + log=Log( + code="success", + detail="GraphQL request successful. Proceed with business logic.", + ), + http_logs=logs, + response=res, + data=response_data.get("data"), + extensions=response_data.get("extensions"), + ) + + # Handle 401 unauthorized + if status_code == 401: + return _handle_401_response(invalid_token_response, req, res) + + # Handle 429 rate limit with retry helper + should_retry, should_return, return_value = ( + await _handle_429_retry_async( + status_code, + attempt, + max_retries, + response_headers, + logs, + req, + res, + ) + ) + if should_return: + if return_value is None: + raise RuntimeError( + "_handle_429_retry_async returned should_return=True but no return_value" + ) + return return_value + if should_retry: + attempt += 1 + continue + + # Handle 5xx errors with retry helper + should_retry, should_return, return_value = ( + await _handle_5xx_retry_async( + status_code, attempt, max_retries, logs, req, res + ) + ) + if should_return: + if return_value is None: + raise RuntimeError( + "_handle_5xx_retry_async returned should_return=True but no return_value" + ) + return return_value + if should_retry: + attempt += 1 + continue + + # Handle non-retryable errors + if status_code == 400: + return GQLResult( + ok=False, + shop=None, + log=Log( + code="http_error_400", + detail="GraphQL query syntax is invalid. Do not retry.", + ), + http_logs=[ + HttpLog( + code="http_error_400", + detail="GraphQL query syntax is invalid. Do not retry.", + req=req, + res=res, + ) + ], + response=res, + data=None, + extensions=None, + ) + + if status_code == 403: + return GQLResult( + ok=False, + shop=None, + log=Log( + code="http_error_403", + detail="Access token lacks required permissions. Do not retry.", + ), + http_logs=[ + HttpLog( + code="http_error_403", + detail="Access token lacks required permissions. Do not retry.", + req=req, + res=res, + ) + ], + response=res, + data=None, + extensions=None, + ) + + # Other HTTP errors + return GQLResult( + ok=False, + shop=None, + log=Log( + code=f"http_error_{status_code}", + detail=f"HTTP error {status_code}", + ), + http_logs=[ + HttpLog( + code=f"http_error_{status_code}", + detail=f"HTTP error {status_code}", + req=req, + res=res, + ) + ], + response=res, + data=None, + extensions=None, + ) + + except (httpx.RequestError, httpx.ConnectError, httpx.TimeoutException): + # Network/connection errors - return immediately without retry + # Use different variable names to avoid redefinition + error_req: RequestInput = { + "url": endpoint, + "method": "POST", + "headers": request_headers, + "body": json.dumps(request_body), + } + error_res = Res(status=0, body="", headers={}) + return GQLResult( + ok=False, + shop=None, + log=Log( + code="network_error", + detail="Network error occurred during GraphQL request", + ), + http_logs=[ + HttpLog( + code="network_error", + detail="Network error occurred during GraphQL request", + req=error_req, + res=error_res, + ) + ], + response=error_res, + data=None, + extensions=None, + ) + + # Should never reach here due to while loop logic + raise AssertionError("unreachable: while loop should always return") diff --git a/shopify_app/helpers/__init__.py b/shopify_app/helpers/__init__.py new file mode 100644 index 0000000..db1377b --- /dev/null +++ b/shopify_app/helpers/__init__.py @@ -0,0 +1,9 @@ +"""Helpers module for Shopify App utility functions.""" + +from __future__ import annotations + +from .app_home_parent_redirect import app_home_parent_redirect +from .app_home_patch_id_token import app_home_patch_id_token +from .app_home_redirect import app_home_redirect + +__all__ = ["app_home_parent_redirect", "app_home_patch_id_token", "app_home_redirect"] diff --git a/shopify_app/helpers/app_home_parent_redirect.py b/shopify_app/helpers/app_home_parent_redirect.py new file mode 100644 index 0000000..b90ed9f --- /dev/null +++ b/shopify_app/helpers/app_home_parent_redirect.py @@ -0,0 +1,234 @@ +""" +Shopify App Home Parent Redirect + +This module provides a helper function to generate redirect responses that +break out of the app home iframe. +""" + +from __future__ import annotations + +import json +from typing import Optional +from urllib.parse import parse_qs, urlencode, urlparse + +from ..types import AppConfig, LogWithReq, RequestInput, Res, ResultForReq +from ..utils.headers import _normalize_headers + +# Restricted params that should be stripped from Shopify domain redirects +RESTRICTED_PARAMS = frozenset( + [ + "hmac", + "locale", + "protocol", + "session", + "id_token", + "shop", + "timestamp", + "host", + "embedded", + "appLoadId", + ] +) + +LINK_HEADER = '; rel="preconnect", ; rel="preload"; as="script", ; rel="preload"; as="script"' + + +def app_home_parent_redirect( + request: RequestInput, + config: AppConfig, + redirect_url: str, + shop: str, + target: Optional[str] = None, +) -> ResultForReq: + """ + Generate a redirect response that breaks out of the app home iframe. + + Args: + request (RequestInput): Request dictionary with method, headers, url, and body + config (AppConfig): App configuration with client_id + redirect_url (str): The URL to redirect to + shop (str): The shop domain (e.g., "test-shop") + target (str, optional): Target window: "_top" or "_blank" (default: "_top") + + Returns: + ResultForReq: Result with ok, shop, log, and response + """ + client_id = config.get("client_id", "") + shop_domain = f"{shop}.myshopify.com" + + # Validate request object + headers = request.get("headers") + if not isinstance(headers, dict): + return ResultForReq( + ok=False, + shop=None, + log=LogWithReq( + code="configuration_error", + detail="Expected request.headers to be an object", + req=request, + ), + response=Res(status=500, body="", headers={}), + ) + + url = request.get("url") + if not isinstance(url, str) or url == "": + return ResultForReq( + ok=False, + shop=None, + log=LogWithReq( + code="configuration_error", + detail="Expected request.url to be a non-empty string", + req=request, + ), + response=Res(status=500, body="", headers={}), + ) + + # Default target to _top + if target is None: + target = "_top" + + # Validate target + if target not in ("_top", "_blank"): + return ResultForReq( + ok=False, + shop=shop, + log=LogWithReq( + code="invalid_target", + detail=f"Target must be '_top' or '_blank'. Received {target}. Respond 400 Bad Request using the provided response.", + req=request, + ), + response=Res(status=400, body="Bad Request", headers={}), + ) + + # Validate redirect URL scheme (must be http or https) + parsed_redirect = urlparse(redirect_url) + if parsed_redirect.scheme not in ("http", "https"): + return ResultForReq( + ok=False, + shop=None, + log=LogWithReq( + code="configuration_error", + detail="Redirect URL must use http or https scheme", + req=request, + ), + response=Res(status=500, body="", headers={}), + ) + + # Normalize headers for case-insensitive access + normalized_headers = _normalize_headers(headers) + + # Determine request type + has_auth_header = "authorization" in normalized_headers + + # Process redirect URL - strip restricted params if needed + processed_redirect_url = _process_redirect_url(redirect_url) + + # Determine response based on request type + if has_auth_header: + # Fetch request - return 401 with reauthorize header + return ResultForReq( + ok=True, + shop=shop, + log=LogWithReq( + code="app_home_parent_redirect_success", + detail="App Home Parent Redirect response constructed. Respond with the provided response to redirect outside the app iframe.", + req=request, + ), + response=Res( + status=401, + body="", + headers={ + "X-Shopify-API-Request-Failure-Reauthorize-Url": processed_redirect_url + }, + ), + ) + + # JSON encode URL and target for safe embedding in JavaScript + # Replace < and > with unicode escapes to prevent XSS + encoded_url = _json_encode_for_js(processed_redirect_url) + encoded_target = _json_encode_for_js(target) + + # Document request - return HTML response with App Bridge + html = f'' + html += f"" + + return ResultForReq( + ok=True, + shop=shop, + log=LogWithReq( + code="app_home_parent_redirect_success", + detail="App Home Parent Redirect response constructed. Respond with the provided response to redirect outside the app iframe.", + req=request, + ), + response=Res( + status=200, + body=html, + headers={ + "Content-Type": "text/html", + "Link": LINK_HEADER, + "Content-Security-Policy": f"frame-ancestors https://{shop_domain} https://admin.shopify.com;", + }, + ), + ) + + +def _process_redirect_url(redirect_url: str) -> str: + """ + Process redirect URL by stripping restricted params if needed. + + Args: + redirect_url (str): The original redirect URL + + Returns: + str: The processed redirect URL + """ + parsed = urlparse(redirect_url) + host = parsed.hostname or "" + + # Check if we need to strip restricted params + is_admin_shopify = host == "admin.shopify.com" + is_myshopify_domain = host.endswith(".myshopify.com") + + if not is_admin_shopify and not is_myshopify_domain: + return redirect_url + + # Parse and filter query params + if not parsed.query: + return redirect_url + + query_params = parse_qs(parsed.query, keep_blank_values=True) + filtered_params = {} + for key, values in query_params.items(): + if key not in RESTRICTED_PARAMS: + # parse_qs returns lists, keep single values as single + filtered_params[key] = values[0] if len(values) == 1 else values + + # Rebuild URL + scheme = parsed.scheme or "https" + path = parsed.path or "" + fragment = f"#{parsed.fragment}" if parsed.fragment else "" + + new_url = f"{scheme}://{host}{path}" + if filtered_params: + new_url += "?" + urlencode(filtered_params, doseq=True) + new_url += fragment + + return new_url + + +def _json_encode_for_js(value: str) -> str: + """ + JSON encode a string for safe embedding in JavaScript. + Escapes < and > as unicode escapes to prevent XSS. + + Args: + value (str): The value to encode + + Returns: + str: The JSON-encoded string (including surrounding quotes) + """ + # JSON encode (escapes quotes, backslashes, etc.) + encoded = json.dumps(value) + # Replace < and > with unicode escapes to prevent script tag injection + encoded = encoded.replace("<", "\\u003C").replace(">", "\\u003E") + return encoded diff --git a/shopify_app/helpers/app_home_patch_id_token.py b/shopify_app/helpers/app_home_patch_id_token.py new file mode 100644 index 0000000..3d28148 --- /dev/null +++ b/shopify_app/helpers/app_home_patch_id_token.py @@ -0,0 +1,115 @@ +""" +Shopify App Home Patch ID Token Page Rendering + +This module provides a helper function to render the App Home Patch ID Token Page +HTML for embedded apps. +""" + +from __future__ import annotations + +from urllib.parse import parse_qs, urlparse + +from ..types import AppConfig, LogWithReq, RequestInput, Res, ResultForReq + + +def app_home_patch_id_token(request: RequestInput, config: AppConfig) -> ResultForReq: + """ + Renders the App Home Patch ID Token page HTML. + + Generates a lightweight HTML page that loads the App Bridge script to obtain + fresh session tokens for embedded apps. + + Args: + request (dict): Request object with method, headers, url, and body + config (dict): App configuration with client_id + + Returns: + ResultForReq: Result with ok, shop, log, and response containing HTML and headers + """ + client_id = config.get("client_id", "") + + # Check for missing client ID + if not client_id: + return ResultForReq( + ok=False, + shop=None, + log=LogWithReq( + code="missing_client_id", + detail="Client ID is required but was not provided. Check configuration and respond 500 Internal Server Error using the provided response.", + req=request, + ), + response=Res(status=500, body="Internal Server Error", headers={}), + ) + + # Extract shop and shopify-reload from request query parameters + url = request.get("url", "") + + if not url: + return ResultForReq( + ok=False, + shop=None, + log=LogWithReq( + code="missing_request_url", + detail="Request URL is required but was not provided.", + req=request, + ), + response=Res(status=400, body="Bad Request", headers={}), + ) + + parsed_url = urlparse(url) + query_params = parse_qs(parsed_url.query) + + # parse_qs returns lists, so get first value if present + shop_list = query_params.get("shop", []) + shop = shop_list[0] if shop_list else "" + + shopify_reload_list = query_params.get("shopify-reload", []) + shopify_reload = shopify_reload_list[0] if shopify_reload_list else "" + + # Check for missing shop + if not shop: + return ResultForReq( + ok=False, + shop=None, + log=LogWithReq( + code="missing_shop", + detail="Shop parameter is required in request URL query string but was not provided. Respond 400 Bad Request using the provided response.", + req=request, + ), + response=Res(status=400, body="Bad Request", headers={}), + ) + + # Check for missing shopify-reload + if not shopify_reload: + return ResultForReq( + ok=False, + shop=None, + log=LogWithReq( + code="missing_shopify_reload", + detail="shopify-reload parameter is required in request URL query string but was not provided. Respond 400 Bad Request using the provided response.", + req=request, + ), + response=Res(status=400, body="Bad Request", headers={}), + ) + + # Generate HTML with client ID from configuration + html_body = f'' + + return ResultForReq( + ok=True, + shop=shop, + log=LogWithReq( + code="patch_id_token_page_success", + detail="App Home Patch ID Token page Response constructed. Respond with the provided response and App Bridge will obtain an id token.", + req=request, + ), + response=Res( + status=200, + body=html_body, + headers={ + "Content-Type": "text/html", + "Link": '; rel="preload"; as="script";', + "Content-Security-Policy": f"frame-ancestors https://{shop} https://admin.shopify.com;", + }, + ), + ) diff --git a/shopify_app/helpers/app_home_redirect.py b/shopify_app/helpers/app_home_redirect.py new file mode 100644 index 0000000..ba5c094 --- /dev/null +++ b/shopify_app/helpers/app_home_redirect.py @@ -0,0 +1,221 @@ +""" +Shopify App Home Redirect + +This module provides a helper function to generate redirect responses that +stay within the app home iFrame. +""" + +from __future__ import annotations + +from urllib.parse import parse_qs, urlencode, urlparse + +from ..types import AppConfig, LogWithReq, RequestInput, Res, ResultForReq +from ..utils.headers import _normalize_headers + +LINK_HEADER = '; rel="preconnect", ; rel="preload"; as="script", ; rel="preload"; as="script"' + + +def app_home_redirect( + request: RequestInput, config: AppConfig, redirect_url: str, shop: str +) -> ResultForReq: + """ + Generate a redirect response that stays within the app home iFrame. + + Args: + request (RequestInput): Request dictionary with method, headers, url, and body + config (AppConfig): App configuration with client_id + redirect_url (str): The relative URL to redirect to (must start with '/') + shop (str): The shop domain (e.g., "test-shop") + + Returns: + ResultForReq: Result with ok, shop, log, and response + """ + client_id = config.get("client_id", "") + shop_domain = f"{shop}.myshopify.com" + + # Validate request object + headers = request.get("headers") + if not isinstance(headers, dict): + return ResultForReq( + ok=False, + shop=None, + log=LogWithReq( + code="configuration_error", + detail="Expected request.headers to be an object", + req=request, + ), + response=Res(status=500, body="", headers={}), + ) + + url = request.get("url") + if not isinstance(url, str) or url == "": + return ResultForReq( + ok=False, + shop=None, + log=LogWithReq( + code="configuration_error", + detail="Expected request.url to be a non-empty string", + req=request, + ), + response=Res(status=500, body="", headers={}), + ) + + # Validate redirect URL is a relative path starting with / + if not _is_valid_relative_url(redirect_url): + return ResultForReq( + ok=False, + shop=shop, + log=LogWithReq( + code="invalid_redirect_url", + detail=f"Redirect URL must be a relative path starting with '/'. Received {redirect_url}. Respond 400 Bad Request using the provided response.", + req=request, + ), + response=Res(status=400, body="Bad Request", headers={}), + ) + + # Normalize headers for case-insensitive access + normalized_headers = _normalize_headers(headers) + + # Determine request type + has_auth_header = "authorization" in normalized_headers + has_bounce_header = "x-shopify-bounce" in normalized_headers + + # Build redirect URL with merged params + merged_url = _merge_url_params(url, redirect_url) + + # Determine response based on request type + if has_auth_header and has_bounce_header: + # Bounce request - return HTML response with App Bridge using _self + html = f'' + html += f"" + + return ResultForReq( + ok=True, + shop=shop, + log=LogWithReq( + code="app_home_redirect_success", + detail="App Home Redirect response constructed. Respond with the provided response to redirect within the app.", + req=request, + ), + response=Res( + status=200, + body=html, + headers={ + "Content-Type": "text/html", + "Link": LINK_HEADER, + "Content-Security-Policy": f"frame-ancestors https://{shop_domain} https://admin.shopify.com;", + }, + ), + ) + + if has_auth_header: + # Fetch request - return plain 302 redirect + return ResultForReq( + ok=True, + shop=shop, + log=LogWithReq( + code="app_home_redirect_success", + detail="App Home Redirect response constructed. Respond with the provided response to redirect within the app.", + req=request, + ), + response=Res( + status=302, + body="", + headers={ + "Location": merged_url, + }, + ), + ) + + # Document request - return 302 redirect with CSP and Link headers + return ResultForReq( + ok=True, + shop=shop, + log=LogWithReq( + code="app_home_redirect_success", + detail="App Home Redirect response constructed. Respond with the provided response to redirect within the app.", + req=request, + ), + response=Res( + status=302, + body="", + headers={ + "Location": merged_url, + "Link": LINK_HEADER, + "Content-Security-Policy": f"frame-ancestors https://{shop_domain} https://admin.shopify.com;", + }, + ), + ) + + +def _is_valid_relative_url(redirect_url: str) -> bool: + """ + Check if the redirect URL is a valid relative path starting with '/'. + + Args: + redirect_url (str): The redirect URL to validate + + Returns: + bool: True if valid relative URL, False otherwise + """ + # Must be non-empty and start with / + if not redirect_url or not redirect_url.startswith("/"): + return False + + # Must not be protocol-relative (//evil.com) + if redirect_url.startswith("//"): + return False + + return True + + +def _merge_url_params(request_url: str, redirect_url: str) -> str: + """ + Merge URL params from request URL into redirect URL. + New params in redirect URL take precedence over existing ones. + + Args: + request_url (str): The original request URL with params to copy + redirect_url (str): The redirect URL (may have its own params) + + Returns: + str: The redirect URL with merged params + """ + # Parse request URL to get existing params + parsed_request = urlparse(request_url) + request_params = parse_qs(parsed_request.query, keep_blank_values=True) + # Flatten single-value lists + request_params_flat = { + k: v[0] if len(v) == 1 else v for k, v in request_params.items() + } + + # Parse redirect URL + parsed_redirect = urlparse(redirect_url) + redirect_params = parse_qs(parsed_redirect.query, keep_blank_values=True) + # Flatten single-value lists + redirect_params_flat = { + k: v[0] if len(v) == 1 else v for k, v in redirect_params.items() + } + redirect_fragment = parsed_redirect.fragment + + # Merge params - redirect params take precedence (they overwrite request params) + # Start with redirect params, then add request params that aren't already there + merged_params = dict(redirect_params_flat) + for key, value in request_params_flat.items(): + if key not in merged_params: + merged_params[key] = value + + # Build the merged URL + path = parsed_redirect.path + if merged_params: + # Use urlencode to build query string + query_string = urlencode(merged_params, doseq=True) + result = f"{path}?{query_string}" + else: + result = path + + # Append fragment if present + if redirect_fragment: + result = f"{result}#{redirect_fragment}" + + return result diff --git a/shopify_app/py.typed b/shopify_app/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/shopify_app/types.py b/shopify_app/types.py new file mode 100644 index 0000000..c4097dc --- /dev/null +++ b/shopify_app/types.py @@ -0,0 +1,382 @@ +""" +Shopify App Python SDK Types + +This module provides frozen dataclass types for all SDK return values. +All types use snake_case for field names following Python conventions. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Dict, List, Literal, Optional, TypedDict + +# ============================================================================= +# Configuration Types +# ============================================================================= + + +class AppConfig(TypedDict): + """ + App configuration dictionary. + + Attributes: + client_id: The Shopify app client ID (required) + client_secret: The Shopify app client secret (required) + old_client_secret: Previous client secret for rotation (value can be None) + """ + + client_id: str + client_secret: str + old_client_secret: Optional[str] + + +class RequestInput(TypedDict): + """ + HTTP request input for verification functions. + + Attributes: + method: HTTP method (e.g., "GET", "POST") + headers: HTTP headers as key-value pairs + url: Full request URL + body: Request body as string + """ + + method: str + headers: Dict[str, str] + url: str + body: str + + +# ============================================================================= +# Core Types +# ============================================================================= + + +@dataclass(frozen=True) +class Res: + """Response object returned by SDK functions.""" + + status: int + body: str + headers: Dict[str, str] + + +@dataclass(frozen=True) +class Log: + """ + Log object describing the state of a request. + + Attributes: + code: A unique log code (e.g., "missing_id_token") + detail: A short description of the state and what to do with the response + """ + + code: str + detail: str + + +@dataclass(frozen=True) +class LogWithReq: + """ + Log object with request details for verification results. + + Used by request verification functions where the request context + is always available and should be included in the log. + + Attributes: + code: A unique log code (e.g., "missing_id_token") + detail: A short description of the state and what to do with the response + req: Full request object for debugging + """ + + code: str + detail: str + req: "RequestInput" + + +@dataclass(frozen=True) +class HttpLog: + """ + Describes a request the function made and the response it received. + + Used by exchange and GraphQL functions to log HTTP interactions. + + Attributes: + code: A unique log code (e.g., "retry_request", "success") + detail: A description of what happened and what should happen next + req: The request the function made (matches RequestInput structure) + res: The response the function received + """ + + code: str + detail: str + req: RequestInput + res: "Res" + + +# ============================================================================= +# Token Types +# ============================================================================= + + +@dataclass(frozen=True) +class IdTokenDetails: + """ + ID token details from verification. + + Attributes: + exchangeable: Whether this token can be exchanged for an access token. + True for App Home, Admin UI Extension, POS UI Extension. + False for Checkout UI Extension, Customer Account UI Extension. + token: The JWT token string + claims: The decoded JWT claims as a dictionary + """ + + exchangeable: bool + token: str + claims: Dict[str, Any] + + +@dataclass(frozen=True) +class User: + """ + User information for online access tokens. + + Attributes: + id: User ID + first_name: User's first name + last_name: User's last name + scope: User's granted scopes + email: User's email address + account_owner: Whether user is the account owner + locale: User's locale + collaborator: Whether user is a collaborator + email_verified: Whether user's email is verified + """ + + id: int + first_name: str + last_name: str + scope: str + email: str + account_owner: bool + locale: str + collaborator: bool + email_verified: bool + + +@dataclass(frozen=True) +class TokenExchangeAccessToken: + """ + Access token from token exchange (supports online and offline modes). + + Attributes: + shop: The shop identifier (without .myshopify.com) + token: The access token string + expires: ISO 8601 timestamp when token expires (or None if never) + scope: Granted scopes + access_mode: Either "online" or "offline" + refresh_token: Token used to refresh this access token + refresh_token_expires: ISO 8601 timestamp when refresh token expires + user: User info for online tokens (None for offline) + """ + + shop: str + token: str + expires: Optional[str] + scope: str + access_mode: Literal["online", "offline"] + refresh_token: str + refresh_token_expires: Optional[str] + user: Optional[User] + + +@dataclass(frozen=True) +class ClientCredentialsAccessToken: + """ + Access token from client credentials exchange (offline mode only). + + Attributes: + shop: The shop identifier (without .myshopify.com) + token: The access token string + expires: ISO 8601 timestamp when token expires (or None if never) + scope: Granted scopes + access_mode: Always "offline" for client credentials + user: Always None for client credentials (no user context) + """ + + shop: str + token: str + expires: Optional[str] + scope: str + access_mode: Literal["offline"] + user: None + + +# ============================================================================= +# Result Types +# ============================================================================= + + +@dataclass(frozen=True) +class ResultForReq: + """ + Result from verify_webhook_req and verify_flow_action_req. + + Attributes: + ok: Whether verification succeeded + shop: The shop identifier (without .myshopify.com), or None on failure + log: LogWithReq object with verification details (includes the request) + response: Suggested HTTP response to return + """ + + ok: bool + shop: Optional[str] + log: LogWithReq + response: Res + + +@dataclass(frozen=True) +class ResultWithNonExchangeableIdToken: + """ + Result from verify_checkout_ui_ext_req and verify_customer_account_ui_ext_req. + + Includes the same fields as ResultForReq, plus a non-exchangeable ID token. + + Attributes: + ok: Whether verification succeeded + shop: The shop identifier (without .myshopify.com), or None on failure + log: LogWithReq object with verification details (includes the request) + response: Suggested HTTP response to return + id_token: ID token details (non-exchangeable), or None on failure + """ + + ok: bool + shop: Optional[str] + log: LogWithReq + response: Res + id_token: Optional[IdTokenDetails] + + +@dataclass(frozen=True) +class ResultWithExchangeableIdToken: + """ + Result from verify_admin_ui_ext_req, verify_pos_ui_ext_req, and verify_app_home_req. + + Includes the same fields as ResultForReq, plus an exchangeable ID token and user info. + + Attributes: + ok: Whether verification succeeded + shop: The shop identifier (without .myshopify.com), or None on failure + log: LogWithReq object with verification details (includes the request) + response: Suggested HTTP response to return + user_id: The user ID from the token's sub claim, or None on failure + id_token: ID token details (exchangeable), or None on failure + new_id_token_response: Pre-built response for token refresh scenarios + """ + + ok: bool + shop: Optional[str] + log: LogWithReq + response: Res + user_id: Optional[str] + id_token: Optional[IdTokenDetails] + new_id_token_response: Optional[Res] + + +@dataclass(frozen=True) +class ResultWithLoggedInCustomerId: + """ + Result from verify_app_proxy_req. + + Includes the same fields as ResultForReq, plus logged_in_customer_id. + + Attributes: + ok: Whether verification succeeded + shop: The shop identifier (without .myshopify.com), or None on failure + log: LogWithReq object with verification details (includes the request) + response: Suggested HTTP response to return + logged_in_customer_id: Customer ID if logged in, or None + """ + + ok: bool + shop: Optional[str] + log: LogWithReq + response: Res + logged_in_customer_id: Optional[str] + + +@dataclass(frozen=True) +class TokenExchangeResult: + """ + Result from exchange_using_token_exchange and refresh_token_exchanged_access_token. + + Attributes: + ok: Whether the exchange succeeded + shop: The shop identifier (without .myshopify.com), or None on failure + log: Log object with exchange details + response: Suggested HTTP response to return + access_token: The exchanged access token, or None on failure + http_logs: List of HTTP interactions during the exchange + """ + + ok: bool + shop: Optional[str] + log: Log + response: Res + access_token: Optional[TokenExchangeAccessToken] + http_logs: List[HttpLog] + + +@dataclass(frozen=True) +class ClientCredentialsExchangeResult: + """ + Result from exchange_using_client_credentials. + + Attributes: + ok: Whether the exchange succeeded + shop: The shop identifier (without .myshopify.com), or None on failure + log: Log object with exchange details + response: Suggested HTTP response to return + access_token: The exchanged access token (offline only), or None on failure + http_logs: List of HTTP interactions during the exchange + """ + + ok: bool + shop: Optional[str] + log: Log + response: Res + access_token: Optional[ClientCredentialsAccessToken] + http_logs: List[HttpLog] + + +@dataclass(frozen=True) +class GQLResult: + """ + Result from admin_graphql_request. + + Attributes: + ok: Whether the GraphQL request succeeded + shop: The shop identifier (without .myshopify.com), or None on failure + log: Log object with request details + response: The HTTP response received + data: GraphQL response data, or None on failure + extensions: GraphQL response extensions, or None + http_logs: List of HTTP interactions (including retries) + """ + + ok: bool + shop: Optional[str] + log: Log + response: Res + data: Optional[Dict[str, Any]] + extensions: Optional[Dict[str, Any]] + http_logs: List[HttpLog] + + +# ============================================================================= +# Type Aliases for Convenience +# ============================================================================= + +# Access mode literal type +AccessMode = Literal["online", "offline"] diff --git a/shopify_app/utils/__init__.py b/shopify_app/utils/__init__.py new file mode 100644 index 0000000..ba73f25 --- /dev/null +++ b/shopify_app/utils/__init__.py @@ -0,0 +1,9 @@ +"""Utility functions for Shopify App.""" + +from __future__ import annotations + +from .headers import _normalize_headers +from .input_converters import _get_attr, _to_res +from .user_agent import _get_user_agent + +__all__ = ["_normalize_headers", "_get_user_agent", "_get_attr", "_to_res"] diff --git a/shopify_app/utils/headers.py b/shopify_app/utils/headers.py new file mode 100644 index 0000000..0c29574 --- /dev/null +++ b/shopify_app/utils/headers.py @@ -0,0 +1,22 @@ +"""Header normalization utilities.""" + +from __future__ import annotations + +from typing import Dict + + +def _normalize_headers(headers: Dict[str, str]) -> Dict[str, str]: + """ + Normalize HTTP headers to lowercase for case-insensitive comparison. + + HTTP headers are case-insensitive per RFC 2616, but different frameworks + and clients may send them with different casing. This function normalizes + all header names to lowercase to ensure consistent access. + + Args: + headers (dict): Dictionary of HTTP headers + + Returns: + dict: Dictionary with lowercase header names + """ + return {k.lower(): v for k, v in headers.items()} diff --git a/shopify_app/utils/http_client.py b/shopify_app/utils/http_client.py new file mode 100644 index 0000000..c6466a6 --- /dev/null +++ b/shopify_app/utils/http_client.py @@ -0,0 +1,79 @@ +"""HTTP client lifecycle management utilities. + +This module provides context managers for managing HTTP client lifecycles +in both sync and async contexts. +""" + +import httpx + + +class HTTPClientContext: + """Manages sync HTTP client lifecycle. + + Usage: + with HTTPClientContext(http_client) as client: + response = client.post(...) + """ + + def __init__(self, injected_client=None, timeout=30.0): + """Initialize HTTP client context. + + Args: + injected_client: Optional pre-configured httpx.Client for testing + timeout: Request timeout in seconds (default: 30.0) + """ + self.injected_client = injected_client + self.timeout = timeout + self.client = None + self.should_close = False + + def __enter__(self): + """Enter context manager and return client.""" + if self.injected_client is not None: + self.client = self.injected_client + self.should_close = False + else: + self.client = httpx.Client(timeout=self.timeout) + self.should_close = True + return self.client + + def __exit__(self, exc_type, exc_val, exc_tb): + """Exit context manager and cleanup client if needed.""" + if self.should_close and self.client is not None: + self.client.close() + + +class AsyncHTTPClientContext: + """Manages async HTTP client lifecycle. + + Usage: + async with AsyncHTTPClientContext(http_client) as client: + response = await client.post(...) + """ + + def __init__(self, injected_client=None, timeout=30.0): + """Initialize async HTTP client context. + + Args: + injected_client: Optional pre-configured httpx.AsyncClient for testing + timeout: Request timeout in seconds (default: 30.0) + """ + self.injected_client = injected_client + self.timeout = timeout + self.client = None + self.should_close = False + + async def __aenter__(self): + """Enter async context manager and return client.""" + if self.injected_client is not None: + self.client = self.injected_client + self.should_close = False + else: + self.client = httpx.AsyncClient(timeout=self.timeout) + self.should_close = True + return self.client + + async def __aexit__(self, exc_type, exc_val, exc_tb): + """Exit async context manager and cleanup client if needed.""" + if self.should_close and self.client is not None: + await self.client.aclose() diff --git a/shopify_app/utils/input_converters.py b/shopify_app/utils/input_converters.py new file mode 100644 index 0000000..d0e36da --- /dev/null +++ b/shopify_app/utils/input_converters.py @@ -0,0 +1,80 @@ +""" +Input conversion utilities for handling Union[DataClass, dict] inputs. + +These helpers allow SDK functions to accept both dataclass instances and plain dicts, +providing flexibility for consumers while maintaining type safety internally. + +Pattern: +- _get_attr(): Extract a single field from dataclass or dict (for specific field access) +- _to_res(): Convert dict or Res to Res dataclass (for passing whole objects through) + +Note on typing: _get_attr() returns Any because Python's type system cannot express +"return the type of attribute X on object Y" without complex generics. Callers should +use typing.cast() when they need specific return types, or use isinstance() for type +narrowing before accessing attributes directly. +""" + +from __future__ import annotations + +import dataclasses +from typing import Any, Optional, TypeVar, Union + +from ..types import Res + +T = TypeVar("T") + + +def _get_attr(obj: Any, key: str, default: T = None) -> Union[Any, T]: # type: ignore[assignment] + """ + Get a single attribute from a dataclass or dict. + + Use this when you need specific fields from a Union[DataClass, dict] input. + Note: Returns Any because the return type depends on the attribute accessed. + Use cast() or isinstance() for type narrowing when needed. + + Args: + obj: A dataclass instance or dict + key: The attribute/key name to retrieve + default: Default value if the key doesn't exist + + Returns: + The value of the attribute/key, or default if not found + + Example: + shop = _get_attr(access_token, "shop", "") + token = _get_attr(id_token, "token", "") + """ + if dataclasses.is_dataclass(obj) and not isinstance(obj, type): + return getattr(obj, key, default) + elif isinstance(obj, dict): + return obj.get(key, default) + return default + + +def _to_res(obj: Optional[Union[Res, dict]]) -> Optional[Res]: + """ + Convert a dict or Res to a Res dataclass. + + Use this when passing a whole response object through unchanged. + Returns the original if already a Res dataclass (efficient). + + Args: + obj: A Res dataclass instance, dict, or None + + Returns: + A Res dataclass instance, or None if input was None + + Example: + response = _to_res(invalid_token_response) or Res(status=401, body="", headers={}) + """ + if obj is None: + return None + if dataclasses.is_dataclass(obj) and not isinstance(obj, type): + return obj + if isinstance(obj, dict): + return Res( + status=obj.get("status", 0), + body=obj.get("body", ""), + headers=obj.get("headers", {}), + ) + return None diff --git a/shopify_app/utils/user_agent.py b/shopify_app/utils/user_agent.py new file mode 100644 index 0000000..1fba2bb --- /dev/null +++ b/shopify_app/utils/user_agent.py @@ -0,0 +1,25 @@ +"""User-Agent header utilities.""" + +from __future__ import annotations + +import sys + +from .._version import __version__ + +PACKAGE_NAME = "shopify-app" + + +def _get_user_agent() -> str: + """ + Get the User-Agent string for HTTP requests. + + Format: "{package-name} v{version} | Python {python_version}" + Example: "shopify-app v0.1.0 | Python 3.11.0" + + Returns: + str: The formatted User-Agent string + """ + python_version = ( + f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}" + ) + return f"{PACKAGE_NAME} v{__version__} | Python {python_version}" diff --git a/shopify_app/verify/__init__.py b/shopify_app/verify/__init__.py new file mode 100644 index 0000000..8d5408a --- /dev/null +++ b/shopify_app/verify/__init__.py @@ -0,0 +1,8 @@ +"""Verify module for Shopify App webhooks and requests.""" + +from __future__ import annotations + +from .app_home_req import verify_app_home_req +from .webhook import verify_webhook_req + +__all__ = ["verify_webhook_req", "verify_app_home_req"] diff --git a/shopify_app/verify/_body_hmac_in_header.py b/shopify_app/verify/_body_hmac_in_header.py new file mode 100644 index 0000000..559abc0 --- /dev/null +++ b/shopify_app/verify/_body_hmac_in_header.py @@ -0,0 +1,178 @@ +""" +Shared Body HMAC in Header Verification + +This module provides the core HMAC verification logic used by both +webhook and flow action request verification, where the HMAC signature +of the request body is provided in the X-Shopify-Hmac-SHA256 header. +""" + +from __future__ import annotations + +import base64 +import hashlib +import hmac + +from ..types import AppConfig, LogWithReq, RequestInput, Res, ResultForReq +from ..utils.headers import _normalize_headers + + +def _verify_body_hmac_in_header( + request: RequestInput, config: AppConfig, request_type: str +) -> ResultForReq: + """ + Verifies HMAC-signed requests from Shopify (webhooks, flow actions, etc.) + + Args: + request (dict): The request object containing method, headers, and body + config (dict): The app configuration with client_secret + request_type (str): The type of request for log messages (e.g., "Webhook", "Flow action") + + Returns: + ResultForReq: Verification result with ok, shop, log, and response fields + """ + # Validate request object + method = request.get("method") + if not isinstance(method, str) or method == "": + return ResultForReq( + ok=False, + shop=None, + log=LogWithReq( + code="configuration_error", + detail="Expected request.method to be a non-empty string", + req=request, + ), + response=Res( + status=500, + body="", + headers={}, + ), + ) + + headers = request.get("headers") + if not isinstance(headers, dict): + return ResultForReq( + ok=False, + shop=None, + log=LogWithReq( + code="configuration_error", + detail="Expected request.headers to be an object", + req=request, + ), + response=Res( + status=500, + body="", + headers={}, + ), + ) + + body = request.get("body") + if not isinstance(body, str): + return ResultForReq( + ok=False, + shop=None, + log=LogWithReq( + code="configuration_error", + detail="Expected request.body to be a string", + req=request, + ), + response=Res( + status=500, + body="", + headers={}, + ), + ) + + client_secret = config.get("client_secret", "") + old_client_secret = config.get("old_client_secret") + + # Request method validation + if method != "POST": + return ResultForReq( + ok=False, + shop=None, + log=LogWithReq( + code="post_method_expected", + detail=f"{request_type} requests are expected to use the POST method. Respond 405 Method Not Allowed using the provided response.", + req=request, + ), + response=Res( + status=405, + body="Method not allowed", + headers={}, + ), + ) + + # Normalize headers for case-insensitive comparison + normalized_headers = _normalize_headers(headers) + + # Check for HMAC header first (most important for security) + if "x-shopify-hmac-sha256" not in normalized_headers: + return ResultForReq( + ok=False, + shop=None, + log=LogWithReq( + code="missing_hmac_header", + detail="Required `X-Shopify-Hmac-SHA256` header is missing. Respond 400 Bad Request using the provided response.", + req=request, + ), + response=Res( + status=400, + body="Bad Request", + headers={}, + ), + ) + + # HMAC validation + received_hmac = normalized_headers.get("x-shopify-hmac-sha256", "") + + def calculate_hmac(secret: str) -> str: + """Calculate HMAC for a given secret.""" + digest = hmac.new( + secret.encode("utf-8"), body.encode("utf-8"), hashlib.sha256 + ).digest() + return base64.b64encode(digest).decode("utf-8") + + # Try current secret first + calculated_hmac = calculate_hmac(client_secret) + hmac_valid = hmac.compare_digest(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) + + if not hmac_valid: + return ResultForReq( + ok=False, + shop=None, + log=LogWithReq( + code="invalid_hmac", + detail="`X-Shopify-Hmac-SHA256` header value does not match the body's HMAC. Respond 401 Unauthorized using the provided response.", + req=request, + ), + response=Res( + status=401, + body="Unauthorized", + headers={}, + ), + ) + + # Extract shop from header + shop_domain = normalized_headers.get("x-shopify-shop-domain", "") + # Extract shop by removing .myshopify.com suffix + shop = shop_domain.replace(".myshopify.com", "") if shop_domain else "" + + return ResultForReq( + ok=True, + shop=shop, + log=LogWithReq( + code="verified", + detail=f"{request_type} request verified successfully. Respond 200 OK using the provided response.", + req=request, + ), + response=Res( + status=200, + body="", + headers={}, + ), + ) diff --git a/shopify_app/verify/_non_exchangeable_id_token.py b/shopify_app/verify/_non_exchangeable_id_token.py new file mode 100644 index 0000000..19ec4db --- /dev/null +++ b/shopify_app/verify/_non_exchangeable_id_token.py @@ -0,0 +1,240 @@ +""" +Shared Non-Exchangeable ID Token Verification + +This module provides the core ID token verification logic used by both +Checkout UI Extension and Customer Account UI Extension request verification, +where a non-exchangeable ID token is provided in the Authorization header. +""" + +from __future__ import annotations + +import jwt +from jwt.exceptions import PyJWTError + +from ..types import ( + AppConfig, + IdTokenDetails, + LogWithReq, + RequestInput, + Res, + ResultWithNonExchangeableIdToken, +) +from ..utils.headers import _normalize_headers + + +def _verify_non_exchangeable_id_token( + request: RequestInput, config: AppConfig, request_type: str +) -> ResultWithNonExchangeableIdToken: + """ + Verifies non-exchangeable ID token requests from Shopify (Checkout UI Extensions, Customer Account UI Extensions, etc.) + + Args: + request (dict): The request object containing method, headers, url, and body + config (dict): The app configuration with client_id, client_secret and optional old_client_secret + request_type (str): The type of request for log messages (e.g., "Checkout UI Extension", "Customer Account UI Extension") + + Returns: + ResultWithNonExchangeableIdToken: Verification result with id_token details + """ + # Validate request object + method = request.get("method") + if not isinstance(method, str) or method == "": + return ResultWithNonExchangeableIdToken( + ok=False, + shop=None, + id_token=None, + log=LogWithReq( + code="configuration_error", + detail="Expected request.method to be a non-empty string", + req=request, + ), + response=Res( + status=500, + body="", + headers={}, + ), + ) + + headers = request.get("headers") + if not isinstance(headers, dict): + return ResultWithNonExchangeableIdToken( + ok=False, + shop=None, + id_token=None, + log=LogWithReq( + code="configuration_error", + detail="Expected request.headers to be an object", + req=request, + ), + response=Res( + status=500, + body="", + headers={}, + ), + ) + url = request.get("url", "") + + client_secret = config.get("client_secret", "") + old_client_secret = config.get("old_client_secret") + client_id = config.get("client_id", "") + + # Normalize headers for case-insensitive comparison + normalized_headers = _normalize_headers(headers) + + # Handle OPTIONS requests for CORS preflight + if method == "OPTIONS": + origin = normalized_headers.get("origin", "") + # If Origin is different from app URL, return CORS headers + if origin and origin != url: + return ResultWithNonExchangeableIdToken( + ok=True, + shop=None, + id_token=None, + log=LogWithReq( + code="options_request", + detail="OPTIONS request handled for CORS preflight. Respond 204 No Content using the provided response.", + req=request, + ), + response=Res( + status=204, + body="", + headers={ + "Access-Control-Max-Age": "7200", + "Access-Control-Allow-Origin": "*", + "Access-Control-Expose-Headers": "X-Shopify-API-Request-Failure-Reauthorize-Url", + "Access-Control-Allow-Headers": "Authorization, Content-Type", + }, + ), + ) + + # Check for Authorization header + if "authorization" not in normalized_headers: + return ResultWithNonExchangeableIdToken( + ok=False, + shop=None, + id_token=None, + log=LogWithReq( + code="missing_authorization_header", + detail="Required `Authorization` header is missing. Respond 401 Unauthorized using the provided response.", + req=request, + ), + response=Res( + status=401, + body="Unauthorized", + headers={}, + ), + ) + + # Extract the Bearer token + auth_header = normalized_headers.get("authorization", "") + if not auth_header.startswith("Bearer "): + return ResultWithNonExchangeableIdToken( + ok=False, + shop=None, + id_token=None, + log=LogWithReq( + code="invalid_id_token", + detail="ID token verification failed. Respond 401 Unauthorized using the provided response.", + req=request, + ), + response=Res( + status=401, + body="Unauthorized", + headers={}, + ), + ) + + id_token = auth_header[7:] # Remove "Bearer " prefix + + # Try to verify with old secret first (if provided), then new secret + payload = None + verification_error = None + + secrets_to_try = [] + if old_client_secret: + secrets_to_try.append(old_client_secret) + secrets_to_try.append(client_secret) + + for secret in secrets_to_try: + try: + payload = jwt.decode( + id_token, + secret, + algorithms=["HS256"], + leeway=10, # Clock tolerance of 10 seconds + options={ + "verify_aud": False, # We'll verify manually below + }, + ) + break # Successfully decoded + except PyJWTError as e: + verification_error = e + continue # Try next secret + + if payload is None: + # Determine if it was an expiration error + error_code = "invalid_id_token" + if verification_error and "expired" in str(verification_error).lower(): + error_code = "expired_id_token" + detail_msg = "ID token has expired. Respond 401 Unauthorized using the provided response." + else: + detail_msg = "ID token verification failed. Respond 401 Unauthorized using the provided response." + + return ResultWithNonExchangeableIdToken( + ok=False, + shop=None, + id_token=None, + log=LogWithReq( + code=error_code, + detail=detail_msg, + req=request, + ), + response=Res( + status=401, + body="Unauthorized", + headers={}, + ), + ) + + # Verify the audience claim matches the clientId + token_aud = payload.get("aud") + if token_aud != client_id: + return ResultWithNonExchangeableIdToken( + ok=False, + shop=None, + id_token=None, + log=LogWithReq( + code="invalid_aud", + detail="ID token audience (aud) claim does not match clientId. Respond 401 Unauthorized using the provided response.", + req=request, + ), + response=Res( + status=401, + body="Unauthorized", + headers={}, + ), + ) + + # Extract shop from dest claim + dest = payload.get("dest", "") + shop = dest.replace(".myshopify.com", "") if dest else "" + + return ResultWithNonExchangeableIdToken( + ok=True, + shop=shop, + id_token=IdTokenDetails( + exchangeable=False, + token=id_token, + claims=payload, + ), + log=LogWithReq( + code="verified", + detail=f"{request_type} request verified. Proceed with business logic.", + req=request, + ), + response=Res( + status=200, + body="", + headers={}, + ), + ) diff --git a/shopify_app/verify/admin_ui_ext.py b/shopify_app/verify/admin_ui_ext.py new file mode 100644 index 0000000..72dc6df --- /dev/null +++ b/shopify_app/verify/admin_ui_ext.py @@ -0,0 +1,256 @@ +""" +Shopify Admin UI Extension Verification + +This module provides functions to verify Shopify Admin UI Extension requests. +""" + +from __future__ import annotations + +import jwt +from jwt.exceptions import PyJWTError + +from ..types import ( + AppConfig, + IdTokenDetails, + LogWithReq, + RequestInput, + Res, + ResultWithExchangeableIdToken, +) +from ..utils.headers import _normalize_headers + + +def verify_admin_ui_ext_req( + request: RequestInput, config: AppConfig +) -> ResultWithExchangeableIdToken: + """ + Verifies requests coming from Shopify Admin UI Extensions. + + Args: + request (dict): The request object containing method, headers, url, and body + config (dict): The app configuration with client_id, client_secret and optional old_client_secret + + Returns: + ResultWithExchangeableIdToken: Verification result with exchangeable ID token + """ + # Validate request object + method = request.get("method") + if not isinstance(method, str) or method == "": + return ResultWithExchangeableIdToken( + ok=False, + shop=None, + log=LogWithReq( + code="configuration_error", + detail="Expected request.method to be a non-empty string", + req=request, + ), + response=Res(status=500, body="", headers={}), + user_id=None, + id_token=None, + new_id_token_response=None, + ) + + headers = request.get("headers") + if not isinstance(headers, dict): + return ResultWithExchangeableIdToken( + ok=False, + shop=None, + log=LogWithReq( + code="configuration_error", + detail="Expected request.headers to be an object", + req=request, + ), + response=Res(status=500, body="", headers={}), + user_id=None, + id_token=None, + new_id_token_response=None, + ) + + url = request.get("url") + if not isinstance(url, str) or url == "": + return ResultWithExchangeableIdToken( + ok=False, + shop=None, + log=LogWithReq( + code="configuration_error", + detail="Expected request.url to be a non-empty string", + req=request, + ), + response=Res(status=500, body="", headers={}), + user_id=None, + id_token=None, + new_id_token_response=None, + ) + + client_secret = config.get("client_secret", "") + old_client_secret = config.get("old_client_secret") + client_id = config.get("client_id", "") + + # Normalize headers for case-insensitive comparison + normalized_headers = _normalize_headers(headers) + + # Handle OPTIONS requests for CORS preflight + if method == "OPTIONS": + origin = normalized_headers.get("origin", "") + # If Origin is different from app URL, return CORS headers + if origin and origin != url: + return ResultWithExchangeableIdToken( + ok=True, + shop=None, + log=LogWithReq( + code="options_request", + detail="OPTIONS request handled for CORS preflight. Respond 204 No Content using the provided response.", + req=request, + ), + response=Res( + status=204, + body="", + headers={ + "Access-Control-Max-Age": "7200", + "Access-Control-Allow-Origin": "*", + "Access-Control-Expose-Headers": "X-Shopify-Retry-Invalid-Session-Request", + "Access-Control-Allow-Headers": "Authorization, Content-Type", + }, + ), + user_id=None, + id_token=None, + new_id_token_response=None, + ) + + # Check for Authorization header + if "authorization" not in normalized_headers: + return ResultWithExchangeableIdToken( + ok=False, + shop=None, + log=LogWithReq( + code="missing_authorization_header", + detail="Required `Authorization` header is missing. Respond 401 Unauthorized using the provided response.", + req=request, + ), + response=Res(status=401, body="Unauthorized", headers={}), + user_id=None, + id_token=None, + new_id_token_response=None, + ) + + # Extract the Bearer token + auth_header = normalized_headers.get("authorization", "") + if not auth_header.startswith("Bearer "): + return ResultWithExchangeableIdToken( + ok=False, + shop=None, + log=LogWithReq( + code="invalid_id_token", + detail="ID token verification failed. Respond 401 Unauthorized using the provided response.", + req=request, + ), + response=Res( + status=401, + body="Unauthorized", + headers={"X-Shopify-Retry-Invalid-Session-Request": "1"}, + ), + user_id=None, + id_token=None, + new_id_token_response=None, + ) + + id_token = auth_header[7:] # Remove "Bearer " prefix + + # Try to verify with old secret first (if provided), then new secret + payload = None + verification_error = None + + secrets_to_try = [] + if old_client_secret: + secrets_to_try.append(old_client_secret) + secrets_to_try.append(client_secret) + + for secret in secrets_to_try: + try: + payload = jwt.decode( + id_token, + secret, + algorithms=["HS256"], + leeway=10, # Clock tolerance of 10 seconds + options={ + # We need to verify the audience claim for Admin UI Extension tokens + "verify_aud": False, # We'll manually verify below + }, + ) + break # Successfully decoded + except PyJWTError as e: + verification_error = e + continue # Try next secret + + if payload is None: + # Determine if it was an expiration error + error_code = "invalid_id_token" + if verification_error and "expired" in str(verification_error).lower(): + error_code = "expired_id_token" + detail_msg = "ID token has expired. Respond 401 Unauthorized using the provided response." + else: + detail_msg = "ID token verification failed. Respond 401 Unauthorized using the provided response." + + return ResultWithExchangeableIdToken( + ok=False, + shop=None, + log=LogWithReq(code=error_code, detail=detail_msg, req=request), + response=Res( + status=401, + body="Unauthorized", + headers={"X-Shopify-Retry-Invalid-Session-Request": "1"}, + ), + user_id=None, + id_token=None, + new_id_token_response=None, + ) + + # Verify the audience (aud) matches the clientId + token_aud = payload.get("aud", "") + if token_aud != client_id: + return ResultWithExchangeableIdToken( + ok=False, + shop=None, + log=LogWithReq( + code="invalid_aud", + detail="ID token audience (aud) claim does not match clientId. Respond 401 Unauthorized using the provided response.", + req=request, + ), + response=Res( + status=401, + body="Unauthorized", + headers={"X-Shopify-Retry-Invalid-Session-Request": "1"}, + ), + user_id=None, + id_token=None, + new_id_token_response=None, + ) + + # Extract shop from dest claim + dest = payload.get("dest", "") + shop = dest.replace("https://", "").replace(".myshopify.com", "") if dest else "" + + # Extract user_id from sub claim + user_id = payload.get("sub") + + return ResultWithExchangeableIdToken( + ok=True, + shop=shop, + log=LogWithReq( + code="verified", + detail="Admin UI Extension request verified. Proceed with business logic.", + req=request, + ), + response=Res(status=200, body="", headers={}), + user_id=user_id, + id_token=IdTokenDetails( + exchangeable=True, + token=id_token, + claims=payload, + ), + new_id_token_response=Res( + status=401, + body="", + headers={"X-Shopify-Retry-Invalid-Session-Request": "1"}, + ), + ) diff --git a/shopify_app/verify/app_home_req.py b/shopify_app/verify/app_home_req.py new file mode 100644 index 0000000..6738e33 --- /dev/null +++ b/shopify_app/verify/app_home_req.py @@ -0,0 +1,421 @@ +""" +Shopify App Home Request Verification + +This module provides functions to verify requests from Shopify App Home. +""" + +from __future__ import annotations + +from typing import Dict +from urllib.parse import ParseResult, parse_qs, quote, urlparse + +import jwt +from jwt.exceptions import PyJWTError + +from ..types import ( + AppConfig, + IdTokenDetails, + LogWithReq, + RequestInput, + Res, + ResultWithExchangeableIdToken, +) +from ..utils.headers import _normalize_headers + + +def _build_patch_id_token_redirect( + parsed_url: ParseResult, + path: str, + query_params: Dict[str, str], + app_home_patch_id_token_path: str, + request: RequestInput, +) -> ResultWithExchangeableIdToken: + """ + Helper to build a response redirecting to the patch id token URL. + + Args: + parsed_url: Parsed URL object from urlparse + path: Request path + query_params: Dictionary of query parameters + app_home_patch_id_token_path: Path to the patch id token page + request: The original request object + + Returns: + ResultWithExchangeableIdToken: Redirect response with 302 status and Location header + """ + clean_params = query_params.copy() + clean_params.pop("id_token", None) + + # Build reload path with query string (preserve base64 = padding) + reload_parts = [f"{key}={value}" for key, value in clean_params.items()] + reload_query = "&".join(reload_parts) + reload_path = path + ("?" + reload_query if reload_query else "") + + # Build patch id token URL with shopify-reload parameter + patch_id_token_query_parts = [ + f"{key}={value}" for key, value in clean_params.items() + ] + patch_id_token_query_parts.append(f"shopify-reload={quote(reload_path, safe='')}") + patch_id_token_query = "&".join(patch_id_token_query_parts) + + patch_id_token_location = f"{parsed_url.scheme}://{parsed_url.netloc}{app_home_patch_id_token_path}?{patch_id_token_query}" + + return ResultWithExchangeableIdToken( + ok=False, + shop=None, + log=LogWithReq( + code="redirect_to_patch_id_token_page", + detail="Embedded app without id_token. Redirect to the patch ID token page to obtain a new token using the provided response.", + req=request, + ), + response=Res( + status=302, + body="", + headers={"Location": patch_id_token_location}, + ), + user_id=None, + id_token=None, + new_id_token_response=None, + ) + + +def verify_app_home_req( + request: RequestInput, + config: AppConfig, + app_home_patch_id_token_path: str, +) -> ResultWithExchangeableIdToken: + """ + Verifies requests coming from Shopify App Home. + + Args: + request (dict): The request object containing method, headers, url, and body + config (dict): The app configuration with client_id, client_secret and optional old_client_secret + app_home_patch_id_token_path (str): Path to the patch ID token page + + Returns: + ResultWithExchangeableIdToken: Verification result with exchangeable ID token + """ + # Validate app_home_patch_id_token_path + if not isinstance(app_home_patch_id_token_path, str): + return ResultWithExchangeableIdToken( + ok=False, + shop=None, + log=LogWithReq( + code="configuration_error", + detail="Expected appHomePatchIdTokenPath to be a non-empty string", + req=request, + ), + response=Res( + status=500, + body="", + headers={}, + ), + user_id=None, + id_token=None, + new_id_token_response=None, + ) + + if app_home_patch_id_token_path == "": + return ResultWithExchangeableIdToken( + ok=False, + shop=None, + log=LogWithReq( + code="configuration_error", + detail="Expected appHomePatchIdTokenPath to be a non-empty string, but got ''", + req=request, + ), + response=Res( + status=500, + body="", + headers={}, + ), + user_id=None, + id_token=None, + new_id_token_response=None, + ) + + # Validate request object + url = request.get("url") + if not isinstance(url, str) or url == "": + return ResultWithExchangeableIdToken( + ok=False, + shop=None, + log=LogWithReq( + code="configuration_error", + detail="Expected request.url to be a non-empty string", + req=request, + ), + response=Res( + status=500, + body="", + headers={}, + ), + user_id=None, + id_token=None, + new_id_token_response=None, + ) + + headers = request.get("headers") + if not isinstance(headers, dict): + return ResultWithExchangeableIdToken( + ok=False, + shop=None, + log=LogWithReq( + code="configuration_error", + detail="Expected request.headers to be an object", + req=request, + ), + response=Res( + status=500, + body="", + headers={}, + ), + user_id=None, + id_token=None, + new_id_token_response=None, + ) + + client_secret = config.get("client_secret", "") + old_client_secret = config.get("old_client_secret") + client_id = config.get("client_id", "") + + # Normalize headers for case-insensitive comparison + normalized_headers = _normalize_headers(headers) + + # Parse URL for query parameters + parsed_url = urlparse(url) + path = parsed_url.path + query_dict = parse_qs(parsed_url.query, keep_blank_values=True) + + # Flatten query params (parse_qs returns lists) + query_params = {} + for key, value_list in query_dict.items(): + query_params[key] = value_list[0] if value_list else "" + + # Check for Authorization header to determine request type + auth_header = normalized_headers.get("authorization", "") + has_authorization_header = bool(auth_header) + + id_token = None + + # If no Authorization header, check if this is a document request + if not has_authorization_header: + id_token_param = query_params.get("id_token", "") + + # If no id_token, redirect to patch ID token page + if not id_token_param: + return _build_patch_id_token_redirect( + parsed_url, path, query_params, app_home_patch_id_token_path, request + ) + + id_token = id_token_param + else: + if not auth_header.startswith("Bearer "): + return ResultWithExchangeableIdToken( + ok=False, + shop=None, + log=LogWithReq( + code="invalid_id_token", + detail="ID token verification failed. Respond 401 Unauthorized using the provided response.", + req=request, + ), + response=Res( + status=401, + body="Unauthorized", + headers={ + "X-Shopify-Retry-Invalid-Session-Request": "1", + }, + ), + user_id=None, + id_token=None, + new_id_token_response=None, + ) + id_token = auth_header[7:] # Remove "Bearer " prefix + + if not id_token: + return ResultWithExchangeableIdToken( + ok=False, + shop=None, + log=LogWithReq( + code="missing_authorization_and_id_token", + detail="Neither Authorization header nor id_token query parameter present. Respond 401 Unauthorized using the provided response.", + req=request, + ), + response=Res( + status=401, + body="Unauthorized", + headers={}, + ), + user_id=None, + id_token=None, + new_id_token_response=None, + ) + + payload = None + verification_error = None + + secrets_to_try = [] + if old_client_secret: + secrets_to_try.append(old_client_secret) + secrets_to_try.append(client_secret) + + for secret in secrets_to_try: + try: + payload = jwt.decode( + id_token, + secret, + algorithms=["HS256"], + leeway=10, # Clock tolerance of 10 seconds + options={ + "verify_aud": False, + }, + ) + break + except PyJWTError as e: + verification_error = e + continue # Try next secret + + if payload is None: + # For document requests with invalid/stale tokens, redirect to patch ID token page + if not has_authorization_header: + return _build_patch_id_token_redirect( + parsed_url, path, query_params, app_home_patch_id_token_path, request + ) + + # For fetch requests, return 401 with retry header + error_code = "invalid_id_token" + if verification_error and "expired" in str(verification_error).lower(): + error_code = "expired_id_token" + detail_msg = "ID token has expired. Respond 401 Unauthorized using the provided response." + else: + detail_msg = "ID token verification failed. Respond 401 Unauthorized using the provided response." + + return ResultWithExchangeableIdToken( + ok=False, + shop=None, + log=LogWithReq( + code=error_code, + detail=detail_msg, + req=request, + ), + response=Res( + status=401, + body="Unauthorized", + headers={ + "X-Shopify-Retry-Invalid-Session-Request": "1", + }, + ), + user_id=None, + id_token=None, + new_id_token_response=None, + ) + + # Verify the audience (aud) matches the clientId + token_aud = payload.get("aud", "") + if token_aud != client_id: + # For Authorization header requests, include retry header + response_headers = {} + if has_authorization_header: + response_headers = { + "X-Shopify-Retry-Invalid-Session-Request": "1", + } + + return ResultWithExchangeableIdToken( + ok=False, + shop=None, + log=LogWithReq( + code="invalid_aud", + detail="ID token audience (aud) claim does not match clientId. Respond 401 Unauthorized using the provided response.", + req=request, + ), + response=Res( + status=401, + body="Unauthorized", + headers=response_headers, + ), + user_id=None, + id_token=None, + new_id_token_response=None, + ) + + # Extract shop from dest claim (parse as URL and get hostname) + dest = payload.get("dest", "") + dest_parts = urlparse(dest) + shop_hostname = dest_parts.hostname if dest_parts.hostname else dest + shop = shop_hostname.replace(".myshopify.com", "") + + # Extract user_id from sub claim + user_id = payload.get("sub") + + # For document requests, add security and preload headers + response_headers = {} + if not has_authorization_header: + response_headers = { + "Content-Security-Policy": f"frame-ancestors https://{shop_hostname} https://admin.shopify.com;", + "Link": '; rel="preconnect", ; rel="preload"; as="script", ; rel="preload"; as="script"', + } + + # Build new_id_token_response + new_id_token_response = None + if not has_authorization_header: + # Document request - build patch ID token URL + clean_params = query_params.copy() + clean_params.pop("id_token", None) + + reload_parts = [f"{key}={value}" for key, value in clean_params.items()] + reload_query = "&".join(reload_parts) + reload_path = path + ("?" + reload_query if reload_query else "") + + patch_id_token_query_parts = [ + f"{key}={value}" for key, value in clean_params.items() + ] + patch_id_token_query_parts.append( + f"shopify-reload={quote(reload_path, safe='')}" + ) + patch_id_token_query = "&".join(patch_id_token_query_parts) + + patch_id_token_location = f"{parsed_url.scheme}://{parsed_url.netloc}{app_home_patch_id_token_path}?{patch_id_token_query}" + + new_id_token_response = Res( + status=302, + body="", + headers={ + "Location": patch_id_token_location, + }, + ) + else: + # Fetch request + new_id_token_response = Res( + status=401, + body="", + headers={ + "X-Shopify-Retry-Invalid-Session-Request": "1", + }, + ) + + # Build log detail message + log_detail = "App Home request verified. Proceed with business logic." + if not has_authorization_header: + log_detail += " Include the headers in the provided response." + + return ResultWithExchangeableIdToken( + ok=True, + shop=shop, + log=LogWithReq( + code="verified", + detail=log_detail, + req=request, + ), + response=Res( + status=200, + body="", + headers=response_headers, + ), + user_id=user_id, + id_token=IdTokenDetails( + exchangeable=True, + token=id_token, + claims=payload, + ), + new_id_token_response=new_id_token_response, + ) diff --git a/shopify_app/verify/app_proxy.py b/shopify_app/verify/app_proxy.py new file mode 100644 index 0000000..07de0d4 --- /dev/null +++ b/shopify_app/verify/app_proxy.py @@ -0,0 +1,252 @@ +""" +Shopify App Proxy Verification + +This module provides functions to verify Shopify App Proxy requests. +""" + +from __future__ import annotations + +import hashlib +import hmac +import time +from typing import Any, Dict, List, Optional, Union +from urllib.parse import parse_qs, urlparse + +from ..types import ( + AppConfig, + LogWithReq, + RequestInput, + Res, + ResultWithLoggedInCustomerId, +) + + +def verify_app_proxy_req( + request: RequestInput, config: AppConfig +) -> ResultWithLoggedInCustomerId: + """ + Verifies requests coming from Shopify App Proxy. + + Args: + request (dict): The request object containing method, headers, url, and body + config (dict): The app configuration with client_secret and optional old_client_secret + + Returns: + ResultWithLoggedInCustomerId: Verification result with logged_in_customer_id + """ + # Validate request object + url = request.get("url") + if not isinstance(url, str) or url == "": + return ResultWithLoggedInCustomerId( + ok=False, + shop=None, + logged_in_customer_id=None, + log=LogWithReq( + code="configuration_error", + detail="Expected request.url to be a non-empty string", + req=request, + ), + response=Res( + status=500, + body="", + headers={}, + ), + ) + + client_secret = config.get("client_secret", "") + old_client_secret = config.get("old_client_secret") + + # Parse query parameters from URL + parsed_url = urlparse(url) + query_params = parse_qs(parsed_url.query, keep_blank_values=True) + + # Convert lists to single values (parse_qs returns lists) + # Keep arrays as arrays if they have multiple values + params: Dict[str, Union[str, List[str]]] = {} + for key, value in query_params.items(): + if len(value) == 1: + params[key] = value[0] + else: + params[key] = value + + # Check for missing timestamp + if "timestamp" not in params: + return ResultWithLoggedInCustomerId( + ok=False, + shop=None, + logged_in_customer_id=None, + log=LogWithReq( + code="missing_timestamp", + detail="Required `timestamp` query parameter is missing. Respond 401 Unauthorized using the provided response.", + req=request, + ), + response=Res( + status=401, + body="Unauthorized", + headers={}, + ), + ) + + # Check timestamp is not too old (prevents replay attacks) + try: + timestamp_val = params["timestamp"] + timestamp = int( + timestamp_val if isinstance(timestamp_val, str) else timestamp_val[0] + ) + current_time = int(time.time()) + time_diff = abs(current_time - timestamp) + + if time_diff > 90: + return ResultWithLoggedInCustomerId( + ok=False, + shop=None, + logged_in_customer_id=None, + log=LogWithReq( + code="timestamp_too_old", + detail="The `timestamp` query parameter is more than 90 seconds old. Respond 401 Unauthorized using the provided response.", + req=request, + ), + response=Res( + status=401, + body="Unauthorized", + headers={}, + ), + ) + except (ValueError, TypeError): + return ResultWithLoggedInCustomerId( + ok=False, + shop=None, + logged_in_customer_id=None, + log=LogWithReq( + code="invalid_timestamp", + detail="The `timestamp` query parameter is not a valid integer. Respond 401 Unauthorized using the provided response.", + req=request, + ), + response=Res( + status=401, + body="Unauthorized", + headers={}, + ), + ) + + # Check for missing signature + if "signature" not in params or not isinstance(params["signature"], str): + return ResultWithLoggedInCustomerId( + ok=False, + shop=None, + logged_in_customer_id=None, + log=LogWithReq( + code="missing_signature", + detail="Required `signature` query parameter is missing. Respond 401 Unauthorized using the provided response.", + req=request, + ), + response=Res( + status=401, + body="Unauthorized", + headers={}, + ), + ) + + # Extract and remove signature from params + sig_val = params.pop("signature") + received_signature = sig_val if isinstance(sig_val, str) else sig_val[0] + + # Generate param string + param_string = _generate_param_string(params) + + # Calculate HMAC + def calculate_hmac_hex(secret: str) -> str: + """Calculate HMAC-SHA256 in hexadecimal format.""" + return hmac.new( + secret.encode("utf-8"), param_string.encode("utf-8"), hashlib.sha256 + ).hexdigest() + + # Try current secret first + calculated_hmac = calculate_hmac_hex(client_secret) + signature_valid = hmac.compare_digest(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) + + if not signature_valid: + return ResultWithLoggedInCustomerId( + ok=False, + shop=None, + logged_in_customer_id=None, + log=LogWithReq( + code="invalid_signature", + detail="`signature` query parameter does not match the expected HMAC. Respond 401 Unauthorized using the provided response.", + req=request, + ), + response=Res( + status=401, + body="Unauthorized", + headers={}, + ), + ) + + # Extract shop by removing .myshopify.com suffix from the shop param + shop_domain = params.get("shop", "") + shop: Optional[str] = ( + shop_domain.replace(".myshopify.com", "") + if isinstance(shop_domain, str) + else None + ) + if not shop: + shop = None + + # Extract logged in customer ID + logged_in_customer_id_val = params.get("logged_in_customer_id") + logged_in_customer_id: Optional[str] = ( + logged_in_customer_id_val + if isinstance(logged_in_customer_id_val, str) + else (logged_in_customer_id_val[0] if logged_in_customer_id_val else None) + ) + + return ResultWithLoggedInCustomerId( + ok=True, + shop=shop, + logged_in_customer_id=logged_in_customer_id, + log=LogWithReq( + code="verified", + detail="App Proxy request verified successfully. Proceed with business logic.", + req=request, + ), + response=Res( + status=200, + body="", + headers={}, + ), + ) + + +def _generate_param_string(params: Dict[str, Any]) -> str: + """ + Generate the param string for HMAC calculation. + + Alphabetically sorts params and stringifies them according to Shopify's spec: + - Separate key & value using = + - No separators between key-value pairs + - Array values are comma-separated + + Args: + params (dict): Query parameters (without signature) + + Returns: + str: Stringified params for HMAC calculation + """ + # Sort params alphabetically by key + sorted_params = sorted(params.items()) + + # Build param string + param_string = "" + for key, value in sorted_params: + if isinstance(value, list): + # Arrays are stringified as comma-separated values + param_string += f"{key}={','.join(value)}" + else: + param_string += f"{key}={value}" + + return param_string diff --git a/shopify_app/verify/checkout_ui_ext.py b/shopify_app/verify/checkout_ui_ext.py new file mode 100644 index 0000000..bb77f69 --- /dev/null +++ b/shopify_app/verify/checkout_ui_ext.py @@ -0,0 +1,26 @@ +""" +Shopify Checkout UI Extension Verification + +This module provides functions to verify Shopify Checkout UI Extension requests. +""" + +from __future__ import annotations + +from ..types import AppConfig, RequestInput, ResultWithNonExchangeableIdToken +from ._non_exchangeable_id_token import _verify_non_exchangeable_id_token + + +def verify_checkout_ui_ext_req( + request: RequestInput, config: AppConfig +) -> ResultWithNonExchangeableIdToken: + """ + Verifies requests coming from Shopify Checkout UI Extensions. + + Args: + request (dict): The request object containing method, headers, url, and body + config (dict): The app configuration with client_id, client_secret and optional old_client_secret + + Returns: + ResultWithNonExchangeableIdToken: Verification result with id_token details + """ + return _verify_non_exchangeable_id_token(request, config, "Checkout UI Extension") diff --git a/shopify_app/verify/customer_account_ui_ext.py b/shopify_app/verify/customer_account_ui_ext.py new file mode 100644 index 0000000..16b25d5 --- /dev/null +++ b/shopify_app/verify/customer_account_ui_ext.py @@ -0,0 +1,28 @@ +""" +Shopify Customer Account UI Extension Verification + +This module provides functions to verify Shopify Customer Account UI Extension requests. +""" + +from __future__ import annotations + +from ..types import AppConfig, RequestInput, ResultWithNonExchangeableIdToken +from ._non_exchangeable_id_token import _verify_non_exchangeable_id_token + + +def verify_customer_account_ui_ext_req( + request: RequestInput, config: AppConfig +) -> ResultWithNonExchangeableIdToken: + """ + Verifies requests coming from Shopify Customer Account UI Extensions. + + Args: + request (dict): The request object containing method, headers, url, and body + config (dict): The app configuration with client_id, client_secret and optional old_client_secret + + Returns: + ResultWithNonExchangeableIdToken: Verification result with id_token details + """ + return _verify_non_exchangeable_id_token( + request, config, "Customer Account UI Extension" + ) diff --git a/shopify_app/verify/flow_action.py b/shopify_app/verify/flow_action.py new file mode 100644 index 0000000..51fad15 --- /dev/null +++ b/shopify_app/verify/flow_action.py @@ -0,0 +1,24 @@ +""" +Shopify Flow Action Request Verification + +This module provides functions to verify Shopify Flow action requests. +""" + +from __future__ import annotations + +from ..types import AppConfig, RequestInput, ResultForReq +from ._body_hmac_in_header import _verify_body_hmac_in_header + + +def verify_flow_action_req(request: RequestInput, config: AppConfig) -> ResultForReq: + """ + Verifies requests coming from Shopify Flow actions. + + Args: + request (dict): The request object containing method, headers, and body + config (dict): The app configuration with client_secret + + Returns: + ResultForReq: Verification result with ok, shop, log, and response fields + """ + return _verify_body_hmac_in_header(request, config, "Flow action") diff --git a/shopify_app/verify/pos_ui_ext.py b/shopify_app/verify/pos_ui_ext.py new file mode 100644 index 0000000..5c4aaeb --- /dev/null +++ b/shopify_app/verify/pos_ui_ext.py @@ -0,0 +1,275 @@ +""" +Shopify POS UI Extension Verification + +This module provides functions to verify Shopify POS UI Extension requests. +""" + +from __future__ import annotations + +import jwt +from jwt.exceptions import PyJWTError + +from ..types import ( + AppConfig, + IdTokenDetails, + LogWithReq, + RequestInput, + Res, + ResultWithExchangeableIdToken, +) +from ..utils.headers import _normalize_headers + + +def verify_pos_ui_ext_req( + request: RequestInput, config: AppConfig +) -> ResultWithExchangeableIdToken: + """ + Verifies requests coming from Shopify POS UI Extensions. + + Args: + request (dict): The request object containing method, headers, url, and body + config (dict): The app configuration with client_id, client_secret and optional old_client_secret + + Returns: + ResultWithExchangeableIdToken: Verification result with ok, shop, log, response, user_id, id_token, and new_id_token_response fields + """ + # Validate request object + method = request.get("method") + if not isinstance(method, str) or method == "": + return ResultWithExchangeableIdToken( + ok=False, + shop=None, + log=LogWithReq( + code="configuration_error", + detail="Expected request.method to be a non-empty string", + req=request, + ), + response=Res( + status=500, + body="", + headers={}, + ), + user_id=None, + id_token=None, + new_id_token_response=None, + ) + + headers = request.get("headers") + if not isinstance(headers, dict): + return ResultWithExchangeableIdToken( + ok=False, + shop=None, + log=LogWithReq( + code="configuration_error", + detail="Expected request.headers to be an object", + req=request, + ), + response=Res( + status=500, + body="", + headers={}, + ), + user_id=None, + id_token=None, + new_id_token_response=None, + ) + + url = request.get("url") + if not isinstance(url, str) or url == "": + return ResultWithExchangeableIdToken( + ok=False, + shop=None, + log=LogWithReq( + code="configuration_error", + detail="Expected request.url to be a non-empty string", + req=request, + ), + response=Res( + status=500, + body="", + headers={}, + ), + user_id=None, + id_token=None, + new_id_token_response=None, + ) + + client_id = config.get("client_id", "") + client_secret = config.get("client_secret", "") + old_client_secret = config.get("old_client_secret") + + # Normalize headers for case-insensitive comparison + normalized_headers = _normalize_headers(headers) + + # Handle OPTIONS requests for CORS preflight + if method == "OPTIONS": + origin = normalized_headers.get("origin", "") + # If Origin is different from app URL, return CORS headers + if origin and origin != url: + return ResultWithExchangeableIdToken( + ok=True, + shop=None, + log=LogWithReq( + code="options_request", + detail="OPTIONS request handled for CORS preflight. Respond 204 No Content using the provided response.", + req=request, + ), + response=Res( + status=204, + body="", + headers={ + "Access-Control-Max-Age": "7200", + "Access-Control-Allow-Origin": "*", + "Access-Control-Expose-Headers": "X-Shopify-API-Request-Failure-Reauthorize-Url", + "Access-Control-Allow-Headers": "Authorization, Content-Type", + }, + ), + user_id=None, + id_token=None, + new_id_token_response=None, + ) + + # Check for Authorization header + if "authorization" not in normalized_headers: + return ResultWithExchangeableIdToken( + ok=False, + shop=None, + log=LogWithReq( + code="missing_authorization_header", + detail="Required `Authorization` header is missing. Respond 401 Unauthorized using the provided response.", + req=request, + ), + response=Res( + status=401, + body="Unauthorized", + headers={}, + ), + user_id=None, + id_token=None, + new_id_token_response=None, + ) + + # Extract the Bearer token + auth_header = normalized_headers.get("authorization", "") + if not auth_header.startswith("Bearer "): + return ResultWithExchangeableIdToken( + ok=False, + shop=None, + log=LogWithReq( + code="invalid_id_token", + detail="ID token verification failed. Respond 401 Unauthorized using the provided response.", + req=request, + ), + response=Res( + status=401, + body="Unauthorized", + headers={}, + ), + user_id=None, + id_token=None, + new_id_token_response=None, + ) + + id_token = auth_header[7:] # Remove "Bearer " prefix + + # Try to verify with old secret first (if provided), then new secret + payload = None + verification_error = None + + secrets_to_try = [] + if old_client_secret: + secrets_to_try.append(old_client_secret) + secrets_to_try.append(client_secret) + + for secret in secrets_to_try: + try: + payload = jwt.decode( + id_token, + secret, + algorithms=["HS256"], + leeway=10, # Clock tolerance of 10 seconds + options={ + "verify_aud": False, + }, + ) + break # Successfully decoded + except PyJWTError as e: + verification_error = e + continue # Try next secret + + if payload is None: + # Determine if it was an expiration error + error_code = "invalid_id_token" + if verification_error and "expired" in str(verification_error).lower(): + error_code = "expired_id_token" + detail_msg = "ID token has expired. Respond 401 Unauthorized using the provided response." + else: + detail_msg = "ID token verification failed. Respond 401 Unauthorized using the provided response." + + return ResultWithExchangeableIdToken( + ok=False, + shop=None, + log=LogWithReq( + code=error_code, + detail=detail_msg, + req=request, + ), + response=Res( + status=401, + body="Unauthorized", + headers={}, + ), + user_id=None, + id_token=None, + new_id_token_response=None, + ) + + # Verify the audience (aud) matches clientId + aud = payload.get("aud", "") + if aud != client_id: + return ResultWithExchangeableIdToken( + ok=False, + shop=None, + log=LogWithReq( + code="invalid_aud", + detail="ID token audience (aud) claim does not match clientId. Respond 401 Unauthorized using the provided response.", + req=request, + ), + response=Res( + status=401, + body="Unauthorized", + headers={}, + ), + user_id=None, + id_token=None, + new_id_token_response=None, + ) + + # Extract shop from dest claim (format: https://shop-name.myshopify.com) + dest = payload.get("dest", "") + shop = dest.replace("https://", "").replace(".myshopify.com", "") if dest else "" + + # Extract user_id from sub claim + user_id = payload.get("sub") + + return ResultWithExchangeableIdToken( + ok=True, + shop=shop, + log=LogWithReq( + code="verified", + detail="POS UI Extension request verified. Proceed with business logic.", + req=request, + ), + response=Res( + status=200, + body="", + headers={}, + ), + user_id=user_id, + id_token=IdTokenDetails( + exchangeable=True, + token=id_token, + claims=payload, + ), + new_id_token_response=None, + ) diff --git a/shopify_app/verify/webhook.py b/shopify_app/verify/webhook.py new file mode 100644 index 0000000..7154a16 --- /dev/null +++ b/shopify_app/verify/webhook.py @@ -0,0 +1,24 @@ +""" +Shopify Webhook Verification + +This module provides functions to verify Shopify webhook requests. +""" + +from __future__ import annotations + +from ..types import AppConfig, RequestInput, ResultForReq +from ._body_hmac_in_header import _verify_body_hmac_in_header + + +def verify_webhook_req(request: RequestInput, config: AppConfig) -> ResultForReq: + """ + Verifies requests coming from Shopify Webhooks. + + Args: + request (dict): The request object containing method, headers, and body + config (dict): The app configuration with client_secret + + Returns: + ResultForReq: Verification result with ok, shop, log, and response fields + """ + return _verify_body_hmac_in_header(request, config, "Webhook") From 18dd67314bab4d8caa6ce3b888652d9c14d620e2 Mon Sep 17 00:00:00 2001 From: Richard Powell Date: Thu, 8 Jan 2026 11:44:05 -0500 Subject: [PATCH 2/2] Add more GitHub Config --- .github/CODEOWNERS | 1 + .github/CODE_OF_CONDUCT.md | 73 ++++++++++++++++++++++ CONTRIBUTING.md => .github/CONTRIBUTING.md | 0 .github/workflows/close-external-prs.yml | 2 +- README.md | 2 +- 5 files changed, 76 insertions(+), 2 deletions(-) create mode 100644 .github/CODEOWNERS create mode 100644 .github/CODE_OF_CONDUCT.md rename CONTRIBUTING.md => .github/CONTRIBUTING.md (100%) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..0445d6e --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @shopify/client-libraries-app-templates diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..b22ab47 --- /dev/null +++ b/.github/CODE_OF_CONDUCT.md @@ -0,0 +1,73 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as +contributors and maintainers pledge to making participation in our project and +our community a harassment-free experience for everyone, regardless of age, body +size, disability, ethnicity, gender identity and expression, level of experience, +nationality, personal appearance, race, religion, or sexual identity and +orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment +include: + +- Using welcoming and inclusive language +- Being respectful of differing viewpoints and experiences +- Gracefully accepting constructive criticism +- Focusing on what is best for the community +- Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +- The use of sexualized language or imagery and unwelcome sexual attention or + advances +- Trolling, insulting/derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or electronic + address, without explicit permission +- Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable +behavior and are expected to take appropriate and fair corrective action in +response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or +reject comments, commits, code, wiki edits, issues, and other contributions +that are not aligned to this Code of Conduct, or to ban temporarily or +permanently any contributor for other behaviors that they deem inappropriate, +threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces +when an individual is representing the project or its community. Examples of +representing a project or community include using an official project e-mail +address, posting via an official social media account, or acting as an appointed +representative at an online or offline event. Representation of a project may be +further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported by contacting the project team at opensource@shopify.com. All +complaints will be reviewed and investigated and will result in a response that +is deemed necessary and appropriate to the circumstances. The project team is +obligated to maintain confidentiality with regard to the reporter of an incident. +Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good +faith may face temporary or permanent repercussions as determined by other +members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, +available at https://www.contributor-covenant.org/version/1/4/code-of-conduct/ + +[homepage]: https://www.contributor-covenant.org diff --git a/CONTRIBUTING.md b/.github/CONTRIBUTING.md similarity index 100% rename from CONTRIBUTING.md rename to .github/CONTRIBUTING.md diff --git a/.github/workflows/close-external-prs.yml b/.github/workflows/close-external-prs.yml index ad6f154..a34016b 100644 --- a/.github/workflows/close-external-prs.yml +++ b/.github/workflows/close-external-prs.yml @@ -54,7 +54,7 @@ To report a bug, request a feature, or share feedback, please post in the [Shopi We triage in the forums, not in this repo. PRs and issues here are closed without review. -For more details see [CONTRIBUTING.md](https://github.com/Shopify/shopify-app-python/blob/main/CONTRIBUTING.md).` +For more details see [CONTRIBUTING.md](https://github.com/Shopify/shopify-app-python?tab=contributing-ov-file).` }); // Close the PR diff --git a/README.md b/README.md index 3fce1a8..e9305f4 100644 --- a/README.md +++ b/README.md @@ -745,7 +745,7 @@ This package does not accept contributions, but we'd love to hear your feedback. To report a bug, request a feature, or share feedback, post in the [Shopify dev community forums](https://community.shopify.dev/c/shopify-cli-libraries/14). Please don’t open pull requests or GitHub issues here; They will be closed automatically. -We triage and discuss work in the forums. Please see [CONTRIBUTING.md](https://github.com/Shopify/shopify-app-python/blob/main/CONTRIBUTING.md) for details. +We triage and discuss work in the forums. Please see [CONTRIBUTING.md](https://github.com/Shopify/shopify-app-python?tab=contributing-ov-file) for details. ## Created a template?