Skip to content

Commit 0eca48d

Browse files
authored
✨ [oauth] Add oauth hmac signature validation function (#147)
* Working with ids * Try _qs variant - even uglier * Docs * Split into two functions * Clean up, document, and change name a bit * Remove extra exports & document safe characters * Remove missed __all__ list
1 parent ebc1026 commit 0eca48d

File tree

3 files changed

+80
-0
lines changed

3 files changed

+80
-0
lines changed

spylib/oauth/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@
44
exchange_token,
55
)
66
from .models import OfflineTokenModel, OnlineTokenModel
7+
from .signature_validation import validate_signed_query_string
78

89
__all__ = [
910
'exchange_token',
1011
'exchange_offline_token',
1112
'exchange_online_token',
1213
'OfflineTokenModel',
1314
'OnlineTokenModel',
15+
'validate_signed_query_string',
1416
]

spylib/oauth/signature_validation.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
from json import dumps
2+
from typing import List, Optional, Tuple
3+
from urllib.parse import parse_qsl, urlencode
4+
5+
from spylib.hmac import validate
6+
7+
8+
def validate_signed_query_string(query_string: str, *, api_secret_key: str):
9+
"""Validates that a query string has been signed by Shopify.
10+
11+
[Implements the parsing algorithm defined by Shopify here](https://shopify.dev/apps/auth/oauth/getting-started#step-7-verify-a-request).
12+
Including the special case [`ids` parameter parsing](https://shopify.dev/apps/auth/oauth/getting-started#ids-array-parameter)
13+
14+
15+
Args:
16+
query_string: A valid query string. `hmac=123&test=456`
17+
api_secret_key: The api secret key from Shopify partners.
18+
19+
Raises:
20+
Exception: `ValueError`
21+
"""
22+
23+
signature: Optional[str] = None
24+
query_params: List[Tuple[str, str]] = []
25+
26+
# I tried a number of options here to avoid doing
27+
# manual parsing and string manipulation. `parse_qs`
28+
# can work as long as you pass `doseq=True` & `quote_via=quote``
29+
# to urlencode. It does not, however, handle the ids param.
30+
# The code ended up being cleaner this way in one loop.
31+
32+
ids: List[str] = []
33+
for key, value in parse_qsl(query_string, strict_parsing=True):
34+
if key == 'hmac':
35+
signature = value
36+
continue
37+
if key == 'ids[]':
38+
if not ids:
39+
query_params.append(('ids', ''))
40+
ids.append(value)
41+
continue
42+
query_params.append((key, value))
43+
44+
# `safe` param via: https://stackoverflow.com/a/49244224
45+
message = urlencode(query_params, safe=':/').replace('ids=', f'ids={dumps(ids)}')
46+
47+
validate(
48+
sent_hmac=signature or '',
49+
message=message,
50+
secret=api_secret_key,
51+
)
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import pytest
2+
3+
# Initially tested with real data generated by Shopify and
4+
# a valid secret key. Then once passing, swapped characters
5+
# in the query strings out with random & generated a new hmac
6+
# with a fake secret key.
7+
8+
API_SECRET_KEY = 'fake_shopify123secret456key789'
9+
10+
install = 'hmac=fe078eb09b2b418b5db8c4efd2093ccab0855f28b3bc7d99e14c4971cf4ae670&host=XbFtofRsfgmNkF7qz32ytwZKHeAy7LPosJhqt93&shop=example-store.myshopify.com&timestamp=1654212701'
11+
embedded = 'hmac=3ec71fde88e45edee545bd79ff92da068661ec6298dcfbfb9da8f357dc16729b&host=XbFtofRsfgmNkF7qz32ytwZKHeAy7LPosJhqt93&locale=en-CA&session=76ef4wam8rhmm4qdertc5z36qu42i349syobtivqua9ih6hh667akenccbcur9de&shop=example-store.myshopify.com&timestamp=1654212956'
12+
admin_link = 'hmac=38a63775f637952cfcd9ef387367fad7ab0ffec892061c383031aacbb5c881ed&host=XbFtofRsfgmNkF7qz32ytwZKHeAy7LPosJhqt93&id=4729471893592&locale=en-CA&session=76ef4wam8rhmm4qdertc5z36qu42i349syobtivqua9ih6hh667akenccbcur9de&shop=example-store.myshopify.com&timestamp=1654214567'
13+
bulk_link = 'hmac=c1de8aff907271dd12d191f9ba41f1080c630c8b223c7706341a2329f4df2c7f&host=XbFtofRsfgmNkF7qz32ytwZKHeAy7LPosJhqt93&ids%5B%5D=4729471893592&ids%5B%5D=4751729295448&ids%5B%5D=4751914860632&ids%5B%5D=4751918727256&ids%5B%5D=6752260456536&locale=en-CA&session=76ef4wam8rhmm4qdertc5z36qu42i349syobtivqua9ih6hh667akenccbcur9de&shop=example-store.myshopify.com&timestamp=1654214623'
14+
15+
valid_params = [
16+
pytest.param(install, API_SECRET_KEY, id='install'),
17+
pytest.param(embedded, API_SECRET_KEY, id='embedded'),
18+
pytest.param(admin_link, API_SECRET_KEY, id='admin link'),
19+
pytest.param(bulk_link, API_SECRET_KEY, id='admin bulk link'),
20+
]
21+
22+
23+
@pytest.mark.parametrize('query_string,api_secret_key', valid_params)
24+
def test_signature_validation(query_string, api_secret_key):
25+
from spylib.oauth import validate_signed_query_string
26+
27+
validate_signed_query_string(query_string=query_string, api_secret_key=api_secret_key)

0 commit comments

Comments
 (0)