SyncMadeEasy/force-app/main/default/classes/ShopifyOAuthCallbackCtrl.cls

162 lines
7.5 KiB
OpenEdge ABL

/**
* 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://<your-site-domain>/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://<store>.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<String, String> 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<String, Object> payload = (Map<String, Object>) 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<String, String>{
'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<String, Object> tokenResp = (Map<String, Object>) 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<String, String> params, String clientSecret, String receivedHmac) {
try {
Map<String, String> filtered = new Map<String, String>(params);
filtered.remove('hmac');
List<String> pairs = new List<String>();
List<String> keys = new List<String>(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;
}
}
}