/** * ─────────────────────────────────────────────────────────────────────────────────────────────────┐ * 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 body; try { body = (Map) 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 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 NON_FATAL_GRANT_HINTS = new List{ '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 b = (Map) 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); } }