├── .github └── workflows │ └── ci.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── MANIFEST.in ├── README.md ├── requirements.txt ├── setup.py ├── shopify_auth ├── __init__.py ├── apps.py ├── backends.py ├── checks.py ├── context_processors.py ├── decorators.py ├── helpers.py ├── mixins.py ├── models.py ├── session_tokens │ ├── README.md │ ├── __init__.py │ ├── apps.py │ ├── authentication.py │ ├── context_processors.py │ ├── managed_install.py │ ├── middleware.py │ ├── models.py │ ├── tests │ │ ├── __init__.py │ │ ├── test_authentication.py │ │ ├── test_finalize_view.py │ │ ├── test_get_scope_permission.py │ │ ├── test_managed_install.py │ │ └── test_session_token_bounce_view.py │ ├── urls.py │ └── views.py ├── templates │ └── shopify_auth │ │ ├── iframe_redirect.html │ │ └── login.html ├── tests │ ├── __init__.py │ ├── test_helpers.py │ ├── test_user.py │ ├── test_views.py │ └── urls.py ├── urls.py └── views.py └── test.py /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | python-version: ["3.9", "3.10", "3.11", "3.12"] 16 | django: ["Django<5.0", "Django<5.1", "Django<5.2"] 17 | 18 | steps: 19 | - uses: actions/checkout@v3 20 | - name: Set up Python ${{ matrix.python-version }} 21 | uses: actions/setup-python@v4 22 | with: 23 | python-version: ${{ matrix.python-version }} 24 | - name: Install dependencies 25 | run: | 26 | python -m pip install --upgrade pip 27 | pip install "${{ matrix.django }}" 28 | pip install -r requirements.txt 29 | - name: Tests 30 | run: | 31 | python test.py 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .python-version 3 | *.pyc 4 | .eggs 5 | MANIFEST 6 | build 7 | dist 8 | django_shopify_auth.egg-info 9 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | All notable changes to this project will be documented in this file. 3 | 4 | ## Unreleased 5 | No unreleased changes. 6 | 7 | ## 2.1.2 - 2025-01-17 8 | 9 | ### Changed 10 | - Replaced 'python-jose' with 'pyjwt' because 'python-jose' is no longer maintained. 11 | 12 | 13 | ## 2.1.1 - 2024-10-02 14 | 15 | ### Changed 16 | - Reverted Django version requirement back to 3.2. 17 | 18 | 19 | ## 2.1.0 - 2024-10-02 20 | 21 | ### Added 22 | - Added utility functions for managed app installation. 23 | 24 | 25 | ## 2.0.1 - 2024-06-14 26 | 27 | ### Changed 28 | - Shopify UI extensions send tokens without iss key. To prevent the middleware from raising exceptions for these requests we're now ignoring missing iss key. 29 | 30 | 31 | ## 2.0.0 - 2023-01-10 32 | 33 | ### Removed 34 | - Removed support for cookie based embedded apps. 35 | ### Changed 36 | - Migrate redirect calls to App Bridge 3. 37 | - The template `iframe_redirect.html` now expects `host` parameter, not `shop`. 38 | - `session_tokens.views.get_scope_permission` now detects `embedded` query parameter. 39 | - `session_tokens.views.FinalizeAuthView` now redirects to the domain defined in `host` query parameter if present. 40 | 41 | 42 | ## 1.2.3 - 2022-04-14 43 | 44 | ### Changed 45 | - Don't escape redirect_uri in iframe_redirect.html. Shopify gracefully handles HTML encoded URLs, but occasionally it doesn't. 46 | - Update URL definitions to be compatible with Django 4. 47 | 48 | ## 1.2.2 - 2022-02-28 49 | 50 | ### Changed 51 | - Migrate redirect calls to App Bridge. 52 | 53 | ## 1.2.1 - 2021-11-08 54 | 55 | ### Added 56 | - Added function allowing to get the user instance using the session token string. 57 | - Added session_tokens app specific `context_processors`. 58 | 59 | ## 1.2.0 - 2021-08-17 60 | 61 | ### Added 62 | - Added a way to customize user creation. 63 | - Session Token Auth now supports App Bridge 2. 64 | 65 | 66 | ## 1.1.1 - 2021-04-16 67 | ### Changed 68 | - Fixed session tokens DRF backend authentication return value. 69 | 70 | ## 1.1.0 - 2021-04-15 71 | ### Added 72 | - Added support for session token auth. 73 | 74 | ## 1.0.2 - 2021-02-19 75 | ### Changed 76 | - Add forgotten dependency and update other ones. 77 | 78 | ## 1.0.1 - 2020-12-09 79 | ### Changed 80 | - Fix issue where `next` parameter wasn't honored when `SHOPIFY_APP_THIRD_PARTY_COOKIE_CHECK` is enabled. 81 | ### Added 82 | - Added a middleware to accommodate changes to SameSite policy. 83 | 84 | ## 1.0.0 - 2020-12-06 85 | ### Changed 86 | - 3rd party cookie check is now disabled by default as it doesn't pass Shopify's review check. 87 | - Increased the token field max_length 88 | - Updated test matrix to test only supported Django and Python versions 89 | 90 | ### Added 91 | - Added a mixin which verifies that the current shop is authenticated and app is installed in shopify. 92 | ### Removed 93 | - Dropped support for Django 1.x 94 | 95 | ## 0.9.1 - 2020-03-25 96 | ### Added 97 | - Detection if third party cookies are allowed 98 | 99 | ### Removed 100 | - Dropped support for Python 3.4 101 | 102 | ## 0.9.0 - 2019-12-22 103 | ### Added 104 | - Support for Django 3 by removing Python 2 support 105 | 106 | ### Removed 107 | - Dropped support for Python 2 108 | 109 | ## 0.8.2 - 2019-09-24 110 | ### Added 111 | - Support for `return_to` redirect parameter. 112 | - Added Django v2.1, v2.2 and v2.3 to test matrix. 113 | 114 | ### Removed 115 | - Support for deprecated Django versions 1.x. 116 | 117 | ## 0.8.1 - 2019-07-01 118 | ### Changed 119 | - Added support for Shopify API versioning. 120 | 121 | ## 0.8.0 - 2018-05-31 122 | ### Changed 123 | - Added support for Django 2.0. 124 | 125 | ## 0.7.0 - 2017-11-24 126 | ### Changed 127 | - Improved support for later Django versions 128 | 129 | ## 0.6.0 - 2017-11-07 130 | ### Removed 131 | - Support for deprecated Django versions 1.7, 1.9 132 | - Removed Python 3.3 from test matrix 133 | 134 | ## 0.5.0 - 2017-03-01 135 | ### Changed 136 | - Updated authentication redirect for Chrome `postMessage` change 137 | 138 | ## 0.4.8 - 2016-11-11 139 | ### Changed 140 | - Bugfix with items()/iteritems() dependence in `login_required` decorator 141 | 142 | ## 0.4.7 - 2016-10-19 143 | ### Added 144 | - Django 1.10 support 145 | 146 | ### Removed 147 | - Dependence on `six` package 148 | 149 | ## 0.4.6 - 2015-12-25 150 | ### Changed 151 | - Fix OAuth regression 152 | 153 | ## 0.4.5 - 2015-12-23 154 | ### Changed 155 | - Include templates in package 156 | 157 | ## 0.4.4 - 2015-12-19 158 | ### Changed 159 | - *Actual* Django 1.9 compatibility 160 | 161 | ## 0.4.3 - 2015-12-19 162 | ### Changed 163 | - Django 1.9 compatibility 164 | - Better PEP8 conformity 165 | 166 | ## 0.4.2 - 2015-05-17 167 | ### Changed 168 | - More Python 3 support 169 | 170 | ## 0.4.1 - 2015-05-17 171 | ### Changed 172 | - Improve Python 3 support 173 | 174 | ## 0.4.0 - 2015-05-07 175 | ### Changed 176 | - Updated ShopifyAPI dependency to v2.1.2 177 | 178 | ## 0.3.1 - 2014-12-06 179 | ### Added 180 | - This new-format CHANGELOG, based on http://keepachangelog.com 181 | 182 | ### Changed 183 | - AbstractShopUser.token now has a default value to allow easy resetting 184 | - Improvements to README 185 | 186 | ## 0.3.0 - 2014-10-22 187 | ### Added 188 | - Context processor to add common variables to templates 189 | 190 | ### Changed 191 | - Major rewrite and update of README 192 | 193 | ## 0.2.5 - 2014-09-28 194 | ### Added 195 | - Support for Django 1.7 196 | - A `login_required` decorator to handle passing off Shopify authentication parameters to the login URL 197 | 198 | ### Removed 199 | - Support for any version of Django < 1.7 200 | 201 | ### Fixed 202 | - Numerous issues with packaging and distribution of templates 203 | 204 | ## 0.1.6 - 2014-08-25 205 | ### Fixed 206 | - Move imports inside initialize() method so that we don’t break things on initial setup 207 | 208 | ## 0.1.5 - 2014-08-25 209 | ### Added 210 | - Added `api` module for TastyPie-supported models 211 | - Initialise properly 212 | - Add dynamic `session` property to user 213 | 214 | ### Changed 215 | - Swapped to setuptools for distribution 216 | 217 | ## 0.1.0 - 2014-04-04 218 | ### Added 219 | - Initial package structure 220 | - Custom models and decorators to support Shopify authentication 221 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014-2015 Gavin Ballard 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.md 3 | recursive-include shopify_auth *.html 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Django Shopify Auth 2 | =================== 3 | 4 | [![PyPI version](https://badge.fury.io/py/django-shopify-auth.svg)](http://badge.fury.io/py/django-shopify-auth) 5 | ![example workflow](https://github.com/discolabs/django-shopify-auth/actions/workflows/ci.yml/badge.svg) 6 | 7 | This Django package makes it easy to integrate Shopify authentication into your Django app. 8 | 9 | * It provides a custom Django Authentication scheme based on `AbstractBaseUser` and `RemoteUserBackend`, meaning shops 10 | will be authenticated as "users" of your Django app. This makes it easier to use common Django patterns and libraries 11 | (such as accessing the currently authenticated store as `request.user`). 12 | 13 | * It persists users' Shopify access tokens in the database, rather than in the Session, meaning your app will be able 14 | to make API calls on behalf of a user when they're not logged in. 15 | 16 | * It supports the token-based authentication flow for Embedded Shopify apps. 17 | 18 | Embedded vs Standalone apps 19 | -------------- 20 | 21 | Session token-based authentication is now required for embedded apps. Support for it is implemented in [separate app](shopify_auth/session_tokens/). Read [this](https://shopify.dev/apps/getting-started/app-types#embedded-apps) if you're not sure what approach to use. 22 | 23 | Requirements 24 | ------------ 25 | Tests are run against Django versions defined in `.github/workflows/ci.yml`. This package may work for 26 | other Django versions but it's not guaranteed. 27 | 28 | You'll need a [Shopify partner account](http://shopify.com/partners) and to have created an app in order to get an API key and secret. 29 | 30 | 31 | Package Installation and Setup - Standalone app 32 | ------------------------------ 33 | There are a few moving parts to set up, but hopefully the following instructions will make things straightforward. 34 | 35 | We're assuming in this setup that you're using a standard Django project layout (the sort that's created with the 36 | `django-admin.py startproject` command). We're also assuming that our project is called `auth_demo` and that the primary 37 | Django app inside our project is going to be called `auth_app`. 38 | 39 | 40 | ### 1. Install package 41 | Installation is super easy via `pip`: 42 | 43 | ```shell 44 | > pip install django-shopify-auth 45 | ``` 46 | 47 | Once you have the package installed, add `shopify_auth` to your `INSTALLED_APPS`. 48 | 49 | 50 | ### 2. Add custom user model 51 | Because `shopify_auth` makes use of Django's authentication system, it provides a custom authentication backend 52 | (`shopify_auth.backends.ShopUserBackend`) which allows authentication through Shopify's OAuth flow. 53 | 54 | This backend requires that the user model for your app (specified by `AUTH_USER_MODEL` in your `settings.py`) inherits 55 | from `shopify_auth.models.AbstractShopUser`. To do this, just add something like this to the `models.py` for your 56 | Django app: 57 | 58 | ```python 59 | # auth_demo/auth_app/models.py 60 | from shopify_auth.models import AbstractShopUser 61 | 62 | class AuthAppShopUser(AbstractShopUser): 63 | pass 64 | ``` 65 | 66 | Before moving forward, ensure this model has been added to the database by running 67 | ``` 68 | python manage.py makemigrations 69 | python manage.py migrate 70 | ``` 71 | 72 | Then make sure that you have the following line or similar in `settings.py`: 73 | 74 | ```python 75 | AUTH_USER_MODEL = 'auth_app.AuthAppShopUser' 76 | ``` 77 | 78 | 79 | ### 3. Configure settings 80 | In addition to setting `AUTH_USER_MODEL`, there are a few more required additions to `settings.py`: 81 | 82 | ```python 83 | # Configure Shopify Application settings 84 | SHOPIFY_APP_NAME = 'Your App Name' 85 | SHOPIFY_APP_API_KEY = os.environ.get('SHOPIFY_APP_API_KEY') 86 | SHOPIFY_APP_API_SECRET = os.environ.get('SHOPIFY_APP_API_SECRET') 87 | SHOPIFY_APP_API_SCOPE = ['read_products', 'read_orders'] 88 | # Find API version to pin at https://help.shopify.com/en/api/versioning 89 | SHOPIFY_APP_API_VERSION = "0000-00" 90 | SHOPIFY_APP_DEV_MODE = False 91 | 92 | # Use the Shopify Auth authentication backend as the sole authentication backend. 93 | AUTHENTICATION_BACKENDS = ( 94 | 'shopify_auth.backends.ShopUserBackend', 95 | ) 96 | 97 | # Add the Shopify Auth template context processor to the list of processors. 98 | # Note that this assumes you've defined TEMPLATE_CONTEXT_PROCESSORS earlier in your settings. 99 | TEMPLATE_CONTEXT_PROCESSORS += ( 100 | 'shopify_auth.context_processors.shopify_auth', 101 | ) 102 | 103 | # Use the Shopify Auth user model. 104 | AUTH_USER_MODEL = 'auth_app.AuthAppShopUser' 105 | 106 | # Set the login redirect URL to the "home" page for your app (where to go after logging on). 107 | LOGIN_REDIRECT_URL = '/' 108 | 109 | # Set secure proxy header to allow proper detection of secure URLs behind a proxy. 110 | # This ensures that correct 'https' URLs are generated when our Django app is running behind a proxy like nginx, or is 111 | # being tunneled (by ngrok, for example). 112 | SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') 113 | ``` 114 | 115 | Note that in the example above, the application API key and API secret are pulled from environment settings, which is a 116 | best practice for Django apps that helps avoid the accidental check-in of sensitive information to source files. 117 | 118 | Setting `SHOPIFY_APP_DEV_MODE` to `True` allows you to test your apps locally by skipping the external OAuth phase for 119 | your app. As it means you can log into your app as any store, you should obviously ***never*** set this to `True` in 120 | production. 121 | 122 | Now that all of the settings are configured, you can run `migrate` to set up the database for your new user model: 123 | 124 | ```shell 125 | > python manage.py migrate 126 | ``` 127 | 128 | 129 | ### 4. Configure URL mappings 130 | 131 | Include `shopify_auth` URLs in your project's `urls.py`: 132 | 133 | ```python 134 | # urls.py 135 | from django.urls import include, path 136 | 137 | urlpatterns = [ 138 | path('login/', include('shopify_auth.urls')), 139 | 140 | # ... remaining configuration here ... 141 | ] 142 | ``` 143 | 144 | ### 5. Create application views 145 | Now that you've gotten the configuration out of the way, you can start building your application. 146 | 147 | All views inside your application should be decorated with `@login_required`. 148 | This decorator will check that a user has authenticated through the Shopify OAuth flow. 149 | If they haven't, they'll be redirected to the login screen. 150 | 151 | ```python 152 | from django.shortcuts import render 153 | from shopify_auth.decorators import login_required 154 | 155 | @login_required 156 | def home(request, *args, **kwargs): 157 | return render(request, "my_app/home.html") 158 | ``` 159 | 160 | ### 6. Making Shopify API calls 161 | To make Shopify API calls on behalf of a user, we can use the user's `session` property inside a `with` statement: 162 | 163 | ```python 164 | def view(request, *args, **kwargs): 165 | 166 | # Get a list of the user's products. 167 | with request.user.session: 168 | products = shopify.Product.find() 169 | 170 | # ... remaining view code ... 171 | ``` 172 | 173 | Behind the scenes, using `with request.user.session` sets up a temporary Shopify API session using the OAuth token we 174 | obtained for that specific user during authentication. 175 | 176 | All code wrapped within the `with` statement is executed in the context of the specified user. You should always wrap 177 | calls to the Shopify API using this pattern. 178 | 179 | 180 | Partner Application Setup 181 | ------------------------- 182 | In addition to getting the package up and running in your local Django project, you'll need to configure your 183 | application via the Shopify Partner dashboard. 184 | 185 | To avoid getting an OAuth error while customers try to install your application, make sure your application's settings 186 | include the absolute URL to `/login/finalize/` (including the trailing slash) in their whitelisted URLs. For example, 187 | if your application resides at `https://myapp.example.com`, then you should include 188 | `https://myapp.example.com/login/finalize/` in the "Redirection URL" section of your application settings. 189 | 190 | 191 | Questions or Problems? 192 | ---------------------- 193 | 194 | Read up on the possible API calls: 195 | 196 | 197 | Ask technical questions on Stack Overflow: 198 | 199 | 200 | 201 | Release History 202 | --------------- 203 | 204 | Refer to the [change log](https://github.com/discolabs/django-shopify-auth/blob/master/CHANGELOG.md) for a full list of changes. 205 | 206 | Special Thanks 207 | -------------- 208 | 209 | A big shout-out to [Josef Rousek](https://github.com/stlk) for his contributions and help maintaining this package. 210 | 211 | Work with us! 212 | ------------------ 213 | 214 | If you want to work with Django and maybe even React, the current maintainer [Digismoothie.com is hiring](https://www.digismoothie.com/company/careers). 215 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | django >=3.2 2 | ShopifyAPI >=8.0.0 3 | ua-parser 4 | pyjwt >=2.0.0 5 | requests 6 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | version = __import__('shopify_auth').__version__ 4 | 5 | setup( 6 | name='django-shopify-auth', 7 | version=version, 8 | description='A simple package for adding Shopify authentication to Django apps.', 9 | long_description=open('README.md').read(), 10 | long_description_content_type='text/markdown', 11 | author='Gavin Ballard', 12 | author_email='gavin@discolabs.com', 13 | url='https://github.com/discolabs/django-shopify-auth', 14 | license='MIT', 15 | 16 | packages=find_packages(), 17 | package_data={ 18 | 'shopify_auth': ['*.html'] 19 | }, 20 | 21 | install_requires=[ 22 | 'django >=3.2', 23 | 'ShopifyAPI >=8.0.0', 24 | 'setuptools >=5.7', 25 | 'PyJWT >=2.0.0', 26 | 'requests >=2.0.0', 27 | ], 28 | 29 | tests_require=[], 30 | 31 | zip_safe=False, 32 | include_package_data=True, 33 | classifiers=[], 34 | ) 35 | -------------------------------------------------------------------------------- /shopify_auth/__init__.py: -------------------------------------------------------------------------------- 1 | VERSION = (2, 1, 2) 2 | __version__ = '.'.join(map(str, VERSION)) 3 | __author__ = 'Gavin Ballard' 4 | 5 | 6 | default_app_config = 'shopify_auth.apps.ShopifyAuthConfig' 7 | -------------------------------------------------------------------------------- /shopify_auth/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | from django.conf import settings 3 | from django.core.exceptions import ImproperlyConfigured 4 | 5 | import shopify 6 | 7 | 8 | class ShopifyAuthConfig(AppConfig): 9 | """ 10 | Application configuration for the Shopify Auth application. 11 | """ 12 | 13 | name = 'shopify_auth' 14 | verbose_name = 'Shopify Auth' 15 | 16 | def ready(self): 17 | """ 18 | The ready() method is called after Django setup. 19 | """ 20 | initialise_shopify_session() 21 | import shopify_auth.checks 22 | 23 | 24 | 25 | def initialise_shopify_session(): 26 | """ 27 | Initialise the Shopify session with the Shopify App's API credentials. 28 | """ 29 | if not settings.SHOPIFY_APP_API_KEY or not settings.SHOPIFY_APP_API_SECRET: 30 | raise ImproperlyConfigured("SHOPIFY_APP_API_KEY and SHOPIFY_APP_API_SECRET must be set in settings") 31 | shopify.Session.setup(api_key=settings.SHOPIFY_APP_API_KEY, secret=settings.SHOPIFY_APP_API_SECRET) 32 | -------------------------------------------------------------------------------- /shopify_auth/backends.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.backends import RemoteUserBackend 2 | 3 | 4 | class ShopUserBackend(RemoteUserBackend): 5 | def authenticate(self, request=None, myshopify_domain=None, token=None, **kwargs): 6 | if not myshopify_domain or not token or not request: 7 | return 8 | 9 | user = super(ShopUserBackend, self).authenticate(request=request, remote_user=myshopify_domain) 10 | 11 | if not user: 12 | return 13 | 14 | user.token = token 15 | user.save() 16 | return user 17 | -------------------------------------------------------------------------------- /shopify_auth/checks.py: -------------------------------------------------------------------------------- 1 | from django.core.checks import Error, register 2 | from django.conf import settings 3 | 4 | @register() 5 | def check_shopify_auth_bounce_page_url(app_configs, **kwargs): 6 | errors = [] 7 | if not hasattr(settings, 'SHOPIFY_AUTH_BOUNCE_PAGE_URL'): 8 | errors.append( 9 | Error( 10 | 'SHOPIFY_AUTH_BOUNCE_PAGE_URL is not set in settings.', 11 | hint='Set SHOPIFY_AUTH_BOUNCE_PAGE_URL in your settings file or environment variables.', 12 | id='shopify_auth.E001', 13 | ) 14 | ) 15 | elif not settings.SHOPIFY_AUTH_BOUNCE_PAGE_URL: 16 | errors.append( 17 | Error( 18 | 'SHOPIFY_AUTH_BOUNCE_PAGE_URL is empty.', 19 | hint='Provide a valid URL for SHOPIFY_AUTH_BOUNCE_PAGE_URL in your settings file or environment variables.', 20 | id='shopify_auth.E002', 21 | ) 22 | ) 23 | return errors -------------------------------------------------------------------------------- /shopify_auth/context_processors.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | 4 | def shopify_auth(request): 5 | return { 6 | 'SHOPIFY_APP_NAME': getattr(settings, 'SHOPIFY_APP_NAME'), 7 | 'SHOPIFY_APP_API_KEY': getattr(settings, 'SHOPIFY_APP_API_KEY'), 8 | 'SHOPIFY_APP_DEV_MODE': getattr(settings, 'SHOPIFY_APP_DEV_MODE'), 9 | } 10 | -------------------------------------------------------------------------------- /shopify_auth/decorators.py: -------------------------------------------------------------------------------- 1 | 2 | from functools import wraps 3 | 4 | from django.contrib.auth.decorators import user_passes_test 5 | from django.conf import settings 6 | from django.contrib.auth import REDIRECT_FIELD_NAME 7 | from django.utils.encoding import force_str 8 | from django.shortcuts import resolve_url 9 | from django.contrib.auth.decorators import login_required as django_login_required 10 | 11 | from .helpers import add_query_parameters_to_url 12 | 13 | def is_anonymous(user): 14 | return user.is_anonymous 15 | 16 | def anonymous_required(function=None, redirect_url=None): 17 | """ 18 | Decorator requiring the current user to be anonymous (not logged in). 19 | """ 20 | if not redirect_url: 21 | redirect_url = settings.LOGIN_REDIRECT_URL 22 | 23 | actual_decorator = user_passes_test( 24 | is_anonymous, 25 | login_url=redirect_url, 26 | redirect_field_name=None 27 | ) 28 | 29 | if function: 30 | return actual_decorator(function) 31 | return actual_decorator 32 | 33 | 34 | def login_required(f, redirect_field_name=REDIRECT_FIELD_NAME, login_url=None): 35 | """ 36 | Decorator that wraps django.contrib.auth.decorators.login_required, but supports extracting Shopify's authentication 37 | query parameters (`shop`, `timestamp`, `signature` and `hmac`) and passing them on to the login URL (instead of just 38 | wrapping them up and encoding them in to the `next` parameter). 39 | 40 | This is useful for ensuring that users are automatically logged on when they first access a page through the Shopify 41 | Admin, which passes these parameters with every page request to an embedded app. 42 | """ 43 | 44 | @wraps(f) 45 | def wrapper(request, *args, **kwargs): 46 | if request.user.is_authenticated: 47 | return f(request, *args, **kwargs) 48 | 49 | # Extract the Shopify-specific authentication parameters from the current request. 50 | shopify_params = { 51 | k: request.GET[k] 52 | for k in ['shop', 'timestamp', 'signature', 'hmac'] 53 | if k in request.GET 54 | } 55 | 56 | # Get the login URL. 57 | resolved_login_url = force_str(resolve_url(login_url or settings.LOGIN_URL)) 58 | 59 | # Add the Shopify authentication parameters to the login URL. 60 | updated_login_url = add_query_parameters_to_url(resolved_login_url, shopify_params) 61 | 62 | django_login_required_decorator = django_login_required(redirect_field_name=redirect_field_name, 63 | login_url=updated_login_url) 64 | return django_login_required_decorator(f)(request, *args, **kwargs) 65 | 66 | return wrapper 67 | -------------------------------------------------------------------------------- /shopify_auth/helpers.py: -------------------------------------------------------------------------------- 1 | import urllib 2 | from collections import OrderedDict 3 | 4 | 5 | def add_query_parameters_to_url(url, query_parameters): 6 | """ 7 | Merge a dictionary of query parameters into the given URL. 8 | Ensures all parameters are sorted in dictionary order when returning the URL. 9 | """ 10 | # Parse the given URL into parts. 11 | url_parts = urllib.parse.urlparse(url) 12 | 13 | # Parse existing parameters and add new parameters. 14 | qs_args = urllib.parse.parse_qs(url_parts[4]) 15 | qs_args.update(query_parameters) 16 | 17 | # Sort parameters to ensure consistent order. 18 | sorted_qs_args = OrderedDict() 19 | for k in sorted(qs_args.keys()): 20 | sorted_qs_args[k] = qs_args[k] 21 | 22 | # Encode the new parameters and return the updated URL. 23 | new_qs = urllib.parse.urlencode(sorted_qs_args, True) 24 | return urllib.parse.urlunparse(list(url_parts[0:4]) + [new_qs] + list(url_parts[5:])) 25 | -------------------------------------------------------------------------------- /shopify_auth/mixins.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import logout 2 | from django.contrib.auth.mixins import LoginRequiredMixin 3 | import shopify 4 | 5 | from .helpers import add_query_parameters_to_url 6 | 7 | 8 | class ShopifyLoginRequiredMixin(LoginRequiredMixin): 9 | """ 10 | Mixin which verifies that the current shop is authenticated and app is installed in shopify. 11 | """ 12 | 13 | def handle_no_permission(self): 14 | shopify_params = { 15 | k: self.request.GET[k] 16 | for k in ["shop", "timestamp", "signature", "hmac"] 17 | if k in self.request.GET 18 | } 19 | 20 | # Add the Shopify authentication parameters to the login URL. 21 | self.login_url = add_query_parameters_to_url( 22 | self.get_login_url(), shopify_params 23 | ) 24 | return super().handle_no_permission() 25 | 26 | 27 | def dispatch(self, request, *args, **kwargs): 28 | if not request.user.is_authenticated: 29 | return self.handle_no_permission() 30 | 31 | # Handle mismatch between request and session 32 | shop_name = request.GET.get("shop", None) 33 | if shop_name and request.user.myshopify_domain != shop_name: 34 | logout(request) 35 | return self.handle_no_permission() 36 | 37 | # Initialize shop making sure we can still access it 38 | try: 39 | with request.user.session: 40 | self.shop = shopify.Shop.current() 41 | except: 42 | logout(request) 43 | return self.handle_no_permission() 44 | return super().dispatch(request, *args, **kwargs) 45 | -------------------------------------------------------------------------------- /shopify_auth/models.py: -------------------------------------------------------------------------------- 1 | import shopify 2 | 3 | from django.conf import settings 4 | from django.contrib.auth.models import AbstractBaseUser, BaseUserManager 5 | from django.db import models 6 | 7 | 8 | class ShopUserManager(BaseUserManager): 9 | 10 | def create_user(self, myshopify_domain, password=None): 11 | """ 12 | Creates and saves a ShopUser with the given domain and password. 13 | """ 14 | if not myshopify_domain: 15 | raise ValueError('ShopUsers must have a myshopify domain') 16 | 17 | user = self.model(myshopify_domain=myshopify_domain) 18 | 19 | # Never want to be able to log on externally. 20 | # Authentication will be taken care of by Shopify OAuth. 21 | user.set_unusable_password() 22 | user.save(using=self._db) 23 | return user 24 | 25 | def create_superuser(self, myshopify_domain, password): 26 | """ 27 | Creates and saves a ShopUser with the given domains and password. 28 | """ 29 | return self.create_user(myshopify_domain, password) 30 | 31 | 32 | class AbstractShopUser(AbstractBaseUser): 33 | myshopify_domain = models.CharField(max_length=255, unique=True, editable=False) 34 | token = models.CharField(max_length=64, editable=False, default='00000000000000000000000000000000') 35 | 36 | objects = ShopUserManager() 37 | 38 | USERNAME_FIELD = 'myshopify_domain' 39 | 40 | @property 41 | def session(self): 42 | return shopify.Session.temp(self.myshopify_domain, getattr(settings, 'SHOPIFY_APP_API_VERSION', 'unstable'), self.token) 43 | 44 | def get_full_name(self): 45 | return self.myshopify_domain 46 | 47 | def get_short_name(self): 48 | return self.myshopify_domain 49 | 50 | def __str__(self): 51 | return self.get_full_name() 52 | 53 | @classmethod 54 | def update_or_create(cls, shopify_session: shopify.Session, request): 55 | shop, created = cls.objects.update_or_create( 56 | myshopify_domain=shopify_session.url, 57 | defaults={"token": shopify_session.token}, 58 | ) 59 | return shop 60 | 61 | def install(self, request): 62 | pass 63 | 64 | def uninstall(self): 65 | pass 66 | 67 | class Meta: 68 | abstract = True 69 | -------------------------------------------------------------------------------- /shopify_auth/session_tokens/README.md: -------------------------------------------------------------------------------- 1 | # Authenticate an embedded app using session tokens 2 | 3 | Session token based authentication comes with significant changes requried. I recommend you to follow this [article](https://shopify.dev/tutorials/authenticate-your-app-using-session-tokens). 4 | 5 | This app takes care of the installation and provides middleware that adds a user to a request based on a request header. 6 | 7 | I created a [demo app](https://github.com/digismoothie/django-session-token-auth-demo) that uses Hotwire, successor of Turbolinks. 8 | 9 | > [!NOTE] 10 | > 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. 11 | 12 | ### Instalation 13 | 14 | ### 1. Install package 15 | Installation is super easy via `pip`: 16 | 17 | ```shell 18 | > pip install django-shopify-auth 19 | ``` 20 | 21 | Once you have the package installed, add `shopify_auth` to your `INSTALLED_APPS`. 22 | 23 | 24 | ### 2. Add custom user model 25 | 26 | This package requires that the user model for your app (specified by `AUTH_USER_MODEL` in your `settings.py`) inherits 27 | from `shopify_auth.models.AbstractShopUser`. To do this, just add something like this to the `models.py` for your 28 | Django app: 29 | 30 | ```python 31 | # auth_demo/auth_app/models.py 32 | from shopify_auth.models import AbstractShopUser 33 | 34 | class AuthAppShopUser(AbstractShopUser): 35 | pass 36 | ``` 37 | 38 | Before moving forward, ensure this model has been added to the database by running 39 | ``` 40 | python manage.py makemigrations 41 | python manage.py migrate 42 | ``` 43 | 44 | Then make sure that you have the following line or similar in `settings.py`: 45 | 46 | ```python 47 | AUTH_USER_MODEL = 'auth_app.AuthAppShopUser' 48 | ``` 49 | 50 | 51 | ### 3. Configure settings 52 | In addition to setting `AUTH_USER_MODEL`, there are a few more required additions to `settings.py`: 53 | 54 | ```python 55 | # Configure Shopify Application settings 56 | SHOPIFY_APP_NAME = 'Your App Name' 57 | SHOPIFY_APP_API_KEY = os.environ.get('SHOPIFY_APP_API_KEY') 58 | SHOPIFY_APP_API_SECRET = os.environ.get('SHOPIFY_APP_API_SECRET') 59 | SHOPIFY_APP_API_SCOPE = ['read_products', 'read_orders'] 60 | # Find API version to pin at https://help.shopify.com/en/api/versioning 61 | SHOPIFY_APP_API_VERSION = "0000-00" 62 | 63 | # Add the Shopify Auth template context processor to the list of processors. 64 | # Note that this assumes you've defined TEMPLATE_CONTEXT_PROCESSORS earlier in your settings. 65 | TEMPLATE_CONTEXT_PROCESSORS += ( 66 | 'shopify_auth.session_tokens.context_processors.shopify_auth', 67 | ) 68 | 69 | # Use the Shopify Auth user model. 70 | AUTH_USER_MODEL = 'auth_app.AuthAppShopUser' 71 | 72 | # Set secure proxy header to allow proper detection of secure URLs behind a proxy. 73 | # This ensures that correct 'https' URLs are generated when our Django app is running behind a proxy like nginx, or is 74 | # being tunneled (by ngrok, for example). 75 | SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') 76 | ``` 77 | 78 | Note that in the example above, the application API key and API secret are pulled from environment settings, which is a 79 | best practice for Django apps that helps avoid the accidental check-in of sensitive information to source files. 80 | 81 | Now that all of the settings are configured, you can run `migrate` to set up the database for your new user model: 82 | 83 | ```shell 84 | > python manage.py migrate 85 | ``` 86 | 87 | ### 4. Configure authentication based on what Views are you using. 88 | 89 | #### Plain Django views 90 | 91 | myproject/settings.py 92 | ```python 93 | MIDDLEWARE = [ 94 | ... 95 | "django.contrib.auth.middleware.AuthenticationMiddleware", 96 | "shopify_auth.session_tokens.middleware.SessionTokensAuthMiddleware", # This middleware has to be after django.contrib.auth.middleware.AuthenticationMiddleware. 97 | ... 98 | ] 99 | ``` 100 | 101 | #### Django REST Framework 102 | 103 | myproject/settings.py 104 | ```python 105 | REST_FRAMEWORK = {"DEFAULT_AUTHENTICATION_CLASSES": ["shopify_auth.session_tokens.authentication.ShopifyTokenAuthentication"]} 106 | ``` 107 | 108 | ### 5. Configure URL mappings 109 | 110 | Include `shopify_auth.session_tokens` URLs in your project's `urls.py`: 111 | 112 | ```python 113 | # urls.py 114 | from django.urls import include, path 115 | 116 | urlpatterns = [ 117 | path("auth/", include("shopify_auth.session_tokens.urls", namespace="session_tokens")), 118 | 119 | # ... remaining configuration here ... 120 | ] 121 | ``` 122 | 123 | ### 6. Create view that supports unauthenticated requests. 124 | 125 | 126 | ```python 127 | import shopify 128 | from django.conf import settings 129 | from django.contrib.auth import get_user_model 130 | from django.contrib.auth.signals import user_logged_in 131 | from django.http import HttpResponse 132 | from django.shortcuts import render 133 | from django.views import generic 134 | from pyactiveresource.connection import UnauthorizedAccess 135 | from shopify_auth.session_tokens.views import get_scope_permission 136 | 137 | 138 | class DashboardView(generic.View): 139 | template_name = "myapp/dashboard.html" 140 | 141 | def get(self, request): 142 | myshopify_domain = request.GET.get("shop") 143 | encoded_host = request.GET.get("host") # New in App Bridge 2.0 144 | 145 | if not myshopify_domain: 146 | return HttpResponse("Shop parameter missing.") 147 | try: 148 | shop = get_user_model().objects.get(myshopify_domain=myshopify_domain) 149 | except get_user_model().DoesNotExist: 150 | return get_scope_permission(request, myshopify_domain) 151 | 152 | with shop.session: 153 | try: 154 | shopify_shop = shopify.Shop.current() 155 | # Do your billing logic here 156 | except UnauthorizedAccess: 157 | shop.uninstall() 158 | return get_scope_permission(request, myshopify_domain) 159 | 160 | if not encoded_host: 161 | return redirect(f"https://{myshopify_domain}/admin/apps/{settings.SHOPIFY_APP_API_KEY}") 162 | 163 | user_logged_in.send(sender=shop.__class__, request=request, user=shop) 164 | 165 | return render( 166 | request, 167 | self.template_name, 168 | { 169 | "data": { 170 | "shopOrigin": shop.myshopify_domain, 171 | "apiKey": getattr(settings, "SHOPIFY_APP_API_KEY"), 172 | "encodedHost": encoded_host, 173 | } 174 | }, 175 | ) 176 | ``` -------------------------------------------------------------------------------- /shopify_auth/session_tokens/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stlk/django-shopify-auth/0b3edf402acc1cee20f293fb75b550d6330c0681/shopify_auth/session_tokens/__init__.py -------------------------------------------------------------------------------- /shopify_auth/session_tokens/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class SessionTokensConfig(AppConfig): 5 | name = "session_tokens" 6 | -------------------------------------------------------------------------------- /shopify_auth/session_tokens/authentication.py: -------------------------------------------------------------------------------- 1 | from rest_framework.authentication import BaseAuthentication, get_authorization_header 2 | from rest_framework.exceptions import AuthenticationFailed 3 | 4 | from .middleware import get_user 5 | 6 | INVALID_TOKEN_MESSAGE = "Invalid JWT Token" 7 | 8 | 9 | class ShopifyTokenAuthentication(BaseAuthentication): 10 | keyword = "Bearer" 11 | 12 | def authenticate(self, request): 13 | auth = get_authorization_header(request).split() 14 | if not auth or auth[0].lower() != self.keyword.lower().encode(): 15 | return None 16 | if len(auth) == 1: 17 | msg = "Invalid token header. No credentials provided." 18 | raise AuthenticationFailed(msg) 19 | elif len(auth) > 2: 20 | msg = "Invalid token header. Token string should not contain spaces." 21 | raise AuthenticationFailed(msg) 22 | 23 | try: 24 | token = auth[1].decode() 25 | except UnicodeError: 26 | msg = "Invalid token header. Token string should not contain invalid characters." 27 | raise AuthenticationFailed(msg) 28 | 29 | user = get_user(token) 30 | if not user: 31 | raise AuthenticationFailed(INVALID_TOKEN_MESSAGE) 32 | 33 | return user, token 34 | -------------------------------------------------------------------------------- /shopify_auth/session_tokens/context_processors.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | 4 | def shopify_auth(request): 5 | return { 6 | 'SHOPIFY_APP_NAME': getattr(settings, 'SHOPIFY_APP_NAME'), 7 | 'SHOPIFY_APP_API_KEY': getattr(settings, 'SHOPIFY_APP_API_KEY'), 8 | } 9 | -------------------------------------------------------------------------------- /shopify_auth/session_tokens/managed_install.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from django.core.exceptions import ImproperlyConfigured 3 | 4 | import requests 5 | from django.conf import settings 6 | from django.http import HttpRequest, HttpResponse 7 | from django.shortcuts import redirect 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | from typing import TypedDict 12 | 13 | 14 | class ResponseData(TypedDict): 15 | access_token: str 16 | scope: list[str] 17 | 18 | 19 | # From https://shopify.dev/docs/apps/build/authentication-authorization/get-access-tokens/exchange-tokens#step-2-get-an-access-token 20 | def retrieve_api_token(shop: str, session_token: str) -> ResponseData: 21 | url = f"https://{shop}/admin/oauth/access_token" 22 | payload = { 23 | "client_id": settings.SHOPIFY_APP_API_KEY, 24 | "client_secret": settings.SHOPIFY_APP_API_SECRET, 25 | "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", 26 | "subject_token": session_token, 27 | "subject_token_type": "urn:ietf:params:oauth:token-type:id_token", 28 | "requested_token_type": "urn:shopify:params:oauth:token-type:offline-access-token", 29 | } 30 | headers = {"Content-Type": "application/json", "Accept": "application/json"} 31 | response = requests.post(url, json=payload, headers=headers) 32 | response.raise_for_status() 33 | response_data_raw = response.json() 34 | response_data: ResponseData = { 35 | "access_token": response_data_raw["access_token"], 36 | "scope": [scope.strip() for scope in response_data_raw["scope"].split(",")], 37 | } 38 | return response_data 39 | 40 | 41 | def session_token_bounce_page_url(request: HttpRequest) -> str: 42 | search_params = request.GET.copy() 43 | search_params.pop("id_token", None) 44 | search_params["shopify-reload"] = f"{request.path}?{search_params.urlencode()}" 45 | 46 | bounce_page_url = settings.SHOPIFY_AUTH_BOUNCE_PAGE_URL 47 | return f"{bounce_page_url}?{search_params.urlencode()}" 48 | 49 | 50 | def redirect_to_session_token_bounce_page(request: HttpRequest) -> HttpResponse: 51 | return redirect(session_token_bounce_page_url(request)) 52 | -------------------------------------------------------------------------------- /shopify_auth/session_tokens/middleware.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import re 3 | from urllib.parse import urlparse 4 | from django.core.exceptions import ObjectDoesNotExist 5 | from django.contrib.auth import get_user_model 6 | 7 | import jwt 8 | from django.conf import settings 9 | from jwt.exceptions import ExpiredSignatureError, MissingRequiredClaimError, PyJWTError 10 | 11 | 12 | def get_hostname(url): 13 | return urlparse(url).netloc 14 | 15 | def decode_token(token): 16 | decoded_payload = jwt.decode( 17 | token, 18 | settings.SHOPIFY_APP_API_SECRET, 19 | algorithms=["HS256"], 20 | audience=settings.SHOPIFY_APP_API_KEY, 21 | options={"verify_sub": False, "verify_nbf": False}, 22 | ) 23 | dest_host = get_hostname(decoded_payload["dest"]) 24 | iss_host = get_hostname(decoded_payload["iss"]) 25 | 26 | assert ( 27 | dest_host == iss_host 28 | ), "'dest' claim host does not match 'iss' claim host" 29 | assert dest_host, "'dest' claim host not a valid shopify host" 30 | 31 | return dest_host 32 | 33 | def get_user(token): 34 | try: 35 | dest_host = decode_token(token) 36 | return get_user_model().objects.get(myshopify_domain=dest_host) 37 | 38 | except ( 39 | ExpiredSignatureError, 40 | PyJWTError, 41 | MissingRequiredClaimError, 42 | AssertionError, 43 | ObjectDoesNotExist, 44 | KeyError, 45 | ) as e: 46 | logging.warning(f"Login user failed: {e}.") 47 | 48 | def authenticate(request): 49 | jwt_token = request.headers.get("Authorization") 50 | 51 | if not jwt_token: 52 | return 53 | 54 | striped_jwt_token = re.sub(r"Bearer\s", "", jwt_token) 55 | return get_user(striped_jwt_token) 56 | 57 | 58 | class SessionTokensAuthMiddleware: 59 | def __init__(self, get_response): 60 | self.get_response = get_response 61 | 62 | def __call__(self, request): 63 | user = authenticate(request) 64 | if user: 65 | request.user = user 66 | return self.get_response(request) 67 | -------------------------------------------------------------------------------- /shopify_auth/session_tokens/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | # Create your models here. 4 | -------------------------------------------------------------------------------- /shopify_auth/session_tokens/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stlk/django-shopify-auth/0b3edf402acc1cee20f293fb75b550d6330c0681/shopify_auth/session_tokens/tests/__init__.py -------------------------------------------------------------------------------- /shopify_auth/session_tokens/tests/test_authentication.py: -------------------------------------------------------------------------------- 1 | from django.test import RequestFactory, TestCase 2 | import jwt 3 | from django.conf import settings 4 | from ..middleware import authenticate 5 | from django.contrib.auth import get_user_model 6 | 7 | 8 | def generate_jwt_token(shop): 9 | return jwt.encode( 10 | { 11 | "iss": f"https://{shop.myshopify_domain}", 12 | "dest": f"https://{shop.myshopify_domain}", 13 | "aud": settings.SHOPIFY_APP_API_KEY, 14 | }, 15 | settings.SHOPIFY_APP_API_SECRET, 16 | ) 17 | 18 | 19 | class ShopifyTokenAuthenticationTest(TestCase): 20 | def setUp(self): 21 | settings.AUTH_USER_MODEL = "shopify_auth.AuthAppShopUser" 22 | self.myshopify_domain = "test-1.myshopify.com" 23 | self.user = get_user_model().objects.create( 24 | myshopify_domain=self.myshopify_domain 25 | ) 26 | self.factory = RequestFactory() 27 | self.valid_token = f"Bearer {generate_jwt_token(self.user)}" 28 | 29 | def test_valid_token(self): 30 | request = self.factory.get("/", HTTP_AUTHORIZATION=self.valid_token) 31 | user = authenticate(request) 32 | self.assertEqual(user.myshopify_domain, self.myshopify_domain) 33 | self.assertEqual(self.user.id, user.id) 34 | 35 | def test_not_existing_app_installation_valid_token(self): 36 | self.user.delete() 37 | request = self.factory.get("/", HTTP_AUTHORIZATION=self.valid_token) 38 | user = authenticate(request) 39 | self.assertIsNone(user) 40 | 41 | def test_invalid_token(self): 42 | jwt_token = "Bearer not_jwt" 43 | request = self.factory.get("/", HTTP_AUTHORIZATION=jwt_token) 44 | user = authenticate(request) 45 | self.assertIsNone(user) 46 | 47 | def test_missing_header(self): 48 | request = self.factory.get("/") 49 | result = authenticate(request) 50 | self.assertIsNone(result) 51 | 52 | def test_empty_header(self): 53 | request = self.factory.get("/", HTTP_AUTHORIZATION="") 54 | result = authenticate(request) 55 | self.assertIsNone(result) 56 | -------------------------------------------------------------------------------- /shopify_auth/session_tokens/tests/test_finalize_view.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | 3 | from django.shortcuts import reverse 4 | from shopify_auth.tests.test_user import ViewsTestCase 5 | 6 | from django.contrib.auth import get_user_model 7 | import shopify 8 | 9 | HOST_MYSHOPIFY_DOMAIN = "cmFuZG9tX3Nob3AubXlzaG9waWZ5LmNvbS9hZG1pbg" 10 | HOST_ADMIN_SHOPIFY_COM = "YWRtaW4uc2hvcGlmeS5jb20vc3RvcmUvam9zZWYtZGV2LTIwMjEtMDc" 11 | 12 | def request_token(self, params): 13 | self.token = "TOKEN" 14 | 15 | 16 | class FinalizeViewTest(ViewsTestCase): 17 | def setUp(self): 18 | super().setUp() 19 | self.shop_patcher = patch("shopify.Shop", autospec=True) 20 | mck = self.shop_patcher.start() 21 | mck.current().currency = "CZK" 22 | self.addCleanup(self.shop_patcher.stop) 23 | self.url = reverse("session_tokens:finalize") + "?shop=random_shop.myshopify.com" 24 | 25 | @patch.object(shopify.Session, "request_token", request_token) 26 | def test_creates_user(self): 27 | response = self.client.get(self.url) 28 | AuthAppShopUser = get_user_model() 29 | self.assertTrue( 30 | AuthAppShopUser.objects.filter( 31 | myshopify_domain="random_shop.myshopify.com" 32 | ).exists() 33 | ) 34 | self.assertEqual(response.status_code, 302) 35 | self.assertEqual(response.url, "https://random_shop.myshopify.com/admin/apps/test-api-key") 36 | 37 | 38 | @patch.object(shopify.Session, "request_token", request_token) 39 | def test_creates_user_redirects_host_myshopify(self): 40 | response = self.client.get(f"{self.url}&host={HOST_MYSHOPIFY_DOMAIN}") 41 | AuthAppShopUser = get_user_model() 42 | self.assertTrue( 43 | AuthAppShopUser.objects.filter( 44 | myshopify_domain="random_shop.myshopify.com" 45 | ).exists() 46 | ) 47 | self.assertEqual(response.status_code, 302) 48 | self.assertEqual(response.url, "https://random_shop.myshopify.com/admin/apps/test-api-key") 49 | 50 | 51 | @patch.object(shopify.Session, "request_token", request_token) 52 | def test_creates_user_redirects_host_admin_shopify_com(self): 53 | response = self.client.get(f"{self.url}&host={HOST_ADMIN_SHOPIFY_COM}") 54 | AuthAppShopUser = get_user_model() 55 | self.assertTrue( 56 | AuthAppShopUser.objects.filter( 57 | myshopify_domain="random_shop.myshopify.com" 58 | ).exists() 59 | ) 60 | self.assertEqual(response.status_code, 302) 61 | self.assertEqual(response.url, "https://admin.shopify.com/store/josef-dev-2021-07/apps/test-api-key") 62 | -------------------------------------------------------------------------------- /shopify_auth/session_tokens/tests/test_get_scope_permission.py: -------------------------------------------------------------------------------- 1 | from django.test import Client, TestCase 2 | from django.test.client import RequestFactory 3 | 4 | from django.contrib.auth import get_user_model 5 | from shopify_auth.session_tokens.views import get_scope_permission 6 | 7 | 8 | class GetScopePermissionTest(TestCase): 9 | def test_get_scope_permission_not_embedded(self): 10 | self.client = Client() 11 | rf = RequestFactory() 12 | self.myshopify_domain = "test-1.myshopify.com" 13 | shop = get_user_model().objects.create(myshopify_domain=self.myshopify_domain) 14 | request = rf.get(f"/?shop={shop.myshopify_domain}") 15 | request.user = shop 16 | response = get_scope_permission(request, shop.myshopify_domain) 17 | self.assertRedirects(response, "https://test-1.myshopify.com/admin/oauth/authorize?client_id=test-api-key&redirect_uri=http%3A%2F%2Ftestserver%2Fsession_tokens%2Ffinalize&scope=read_products", fetch_redirect_response=False) 18 | 19 | 20 | def test_get_scope_permission_embedded(self): 21 | self.client = Client() 22 | rf = RequestFactory() 23 | self.myshopify_domain = "test-1.myshopify.com" 24 | shop = get_user_model().objects.create(myshopify_domain=self.myshopify_domain) 25 | request = rf.get(f"/?shop={shop.myshopify_domain}&embedded=1&host=placeholder") 26 | request.user = shop 27 | response = get_scope_permission(request, shop.myshopify_domain) 28 | self.assertEqual(response.status_code, 200) 29 | self.assertContains(response, shop.myshopify_domain) 30 | -------------------------------------------------------------------------------- /shopify_auth/session_tokens/tests/test_managed_install.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase, RequestFactory 2 | from django.core.exceptions import ImproperlyConfigured 3 | from unittest.mock import patch 4 | 5 | from ..managed_install import ( 6 | retrieve_api_token, 7 | session_token_bounce_page_url, 8 | redirect_to_session_token_bounce_page, 9 | ) 10 | 11 | class ManagedInstallTestCase(TestCase): 12 | def setUp(self): 13 | self.factory = RequestFactory() 14 | 15 | @patch('requests.post') 16 | def test_retrieve_api_token(self, mock_post): 17 | # Mock the response from Shopify 18 | mock_response = mock_post.return_value 19 | mock_response.json.return_value = { 20 | 'access_token': 'test_token', 21 | 'scope': 'read_products,write_orders' 22 | } 23 | 24 | result = retrieve_api_token('test-shop.myshopify.com', 'test_session_token') 25 | 26 | self.assertEqual(result['access_token'], 'test_token') 27 | self.assertEqual(result['scope'], ['read_products', 'write_orders']) 28 | 29 | # Check if the request was made with correct parameters 30 | mock_post.assert_called_once() 31 | args, kwargs = mock_post.call_args 32 | self.assertEqual(args[0], 'https://test-shop.myshopify.com/admin/oauth/access_token') 33 | 34 | def test_session_token_bounce_page_url(self): 35 | request = self.factory.get('/test-path/?param1=value1&id_token=test_token') 36 | 37 | with self.settings(SHOPIFY_AUTH_BOUNCE_PAGE_URL='/bounce/'): 38 | url = session_token_bounce_page_url(request) 39 | 40 | expected_url = '/bounce/?param1=value1&shopify-reload=%2Ftest-path%2F%3Fparam1%3Dvalue1' 41 | self.assertEqual(url, expected_url) 42 | 43 | def test_redirect_to_session_token_bounce_page(self): 44 | request = self.factory.get('/test-path/') 45 | 46 | with self.settings(SHOPIFY_AUTH_BOUNCE_PAGE_URL='/bounce/'): 47 | response = redirect_to_session_token_bounce_page(request) 48 | 49 | self.assertEqual(response.status_code, 302) 50 | self.assertTrue(response.url.startswith('/bounce/')) 51 | -------------------------------------------------------------------------------- /shopify_auth/session_tokens/tests/test_session_token_bounce_view.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase, RequestFactory 2 | from django.conf import settings 3 | from django.http import HttpResponse 4 | 5 | from ..views import session_token_bounce 6 | 7 | class SessionTokenBounceTestCase(TestCase): 8 | def test_session_token_bounce(self): 9 | request = RequestFactory().get('/bounce/') 10 | response = session_token_bounce(request) 11 | 12 | self.assertIsInstance(response, HttpResponse) 13 | self.assertEqual(response['Content-Type'], 'text/html') 14 | self.assertIn(settings.SHOPIFY_APP_API_KEY, response.content.decode()) 15 | self.assertIn('https://cdn.shopify.com/shopifycloud/app-bridge.js', response.content.decode()) -------------------------------------------------------------------------------- /shopify_auth/session_tokens/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from . import views 4 | 5 | app_name = "session_tokens" 6 | 7 | urlpatterns = [ 8 | path("finalize", views.FinalizeAuthView.as_view(), name="finalize"), 9 | path("authenticate", views.get_scope_permission, name="authenticate"), 10 | path("session-token-bounce", views.session_token_bounce, name="session-token-bounce"), 11 | ] 12 | -------------------------------------------------------------------------------- /shopify_auth/session_tokens/views.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import base64 3 | import urllib.parse 4 | from django.conf import settings 5 | from django.http.response import HttpResponse, HttpResponseRedirect 6 | from django.shortcuts import render, reverse 7 | from django.views.generic.base import View 8 | 9 | import shopify 10 | 11 | from django.contrib.auth import get_user_model 12 | 13 | 14 | def base64_decode(string): 15 | """ 16 | Adds back in the required padding before decoding. 17 | """ 18 | padding = 4 - (len(string) % 4) 19 | string = string + ("=" * padding) 20 | return base64.urlsafe_b64decode(string) 21 | 22 | 23 | def get_scope_permission(request, myshopify_domain): 24 | redirect_uri = request.build_absolute_uri(reverse("session_tokens:finalize")) 25 | myshopify_domain = myshopify_domain.strip() 26 | permission_url = shopify.Session( 27 | myshopify_domain, 28 | getattr(settings, "SHOPIFY_APP_API_VERSION", "unstable"), 29 | ).create_permission_url(settings.SHOPIFY_APP_API_SCOPE, redirect_uri) 30 | 31 | embedded = request.GET.get("embedded") 32 | if not embedded or embedded != "1": 33 | return HttpResponseRedirect(permission_url) 34 | 35 | host = request.GET.get("host") 36 | if not host: 37 | raise Exception("expected host query parameter when embedded=1 is present") 38 | 39 | return render( 40 | request, 41 | "shopify_auth/iframe_redirect.html", 42 | {"host": host, "redirect_uri": permission_url}, 43 | ) 44 | 45 | 46 | class FinalizeAuthView(View): 47 | def get(self, request): 48 | myshopify_domain = request.GET.get("shop") 49 | 50 | try: 51 | shopify_session = shopify.Session( 52 | myshopify_domain, 53 | getattr(settings, "SHOPIFY_APP_API_VERSION", "unstable"), 54 | ) 55 | shopify_session.request_token(request.GET) 56 | except: 57 | logging.exception("Shopify login failed.") 58 | return HttpResponse("Shopify login failed.") 59 | 60 | shop = get_user_model().update_or_create(shopify_session, request) 61 | shop.install(request) 62 | 63 | if host := request.GET.get("host"): 64 | decoded_host = base64_decode(host).decode('utf-8') 65 | redirect_url = f"https://{decoded_host}/apps/{settings.SHOPIFY_APP_API_KEY}" 66 | return HttpResponseRedirect(redirect_url) 67 | 68 | 69 | return HttpResponseRedirect( 70 | f"https://{myshopify_domain}/admin/apps/{settings.SHOPIFY_APP_API_KEY}" 71 | ) 72 | 73 | 74 | def session_token_bounce(request) -> HttpResponse: 75 | """ 76 | 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 77 | """ 78 | response = HttpResponse(content_type="text/html") 79 | html = f""" 80 | 81 | 82 | 83 | 84 | """ 85 | response.write(html) 86 | return response 87 | -------------------------------------------------------------------------------- /shopify_auth/templates/shopify_auth/iframe_redirect.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /shopify_auth/templates/shopify_auth/login.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 |
5 |
6 |
7 |
8 |

{{SHOPIFY_APP_NAME}}

9 |
10 |

11 | Enter your shop domain to log in or install this app. 12 |

13 |
14 |
15 |
16 |
17 | {% csrf_token %} 18 |
19 |
20 |
21 | 22 |
23 |
24 |
25 | 26 |
27 |
28 |
29 |
30 |
31 | 37 |
38 |
39 | 40 |
41 |
42 |
43 |
44 |
45 |
-------------------------------------------------------------------------------- /shopify_auth/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stlk/django-shopify-auth/0b3edf402acc1cee20f293fb75b550d6330c0681/shopify_auth/tests/__init__.py -------------------------------------------------------------------------------- /shopify_auth/tests/test_helpers.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from ..helpers import add_query_parameters_to_url 3 | 4 | 5 | class HelpersTestCase(TestCase): 6 | 7 | def test_add_query_parameters_to_url(self): 8 | """ 9 | Test add_query_parameters_to_url helper. 10 | """ 11 | # Define a list of test cases as triples in the format (url, query_parameters, expected_url) 12 | test_cases = [ 13 | ('http://example.com', {'page': 2}, 'http://example.com?page=2'), 14 | ('http://example.com?size=xl', {'page': 5}, 'http://example.com?page=5&size=xl'), 15 | ('http://example.com?size=xl&page=1', {'page': 5}, 'http://example.com?page=5&size=xl'), 16 | ] 17 | 18 | # Run each test case through the helper and check the expected output. 19 | for url, query_parameters, expected_url in test_cases: 20 | self.assertEqual(add_query_parameters_to_url(url, query_parameters), expected_url) 21 | -------------------------------------------------------------------------------- /shopify_auth/tests/test_user.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from django.conf import settings 3 | from django.core.management import call_command 4 | 5 | from shopify_auth.models import AbstractShopUser 6 | 7 | 8 | class AuthAppShopUser(AbstractShopUser): 9 | class Meta: 10 | app_label = 'shopify_auth' 11 | 12 | 13 | class ViewsTestCase(TestCase): 14 | 15 | def setUp(self): 16 | settings.AUTH_USER_MODEL = 'shopify_auth.AuthAppShopUser' 17 | 18 | def test_create_super_user(self): 19 | call_command('createsuperuser', '--noinput', myshopify_domain='myshop.shopify.com') 20 | -------------------------------------------------------------------------------- /shopify_auth/tests/test_views.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase, Client 2 | from django.conf import settings 3 | 4 | 5 | class ViewsTestCase(TestCase): 6 | 7 | def setUp(self): 8 | self.client = Client() 9 | 10 | def tearDown(self): 11 | settings.SHOPIFY_APP_DEV_MODE = False 12 | self.client = None 13 | 14 | def test_login_view(self): 15 | """ 16 | Test the login view loads when we're an anonymous user. 17 | """ 18 | response = self.client.get('/') 19 | self.assertEqual(response.status_code, 200) 20 | 21 | def test_authenticate_view(self): 22 | """ 23 | Test the authenticate view renders correctly with a shop param. 24 | """ 25 | response = self.client.get('/authenticate/') 26 | self.assertEqual(response.status_code, 302) 27 | response = self.client.get('/authenticate/', follow=True) 28 | self.assertEqual(response.status_code, 404) 29 | response = self.client.post('/authenticate/') 30 | self.assertEqual(response.status_code, 302) 31 | 32 | # Dev mode so token does not need to be valid 33 | settings.SHOPIFY_APP_DEV_MODE = True 34 | response = self.client.get('/authenticate/?shop=test.myshopify.com') 35 | self.assertEqual(response.status_code, 302) 36 | self.assertGreater(int(self.client.session['_auth_user_id']), 0) 37 | self.assertEqual(self.client.session['_auth_user_backend'], 'shopify_auth.backends.ShopUserBackend') 38 | self.assertIsNot(self.client.session['_auth_user_hash'], None) 39 | 40 | def test_authenticates_standalone_app_with_shop_param(self): 41 | response = self.client.get('/?shop=test.myshopify.com') 42 | self.assertEqual(response.status_code, 302) 43 | 44 | def test_redirect_to_view(self): 45 | """ 46 | Test that return_address is persisted through login flow. 47 | """ 48 | response = self.client.get('/authenticate/?shop=test.myshopify.com&next=other-view') 49 | self.assertRedirects(response, 'https://test.myshopify.com/admin/oauth/authorize?client_id=test-api-key&scope=read_products&redirect_uri=http%3A%2F%2Ftestserver%2Ffinalize%2F', fetch_redirect_response=False) 50 | 51 | settings.SHOPIFY_APP_DEV_MODE = True 52 | response = self.client.get('/authenticate/?shop=test.myshopify.com') 53 | self.assertEqual(response.status_code, 302) 54 | self.assertEqual(response.url, 'other-view') 55 | -------------------------------------------------------------------------------- /shopify_auth/tests/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import include, path 2 | 3 | urlpatterns = [ 4 | path("", include("shopify_auth.urls")), 5 | path("session_tokens/", include("shopify_auth.session_tokens.urls", namespace="session_tokens")), 6 | ] 7 | -------------------------------------------------------------------------------- /shopify_auth/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from . import views 4 | 5 | urlpatterns = [ 6 | path('finalize/', views.finalize), 7 | path('authenticate/', views.authenticate), 8 | path('', views.login), 9 | ] 10 | -------------------------------------------------------------------------------- /shopify_auth/views.py: -------------------------------------------------------------------------------- 1 | import shopify 2 | 3 | from django.conf import settings 4 | from django.contrib import auth 5 | from django.http.response import HttpResponseRedirect 6 | from django.shortcuts import render, resolve_url 7 | from django.urls import reverse 8 | 9 | from .decorators import anonymous_required 10 | 11 | 12 | SESSION_REDIRECT_FIELD_NAME = 'shopify_auth_next' 13 | 14 | def get_return_address(request): 15 | return request.session.get(SESSION_REDIRECT_FIELD_NAME) or request.GET.get(auth.REDIRECT_FIELD_NAME) or resolve_url(settings.LOGIN_REDIRECT_URL) 16 | 17 | 18 | @anonymous_required 19 | def login(request, *args, **kwargs): 20 | # The `shop` parameter may be passed either directly in query parameters, or 21 | # as a result of submitting the login form. 22 | shop = request.POST.get('shop', request.GET.get('shop')) 23 | if shop: 24 | # Store return adress so merchant gets where they intended to. 25 | return_address_parameter = request.GET.get(auth.REDIRECT_FIELD_NAME) 26 | if return_address_parameter: 27 | request.session[SESSION_REDIRECT_FIELD_NAME] = return_address_parameter 28 | 29 | # If the shop parameter has already been provided, attempt to authenticate immediately. 30 | return authenticate(request, *args, **kwargs) 31 | 32 | return render(request, "shopify_auth/login.html", { 33 | 'SHOPIFY_APP_NAME': settings.SHOPIFY_APP_NAME, 34 | 'shop': shop, 35 | }) 36 | 37 | 38 | @anonymous_required 39 | def authenticate(request, *args, **kwargs): 40 | shop = request.POST.get('shop', request.GET.get('shop')) 41 | 42 | if settings.SHOPIFY_APP_DEV_MODE: 43 | return finalize(request, token='00000000000000000000000000000000', *args, **kwargs) 44 | 45 | if shop: 46 | # Store return adress so merchant gets where they intended to. 47 | return_address_parameter = request.GET.get(auth.REDIRECT_FIELD_NAME) 48 | if return_address_parameter: 49 | request.session[SESSION_REDIRECT_FIELD_NAME] = return_address_parameter 50 | 51 | redirect_uri = request.build_absolute_uri(reverse(finalize)) 52 | scope = settings.SHOPIFY_APP_API_SCOPE 53 | permission_url = shopify.Session(shop.strip(), getattr(settings, 'SHOPIFY_APP_API_VERSION', 'unstable')).create_permission_url(scope, redirect_uri) 54 | 55 | # Non-Embedded Apps should use a standard redirect. 56 | return HttpResponseRedirect(permission_url) 57 | 58 | return_address = get_return_address(request) 59 | return HttpResponseRedirect(return_address) 60 | 61 | 62 | @anonymous_required 63 | def finalize(request, *args, **kwargs): 64 | shop = request.POST.get('shop', request.GET.get('shop')) 65 | 66 | try: 67 | shopify_session = shopify.Session(shop, getattr(settings, 'SHOPIFY_APP_API_VERSION', 'unstable'), token=kwargs.get('token')) 68 | shopify_session.request_token(request.GET) 69 | except Exception: 70 | login_url = reverse(login) 71 | return HttpResponseRedirect(login_url) 72 | 73 | # Attempt to authenticate the user and log them in. 74 | user = auth.authenticate(request=request, myshopify_domain=shopify_session.url, token=shopify_session.token) 75 | if user: 76 | auth.login(request, user) 77 | 78 | return_address = get_return_address(request) 79 | return HttpResponseRedirect(return_address) 80 | -------------------------------------------------------------------------------- /test.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import django 3 | from django.conf import settings 4 | 5 | configuration = { 6 | 'DEBUG': True, 7 | 'DATABASES': { 8 | 'default': { 9 | 'ENGINE': 'django.db.backends.sqlite3', 10 | } 11 | }, 12 | 'INSTALLED_APPS': [ 13 | 'django.contrib.auth', 14 | 'django.contrib.contenttypes', 15 | 'django.contrib.sessions', 16 | 'shopify_auth', 17 | ], 18 | 'AUTHENTICATION_BACKENDS': ['shopify_auth.backends.ShopUserBackend'], 19 | 'TEMPLATES': [ 20 | { 21 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 22 | 'APP_DIRS': True 23 | } 24 | ], 25 | 'ROOT_URLCONF': 'shopify_auth.tests.urls', 26 | 'SHOPIFY_APP_NAME': 'Test App', 27 | 'SHOPIFY_APP_API_KEY': 'test-api-key', 28 | 'SHOPIFY_APP_API_SECRET': 'test-api-secret', 29 | 'SHOPIFY_APP_API_VERSION': 'unstable', 30 | 'SHOPIFY_APP_API_SCOPE': ['read_products'], 31 | 'SHOPIFY_APP_DEV_MODE': False, 32 | 'SHOPIFY_APP_THIRD_PARTY_COOKIE_CHECK': True, 33 | 'SHOPIFY_AUTH_BOUNCE_PAGE_URL': '/', 34 | 'SECRET_KEY': 'uq8e140t1rm3^kk&blqxi*y9h_j5yd9ghjv+fd1p%08g4%t6%i', 35 | 'MIDDLEWARE': [ 36 | 'django.middleware.common.CommonMiddleware', 37 | 'django.contrib.sessions.middleware.SessionMiddleware', 38 | 'django.middleware.csrf.CsrfViewMiddleware', 39 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 40 | 'django.contrib.messages.middleware.MessageMiddleware', 41 | ] 42 | } 43 | 44 | settings.configure(**configuration) 45 | 46 | django.setup() 47 | 48 | from django.test.runner import DiscoverRunner 49 | 50 | test_runner = DiscoverRunner() 51 | failures = test_runner.run_tests(['shopify_auth']) 52 | if failures: 53 | sys.exit(failures) 54 | --------------------------------------------------------------------------------