Thanks for looking into this. We traced the full path on our side, from where the merchant first grants permissions through to how we refresh the token, and here is everything relevant.
1. Where scopes are actually requested: our merchant web app builds the Square authorize URL.
This is the only place in our entire codebase where a scope value is set for Square OAuth. The link the merchant clicks is rendered here:
<a class="button-gray" style="margin-top: 0px; margin-bottom: 20px"
href="${authUrl}/oauth2/authorize?scope=${authScope}&client_id=${clientId}&session=${authSessionEnabled}&state=${uuid}">Connect
with <img src="${cdnBaseUrl}/img/square-logo-white.svg" alt=""> </a>
authScope comes from a controller that picks one of two fixed, hardcoded scope strings based on the app type the merchant is onboarding into:
model.addAttribute("authScope", getAuthScopeBasedOnAppType(appType));
private String getAuthScopeBasedOnAppType(String appType) {
if (appType.contains("kiosk")) {
return messageSource.getMessage("merchant.partner.app.orderahead.kiosk.oauth.scope", null, Locale.ENGLISH);
} else {
return messageSource.getMessage("merchant.partner.app.orderahead.oauth.scope", null, Locale.ENGLISH);
}
}
Our production values for those two scope strings are:
merchant.partner.app.orderahead.oauth.scope = CUSTOMERS_READ,CUSTOMERS_WRITE,ITEMS_READ,ORDERS_WRITE,PAYMENTS_READ,PAYMENTS_WRITE,MERCHANT_PROFILE_READ,ORDERS_READ,DEVICE_CREDENTIAL_MANAGEMENT,PAYMENTS_WRITE_ADDITIONAL_RECIPIENTS,LOYALTY_READ,LOYALTY_WRITE,INVENTORY_READ
merchant.partner.app.orderahead.kiosk.oauth.scope = CUSTOMERS_READ,CUSTOMERS_WRITE,ITEMS_READ,ORDERS_WRITE,PAYMENTS_READ,PAYMENTS_WRITE,MERCHANT_PROFILE_READ,PAYMENTS_WRITE_IN_PERSON,ORDERS_READ,DEVICE_CREDENTIAL_MANAGEMENT,PAYMENTS_WRITE_ADDITIONAL_RECIPIENTS,LOYALTY_READ,LOYALTY_WRITE,INVENTORY_READ
Both include ORDERS_READ. The only difference between them is PAYMENTS_WRITE_IN_PERSON for the kiosk variant. We checked the history of this config and ORDERS_READ was added in 2020 and has not been touched since, so there is no code path on our side, in any app type, that would generate a Square authorize link without ORDERS_READ.
2. This authorize link is only followed when a merchant clicks Connect. It is not something we trigger programmatically. The redirect back from Square, with the resulting code, is handled here:
@RequestMapping(value = "authorize-square", method = RequestMethod.GET)
public String handlePartnerAuthorization(ModelMap model,
HttpServletRequest request,
@RequestParam(value = "code", required = false) String code,
@RequestParam(value = "state", required = false) String state,
@RequestParam(value = "error", required = false) String error) throws BusinessNotFoundException {
...
RegisterOAuthMerchantResponse registerOAuthMerchantResponse = businessApiService.registerSquareBusinessViaOauth(code, UserDetailsService.getUserId(request));
...
}
That call passes the code down to our backend service, which exchanges it for a token with no scopes parameter of its own:
public TokenResponse obtainToken(String code) {
try {
SquareClient client = createClient();
ObtainTokenRequest body = ObtainTokenRequest.builder()
.clientId(clientId)
.grantType("authorization_code")
.clientSecret(clientSecret)
.code(code).build();
ObtainTokenResponse result = client.oAuth().obtainToken(body);
return getTokenResponse(result);
} catch (Exception e) {
LOGGER.error("Exception while obtaining oauth token ", e);
throw ApptizerApiExceptions.INTERNAL_SERVER_ERROR_EXCEPTION;
}
}
3. Token refresh, separately, never touches scopes at all and runs on its own fixed schedule, unrelated to anything above:
@Override
public TokenResponse renewToken(String accessToken) {
try {
String clientId = squareupConfiguration.getClientId();
String clientSecret = squareupConfiguration.getClientSecret();
SquareClient client = createClient();
ObtainTokenRequest requestBody = ObtainTokenRequest.builder()
.clientId(clientId)
.grantType("refresh_token")
.clientSecret(clientSecret)
.refreshToken(accessToken)
.build();
ObtainTokenResponse result = client.oAuth().obtainToken(requestBody);
return getTokenResponse(result);
} catch (SquareApiException e) {
LOGGER.error("SquareApiException while renewing Token [{}] ", toJson(mapToSquareError(e)), e);
throw ApptizerApiExceptions.INTERNAL_SERVER_ERROR_EXCEPTION;
} catch (Exception e) {
LOGGER.error("Exception while renewing oauth token", e);
throw ApptizerApiExceptions.INTERNAL_SERVER_ERROR_EXCEPTION;
}
}
public boolean handleTokenRenewalForBusinessUser(BusinessUserData businessUserData, boolean forceSync) {
...
TokenResponse tokenRefreshResponse = squareupApiClient.renewToken(squareRefreshKey);
updateMerchantBusinessesWithRenewedTokens(merchantBusinessList, tokenRefreshResponse);
...
}
So, end to end, the JSP link above is the only place in our system where a scope value is ever set for this integration. Both of our possible scope values include ORDERS_READ, they have not changed in years, and refresh never sends a scope value at all. Given that, we don’t see a code path on our end that would produce the reduced scope set you found for this merchant on May 26. Would you be able to share what scope value Square actually received on that request, so we can compare it against the two values above and confirm whether it originated from our authorize link or from a manual change on Square’s side?