/** * ShopifyOAuthCallbackRest * ───────────────────────────────────────────────────────────────────────────── * REST endpoint that acts as the OAuth redirect_uri for the Shopify * Authorization Code Grant flow. Accessible from any Salesforce Site at: * * https:////services/apexrest/HIC_ShopifySync/OAuthCallback * * Unlike a Visualforce page, a @RestResource endpoint does NOT need to be * added to the Site's "Site Visualforce Pages" list — the only requirement is * that the Site Guest User has Apex Class access (granted automatically by the * SMEPackagePostInstallScript that runs on every install/upgrade). * * Flow: * 1. Merchant approves the app on Shopify. * 2. Shopify redirects to this URL with ?code=...&shop=...&state=...&hmac=... * 3. This endpoint validates the HMAC, exchanges the code for an offline token, * saves it to the store's HIC_Configuration record, and returns a branded * HTML page that auto-closes after 2.5 seconds. * 4. The LWC polls getStoreAccessToken every 2 s and fills the field the * instant the token lands. */ @RestResource(urlMapping='/OAuthCallback') global without sharing class ShopifyOAuthCallbackRest { @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(buildPage(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(buildPage(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(buildPage(false, 'Invalid Request', 'The authorization state is incomplete.', null)); return; } // --- Validate HMAC ------------------------------------------------ if (!String.isBlank(hmac) && !validateHmac(req.params, clientSecret, hmac)) { res.statusCode = 400; res.responseBody = Blob.valueOf(buildPage(false, 'Security Check Failed', 'The request signature could not be verified.', null)); return; } // --- Ensure Remote Site Setting exists for this shop -------------- // We call the MetadataService directly here instead of going through // ShopifyController.createRemoteSiteSettings() because that method is // @AuraEnabled and throws AuraHandledException, which is illegal in a // REST context and causes a System.LimitException in subscriber orgs. System.debug(LoggingLevel.INFO, 'SME_OAUTH_DBG HMAC ok/skipped. Ensuring Remote Site for https://' + shop); ensureRemoteSite('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(buildPage(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(buildPage(false, 'No Token Received', 'Shopify did not return an access token. Please try again.', null)); return; } // --- NO DML: show 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 Access Token field and clicks Check // Credential, which runs the existing validation/save path. res.statusCode = 200; res.responseBody = Blob.valueOf(buildTokenPage(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(buildPage(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. // ───────────────────────────────────────────────────────────────────────── private static String buildTokenPage(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 by Salesforce. // Inlines the Metadata API logic from ShopifyController.createRemoteSiteSettings // without going through the @AuraEnabled wrapper (which throws // AuraHandledException — illegal in a REST context). // ───────────────────────────────────────────────────────────────────────── private static void ensureRemoteSite(String shopUrl) { System.debug(LoggingLevel.INFO, 'SME_OAUTH_DBG ensureRemoteSite START url=' + shopUrl + ' | UserType=' + UserInfo.getUserType() + ' | UserName=' + UserInfo.getUserName()); try { // Read the Metadata-API session token in SYSTEM MODE. // We MUST NOT use ShopifyController.getCryptoKey() here: it builds the // SOQL through CheckAccessibility.isAccessible(), which strips every field // the running user lacks FLS on. 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 it throws // System.QueryException — which is why RSS creation silently failed in // subscriber orgs. A direct SOQL in this 'without sharing' class runs in // system context (no FLS enforcement) and works for the guest user. 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, this is the Guest-User FLS issue: 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; // no token available — skip; the LWC may have already created the site } String accessToken = cfg[0].HIC_ShopifySync__Access_Token__c; System.debug(LoggingLevel.INFO, 'SME_OAUTH_DBG Access_Token__c present, length=' + accessToken.length()); // Already registered? Nothing to do. (CheckingRemotesiteExistence is // try/catch-safe and never throws AuraHandledException.) Boolean alreadyExists = ShopifyController.CheckingRemotesiteExistence(shopUrl, accessToken); System.debug(LoggingLevel.INFO, 'SME_OAUTH_DBG CheckingRemotesiteExistence(' + shopUrl + ') = ' + alreadyExists); if (alreadyExists) return; // Create via the Metadata API. createService() just builds the port — // it does not throw AuraHandledException. 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) { // Log but never block the callback — the callout below will surface // a clear "Unauthorized endpoint" error if the site is still missing. 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 validateHmac(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; } } // ───────────────────────────────────────────────────────────────────────── // Build a self-contained HTML response page (no static resources needed). // ───────────────────────────────────────────────────────────────────────── private static String buildPage(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 + '
'; } }