diff --git a/CHANGELOG.md b/CHANGELOG.md index e88ef53..eba403c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,11 +5,22 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.0.0] + +- **Breaking:** rename the verify result field `new_id_token_response` to `invalid_token_response`, matching the `exchange_using_token_exchange` and `admin_graphql_request` parameters. Update any code that reads this field: + + ```diff + - result.new_id_token_response + + result.invalid_token_response + ``` + ## [0.1.4] - Verify the dest property is not a malicious URL before making a token exchange request - Reject App Proxy requests with multiple `shop` query parameters with a 401 response. - Refreshing a non-expiring token now returns a no-refresh-needed result instead of an error +- Checkout UI and Customer Account UI Extension requests now return `shop` without the `https://` prefix +- Update the README for the package ## [0.1.3] diff --git a/README.md b/README.md index f3d3e26..72d5f9c 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,43 @@ Python package for building Shopify applications. +This package encodes the secure, correct way to build a Shopify app. Most of what you need is already handled for you. The guidance below is written so you use the built-in path and do not reimplement (less safely) things the package already does. If you are ever tempted to parse, verify, or refresh something yourself, check the [Common misuses to avoid](#common-misuses-to-avoid) section first. + +> **Upgrading from [`ShopifyAPI`](https://github.com/Shopify/shopify_python_api)?** This is a new, framework-agnostic package with a different API, designed for incremental adoption. Adopt it gradually, one route at a time. Breaking changes between releases are listed in the [CHANGELOG](CHANGELOG.md). + +## Contents + +- [Prerequisites](#prerequisites) +- [Installation](#installation) +- [Requirements](#requirements) +- [Features](#features) +- [Principles](#principles) +- [Start here (the green path)](#start-here-the-green-path) +- [Setup steps](#setup-steps) +- [Using the package](#using-the-package) + - [Initialization](#initialization) + - [Converting a request](#converting-a-request) + - [Converting a Shopify response](#converting-a-shopify-response) + - [The verify result](#the-verify-result) + - [Getting the shop](#getting-the-shop) + - [Verifying requests with exchangeable ID tokens](#verifying-requests-with-exchangeable-id-tokens) + - [Navigating inside and outside the App Home iframe](#navigating-inside-and-outside-the-app-home-iframe) + - [GraphQL requests](#graphql-requests) + - [Verifying requests without exchangeable ID tokens](#verifying-requests-without-exchangeable-id-tokens) + - [Getting access tokens with client credentials](#getting-access-tokens-with-client-credentials) + - [Async variants](#async-variants) +- [Common misuses to avoid](#common-misuses-to-avoid) +- [Contributing, issues, feedback and feature requests](#contributing-issues-feedback-and-feature-requests) + +## Prerequisites + +Before you start, make sure you have: + +- A [Shopify Partner account](https://partners.shopify.com) and a development store +- The [Shopify CLI](https://shopify.dev/docs/api/shopify-cli#installation) installed +- Python 3.8+ and pip +- A web framework project (Django, Flask, or FastAPI) + ## Installation ```bash @@ -16,6 +53,8 @@ pip install --upgrade shopifyapp ## Features +Each function encodes a piece of the secure green path. Use the listed function rather than building your own version. + Request Verification: - `verify_admin_ui_ext_req`: Requests from Admin UI extensions @@ -31,7 +70,7 @@ 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. +- `refresh_token_exchanged_access_token`: Refresh an access token that was created using Token Exchange. Checks if a token refresh can and should happen. GraphQL: @@ -39,7 +78,7 @@ GraphQL: Helpers: -- `app_home_patch_id_token`: Render the patch ID token page for embedded apps +- `app_home_patch_id_token`: Securely renders the HTML to refresh a stale ID token. Use this instead of your own logic. - `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 @@ -50,19 +89,22 @@ Helpers: 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 +## Start here (the green path) -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. +Most apps follow the same path on every request from Shopify: -### Install the Shopify CLI +1. Convert your framework's request into the package's request shape. +2. Verify the request with the matching `verify_...` function. +3. If `ok` is `False`, return the provided `response`. +4. Use `result.shop` to look up or store the shop's access token. +5. Exchange or refresh the token when needed. +6. Make Admin GraphQL calls, passing the retry response the package gave you. -This installs Shopify CLI globally on your system, so you can run shopify commands from any directory. +Every step below has a built-in function. Use it. The recurring rule in this README: if the package already gives you a value or a response, use that value or response. Do not parse, craft, or re-verify it yourself. -``` -npm install -g @shopify/cli@latest -``` +## Setup steps -Please see [this guide](https://shopify.dev/docs/api/shopify-cli#installation) for using other JavaScript package managers +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. ### Initialize your web framework @@ -98,7 +140,7 @@ Make sure there is at-least a minimal `package.json`: } ``` -Create a `shopify.web.toml`: +Create a `shopify.web.toml`. The Shopify CLI needs this file to know how to serve your app during development. Set `roles` and the `dev` command so the CLI can serve and proxy your app. ```toml name = "My Python App" @@ -163,11 +205,27 @@ shopify = ShopifyApp( ) ``` -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). +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. Requests signed with either the current or the old secret are accepted while you roll the secret out, so you avoid downtime during rotation. Read more about [secret rotation](https://shopify.dev/docs/apps/build/authentication-authorization/client-secrets/rotate-revoke-client-credentials). + +```python +shopify = ShopifyApp( + client_id=os.getenv("SHOPIFY_API_KEY"), + client_secret=os.getenv("SHOPIFY_API_SECRET"), + old_client_secret=os.getenv("SHOPIFY_OLD_API_SECRET"), +) +``` + +**Do** set `old_client_secret` while rotating a secret, then remove it once the rotation is complete. + +**Don't** roll a secret without it. If you swap the secret in one step, in-flight requests signed with the previous secret will fail verification. + +### Converting a request -### Converting a Request +So that the package can support multiple frameworks, your app must convert your framework's concept of a Request to the package's concept. -So that the package can support multiple frameworks, your app must convert your frameworks concept of a Request to the package's concept. +**Do** pass the raw request through unchanged: the method, all headers, the full URL including query string, and the unmodified body. + +**Don't** filter headers, drop query parameters, or re-encode the body. Verification depends on the exact bytes Shopify sent. Altering them causes valid requests to fail verification. Django Example: @@ -213,7 +271,7 @@ def request_to_shopify_req(): ### 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: +Your app must convert the package's concept of a Response to the framework's 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. @@ -221,6 +279,10 @@ Your app must convert the packages concept of a Response to the frameworks conce We recommend logging this information to help you debug. +**Do** return the package's `response` verbatim, including its `status`, `body`, and `headers`. + +**Don't** build your own response for failures or drop the headers. The package's response carries the correct status and the security headers Shopify requires (see [Getting the shop](#getting-the-shop) and the App Home section for why this matters). + Django example: ```python @@ -275,34 +337,44 @@ def shopify_result_to_response(result): ) ``` -### Verifying request result +### The verify 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 | +| 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. Already parsed correctly for the request type and verified. Always use this value. See [Getting the shop](#getting-the-shop). | 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`. Carries required security headers. Return it as-is. | 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 | +| 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 | +| `invalid_token_response` | Pre-built response for the invalid token retry flow. Pass it to `exchange_using_token_exchange` and `admin_graphql_request` so Shopify can retry requests with a fresh token. | 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 | +| 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 +### Getting the shop + +**Do** use `shop` from the verify result. We securely parse it from the request, avoiding known attack vectors. + +**Don't** read the shop from the request yourself (for example by decoding the ID token). That is fragile and skips that protection. + +```python +shop = result.shop # e.g. "test-shop" +``` + +### Verifying requests with exchangeable ID tokens Some requests provide exchangeable ID tokens: @@ -330,9 +402,10 @@ def app_home(request): # The request should not be trusted if not result.ok: return shopify_result_to_response(result) - ``` +`app_home_patch_id_token_path` is required on every `verify_app_home_req` call. It points at your [token-refresh route](#add-the-token-refresh-route). When a page request arrives without a token, or with a stale one, verify redirects the browser there to get a fresh token, so it needs to know the path. Verify checks it on every call, so pass it even on routes that only ever receive `fetch` requests. An empty or missing value returns a configuration error (HTTP 500). + 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 @@ -346,23 +419,28 @@ Then we check if there is an access token in the database. If there is one we ch return shopify_result_to_response(refresh_result) if refresh_result.access_token: - # Package returned a refreshed token — save it + # Package returned a refreshed token, save it save_access_token(refresh_result.access_token) ``` +**Do** call `refresh_token_exchanged_access_token` and save the token if one is returned. + +**Don't** add your own checks before calling it. It already verifies whether a refresh token exists and whether it has expired, and returns a fresh token only when one is needed. Wrapping it with your own pre-checks just duplicates that logic and risks getting it wrong. + 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 or None | Token used to refresh the access token. `None` for non-expiring tokens. | -| `expires` | str or None | ISO 8601 datetime when access token expires. `None` for non-expiring tokens. | +| Attribute | Type | Description | +| --- | --- | --- | +| `shop` | str | Shop sub domain (e.g., "test-shop") | +| `access_mode` | str | Access mode: "online" or "offline" | +| `token` | str | The access token | +| `scope` | str | Granted scopes | +| `refresh_token` | str or None | Token used to refresh the access token. `None` for non-expiring tokens. | +| `expires` | str or None | ISO 8601 datetime when access token expires. `None` for non-expiring tokens. | | `refresh_token_expires` | str or None | ISO 8601 datetime when refresh token expires. `None` for non-expiring tokens. | -| `user_id` | str | A unique identifier for the user | -| `user` | AccessUser | User details (online mode only, `None` for offline) | +| `user` | User or None | User details (online mode only, `None` for offline) | + +For the merchant user id, use `result.user_id` from the verify result. Online access tokens also include a `user` object with an `id`; offline tokens have no `user`. When `access_mode` is "online", the `user` dataclass contains: @@ -387,7 +465,7 @@ If there is no access token in the database, use token exchange to get one: exchange_result = shopify.exchange_using_token_exchange( access_mode="offline", id_token=result.id_token, - invalid_token_response=result.new_id_token_response, + invalid_token_response=result.invalid_token_response, ) if not exchange_result.ok: @@ -399,12 +477,16 @@ If there is no access token in the database, use token exchange to get one: 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. +- `exchange_using_token_exchange` receives `result.invalid_token_response` from the verify function. Passing it lets Shopify automatically retry the request if the id token has become stale. Whether you pass it depends on the request type (see the GraphQL section). - Pass `expiring=False` to request a non-expiring token (no `refresh_token` or `refresh_token_expires`). Defaults to `True`. -- If using online access tokens, use the `user_id` provided by the `result`. +- If using online access tokens, use the `user_id` provided by the verify `result`, not the access token. - 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: +##### Return the required App Home response headers + +App home requests require [special Response headers](https://shopify.dev/docs/apps/build/security/set-up-iframe-protection) (for example, the `Content-Security-Policy` `frame-ancestors` directive). These headers are what allow your app to load securely inside the Shopify admin iframe. + +**Do** copy the headers from the verify result onto your App Home response: ```python # Copy headers from result to your response @@ -412,6 +494,8 @@ for header, value in result.response.headers.items(): response[header] = value ``` +When `ok` is `False`, return the provided `response` as-is. When `ok` is `True`, copy `response.headers` onto your framework's response before rendering App Home. + 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 @@ -424,7 +508,11 @@ App requests should also contain [App Bridge](https://shopify.dev/docs/api/app-b 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: +##### Add the token-refresh route + +Add a route that serves the token-refresh (patch ID token) page. This is what makes ordinary link and full-page navigation inside the App Home iframe work: when a navigation reaches your server without a session token, App Bridge uses this route to obtain a fresh one and retry the original request. Skipping it means in-app navigations that arrive without a token cannot recover. + +**Do** use `app_home_patch_id_token` for this route: ```python def patch_id_token(request): @@ -434,7 +522,9 @@ def patch_id_token(request): return shopify_result_to_response(result) ``` -This route should match the path configured here: +**Don't** build your own token-refresh page. By using `app_home_patch_id_token`, your app stays secure against known attack vulnerabilities. + +This route should match the path configured in `verify_app_home_req`: ```python result = shopify.verify_app_home_req( @@ -443,7 +533,31 @@ This route should match the path configured here: ) ``` -#### Redirecting Outside the App Home Iframe +#### 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. + +### Navigating inside and outside the App Home iframe + +App Home renders inside a cross-origin iframe. Because of this, ordinary links and redirects need care: the iframe cannot rely on cookies, and every authenticated request needs a session token. Use the helpers below rather than building redirects by hand. + +#### 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: @@ -467,7 +581,7 @@ def some_handler(request): 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 +#### 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: @@ -491,29 +605,19 @@ def some_handler(request): 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 +#### Authenticated navigation between your own pages -#### POS UI Extension +App Bridge attaches the session token to `fetch` and `XMLHttpRequest`, and it intercepts link clicks on `a`, `s-link`, `s-button`, and `s-clickable` elements. It does not intercept same-origin navigations that target the current frame, so an ordinary link or full-page navigation to one of your own pages reaches your server without a session token, and the destination cannot verify the request. -POS UI Extension are very similar to App Home. You only need change the verify method: +You do not need a single-page app to handle this. As long as you wire the [token-refresh route](#add-the-token-refresh-route) and pass `app_home_patch_id_token_path` to every `verify_app_home_req` call, the package recovers automatically: when a navigation arrives without a token, verify returns a response that redirects to the patch ID token page, App Bridge re-requests the same URL with a fresh token, and the retried request verifies. Standard multi-page apps work this way. -```python -result = shopify.verify_pos_ui_ext_req(req) -``` +Do not pass the ID token as a URL parameter to work around this. The token expires after about a minute, so it goes stale on any later navigation, and tokens in URLs leak into logs, referrers, and browser history. -POS UI extensions do not need the app home patch id token route. They do not need special headers or Polaris and App Bridge +For more detail, see the [App Home documentation](https://shopify.dev/docs/api/app-home). -### GraphQL Requests +### 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: +The package provides a method for making Admin GraphQL requests. Note, there may be a better more performant way 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) @@ -521,6 +625,8 @@ The package provides a method for making Admin GraphQL requests. Note, there may - 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. +**Do** prefer the surface-specific data APIs above when you can. **Don't** route data through your own server with `admin_graphql_request` when a faster first-party option exists for that surface. + If you do wish to access the Admin GraphQL API on your server, here is how: #### When responding to a request from Shopify @@ -529,7 +635,7 @@ Here is how to make a GraphQL request in the context of a request from Shopify. 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. +3. This example 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. @@ -537,13 +643,27 @@ More details on points 2 & 3 after the code example. def app_home_handler(request): req = request_to_shopify_req(request) - result = shopify.verify_app_home_req(req) + 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) # Your database logic here access_token = get_access_token(shop=result.shop, mode="offline") + # If there is no stored token (for example after a delete & retry, where the token + # was just deleted), exchange one before using it. Otherwise `access_token` is None + # and the request below crashes. + if not access_token: + exchange_result = shopify.exchange_using_token_exchange( + access_mode="offline", + id_token=result.id_token, + invalid_token_response=result.invalid_token_response, + ) + if not exchange_result.ok: + return shopify_result_to_response(exchange_result) + save_access_token(exchange_result.access_token) + access_token = exchange_result.access_token + graphql_result = shopify.admin_graphql_request( """ { @@ -553,12 +673,12 @@ def app_home_handler(request): } """, shop=result.shop, - access_token=access_token, + access_token=access_token.token, api_version="2025-01", - # Passing `result.new_id_token_response` from the verify function + # Passing `result.invalid_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, + invalid_token_response=result.invalid_token_response, ) # The GraphQL failed @@ -576,23 +696,27 @@ def app_home_handler(request): shop_id = graphql_result.data["shop"]["id"] ``` -You will get an `unauthorized` log code if: +**Do** branch on the `log` `code` the package returns, as shown above. -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 +**Don't** invent your own error codes or parse error messages. Branch on the `log` `code` the package returns. -If 1 happens, the merchant needs to manually reinstall the app. If 2 or 3 happens there are different approaches you can take: +Some failures are unrecoverable (for example the app was uninstalled), in which case the merchant must reinstall. If the token is valid but the merchant has not approved a required scope, they must approve it (do not retry). If the token is revoked or invalid, you can recover; the options are below. -| 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 | +For a revoked or invalid token, 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 | + +**Note on "Delete & retry":** after you delete the token and return the retry response, App Bridge retries the _same_ request with a fresh session token. That retried request no longer has a stored access token, so the route it lands on must be able to obtain one again (exchange `id_token` from the verify result). If the route only reads a stored token, the retry will fail. The App Home flow above already handles this: it exchanges a token when none is stored. #### 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. +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. + +**Do** pass `None` here. In a background job there is no live request for Shopify to retry, so there is nothing for a retry response to attach to. ```python def process_job(shop): @@ -643,7 +767,7 @@ def process_job(shop): - `data`: The GraphQL response data (dict), or `None` if the request failed. - `extensions`: The GraphQL extensions (dict, e.g., cost information), or `None` if not present. -### Verifying requests without exchangeable id tokens +### Verifying requests without exchangeable ID tokens The following requests do not provide the required information for token exchange: @@ -656,6 +780,8 @@ Webhook and App Proxy requests do not provide an id token. Customer Account and 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. +In every case below, use `result.shop` to look up the stored token. Do not parse the shop yourself (see [Getting the shop](#getting-the-shop)). + #### Webhooks ```python @@ -679,7 +805,7 @@ 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. +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 @@ -705,10 +831,14 @@ Flow Action requests are almost identical to webhooks: result = shopify.verify_flow_action_req(req) ``` -### Getting access tokens with Client Credentials +### 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). +**Do** use this for trusted server-to-server work where there is no merchant request. + +**Don't** use it to authenticate a request coming from App Home or an extension. Those requests carry an ID token, and you should verify them and exchange that token instead. + ```python def get_or_refresh_access_token(shop): # Check if we have a valid token @@ -731,15 +861,34 @@ def get_or_refresh_access_token(shop): 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) | +| Attribute | Description | +| ------------- | --------------------------------------------------- | +| `shop` | The shop sub domain (e.g., "test-shop") | +| `access_mode` | Always "offline" | +| `token` | The access token string | +| `scope` | The granted scopes | +| `expires` | ISO 8601 datetime when the token expires (24 hours) | +| `user` | Always `None` for client credentials | Note: Client credentials tokens expire after 24 hours and do not include a refresh token. When the token expires, request a new one using `exchange_using_client_credentials` with the same credentials. +### Async variants + +Every network call has an async version with the same signature and an `_async` suffix: `exchange_using_token_exchange_async`, `refresh_token_exchanged_access_token_async`, `exchange_using_client_credentials_async`, and `admin_graphql_request_async`. Use these in async frameworks (for example FastAPI) so you do not block the event loop. + +## Common misuses to avoid + +The package already handles these for you. Rebuilding them yourself is slower, and in the security-sensitive cases it is genuinely risky. Use the built-in path. + +| Don't | Do instead | Why | +| --- | --- | --- | +| Parse the shop from the ID token or request | Use `result.shop` | It is already parsed correctly per request type and verified against spoofing. The shop decides which store's token is used. | +| Build your own token-refresh page | Use `app_home_patch_id_token` | It returns a complete, safe response. Custom pages that render request values are a common injection risk. | +| Render App Home without the response headers | Copy `result.response.headers` onto your response | These are the required iframe-protection headers (for example CSP `frame-ancestors`). | +| Craft your own response on a failed verify | Return `result.response` as-is | It has the correct status and the security headers already set. | +| Alter headers, query string, or body before verifying | Pass the raw request through unchanged | Verification depends on the exact bytes Shopify sent. | +| Add the ID token to the URL, or reach for a single-page app, to keep navigation authenticated | Wire the token-refresh route and pass `app_home_patch_id_token_path` on every verify | Same-origin link and full-page navigations arrive without a session token. The route lets App Bridge re-request with a fresh one, so multi-page apps work without tokens in URLs. | + ## Contributing, issues, feedback and feature requests This package does not accept contributions, but we'd love to hear your feedback. diff --git a/shopify_app/__init__.py b/shopify_app/__init__.py index a59831a..8450404 100644 --- a/shopify_app/__init__.py +++ b/shopify_app/__init__.py @@ -130,7 +130,7 @@ def verify_pos_ui_ext_req( 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 + ResultWithExchangeableIdToken: Verification result with ok, shop, user_id, id_token, log, response, and invalid_token_response fields """ return verify_pos_ui_ext_req(request, self.config) @@ -158,7 +158,7 @@ def verify_admin_ui_ext_req( 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 + ResultWithExchangeableIdToken: Verification result with ok, shop, user_id, id_token, log, response, and invalid_token_response fields """ return verify_admin_ui_ext_req(request, self.config) @@ -173,7 +173,7 @@ def verify_app_home_req( 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 + ResultWithExchangeableIdToken: Verification result with ok, shop, user_id, id_token, log, response, and invalid_token_response fields """ return verify_app_home_req(request, self.config, app_home_patch_id_token_path) diff --git a/shopify_app/_version.py b/shopify_app/_version.py index 0bedf82..d7e5eb3 100644 --- a/shopify_app/_version.py +++ b/shopify_app/_version.py @@ -2,4 +2,4 @@ from __future__ import annotations -__version__ = "0.1.4" +__version__ = "1.0.0" diff --git a/shopify_app/types.py b/shopify_app/types.py index 783e8c8..5a6c7a7 100644 --- a/shopify_app/types.py +++ b/shopify_app/types.py @@ -272,7 +272,7 @@ class ResultWithExchangeableIdToken: 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 + invalid_token_response: Pre-built response for token refresh scenarios """ ok: bool @@ -281,7 +281,7 @@ class ResultWithExchangeableIdToken: response: Res user_id: Optional[str] id_token: Optional[IdTokenDetails] - new_id_token_response: Optional[Res] + invalid_token_response: Optional[Res] @dataclass(frozen=True) diff --git a/shopify_app/verify/_non_exchangeable_id_token.py b/shopify_app/verify/_non_exchangeable_id_token.py index a98d1f0..de995b2 100644 --- a/shopify_app/verify/_non_exchangeable_id_token.py +++ b/shopify_app/verify/_non_exchangeable_id_token.py @@ -219,7 +219,7 @@ def _verify_non_exchangeable_id_token( # Extract shop from dest claim dest = payload.get("dest", "") - shop = dest.replace(".myshopify.com", "") if dest else "" + shop = dest.replace("https://", "").replace(".myshopify.com", "") if dest else "" return ResultWithNonExchangeableIdToken( ok=True, diff --git a/shopify_app/verify/admin_ui_ext.py b/shopify_app/verify/admin_ui_ext.py index bf9d6b2..c91e92a 100644 --- a/shopify_app/verify/admin_ui_ext.py +++ b/shopify_app/verify/admin_ui_ext.py @@ -49,7 +49,7 @@ def verify_admin_ui_ext_req( response=Res(status=500, body="", headers={}), user_id=None, id_token=None, - new_id_token_response=None, + invalid_token_response=None, ) headers = request.get("headers") @@ -65,7 +65,7 @@ def verify_admin_ui_ext_req( response=Res(status=500, body="", headers={}), user_id=None, id_token=None, - new_id_token_response=None, + invalid_token_response=None, ) url = request.get("url") @@ -81,7 +81,7 @@ def verify_admin_ui_ext_req( response=Res(status=500, body="", headers={}), user_id=None, id_token=None, - new_id_token_response=None, + invalid_token_response=None, ) client_secret = config.get("client_secret", "") @@ -116,7 +116,7 @@ def verify_admin_ui_ext_req( ), user_id=None, id_token=None, - new_id_token_response=None, + invalid_token_response=None, ) # Check for Authorization header @@ -132,7 +132,7 @@ def verify_admin_ui_ext_req( response=Res(status=401, body="Unauthorized", headers={}), user_id=None, id_token=None, - new_id_token_response=None, + invalid_token_response=None, ) # Extract the Bearer token @@ -153,7 +153,7 @@ def verify_admin_ui_ext_req( ), user_id=None, id_token=None, - new_id_token_response=None, + invalid_token_response=None, ) id_token = auth_header[7:] # Remove "Bearer " prefix @@ -204,7 +204,7 @@ def verify_admin_ui_ext_req( ), user_id=None, id_token=None, - new_id_token_response=None, + invalid_token_response=None, ) # Verify the audience (aud) matches the clientId @@ -225,7 +225,7 @@ def verify_admin_ui_ext_req( ), user_id=None, id_token=None, - new_id_token_response=None, + invalid_token_response=None, ) # Extract shop from dest claim @@ -250,7 +250,7 @@ def verify_admin_ui_ext_req( token=id_token, claims=payload, ), - new_id_token_response=Res( + invalid_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 index d9acd34..0f90dd2 100644 --- a/shopify_app/verify/app_home_req.py +++ b/shopify_app/verify/app_home_req.py @@ -91,7 +91,7 @@ def _build_patch_id_token_redirect( ), user_id=None, id_token=None, - new_id_token_response=None, + invalid_token_response=None, ) @@ -129,7 +129,7 @@ def verify_app_home_req( ), user_id=None, id_token=None, - new_id_token_response=None, + invalid_token_response=None, ) if app_home_patch_id_token_path == "": @@ -148,7 +148,7 @@ def verify_app_home_req( ), user_id=None, id_token=None, - new_id_token_response=None, + invalid_token_response=None, ) # Validate request object @@ -169,7 +169,7 @@ def verify_app_home_req( ), user_id=None, id_token=None, - new_id_token_response=None, + invalid_token_response=None, ) headers = request.get("headers") @@ -189,7 +189,7 @@ def verify_app_home_req( ), user_id=None, id_token=None, - new_id_token_response=None, + invalid_token_response=None, ) client_secret = config.get("client_secret", "") @@ -249,7 +249,7 @@ def verify_app_home_req( ), user_id=None, id_token=None, - new_id_token_response=None, + invalid_token_response=None, ) id_token = auth_header[7:] # Remove "Bearer " prefix @@ -269,7 +269,7 @@ def verify_app_home_req( ), user_id=None, id_token=None, - new_id_token_response=None, + invalid_token_response=None, ) payload = None @@ -332,7 +332,7 @@ def verify_app_home_req( ), user_id=None, id_token=None, - new_id_token_response=None, + invalid_token_response=None, ) # Verify the audience (aud) matches the clientId @@ -360,7 +360,7 @@ def verify_app_home_req( ), user_id=None, id_token=None, - new_id_token_response=None, + invalid_token_response=None, ) # Extract shop from dest claim (parse as URL and get hostname) @@ -380,8 +380,8 @@ def verify_app_home_req( "Link": '; rel="preconnect", ; rel="preload"; as="script", ; rel="preload"; as="script"', } - # Build new_id_token_response - new_id_token_response = None + # Build invalid_token_response + invalid_token_response = None if not has_authorization_header: # Document request - build patch ID token URL clean_query = _remove_query_param(parsed_url.query, "id_token") @@ -394,7 +394,7 @@ def verify_app_home_req( 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( + invalid_token_response = Res( status=302, body="", headers={ @@ -403,7 +403,7 @@ def verify_app_home_req( ) else: # Fetch request - new_id_token_response = Res( + invalid_token_response = Res( status=401, body="", headers={ @@ -435,5 +435,5 @@ def verify_app_home_req( token=id_token, claims=payload, ), - new_id_token_response=new_id_token_response, + invalid_token_response=invalid_token_response, ) diff --git a/shopify_app/verify/pos_ui_ext.py b/shopify_app/verify/pos_ui_ext.py index 18f1f96..0306523 100644 --- a/shopify_app/verify/pos_ui_ext.py +++ b/shopify_app/verify/pos_ui_ext.py @@ -32,7 +32,7 @@ def verify_pos_ui_ext_req( 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 + ResultWithExchangeableIdToken: Verification result with ok, shop, log, response, user_id, id_token, and invalid_token_response fields """ req = redact_http_log(request) # Validate request object @@ -53,7 +53,7 @@ def verify_pos_ui_ext_req( ), user_id=None, id_token=None, - new_id_token_response=None, + invalid_token_response=None, ) headers = request.get("headers") @@ -73,7 +73,7 @@ def verify_pos_ui_ext_req( ), user_id=None, id_token=None, - new_id_token_response=None, + invalid_token_response=None, ) url = request.get("url") @@ -93,7 +93,7 @@ def verify_pos_ui_ext_req( ), user_id=None, id_token=None, - new_id_token_response=None, + invalid_token_response=None, ) client_id = config.get("client_id", "") @@ -128,7 +128,7 @@ def verify_pos_ui_ext_req( ), user_id=None, id_token=None, - new_id_token_response=None, + invalid_token_response=None, ) # Check for Authorization header @@ -148,7 +148,7 @@ def verify_pos_ui_ext_req( ), user_id=None, id_token=None, - new_id_token_response=None, + invalid_token_response=None, ) # Extract the Bearer token @@ -169,7 +169,7 @@ def verify_pos_ui_ext_req( ), user_id=None, id_token=None, - new_id_token_response=None, + invalid_token_response=None, ) id_token = auth_header[7:] # Remove "Bearer " prefix @@ -223,7 +223,7 @@ def verify_pos_ui_ext_req( ), user_id=None, id_token=None, - new_id_token_response=None, + invalid_token_response=None, ) # Verify the audience (aud) matches clientId @@ -244,7 +244,7 @@ def verify_pos_ui_ext_req( ), user_id=None, id_token=None, - new_id_token_response=None, + invalid_token_response=None, ) # Extract shop from dest claim (format: https://shop-name.myshopify.com) @@ -273,5 +273,5 @@ def verify_pos_ui_ext_req( token=id_token, claims=payload, ), - new_id_token_response=None, + invalid_token_response=None, )