Skip to content

fix(salesforce): persist rotated refresh tokens and route jsforce refreshes through core - #101

Merged
d-klotz merged 3 commits into
nextfrom
fix/salesforce-rotated-refresh-token
Sep 17, 2026
Merged

d-klotz merged 3 commits into
nextfrom
fix/salesforce-rotated-refresh-token

Conversation

@d-klotz

@d-klotz d-klotz commented Sep 17, 2026 •

Copy link
Copy Markdown
Contributor

Problem

With refresh-token rotation enabled on the connected app, Salesforce returns a new refresh_token on every refresh and invalidates the old one. jsforce's default oauthRefreshFn never updates conn.refreshToken (connection.js _establish keeps the old value), so api.js persisted this.conn.refreshToken — the consumed token — over the good one on every refresh.

Presenting a rotated-out token later trips Salesforce's reuse detection, which revokes the current refresh token and every access token in the family.

Observed in production on an integration syncing into a Salesforce org with rotation enabled: four concurrent Lambda workers overwrote a long-running process's correct write, then 765 refreshes failed with Unable to refresh session due to: expired access/refresh token. The credential stayed authIsValid: true and the integration stayed ENABLED — no signal, no recovery.

Fix

Hand jsforce a custom refreshFn that delegates to core's _refreshAuthOnce(). Core (frigg#636, now in @friggframework/core@2.0.0-next.110) supplies adopt-before-refresh, definitive-vs-transport classification, adopt-after-invalid_grant with backoff, INVALID_AUTH, telemetry and single-flight. The module adds only:

  • _buildConnection() — one place that constructs the jsforce Connection, with the refreshFn. resetToSandbox() previously built its connection without any refresh listener, so sandbox connections never persisted a refresh at all.
  • _adoptNewerCredential() — mirrors adopted tokens onto conn.
  • refreshAccessToken() — accepts both core's { refresh_token } and a token response the caller already obtained (the call shape of a long-running consumer that refreshes outside jsforce). Persists res.refresh_token. Leaves instanceUrl alone on purpose: getCredentialDetails keys the credential upsert on it, and moving it would create a second Credential row holding the rotated token.
  • _normalizeTokenError() — jsforce reports the OAuth code in err.name with no HTTP status; core classifies on statusCode first. ERROR_HTTP_5xx → that status (transport). invalid_grant, invalid_client, invalid_client_id, invalid_app_access, inactive_user, inactive_org → 400 (definitive).
  • _refreshRejected memo — after a definitive rejection, later 401s only re-read the store. Without it every 401 on a warm instance repeats the full cycle (4 DB reads, 1 token POST, up to 3 s backoff, 3 writes) and re-fires CREDENTIAL_INVALIDATED. The dead token is never replayed, and the instance recovers by itself the moment another process writes a fresh credential.

The refresh event handler is deleted: a custom refreshFn still emits 'refresh', and the old handler was fire-and-forget and logged the live access token to stdout.

Dependency

@friggframework/core: exact 2.0.0-next.79 → ^2.0.0-next.110, matching the other v1-ready modules. Every primitive above is absent in next.79.

Semver note: ^2.0.0-next.110 does not match the 2.0.0--canary.636.* builds (the leading -canary identifier sorts below next). A consumer still on a canary gets a nested next.110 copy locally; the deploy bundle prunes nested node_modules and the files are byte-identical.

Tests

npm run test:unit — 45 passing (13 existing + 20 new). New coverage, one behaviour per it:

  • rotated refresh token persisted; token handed to jsforce only after the credential write is awaited; no-rotation preserves the stored token
  • adopt a newer stored credential instead of hitting the token endpoint; adopt after invalid_grant when another worker wrote a newer token
  • invalid_grant with nothing newer → one INVALID_AUTH; invalid_client_id → definitive with statusCode: 400; ERROR_HTTP_503 → transport, no invalidation
  • rejection memo: later refreshes re-read once, never re-POST, recover on a newer store
  • resetToSandbox keeps the refresh hook
  • core refreshAuth() refreshes through jsforce; refreshAccessToken(res) persist-only path; instanceUrl never moves on refresh
  • no tokens in console output on success or rejection
  • a tokenless refreshAccessToken({}) throws before any POST; a transport failure leaves the rejection memo disarmed; when the credential write fails the rotated pair stays in memory and an error is logged
  • a re-authorization on the same instance clears the rejection memo (both getAccessToken and refreshAccessToken(res)); a throwing credential reload while the memo is set reaches jsforce's callback

Fixture: baseParams now carries scope, as definition.env does at runtime. The two getAuthorizationUri scope assertions were already failing on next before this branch.

Prettier: only the file's pre-existing long lines remain non-compliant; nothing outside this change was rewrapped.

After review

Second commit addresses an Opus review of the diff:

  • refreshAccessToken() with neither token now throws before the request — previously it POSTed an empty refresh_token, got invalid_grant, and would have marked a healthy credential invalid.
  • A failed credential write after a successful token POST now logs at error level (rotated refresh token was not persisted; the stored token is now consumed) before rethrowing. The in-memory ordering was already right; it was silent.
  • _refreshRejected initialised in the constructor.
  • Dropped the refresh-event subscription test as implementation-coupled; its intent is covered by the one-TOKEN_UPDATE-per-refresh and no-tokens-in-console tests.

Kept deliberately: invalid_client_id / inactive_* are treated as definitive (extends core's invalid_client policy); a missing client_id now surfaces at the first 401 instead of at construction.

Third commit addresses Codex's two inline findings on 97581e8:

  • P1 — a re-authorization on the same instance (getAccessToken, or the persist-only refreshAccessToken(res)) left the memo armed, so the fresh refresh token was never submitted and the instance stayed read-only until recreated. Every successful setTokens() now clears the memo.
  • P2 — the memo branch now shares _refreshAuthOnce's try/catch, so a throwing credential reload reaches jsforce's callback instead of leaving the refresh promise unsettled.

Rollout

Credentials already holding a consumed token (there is at least one) will, on first refresh after deploy, go invalid_grant → backoff re-read → INVALID_AUTH → integration ERROR. That is the intended change from today's silent forever-failure. Re-authorizing through Frigg's POST /api/authorize restores ENABLED via ProcessAuthorizationCallback.restoreIntegrationsForEntity.

Follow-ups (not in this PR)

  1. Consumers: bump @friggframework/core to ^2.0.0-next.110.
  2. Consumers that refresh outside jsforce (for example a long-running CDC listener calling conn.oauth2.refreshToken directly) can switch to api.refreshAuth() — one refresh implementation.
  3. Core: cross-process refresh lock (advisory lock on the Credential row); retry the persist in Module.onTokenUpdate; extend _isDefinitiveAuthRejection's regex to the codes above.

🤖 Generated with Claude Code

Version

Published prerelease version: @friggframework/api-module-salesforce@2.0.0-next.9

Changelog

🐛 Bug Fix

  • @friggframework/api-module-salesforce
    • fix(salesforce): persist rotated refresh tokens and route jsforce refreshes through core #101 (@d-klotz)
  • @friggframework/api-module-microsoft-teams, @friggframework/api-module-slack, @friggframework/api-module-42matters, @friggframework/api-module-asana, @friggframework/api-module-attio, @friggframework/api-module-clio, @friggframework/api-module-connectwise, @friggframework/api-module-contentful, @friggframework/api-module-contentstack, @friggframework/api-module-crossbeam, @friggframework/api-module-deel, @friggframework/api-module-fathom, @friggframework/api-module-fireflies, @friggframework/api-module-frigg-scale-test, @friggframework/api-module-frontify, @friggframework/api-module-gong, @friggframework/api-module-google-calendar, @friggframework/api-module-google-drive, @friggframework/api-module-helpscout, @friggframework/api-module-hubspot, @friggframework/api-module-ironclad, @friggframework/api-module-linear, @friggframework/api-module-otter, @friggframework/api-module-pipedrive, @friggframework/api-module-quo, @friggframework/api-module-reevo, @friggframework/api-module-salesforce, @friggframework/api-module-stripe, @friggframework/api-module-unbabel-projects, @friggframework/api-module-unbabel, @friggframework/api-module-zoho-crm, @friggframework/api-module-zoom
    • fix(salesforce): preserve the caller's OAuth state alongside the PKCE verifier #100 (@d-klotz)

Authors: 1

…reshes through core

With refresh-token rotation enabled on the connected app, Salesforce returns a
new refresh_token on every refresh and invalidates the old one. jsforce's default
oauthRefreshFn never updates conn.refreshToken (connection.js _establish keeps
the old value), and the module persisted this.conn.refreshToken — the consumed
token — over the good one. Presenting a rotated-out token later trips
Salesforce's reuse detection, which revokes the current token and every access
token in the family. Observed 2026-09-16: four worker Lambdas overwrote the
daemon's correct write, then 765 refreshes failed with "expired access/refresh
token" while the credential stayed authIsValid=true.

Hand jsforce a custom refreshFn that delegates to core's _refreshAuthOnce().
Core (frigg#636, @friggframework/core 2.0.0-next.110) supplies
adopt-before-refresh, definitive-vs-transport classification,
adopt-after-invalid_grant with backoff, INVALID_AUTH, telemetry and
single-flight. The module adds only:

- _buildConnection(): the one place that constructs the jsforce Connection,
  with the refreshFn. resetToSandbox() previously built its connection without
  any refresh listener, so sandbox connections never persisted a refresh.
- _adoptNewerCredential(): mirrors adopted tokens onto conn.
- refreshAccessToken(): accepts both core's { refresh_token } and a token
  response the caller already obtained (the CDC daemon's call shape). Persists
  res.refresh_token. Leaves instanceUrl alone on purpose: getCredentialDetails
  keys the credential upsert on it, and moving it would fork the Credential row.
- _normalizeTokenError(): jsforce reports the OAuth code in err.name with no
  HTTP status; core classifies on statusCode first. ERROR_HTTP_5xx keeps that
  status (transport); invalid_grant, invalid_client, invalid_client_id,
  invalid_app_access, inactive_user and inactive_org become 400 (definitive).
- _refreshRejected memo: after a definitive rejection, later 401s only re-read
  the store. Without it every 401 on a warm instance repeats the full cycle
  (4 reads, 1 POST, 3 s backoff, 3 writes) and re-fires CREDENTIAL_INVALIDATED.
  The dead token is never replayed.

The refresh event handler is deleted: a custom refreshFn still emits 'refresh',
and the old handler was fire-and-forget and logged the live access token.

Bump @friggframework/core from the exact 2.0.0-next.79 pin to ^2.0.0-next.110,
matching the other v1-ready modules. Every primitive above is absent in next.79.

Test fixture: baseParams now carries scope, as definition.env does at runtime.
The two getAuthorizationUri scope assertions were already failing on next.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 17, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-09-17T11:40:55.132529Z 97581e8 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 97581e860a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +221 to +223
if (!refreshed) {
this._refreshRejected = true;
return callback(new Error('Salesforce rejected the refresh token'));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Clear the rejection memo when fresh credentials are installed

After a definitive rejection sets _refreshRejected, the same API instance can later receive valid credentials through getAccessToken() during reauthorization or through the public persist-only refreshAccessToken(response) path, but neither clears this flag. When that new access token eventually expires, this branch only reloads the credential; because the stored refresh token now equals the in-memory token, adoption returns false and the valid refresh token is never submitted to Salesforce, leaving the reauthorized connection unable to refresh until the API object is recreated.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 46601ad: every successful setTokens() now clears _refreshRejected, so a re-authorization via getAccessToken() or the persist-only refreshAccessToken(response) path submits the fresh refresh token on the next 401. Covered by two tests (one per entry point).

Comment thread packages/v1-ready/salesforce/api.js Outdated
Comment on lines +206 to +208
if (this._refreshRejected) {
if (!(await this._adoptNewerCredential())) {
return callback(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Report credential reload failures through the callback

When _refreshRejected is set and the credential reload throws—for example, during a transient database failure—the exception escapes this async callback adapter before callback is invoked. jsforce's callback-based refresh remains unsettled, so the originating API request hangs until an outer timeout and may also produce an unhandled rejection; wrap this reload path in the same error-to-callback handling used for _refreshAuthOnce().

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 46601ad: both branches of _jsforceRefreshFn now share one try/catch, so a throwing credential reload reaches jsforce's callback instead of leaving the refresh promise unsettled. Test drives a rejecting reload while the memo is set and asserts the callback receives the error.

…rsist

Review follow-ups on the rotated-refresh-token change.

- refreshAccessToken() with neither access_token nor refresh_token used to
  POST an empty refresh_token; Salesforce answers invalid_grant, which the
  normaliser classifies as definitive and core turns into INVALID_AUTH. A
  caller passing a partial response could brick a healthy credential. It now
  throws before the request.
- When the token POST succeeds but the credential write fails, the rotated
  pair stays in memory on purpose (replaying the consumed token revokes the
  family), but the stored token is now consumed and nothing said so. Log it at
  error level before rethrowing.
- Initialise _refreshRejected in the constructor.
- Tests: a transport failure must not arm the rejection memo (the 503 test now
  refreshes a second time and expects a token POST); the tokenless call throws
  without a POST or an INVALID_AUTH; the persist-failure ordering and its log.
  Dropped the "does not subscribe to the jsforce refresh event" test: it pinned
  a mechanism, not a behaviour. One TOKEN_UPDATE per refresh and no tokens in
  the console already cover what it stood for.

Left as-is, deliberately: invalid_client_id / inactive_* are classified
definitive (extends core's existing invalid_client policy); a missing client_id
now surfaces on the first 401 rather than at construction.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@d-klotz

d-klotz commented Sep 17, 2026

Copy link
Copy Markdown
Contributor Author

Pushed a second commit after an Opus review of the diff (97581e8 → HEAD):

  • refreshAccessToken({}) — neither token — now throws before any POST. Previously it sent an empty refresh_token, got invalid_grant, and would have invalidated a healthy credential.
  • A failed credential write after a successful token POST is now logged at error level before rethrowing (the rotated pair correctly stays in memory; the stored token is consumed — that state was invisible).
  • _refreshRejected initialised in the constructor.
  • Tests: transport failure must not arm the memo; tokenless call throws without POST/INVALID_AUTH; persist-failure ordering + log. Removed the refresh-event subscription test as mechanism-coupled.

npm run test:unit: 42 passing. Prettier deltas remain only on pre-existing lines.

Two Codex findings on the memo introduced in 97581e8.

- After a definitive rejection armed _refreshRejected, a re-authorization on
  the same Api instance — getAccessToken(), or the persist-only
  refreshAccessToken(response) shape — installed fresh tokens but left the
  memo set. On the next 401 the instance only re-read the store; the stored
  refresh token equalled the in-memory one, adoption returned false, and the
  perfectly valid token was never submitted. The instance stayed read-only
  until recreated. Every successful setTokens() now clears the memo.
- The memo branch called _adoptNewerCredential() outside the try/catch that
  guards _refreshAuthOnce(). A throwing reload escaped the async refreshFn
  adapter, leaving jsforce's refresh promise unsettled and the originating
  request hanging. Both branches now share one try/catch.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@d-klotz

d-klotz commented Sep 17, 2026

Copy link
Copy Markdown
Contributor Author

Third commit (46601ad) addresses Codex's two inline findings — the rejection memo is cleared by any successful setTokens(), and the memo branch shares the _refreshAuthOnce try/catch. npm run test:unit: 45 passing. PR description updated; client-specific context replaced with a generic description.

@d-klotz
d-klotz merged commit 48ea864 into next Sep 17, 2026
4 checks passed
@d-klotz
d-klotz deleted the fix/salesforce-rotated-refresh-token branch September 17, 2026 14:12
@seanspeaks

Copy link
Copy Markdown
Contributor

🚀 PR was released in @friggframework/api-module-salesforce@2.0.0-next.9 🚀

@seanspeaks seanspeaks added the prerelease This change is available in a prerelease. label Sep 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

prerelease This change is available in a prerelease. release

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants