377 lines
22 KiB
OpenEdge ABL
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>';
|
|
}
|
|
} |