Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for managed install #88

Open
wants to merge 5 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ django >=3.2
ShopifyAPI >=8.0.0
ua-parser
python-jose
requests
5 changes: 3 additions & 2 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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=[],
Expand Down
2 changes: 2 additions & 0 deletions shopify_auth/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
23 changes: 23 additions & 0 deletions shopify_auth/checks.py
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions shopify_auth/session_tokens/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
51 changes: 51 additions & 0 deletions shopify_auth/session_tokens/managed_install.py
Original file line number Diff line number Diff line change
@@ -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))
50 changes: 50 additions & 0 deletions shopify_auth/session_tokens/tests/test_managed_install.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
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_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/'))
Original file line number Diff line number Diff line change
@@ -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())
1 change: 1 addition & 0 deletions shopify_auth/session_tokens/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
]
15 changes: 15 additions & 0 deletions shopify_auth/session_tokens/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"""
<head>
<meta name="shopify-api-key" content="{settings.SHOPIFY_APP_API_KEY}" />
<script src="https://cdn.shopify.com/shopifycloud/app-bridge.js"></script>
</head>
"""
response.write(html)
return response
1 change: 1 addition & 0 deletions test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading