Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 33 additions & 7 deletions checkout_sdk/oauth_scopes.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,36 @@


class OAuthScopes(str, Enum):
"""OAuth 2.0 client credentials scopes.

Mirrors components.securitySchemes.OAuth.flows.clientCredentials.scopes in the Checkout.com API
specification, plus the scopes that appear only in per-operation security requirements and are
never declared in that map: compliance-requests, compliance-requests:read,
compliance-requests:respond, vault:gpayme-enrollment and vault:tokens-metadata.

Five further members -- issuing:card-mgmt, issuing:client, marketplace, middleware:gateway and
middleware:payment-context -- appear nowhere in the specification at all, but the authorization
server still grants them and callers still request them, so they are kept for backward
compatibility. Each is marked inline. Do not assume a scope is dead because the specification
omits it: the sandbox payouts client is provisioned for marketplace and answers a request for
accounts with invalid_scope.

Members are ordered alphabetically. Note that PAYMENT_CONTEXT and GATEWAY_PAYMENT_CONTEXTS are
different scopes: the specification requires the former for GET /payment-contexts/{id} and the
latter for POST /payment-contexts. 'Payment Context' is the only scope whose wire value contains
a space and a capital letter, which looks like a specification authoring defect; it is mirrored
verbatim regardless, because that is the value the authorization server is documented to accept.
"""

ACCOUNTS = 'accounts'
AGENTIC_INVENTORY = 'agentic:inventory'
BALANCES = 'balances'
BALANCES_VIEW = 'balances:view'
BALANCES_TOP_UP_INSTRUCTIONS = 'balances:top-up-instructions'
BALANCES_VIEW = 'balances:view'
CARD_MANAGEMENT = 'card-management'
COMPLIANCE_REQUESTS = 'compliance-requests'
COMPLIANCE_REQUESTS_READ = 'compliance-requests:read'
COMPLIANCE_REQUESTS_RESPOND = 'compliance-requests:respond'
DISPUTES = 'disputes'
DISPUTES_ACCEPT = 'disputes:accept'
DISPUTES_PROVIDE_EVIDENCE = 'disputes:provide-evidence'
Expand Down Expand Up @@ -39,24 +64,24 @@ class OAuthScopes(str, Enum):
IDENTITY_VERIFICATION = 'identity-verification'
ISSUING_CARD_MANAGEMENT_READ = 'issuing:card-management-read'
ISSUING_CARD_MANAGEMENT_WRITE = 'issuing:card-management-write'
ISSUING_CARD_MGMT = 'issuing:card-mgmt'
ISSUING_CLIENT = 'issuing:client'
ISSUING_CARD_MGMT = 'issuing:card-mgmt' # not in spec; kept for backward compat
ISSUING_CLIENT = 'issuing:client' # not in spec; kept for backward compat
ISSUING_CONTROLS_READ = 'issuing:controls-read'
ISSUING_CONTROLS_WRITE = 'issuing:controls-write'
ISSUING_DISPUTES = 'issuing-disputes'
ISSUING_DISPUTES_READ = 'issuing:disputes-read'
ISSUING_DISPUTES_WRITE = 'issuing:disputes-write'
ISSUING_TRANSACTIONS_READ = 'issuing:transactions-read'
ISSUING_TRANSACTIONS_WRITE = 'issuing:transactions-write'
MARKETPLACE = 'marketplace'
MARKETPLACE = 'marketplace' # not in spec; kept for backward compat
MIDDLEWARE = 'middleware'
MIDDLEWARE_GATEWAY = 'middleware:gateway'
MIDDLEWARE_GATEWAY = 'middleware:gateway' # not in spec; kept for backward compat
MIDDLEWARE_MERCHANTS_PUBLIC = 'middleware:merchants-public'
MIDDLEWARE_MERCHANTS_SECRET = 'middleware:merchants-secret'
MIDDLEWARE_PAYMENT_CONTEXT = 'middleware:payment-context'
PAYMENTS_SEARCH = 'payments:search'
MIDDLEWARE_PAYMENT_CONTEXT = 'middleware:payment-context' # not in spec; kept for backward compat
PAYMENT_CONTEXT = 'Payment Context'
PAYMENT_SESSIONS = 'payment-sessions'
PAYMENTS_SEARCH = 'payments:search'
PAYOUTS_BANK_DETAILS = 'payouts:bank-details'
REPORTS = 'reports'
REPORTS_VIEW = 'reports:view'
Expand All @@ -75,3 +100,4 @@ class OAuthScopes(str, Enum):
VAULT_NETWORK_TOKENS = 'vault:network-tokens'
VAULT_REAL_TIME_ACCOUNT_UPDATER = 'vault:real-time-account-updater'
VAULT_TOKENIZATION = 'vault:tokenization'
VAULT_TOKENS_METADATA = 'vault:tokens-metadata'
4 changes: 3 additions & 1 deletion tests/accounts/accounts_payout_schedules_integration_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ def payout_schedules_api():
.oauth() \
.client_credentials(client_id=os.environ.get('CHECKOUT_DEFAULT_OAUTH_PAYOUT_SCHEDULE_CLIENT_ID'),
client_secret=os.environ.get('CHECKOUT_DEFAULT_OAUTH_PAYOUT_SCHEDULE_CLIENT_SECRET')) \
.scopes([OAuthScopes.MARKETPLACE])
.scopes([OAuthScopes.ACCOUNTS])
# The marketplace scope was retired; both payout-schedules operations document accounts as their
# OAuth requirement.
# The sandbox OAuth clients are not provisioned for the merchant-specific subdomain, so the
# token request would come back invalid_client. Opting out explicitly until they are.
with warnings.catch_warnings():
Expand Down
5 changes: 4 additions & 1 deletion tests/issuing/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,11 @@ def issuing_checkout_api():
.oauth() \
.client_credentials(client_id=os.environ.get('CHECKOUT_DEFAULT_OAUTH_ISSUING_CLIENT_ID'),
client_secret=os.environ.get('CHECKOUT_DEFAULT_OAUTH_ISSUING_CLIENT_SECRET')) \
.scopes([OAuthScopes.ISSUING_CLIENT, OAuthScopes.ISSUING_CARD_MGMT,
.scopes([OAuthScopes.ISSUING_CARD_MANAGEMENT_READ, OAuthScopes.ISSUING_CARD_MANAGEMENT_WRITE,
OAuthScopes.ISSUING_CONTROLS_READ, OAuthScopes.ISSUING_CONTROLS_WRITE])
# issuing:card-mgmt and issuing:client were retired: neither is declared in the spec's scope map
# nor requested by any operation. The card-management pair above replaces the former; the latter
# has no documented equivalent.
# The sandbox OAuth clients are not provisioned for the merchant-specific subdomain, so the
# token request would come back invalid_client. Opting out explicitly until they are.
with warnings.catch_warnings():
Expand Down
81 changes: 81 additions & 0 deletions tests/oauth_scopes_test.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from collections import Counter

from checkout_sdk.oauth_scopes import OAuthScopes


Expand All @@ -16,3 +18,82 @@ def test_should_expose_documented_balances_scope_values(self):
assert OAuthScopes.BALANCES.value == 'balances'
assert OAuthScopes.BALANCES_VIEW.value == 'balances:view'
assert OAuthScopes.BALANCES_TOP_UP_INSTRUCTIONS.value == 'balances:top-up-instructions'

def test_should_expose_documented_values_for_scopes_added_in_spec_sync(self):
"""The scopes added when this enum was synced against the spec.

None of these four are declared in clientCredentials.scopes: they appear only in the
per-operation security requirements of GET/POST /compliance-requests/{payment_id} and
GET /tokens/{tokenId}/metadata. An enum built from the declared map alone would miss them.
"""
assert OAuthScopes.COMPLIANCE_REQUESTS.value == 'compliance-requests'
assert OAuthScopes.COMPLIANCE_REQUESTS_READ.value == 'compliance-requests:read'
assert OAuthScopes.COMPLIANCE_REQUESTS_RESPOND.value == 'compliance-requests:respond'
assert OAuthScopes.VAULT_TOKENS_METADATA.value == 'vault:tokens-metadata'

def test_should_expose_the_agentic_commerce_inventory_scope(self):
"""agentic:inventory is declared in clientCredentials.scopes ("Manage agentic commerce
inventory and reservations"), and it is the OAuth requirement of the ten /inventory/*
operations behind the beta agentic-commerce inventory and reservations endpoints.
"""
assert OAuthScopes.AGENTIC_INVENTORY.value == 'agentic:inventory'

def test_should_retain_the_legacy_scopes_the_spec_omits(self):
"""These five appear nowhere in the spec, so a spec-driven sweep would delete them.

They are kept deliberately: the authorization server still grants them and callers still
request them. marketplace is the proof -- the sandbox payouts client is provisioned for it
and answers a request for accounts with invalid_scope.
"""
assert OAuthScopes.ISSUING_CARD_MGMT.value == 'issuing:card-mgmt'
assert OAuthScopes.ISSUING_CLIENT.value == 'issuing:client'
assert OAuthScopes.MARKETPLACE.value == 'marketplace'
assert OAuthScopes.MIDDLEWARE_GATEWAY.value == 'middleware:gateway'
assert OAuthScopes.MIDDLEWARE_PAYMENT_CONTEXT.value == 'middleware:payment-context'

def test_should_distinguish_the_two_payment_context_scopes(self):
"""PAYMENT_CONTEXT and GATEWAY_PAYMENT_CONTEXTS read alike but are unrelated scopes.

The spec requires the former for GET /payment-contexts/{id} and the latter for
POST /payment-contexts. 'Payment Context' is the only scope whose value contains a space and
a capital letter, which is almost certainly a spec authoring defect -- asserted verbatim
because that is the value the authorization server is documented to accept.
"""
assert OAuthScopes.PAYMENT_CONTEXT.value == 'Payment Context'
assert OAuthScopes.GATEWAY_PAYMENT_CONTEXTS.value == 'gateway:payment-contexts'

def test_should_expose_a_non_blank_wire_value_for_every_member(self):
"""A blank value is not caught by the assertions above, which only read members they name.

oauth_credentials.py joins the requested scopes with a space, so a blank member would be
sent as an empty entry and the token endpoint would reject the whole request, costing the
caller every other scope it asked for.
"""
blank = [scope.name for scope in OAuthScopes if not scope.value.strip()]
assert blank == []

def test_should_not_reuse_a_wire_value_across_members(self):
"""A duplicate wire value means one of the two members is a copy-paste error.

Python's Enum hides this far better than the other SDKs' constructs do: the second member
to declare a value becomes an *alias* of the first rather than a member of its own, so
`OAuthScopes.VAULT_TOKENS_METADATA is OAuthScopes.VAULT_TOKENIZATION` would simply be True
and the scope the aliased name was meant to carry would be unreachable, with nothing
failing loudly.

This must iterate __members__, not the enum: iteration *skips* aliases, so counting values
that way can never see a duplicate and the assertion would hold vacuously.
"""
duplicates = [value for value, count in
Counter(scope.value for scope in OAuthScopes.__members__.values()).items()
if count > 1]
assert duplicates == []

def test_should_declare_members_in_alphabetical_order(self):
"""Members are kept alphabetical so the next spec sync produces a readable diff.

Underscores are ignored when comparing, which is what puts PAYMENT_CONTEXT,
PAYMENT_SESSIONS and PAYMENTS_SEARCH in that order, matching the other Checkout SDKs.
"""
declared = [scope.name.replace('_', '').lower() for scope in OAuthScopes]
assert declared == sorted(declared)
Loading