162 lines
7.5 KiB
OpenEdge ABL
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;
|
|
}
|
|
}
|
|
} |