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

724 lines
42 KiB
OpenEdge ABL

/**
* ─────────────────────────────────────────────────────────────────────────────────────────────────┐
* This class is handle for all Shopify Webhook response
* Authenticate webhooks classes
* Shopify API Documentation: https://shopify.dev/docs/admin-api/rest/reference
* Create Shopify Store: https://www.shopify.com/signup?itcat=identity&itterm=create-new-store
* Shopify API Release 2020: https://www.shopify.in/partners/blog/shopify-api-release-january-2020
* What is Person Account in Salesforce ? https://help.salesforce.com/articleView?id=account_person.htm&type=5
* ──────────────────────────────────────────────────────────────────────────────────────────────────
* @author Sumit <sumit@hicShopify.com>
* ──────────────────────────────────────────────────────────────────────────────────────────────────
*/
@RestResource(urlMapping='/AuthenticateAllApexClass/*')
global class AuthenticateAllApexClass {
@HttpPost
global static string AuthenticateClass(){
RestRequest request = RestContext.request;
RestResponse response = RestContext.response;
Map <String, String> params = request.params;
String GetPath = request.resourcePath;
String JSONResponse;
String ObjectName;
String DMLType;
String store = 'Default';
String isServer = 'false';
JSONResponse = request.requestBody.toString().trim();
if(!params.isEmpty() && params.containskey('objectname'))
ObjectName = params.get('objectname');
if(!params.isEmpty() && params.containskey('DMLType'))
DMLType = params.get('DMLType');
if(!params.isEmpty() && params.containskey('store'))
store = params.get('store');
if(!params.isEmpty() && params.containskey('isProfessional'))
isServer = params.get('isProfessional');
system.debug('@@@ params : '+params);
system.debug('@@@ JSONResponse : '+JSONResponse);
/*if(ObjectName == 'UpdateRefreshToken'){
if( DMLType == 'Update' ){
updatetoken(JSONResponse );
}
}*//*else if(ObjectName == 'UpdateAccessToken'){
//updateAccesstoken(JSONResponse);
SfUtility.postToSF(JSONResponse , 'UpdateAdminAccessToken','');
}
else if(ObjectName == 'Customer'){
if(DMLType == 'Create' || DMLType == 'Update' ){
SfUtility.postToSF(JSONResponse , 'UpdateCustomerFromShopify',store);
}else if(DMLType == 'Delete'){
SfUtility.postToSF(JSONResponse , 'DeleteCustomerFromShopify',store);
}
}else if(ObjectName == 'Product2'){
if(DMLType == 'Create' || DMLType == 'Update' ){
SfUtility.postToSF(JSONResponse , 'UpdateProductAndVarientFromShopify',store);
}else if(DMLType == 'Delete'){
SfUtility.postToSF(JSONResponse , 'DeleteProductAndVariantfromShopify',store);
}
}else if(ObjectName == 'Order'){
if(DMLType == 'Create' || DMLType == 'Update' ){
SfUtility.postToSF(JSONResponse , 'UpdateOrderFromShopify',store);
}else if(DMLType == 'Delete'){
SfUtility.postToSF(JSONResponse , 'DeleteOrderFromShopify',store);
}
}else if(ObjectName == 'HIC_ShopifySync__Collection__c'){
if(DMLType == 'Create' || DMLType == 'Update' ){
SfUtility.postToSF(JSONResponse , 'UpdateCollectionAndCollectsFromShopify',store);
}else if(DMLType == 'Delete'){
SfUtility.postToSF(JSONResponse , 'DeleteCollectionFromShopify',store);
}
}else if(ObjectName == 'HIC_ShopifySync__Inventory_Level__c'){
if(DMLType == 'Connect' || DMLType == 'Update' ){
SfUtility.postToSF(JSONResponse , 'UpdateInventoryLevelsFromShopify',store);
}else if(DMLType == 'Disconnect'){
SfUtility.postToSF(JSONResponse , 'DisconnectInventoryFormShopify',store);
}
}else if(ObjectName == 'DraftOrder'){
if(DMLType == 'Create' || DMLType == 'Update' ){
Map<String, Object> meta = (Map<String, Object>) JSON.deserializeUntyped(JSONResponseBody);
if(!meta.isEmpty() && meta.containsKey('status') && meta.get('status') == 'completed')
SfUtility.postToSF(JSONResponse , 'DeleteOrderFromShopify', store);
else
SfUtility.postToSF(JSONResponse , 'UpdateDraftOrderFromShopify',store);
}else if(DMLType == 'Delete'){
SfUtility.postToSF(JSONResponse , 'DeleteOrderFromShopify', store);
}
}else if(ObjectName == 'HIC_ShopifySync__Location__c'){
if(DMLType == 'Create' || DMLType == 'Update' ){
SfUtility.postToSF(JSONResponse , 'UpdateLocationFromShopify',store);
}else if(DMLType == 'Delete'){
SfUtility.postToSF(JSONResponse , 'DeleteLocationFromShopify',store);
}
}else{
SfUtility.postToSF(JSONResponse , 'CreateShopifyLog', JSON.serialize(params));
}*/
Map<String,HIC_ShopifySync__WebhookURlMapping__c> shObjectNameVsMappingMap = new Map<String,HIC_ShopifySync__WebhookURlMapping__c>();
//shObjectNameVsMappingMap = new Map<String,HIC_ShopifySync__WebhookURlMapping__c>();
for(HIC_ShopifySync__WebhookURlMapping__c webMap : [SELECT Name, HIC_ShopifySync__DeleteURL__c, HIC_ShopifySync__UpsertURL__c From HIC_ShopifySync__WebhookURlMapping__c Where HIC_ShopifySync__Activated__c = true Limit 9999]){
shObjectNameVsMappingMap.put(webMap.Name, webMap);
}
system.debug('@@@ store : '+store);
system.debug('@@@ shObjectNameVsMappingMap :'+shObjectNameVsMappingMap);
//System.debug('shObjectNameVsMappingMap--'+shObjectNameVsMappingMap);
if(ObjectName == 'UpdateRefreshToken'){
if( DMLType == 'Update' ){
// The rotated-token write-back is now accepted from EVERY caller. It used to also require
// isServer=='false' (i.e. the ?isProfessional= query param absent or false). Any caller
// that sent isProfessional=true had its token write SILENTLY SKIPPED while still receiving
// HTTP 200 — the org then kept a superseded refresh token and the connection died days
// later. A safety-critical token write must never be gated on a routing hint.
Boolean stored = persistRotatedToken(JSONResponse);
if(!stored){
// LOUD FAILURE. Under mandatory rotation the caller has ALREADY consumed the previous
// refresh token, so answering 200 here is what silently desynchronises the org.
// A 500 tells the caller to retry while its new token is still the live one.
setResponseStatus(500);
return '{"error":"token_not_stored","error_description":"rotated refresh token could not be persisted"}';
}
setResponseStatus(200);
return '{"status":"ok"}';
}
} else if (ObjectName == 'RefreshToken') {
// Subscriber orgs route their grant_type=refresh_token call through here
// so the callout originates from this site's IND56 IP, satisfying the
// "Enforce Refresh Token IP Allowlist" restriction on the Connected App.
return proxyRefreshToken(JSONResponse);
} else if(!shObjectNameVsMappingMap.isEmpty() && shObjectNameVsMappingMap.containsKey(ObjectName)){
if(DMLType == 'Create' || DMLType == 'Update' || ObjectName == 'UpdateAccessToken'){
/* if(ObjectName == 'DraftOrder'){
Map<String, Object> meta = (Map<String, Object>) JSON.deserializeUntyped(JSONResponse);
if(!meta.isEmpty() && meta.containsKey('status') && meta.get('status') == 'completed')
SfUtility.postToSF(JSONResponse , shObjectNameVsMappingMap.get(ObjectName).HIC_ShopifySync__DeleteURL__c, store);
else
SfUtility.postToSF(JSONResponse , shObjectNameVsMappingMap.get(ObjectName).HIC_ShopifySync__UpsertURL__c, store);
}else*/
system.debug('@@@ shObjectNameVsMappingMap inside if:'+shObjectNameVsMappingMap);
SfUtility.postToSF(JSONResponse , shObjectNameVsMappingMap.get(ObjectName).HIC_ShopifySync__UpsertURL__c, store, ObjectName);
}else if(DMLType == 'Delete'){
SfUtility.postToSF(JSONResponse , shObjectNameVsMappingMap.get(ObjectName).HIC_ShopifySync__DeleteURL__c, store,ObjectName);
}
}else{
SfUtility.postToSF(JSONResponse , 'HIC_ShopifySync/CreateShopifyLog', JSON.serialize(params),ObjectName);
}
return null;
}
// Preserved global signature (managed-package API cannot change). Delegates to persistRotatedToken.
global static void updatetoken(String JSONResponseBody ){
persistRotatedToken(JSONResponseBody);
}
/**
* Persists a refresh token (and optional access token) pushed in by the packaging org's authenticator
* during initialize — AuthenticateInternally posts here after exchanging the ?code. That is the ONLY
* legitimate external caller: this package is 100% Salesforce-native, with no middleware or external
* server, and TokenService is the only code that rotates.
*
* This is the most safety-critical write in the package: under mandatory Refresh Token
* Rotation the caller has ALREADY consumed the previous refresh token by the time it calls us, so if
* this write does not land the org is left holding a superseded token and every later refresh fails
* with invalid_grant — days later, long after the actual fault.
*
* Two silent-failure modes were removed here:
* 1. The record lookup built its field list through CheckAccessibility.isAccessible(), which returns
* an EMPTY STRING when the running user cannot see the object (CheckAccessibility.cls:94-113).
* This endpoint is reachable through the public Site as the Guest User, so the query collapsed to
* "Select from HIC_ShopifySync__Configuration__c where = 'Default'" and threw QueryException —
* and that query was NOT inside the try/catch, so the whole callout 500'd or the write vanished.
* Plain SOQL is used now: an integration write-back must not depend on the caller's FLS.
* 2. The update sat in a catch that only called System.debug, so a failed write still reported
* success to the caller.
*
* @return true only when the token has been durably committed.
*/
public static Boolean persistRotatedToken(String JSONResponseBody){
String refreshToken;
String accessToken;
String expectedPreviousRefreshToken;
try {
Map<String, Object> meta = (Map<String, Object>) JSON.deserializeUntyped(JSONResponseBody);
if (meta == null || meta.isEmpty()) {
System.debug(LoggingLevel.ERROR, 'SME_TOKEN persistRotatedToken: empty body');
return false;
}
refreshToken = blankToNull(meta.get('HIC_ShopifySync__Refresh_Token__c'));
accessToken = blankToNull(meta.get('HIC_ShopifySync__Access_Token__c'));
// Optional compare-and-swap guard (see below). Accepted under either spelling.
expectedPreviousRefreshToken = blankToNull(meta.get('previous_refresh_token'));
if (expectedPreviousRefreshToken == null) {
expectedPreviousRefreshToken = blankToNull(meta.get('HIC_ShopifySync__Previous_Refresh_Token'));
}
} catch (Exception parseEx) {
System.debug(LoggingLevel.ERROR, 'SME_TOKEN persistRotatedToken: unparseable body — '
+ parseEx.getMessage());
return false;
}
if (String.isBlank(refreshToken) && String.isBlank(accessToken)) {
System.debug(LoggingLevel.ERROR, 'SME_TOKEN persistRotatedToken: body carried no token');
return false;
}
try {
// Plain SOQL, and FOR UPDATE so this write serialises against TokenService's own refresh.
List<HIC_ShopifySync__Configuration__c> cfg = [
SELECT Id, HIC_ShopifySync__Refresh_Token__c, HIC_ShopifySync__Access_Token__c
FROM HIC_ShopifySync__Configuration__c
WHERE Name = 'Default'
LIMIT 1
FOR UPDATE
];
if (cfg.isEmpty()) {
System.debug(LoggingLevel.ERROR,
'SME_TOKEN persistRotatedToken: no Default Configuration row exists to write into');
return false;
}
// ── COMPARE-AND-SWAP ────────────────────────────────────────────────────────────────
// This write is last-write-wins by nature, which is unsafe for a value whose entire meaning
// is "supersedes exactly one predecessor". Because a failed write now answers HTTP 500, the
// caller RETRIES — and if TokenService rotated in between, a retry carrying the old token
// would reinstall a dead token over the live one. So when the caller tells us which token it
// believes it is replacing, we verify that belief and reject a stale body with 409 instead.
//
// Optional by design: a caller that omits previous_refresh_token keeps the old
// last-write-wins behaviour, so the initialize hand-off (which legitimately installs a token
// over whatever was there) is unaffected.
String stored = cfg[0].HIC_ShopifySync__Refresh_Token__c;
if (String.isNotBlank(expectedPreviousRefreshToken)
&& String.isNotBlank(stored)
&& stored != expectedPreviousRefreshToken) {
System.debug(LoggingLevel.ERROR, 'SME_TOKEN persistRotatedToken: STALE write rejected — '
+ 'caller expected to replace a token that is no longer current (a newer rotation won)');
setResponseStatus(409);
return false;
}
// Idempotent replay of the same value: nothing to do, and reporting success is correct.
if (String.isNotBlank(refreshToken) && refreshToken == stored
&& (String.isBlank(accessToken) || accessToken == cfg[0].HIC_ShopifySync__Access_Token__c)) {
System.debug(LoggingLevel.INFO, 'SME_TOKEN persistRotatedToken: replay of current token, no-op');
return true;
}
if (String.isNotBlank(refreshToken)) cfg[0].HIC_ShopifySync__Refresh_Token__c = refreshToken;
if (String.isNotBlank(accessToken)) cfg[0].HIC_ShopifySync__Access_Token__c = accessToken;
update cfg;
System.debug(LoggingLevel.INFO, 'SME_TOKEN persistRotatedToken: stored'
+ ' refresh=' + String.isNotBlank(refreshToken)
+ ' access=' + String.isNotBlank(accessToken));
return true;
} catch (Exception e) {
System.debug(LoggingLevel.ERROR, 'SME_TOKEN persistRotatedToken FAILED — '
+ e.getTypeName() + ' | ' + e.getMessage() + ' | line ' + e.getLineNumber());
return false;
}
}
/**
* Normalises an inbound token value, and REJECTS poison values.
*
* String.valueOf(null) yields the four-character string 'null', and Apex/JS callers that build a body
* by concatenation (e.g. AuthenticateInternally) can post exactly that. A stored 'null' passes every
* blank check in the package: the keep-alive's String.isBlank() accepts it, and
* ShopifyController.refreshTokenCheck hands it back as if it were a live access token — so the org
* looks connected while every refresh fails. Treat these as absent.
*/
private static String blankToNull(Object o) {
if (o == null) return null;
String s = String.valueOf(o).trim();
if (String.isBlank(s)) return null;
if (s.equalsIgnoreCase('null') || s.equalsIgnoreCase('undefined')) return null;
return s;
}
// RestContext.response is absent in some contexts (e.g. direct Apex invocation from tests).
private static void setResponseStatus(Integer code) {
try {
if (RestContext.response != null) {
RestContext.response.statusCode = code;
}
} catch (Exception e) {
System.debug('setResponseStatus skipped: ' + e.getMessage());
}
}
global static void updateAccesstoken(String JSONResponseBody ){
System.debug('JSONResponseBody---'+JSONResponseBody);
Map<String, Object> meta = (Map<String, Object>) JSON.deserializeUntyped(JSONResponseBody);
if(!meta.isEmpty() && (meta.containskey('access_token') && meta.get('access_token') != null && meta.get('access_token') != '') &&
(meta.containskey('store_url') && meta.get('store_url') != null && meta.get('store_url') != '')){
String access_token = String.valueof(meta.get('access_token'));
System.debug('access_token--'+access_token);
String StoreURL = String.valueof(meta.get('store_url'));
String store = StoreURL.replace('https://','').replace('.myshopify.com','').remove('/').remove('"');
if(access_token != null && access_token != ''){
list<HIC_ShopifySync__HIC_Configuration__c> listofConfiguration = new list<HIC_ShopifySync__HIC_Configuration__c>();
/*String query = 'Select '+CheckAccessibility.isAccessible('id,name,HIC_ShopifySync__Batch_Is_Processing__c,HIC_ShopifySync__Specify_Close_Date__c, HIC_ShopifySync__Account_Contact__c, HIC_ShopifySync__Person_Account__c, HIC_ShopifySync__Default_Account__c, HIC_ShopifySync__Shopify_URL__c, HIC_ShopifySync__API_Key__c, HIC_ShopifySync__Private_Key_Password__c, HIC_ShopifySync__Opportunity__c, HIC_ShopifySync__Order__c, HIC_ShopifySync__Refresh_Token__c,HIC_ShopifySync__Opportunity_Line_Item__c, HIC_ShopifySync__Order_Line_Item__c, HIC_ShopifySync__Order_Fulfillment__c','HIC_ShopifySync__HIC_Configuration__c')+' from HIC_ShopifySync__HIC_Configuration__c where '+CheckAccessibility.isAccessible('name','HIC_ShopifySync__HIC_Configuration__c')+' = \''+store+'\' LIMIT 1';
System.debug('query--'+query);
listofConfiguration = database.query(query);
system.debug('***listofConfiguration ' +listofConfiguration);
if(listofConfiguration.isEmpty()) {*/
HIC_ShopifySync__HIC_Configuration__c config = new HIC_ShopifySync__HIC_Configuration__c();
config.Name = store;
config.HIC_ShopifySync__Store__c = store;
config.HIC_ShopifySync__Shopify_URL__c = 'https://'+store+'.myshopify.com';
config.HIC_ShopifySync__Private_Key_Password__c = access_token;
listofConfiguration.add(config);
/*}else{
listofConfiguration[0].HIC_ShopifySync__Private_Key_Password__c = access_token;
}*/
if(listofConfiguration != null && !listofConfiguration.isEmpty()){
try{
upsert listofConfiguration name;
}catch(Exception e){
System.debug('Error '+ e.getMessage());
}
}
}
}
}
// ═══════════════════════════════════════════════════════════════════════════
// Shopify OAuth callback (@HttpGet) — merged in from ShopifyOAuthCallbackRest
// so a Salesforce Site only needs guest Apex access to ONE class
// (AuthenticateAllApexClass) instead of three. Shopify redirects the merchant's
// browser here (GET) after they approve the Custom App; we validate the HMAC,
// exchange the code for an offline Admin API token, and save it to the store's
// HIC_Configuration record. The LWC polls getStoreAccessToken and fills the
// field the moment the token lands.
//
// Redirect URI: https://<site>/services/apexrest/HIC_ShopifySync/AuthenticateAllApexClass
// ═══════════════════════════════════════════════════════════════════════════
@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(buildOAuthPage(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(buildOAuthPage(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(buildOAuthPage(false,
'Invalid Request',
'The authorization state is incomplete.',
null));
return;
}
// --- Validate HMAC ------------------------------------------------
if (!String.isBlank(hmac) && !validateOAuthHmac(req.params, clientSecret, hmac)) {
res.statusCode = 400;
res.responseBody = Blob.valueOf(buildOAuthPage(false,
'Security Check Failed',
'The request signature could not be verified.',
null));
return;
}
// --- Ensure Remote Site Setting exists for this shop --------------
System.debug(LoggingLevel.INFO, 'SME_OAUTH_DBG HMAC ok/skipped. Ensuring Remote Site for https://' + shop);
ensureOAuthRemoteSite('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(buildOAuthPage(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(buildOAuthPage(false,
'No Token Received',
'Shopify did not return an access token. Please try again.',
null));
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 Admin API access token field and clicks
// Check Credential, which runs the existing validation/save path.
res.statusCode = 200;
res.responseBody = Blob.valueOf(buildOAuthTokenPage(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(buildOAuthPage(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, so the
// HIC_Configuration__c record is never created/modified by the guest user.
private static String buildOAuthTokenPage(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. Reads the Metadata-API session token
// in SYSTEM MODE (a direct SOQL) rather than ShopifyController.getCryptoKey():
// getCryptoKey strips fields via CheckAccessibility.isAccessible(), and 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 throws System.QueryException, which is why RSS creation silently failed
// in subscriber orgs.
private static void ensureOAuthRemoteSite(String shopUrl) {
System.debug(LoggingLevel.INFO, 'SME_OAUTH_DBG ensureRemoteSite START url=' + shopUrl
+ ' | UserType=' + UserInfo.getUserType()
+ ' | UserName=' + UserInfo.getUserName());
try {
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, the 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;
}
String accessToken = cfg[0].HIC_ShopifySync__Access_Token__c;
System.debug(LoggingLevel.INFO, 'SME_OAUTH_DBG Access_Token__c present, length=' + accessToken.length());
Boolean alreadyExists = ShopifyController.CheckingRemotesiteExistence(shopUrl, accessToken);
System.debug(LoggingLevel.INFO, 'SME_OAUTH_DBG CheckingRemotesiteExistence(' + shopUrl + ') = ' + alreadyExists);
if (alreadyExists) return;
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) {
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 validateOAuthHmac(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;
}
}
// HARDENED against the Refresh-Token-Rotation race (Salesforce case: two refreshes 24ms apart on the
// same token → RTR reuse-detection invalidates the whole family).
//
// This endpoint NO LONGER runs its own grant_type=refresh_token. That independent redemption was the
// second, unlocked refresher that raced TokenService. It now routes through TokenService — the single
// FOR UPDATE + single-flight authority — which:
// - refreshes the org's CURRENT stored refresh token (never an arbitrary/stale one passed in), and
// - serialises against every other in-org refresh, so two refreshes can never redeem concurrently,
// and a peer that already refreshed is reused instead of re-redeemed.
// We pass the pre-refresh access token as the "failed" token so single-flight reuses a peer's result
// rather than rotating again. Returns an OAuth-shaped body so existing callers keep parsing it.
private static String proxyRefreshToken(String requestBody) {
try {
List<HIC_ShopifySync__Configuration__c> before = [
SELECT HIC_ShopifySync__Access_Token__c, HIC_ShopifySync__Refresh_Token__c
FROM HIC_ShopifySync__Configuration__c WHERE Name = 'Default' LIMIT 1
];
String priorAccess = before.isEmpty() ? null : before[0].HIC_ShopifySync__Access_Token__c;
try {
TokenService.getFreshAccessToken(priorAccess, false); // serialized: refresh-if-current, reuse-if-peer-refreshed
} catch (Exception te) {
RestContext.response.statusCode = 400;
return '{"error":"invalid_grant","error_description":"'
+ String.valueOf(te.getMessage()).replace('"', '\\"') + '"}';
}
List<HIC_ShopifySync__Configuration__c> after = [
SELECT HIC_ShopifySync__Access_Token__c, HIC_ShopifySync__Refresh_Token__c
FROM HIC_ShopifySync__Configuration__c WHERE Name = 'Default' LIMIT 1
];
if (after.isEmpty() || String.isBlank(after[0].HIC_ShopifySync__Access_Token__c)) {
RestContext.response.statusCode = 400;
return '{"error":"invalid_grant","error_description":"no current token; reinitialize required"}';
}
RestContext.response.statusCode = 200;
RestContext.response.addHeader('Content-Type', 'application/json');
return JSON.serialize(new Map<String, Object>{
'access_token' => after[0].HIC_ShopifySync__Access_Token__c,
'refresh_token' => after[0].HIC_ShopifySync__Refresh_Token__c,
'instance_url' => URL.getOrgDomainUrl().toExternalForm(),
'token_type' => 'Bearer'
});
} catch (Exception e) {
System.debug('SME proxyRefreshToken error: ' + e.getMessage() + ' line ' + e.getLineNumber());
RestContext.response.statusCode = 500;
return '{"error":"proxy_error","error_description":"' + e.getMessage().replace('"', '\\"') + '"}';
}
}
// Build a self-contained HTML response page (no static resources needed).
private static String buildOAuthPage(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>';
}
}