diff --git a/ecommerce_integrations/patches.txt b/ecommerce_integrations/patches.txt index ea16f841a..af76a2c12 100644 --- a/ecommerce_integrations/patches.txt +++ b/ecommerce_integrations/patches.txt @@ -1,2 +1,3 @@ ecommerce_integrations.patches.update_shopify_custom_fields ecommerce_integrations.patches.set_default_amazon_item_fields_map +ecommerce_integrations.patches.set_default_shopify_auth_method diff --git a/ecommerce_integrations/patches/set_default_shopify_auth_method.py b/ecommerce_integrations/patches/set_default_shopify_auth_method.py new file mode 100644 index 000000000..5472bd662 --- /dev/null +++ b/ecommerce_integrations/patches/set_default_shopify_auth_method.py @@ -0,0 +1,23 @@ +import frappe + +from ecommerce_integrations.shopify.constants import SETTING_DOCTYPE + + +def execute(): + """ + Migration patch to set default authentication method for existing Shopify installations. + This ensures backward compatibility when introducing OAuth 2.0 support. + """ + frappe.reload_doc("shopify", "doctype", "shopify_setting") + + if frappe.db.exists("DocType", SETTING_DOCTYPE): + settings = frappe.get_doc(SETTING_DOCTYPE) + + # Set default authentication method to "Static Token" for existing installations + if not settings.authentication_method: + settings.db_set("authentication_method", "Static Token", update_modified=False) + frappe.db.commit() + + frappe.logger().info( + "Shopify Setting: Set default authentication method to 'Static Token' for existing installation" + ) diff --git a/ecommerce_integrations/shopify/connection.py b/ecommerce_integrations/shopify/connection.py index 4a4c7c86d..e66191ed4 100644 --- a/ecommerce_integrations/shopify/connection.py +++ b/ecommerce_integrations/shopify/connection.py @@ -19,7 +19,12 @@ def temp_shopify_session(func): - """Any function that needs to access shopify api needs this decorator. The decorator starts a temp session that's destroyed when function returns.""" + """Any function that needs to access shopify api needs this decorator. + The decorator starts a temp session that's destroyed when function returns. + + Supports both Static Token and OAuth 2.0 Client Credentials authentication methods. + For OAuth, automatically refreshes token if expired or expiring soon. + """ @functools.wraps(func) def wrapper(*args, **kwargs): @@ -29,7 +34,9 @@ def wrapper(*args, **kwargs): setting = frappe.get_doc(SETTING_DOCTYPE) if setting.is_enabled(): - auth_details = (setting.shopify_url, API_VERSION, setting.get_password("password")) + # Get access token based on authentication method + access_token = _get_access_token(setting) + auth_details = (setting.shopify_url, API_VERSION, access_token) with Session.temp(*auth_details): return func(*args, **kwargs) @@ -37,6 +44,40 @@ def wrapper(*args, **kwargs): return wrapper +def _get_access_token(setting): + """ + Get the appropriate access token based on authentication method. + For OAuth, ensures token is valid and refreshes if needed. + + Args: + setting: ShopifySetting document instance + + Returns: + Valid access token + """ + if setting.authentication_method == "OAuth 2.0 Client Credentials": + # Import here to avoid circular dependency + from ecommerce_integrations.shopify.oauth import get_valid_access_token + + try: + return get_valid_access_token(setting) + except Exception as e: + # Log the error and re-raise with context + create_shopify_log( + status="Error", + method="ecommerce_integrations.shopify.connection._get_access_token", + message=_("Failed to get valid OAuth access token"), + exception=str(e), + ) + frappe.throw( + _("Failed to authenticate with Shopify using OAuth 2.0: {0}").format(str(e)), + title=_("Authentication Error"), + ) + else: + # Static Token authentication + return setting.get_password("password") + + def register_webhooks(shopify_url: str, password: str) -> list[Webhook]: """Register required webhooks with shopify and return registered webhooks.""" new_webhooks = [] @@ -120,7 +161,18 @@ def process_request(data, event): def _validate_request(req, hmac_header): settings = frappe.get_doc(SETTING_DOCTYPE) - secret_key = settings.shared_secret + + # Get the appropriate secret key based on authentication method + if settings.authentication_method == "OAuth 2.0 Client Credentials": + # For OAuth apps, use client_secret for HMAC validation + secret_key = settings.get_password("client_secret") + else: + # For static token apps, use shared_secret + secret_key = settings.shared_secret + + if not secret_key: + create_shopify_log(status="Error", request_data=req.data, exception="Secret key not configured") + frappe.throw(_("Webhook validation failed: Secret key not configured")) sig = base64.b64encode(hmac.new(secret_key.encode("utf8"), req.data, hashlib.sha256).digest()) diff --git a/ecommerce_integrations/shopify/doctype/shopify_setting/shopify_setting.json b/ecommerce_integrations/shopify/doctype/shopify_setting/shopify_setting.json index 01722169b..42b328998 100644 --- a/ecommerce_integrations/shopify/doctype/shopify_setting/shopify_setting.json +++ b/ecommerce_integrations/shopify/doctype/shopify_setting/shopify_setting.json @@ -8,10 +8,15 @@ "enable_shopify", "column_break_4", "section_break_2", + "authentication_method", "shopify_url", "column_break_3", "password", "shared_secret", + "client_id", + "client_secret", + "oauth_access_token", + "token_expires_at", "section_break_4", "webhooks", "customer_settings_section", @@ -76,6 +81,14 @@ "fieldtype": "Section Break", "label": "Authentication Details" }, + { + "default": "Static Token", + "description": "Select Static Token for existing apps or OAuth 2.0 for apps created after Jan 1, 2026", + "fieldname": "authentication_method", + "fieldtype": "Select", + "label": "Authentication Method", + "options": "Static Token\nOAuth 2.0 Client Credentials" + }, { "description": "eg: frappe.myshopify.com", "fieldname": "shopify_url", @@ -89,16 +102,50 @@ "fieldtype": "Column Break" }, { + "depends_on": "eval:doc.authentication_method=='Static Token'", "fieldname": "password", "fieldtype": "Password", "label": "Password / Access Token", - "mandatory_depends_on": "eval:doc.enable_shopify" + "mandatory_depends_on": "eval:doc.enable_shopify && doc.authentication_method=='Static Token'" }, { + "depends_on": "eval:doc.authentication_method=='Static Token'", "fieldname": "shared_secret", "fieldtype": "Data", "label": "Shared secret / API Secret", - "mandatory_depends_on": "eval:doc.enable_shopify" + "mandatory_depends_on": "eval:doc.enable_shopify && doc.authentication_method=='Static Token'" + }, + { + "depends_on": "eval:doc.authentication_method=='OAuth 2.0 Client Credentials'", + "description": "Client ID from Shopify Partner Dashboard", + "fieldname": "client_id", + "fieldtype": "Data", + "label": "Client ID", + "mandatory_depends_on": "eval:doc.enable_shopify && doc.authentication_method=='OAuth 2.0 Client Credentials'" + }, + { + "depends_on": "eval:doc.authentication_method=='OAuth 2.0 Client Credentials'", + "description": "Client Secret from Shopify Partner Dashboard", + "fieldname": "client_secret", + "fieldtype": "Password", + "label": "Client Secret", + "mandatory_depends_on": "eval:doc.enable_shopify && doc.authentication_method=='OAuth 2.0 Client Credentials'" + }, + { + "description": "Auto-generated OAuth access token", + "fieldname": "oauth_access_token", + "fieldtype": "Password", + "hidden": 1, + "label": "OAuth Access Token", + "read_only": 1 + }, + { + "description": "OAuth token expiry time", + "fieldname": "token_expires_at", + "fieldtype": "Datetime", + "hidden": 1, + "label": "Token Expires At", + "read_only": 1 }, { "collapsible": 1, @@ -392,7 +439,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2023-10-24 10:38:49.247431", + "modified": "2026-01-13 14:36:33.238183", "modified_by": "Administrator", "module": "shopify", "name": "Shopify Setting", @@ -409,8 +456,9 @@ "write": 1 } ], + "row_format": "Dynamic", "sort_field": "modified", "sort_order": "DESC", "states": [], "track_changes": 1 -} \ No newline at end of file +} diff --git a/ecommerce_integrations/shopify/doctype/shopify_setting/shopify_setting.py b/ecommerce_integrations/shopify/doctype/shopify_setting/shopify_setting.py index dc974e70e..49b7f1133 100644 --- a/ecommerce_integrations/shopify/doctype/shopify_setting/shopify_setting.py +++ b/ecommerce_integrations/shopify/doctype/shopify_setting/shopify_setting.py @@ -25,6 +25,7 @@ ORDER_STATUS_FIELD, SUPPLIER_ID_FIELD, ) +from ecommerce_integrations.shopify.oauth import validate_oauth_credentials from ecommerce_integrations.shopify.utils import ( ensure_old_connector_is_disabled, migrate_from_old_connector, @@ -35,11 +36,30 @@ class ShopifySetting(SettingController): def is_enabled(self) -> bool: return bool(self.enable_shopify) + def _get_password_safe(self, fieldname: str) -> str: + """ + Safely get password field value without raising exceptions. + Returns empty string if password doesn't exist or document is new. + """ + try: + # Check if document is saved + if not self.name or self.is_new(): + return "" + + password = self.get_password(fieldname, raise_exception=False) + return password if password else "" + except Exception: + return "" + def validate(self): ensure_old_connector_is_disabled() if self.shopify_url: - self.shopify_url = self.shopify_url.replace("https://", "") + self.shopify_url = self.shopify_url.replace("https://", "").replace("http://", "") + + self._set_default_authentication_method() + self._validate_authentication_fields() + self._validate_oauth_credentials_if_needed() self._handle_webhooks() self._validate_warehouse_links() self._initalize_default_values() @@ -51,9 +71,97 @@ def on_update(self): if self.is_enabled() and not self.is_old_data_migrated: migrate_from_old_connector() + def _set_default_authentication_method(self): + """Set default authentication method for existing documents.""" + if not self.authentication_method: + self.authentication_method = "Static Token" + + def _validate_authentication_fields(self): + """Validate that required fields are present based on authentication method.""" + if not self.is_enabled(): + return + + if self.authentication_method == "Static Token": + # Check password field exists and has value + password = self._get_password_safe("password") + if not password: + frappe.throw(_("Password / Access Token is required for Static Token authentication")) + + if not self.shared_secret: + frappe.throw(_("Shared secret / API Secret is required for Static Token authentication")) + + elif self.authentication_method == "OAuth 2.0 Client Credentials": + if not self.client_id: + frappe.throw(_("Client ID is required for OAuth 2.0 authentication")) + + # Check client_secret field exists and has value + client_secret = self._get_password_safe("client_secret") + if not client_secret: + frappe.throw(_("Client Secret is required for OAuth 2.0 authentication")) + + def _validate_oauth_credentials_if_needed(self): + """Validate OAuth credentials by generating a test token if credentials changed.""" + if not self.is_enabled(): + return + + if self.authentication_method != "OAuth 2.0 Client Credentials": + return + + # Check if OAuth credentials have changed + if self.has_value_changed("client_id") or self.has_value_changed("client_secret"): + client_secret = self._get_password_safe("client_secret") + if not client_secret: + return # Will be caught by _validate_authentication_fields + + try: + # Validate credentials by attempting to generate a token + 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: + # Error is already logged by validate_oauth_credentials + raise + + def before_save(self): + """Optional: Pre-generate OAuth token for better UX.""" + if not self.is_enabled(): + return + + if self.authentication_method == "OAuth 2.0 Client Credentials": + # Pre-generate token if credentials are new or changed + # This is optional - token will be generated on-demand if not done here + current_token = self._get_password_safe("oauth_access_token") + + if ( + self.has_value_changed("client_id") + or self.has_value_changed("client_secret") + or not current_token + ): + try: + self._get_or_generate_oauth_token() + except Exception: + # Don't block save, token will be generated on-demand when needed + pass + def _handle_webhooks(self): + """Handle webhook registration/unregistration. Uses appropriate token based on auth method.""" if self.is_enabled() and not self.webhooks: - new_webhooks = connection.register_webhooks(self.shopify_url, self.get_password("password")) + # Get the appropriate password/token for webhook registration + if self.authentication_method == "OAuth 2.0 Client Credentials": + # For OAuth, get or generate a valid token + password = self._get_or_generate_oauth_token() + else: + # For Static Token, use the password field + password = self.get_password("password") + + new_webhooks = connection.register_webhooks(self.shopify_url, password) if not new_webhooks: msg = _("Failed to register webhooks with Shopify.") + "
" @@ -65,10 +173,43 @@ def _handle_webhooks(self): self.append("webhooks", {"webhook_id": webhook.id, "method": webhook.topic}) elif not self.is_enabled(): - connection.unregister_webhooks(self.shopify_url, self.get_password("password")) + # Get the appropriate password/token for webhook unregistration + if self.authentication_method == "OAuth 2.0 Client Credentials": + password = self._get_password_safe("oauth_access_token") + else: + password = self._get_password_safe("password") + + if password: # Only unregister if we have a password + connection.unregister_webhooks(self.shopify_url, password) self.webhooks = list() # remove all webhooks + def _get_or_generate_oauth_token(self) -> str: + """ + Get OAuth token if valid, or generate a new one if expired/missing. + This ensures we always have a valid token when needed. + """ + from ecommerce_integrations.shopify.oauth import is_token_valid, refresh_oauth_token + + # Check if we have a valid token + current_token = self._get_password_safe("oauth_access_token") + token_expiry = self.token_expires_at + + # If token exists and is valid, return it + if current_token and is_token_valid(token_expiry): + return current_token + + # Token is missing, expired, or expiring soon - generate new one + try: + # Generate new token and save it + new_token = refresh_oauth_token(self) + return new_token + except Exception as e: + frappe.throw( + _("Failed to generate OAuth token: {0}").format(str(e)), + title=_("OAuth Authentication Error"), + ) + def _validate_warehouse_links(self): for wh_map in self.shopify_warehouse_mapping: if not wh_map.erpnext_warehouse: diff --git a/ecommerce_integrations/shopify/oauth.py b/ecommerce_integrations/shopify/oauth.py new file mode 100644 index 000000000..68184c221 --- /dev/null +++ b/ecommerce_integrations/shopify/oauth.py @@ -0,0 +1,259 @@ +# Copyright (c) 2026, Frappe and contributors +# For license information, please see LICENSE + +""" +OAuth 2.0 Client Credentials Flow for Shopify Apps +Implements token generation and refresh for apps created via Shopify Dev Dashboard +""" + +import json +import time +from datetime import datetime, timedelta + +import frappe +import requests +from frappe import _ +from frappe.utils import get_datetime, get_datetime_str, now_datetime +from frappe.utils.password import set_encrypted_password + +from ecommerce_integrations.shopify.utils import create_shopify_log + + +def get_oauth_token_endpoint(shopify_url: str) -> str: + """ + Construct the OAuth token endpoint URL for a given shop. + + Args: + shopify_url: The shop URL (e.g., 'example.myshopify.com') + + Returns: + Full OAuth token endpoint URL + """ + shop_url = shopify_url.replace("https://", "").replace("http://", "") + return f"https://{shop_url}/admin/oauth/access_token" + + +def generate_oauth_token(shopify_url: str, client_id: str, client_secret: str) -> dict: + """ + Generate a new OAuth 2.0 access token using client credentials flow. + + Args: + shopify_url: The shop URL + client_id: OAuth client ID from Shopify Partner Dashboard + client_secret: OAuth client secret from Shopify Partner Dashboard + + Returns: + Dictionary containing: + - access_token: The OAuth access token + - expires_in: Token validity duration in seconds (86399 = 24 hours) + - scope: Granted scopes + + Raises: + frappe.ValidationError: If token generation fails + """ + token_endpoint = get_oauth_token_endpoint(shopify_url) + + payload = { + "grant_type": "client_credentials", + "client_id": client_id, + "client_secret": client_secret, + } + + headers = { + "Content-Type": "application/x-www-form-urlencoded", + } + + try: + response = requests.post(token_endpoint, data=payload, headers=headers, timeout=30) + response.raise_for_status() + + token_data = response.json() + + # Log successful token generation + create_shopify_log( + status="Success", + method="ecommerce_integrations.shopify.oauth.generate_oauth_token", + message=_("OAuth token generated successfully"), + ) + + return token_data + + except requests.exceptions.RequestException as e: + error_message = str(e) + error_response = None + + if hasattr(e, "response") and e.response is not None: + try: + error_response = e.response.json() + error_message = error_response.get("error_description", error_response.get("error", str(e))) + except json.JSONDecodeError: + error_message = e.response.text or str(e) + + # Sanitize payload before logging - remove sensitive credentials + sanitized_payload = payload.copy() + sanitized_payload["client_secret"] = "REDACTED" + + # Log the error + create_shopify_log( + status="Error", + method="ecommerce_integrations.shopify.oauth.generate_oauth_token", + message=_("Failed to generate OAuth token"), + exception=error_message, + request_data=sanitized_payload, + response_data=error_response, + ) + + frappe.throw( + _("Failed to generate OAuth token: {0}").format(error_message), + title=_("OAuth Authentication Error"), + ) + + +def is_token_valid(token_expires_at: datetime, buffer_minutes: int = 5) -> bool: + """ + Check if the OAuth token is still valid with a buffer period. + + Args: + token_expires_at: Datetime when the token expires + buffer_minutes: Minutes before expiry to consider token invalid (default: 5) + + Returns: + True if token is valid and not expiring soon, False otherwise + """ + if not token_expires_at: + return False + + expiry_datetime = get_datetime(token_expires_at) + buffer_time = now_datetime() + timedelta(minutes=buffer_minutes) + + return expiry_datetime > buffer_time + + +def calculate_token_expiry(expires_in_seconds: int) -> datetime: + """ + Calculate the exact expiry datetime for a token. + + Args: + expires_in_seconds: Validity duration in seconds (typically 86399 for Shopify) + + Returns: + Datetime when the token will expire + """ + return now_datetime() + timedelta(seconds=expires_in_seconds) + + +def refresh_oauth_token(setting) -> str: + """ + Refresh the OAuth token and update the setting document. + This is called when the token is expired or about to expire. + + Args: + setting: ShopifySetting document instance + + Returns: + The new access token + + Raises: + frappe.ValidationError: If token refresh fails + """ + if setting.authentication_method != "OAuth 2.0 Client Credentials": + frappe.throw( + _("Token refresh is only applicable for OAuth 2.0 authentication"), + title=_("Invalid Authentication Method"), + ) + + # Check one more time with fresh data + setting.reload() + + # Get fresh token + token_data = generate_oauth_token( + setting.shopify_url, + setting.client_id, + setting.get_password("client_secret"), + ) + + # Calculate expiry time + expires_at = calculate_token_expiry(token_data.get("expires_in", 86399)) + + set_encrypted_password( + "Shopify Setting", + setting.name, + token_data["access_token"], + fieldname="oauth_access_token", + ) + + frappe.db.set_value( + "Shopify Setting", + setting.name, + "token_expires_at", + get_datetime_str(expires_at), + update_modified=False, + ) + + setting.reload() + + return token_data["access_token"] + + +def get_valid_access_token(setting) -> str: + """ + Get a valid OAuth access token, refreshing if necessary. + + Args: + setting: ShopifySetting document instance + + Returns: + A valid access token ready to use + + Raises: + frappe.ValidationError: If unable to get a valid token + """ + if setting.authentication_method != "OAuth 2.0 Client Credentials": + frappe.throw( + _("This method is only for OAuth 2.0 authentication"), + title=_("Invalid Authentication Method"), + ) + + # Check if we already have a valid token + if is_token_valid(setting.token_expires_at): + current_token = setting.get_password("oauth_access_token", raise_exception=False) + if current_token: + return current_token + + # Token is invalid/missing - refresh it + try: + return refresh_oauth_token(setting) + except Exception as e: + # Single retry for transient network issues + create_shopify_log( + status="Warning", + method="ecommerce_integrations.shopify.oauth.get_valid_access_token", + message=_("Token refresh failed, retrying once..."), + exception=str(e), + ) + time.sleep(1) # Brief pause + return refresh_oauth_token(setting) # Let this throw if it fails + + +def validate_oauth_credentials(shopify_url: str, client_id: str, client_secret: str) -> bool: + """ + Validate OAuth credentials by attempting to generate a token. + Used during setup to verify credentials are correct. + + Args: + shopify_url: The shop URL + client_id: OAuth client ID + client_secret: OAuth client secret + + Returns: + True if credentials are valid + + Raises: + frappe.ValidationError: If credentials are invalid + """ + try: + token_data = generate_oauth_token(shopify_url, client_id, client_secret) + return bool(token_data.get("access_token")) + except Exception: + # Error is already logged and thrown by generate_oauth_token + raise