Skip to content

Commit 026c6e9

Browse files
authored
Merge pull request #479 from Shopify/add-session-token-utilites
[Feature] Add session token support
2 parents 8e654ee + 203cde9 commit 026c6e9

File tree

8 files changed

+306
-4
lines changed

8 files changed

+306
-4
lines changed

.pre-commit-config.yaml

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,6 @@ repos:
66
hooks:
77
- id: end-of-file-fixer
88
- id: trailing-whitespace
9-
- repo: https://github.com/pre-commit/mirrors-autopep8
10-
rev: v1.5.4
11-
hooks:
12-
- id: autopep8
139
- repo: https://github.com/psf/black
1410
rev: 20.8b1
1511
hooks:

README.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,61 @@ _Note: Your application must be public to test the billing process. To test on a
120120
has_been_billed = activated_charge.status == 'active'
121121
```
122122

123+
### Session tokens
124+
125+
The Shopify Python API library provides helper methods to decode [session tokens](https://shopify.dev/concepts/apps/building-embedded-apps-using-session-tokens). You can use the `decode_from_header` function to extract and decode a session token from an HTTP Authorization header.
126+
127+
#### Basic usage
128+
129+
```python
130+
from shopify import session_token
131+
132+
decoded_payload = session_token.decode_from_header(
133+
authorization_header=your_auth_request_header,
134+
api_key=your_api_key,
135+
secret=your_api_secret,
136+
)
137+
```
138+
139+
#### Create a decorator using `session_token`
140+
141+
Here's a sample decorator that protects your app views/routes by requiring the presence of valid session tokens as part of a request's headers.
142+
143+
```python
144+
from shopify import session_token
145+
146+
147+
def session_token_required(func):
148+
def wrapper(*args, **kwargs):
149+
request = args[0] # Or flask.request if you use Flask
150+
try:
151+
decoded_session_token = session_token.decode_from_header(
152+
authorization_header = request.headers.get('Authorization'),
153+
api_key = SHOPIFY_API_KEY,
154+
secret = SHOPIFY_API_SECRET
155+
)
156+
with shopify_session(decoded_session_token):
157+
return func(*args, **kwargs)
158+
except session_token.SessionTokenError as e:
159+
# Log the error here
160+
return unauthorized_401_response()
161+
162+
return wrapper
163+
164+
165+
def shopify_session(decoded_session_token):
166+
shopify_domain = decoded_session_token.get("dest")
167+
access_token = get_offline_access_token_by_shop_domain(shopify_domain)
168+
169+
return shopify.Session.temp(shopify_domain, SHOPIFY_API_VERSION, access_token)
170+
171+
172+
@session_token_required # Requests to /products require session tokens
173+
def products(request):
174+
products = shopify.Product.find()
175+
...
176+
```
177+
123178
### Advanced Usage
124179
It is recommended to have at least a basic grasp on the principles of the [pyactiveresource](https://github.com/Shopify/pyactiveresource) library, which is a port of rails/ActiveResource to Python and upon which this package relies heavily.
125180

setup.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323
license="MIT License",
2424
install_requires=[
2525
"pyactiveresource>=2.2.2",
26+
"PyJWT <= 1.7.1; python_version == '2.7'",
27+
"PyJWT >= 2.0.0; python_version >= '3.6'",
2628
"PyYAML",
2729
"six",
2830
],

shopify/session_token.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import jwt
2+
import re
3+
import six
4+
import sys
5+
6+
from shopify.utils import shop_url
7+
8+
if sys.version_info[0] < 3: # Backwards compatibility for python < v3.0.0
9+
from urlparse import urljoin
10+
else:
11+
from urllib.parse import urljoin
12+
13+
14+
ALGORITHM = "HS256"
15+
PREFIX = "Bearer "
16+
REQUIRED_FIELDS = ["iss", "dest", "sub", "jti", "sid"]
17+
18+
19+
class SessionTokenError(Exception):
20+
pass
21+
22+
23+
class InvalidIssuerError(SessionTokenError):
24+
pass
25+
26+
27+
class MismatchedHostsError(SessionTokenError):
28+
pass
29+
30+
31+
class TokenAuthenticationError(SessionTokenError):
32+
pass
33+
34+
35+
def decode_from_header(authorization_header, api_key, secret):
36+
session_token = _extract_session_token(authorization_header)
37+
decoded_payload = _decode_session_token(session_token, api_key, secret)
38+
_validate_issuer(decoded_payload)
39+
40+
return decoded_payload
41+
42+
43+
def _extract_session_token(authorization_header):
44+
if not authorization_header.startswith(PREFIX):
45+
raise TokenAuthenticationError("The HTTP_AUTHORIZATION_HEADER provided does not contain a Bearer token")
46+
47+
return authorization_header[len(PREFIX) :]
48+
49+
50+
def _decode_session_token(session_token, api_key, secret):
51+
try:
52+
return jwt.decode(
53+
session_token,
54+
secret,
55+
audience=api_key,
56+
algorithms=[ALGORITHM],
57+
options={"require": REQUIRED_FIELDS},
58+
)
59+
except jwt.exceptions.PyJWTError as exception:
60+
six.raise_from(SessionTokenError(str(exception)), exception)
61+
62+
63+
def _validate_issuer(decoded_payload):
64+
_validate_issuer_hostname(decoded_payload)
65+
_validate_issuer_and_dest_match(decoded_payload)
66+
67+
68+
def _validate_issuer_hostname(decoded_payload):
69+
issuer_root = urljoin(decoded_payload["iss"], "/")
70+
71+
if not shop_url.sanitize_shop_domain(issuer_root):
72+
raise InvalidIssuerError("Invalid issuer")
73+
74+
75+
def _validate_issuer_and_dest_match(decoded_payload):
76+
issuer_root = urljoin(decoded_payload["iss"], "/")
77+
dest_root = urljoin(decoded_payload["dest"], "/")
78+
79+
if issuer_root != dest_root:
80+
raise MismatchedHostsError("The issuer and destination do not match")

shopify/utils/__init__.py

Whitespace-only changes.

shopify/utils/shop_url.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import re
2+
import sys
3+
4+
if sys.version_info[0] < 3: # Backwards compatibility for python < v3.0.0
5+
from urlparse import urlparse
6+
else:
7+
from urllib.parse import urlparse
8+
9+
HOSTNAME_PATTERN = r"[a-z0-9][a-z0-9-]*[a-z0-9]"
10+
11+
12+
def sanitize_shop_domain(shop_domain, myshopify_domain="myshopify.com"):
13+
name = str(shop_domain).lower().strip()
14+
if myshopify_domain not in name and "." not in name:
15+
name += ".{domain}".format(domain=myshopify_domain)
16+
name = re.sub(r"https?://", "", name)
17+
18+
uri = urlparse("http://{hostname}".format(hostname=name))
19+
if re.match(r"{h}\.{d}$".format(h=HOSTNAME_PATTERN, d=re.escape(myshopify_domain)), uri.netloc):
20+
return uri.netloc

test/session_token_test.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
from shopify import session_token
2+
from test.test_helper import TestCase
3+
from datetime import datetime, timedelta
4+
5+
import jwt
6+
import sys
7+
8+
if sys.version_info[0] < 3: # Backwards compatibility for python < v3.0.0
9+
import time
10+
11+
12+
def timestamp(date):
13+
return time.mktime(date.timetuple()) if sys.version_info[0] < 3 else date.timestamp()
14+
15+
16+
class TestSessionTokenGetDecodedSessionToken(TestCase):
17+
@classmethod
18+
def setUpClass(self):
19+
self.secret = "API Secret"
20+
self.api_key = "API key"
21+
22+
@classmethod
23+
def setUp(self):
24+
current_time = datetime.now()
25+
self.payload = {
26+
"iss": "https://test-shop.myshopify.com/admin",
27+
"dest": "https://test-shop.myshopify.com",
28+
"aud": self.api_key,
29+
"sub": "1",
30+
"exp": timestamp((current_time + timedelta(0, 60))),
31+
"nbf": timestamp(current_time),
32+
"iat": timestamp(current_time),
33+
"jti": "4321",
34+
"sid": "abc123",
35+
}
36+
37+
@classmethod
38+
def build_auth_header(self):
39+
mock_session_token = jwt.encode(self.payload, self.secret, algorithm="HS256")
40+
return "Bearer {session_token}".format(session_token=mock_session_token)
41+
42+
def test_raises_if_token_authentication_header_is_not_bearer(self):
43+
authorization_header = "Bad auth header"
44+
45+
with self.assertRaises(session_token.TokenAuthenticationError) as cm:
46+
session_token.decode_from_header(authorization_header, api_key=self.api_key, secret=self.secret)
47+
48+
self.assertEqual("The HTTP_AUTHORIZATION_HEADER provided does not contain a Bearer token", str(cm.exception))
49+
50+
def test_raises_jwt_error_if_session_token_is_expired(self):
51+
self.payload["exp"] = timestamp((datetime.now() + timedelta(0, -10)))
52+
53+
with self.assertRaises(session_token.SessionTokenError) as cm:
54+
session_token.decode_from_header(self.build_auth_header(), api_key=self.api_key, secret=self.secret)
55+
56+
self.assertEqual("Signature has expired", str(cm.exception))
57+
58+
def test_raises_jwt_error_if_invalid_alg(self):
59+
bad_session_token = jwt.encode(self.payload, None, algorithm="none")
60+
invalid_header = "Bearer {session_token}".format(session_token=bad_session_token)
61+
62+
with self.assertRaises(session_token.SessionTokenError) as cm:
63+
session_token.decode_from_header(invalid_header, api_key=self.api_key, secret=self.secret)
64+
65+
self.assertEqual("The specified alg value is not allowed", str(cm.exception))
66+
67+
def test_raises_jwt_error_if_invalid_signature(self):
68+
bad_session_token = jwt.encode(self.payload, "bad_secret", algorithm="HS256")
69+
invalid_header = "Bearer {session_token}".format(session_token=bad_session_token)
70+
71+
with self.assertRaises(session_token.SessionTokenError) as cm:
72+
session_token.decode_from_header(invalid_header, api_key=self.api_key, secret=self.secret)
73+
74+
self.assertEqual("Signature verification failed", str(cm.exception))
75+
76+
def test_raises_if_aud_doesnt_match_api_key(self):
77+
self.payload["aud"] = "bad audience"
78+
79+
with self.assertRaises(session_token.SessionTokenError) as cm:
80+
session_token.decode_from_header(self.build_auth_header(), api_key=self.api_key, secret=self.secret)
81+
82+
self.assertEqual("Invalid audience", str(cm.exception))
83+
84+
def test_raises_if_issuer_hostname_is_invalid(self):
85+
self.payload["iss"] = "bad_shop_hostname"
86+
87+
with self.assertRaises(session_token.InvalidIssuerError) as cm:
88+
session_token.decode_from_header(self.build_auth_header(), api_key=self.api_key, secret=self.secret)
89+
90+
self.assertEqual("Invalid issuer", str(cm.exception))
91+
92+
def test_raises_if_iss_and_dest_dont_match(self):
93+
self.payload["dest"] = "bad_shop.myshopify.com"
94+
95+
with self.assertRaises(session_token.MismatchedHostsError) as cm:
96+
session_token.decode_from_header(self.build_auth_header(), api_key=self.api_key, secret=self.secret)
97+
98+
self.assertEqual("The issuer and destination do not match", str(cm.exception))
99+
100+
def test_returns_decoded_payload(self):
101+
decoded_payload = session_token.decode_from_header(
102+
self.build_auth_header(), api_key=self.api_key, secret=self.secret
103+
)
104+
105+
self.assertEqual(self.payload, decoded_payload)

test/utils/shop_url_test.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
from shopify.utils import shop_url
2+
from test.test_helper import TestCase
3+
4+
5+
class TestSanitizeShopDomain(TestCase):
6+
def test_returns_hostname_for_good_shop_domains(self):
7+
good_shop_domains = [
8+
"my-shop",
9+
"my-shop.myshopify.com",
10+
"http://my-shop.myshopify.com",
11+
"https://my-shop.myshopify.com",
12+
]
13+
sanitized_shops = [shop_url.sanitize_shop_domain(shop_domain) for shop_domain in good_shop_domains]
14+
15+
self.assertTrue(all(shop == "my-shop.myshopify.com" for shop in sanitized_shops))
16+
17+
def test_returns_none_for_bad_shop_domains(self):
18+
bad_shop_domains = [
19+
"myshop.com",
20+
"myshopify.com",
21+
"shopify.com",
22+
"two words",
23+
"store.myshopify.com.evil.com",
24+
"/foo/bar",
25+
"/foo.myshopify.io.evil.ru",
26+
"%0a123.myshopify.io ",
27+
"foo.bar.myshopify.io",
28+
]
29+
sanitized_shops = [shop_url.sanitize_shop_domain(shop_domain) for shop_domain in bad_shop_domains]
30+
31+
self.assertTrue(all(shop_domain is None for shop_domain in sanitized_shops))
32+
33+
def test_returns_hostname_for_custom_shop_domains(self):
34+
custom_shop_domains = [
35+
"my-shop",
36+
"my-shop.myshopify.io",
37+
"http://my-shop.myshopify.io",
38+
"https://my-shop.myshopify.io",
39+
]
40+
sanitized_shops = [
41+
shop_url.sanitize_shop_domain(shop_domain, "myshopify.io") for shop_domain in custom_shop_domains
42+
]
43+
44+
self.assertTrue(all(shop == "my-shop.myshopify.io" for shop in sanitized_shops))

0 commit comments

Comments
 (0)