724 lines
42 KiB
OpenEdge ABL
724 lines
42 KiB
OpenEdge ABL
/**
|
|
* ─────────────────────────────────────────────────────────────────────────────────────────────────┐
|
|
* This class is handle for all Shopify Webhook response
|
|
* Authenticate webhooks classes
|
|
* Shopify API Documentation: https://shopify.dev/docs/admin-api/rest/reference
|
|
* Create Shopify Store: https://www.shopify.com/signup?itcat=identity&itterm=create-new-store
|
|
* Shopify API Release 2020: https://www.shopify.in/partners/blog/shopify-api-release-january-2020
|
|
* What is Person Account in Salesforce ? https://help.salesforce.com/articleView?id=account_person.htm&type=5
|
|
* ──────────────────────────────────────────────────────────────────────────────────────────────────
|
|
* @author Sumit <sumit@hicShopify.com>
|
|
* ──────────────────────────────────────────────────────────────────────────────────────────────────
|
|
*/
|
|
@RestResource(urlMapping='/AuthenticateAllApexClass/*')
|
|
global class AuthenticateAllApexClass {
|
|
|
|
@HttpPost
|
|
global static string AuthenticateClass(){
|
|
RestRequest request = RestContext.request;
|
|
RestResponse response = RestContext.response;
|
|
Map <String, String> params = request.params;
|
|
String GetPath = request.resourcePath;
|
|
String JSONResponse;
|
|
String ObjectName;
|
|
String DMLType;
|
|
String store = 'Default';
|
|
String isServer = 'false';
|
|
JSONResponse = request.requestBody.toString().trim();
|
|
|
|
if(!params.isEmpty() && params.containskey('objectname'))
|
|
ObjectName = params.get('objectname');
|
|
|
|
if(!params.isEmpty() && params.containskey('DMLType'))
|
|
DMLType = params.get('DMLType');
|
|
|
|
if(!params.isEmpty() && params.containskey('store'))
|
|
store = params.get('store');
|
|
if(!params.isEmpty() && params.containskey('isProfessional'))
|
|
isServer = params.get('isProfessional');
|
|
system.debug('@@@ params : '+params);
|
|
system.debug('@@@ JSONResponse : '+JSONResponse);
|
|
/*if(ObjectName == 'UpdateRefreshToken'){
|
|
if( DMLType == 'Update' ){
|
|
updatetoken(JSONResponse );
|
|
}
|
|
}*//*else if(ObjectName == 'UpdateAccessToken'){
|
|
//updateAccesstoken(JSONResponse);
|
|
SfUtility.postToSF(JSONResponse , 'UpdateAdminAccessToken','');
|
|
}
|
|
else if(ObjectName == 'Customer'){
|
|
if(DMLType == 'Create' || DMLType == 'Update' ){
|
|
SfUtility.postToSF(JSONResponse , 'UpdateCustomerFromShopify',store);
|
|
}else if(DMLType == 'Delete'){
|
|
SfUtility.postToSF(JSONResponse , 'DeleteCustomerFromShopify',store);
|
|
}
|
|
}else if(ObjectName == 'Product2'){
|
|
if(DMLType == 'Create' || DMLType == 'Update' ){
|
|
SfUtility.postToSF(JSONResponse , 'UpdateProductAndVarientFromShopify',store);
|
|
}else if(DMLType == 'Delete'){
|
|
SfUtility.postToSF(JSONResponse , 'DeleteProductAndVariantfromShopify',store);
|
|
}
|
|
}else if(ObjectName == 'Order'){
|
|
if(DMLType == 'Create' || DMLType == 'Update' ){
|
|
SfUtility.postToSF(JSONResponse , 'UpdateOrderFromShopify',store);
|
|
}else if(DMLType == 'Delete'){
|
|
SfUtility.postToSF(JSONResponse , 'DeleteOrderFromShopify',store);
|
|
}
|
|
}else if(ObjectName == 'HIC_ShopifySync__Collection__c'){
|
|
if(DMLType == 'Create' || DMLType == 'Update' ){
|
|
SfUtility.postToSF(JSONResponse , 'UpdateCollectionAndCollectsFromShopify',store);
|
|
}else if(DMLType == 'Delete'){
|
|
SfUtility.postToSF(JSONResponse , 'DeleteCollectionFromShopify',store);
|
|
}
|
|
}else if(ObjectName == 'HIC_ShopifySync__Inventory_Level__c'){
|
|
if(DMLType == 'Connect' || DMLType == 'Update' ){
|
|
SfUtility.postToSF(JSONResponse , 'UpdateInventoryLevelsFromShopify',store);
|
|
}else if(DMLType == 'Disconnect'){
|
|
SfUtility.postToSF(JSONResponse , 'DisconnectInventoryFormShopify',store);
|
|
}
|
|
}else if(ObjectName == 'DraftOrder'){
|
|
if(DMLType == 'Create' || DMLType == 'Update' ){
|
|
Map<String, Object> meta = (Map<String, Object>) JSON.deserializeUntyped(JSONResponseBody);
|
|
if(!meta.isEmpty() && meta.containsKey('status') && meta.get('status') == 'completed')
|
|
SfUtility.postToSF(JSONResponse , 'DeleteOrderFromShopify', store);
|
|
else
|
|
SfUtility.postToSF(JSONResponse , 'UpdateDraftOrderFromShopify',store);
|
|
}else if(DMLType == 'Delete'){
|
|
SfUtility.postToSF(JSONResponse , 'DeleteOrderFromShopify', store);
|
|
}
|
|
}else if(ObjectName == 'HIC_ShopifySync__Location__c'){
|
|
if(DMLType == 'Create' || DMLType == 'Update' ){
|
|
SfUtility.postToSF(JSONResponse , 'UpdateLocationFromShopify',store);
|
|
}else if(DMLType == 'Delete'){
|
|
SfUtility.postToSF(JSONResponse , 'DeleteLocationFromShopify',store);
|
|
}
|
|
}else{
|
|
SfUtility.postToSF(JSONResponse , 'CreateShopifyLog', JSON.serialize(params));
|
|
}*/
|
|
Map<String,HIC_ShopifySync__WebhookURlMapping__c> shObjectNameVsMappingMap = new Map<String,HIC_ShopifySync__WebhookURlMapping__c>();
|
|
//shObjectNameVsMappingMap = new Map<String,HIC_ShopifySync__WebhookURlMapping__c>();
|
|
for(HIC_ShopifySync__WebhookURlMapping__c webMap : [SELECT Name, HIC_ShopifySync__DeleteURL__c, HIC_ShopifySync__UpsertURL__c From HIC_ShopifySync__WebhookURlMapping__c Where HIC_ShopifySync__Activated__c = true Limit 9999]){
|
|
shObjectNameVsMappingMap.put(webMap.Name, webMap);
|
|
}
|
|
system.debug('@@@ store : '+store);
|
|
system.debug('@@@ shObjectNameVsMappingMap :'+shObjectNameVsMappingMap);
|
|
//System.debug('shObjectNameVsMappingMap--'+shObjectNameVsMappingMap);
|
|
if(ObjectName == 'UpdateRefreshToken'){
|
|
if( DMLType == 'Update' ){
|
|
// The rotated-token write-back is now accepted from EVERY caller. It used to also require
|
|
// isServer=='false' (i.e. the ?isProfessional= query param absent or false). Any caller
|
|
// that sent isProfessional=true had its token write SILENTLY SKIPPED while still receiving
|
|
// HTTP 200 — the org then kept a superseded refresh token and the connection died days
|
|
// later. A safety-critical token write must never be gated on a routing hint.
|
|
Boolean stored = persistRotatedToken(JSONResponse);
|
|
if(!stored){
|
|
// LOUD FAILURE. Under mandatory rotation the caller has ALREADY consumed the previous
|
|
// refresh token, so answering 200 here is what silently desynchronises the org.
|
|
// A 500 tells the caller to retry while its new token is still the live one.
|
|
setResponseStatus(500);
|
|
return '{"error":"token_not_stored","error_description":"rotated refresh token could not be persisted"}';
|
|
}
|
|
setResponseStatus(200);
|
|
return '{"status":"ok"}';
|
|
}
|
|
} else if (ObjectName == 'RefreshToken') {
|
|
// Subscriber orgs route their grant_type=refresh_token call through here
|
|
// so the callout originates from this site's IND56 IP, satisfying the
|
|
// "Enforce Refresh Token IP Allowlist" restriction on the Connected App.
|
|
return proxyRefreshToken(JSONResponse);
|
|
} else if(!shObjectNameVsMappingMap.isEmpty() && shObjectNameVsMappingMap.containsKey(ObjectName)){
|
|
if(DMLType == 'Create' || DMLType == 'Update' || ObjectName == 'UpdateAccessToken'){
|
|
/* if(ObjectName == 'DraftOrder'){
|
|
Map<String, Object> meta = (Map<String, Object>) JSON.deserializeUntyped(JSONResponse);
|
|
if(!meta.isEmpty() && meta.containsKey('status') && meta.get('status') == 'completed')
|
|
SfUtility.postToSF(JSONResponse , shObjectNameVsMappingMap.get(ObjectName).HIC_ShopifySync__DeleteURL__c, store);
|
|
else
|
|
SfUtility.postToSF(JSONResponse , shObjectNameVsMappingMap.get(ObjectName).HIC_ShopifySync__UpsertURL__c, store);
|
|
}else*/
|
|
system.debug('@@@ shObjectNameVsMappingMap inside if:'+shObjectNameVsMappingMap);
|
|
|
|
SfUtility.postToSF(JSONResponse , shObjectNameVsMappingMap.get(ObjectName).HIC_ShopifySync__UpsertURL__c, store, ObjectName);
|
|
}else if(DMLType == 'Delete'){
|
|
SfUtility.postToSF(JSONResponse , shObjectNameVsMappingMap.get(ObjectName).HIC_ShopifySync__DeleteURL__c, store,ObjectName);
|
|
}
|
|
}else{
|
|
SfUtility.postToSF(JSONResponse , 'HIC_ShopifySync/CreateShopifyLog', JSON.serialize(params),ObjectName);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Preserved global signature (managed-package API cannot change). Delegates to persistRotatedToken.
|
|
global static void updatetoken(String JSONResponseBody ){
|
|
persistRotatedToken(JSONResponseBody);
|
|
}
|
|
|
|
/**
|
|
* Persists a refresh token (and optional access token) pushed in by the packaging org's authenticator
|
|
* during initialize — AuthenticateInternally posts here after exchanging the ?code. That is the ONLY
|
|
* legitimate external caller: this package is 100% Salesforce-native, with no middleware or external
|
|
* server, and TokenService is the only code that rotates.
|
|
*
|
|
* This is the most safety-critical write in the package: under mandatory Refresh Token
|
|
* Rotation the caller has ALREADY consumed the previous refresh token by the time it calls us, so if
|
|
* this write does not land the org is left holding a superseded token and every later refresh fails
|
|
* with invalid_grant — days later, long after the actual fault.
|
|
*
|
|
* Two silent-failure modes were removed here:
|
|
* 1. The record lookup built its field list through CheckAccessibility.isAccessible(), which returns
|
|
* an EMPTY STRING when the running user cannot see the object (CheckAccessibility.cls:94-113).
|
|
* This endpoint is reachable through the public Site as the Guest User, so the query collapsed to
|
|
* "Select from HIC_ShopifySync__Configuration__c where = 'Default'" and threw QueryException —
|
|
* and that query was NOT inside the try/catch, so the whole callout 500'd or the write vanished.
|
|
* Plain SOQL is used now: an integration write-back must not depend on the caller's FLS.
|
|
* 2. The update sat in a catch that only called System.debug, so a failed write still reported
|
|
* success to the caller.
|
|
*
|
|
* @return true only when the token has been durably committed.
|
|
*/
|
|
public static Boolean persistRotatedToken(String JSONResponseBody){
|
|
String refreshToken;
|
|
String accessToken;
|
|
String expectedPreviousRefreshToken;
|
|
try {
|
|
Map<String, Object> meta = (Map<String, Object>) JSON.deserializeUntyped(JSONResponseBody);
|
|
if (meta == null || meta.isEmpty()) {
|
|
System.debug(LoggingLevel.ERROR, 'SME_TOKEN persistRotatedToken: empty body');
|
|
return false;
|
|
}
|
|
refreshToken = blankToNull(meta.get('HIC_ShopifySync__Refresh_Token__c'));
|
|
accessToken = blankToNull(meta.get('HIC_ShopifySync__Access_Token__c'));
|
|
// Optional compare-and-swap guard (see below). Accepted under either spelling.
|
|
expectedPreviousRefreshToken = blankToNull(meta.get('previous_refresh_token'));
|
|
if (expectedPreviousRefreshToken == null) {
|
|
expectedPreviousRefreshToken = blankToNull(meta.get('HIC_ShopifySync__Previous_Refresh_Token'));
|
|
}
|
|
} catch (Exception parseEx) {
|
|
System.debug(LoggingLevel.ERROR, 'SME_TOKEN persistRotatedToken: unparseable body — '
|
|
+ parseEx.getMessage());
|
|
return false;
|
|
}
|
|
|
|
if (String.isBlank(refreshToken) && String.isBlank(accessToken)) {
|
|
System.debug(LoggingLevel.ERROR, 'SME_TOKEN persistRotatedToken: body carried no token');
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
// Plain SOQL, and FOR UPDATE so this write serialises against TokenService's own refresh.
|
|
List<HIC_ShopifySync__Configuration__c> cfg = [
|
|
SELECT Id, HIC_ShopifySync__Refresh_Token__c, HIC_ShopifySync__Access_Token__c
|
|
FROM HIC_ShopifySync__Configuration__c
|
|
WHERE Name = 'Default'
|
|
LIMIT 1
|
|
FOR UPDATE
|
|
];
|
|
if (cfg.isEmpty()) {
|
|
System.debug(LoggingLevel.ERROR,
|
|
'SME_TOKEN persistRotatedToken: no Default Configuration row exists to write into');
|
|
return false;
|
|
}
|
|
|
|
// ── COMPARE-AND-SWAP ────────────────────────────────────────────────────────────────
|
|
// This write is last-write-wins by nature, which is unsafe for a value whose entire meaning
|
|
// is "supersedes exactly one predecessor". Because a failed write now answers HTTP 500, the
|
|
// caller RETRIES — and if TokenService rotated in between, a retry carrying the old token
|
|
// would reinstall a dead token over the live one. So when the caller tells us which token it
|
|
// believes it is replacing, we verify that belief and reject a stale body with 409 instead.
|
|
//
|
|
// Optional by design: a caller that omits previous_refresh_token keeps the old
|
|
// last-write-wins behaviour, so the initialize hand-off (which legitimately installs a token
|
|
// over whatever was there) is unaffected.
|
|
String stored = cfg[0].HIC_ShopifySync__Refresh_Token__c;
|
|
if (String.isNotBlank(expectedPreviousRefreshToken)
|
|
&& String.isNotBlank(stored)
|
|
&& stored != expectedPreviousRefreshToken) {
|
|
System.debug(LoggingLevel.ERROR, 'SME_TOKEN persistRotatedToken: STALE write rejected — '
|
|
+ 'caller expected to replace a token that is no longer current (a newer rotation won)');
|
|
setResponseStatus(409);
|
|
return false;
|
|
}
|
|
// Idempotent replay of the same value: nothing to do, and reporting success is correct.
|
|
if (String.isNotBlank(refreshToken) && refreshToken == stored
|
|
&& (String.isBlank(accessToken) || accessToken == cfg[0].HIC_ShopifySync__Access_Token__c)) {
|
|
System.debug(LoggingLevel.INFO, 'SME_TOKEN persistRotatedToken: replay of current token, no-op');
|
|
return true;
|
|
}
|
|
|
|
if (String.isNotBlank(refreshToken)) cfg[0].HIC_ShopifySync__Refresh_Token__c = refreshToken;
|
|
if (String.isNotBlank(accessToken)) cfg[0].HIC_ShopifySync__Access_Token__c = accessToken;
|
|
update cfg;
|
|
|
|
System.debug(LoggingLevel.INFO, 'SME_TOKEN persistRotatedToken: stored'
|
|
+ ' refresh=' + String.isNotBlank(refreshToken)
|
|
+ ' access=' + String.isNotBlank(accessToken));
|
|
return true;
|
|
|
|
} catch (Exception e) {
|
|
System.debug(LoggingLevel.ERROR, 'SME_TOKEN persistRotatedToken FAILED — '
|
|
+ e.getTypeName() + ' | ' + e.getMessage() + ' | line ' + e.getLineNumber());
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Normalises an inbound token value, and REJECTS poison values.
|
|
*
|
|
* String.valueOf(null) yields the four-character string 'null', and Apex/JS callers that build a body
|
|
* by concatenation (e.g. AuthenticateInternally) can post exactly that. A stored 'null' passes every
|
|
* blank check in the package: the keep-alive's String.isBlank() accepts it, and
|
|
* ShopifyController.refreshTokenCheck hands it back as if it were a live access token — so the org
|
|
* looks connected while every refresh fails. Treat these as absent.
|
|
*/
|
|
private static String blankToNull(Object o) {
|
|
if (o == null) return null;
|
|
String s = String.valueOf(o).trim();
|
|
if (String.isBlank(s)) return null;
|
|
if (s.equalsIgnoreCase('null') || s.equalsIgnoreCase('undefined')) return null;
|
|
return s;
|
|
}
|
|
|
|
// RestContext.response is absent in some contexts (e.g. direct Apex invocation from tests).
|
|
private static void setResponseStatus(Integer code) {
|
|
try {
|
|
if (RestContext.response != null) {
|
|
RestContext.response.statusCode = code;
|
|
}
|
|
} catch (Exception e) {
|
|
System.debug('setResponseStatus skipped: ' + e.getMessage());
|
|
}
|
|
}
|
|
|
|
global static void updateAccesstoken(String JSONResponseBody ){
|
|
System.debug('JSONResponseBody---'+JSONResponseBody);
|
|
Map<String, Object> meta = (Map<String, Object>) JSON.deserializeUntyped(JSONResponseBody);
|
|
if(!meta.isEmpty() && (meta.containskey('access_token') && meta.get('access_token') != null && meta.get('access_token') != '') &&
|
|
(meta.containskey('store_url') && meta.get('store_url') != null && meta.get('store_url') != '')){
|
|
String access_token = String.valueof(meta.get('access_token'));
|
|
System.debug('access_token--'+access_token);
|
|
String StoreURL = String.valueof(meta.get('store_url'));
|
|
String store = StoreURL.replace('https://','').replace('.myshopify.com','').remove('/').remove('"');
|
|
if(access_token != null && access_token != ''){
|
|
list<HIC_ShopifySync__HIC_Configuration__c> listofConfiguration = new list<HIC_ShopifySync__HIC_Configuration__c>();
|
|
|
|
/*String query = 'Select '+CheckAccessibility.isAccessible('id,name,HIC_ShopifySync__Batch_Is_Processing__c,HIC_ShopifySync__Specify_Close_Date__c, HIC_ShopifySync__Account_Contact__c, HIC_ShopifySync__Person_Account__c, HIC_ShopifySync__Default_Account__c, HIC_ShopifySync__Shopify_URL__c, HIC_ShopifySync__API_Key__c, HIC_ShopifySync__Private_Key_Password__c, HIC_ShopifySync__Opportunity__c, HIC_ShopifySync__Order__c, HIC_ShopifySync__Refresh_Token__c,HIC_ShopifySync__Opportunity_Line_Item__c, HIC_ShopifySync__Order_Line_Item__c, HIC_ShopifySync__Order_Fulfillment__c','HIC_ShopifySync__HIC_Configuration__c')+' from HIC_ShopifySync__HIC_Configuration__c where '+CheckAccessibility.isAccessible('name','HIC_ShopifySync__HIC_Configuration__c')+' = \''+store+'\' LIMIT 1';
|
|
System.debug('query--'+query);
|
|
listofConfiguration = database.query(query);
|
|
system.debug('***listofConfiguration ' +listofConfiguration);
|
|
if(listofConfiguration.isEmpty()) {*/
|
|
HIC_ShopifySync__HIC_Configuration__c config = new HIC_ShopifySync__HIC_Configuration__c();
|
|
config.Name = store;
|
|
config.HIC_ShopifySync__Store__c = store;
|
|
config.HIC_ShopifySync__Shopify_URL__c = 'https://'+store+'.myshopify.com';
|
|
config.HIC_ShopifySync__Private_Key_Password__c = access_token;
|
|
listofConfiguration.add(config);
|
|
/*}else{
|
|
|
|
listofConfiguration[0].HIC_ShopifySync__Private_Key_Password__c = access_token;
|
|
}*/
|
|
if(listofConfiguration != null && !listofConfiguration.isEmpty()){
|
|
try{
|
|
upsert listofConfiguration name;
|
|
}catch(Exception e){
|
|
|
|
System.debug('Error '+ e.getMessage());
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// Shopify OAuth callback (@HttpGet) — merged in from ShopifyOAuthCallbackRest
|
|
// so a Salesforce Site only needs guest Apex access to ONE class
|
|
// (AuthenticateAllApexClass) instead of three. Shopify redirects the merchant's
|
|
// browser here (GET) after they approve the Custom App; we validate the HMAC,
|
|
// exchange the code for an offline Admin API token, and save it to the store's
|
|
// HIC_Configuration record. The LWC polls getStoreAccessToken and fills the
|
|
// field the moment the token lands.
|
|
//
|
|
// Redirect URI: https://<site>/services/apexrest/HIC_ShopifySync/AuthenticateAllApexClass
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@HttpGet
|
|
global static void doGet() {
|
|
RestRequest req = RestContext.request;
|
|
RestResponse res = RestContext.response;
|
|
res.addHeader('Content-Type', 'text/html; charset=UTF-8');
|
|
|
|
String code = req.params.get('code');
|
|
String shop = req.params.get('shop');
|
|
String state = req.params.get('state');
|
|
String hmac = req.params.get('hmac');
|
|
|
|
System.debug(LoggingLevel.INFO, 'SME_OAUTH_DBG ===== OAuthCallback START =====');
|
|
System.debug(LoggingLevel.INFO, 'SME_OAUTH_DBG Context -> UserName=' + UserInfo.getUserName()
|
|
+ ' | UserId=' + UserInfo.getUserId()
|
|
+ ' | UserType=' + UserInfo.getUserType()
|
|
+ ' | ProfileId=' + UserInfo.getProfileId());
|
|
System.debug(LoggingLevel.INFO, 'SME_OAUTH_DBG Params -> shop=' + shop
|
|
+ ' | codePresent=' + String.isNotBlank(code)
|
|
+ ' | statePresent=' + String.isNotBlank(state)
|
|
+ ' | hmacPresent=' + String.isNotBlank(hmac));
|
|
|
|
// --- Validate required params ----------------------------------------
|
|
if (String.isBlank(code) || String.isBlank(shop) || String.isBlank(state)) {
|
|
res.statusCode = 400;
|
|
res.responseBody = Blob.valueOf(buildOAuthPage(false,
|
|
'Missing Parameters',
|
|
'The authorization request is missing required parameters.',
|
|
null));
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// --- Decode state -------------------------------------------------
|
|
String padded = state.replace('-', '+').replace('_', '/');
|
|
Integer rem = Math.mod(padded.length(), 4);
|
|
if (rem == 2) padded += '==';
|
|
else if (rem == 3) padded += '=';
|
|
|
|
Map<String, Object> payload;
|
|
try {
|
|
payload = (Map<String, Object>) JSON.deserializeUntyped(
|
|
EncodingUtil.base64Decode(padded).toString());
|
|
} catch (Exception ex) {
|
|
res.statusCode = 400;
|
|
res.responseBody = Blob.valueOf(buildOAuthPage(false,
|
|
'Invalid Request',
|
|
'The state parameter could not be decoded.',
|
|
null));
|
|
return;
|
|
}
|
|
|
|
String storeName = (String) payload.get('store');
|
|
String clientId = (String) payload.get('clientId');
|
|
String clientSecret = (String) payload.get('clientSecret');
|
|
|
|
System.debug(LoggingLevel.INFO, 'SME_OAUTH_DBG State decoded -> store=' + storeName
|
|
+ ' | clientIdPresent=' + String.isNotBlank(clientId)
|
|
+ ' | clientSecretPresent=' + String.isNotBlank(clientSecret));
|
|
|
|
if (String.isBlank(storeName) || String.isBlank(clientId) || String.isBlank(clientSecret)) {
|
|
res.statusCode = 400;
|
|
res.responseBody = Blob.valueOf(buildOAuthPage(false,
|
|
'Invalid Request',
|
|
'The authorization state is incomplete.',
|
|
null));
|
|
return;
|
|
}
|
|
|
|
// --- Validate HMAC ------------------------------------------------
|
|
if (!String.isBlank(hmac) && !validateOAuthHmac(req.params, clientSecret, hmac)) {
|
|
res.statusCode = 400;
|
|
res.responseBody = Blob.valueOf(buildOAuthPage(false,
|
|
'Security Check Failed',
|
|
'The request signature could not be verified.',
|
|
null));
|
|
return;
|
|
}
|
|
|
|
// --- Ensure Remote Site Setting exists for this shop --------------
|
|
System.debug(LoggingLevel.INFO, 'SME_OAUTH_DBG HMAC ok/skipped. Ensuring Remote Site for https://' + shop);
|
|
ensureOAuthRemoteSite('https://' + shop);
|
|
|
|
// --- Exchange code for offline access token -----------------------
|
|
System.debug(LoggingLevel.INFO, 'SME_OAUTH_DBG About to call token endpoint: https://' + shop + '/admin/oauth/access_token');
|
|
HttpRequest tokenReq = new HttpRequest();
|
|
tokenReq.setEndpoint('https://' + shop + '/admin/oauth/access_token');
|
|
tokenReq.setMethod('POST');
|
|
tokenReq.setHeader('Content-Type', 'application/json');
|
|
tokenReq.setHeader('Accept', 'application/json');
|
|
tokenReq.setBody(JSON.serialize(new Map<String, String>{
|
|
'client_id' => clientId,
|
|
'client_secret' => clientSecret,
|
|
'code' => code
|
|
}));
|
|
tokenReq.setTimeout(30000);
|
|
|
|
HttpResponse tokenRes = new Http().send(tokenReq);
|
|
|
|
System.debug(LoggingLevel.INFO, 'SME_OAUTH_DBG Token exchange HTTP status=' + tokenRes.getStatusCode());
|
|
if (tokenRes.getStatusCode() != 200) {
|
|
System.debug(LoggingLevel.ERROR, 'SME_OAUTH_DBG Token exchange FAILED body=' + tokenRes.getBody());
|
|
} else {
|
|
System.debug(LoggingLevel.INFO, 'SME_OAUTH_DBG Token exchange OK (access_token received; body length='
|
|
+ String.valueOf(tokenRes.getBody()).length() + ')');
|
|
}
|
|
|
|
if (tokenRes.getStatusCode() != 200) {
|
|
res.statusCode = 200;
|
|
res.responseBody = Blob.valueOf(buildOAuthPage(false,
|
|
'Token Exchange Failed',
|
|
'Shopify returned HTTP ' + tokenRes.getStatusCode() + '. Please try again.',
|
|
null));
|
|
return;
|
|
}
|
|
|
|
Map<String, Object> tokenBody =
|
|
(Map<String, Object>) JSON.deserializeUntyped(tokenRes.getBody());
|
|
String accessToken = (String) tokenBody.get('access_token');
|
|
|
|
if (String.isBlank(accessToken)) {
|
|
res.statusCode = 200;
|
|
res.responseBody = Blob.valueOf(buildOAuthPage(false,
|
|
'No Token Received',
|
|
'Shopify did not return an access token. Please try again.',
|
|
null));
|
|
return;
|
|
}
|
|
|
|
// --- NO DML: display the token so the admin copies it into the app ---
|
|
// The Site Guest User never touches HIC_Configuration__c. The admin
|
|
// pastes the token into the Admin API access token field and clicks
|
|
// Check Credential, which runs the existing validation/save path.
|
|
res.statusCode = 200;
|
|
res.responseBody = Blob.valueOf(buildOAuthTokenPage(storeName, accessToken));
|
|
|
|
} catch (Exception e) {
|
|
System.debug(LoggingLevel.ERROR, 'SME_OAUTH_DBG doGet EXCEPTION type=' + e.getTypeName()
|
|
+ ' | message=' + e.getMessage()
|
|
+ ' | line=' + e.getLineNumber());
|
|
System.debug(LoggingLevel.ERROR, 'SME_OAUTH_DBG doGet STACK: ' + e.getStackTraceString());
|
|
res.statusCode = 200;
|
|
res.responseBody = Blob.valueOf(buildOAuthPage(false,
|
|
'Unexpected Error',
|
|
String.valueOf(e.getMessage()).abbreviate(200),
|
|
null));
|
|
}
|
|
}
|
|
|
|
// Builds the success page that DISPLAYS the access token with a Copy button.
|
|
// No DML — the admin pastes the token into the app manually, so the
|
|
// HIC_Configuration__c record is never created/modified by the guest user.
|
|
private static String buildOAuthTokenPage(String storeName, String accessToken) {
|
|
String storeTag = !String.isBlank(storeName)
|
|
? '<div class="badge">' + storeName.escapeHtml4() + '.myshopify.com</div>'
|
|
: '';
|
|
|
|
return '<!DOCTYPE html>'
|
|
+ '<html lang="en"><head><meta charset="UTF-8"/>'
|
|
+ '<meta name="viewport" content="width=device-width,initial-scale=1"/>'
|
|
+ '<title>Shopify OAuth — SyncMadeEasy</title>'
|
|
+ '<style>'
|
|
+ '*{box-sizing:border-box;margin:0;padding:0}'
|
|
+ 'body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;'
|
|
+ 'background:#f4f6f9;display:flex;align-items:center;justify-content:center;min-height:100vh;}'
|
|
+ '.card{background:#fff;border-radius:12px;padding:2.5rem 2rem;text-align:center;'
|
|
+ 'box-shadow:0 4px 24px rgba(0,0,0,.1);max-width:480px;width:90%;}'
|
|
+ 'h2{font-size:1.2rem;font-weight:700;color:#2e7d32;margin-bottom:0.75rem;}'
|
|
+ 'p{font-size:0.875rem;color:#555;line-height:1.5;}'
|
|
+ '.badge{display:inline-block;background:#f0f6e4;border:1px solid #95BE45;'
|
|
+ 'border-radius:20px;padding:4px 14px;font-size:0.8rem;color:#3b3b3b;margin:0.75rem 0;}'
|
|
+ '.hint{font-size:0.78rem;color:#888;margin-top:1rem;}'
|
|
+ '.tok-row{display:flex;gap:8px;margin-top:14px;}'
|
|
+ '.tok-row input{flex:1;padding:10px 12px;border:1px solid #ccc;border-radius:8px;'
|
|
+ 'font-size:14px;font-family:monospace;background:#f8f8f8;}'
|
|
+ '.tok-row button{padding:10px 18px;border:none;border-radius:8px;background:#95BE45;'
|
|
+ 'color:#fff;font-size:14px;font-weight:600;cursor:pointer;}'
|
|
+ '</style></head>'
|
|
+ '<body><div class="card">'
|
|
+ '<h2>Access Token Generated!</h2>'
|
|
+ storeTag
|
|
+ '<p>Copy the token below and paste it into the <strong>Admin API access token</strong> '
|
|
+ 'field in SyncMadeEasy, then click <strong>Check Credential</strong>.</p>'
|
|
+ '<div class="tok-row">'
|
|
+ '<input type="text" id="smeToken" readonly value="' + accessToken.escapeHtml4() + '"/>'
|
|
+ '<button id="smeCopyBtn" type="button">Copy</button>'
|
|
+ '</div>'
|
|
+ '<p class="hint">This token is shown only once. Close this window after copying.</p>'
|
|
+ '<script>'
|
|
+ '(function(){'
|
|
+ 'var btn=document.getElementById(\'smeCopyBtn\');'
|
|
+ 'var input=document.getElementById(\'smeToken\');'
|
|
+ 'btn.addEventListener(\'click\',function(){'
|
|
+ 'input.select();input.setSelectionRange(0,99999);'
|
|
+ 'if(navigator.clipboard&&navigator.clipboard.writeText){'
|
|
+ 'navigator.clipboard.writeText(input.value).then(function(){btn.textContent=\'Copied!\';});'
|
|
+ '}else if(document.execCommand(\'copy\')){btn.textContent=\'Copied!\';}'
|
|
+ 'setTimeout(function(){btn.textContent=\'Copy\';},2000);'
|
|
+ '});'
|
|
+ '})();'
|
|
+ '</script>'
|
|
+ '</div></body></html>';
|
|
}
|
|
|
|
// Ensures a Remote Site Setting exists for the given Shopify store URL so the
|
|
// token-exchange callout is authorised. Reads the Metadata-API session token
|
|
// in SYSTEM MODE (a direct SOQL) rather than ShopifyController.getCryptoKey():
|
|
// getCryptoKey strips fields via CheckAccessibility.isAccessible(), and the
|
|
// Site Guest User has no FLS on HIC_ShopifySync__Configuration__c — so all
|
|
// fields are dropped, the query collapses to "select from ... where = 'Default'"
|
|
// and throws System.QueryException, which is why RSS creation silently failed
|
|
// in subscriber orgs.
|
|
private static void ensureOAuthRemoteSite(String shopUrl) {
|
|
System.debug(LoggingLevel.INFO, 'SME_OAUTH_DBG ensureRemoteSite START url=' + shopUrl
|
|
+ ' | UserType=' + UserInfo.getUserType()
|
|
+ ' | UserName=' + UserInfo.getUserName());
|
|
try {
|
|
List<HIC_ShopifySync__Configuration__c> cfg = [
|
|
SELECT HIC_ShopifySync__Access_Token__c
|
|
FROM HIC_ShopifySync__Configuration__c
|
|
WHERE Name = 'Default'
|
|
LIMIT 1
|
|
];
|
|
System.debug(LoggingLevel.INFO, 'SME_OAUTH_DBG system-mode token read -> '
|
|
+ (cfg == null ? 'NULL' : cfg.size() + ' row(s)'));
|
|
Boolean tokenBlank = (cfg == null || cfg.isEmpty()
|
|
|| String.isBlank(cfg[0].HIC_ShopifySync__Access_Token__c));
|
|
System.debug(LoggingLevel.INFO, 'SME_OAUTH_DBG Access_Token__c blank/unreadable? ' + tokenBlank
|
|
+ ' -- if TRUE on subscriber, the RSS will NOT be created here');
|
|
if (cfg == null || cfg.isEmpty()
|
|
|| String.isBlank(cfg[0].HIC_ShopifySync__Access_Token__c)) {
|
|
System.debug(LoggingLevel.ERROR, 'SME_OAUTH_DBG RETURNING EARLY -- no readable Access_Token__c, Remote Site Setting NOT created');
|
|
return;
|
|
}
|
|
String accessToken = cfg[0].HIC_ShopifySync__Access_Token__c;
|
|
System.debug(LoggingLevel.INFO, 'SME_OAUTH_DBG Access_Token__c present, length=' + accessToken.length());
|
|
|
|
Boolean alreadyExists = ShopifyController.CheckingRemotesiteExistence(shopUrl, accessToken);
|
|
System.debug(LoggingLevel.INFO, 'SME_OAUTH_DBG CheckingRemotesiteExistence(' + shopUrl + ') = ' + alreadyExists);
|
|
if (alreadyExists) return;
|
|
|
|
MetadataServiceNew.MetadataPort service = ShopifyController.createService(accessToken);
|
|
MetadataServiceNew.RemoteSiteSetting rss = new MetadataServiceNew.RemoteSiteSetting();
|
|
rss.fullName = 'HICShopifysyncmadeeasy' + DateTime.now().getTime();
|
|
rss.url = shopUrl;
|
|
rss.description = 'HIC Shopify Made Easy';
|
|
rss.isActive = true;
|
|
rss.disableProtocolSecurity = false;
|
|
System.debug(LoggingLevel.INFO, 'SME_OAUTH_DBG Creating RemoteSiteSetting fullName=' + rss.fullName + ' url=' + rss.url);
|
|
if (!Test.isRunningTest()) {
|
|
MetadataServiceNew.AsyncResult[] results =
|
|
service.create(new List<MetadataServiceNew.Metadata>{ rss });
|
|
System.debug(LoggingLevel.INFO, 'SME_OAUTH_DBG MetadataService.create result='
|
|
+ (results != null && !results.isEmpty() ? JSON.serialize(results[0]) : 'NULL/empty'));
|
|
MetadataServiceNew.AsyncResult[] checkResults =
|
|
service.checkStatus(new List<String>{ String.valueOf(results[0].Id) });
|
|
System.debug(LoggingLevel.INFO, 'SME_OAUTH_DBG MetadataService.checkStatus result='
|
|
+ (checkResults != null && !checkResults.isEmpty() ? JSON.serialize(checkResults[0]) : 'NULL/empty'));
|
|
}
|
|
} catch (Exception e) {
|
|
System.debug(LoggingLevel.ERROR, 'SME_OAUTH_DBG ensureRemoteSite EXCEPTION type=' + e.getTypeName()
|
|
+ ' | message=' + e.getMessage() + ' | line=' + e.getLineNumber());
|
|
System.debug(LoggingLevel.ERROR, 'SME_OAUTH_DBG ensureRemoteSite STACK: ' + e.getStackTraceString());
|
|
}
|
|
}
|
|
|
|
// Validate the HMAC-SHA256 Shopify appends to the callback URL.
|
|
private static Boolean validateOAuthHmac(Map<String, String> params,
|
|
String clientSecret,
|
|
String receivedHmac) {
|
|
try {
|
|
Map<String, String> filtered = new Map<String, String>(params);
|
|
filtered.remove('hmac');
|
|
List<String> keys = new List<String>(filtered.keySet());
|
|
keys.sort();
|
|
List<String> pairs = new List<String>();
|
|
for (String k : keys) {
|
|
pairs.add(k + '=' + filtered.get(k));
|
|
}
|
|
Blob mac = Crypto.generateMac('hmacSHA256',
|
|
Blob.valueOf(String.join(pairs, '&')),
|
|
Blob.valueOf(clientSecret));
|
|
return EncodingUtil.convertToHex(mac).equalsIgnoreCase(receivedHmac);
|
|
} catch (Exception e) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// HARDENED against the Refresh-Token-Rotation race (Salesforce case: two refreshes 24ms apart on the
|
|
// same token → RTR reuse-detection invalidates the whole family).
|
|
//
|
|
// This endpoint NO LONGER runs its own grant_type=refresh_token. That independent redemption was the
|
|
// second, unlocked refresher that raced TokenService. It now routes through TokenService — the single
|
|
// FOR UPDATE + single-flight authority — which:
|
|
// - refreshes the org's CURRENT stored refresh token (never an arbitrary/stale one passed in), and
|
|
// - serialises against every other in-org refresh, so two refreshes can never redeem concurrently,
|
|
// and a peer that already refreshed is reused instead of re-redeemed.
|
|
// We pass the pre-refresh access token as the "failed" token so single-flight reuses a peer's result
|
|
// rather than rotating again. Returns an OAuth-shaped body so existing callers keep parsing it.
|
|
private static String proxyRefreshToken(String requestBody) {
|
|
try {
|
|
List<HIC_ShopifySync__Configuration__c> before = [
|
|
SELECT HIC_ShopifySync__Access_Token__c, HIC_ShopifySync__Refresh_Token__c
|
|
FROM HIC_ShopifySync__Configuration__c WHERE Name = 'Default' LIMIT 1
|
|
];
|
|
String priorAccess = before.isEmpty() ? null : before[0].HIC_ShopifySync__Access_Token__c;
|
|
|
|
try {
|
|
TokenService.getFreshAccessToken(priorAccess, false); // serialized: refresh-if-current, reuse-if-peer-refreshed
|
|
} catch (Exception te) {
|
|
RestContext.response.statusCode = 400;
|
|
return '{"error":"invalid_grant","error_description":"'
|
|
+ String.valueOf(te.getMessage()).replace('"', '\\"') + '"}';
|
|
}
|
|
|
|
List<HIC_ShopifySync__Configuration__c> after = [
|
|
SELECT HIC_ShopifySync__Access_Token__c, HIC_ShopifySync__Refresh_Token__c
|
|
FROM HIC_ShopifySync__Configuration__c WHERE Name = 'Default' LIMIT 1
|
|
];
|
|
if (after.isEmpty() || String.isBlank(after[0].HIC_ShopifySync__Access_Token__c)) {
|
|
RestContext.response.statusCode = 400;
|
|
return '{"error":"invalid_grant","error_description":"no current token; reinitialize required"}';
|
|
}
|
|
|
|
RestContext.response.statusCode = 200;
|
|
RestContext.response.addHeader('Content-Type', 'application/json');
|
|
return JSON.serialize(new Map<String, Object>{
|
|
'access_token' => after[0].HIC_ShopifySync__Access_Token__c,
|
|
'refresh_token' => after[0].HIC_ShopifySync__Refresh_Token__c,
|
|
'instance_url' => URL.getOrgDomainUrl().toExternalForm(),
|
|
'token_type' => 'Bearer'
|
|
});
|
|
|
|
} catch (Exception e) {
|
|
System.debug('SME proxyRefreshToken error: ' + e.getMessage() + ' line ' + e.getLineNumber());
|
|
RestContext.response.statusCode = 500;
|
|
return '{"error":"proxy_error","error_description":"' + e.getMessage().replace('"', '\\"') + '"}';
|
|
}
|
|
}
|
|
|
|
// Build a self-contained HTML response page (no static resources needed).
|
|
private static String buildOAuthPage(Boolean success, String heading,
|
|
String message, String storeName) {
|
|
String spinnerColor = success ? '#95BE45' : '#c62828';
|
|
String headingColor = success ? '#2e7d32' : '#c62828';
|
|
String autoClose = success
|
|
? '<script>(function(){setTimeout(function(){window.close();},2500);})();</script>'
|
|
: '';
|
|
String storeTag = (success && !String.isBlank(storeName))
|
|
? '<div class="badge">' + storeName.escapeHtml4() + '.myshopify.com</div>'
|
|
: '';
|
|
|
|
return '<!DOCTYPE html>'
|
|
+ '<html lang="en"><head><meta charset="UTF-8"/>'
|
|
+ '<meta name="viewport" content="width=device-width,initial-scale=1"/>'
|
|
+ '<title>Shopify OAuth — SyncMadeEasy</title>'
|
|
+ '<style>'
|
|
+ '*{box-sizing:border-box;margin:0;padding:0}'
|
|
+ 'body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;'
|
|
+ 'background:#f4f6f9;display:flex;align-items:center;justify-content:center;min-height:100vh;}'
|
|
+ '.card{background:#fff;border-radius:12px;padding:2.5rem 2rem;text-align:center;'
|
|
+ 'box-shadow:0 4px 24px rgba(0,0,0,.1);max-width:400px;width:90%;}'
|
|
+ '@keyframes spin{to{transform:rotate(360deg)}}'
|
|
+ '.spinner{width:72px;height:72px;border:5px solid #f0f6e4;'
|
|
+ 'border-top-color:' + spinnerColor + ';border-radius:50%;'
|
|
+ 'animation:spin 1s linear infinite;margin:0 auto 1.5rem;}'
|
|
+ 'h2{font-size:1.2rem;font-weight:700;color:' + headingColor + ';margin-bottom:0.75rem;}'
|
|
+ 'p{font-size:0.875rem;color:#555;line-height:1.5;}'
|
|
+ '.badge{display:inline-block;background:#f0f6e4;border:1px solid #95BE45;'
|
|
+ 'border-radius:20px;padding:4px 14px;font-size:0.8rem;color:#3b3b3b;margin:0.75rem 0;}'
|
|
+ '.hint{font-size:0.78rem;color:#888;margin-top:1rem;}'
|
|
+ '</style></head>'
|
|
+ '<body><div class="card">'
|
|
+ '<div class="spinner"></div>'
|
|
+ '<h2>' + heading.escapeHtml4() + '</h2>'
|
|
+ storeTag
|
|
+ '<p>' + message.escapeHtml4() + '</p>'
|
|
+ (success ? '<p class="hint">This window will close automatically.</p>' : '')
|
|
+ autoClose
|
|
+ '</div></body></html>';
|
|
}
|
|
} |