From e4d63b05460708f7ce875fe25253a4154c86009b Mon Sep 17 00:00:00 2001 From: Josef Rousek Date: Mon, 30 Sep 2024 09:27:23 +0200 Subject: [PATCH 1/5] Add support for managed install --- shopify_auth/apps.py | 2 + shopify_auth/checks.py | 23 ++++++++ .../session_tokens/managed_install.py | 51 +++++++++++++++++ .../tests/test_managed_install.py | 57 +++++++++++++++++++ .../tests/test_session_token_bounce_view.py | 15 +++++ shopify_auth/session_tokens/urls.py | 1 + shopify_auth/session_tokens/views.py | 15 +++++ 7 files changed, 164 insertions(+) create mode 100644 shopify_auth/checks.py create mode 100644 shopify_auth/session_tokens/managed_install.py create mode 100644 shopify_auth/session_tokens/tests/test_managed_install.py create mode 100644 shopify_auth/session_tokens/tests/test_session_token_bounce_view.py diff --git a/shopify_auth/apps.py b/shopify_auth/apps.py index 1fbe499..158a493 100644 --- a/shopify_auth/apps.py +++ b/shopify_auth/apps.py @@ -18,6 +18,8 @@ def ready(self): The ready() method is called after Django setup. """ initialise_shopify_session() + import shopify_auth.checks + def initialise_shopify_session(): diff --git a/shopify_auth/checks.py b/shopify_auth/checks.py new file mode 100644 index 0000000..c9a2300 --- /dev/null +++ b/shopify_auth/checks.py @@ -0,0 +1,23 @@ +from django.core.checks import Error, register +from django.conf import settings + +@register() +def check_shopify_auth_bounce_page_url(app_configs, **kwargs): + errors = [] + if not hasattr(settings, 'SHOPIFY_AUTH_BOUNCE_PAGE_URL'): + errors.append( + Error( + 'SHOPIFY_AUTH_BOUNCE_PAGE_URL is not set in settings.', + hint='Set SHOPIFY_AUTH_BOUNCE_PAGE_URL in your settings file or environment variables.', + id='shopify_auth.E001', + ) + ) + elif not settings.SHOPIFY_AUTH_BOUNCE_PAGE_URL: + errors.append( + Error( + 'SHOPIFY_AUTH_BOUNCE_PAGE_URL is empty.', + hint='Provide a valid URL for SHOPIFY_AUTH_BOUNCE_PAGE_URL in your settings file or environment variables.', + id='shopify_auth.E002', + ) + ) + return errors \ No newline at end of file diff --git a/shopify_auth/session_tokens/managed_install.py b/shopify_auth/session_tokens/managed_install.py new file mode 100644 index 0000000..4d22260 --- /dev/null +++ b/shopify_auth/session_tokens/managed_install.py @@ -0,0 +1,51 @@ +import logging +from django.core.exceptions import ImproperlyConfigured + +import requests +from django.conf import settings +from django.http import HttpRequest, HttpResponse +from django.shortcuts import redirect + +logger = logging.getLogger(__name__) + +from typing import TypedDict + + +class ResponseData(TypedDict): + access_token: str + scope: list[str] + + +# From https://shopify.dev/docs/apps/build/authentication-authorization/get-access-tokens/exchange-tokens#step-2-get-an-access-token +def retrieve_api_token(shop: str, session_token: str) -> ResponseData: + url = f"https://{shop}/admin/oauth/access_token" + payload = { + "client_id": settings.SHOPIFY_APP_API_KEY, + "client_secret": settings.SHOPIFY_APP_API_SECRET, + "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", + "subject_token": session_token, + "subject_token_type": "urn:ietf:params:oauth:token-type:id_token", + "requested_token_type": "urn:shopify:params:oauth:token-type:offline-access-token", + } + headers = {"Content-Type": "application/json", "Accept": "application/json"} + response = requests.post(url, json=payload, headers=headers) + response.raise_for_status() + response_data_raw = response.json() + response_data: ResponseData = { + "access_token": response_data_raw["access_token"], + "scope": [scope.strip() for scope in response_data_raw["scope"].split(",")], + } + return response_data + + +def session_token_bounce_page_url(request: HttpRequest) -> str: + search_params = request.GET.copy() + search_params.pop("id_token", None) + search_params["shopify-reload"] = f"{request.path}?{search_params.urlencode()}" + + bounce_page_url = settings.SHOPIFY_AUTH_BOUNCE_PAGE_URL + return f"{bounce_page_url}?{search_params.urlencode()}" + + +def redirect_to_session_token_bounce_page(request: HttpRequest) -> HttpResponse: + return redirect(session_token_bounce_page_url(request)) diff --git a/shopify_auth/session_tokens/tests/test_managed_install.py b/shopify_auth/session_tokens/tests/test_managed_install.py new file mode 100644 index 0000000..5fee221 --- /dev/null +++ b/shopify_auth/session_tokens/tests/test_managed_install.py @@ -0,0 +1,57 @@ +from django.test import TestCase, RequestFactory +from django.core.exceptions import ImproperlyConfigured +from unittest.mock import patch + +from ..managed_install import ( + retrieve_api_token, + session_token_bounce_page_url, + redirect_to_session_token_bounce_page, +) + +class ManagedInstallTestCase(TestCase): + def setUp(self): + self.factory = RequestFactory() + + @patch('requests.post') + def test_retrieve_api_token(self, mock_post): + # Mock the response from Shopify + mock_response = mock_post.return_value + mock_response.json.return_value = { + 'access_token': 'test_token', + 'scope': 'read_products,write_orders' + } + + result = retrieve_api_token('test-shop.myshopify.com', 'test_session_token') + + self.assertEqual(result['access_token'], 'test_token') + self.assertEqual(result['scope'], ['read_products', 'write_orders']) + + # Check if the request was made with correct parameters + mock_post.assert_called_once() + args, kwargs = mock_post.call_args + self.assertEqual(args[0], 'https://test-shop.myshopify.com/admin/oauth/access_token') + + def test_session_token_bounce_page_url(self): + request = self.factory.get('/test-path/?param1=value1&id_token=test_token') + + with self.settings(SHOPIFY_AUTH_BOUNCE_PAGE_URL='/bounce/'): + url = session_token_bounce_page_url(request) + + expected_url = '/bounce/?param1=value1&shopify-reload=%2Ftest-path%2F%3Fparam1%3Dvalue1' + self.assertEqual(url, expected_url) + + def test_session_token_bounce_page_url_missing_setting(self): + request = self.factory.get('/test-path/') + + with self.settings(SHOPIFY_AUTH_BOUNCE_PAGE_URL=None): + with self.assertRaises(ImproperlyConfigured): + session_token_bounce_page_url(request) + + def test_redirect_to_session_token_bounce_page(self): + request = self.factory.get('/test-path/') + + with self.settings(SHOPIFY_AUTH_BOUNCE_PAGE_URL='/bounce/'): + response = redirect_to_session_token_bounce_page(request) + + self.assertEqual(response.status_code, 302) + self.assertTrue(response.url.startswith('/bounce/')) diff --git a/shopify_auth/session_tokens/tests/test_session_token_bounce_view.py b/shopify_auth/session_tokens/tests/test_session_token_bounce_view.py new file mode 100644 index 0000000..3cd0d6c --- /dev/null +++ b/shopify_auth/session_tokens/tests/test_session_token_bounce_view.py @@ -0,0 +1,15 @@ +from django.test import TestCase, RequestFactory +from django.conf import settings +from django.http import HttpResponse + +from ..views import session_token_bounce + +class SessionTokenBounceTestCase(TestCase): + def test_session_token_bounce(self): + request = RequestFactory().get('/bounce/') + response = session_token_bounce(request) + + self.assertIsInstance(response, HttpResponse) + self.assertEqual(response['Content-Type'], 'text/html') + self.assertIn(settings.SHOPIFY_APP_API_KEY, response.content.decode()) + self.assertIn('https://cdn.shopify.com/shopifycloud/app-bridge.js', response.content.decode()) \ No newline at end of file diff --git a/shopify_auth/session_tokens/urls.py b/shopify_auth/session_tokens/urls.py index 8721de9..e2abd58 100644 --- a/shopify_auth/session_tokens/urls.py +++ b/shopify_auth/session_tokens/urls.py @@ -7,4 +7,5 @@ urlpatterns = [ path("finalize", views.FinalizeAuthView.as_view(), name="finalize"), path("authenticate", views.get_scope_permission, name="authenticate"), + path("session-token-bounce", views.session_token_bounce, name="session-token-bounce"), ] diff --git a/shopify_auth/session_tokens/views.py b/shopify_auth/session_tokens/views.py index 9f931ff..7165625 100644 --- a/shopify_auth/session_tokens/views.py +++ b/shopify_auth/session_tokens/views.py @@ -69,3 +69,18 @@ def get(self, request): return HttpResponseRedirect( f"https://{myshopify_domain}/admin/apps/{settings.SHOPIFY_APP_API_KEY}" ) + + +def session_token_bounce(request) -> HttpResponse: + """ + The entire flow is documented on https://shopify.dev/docs/apps/build/authentication-authorization/set-embedded-app-authorization?extension=javascript#session-token-in-the-url-parameter + """ + response = HttpResponse(content_type="text/html") + html = f""" + + + + + """ + response.write(html) + return response From 7574764f6952aeed2a6b2970f53a5ddadb44ea61 Mon Sep 17 00:00:00 2001 From: Josef Rousek Date: Mon, 30 Sep 2024 11:21:23 +0200 Subject: [PATCH 2/5] Fix tests --- requirements.txt | 1 + shopify_auth/session_tokens/tests/test_managed_install.py | 7 ------- test.py | 1 + 3 files changed, 2 insertions(+), 7 deletions(-) diff --git a/requirements.txt b/requirements.txt index cb50fc0..7505e1b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,4 @@ django >=3.2 ShopifyAPI >=8.0.0 ua-parser python-jose +requests \ No newline at end of file diff --git a/shopify_auth/session_tokens/tests/test_managed_install.py b/shopify_auth/session_tokens/tests/test_managed_install.py index 5fee221..fdc6bf3 100644 --- a/shopify_auth/session_tokens/tests/test_managed_install.py +++ b/shopify_auth/session_tokens/tests/test_managed_install.py @@ -40,13 +40,6 @@ def test_session_token_bounce_page_url(self): expected_url = '/bounce/?param1=value1&shopify-reload=%2Ftest-path%2F%3Fparam1%3Dvalue1' self.assertEqual(url, expected_url) - def test_session_token_bounce_page_url_missing_setting(self): - request = self.factory.get('/test-path/') - - with self.settings(SHOPIFY_AUTH_BOUNCE_PAGE_URL=None): - with self.assertRaises(ImproperlyConfigured): - session_token_bounce_page_url(request) - def test_redirect_to_session_token_bounce_page(self): request = self.factory.get('/test-path/') diff --git a/test.py b/test.py index 430913f..086fcd1 100644 --- a/test.py +++ b/test.py @@ -30,6 +30,7 @@ 'SHOPIFY_APP_API_SCOPE': ['read_products'], 'SHOPIFY_APP_DEV_MODE': False, 'SHOPIFY_APP_THIRD_PARTY_COOKIE_CHECK': True, + 'SHOPIFY_AUTH_BOUNCE_PAGE_URL': '/', 'SECRET_KEY': 'uq8e140t1rm3^kk&blqxi*y9h_j5yd9ghjv+fd1p%08g4%t6%i', 'MIDDLEWARE': [ 'django.middleware.common.CommonMiddleware', From 968897bd1b07bf6604d253b5a89e384e0c4f508d Mon Sep 17 00:00:00 2001 From: Josef Rousek Date: Mon, 30 Sep 2024 11:29:09 +0200 Subject: [PATCH 3/5] Update test matrix --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 03afd77..1c67c2c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,8 +12,8 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.8", "3.9", "3.10", "3.11"] - django: ["Django<4.0", "Django<4.1", "Django<4.2"] + python-version: ["3.9", "3.10", "3.11", "3.12"] + django: ["Django<5.0", "Django<5.1", "Django<5.2"] steps: - uses: actions/checkout@v3 From dea4fd0c5397bed2c0073b36e7ace73a109632c2 Mon Sep 17 00:00:00 2001 From: Josef Rousek Date: Mon, 30 Sep 2024 12:55:16 +0200 Subject: [PATCH 4/5] Mention managed install --- shopify_auth/session_tokens/README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/shopify_auth/session_tokens/README.md b/shopify_auth/session_tokens/README.md index f066f58..9f73d0b 100644 --- a/shopify_auth/session_tokens/README.md +++ b/shopify_auth/session_tokens/README.md @@ -6,6 +6,9 @@ This app takes care of the installation and provides middleware that adds a user I created a [demo app](https://github.com/digismoothie/django-session-token-auth-demo) that uses Hotwire, successor of Turbolinks. +> [!NOTE] +> Managed installation is much more involved because there's no speficic install entrypoint. The main entrypoint is used instead. For now you can use managed_install.py and session_token_bounce view. + ### Instalation ### 1. Install package From bcf6fa6d77218522b2e57e2bfbe5a72f7afb3b46 Mon Sep 17 00:00:00 2001 From: Josef Rousek Date: Mon, 30 Sep 2024 12:57:20 +0200 Subject: [PATCH 5/5] Update setup.py --- setup.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index fa76cc4..37ed14b 100644 --- a/setup.py +++ b/setup.py @@ -19,10 +19,11 @@ }, install_requires=[ - 'django >=3.2', + 'django >=4.2', 'ShopifyAPI >=8.0.0', 'setuptools >=5.7', - 'python-jose >=3.2.0' + 'python-jose >=3.2.0', + 'requests >=2.0.0', ], tests_require=[],