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

277 lines
15 KiB
OpenEdge ABL

/**
* ─────────────────────────────────────────────────────────────────────────────────────────────────┐
* TokenService — the SINGLE, serialized authority for OAuth token refresh under the mandatory
* "Enable Refresh Token Rotation" security control.
*
* Rotation semantics: every grant_type=refresh_token exchange returns a NEW refresh_token and
* IMMEDIATELY invalidates the old one. If any path refreshes but fails to durably persist the new
* token, the org keeps a dead token and every future refresh returns 400 invalid_grant → all sync,
* store-add and setup break permanently until manual re-initialize.
*
* Guarantees provided here (do NOT re-implement refresh anywhere else):
* 1. SERIALIZE — locks the Default Configuration row with FOR UPDATE so only one refresh runs at a
* time; concurrent callers queue instead of racing the single shared token.
* 2. SINGLE-FLIGHT — re-reads inside the lock; if a peer already rotated (stored access token differs
* from the token the caller just failed on), REUSE it instead of rotating again.
* 3. FAIL-LOUD PERSIST — saves BOTH tokens with a bounded retry; if the save cannot be committed it
* THROWS. It never reports success after losing a rotated token.
* 4. NEVER WIPES THE REFRESH TOKEN — not even on invalid_grant. Under mandatory rotation the usual
* cause of invalid_grant is a DESYNC (an external writer rotated the token and its
* write-back to this org never landed), not a genuinely dead grant. Blanking the
* refresh token turns a self-healing desync into a MANDATORY manual re-initialize and
* destroys the only evidence of what the org was holding. Only the ACCESS token is
* invalidated, which makes every later caller re-attempt a refresh and therefore pick
* up a repaired refresh token automatically, with no human involvement.
* 5. NO CALLOUT AFTER DML — the token-save is the last durable action; callers must not call out after
* invoking a refresh that persists.
* ──────────────────────────────────────────────────────────────────────────────────────────────────
*/
public with sharing class TokenService {
// Thrown when the refresh token is genuinely dead (tokens cleared → setup shows Initialize).
public class ReauthRequiredException extends Exception {}
// Thrown on a transient failure (timeout / 5xx / lost response). No stored state was changed.
public class TransientRefreshException extends Exception {}
// Packaged Connected App credentials, used only when the Configuration has none stored.
private static final String PROD_CLIENT_ID = '3MVG9G9pzCUSkzZv4NsQV0kOphm0Il0eDJK5e0YicZLcCZKQcDHS0FORFMYHW8LzqJWPeMxzGUxeyshwEBOin';
private static final String PROD_CLIENT_SECRET = '2CF544F99E959C43977763BFBFA5999D4488E0C69757A99149D101EEB62E92B3';
private static final String SBX_CLIENT_ID = '3MVG9G9pzCUSkzZv4NsQV0kOphuwhNCDbV2xJ4wazQQkD_nyOa73sUwa0Z0DtHz1t5woA9d9AVNwcHvqbQy45';
private static final String SBX_CLIENT_SECRET = 'FE5D62CA3C108BF836DDD3DB01C6B063E66F28FFAC5EC3BF43BC6EB248466F4A';
/**
* Return a usable access token, refreshing (rotating) at most once, serialized on the Default row.
* @param failedAccessToken the access token the caller just had rejected (blank if it had none) —
* used for single-flight so a peer's fresh token is reused instead of rotating again.
* @param forceRefresh true for the keep-alive job (must exercise the refresh token to reset the
* idle TTL); false for runtime callers (reuse a peer's fresh token when possible).
* @throws ReauthRequiredException refresh token is dead (tokens cleared → Initialize button shows).
* @throws TransientRefreshException transient failure; stored tokens are untouched; safe to retry later.
*/
public static String getFreshAccessToken(String failedAccessToken, Boolean forceRefresh) {
HIC_ShopifySync__Configuration__c cfg = lockDefaultRow();
if (cfg == null) {
throw new ReauthRequiredException('No Default Configuration record');
}
String storedAccess = cfg.HIC_ShopifySync__Access_Token__c;
String storedRefresh = cfg.HIC_ShopifySync__Refresh_Token__c;
// SINGLE-FLIGHT: a peer rotated while we waited for the lock → reuse its token, don't re-rotate.
if (!forceRefresh && String.isNotBlank(storedAccess) && storedAccess != failedAccessToken) {
return storedAccess;
}
if (String.isBlank(storedRefresh)) {
throw new ReauthRequiredException('No refresh token stored');
}
// In tests we do not perform the live callout unless a mock is provided; a mock-driven test
// still exercises the full parse/persist path below.
HttpResponse res;
try {
res = rawRefresh(storedRefresh, resolveClientId(cfg), resolveClientSecret(cfg));
} catch (Exception calloutEx) {
// Lost-response window: SF may have rotated before the connection dropped. We cannot know,
// so change NOTHING and signal transient. (Reduced to ~twice a month by serialization.)
throw new TransientRefreshException('refresh callout failed: ' + calloutEx.getMessage());
}
Integer status = (res == null) ? -1 : res.getStatusCode();
if (status == 200) {
Map<String, Object> body;
try {
body = (Map<String, Object>) JSON.deserializeUntyped(res.getBody());
} catch (Exception parseEx) {
throw new TransientRefreshException('Unparseable 200 refresh body');
}
String newAccess = stringVal(body.get('access_token'));
String newRefresh = stringVal(body.get('refresh_token'));
if (String.isBlank(newAccess)) {
throw new TransientRefreshException('200 refresh without access_token');
}
// Rotation is mandatory: a 200 that omits refresh_token would leave us unable to prove what
// replaced the (now dead) old token. Do NOT touch the stored refresh token — treat as transient.
if (String.isBlank(newRefresh)) {
throw new TransientRefreshException('200 refresh without refresh_token (rotation expected)');
}
// FAIL-LOUD persist. Last durable action; no callout after this.
persistOrThrow(cfg.Id, newAccess, newRefresh);
return newAccess;
}
// Non-200. We hold the lock and sent the CURRENT stored refresh token (re-read under lock), so
// this token cannot be exchanged right now. That is NOT the same as "the token is dead and the
// customer must re-initialize", and we must not act as if it were:
//
// - Blanking the refresh token is IRREVERSIBLE. It destroys the only evidence of what the org
// was holding, which is exactly what makes this class of bug undiagnosable after the fact.
// - The row lock serializes callers inside this org but cannot serialize an external writer.
// The initialize hand-off (AuthenticateAllApexClass.persistRotatedToken, called by the
// packaging org's authenticator) can still install a working token afterwards — but only if
// we have not already forced the customer through a manual re-initialize.
// - Several genuinely recoverable conditions surface as invalid_grant; the ones we know of are
// excluded in NON_FATAL_GRANT_HINTS below, but that list will never be exhaustive.
//
// So: invalidate only the ACCESS token and surface ReauthRequired. Runtime and setup callers then
// always re-attempt a refresh, so the connection resumes by itself if the token becomes valid.
if (status == 400 && isDeadGrant(res)) {
invalidateAccessTokenOnly(cfg.Id);
throw new ReauthRequiredException('invalid_grant — refresh token could not be exchanged');
}
// Anything else (timeout→null, 5xx, other 400): transient. Never touch stored tokens.
throw new TransientRefreshException('refresh returned HTTP ' + status);
}
/**
* Best-effort trigger used by runtime paths (webhook / store-add) that must NOT rotate inline.
* Swallows the outcome (the point is only to refresh the shared token for the NEXT attempt).
* Returns true if a usable token is now stored, false if reauth is required / it failed.
*/
public static Boolean tryRefresh(String failedAccessToken) {
try {
String t = getFreshAccessToken(failedAccessToken, false);
return String.isNotBlank(t);
} catch (ReauthRequiredException e) {
return false; // dead token; setup will show Initialize
} catch (Exception e) {
return false; // transient; caller/Shopify will retry
}
}
// ── internals ───────────────────────────────────────────────────────────────────────────────
private static HIC_ShopifySync__Configuration__c lockDefaultRow() {
List<HIC_ShopifySync__Configuration__c> rows = [
SELECT Id,
HIC_ShopifySync__Access_Token__c,
HIC_ShopifySync__Refresh_Token__c,
HIC_ShopifySync__ClientId__c,
HIC_ShopifySync__ClientSecret__c
FROM HIC_ShopifySync__Configuration__c
WHERE Name = 'Default'
LIMIT 1
FOR UPDATE
];
return rows.isEmpty() ? null : rows[0];
}
private static HttpResponse rawRefresh(String refreshToken, String clientId, String clientSecret) {
HttpRequest req = new HttpRequest();
req.setMethod('POST');
req.setEndpoint(isSandbox()
? 'https://test.salesforce.com/services/oauth2/token'
: 'https://login.salesforce.com/services/oauth2/token');
req.setHeader('Content-Type', 'application/x-www-form-urlencoded');
req.setHeader('Accept', 'application/json');
req.setBody('grant_type=refresh_token'
+ '&client_id=' + EncodingUtil.urlEncode(clientId, 'UTF-8')
+ '&client_secret=' + EncodingUtil.urlEncode(clientSecret, 'UTF-8')
+ '&refresh_token=' + EncodingUtil.urlEncode(refreshToken, 'UTF-8'));
req.setTimeout(120000);
return new Http().send(req);
}
private static void persistOrThrow(Id configId, String accessToken, String refreshToken) {
Exception last;
for (Integer attempt = 0; attempt < 3; attempt++) {
try {
update new HIC_ShopifySync__Configuration__c(
Id = configId,
HIC_ShopifySync__Access_Token__c = accessToken,
HIC_ShopifySync__Refresh_Token__c = refreshToken
);
return;
} catch (Exception e) {
last = e;
}
}
// NEVER report success after losing a rotated token. The old token is already dead server-side,
// and the new one could not be saved, so the org needs re-initialization (the admin will see the
// Initialize button on the setup page — no notification is sent).
throw new ReauthRequiredException('Failed to persist rotated token: ' + (last == null ? '' : last.getMessage()));
}
/**
* Invalidate ONLY the access token. The refresh token is deliberately left untouched — see the
* invalid_grant branch in getFreshAccessToken. Blanking the access token is what forces every later
* caller down the refresh path, so the org self-heals as soon as a valid refresh token is present.
* Deliberately non-fatal: if this write fails the stored access token merely stays stale and the next
* caller retries, which is strictly better than aborting and leaving the caller with no signal.
*/
private static void invalidateAccessTokenOnly(Id configId) {
try {
update new HIC_ShopifySync__Configuration__c(
Id = configId,
HIC_ShopifySync__Access_Token__c = ''
);
} catch (Exception e) {
System.debug('TokenService.invalidateAccessTokenOnly failed: ' + e.getMessage());
}
}
// Salesforce returns error=invalid_grant for a range of conditions that do NOT mean the refresh
// token is dead. Treating any of these as death is what previously destroyed live tokens, so each
// one is explicitly excluded. When in doubt we return false (treat as transient and change nothing).
private static final List<String> NON_FATAL_GRANT_HINTS = new List<String>{
'ip restrict', // Connected App IP allowlist rejected this org's IP
'inactive user', // user deactivated — token itself is intact
'inactive org',
'locked out', // user temporarily locked
'password expired', // admin must reset the password; the grant is unaffected
'invalid client', // client_id / client_secret mismatch = config problem, not a dead token
'invalid_client',
'rate limit',
'temporarily unavailable'
};
private static Boolean isDeadGrant(HttpResponse res) {
try {
Map<String, Object> b = (Map<String, Object>) JSON.deserializeUntyped(res.getBody());
String err = stringVal(b.get('error'));
String errDesc = stringVal(b.get('error_description'));
if (!'invalid_grant'.equalsIgnoreCase(err) && !'expired_token'.equalsIgnoreCase(err)) {
return false;
}
if (String.isNotBlank(errDesc)) {
for (String nonFatalHint : NON_FATAL_GRANT_HINTS) {
if (errDesc.containsIgnoreCase(nonFatalHint)) {
return false;
}
}
}
return true;
} catch (Exception e) {
return false;
}
}
private static String resolveClientId(HIC_ShopifySync__Configuration__c cfg) {
if (cfg != null && String.isNotBlank(cfg.HIC_ShopifySync__ClientId__c)) {
return cfg.HIC_ShopifySync__ClientId__c;
}
return isSandbox() ? SBX_CLIENT_ID : PROD_CLIENT_ID;
}
private static String resolveClientSecret(HIC_ShopifySync__Configuration__c cfg) {
if (cfg != null && String.isNotBlank(cfg.HIC_ShopifySync__ClientSecret__c)) {
return cfg.HIC_ShopifySync__ClientSecret__c;
}
return isSandbox() ? SBX_CLIENT_SECRET : PROD_CLIENT_SECRET;
}
private static Boolean isSandbox() {
if (!Schema.sObjectType.Organization.isAccessible()
|| !Schema.sObjectType.Organization.fields.IsSandbox.isAccessible()) {
return false;
}
return [SELECT IsSandbox FROM Organization LIMIT 1].IsSandbox;
}
private static String stringVal(Object o) {
return (o == null) ? null : String.valueOf(o);
}
}