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
66 changes: 53 additions & 13 deletions src/main/java/com/checkout/OAuthScope.java
Original file line number Diff line number Diff line change
@@ -1,15 +1,44 @@
package com.checkout;

/**
* OAuth 2.0 client credentials scopes.
*
* <p>Mirrors {@code components.securitySchemes.OAuth.flows.clientCredentials.scopes} in the
* Checkout.com API specification, plus the scopes that appear only in per-operation
* {@code security} requirements and are never declared in that map:
* {@code compliance-requests}, {@code compliance-requests:read},
* {@code compliance-requests:respond}, {@code vault:gpayme-enrollment} and
* {@code vault:tokens-metadata}.</p>
*
* <p>Five further constants -- {@code issuing:card-mgmt}, {@code issuing:client},
* {@code marketplace}, {@code middleware:gateway} and {@code 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 {@code marketplace} and answers a request for {@code accounts} with
* {@code invalid_scope}.</p>
*
* <p>Constants are ordered alphabetically. Note that {@link #PAYMENT_CONTEXT} and
* {@link #GATEWAY_PAYMENT_CONTEXTS} are different scopes: the specification requires the former for
* {@code GET /payment-contexts/{id}} and the latter for {@code POST /payment-contexts}.
* {@code 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.</p>
*/
public enum OAuthScope {

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"),
DISPUTES_SCHEME_FILES("disputes:scheme-files"),
DISPUTES_VIEW("disputes:view"),
FILES("files"),
FILES_DOWNLOAD("files:download"),
Expand All @@ -19,29 +48,39 @@ public enum OAuthScope {
FINANCIAL_ACTIONS_VIEW("financial-actions:view"),
FLOW("flow"),
FLOW_EVENTS("flow:events"),
FLOW_REFLOW("flow:reflow"),
FLOW_WORKFLOWS("flow:workflows"),
FORWARD("forward"),
FORWARD_SECRETS("forward:secrets"),
FX("fx"),
GATEWAY("gateway"),
GATEWAY_PAYMENT("gateway:payment"),
GATEWAY_PAYMENT_AUTHORIZATION("gateway:payment-authorizations"),
GATEWAY_PAYMENT_CAPTURES("gateway:payment-captures"),
GATEWAY_PAYMENT_CANCELLATIONS("gateway:payment-cancellations"),
GATEWAY_PAYMENT_CAPTURES("gateway:payment-captures"),
GATEWAY_PAYMENT_CONTEXTS("gateway:payment-contexts"),
GATEWAY_PAYMENT_DETAILS("gateway:payment-details"),
GATEWAY_PAYMENT_REFUNDS("gateway:payment-refunds"),
GATEWAY_PAYMENT_VOIDS("gateway:payment-voids"),
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"), // not in spec; kept for backward compat
MIDDLEWARE_MERCHANTS_PUBLIC("middleware:merchants-public"),
MIDDLEWARE_MERCHANTS_SECRET("middleware:merchants-secret"),
PAYMENT_CONTEXTS("gateway:payment-contexts"),
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"),
Expand All @@ -56,17 +95,18 @@ public enum OAuthScope {
VAULT("vault"),
VAULT_APME_ENROLLMENT("vault:apme-enrollment"),
VAULT_CARD_METADATA("vault:card-metadata"),
VAULT_CUSTOMERS("vault:customers"),
VAULT_GPAYME_ENROLLMENT("vault:gpayme-enrollment"),
VAULT_INSTRUMENTS("vault:instruments"),
VAULT_TOKENIZATION("vault:tokenization"),
VAULT_NETWORK_TOKENS("vault:network-tokens"),
FORWARD("forward"),
FORWARD_SECRETS("forward:secrets"),
IDENTITY_VERIFICATION("identity-verification");
VAULT_REAL_TIME_ACCOUNT_UPDATER("vault:real-time-account-updater"),
VAULT_TOKENIZATION("vault:tokenization"),
VAULT_TOKENS_METADATA("vault:tokens-metadata");

private final String scope;

OAuthScope(final String gateway) {
this.scope = gateway;
OAuthScope(final String scope) {
this.scope = scope;
}

public String getScope() {
Expand Down
116 changes: 116 additions & 0 deletions src/test/java/com/checkout/OAuthScopeTest.java
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
package com.checkout;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
import org.junit.jupiter.api.Test;

class OAuthScopeTest {
Expand All @@ -23,4 +30,113 @@ void shouldExposeDocumentedBalancesScopeValues() {
assertEquals("balances:top-up-instructions", OAuthScope.BALANCES_TOP_UP_INSTRUCTIONS.getScope());
}

/**
* The scopes added when this enum was synced against the spec.
*
* <p>The last five are not declared in clientCredentials.scopes at all: they appear only in the
* per-operation security requirements of GET/POST /compliance-requests/{payment_id}, the
* /googlepay/enrollments operations and GET /tokens/{tokenId}/metadata. A client built from the
* declared map alone would be missing them.</p>
*/
@Test
void shouldExposeDocumentedValuesForScopesAddedInSpecSync() {
assertEquals("agentic:inventory", OAuthScope.AGENTIC_INVENTORY.getScope());
assertEquals("disputes:scheme-files", OAuthScope.DISPUTES_SCHEME_FILES.getScope());
assertEquals("flow:reflow", OAuthScope.FLOW_REFLOW.getScope());
assertEquals("issuing-disputes", OAuthScope.ISSUING_DISPUTES.getScope());
assertEquals("issuing:disputes-read", OAuthScope.ISSUING_DISPUTES_READ.getScope());
assertEquals("issuing:disputes-write", OAuthScope.ISSUING_DISPUTES_WRITE.getScope());
assertEquals("vault:customers", OAuthScope.VAULT_CUSTOMERS.getScope());
assertEquals("vault:real-time-account-updater", OAuthScope.VAULT_REAL_TIME_ACCOUNT_UPDATER.getScope());
assertEquals("compliance-requests", OAuthScope.COMPLIANCE_REQUESTS.getScope());
assertEquals("compliance-requests:read", OAuthScope.COMPLIANCE_REQUESTS_READ.getScope());
assertEquals("compliance-requests:respond", OAuthScope.COMPLIANCE_REQUESTS_RESPOND.getScope());
assertEquals("vault:gpayme-enrollment", OAuthScope.VAULT_GPAYME_ENROLLMENT.getScope());
assertEquals("vault:tokens-metadata", OAuthScope.VAULT_TOKENS_METADATA.getScope());
}

/**
* These five scopes appear nowhere in the specification -- neither in the clientCredentials
* scope map nor in any operation's security requirement -- so a sweep driven by the spec alone
* would delete them. They are kept deliberately: the authorization server still grants them and
* callers still request them. {@code marketplace} is the proof: the sandbox payouts client is
* provisioned for it and answers a request for {@code accounts} with {@code invalid_scope}.
*
* <p>This test exists to stop the next specification-driven tidy-up from removing them.</p>
*/
@Test
void shouldRetainTheLegacyScopesTheSpecificationOmits() {
assertEquals("issuing:card-mgmt", OAuthScope.ISSUING_CARD_MGMT.getScope());
assertEquals("issuing:client", OAuthScope.ISSUING_CLIENT.getScope());
assertEquals("marketplace", OAuthScope.MARKETPLACE.getScope());
assertEquals("middleware:gateway", OAuthScope.MIDDLEWARE_GATEWAY.getScope());
assertEquals("middleware:payment-context", OAuthScope.MIDDLEWARE_PAYMENT_CONTEXT.getScope());
}

/**
* PAYMENT_CONTEXT and GATEWAY_PAYMENT_CONTEXTS are one letter apart as names but are unrelated
* scopes, so this pins which is which. GATEWAY_PAYMENT_CONTEXTS was called PAYMENT_CONTEXTS
* until the spec sync; had the new constant been added without that rename, a caller reaching
* for one and landing on the other would be rejected at the token endpoint.
*
* <p>"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 GET /payment-contexts/{id} documents.</p>
*/
@Test
void shouldDistinguishTheTwoPaymentContextScopes() {
assertEquals("Payment Context", OAuthScope.PAYMENT_CONTEXT.getScope());
assertEquals("gateway:payment-contexts", OAuthScope.GATEWAY_PAYMENT_CONTEXTS.getScope());
}

/**
* A blank wire value cannot be caught by the per-scope assertions above, which only read the
* constants they name. It would be sent as an empty entry in the space-joined scope parameter,
* which the token endpoint rejects for the whole request -- costing the caller every other
* scope it asked for.
*/
@Test
void shouldExposeANonBlankWireValueForEveryConstant() {
for (final OAuthScope scope : OAuthScope.values()) {
assertFalse(scope.getScope() == null || scope.getScope().trim().isEmpty(),
scope.name() + " has a blank wire value");
}
}

/**
* Two constants sharing a wire value means one of them is a copy-paste error, and it cannot be
* caught by the per-scope assertions above, which only ever read the constant they name. The
* consequence is silent in both directions: a caller selecting the mistyped constant requests a
* scope it did not ask for, and the scope that constant was supposed to carry is left with no
* constant at all, so it becomes unreachable through this enum.
*/
@Test
void shouldNotReuseAWireValueAcrossConstants() {
final List<String> duplicates = Arrays.stream(OAuthScope.values())
.collect(Collectors.groupingBy(OAuthScope::getScope, Collectors.counting()))
.entrySet().stream()
.filter(entry -> entry.getValue() > 1)
.map(Map.Entry::getKey)
.collect(Collectors.toList());

assertTrue(duplicates.isEmpty(), "wire values used by more than one constant: " + duplicates);
}

/**
* Constants are maintained in alphabetical order so that the next spec sync produces a readable
* diff instead of scattering additions through the file, and so the ordering matches the other
* Checkout SDKs. Underscores are ignored when comparing, which is what makes PAYMENT_CONTEXT,
* PAYMENT_SESSIONS and PAYMENTS_SEARCH sort in that order
*/
@Test
void shouldDeclareConstantsInAlphabeticalOrder() {
final Function<OAuthScope, String> sortKey =
scope -> scope.name().replace("_", "").toLowerCase();
final List<String> declared = Arrays.stream(OAuthScope.values())
.map(sortKey)
.collect(Collectors.toList());

assertEquals(declared.stream().sorted().collect(Collectors.toList()), declared);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,9 @@ private CheckoutApi getPayoutSchedulesCheckoutApi() {
.clientCredentials(
requireNonNull(System.getenv("CHECKOUT_DEFAULT_OAUTH_PAYOUT_SCHEDULE_CLIENT_ID")),
requireNonNull(System.getenv("CHECKOUT_DEFAULT_OAUTH_PAYOUT_SCHEDULE_CLIENT_SECRET")))
.scopes(OAuthScope.MARKETPLACE)
// The marketplace scope was retired; both payout-schedules operations document
// accounts as their OAuth requirement.
.scopes(OAuthScope.ACCOUNTS)
.environment(Environment.SANDBOX)
// 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.
Expand Down
6 changes: 5 additions & 1 deletion src/test/java/com/checkout/issuing/BaseIssuingTestIT.java
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,11 @@ private CheckoutApi getIssuingCheckoutApi() {
.clientCredentials(
requireNonNull(System.getenv("CHECKOUT_DEFAULT_OAUTH_ISSUING_CLIENT_ID")),
requireNonNull(System.getenv("CHECKOUT_DEFAULT_OAUTH_ISSUING_CLIENT_SECRET")))
.scopes(OAuthScope.VAULT, OAuthScope.ISSUING_CLIENT, OAuthScope.ISSUING_CARD_MGMT,
// 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 below
// replaces the former; the latter has no documented equivalent.
.scopes(OAuthScope.VAULT, OAuthScope.ISSUING_CARD_MANAGEMENT_READ,
OAuthScope.ISSUING_CARD_MANAGEMENT_WRITE,
OAuthScope.ISSUING_CONTROLS_READ, OAuthScope.ISSUING_CONTROLS_WRITE,
OAuthScope.ISSUING_TRANSACTIONS_READ, OAuthScope.ISSUING_TRANSACTIONS_WRITE)
.environment(Environment.SANDBOX)
Expand Down
Loading