Skip to content

Commit c085c15

Browse files
lvalicsclaude
andcommitted
feat: add scope filtering support for OAuth 2.0 Client Credentials Grant
Add optional 'scope' parameter to all OAuth methods to enable requesting specific scopes instead of all configured scopes. This solves the issue where apps with multiple API types (Admin API + Customer Account API + Storefront API) cannot generate tokens through /admin/oauth/access_token because that endpoint only supports Admin API scopes. Changes: - Add scope parameter to request_token_client_credentials() - Add scope parameter to request_access_token() - Add scope parameter to refresh_token_if_needed() - Add scope parameter to refresh_token() - Implement scope normalization (convert commas to spaces for OAuth spec) - Add 7 comprehensive test cases for scope filtering functionality - Update CHANGELOG with feature documentation Use case: When a Shopify app has Customer Account API scopes configured (like customer_read_metaobjects), requesting a token without scope filtering fails because Shopify tries to grant ALL scopes through the Admin API endpoint. With scope filtering, developers can request only Admin API scopes: session.request_token_client_credentials(scope="read_products write_orders") Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent 7f6fc68 commit c085c15

3 files changed

Lines changed: 188 additions & 9 deletions

File tree

CHANGELOG

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@
1616
- Session now tracks token_obtained_at and token_expires_at timestamps for client credentials tokens
1717
- Default 5-minute buffer before expiration to ensure tokens are refreshed proactively
1818
- Auto-refresh only works with client credentials flow (API versions >= 2026-01)
19+
- Add scope filtering support for OAuth 2.0 Client Credentials Grant
20+
- All OAuth methods now accept optional 'scope' parameter to request specific scopes
21+
- Enables requesting only Admin API scopes when app has multiple API types configured (Admin, Customer Account, Storefront)
22+
- Scope normalization: Comma-separated scopes automatically converted to space-separated for OAuth 2.0 spec compliance
23+
- Methods supporting scope parameter: request_token_client_credentials(), request_access_token(), refresh_token_if_needed(), refresh_token()
24+
- When scope parameter is omitted, Shopify grants all scopes configured for the app (default behavior)
1925

2026
== Version 12.7.0
2127

shopify/session.py

Lines changed: 46 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ def request_token(self, params):
138138
else:
139139
raise Exception(response.msg)
140140

141-
def request_token_client_credentials(self):
141+
def request_token_client_credentials(self, scope=None):
142142
"""
143143
Exchange client credentials for an access token.
144144
@@ -153,6 +153,12 @@ def request_token_client_credentials(self):
153153
- Session.secret (client_secret) must be set
154154
- Shop URL must be valid
155155
156+
Args:
157+
scope (str, optional): Space or comma-separated list of scopes to request.
158+
If not provided, Shopify will grant all scopes configured
159+
for the app. Use this to request only Admin API scopes
160+
when your app has multiple API types configured.
161+
156162
Returns:
157163
dict: Token response containing:
158164
- access_token (str): The access token for API requests
@@ -166,7 +172,12 @@ def request_token_client_credentials(self):
166172
Example:
167173
>>> session = shopify.Session("mystore.myshopify.com", "2026-01")
168174
>>> shopify.Session.setup(api_key="client_id", secret="client_secret")
175+
>>> # Request all scopes
169176
>>> token_response = session.request_token_client_credentials()
177+
>>> # Or request specific Admin API scopes only
178+
>>> token_response = session.request_token_client_credentials(
179+
... scope="read_products,write_products,read_orders"
180+
... )
170181
>>> session.token = token_response["access_token"]
171182
>>> shopify.ShopifyResource.activate_session(session)
172183
"""
@@ -196,6 +207,12 @@ def request_token_client_credentials(self):
196207
"client_secret": self.secret
197208
}
198209

210+
# Add scope parameter if provided (to filter which scopes to request)
211+
if scope:
212+
# Normalize scope format (convert commas to spaces for OAuth spec)
213+
normalized_scope = scope.replace(',', ' ')
214+
data["scope"] = normalized_scope
215+
199216
# Prepare request headers
200217
headers = {"Content-Type": "application/x-www-form-urlencoded"}
201218

@@ -250,7 +267,7 @@ def request_token_client_credentials(self):
250267
except Exception as e:
251268
raise OAuthException("Unexpected error during OAuth request: %s" % str(e))
252269

253-
def request_access_token(self, params=None):
270+
def request_access_token(self, params=None, scope=None):
254271
"""
255272
Automatically select and execute the appropriate OAuth flow based on API version.
256273
@@ -264,6 +281,9 @@ def request_access_token(self, params=None):
264281
params: OAuth callback parameters (required for versions < 2026-01)
265282
Should include 'code', 'hmac', 'timestamp' for authorization code flow.
266283
Not used for client credentials flow (versions >= 2026-01).
284+
scope (str, optional): Space or comma-separated list of scopes to request.
285+
Only used for client credentials flow (versions >= 2026-01).
286+
If not provided, Shopify grants all configured scopes.
267287
268288
Returns:
269289
For versions >= 2026-01: dict with 'access_token', 'scope', 'expires_in'
@@ -286,7 +306,7 @@ def request_access_token(self, params=None):
286306
"""
287307
if self._requires_client_credentials():
288308
# API version 2026-01+: Use client credentials grant
289-
return self.request_token_client_credentials()
309+
return self.request_token_client_credentials(scope=scope)
290310
else:
291311
# Older API versions: Use authorization code grant
292312
if params is None:
@@ -342,7 +362,7 @@ def is_token_expired(self, buffer_seconds=300):
342362
# Token is expired if current time + buffer >= expiration time
343363
return now + buffer >= self.token_expires_at
344364

345-
def refresh_token_if_needed(self, buffer_seconds=300):
365+
def refresh_token_if_needed(self, buffer_seconds=300, scope=None):
346366
"""
347367
Automatically refresh the access token if expired or expiring soon.
348368
@@ -354,6 +374,10 @@ def refresh_token_if_needed(self, buffer_seconds=300):
354374
buffer_seconds (int): Number of seconds before expiration to trigger
355375
refresh. Default is 300 (5 minutes). This ensures
356376
the token is refreshed before it expires.
377+
scope (str, optional): Space or comma-separated list of scopes to request.
378+
If not provided, Shopify grants all configured scopes.
379+
Use this to request only Admin API scopes when your
380+
app has multiple API types configured.
357381
358382
Returns:
359383
dict or None:
@@ -381,13 +405,15 @@ def refresh_token_if_needed(self, buffer_seconds=300):
381405
... else:
382406
... print("Token is still valid")
383407
>>>
384-
>>> # Use custom buffer (refresh if expires within 10 minutes)
385-
>>> session.refresh_token_if_needed(buffer_seconds=600)
408+
>>> # Request only Admin API scopes when refreshing
409+
>>> result = session.refresh_token_if_needed(
410+
... scope="read_products,write_products,read_orders"
411+
... )
386412
"""
387413
if self.is_token_expired(buffer_seconds=buffer_seconds):
388414
# Only refresh if we have credentials (client credentials flow)
389415
if self._requires_client_credentials():
390-
return self.request_token_client_credentials()
416+
return self.request_token_client_credentials(scope=scope)
391417
else:
392418
# For authorization code flow, we can't auto-refresh
393419
# because we need user interaction for the callback
@@ -396,7 +422,7 @@ def refresh_token_if_needed(self, buffer_seconds=300):
396422
# Token is still valid, no refresh needed
397423
return None
398424

399-
def refresh_token(self):
425+
def refresh_token(self, scope=None):
400426
"""
401427
Manually force a refresh of the access token.
402428
@@ -408,6 +434,12 @@ def refresh_token(self):
408434
For automatic refresh based on expiration, use refresh_token_if_needed()
409435
instead.
410436
437+
Args:
438+
scope (str, optional): Space or comma-separated list of scopes to request.
439+
If not provided, Shopify grants all configured scopes.
440+
Use this to request only Admin API scopes when your
441+
app has multiple API types configured.
442+
411443
Returns:
412444
dict: Token response containing:
413445
- access_token (str): The new access token
@@ -431,6 +463,11 @@ def refresh_token(self):
431463
>>> new_token = session.refresh_token()
432464
>>> print(f"New token: {new_token['access_token']}")
433465
>>> print(f"Expires in: {new_token['expires_in']} seconds")
466+
>>>
467+
>>> # Request only Admin API scopes
468+
>>> new_token = session.refresh_token(
469+
... scope="read_products,write_products,read_orders"
470+
... )
434471
"""
435472
# Only works with client credentials flow
436473
if not self._requires_client_credentials():
@@ -446,7 +483,7 @@ def refresh_token(self):
446483
self.token_obtained_at = None
447484

448485
# Request new token
449-
return self.request_token_client_credentials()
486+
return self.request_token_client_credentials(scope=scope)
450487

451488
@property
452489
def api_version(self):

test/session_test.py

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -625,3 +625,139 @@ def test_token_expiration_tracking_stored(self):
625625
# Verify expires_at is approximately 24 hours from obtained_at
626626
expected_expiration = session.token_obtained_at + timedelta(seconds=86399)
627627
self.assertEqual(session.token_expires_at, expected_expiration)
628+
629+
def test_request_token_client_credentials_with_scope_parameter(self):
630+
"""Test that scope parameter is included in OAuth request when provided"""
631+
import urllib.parse
632+
633+
shopify.Session.setup(api_key="test_client_id", secret="test_client_secret")
634+
session = shopify.Session("testshop.myshopify.com", "2026-01")
635+
636+
# Mock the HTTP request and capture what was sent
637+
self.fake(
638+
None,
639+
url="https://testshop.myshopify.com/admin/oauth/access_token",
640+
method="POST",
641+
body='{"access_token": "test_token", "scope": "read_products write_products", "expires_in": 86399}',
642+
has_user_agent=False,
643+
)
644+
645+
# Request token with specific scopes (Admin API only)
646+
token_response = session.request_token_client_credentials(scope="read_products write_products")
647+
648+
# Verify token received
649+
self.assertEqual(token_response["access_token"], "test_token")
650+
self.assertEqual(token_response["scope"], "read_products write_products")
651+
652+
def test_request_token_client_credentials_without_scope_parameter(self):
653+
"""Test that scope parameter is NOT included when not provided (default behavior)"""
654+
shopify.Session.setup(api_key="test_client_id", secret="test_client_secret")
655+
session = shopify.Session("testshop.myshopify.com", "2026-01")
656+
657+
self.fake(
658+
None,
659+
url="https://testshop.myshopify.com/admin/oauth/access_token",
660+
method="POST",
661+
body='{"access_token": "test_token", "scope": "read_products write_products read_orders", "expires_in": 86399}',
662+
has_user_agent=False,
663+
)
664+
665+
# Request token without scope parameter (should grant all configured scopes)
666+
token_response = session.request_token_client_credentials()
667+
668+
# Verify token received with all scopes
669+
self.assertEqual(token_response["access_token"], "test_token")
670+
# Without scope filter, Shopify returns all configured scopes
671+
self.assertEqual(token_response["scope"], "read_products write_products read_orders")
672+
673+
def test_request_token_client_credentials_scope_normalization(self):
674+
"""Test that comma-separated scopes are normalized to space-separated for OAuth spec"""
675+
shopify.Session.setup(api_key="test_client_id", secret="test_client_secret")
676+
session = shopify.Session("testshop.myshopify.com", "2026-01")
677+
678+
self.fake(
679+
None,
680+
url="https://testshop.myshopify.com/admin/oauth/access_token",
681+
method="POST",
682+
body='{"access_token": "test_token", "scope": "read_products write_products", "expires_in": 86399}',
683+
has_user_agent=False,
684+
)
685+
686+
# Request with comma-separated scopes (should be normalized to spaces)
687+
token_response = session.request_token_client_credentials(scope="read_products,write_products")
688+
689+
# Verify token received
690+
self.assertEqual(token_response["access_token"], "test_token")
691+
692+
def test_refresh_token_if_needed_with_scope_parameter(self):
693+
"""Test that refresh_token_if_needed passes scope parameter correctly"""
694+
from datetime import datetime, timedelta
695+
696+
shopify.Session.setup(api_key="test_client_id", secret="test_client_secret")
697+
session = shopify.Session("testshop.myshopify.com", "2026-01")
698+
699+
# Set expired token
700+
session.token = "expired_token"
701+
session.token_obtained_at = datetime.now() - timedelta(hours=24)
702+
session.token_expires_at = datetime.now() - timedelta(minutes=10)
703+
704+
self.fake(
705+
None,
706+
url="https://testshop.myshopify.com/admin/oauth/access_token",
707+
method="POST",
708+
body='{"access_token": "refreshed_token", "scope": "read_products write_products", "expires_in": 86399}',
709+
has_user_agent=False,
710+
)
711+
712+
# Refresh with specific scope
713+
result = session.refresh_token_if_needed(scope="read_products write_products")
714+
715+
# Verify token was refreshed with correct scopes
716+
self.assertIsNotNone(result)
717+
self.assertEqual(result["access_token"], "refreshed_token")
718+
self.assertEqual(result["scope"], "read_products write_products")
719+
self.assertEqual(session.token, "refreshed_token")
720+
721+
def test_refresh_token_with_scope_parameter(self):
722+
"""Test that refresh_token passes scope parameter correctly"""
723+
shopify.Session.setup(api_key="test_client_id", secret="test_client_secret")
724+
session = shopify.Session("testshop.myshopify.com", "2026-01")
725+
726+
# Set existing token
727+
session.token = "old_token"
728+
729+
self.fake(
730+
None,
731+
url="https://testshop.myshopify.com/admin/oauth/access_token",
732+
method="POST",
733+
body='{"access_token": "new_token", "scope": "read_products", "expires_in": 86399}',
734+
has_user_agent=False,
735+
)
736+
737+
# Force refresh with specific scope (Admin API only)
738+
result = session.refresh_token(scope="read_products")
739+
740+
# Verify token was refreshed with correct scopes
741+
self.assertEqual(result["access_token"], "new_token")
742+
self.assertEqual(result["scope"], "read_products")
743+
self.assertEqual(session.token, "new_token")
744+
745+
def test_request_access_token_with_scope_parameter_2026_01(self):
746+
"""Test that request_access_token passes scope to client credentials flow for 2026-01+"""
747+
shopify.Session.setup(api_key="test_client_id", secret="test_client_secret")
748+
session = shopify.Session("testshop.myshopify.com", "2026-01")
749+
750+
self.fake(
751+
None,
752+
url="https://testshop.myshopify.com/admin/oauth/access_token",
753+
method="POST",
754+
body='{"access_token": "test_token", "scope": "read_products", "expires_in": 86399}',
755+
has_user_agent=False,
756+
)
757+
758+
# Use smart method with scope parameter
759+
result = session.request_access_token(scope="read_products")
760+
761+
# Verify correct token received
762+
self.assertEqual(result["access_token"], "test_token")
763+
self.assertEqual(result["scope"], "read_products")

0 commit comments

Comments
 (0)