Skip to content

feat: Add OAuth 2.0 Client Credentials support for Shopify integration - #438

Open
RohnRoy wants to merge 2 commits into
frappe:developfrom
RohnRoy:feat/shopify-oauth-support
Open

feat: Add OAuth 2.0 Client Credentials support for Shopify integration#438
RohnRoy wants to merge 2 commits into
frappe:developfrom
RohnRoy:feat/shopify-oauth-support

Conversation

@RohnRoy

@RohnRoy RohnRoy commented Jun 30, 2026

Copy link
Copy Markdown

feat(shopify): Add OAuth 2.0 Client Credentials auth for Dev Dashboard apps

Summary

Since January 1, 2026, Shopify no longer allows creating Custom Apps
directly in the Shopify Admin. All new apps must be created through the
Shopify Dev Dashboard (shopify.dev), which issues a Client ID +
Client Secret instead of a permanent access token.

This PR adds dual authentication to ecommerce_integrations, so both old
and new Shopify apps continue to work without any breaking changes.

Supersedes the unmerged PR #399 by @Z4nzu which had the right approach but
was never reviewed.

Related forum thread: https://discuss.frappe.io/t/issues-with-integrating-shopify-post-jan-2026-updates/161201


What changed in Shopify

Before (pre-Jan 2026) After (post-Jan 2026)
Create Custom App in Shopify Admin Create app in Dev Dashboard (shopify.dev)
Receive permanent Access Token + Shared Secret Receive Client ID + Client Secret
Token never expires Token expires every 24 hours, must be re-fetched
HMAC signing key = Shared Secret HMAC signing key = Client Secret

The new token exchange is a standard Client Credentials Grant:

POST https://{shop}.myshopify.com/admin/oauth/access_token
Content-Type: application/x-www-form-urlencoded

grant_type=client_credentials&client_id={id}&client_secret={secret}

→ {"access_token": "...", "expires_in": 86399}

Reference: https://shopify.dev/docs/apps/build/authentication-authorization/access-tokens/client-credentials-grant


Changes

New file: shopify/oauth.py

Core of the PR. Public functions:

  • get_valid_access_token(setting) — main entry point called by connection.py;
    returns a valid token for the current session, auto-refreshing when the token
    is missing or within 5 minutes of its 24-hour expiry.
  • refresh_oauth_token(setting, client_secret=None) — exchanges Client ID +
    Client Secret for a new token via HTTP POST, stores the token encrypted via
    set_encrypted_password, and persists token_expires_at. The optional
    client_secret parameter allows callers to supply the plaintext secret during
    validate/before_save (before Frappe writes the encrypted value to the
    auth store).
  • validate_oauth_credentials(shopify_url, client_id, client_secret) — called
    during validate() to give immediate feedback if credentials are wrong.
  • generate_oauth_token(shopify_url, client_id, client_secret) — raw HTTP call;
    raises frappe.ValidationError with a clean error message on failure, never
    logs the client secret.
  • is_token_valid(token_expires_at, buffer_minutes=5) — returns False when
    the token is missing or within the 5-minute refresh buffer.

Modified: shopify/connection.py

temp_shopify_session decorator now calls the new _get_access_token(setting)
helper, which branches on authentication_method: OAuth mode calls
get_valid_access_token; Static Token mode reads setting.get_password("password")
as before.

_validate_request (webhook HMAC check) uses settings.get_password("client_secret")
as the signing key in OAuth mode and settings.shared_secret in Static Token mode.

_handle_webhooks in shopify_setting.py passes the correct token to
register_webhooks / unregister_webhooks based on auth mode, including a
safe fallback when the token is empty (prevents a crash on disable).

The Static Token code path is 100% unchanged. Existing installations are
not affected.

Modified: shopify/doctype/shopify_setting/shopify_setting.py

New methods:

  • _get_password_safe(fieldname) — wraps get_password in a try/except;
    returns "" instead of raising when the encrypted value doesn't exist yet.
  • _validate_authentication_fields() — checks that the fields required by the
    selected auth mode are present; raises frappe.throw with a clear message.
  • _validate_oauth_credentials_if_needed() — calls validate_oauth_credentials
    when client_id or client_secret changes, giving users immediate feedback.
  • before_save() — pre-generates and caches an OAuth token on save so the
    first sync doesn't incur an extra round-trip.
  • _get_or_generate_oauth_token() — returns the cached token if still valid,
    otherwise calls refresh_oauth_token; passes the in-memory self.client_secret
    during the save flow to avoid a Frappe password-store race condition.

validate() calls _set_default_authentication_method() to ensure the field
is always populated before other validation runs.

_handle_webhooks guards against calling unregister_webhooks with an empty
token (previously crashed when disabling with no password set).

Modified: shopify/doctype/shopify_setting/shopify_setting.json

Five new fields:

fieldname fieldtype Notes
authentication_method Select "Static Token" or "OAuth 2.0 Client Credentials"; default "Static Token"
client_id Data Shown only when OAuth mode; mandatory_depends_on enforced
client_secret Password Shown only when OAuth mode; mandatory_depends_on enforced
oauth_access_token Password Auto-managed, hidden=1, read_only=1
token_expires_at Datetime Auto-managed, hidden=1, read_only=1

Existing password and shared_secret fields gain depends_on so they only
show in Static Token mode, keeping the UI uncluttered for new installs.

New file: patches/set_default_shopify_auth_method.py

Migration patch — sets authentication_method = "Static Token" on any
existing Shopify Setting record so there is zero behaviour change for existing
installs. Registered in patches.txt.


Backward compatibility

Existing Static Token setups: no change required, no action needed

The new authentication_method field defaults to "Static Token". The
migration patch ensures existing records get this value on bench migrate.
Every code path for Static Token delegates to the original implementation.


Testing

bench run-tests --app ecommerce_integrations \
    --module ecommerce_integrations.shopify.tests.test_shopify_oauth

Tests cover (shopify/tests/test_shopify_oauth.py):

  • is_token_valid — missing / valid / within-buffer / expired
  • get_oauth_token_endpoint — plain domain, https:// strip, http:// strip, trailing slash strip
  • generate_oauth_token — success, HTTP 401 raises ValidationError, client_secret never logged
  • get_valid_access_token — cached token (no HTTP), expired triggers refresh, missing triggers fetch
  • _validate_request HMAC — OAuth uses client_secret, Static Token uses shared_secret, wrong secret fails

Manual test checklist

Static Token (existing setup)

  • Open Shopify Setting → Authentication Method shows "Static Token"
  • password and shared_secret fields visible
  • client_id, client_secret, OAuth token fields hidden
  • Save succeeds without touching OAuth fields
  • Order sync, inventory sync, webhooks work as before

OAuth 2.0 (new Dev Dashboard app)

  • Set Authentication Method → "OAuth 2.0 Client Credentials"
  • client_id, client_secret fields appear; password, shared_secret hidden
  • Enter valid Client ID + Secret from Dev Dashboard → Save
  • oauth_access_token and token_expires_at populate automatically
  • Save with invalid credentials shows a clear error immediately
  • Webhooks table populates (5 webhooks registered)
  • Order sync works correctly
  • After manually clearing token_expires_at, next sync auto-refreshes the token
  • Webhook payload arrives and HMAC validates correctly (uses client_secret)

Checklist

  • bench migrate tested locally
  • Static Token regression tested (existing setup untouched)
  • OAuth 2.0 tested against a real Dev Dashboard app (Aquatech Shopify store)
  • Backward-compatible migration patch added
  • PR targets develop branch
  • No breaking changes to existing public API surface
  • Unit tests added (shopify/tests/test_shopify_oauth.py, 13 tests)

@RohnRoy
RohnRoy requested a review from ankush as a code owner June 30, 2026 10:39
@greptile-apps

greptile-apps Bot commented Jun 30, 2026

Copy link
Copy Markdown

Confidence Score: 5/5

  • Safe to merge; the Static Token path is unchanged and the OAuth path is functionally correct.
  • The core OAuth flow — token generation, encrypted storage, secret-safe logging, timing-safe HMAC, and transient-only retry — is all correct. The only unfixed issue is a cosmetic double-wrapped error message when token generation fails.
  • No files require special attention.

Reviews (8): Last reviewed commit: "Merge branch 'develop' into feat/shopify..." | Re-trigger Greptile

Comment thread ecommerce_integrations/shopify/oauth.py Outdated
Comment on lines +102 to +128
def _validate_oauth_credentials_if_needed(self):
"""Validate OAuth credentials by generating a test token when credentials change."""
if not self.is_enabled():
return

if self.authentication_method != "OAuth 2.0 Client Credentials":
return

if self.has_value_changed("client_id") or self.has_value_changed("client_secret"):
# self.client_secret holds plaintext during validate (before encrypted store write)
client_secret = self.client_secret or self._get_password_safe("client_secret")
if not client_secret:
return # Will be caught by _validate_authentication_fields

try:
validate_oauth_credentials(
self.shopify_url,
self.client_id,
client_secret,
)
frappe.msgprint(
_("OAuth credentials validated successfully. Token will be auto-generated on save."),
indicator="green",
alert=True,
)
except Exception:
raise

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Synchronous HTTP calls inside validate() and before_save()

_validate_oauth_credentials_if_needed() (called from validate()) and before_save() both make live HTTP requests to Shopify's token endpoint synchronously. This is against Frappe's lifecycle contract — a network timeout or Shopify outage will block or fail every document save, and Frappe does not expect validate to perform external I/O. Per the project's own custom rules, external API calls in validate/before_save should move to after_insert/on_update or be dispatched via frappe.enqueue().

Additionally, when credentials change, both _validate_oauth_credentials_if_needed (which calls generate_oauth_token and discards the result) and _handle_webhooks (which calls _get_or_generate_oauth_token and stores the result) both fire in the same validate() call — generating and discarding a token unnecessarily before storing a second one.

Context Used: This is a Frappe Framework application (Python bac... (source)

Prompt To Fix With AI
This is a comment left during a code review.
Path: ecommerce_integrations/shopify/doctype/shopify_setting/shopify_setting.py
Line: 102-128

Comment:
**Synchronous HTTP calls inside `validate()` and `before_save()`**

`_validate_oauth_credentials_if_needed()` (called from `validate()`) and `before_save()` both make live HTTP requests to Shopify's token endpoint synchronously. This is against Frappe's lifecycle contract — a network timeout or Shopify outage will block or fail every document save, and Frappe does not expect `validate` to perform external I/O. Per the project's own custom rules, external API calls in `validate`/`before_save` should move to `after_insert`/`on_update` or be dispatched via `frappe.enqueue()`.

Additionally, when credentials change, both `_validate_oauth_credentials_if_needed` (which calls `generate_oauth_token` and discards the result) and `_handle_webhooks` (which calls `_get_or_generate_oauth_token` and stores the result) both fire in the same `validate()` call — generating and discarding a token unnecessarily before storing a second one.

**Context Used:** This is a Frappe Framework application (Python bac... ([source](https://app.greptile.com/review/custom-context?memory=instruction-0))

How can I resolve this? If you propose a fix, please make it concise.

Comment thread ecommerce_integrations/shopify/oauth.py Outdated
Comment thread ecommerce_integrations/shopify/doctype/shopify_setting/shopify_setting.py Outdated
@RohnRoy
RohnRoy force-pushed the feat/shopify-oauth-support branch from 6b1c5f5 to 6fef948 Compare June 30, 2026 10:53
Comment thread ecommerce_integrations/shopify/oauth.py Outdated
@RohnRoy
RohnRoy force-pushed the feat/shopify-oauth-support branch 2 times, most recently from 9b934dd to df12e1b Compare June 30, 2026 11:31
@RohnRoy
RohnRoy force-pushed the feat/shopify-oauth-support branch 2 times, most recently from b01f860 to 65f2af9 Compare June 30, 2026 12:10
Comment thread ecommerce_integrations/shopify/connection.py Outdated
Shopify dev-dashboard apps (post Jan 2026) issue short-lived tokens via
Client Credentials Grant instead of static access tokens. This adds
dual-mode auth: existing installs keep working (Static Token), new apps
use OAuth 2.0 with auto-refresh every 24h.

- oauth.py: token generation, encrypted storage, auto-refresh
- connection.py: session decorator + HMAC webhook validation for both modes
- shopify_setting.py: validate/before_save handle in-memory password field
- shopify_setting.json: new fields (auth method, client_id, client_secret,
  token store, expiry); password/shared_secret conditional on Static Token
- patches/set_default_shopify_auth_method.py: backward-compat migration
@RohnRoy
RohnRoy force-pushed the feat/shopify-oauth-support branch from 65f2af9 to 9b52fad Compare June 30, 2026 12:33
@RohnRoy

RohnRoy commented Jun 30, 2026

Copy link
Copy Markdown
Author

@ankush , could you please review the changes related to this issue: frappe/frappe#38111

If everything looks good, please merge this PR after your review. Thank you!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant