/** * ─────────────────────────────────────────────────────────────────────────────────────────────────┐ * 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 * ────────────────────────────────────────────────────────────────────────────────────────────────── */ @RestResource(urlMapping='/AuthenticateAllApexClass/*') global class AuthenticateAllApexClass { @HttpPost global static string AuthenticateClass(){ RestRequest request = RestContext.request; RestResponse response = RestContext.response; Map 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 meta = (Map) 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 shObjectNameVsMappingMap = new Map(); //shObjectNameVsMappingMap = new Map(); 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 meta = (Map) 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 meta = (Map) 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 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 meta = (Map) 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 listofConfiguration = new list(); /*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:///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 payload; try { payload = (Map) 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{ '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 tokenBody = (Map) 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) ? '
' + storeName.escapeHtml4() + '.myshopify.com
' : ''; return '' + '' + '' + 'Shopify OAuth — SyncMadeEasy' + '' + '
' + '

Access Token Generated!

' + storeTag + '

Copy the token below and paste it into the Admin API access token ' + 'field in SyncMadeEasy, then click Check Credential.

' + '
' + '' + '' + '
' + '

This token is shown only once. Close this window after copying.

' + '' + '
'; } // 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 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{ 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.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 params, String clientSecret, String receivedHmac) { try { Map filtered = new Map(params); filtered.remove('hmac'); List keys = new List(filtered.keySet()); keys.sort(); List pairs = new List(); 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 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 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{ '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 ? '' : ''; String storeTag = (success && !String.isBlank(storeName)) ? '
' + storeName.escapeHtml4() + '.myshopify.com
' : ''; return '' + '' + '' + 'Shopify OAuth — SyncMadeEasy' + '' + '
' + '
' + '

' + heading.escapeHtml4() + '

' + storeTag + '

' + message.escapeHtml4() + '

' + (success ? '

This window will close automatically.

' : '') + autoClose + '
'; } }