Skip to content

Commit 6228338

Browse files
feature/INT-1697 - Oauth scopes review (#237)
* Oauth scopes review * Oauth scopes retrocompatibility * agentic:inventory scope
1 parent 1b5e1d1 commit 6228338

4 files changed

Lines changed: 121 additions & 9 deletions

File tree

checkout_sdk/oauth_scopes.py

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,36 @@
44

55

66
class OAuthScopes(str, Enum):
7+
"""OAuth 2.0 client credentials scopes.
8+
9+
Mirrors components.securitySchemes.OAuth.flows.clientCredentials.scopes in the Checkout.com API
10+
specification, plus the scopes that appear only in per-operation security requirements and are
11+
never declared in that map: compliance-requests, compliance-requests:read,
12+
compliance-requests:respond, vault:gpayme-enrollment and vault:tokens-metadata.
13+
14+
Five further members -- issuing:card-mgmt, issuing:client, marketplace, middleware:gateway and
15+
middleware:payment-context -- appear nowhere in the specification at all, but the authorization
16+
server still grants them and callers still request them, so they are kept for backward
17+
compatibility. Each is marked inline. Do not assume a scope is dead because the specification
18+
omits it: the sandbox payouts client is provisioned for marketplace and answers a request for
19+
accounts with invalid_scope.
20+
21+
Members are ordered alphabetically. Note that PAYMENT_CONTEXT and GATEWAY_PAYMENT_CONTEXTS are
22+
different scopes: the specification requires the former for GET /payment-contexts/{id} and the
23+
latter for POST /payment-contexts. 'Payment Context' is the only scope whose wire value contains
24+
a space and a capital letter, which looks like a specification authoring defect; it is mirrored
25+
verbatim regardless, because that is the value the authorization server is documented to accept.
26+
"""
27+
728
ACCOUNTS = 'accounts'
29+
AGENTIC_INVENTORY = 'agentic:inventory'
830
BALANCES = 'balances'
9-
BALANCES_VIEW = 'balances:view'
1031
BALANCES_TOP_UP_INSTRUCTIONS = 'balances:top-up-instructions'
32+
BALANCES_VIEW = 'balances:view'
1133
CARD_MANAGEMENT = 'card-management'
34+
COMPLIANCE_REQUESTS = 'compliance-requests'
35+
COMPLIANCE_REQUESTS_READ = 'compliance-requests:read'
36+
COMPLIANCE_REQUESTS_RESPOND = 'compliance-requests:respond'
1237
DISPUTES = 'disputes'
1338
DISPUTES_ACCEPT = 'disputes:accept'
1439
DISPUTES_PROVIDE_EVIDENCE = 'disputes:provide-evidence'
@@ -39,24 +64,24 @@ class OAuthScopes(str, Enum):
3964
IDENTITY_VERIFICATION = 'identity-verification'
4065
ISSUING_CARD_MANAGEMENT_READ = 'issuing:card-management-read'
4166
ISSUING_CARD_MANAGEMENT_WRITE = 'issuing:card-management-write'
42-
ISSUING_CARD_MGMT = 'issuing:card-mgmt'
43-
ISSUING_CLIENT = 'issuing:client'
67+
ISSUING_CARD_MGMT = 'issuing:card-mgmt' # not in spec; kept for backward compat
68+
ISSUING_CLIENT = 'issuing:client' # not in spec; kept for backward compat
4469
ISSUING_CONTROLS_READ = 'issuing:controls-read'
4570
ISSUING_CONTROLS_WRITE = 'issuing:controls-write'
4671
ISSUING_DISPUTES = 'issuing-disputes'
4772
ISSUING_DISPUTES_READ = 'issuing:disputes-read'
4873
ISSUING_DISPUTES_WRITE = 'issuing:disputes-write'
4974
ISSUING_TRANSACTIONS_READ = 'issuing:transactions-read'
5075
ISSUING_TRANSACTIONS_WRITE = 'issuing:transactions-write'
51-
MARKETPLACE = 'marketplace'
76+
MARKETPLACE = 'marketplace' # not in spec; kept for backward compat
5277
MIDDLEWARE = 'middleware'
53-
MIDDLEWARE_GATEWAY = 'middleware:gateway'
78+
MIDDLEWARE_GATEWAY = 'middleware:gateway' # not in spec; kept for backward compat
5479
MIDDLEWARE_MERCHANTS_PUBLIC = 'middleware:merchants-public'
5580
MIDDLEWARE_MERCHANTS_SECRET = 'middleware:merchants-secret'
56-
MIDDLEWARE_PAYMENT_CONTEXT = 'middleware:payment-context'
57-
PAYMENTS_SEARCH = 'payments:search'
81+
MIDDLEWARE_PAYMENT_CONTEXT = 'middleware:payment-context' # not in spec; kept for backward compat
5882
PAYMENT_CONTEXT = 'Payment Context'
5983
PAYMENT_SESSIONS = 'payment-sessions'
84+
PAYMENTS_SEARCH = 'payments:search'
6085
PAYOUTS_BANK_DETAILS = 'payouts:bank-details'
6186
REPORTS = 'reports'
6287
REPORTS_VIEW = 'reports:view'
@@ -75,3 +100,4 @@ class OAuthScopes(str, Enum):
75100
VAULT_NETWORK_TOKENS = 'vault:network-tokens'
76101
VAULT_REAL_TIME_ACCOUNT_UPDATER = 'vault:real-time-account-updater'
77102
VAULT_TOKENIZATION = 'vault:tokenization'
103+
VAULT_TOKENS_METADATA = 'vault:tokens-metadata'

tests/accounts/accounts_payout_schedules_integration_test.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@ def payout_schedules_api():
2020
.oauth() \
2121
.client_credentials(client_id=os.environ.get('CHECKOUT_DEFAULT_OAUTH_PAYOUT_SCHEDULE_CLIENT_ID'),
2222
client_secret=os.environ.get('CHECKOUT_DEFAULT_OAUTH_PAYOUT_SCHEDULE_CLIENT_SECRET')) \
23-
.scopes([OAuthScopes.MARKETPLACE])
23+
.scopes([OAuthScopes.ACCOUNTS])
24+
# The marketplace scope was retired; both payout-schedules operations document accounts as their
25+
# OAuth requirement.
2426
# The sandbox OAuth clients are not provisioned for the merchant-specific subdomain, so the
2527
# token request would come back invalid_client. Opting out explicitly until they are.
2628
with warnings.catch_warnings():

tests/issuing/conftest.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,11 @@ def issuing_checkout_api():
2121
.oauth() \
2222
.client_credentials(client_id=os.environ.get('CHECKOUT_DEFAULT_OAUTH_ISSUING_CLIENT_ID'),
2323
client_secret=os.environ.get('CHECKOUT_DEFAULT_OAUTH_ISSUING_CLIENT_SECRET')) \
24-
.scopes([OAuthScopes.ISSUING_CLIENT, OAuthScopes.ISSUING_CARD_MGMT,
24+
.scopes([OAuthScopes.ISSUING_CARD_MANAGEMENT_READ, OAuthScopes.ISSUING_CARD_MANAGEMENT_WRITE,
2525
OAuthScopes.ISSUING_CONTROLS_READ, OAuthScopes.ISSUING_CONTROLS_WRITE])
26+
# issuing:card-mgmt and issuing:client were retired: neither is declared in the spec's scope map
27+
# nor requested by any operation. The card-management pair above replaces the former; the latter
28+
# has no documented equivalent.
2629
# The sandbox OAuth clients are not provisioned for the merchant-specific subdomain, so the
2730
# token request would come back invalid_client. Opting out explicitly until they are.
2831
with warnings.catch_warnings():

tests/oauth_scopes_test.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from collections import Counter
2+
13
from checkout_sdk.oauth_scopes import OAuthScopes
24

35

@@ -16,3 +18,82 @@ def test_should_expose_documented_balances_scope_values(self):
1618
assert OAuthScopes.BALANCES.value == 'balances'
1719
assert OAuthScopes.BALANCES_VIEW.value == 'balances:view'
1820
assert OAuthScopes.BALANCES_TOP_UP_INSTRUCTIONS.value == 'balances:top-up-instructions'
21+
22+
def test_should_expose_documented_values_for_scopes_added_in_spec_sync(self):
23+
"""The scopes added when this enum was synced against the spec.
24+
25+
None of these four are declared in clientCredentials.scopes: they appear only in the
26+
per-operation security requirements of GET/POST /compliance-requests/{payment_id} and
27+
GET /tokens/{tokenId}/metadata. An enum built from the declared map alone would miss them.
28+
"""
29+
assert OAuthScopes.COMPLIANCE_REQUESTS.value == 'compliance-requests'
30+
assert OAuthScopes.COMPLIANCE_REQUESTS_READ.value == 'compliance-requests:read'
31+
assert OAuthScopes.COMPLIANCE_REQUESTS_RESPOND.value == 'compliance-requests:respond'
32+
assert OAuthScopes.VAULT_TOKENS_METADATA.value == 'vault:tokens-metadata'
33+
34+
def test_should_expose_the_agentic_commerce_inventory_scope(self):
35+
"""agentic:inventory is declared in clientCredentials.scopes ("Manage agentic commerce
36+
inventory and reservations"), and it is the OAuth requirement of the ten /inventory/*
37+
operations behind the beta agentic-commerce inventory and reservations endpoints.
38+
"""
39+
assert OAuthScopes.AGENTIC_INVENTORY.value == 'agentic:inventory'
40+
41+
def test_should_retain_the_legacy_scopes_the_spec_omits(self):
42+
"""These five appear nowhere in the spec, so a spec-driven sweep would delete them.
43+
44+
They are kept deliberately: the authorization server still grants them and callers still
45+
request them. marketplace is the proof -- the sandbox payouts client is provisioned for it
46+
and answers a request for accounts with invalid_scope.
47+
"""
48+
assert OAuthScopes.ISSUING_CARD_MGMT.value == 'issuing:card-mgmt'
49+
assert OAuthScopes.ISSUING_CLIENT.value == 'issuing:client'
50+
assert OAuthScopes.MARKETPLACE.value == 'marketplace'
51+
assert OAuthScopes.MIDDLEWARE_GATEWAY.value == 'middleware:gateway'
52+
assert OAuthScopes.MIDDLEWARE_PAYMENT_CONTEXT.value == 'middleware:payment-context'
53+
54+
def test_should_distinguish_the_two_payment_context_scopes(self):
55+
"""PAYMENT_CONTEXT and GATEWAY_PAYMENT_CONTEXTS read alike but are unrelated scopes.
56+
57+
The spec requires the former for GET /payment-contexts/{id} and the latter for
58+
POST /payment-contexts. 'Payment Context' is the only scope whose value contains a space and
59+
a capital letter, which is almost certainly a spec authoring defect -- asserted verbatim
60+
because that is the value the authorization server is documented to accept.
61+
"""
62+
assert OAuthScopes.PAYMENT_CONTEXT.value == 'Payment Context'
63+
assert OAuthScopes.GATEWAY_PAYMENT_CONTEXTS.value == 'gateway:payment-contexts'
64+
65+
def test_should_expose_a_non_blank_wire_value_for_every_member(self):
66+
"""A blank value is not caught by the assertions above, which only read members they name.
67+
68+
oauth_credentials.py joins the requested scopes with a space, so a blank member would be
69+
sent as an empty entry and the token endpoint would reject the whole request, costing the
70+
caller every other scope it asked for.
71+
"""
72+
blank = [scope.name for scope in OAuthScopes if not scope.value.strip()]
73+
assert blank == []
74+
75+
def test_should_not_reuse_a_wire_value_across_members(self):
76+
"""A duplicate wire value means one of the two members is a copy-paste error.
77+
78+
Python's Enum hides this far better than the other SDKs' constructs do: the second member
79+
to declare a value becomes an *alias* of the first rather than a member of its own, so
80+
`OAuthScopes.VAULT_TOKENS_METADATA is OAuthScopes.VAULT_TOKENIZATION` would simply be True
81+
and the scope the aliased name was meant to carry would be unreachable, with nothing
82+
failing loudly.
83+
84+
This must iterate __members__, not the enum: iteration *skips* aliases, so counting values
85+
that way can never see a duplicate and the assertion would hold vacuously.
86+
"""
87+
duplicates = [value for value, count in
88+
Counter(scope.value for scope in OAuthScopes.__members__.values()).items()
89+
if count > 1]
90+
assert duplicates == []
91+
92+
def test_should_declare_members_in_alphabetical_order(self):
93+
"""Members are kept alphabetical so the next spec sync produces a readable diff.
94+
95+
Underscores are ignored when comparing, which is what puts PAYMENT_CONTEXT,
96+
PAYMENT_SESSIONS and PAYMENTS_SEARCH in that order, matching the other Checkout SDKs.
97+
"""
98+
declared = [scope.name.replace('_', '').lower() for scope in OAuthScopes]
99+
assert declared == sorted(declared)

0 commit comments

Comments
 (0)