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

377 lines
22 KiB
OpenEdge ABL

/**
* ShopifyOAuthCallbackRest
* ─────────────────────────────────────────────────────────────────────────────
* REST endpoint that acts as the OAuth redirect_uri for the Shopify
* Authorization Code Grant flow. Accessible from any Salesforce Site at:
*
* https://<site-domain>/<site-path>/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<String, Object> payload;
try {
payload = (Map<String, Object>) 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<String, String>{
'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<String, Object> tokenBody =
(Map<String, Object>) 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)
? '<div class="badge">' + storeName.escapeHtml4() + '.myshopify.com</div>'
: '';
return '<!DOCTYPE html>'
+ '<html lang="en"><head><meta charset="UTF-8"/>'
+ '<meta name="viewport" content="width=device-width,initial-scale=1"/>'
+ '<title>Shopify OAuth — SyncMadeEasy</title>'
+ '<style>'
+ '*{box-sizing:border-box;margin:0;padding:0}'
+ 'body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;'
+ 'background:#f4f6f9;display:flex;align-items:center;justify-content:center;min-height:100vh;}'
+ '.card{background:#fff;border-radius:12px;padding:2.5rem 2rem;text-align:center;'
+ 'box-shadow:0 4px 24px rgba(0,0,0,.1);max-width:480px;width:90%;}'
+ 'h2{font-size:1.2rem;font-weight:700;color:#2e7d32;margin-bottom:0.75rem;}'
+ 'p{font-size:0.875rem;color:#555;line-height:1.5;}'
+ '.badge{display:inline-block;background:#f0f6e4;border:1px solid #95BE45;'
+ 'border-radius:20px;padding:4px 14px;font-size:0.8rem;color:#3b3b3b;margin:0.75rem 0;}'
+ '.hint{font-size:0.78rem;color:#888;margin-top:1rem;}'
+ '.tok-row{display:flex;gap:8px;margin-top:14px;}'
+ '.tok-row input{flex:1;padding:10px 12px;border:1px solid #ccc;border-radius:8px;'
+ 'font-size:14px;font-family:monospace;background:#f8f8f8;}'
+ '.tok-row button{padding:10px 18px;border:none;border-radius:8px;background:#95BE45;'
+ 'color:#fff;font-size:14px;font-weight:600;cursor:pointer;}'
+ '</style></head>'
+ '<body><div class="card">'
+ '<h2>Access Token Generated!</h2>'
+ storeTag
+ '<p>Copy the token below and paste it into the <strong>Admin API access token</strong> '
+ 'field in SyncMadeEasy, then click <strong>Check Credential</strong>.</p>'
+ '<div class="tok-row">'
+ '<input type="text" id="smeToken" readonly value="' + accessToken.escapeHtml4() + '"/>'
+ '<button id="smeCopyBtn" type="button">Copy</button>'
+ '</div>'
+ '<p class="hint">This token is shown only once. Close this window after copying.</p>'
+ '<script>'
+ '(function(){'
+ 'var btn=document.getElementById(\'smeCopyBtn\');'
+ 'var input=document.getElementById(\'smeToken\');'
+ 'btn.addEventListener(\'click\',function(){'
+ 'input.select();input.setSelectionRange(0,99999);'
+ 'if(navigator.clipboard&&navigator.clipboard.writeText){'
+ 'navigator.clipboard.writeText(input.value).then(function(){btn.textContent=\'Copied!\';});'
+ '}else if(document.execCommand(\'copy\')){btn.textContent=\'Copied!\';}'
+ 'setTimeout(function(){btn.textContent=\'Copy\';},2000);'
+ '});'
+ '})();'
+ '</script>'
+ '</div></body></html>';
}
// ─────────────────────────────────────────────────────────────────────────
// 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<HIC_ShopifySync__Configuration__c> 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<MetadataServiceNew.Metadata>{ 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>{ 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<String, String> params,
String clientSecret,
String receivedHmac) {
try {
Map<String, String> filtered = new Map<String, String>(params);
filtered.remove('hmac');
List<String> keys = new List<String>(filtered.keySet());
keys.sort();
List<String> pairs = new List<String>();
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
? '<script>(function(){setTimeout(function(){window.close();},2500);})();</script>'
: '';
String storeTag = (success && !String.isBlank(storeName))
? '<div class="badge">' + storeName.escapeHtml4() + '.myshopify.com</div>'
: '';
return '<!DOCTYPE html>'
+ '<html lang="en"><head><meta charset="UTF-8"/>'
+ '<meta name="viewport" content="width=device-width,initial-scale=1"/>'
+ '<title>Shopify OAuth — SyncMadeEasy</title>'
+ '<style>'
+ '*{box-sizing:border-box;margin:0;padding:0}'
+ 'body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;'
+ 'background:#f4f6f9;display:flex;align-items:center;justify-content:center;min-height:100vh;}'
+ '.card{background:#fff;border-radius:12px;padding:2.5rem 2rem;text-align:center;'
+ 'box-shadow:0 4px 24px rgba(0,0,0,.1);max-width:400px;width:90%;}'
+ '@keyframes spin{to{transform:rotate(360deg)}}'
+ '.spinner{width:72px;height:72px;border:5px solid #f0f6e4;'
+ 'border-top-color:' + spinnerColor + ';border-radius:50%;'
+ 'animation:spin 1s linear infinite;margin:0 auto 1.5rem;}'
+ 'h2{font-size:1.2rem;font-weight:700;color:' + headingColor + ';margin-bottom:0.75rem;}'
+ 'p{font-size:0.875rem;color:#555;line-height:1.5;}'
+ '.badge{display:inline-block;background:#f0f6e4;border:1px solid #95BE45;'
+ 'border-radius:20px;padding:4px 14px;font-size:0.8rem;color:#3b3b3b;margin:0.75rem 0;}'
+ '.hint{font-size:0.78rem;color:#888;margin-top:1rem;}'
+ '</style></head>'
+ '<body><div class="card">'
+ '<div class="spinner"></div>'
+ '<h2>' + heading.escapeHtml4() + '</h2>'
+ storeTag
+ '<p>' + message.escapeHtml4() + '</p>'
+ (success ? '<p class="hint">This window will close automatically.</p>' : '')
+ autoClose
+ '</div></body></html>';
}
}