Skip to content

Commit e5e522e

Browse files
committed
Create SessionTokenUtility object to decode a session token
Add PyJWT dependency Support python 2.7 Remove contradictory autopep8 formatter Create custom exceptions for SessionTokenUtility Address comments Restructure utils into a subpackage
1 parent 8e654ee commit e5e522e

File tree

6 files changed

+165
-4
lines changed

6 files changed

+165
-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:

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/utils/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
from .exceptions import *
2+
from .utilities import SessionTokenUtility

shopify/utils/exceptions.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
class InvalidIssuerError(Exception):
2+
pass
3+
4+
5+
class MismatchedHostsError(Exception):
6+
pass
7+
8+
9+
class TokenAuthenticationError(Exception):
10+
pass

shopify/utils/utilities.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
from .exceptions import *
2+
3+
import jwt
4+
import re
5+
import sys
6+
7+
if sys.version_info[0] < 3: # Backwards compatibility for python < v3.0.0
8+
from urlparse import urljoin
9+
else:
10+
from urllib.parse import urljoin
11+
12+
13+
class SessionTokenUtility:
14+
ALGORITHM = "HS256"
15+
PREFIX = "Bearer "
16+
REQUIRED_FIELDS = ["iss", "dest", "sub", "jti", "sid"]
17+
18+
@classmethod
19+
def get_decoded_session_token(cls, authorization_header, api_key, secret):
20+
session_token = cls.__extract_session_token(authorization_header)
21+
decoded_payload = cls.__decode_session_token(session_token, api_key, secret)
22+
cls.__validate_issuer(decoded_payload)
23+
24+
return decoded_payload
25+
26+
@classmethod
27+
def __extract_session_token(cls, authorization_header):
28+
if not authorization_header.startswith(cls.PREFIX):
29+
raise TokenAuthenticationError("The HTTP_AUTHORIZATION_HEADER provided does not contain a Bearer token")
30+
31+
return authorization_header[len(cls.PREFIX) :]
32+
33+
@classmethod
34+
def __decode_session_token(cls, session_token, api_key, secret):
35+
return jwt.decode(
36+
session_token,
37+
secret,
38+
audience=api_key,
39+
algorithms=[cls.ALGORITHM],
40+
options={"require": cls.REQUIRED_FIELDS},
41+
)
42+
43+
@classmethod
44+
def __validate_issuer(cls, decoded_payload):
45+
cls.__validate_issuer_hostname(decoded_payload)
46+
cls.__validate_issuer_and_dest_match(decoded_payload)
47+
48+
@classmethod
49+
def __validate_issuer_hostname(cls, decoded_payload):
50+
hostname_pattern = r"[a-z0-9][a-z0-9-]*[a-z0-9]"
51+
shop_domain_re = re.compile(r"^https://{h}\.myshopify\.com/$".format(h=hostname_pattern))
52+
53+
issuer_root = urljoin(decoded_payload["iss"], "/")
54+
55+
if not shop_domain_re.match(issuer_root):
56+
raise InvalidIssuerError()
57+
58+
@classmethod
59+
def __validate_issuer_and_dest_match(cls, decoded_payload):
60+
issuer_root = urljoin(decoded_payload["iss"], "/")
61+
dest_root = urljoin(decoded_payload["dest"], "/")
62+
63+
if issuer_root != dest_root:
64+
raise MismatchedHostsError()

test/utils/utilities_test.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
from shopify.utils import *
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 TestSessionTokenUtilityGetDecodedSessionToken(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(TokenAuthenticationError):
46+
SessionTokenUtility.get_decoded_session_token(
47+
authorization_header, api_key=self.api_key, secret=self.secret
48+
)
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(jwt.exceptions.ExpiredSignatureError):
54+
SessionTokenUtility.get_decoded_session_token(
55+
self.build_auth_header(), api_key=self.api_key, secret=self.secret
56+
)
57+
58+
def test_raises_if_aud_doesnt_match_api_key(self):
59+
self.payload["aud"] = "bad audience"
60+
61+
with self.assertRaises(jwt.exceptions.InvalidAudienceError):
62+
SessionTokenUtility.get_decoded_session_token(
63+
self.build_auth_header(), api_key=self.api_key, secret=self.secret
64+
)
65+
66+
def test_raises_if_issuer_hostname_is_invalid(self):
67+
self.payload["iss"] = "bad_shop_hostname"
68+
69+
with self.assertRaises(InvalidIssuerError):
70+
SessionTokenUtility.get_decoded_session_token(
71+
self.build_auth_header(), api_key=self.api_key, secret=self.secret
72+
)
73+
74+
def test_raises_if_iss_and_dest_dont_match(self):
75+
self.payload["dest"] = "bad_shop.myshopify.com"
76+
77+
with self.assertRaises(MismatchedHostsError):
78+
SessionTokenUtility.get_decoded_session_token(
79+
self.build_auth_header(), api_key=self.api_key, secret=self.secret
80+
)
81+
82+
def test_returns_decoded_payload(self):
83+
decoded_payload = SessionTokenUtility.get_decoded_session_token(
84+
self.build_auth_header(), api_key=self.api_key, secret=self.secret
85+
)
86+
87+
self.assertEqual(self.payload, decoded_payload)

0 commit comments

Comments
 (0)