/** * Controller for the ShopifyOAuthCallback Visualforce page. * * This page is hosted on a Salesforce Site and acts as the OAuth redirect_uri * for the Shopify Authorization Code Grant flow. When the merchant approves * the app in Shopify, Shopify redirects to: * https:///ShopifyOAuthCallback?code=...&shop=...&state=...&hmac=... * * This controller: * 1. Decodes the state parameter (URL-safe base64 JSON) to recover storeName, * clientId, and clientSecret. * 2. Validates the HMAC signature provided by Shopify. * 3. POSTs to Shopify's token endpoint to exchange the code for an offline * (permanent) access token. * 4. Saves the token to the store's SME_Configuration__c record. * * SETUP REQUIRED: * - Add the ShopifyOAuthCallback page to your Salesforce Site's enabled pages. * - Grant the Site Guest User profile Create/Edit access on HIC_ShopifySync__SME_Configuration__c. * - Ensure the Remote Site Setting for https://.myshopify.com exists * (created automatically when the user clicks "Generate Token" in the LWC). */ public without sharing class ShopifyOAuthCallbackCtrl { public Boolean isSuccess { get; private set; } public String statusMsg { get; private set; } public String storeName { get; private set; } public String accessToken { get; private set; } public void init() { Map params = ApexPages.currentPage().getParameters(); String code = params.get('code'); String shop = params.get('shop'); String state = params.get('state'); String hmac = params.get('hmac'); if (String.isBlank(code) || String.isBlank(shop) || String.isBlank(state)) { this.isSuccess = false; this.statusMsg = 'Missing required OAuth callback parameters.'; return; } try { // --- Decode state ------------------------------------------------- // Re-pad URL-safe base64 to standard base64 String padded = state.replace('-', '+').replace('_', '/'); Integer rem = Math.mod(padded.length(), 4); if (rem == 2) padded += '=='; else if (rem == 3) padded += '='; String stateJson = EncodingUtil.base64Decode(padded).toString(); Map payload = (Map) JSON.deserializeUntyped(stateJson); String storeNameLocal = (String) payload.get('store'); String clientId = (String) payload.get('clientId'); String clientSecret = (String) payload.get('clientSecret'); if (String.isBlank(storeNameLocal) || String.isBlank(clientId) || String.isBlank(clientSecret)) { this.isSuccess = false; this.statusMsg = 'Invalid state payload — missing credentials.'; return; } // --- Validate HMAC ------------------------------------------------ if (!String.isBlank(hmac) && !validateHmac(params, clientSecret, hmac)) { this.isSuccess = false; this.statusMsg = 'HMAC validation failed. The callback may have been tampered with.'; return; } // --- Ensure a Remote Site Setting exists for the ACTUAL shop ------- // Shopify returns the store's permanent .myshopify.com domain in `shop`, // which can differ from the name the user typed. Without a matching // Remote Site Setting the token callout is blocked as "Unauthorized // endpoint". createRemoteSiteSettings uses a stored token, so it works // even though this page runs as the Site guest user. The newly created // setting is committed by the Metadata API call and active for the // subsequent callout in this same transaction. String shopUrl = 'https://' + shop; try { if (!ShopifyController.checkStoreNameRemoteSite(shopUrl)) { ShopifyController.createRemoteSiteSettings(shopUrl); } } catch (Exception rsEx) { System.debug(LoggingLevel.WARN, 'Remote site ensure failed: ' + rsEx.getMessage()); // Continue — the callout below will surface a clear error if still blocked } // --- Exchange code for offline access token ----------------------- HttpRequest req = new HttpRequest(); req.setEndpoint('https://' + shop + '/admin/oauth/access_token'); req.setMethod('POST'); req.setHeader('Content-Type', 'application/json'); req.setHeader('Accept', 'application/json'); req.setBody(JSON.serialize(new Map{ 'client_id' => clientId, 'client_secret' => clientSecret, 'code' => code })); req.setTimeout(30000); HttpResponse res = new Http().send(req); if (res.getStatusCode() != 200) { this.isSuccess = false; this.statusMsg = 'Shopify token exchange failed (HTTP ' + res.getStatusCode() + '): ' + res.getBody(); return; } Map tokenResp = (Map) JSON.deserializeUntyped(res.getBody()); String accessToken = (String) tokenResp.get('access_token'); if (String.isBlank(accessToken)) { this.isSuccess = false; this.statusMsg = 'No access_token in Shopify response: ' + res.getBody(); 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 Access Token field and clicks Check // Credential, which runs the existing validation/save path. this.accessToken = accessToken; this.storeName = storeNameLocal; this.isSuccess = true; this.statusMsg = 'Access token generated successfully!'; } catch (Exception e) { this.isSuccess = false; this.statusMsg = 'Unexpected error: ' + e.getMessage(); System.debug(LoggingLevel.ERROR, 'ShopifyOAuthCallbackCtrl.init error: ' + e.getMessage() + ' | ' + e.getStackTraceString()); } } // ------------------------------------------------------------------------- // Validates the HMAC-SHA256 that Shopify appends to the callback URL. // All parameters except 'hmac' itself are sorted, joined, and signed with // the client_secret. // ------------------------------------------------------------------------- private static Boolean validateHmac(Map params, String clientSecret, String receivedHmac) { try { Map filtered = new Map(params); filtered.remove('hmac'); List pairs = new List(); List keys = new List(filtered.keySet()); keys.sort(); for (String k : keys) { pairs.add(k + '=' + filtered.get(k)); } String message = String.join(pairs, '&'); Blob macBlob = Crypto.generateMac('hmacSHA256', Blob.valueOf(message), Blob.valueOf(clientSecret)); String computedHmac = EncodingUtil.convertToHex(macBlob); return computedHmac.equalsIgnoreCase(receivedHmac); } catch (Exception e) { return false; } } }