@salesforce/core 9.1.11 → 9.1.12-qa.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -321,6 +321,72 @@ export declare class AuthInfo extends AsyncOptionalCreatable<AuthInfo.Options> {
321
321
  private loadDecryptedAuthFromConfig;
322
322
  private isTokenOptions;
323
323
  private refreshFn;
324
+ /**
325
+ * Single-flight a refresh-token refresh so two connections/processes can't rotate the same token at once.
326
+ *
327
+ * With Refresh Token Rotation (RTR) enabled, each refresh returns a new refresh token and invalidates the
328
+ * previous one; a second refresh sent with the now-stale refresh token fails and invalidates the current token too.
329
+ * We take a cross-process lock (which also serializes same-process contenders), then re-read the latest
330
+ * tokens from disk: if another actor already rotated while we waited, we adopt their fresh credentials
331
+ * instead of refreshing again with our now-invalid token.
332
+ *
333
+ * Each pass is one call to `tryRotateOrAdopt`, which (1) adopts if disk already holds a rotated token, else
334
+ * (2) tries to acquire the lock and, once held, re-checks-then-refreshes. If we can't acquire (a live holder
335
+ * is mid-rotation, ELOCKED), we do NOT fall back to an unlocked refresh, which would race the holder and double-rotate
336
+ * under RTR; we simply loop back to (1) and re-run the pass (re-read disk, then try to acquire the lock again).
337
+ * `proper-lockfile` only grants the lock on genuine release or genuine staleness (a live
338
+ * holder refreshes its lock mtime ~every 5s and so is never stolen from), so looping converges: a slow holder is waited out,
339
+ * a dead holder's lock crosses the ~10s stale line and is stolen on a later attempt.
340
+ * Only a holder that keeps the lock alive for the whole budget below yields the timeout error.
341
+ *
342
+ * The lock uses a dedicated `<authfile>.token-rotation.lock`, kept separate from ConfigFile's
343
+ * own `<authfile>.lock` write lock, because proper-lockfile is not re-entrant: holding the auth-file lock
344
+ * here and then letting save() re-acquire the same path would self-block.
345
+ *
346
+ * KNOWN LIMITATIONS (all inherent, not bugs):
347
+ *
348
+ * (1) Only actors that take THIS lock are serialized. Within a single CLI release this is a non-issue: the
349
+ * CLI deduplicates @salesforce/core to one version, so every in-process refresher runs this same locking
350
+ * code (and the lock path is derived deterministically, so differing versions that BOTH lock still
351
+ * interoperate). The realistic gap is a 2nd/3rd-party plugin: those install under the CLI's plugin data dir
352
+ * with their OWN non-deduped node_modules and may bundle a core version predating this lock. Such a plugin
353
+ * (or any external tool that refreshes the token directly) won't honor `<authfile>.token-rotation.lock` and
354
+ * can still double-rotate the same auth while we hold it. There is no server-side coordination to prevent this.
355
+ *
356
+ * (2) On the web runtime (Global.isWeb), lockInit is a no-op that takes no lock (matching existing
357
+ * ConfigFile behavior), so nothing is serialized there.
358
+ *
359
+ * (3) A genuinely-stuck holder makes this block for up to the budget below plus one in-flight lock cycle
360
+ * (~40s) before throwing. Because that happens inside a live jsforce session-refresh, an upstream client
361
+ * with a shorter timeout may give up first with a less specific error. Only the pathological stuck case
362
+ * pays this; the common paths return in one lock cycle or via the lock-free adopt.
363
+ *
364
+ * @param heldRefreshToken the refresh token this connection currently holds (read from the auth file); the
365
+ * baseline we compare against disk to detect a rotation, and the token we would send if we do rotate.
366
+ */
367
+ private refreshWithTokenRotationLock;
368
+ /**
369
+ * One rotation attempt: adopt an already-rotated token if disk has one, else take the lock and
370
+ * double-check-then-rotate under it.
371
+ *
372
+ * @returns `true` if we adopted or refreshed (caller is done); `false` if the lock was contended
373
+ * (`ELOCKED`) and the caller should re-attempt. Throws on any non-`ELOCKED` failure.
374
+ */
375
+ private tryRotateOrAdopt;
376
+ /**
377
+ * Re-read the on-disk auth (without disturbing this instance's in-memory fields) and, if another
378
+ * connection/process has already rotated the refresh token, adopt those fresh credentials. Disk is
379
+ * already current in that case, so no save is needed.
380
+ *
381
+ * NOTE: this gates on the refresh token only, not access-token freshness (we don't persist access-token
382
+ * expiry). If the adopted access token has since expired, jsforce gets a 401 and re-enters refreshFn,
383
+ * which then rotates under the lock (our refresh token now matches disk, so the double-check falls through
384
+ * to a real refresh). That is one extra round trip in a narrow case, and still strictly better than the
385
+ * pre-adopt behavior, where refreshing with our rotated-out token would have failed outright.
386
+ *
387
+ * @returns true if a rotated token was adopted; false if the on-disk token still matches ours.
388
+ */
389
+ private adoptIfAlreadyRotated;
324
390
  private readJwtKey;
325
391
  private authJwt;
326
392
  private tryJwtAuth;
@@ -72,12 +72,13 @@ const filters_1 = require("../logger/filters");
72
72
  const messages_1 = require("../messages");
73
73
  const sfdcUrl_1 = require("../util/sfdcUrl");
74
74
  const findSuggestion_1 = require("../util/findSuggestion");
75
+ const fileLocking_1 = require("../util/fileLocking");
75
76
  const connection_1 = require("./connection");
76
77
  const determineOrg_1 = require("./determineOrg");
77
78
  const org_1 = require("./org");
78
79
  const orgConfigProperties_1 = require("./orgConfigProperties");
79
80
  ;
80
- const messages = new messages_1.Messages('@salesforce/core', 'core', new Map([["authInfoCreationError", "Must pass a username and/or OAuth options when creating an AuthInfo instance."], ["authInfoOverwriteError", "Cannot create an AuthInfo instance that will overwrite existing auth data."], ["authInfoOverwriteError.actions", ["Create the AuthInfo instance using existing auth data by just passing the username. E.g., `AuthInfo.create({ username: 'my@user.org' });`."]], ["authCodeExchangeError", "Error authenticating with auth code due to: %s"], ["authCodeUsernameRetrievalError", "Could not retrieve the username after successful auth code exchange.\n\nDue to: %s"], ["jwtAuthError", "Error authenticating with JWT config due to: %s"], ["jwtAuthErrors", "Error authenticating with JWT.\nErrors encountered:\n%s"], ["refreshTokenAuthError", "Error authenticating with the refresh token due to: %s"], ["invalidSfdxAuthUrlError", "Invalid SFDX authorization URL. Must be in the format \"force://<clientId>:<clientSecret>:<refreshToken>@<instanceUrl>\". Note that the \"instanceUrl\" inside the SFDX authorization URL doesn\\'t include the protocol (\"https://\"). Run \"org display --target-org\" on an org to see an example of an SFDX authorization URL."], ["orgDataNotAvailableError", "An attempt to refresh the authentication token failed with a 'Data Not Found Error'. The org identified by username %s does not appear to exist. Likely cause is that the org was deleted by another user or has expired."], ["orgDataNotAvailableError.actions", ["Run `sfdx force:org:list --clean` to remove stale org authentications.", "Use `sfdx force:config:set` to update the defaultusername.", "Use `sfdx force:org:create` to create a new org.", "Use `sfdx auth` to authenticate an existing org."]], ["namedOrgNotFound", "No authorization information found for %s."], ["noAliasesFound", "Nothing to set."], ["invalidFormat", "Setting aliases must be in the format <key>=<value> but found: [%s]."], ["invalidJsonCasing", "All JSON input must have heads down camelcase keys. E.g., `{ sfdcLoginUrl: \"https://login.salesforce.com\" }`\nFound \"%s\" at %s"], ["missingClientId", "Client ID is required for JWT authentication."]]));
81
+ const messages = new messages_1.Messages('@salesforce/core', 'core', new Map([["authInfoCreationError", "Must pass a username and/or OAuth options when creating an AuthInfo instance."], ["authInfoOverwriteError", "Cannot create an AuthInfo instance that will overwrite existing auth data."], ["authInfoOverwriteError.actions", ["Create the AuthInfo instance using existing auth data by just passing the username. E.g., `AuthInfo.create({ username: 'my@user.org' });`."]], ["authCodeExchangeError", "Error authenticating with auth code due to: %s"], ["authCodeUsernameRetrievalError", "Could not retrieve the username after successful auth code exchange.\n\nDue to: %s"], ["jwtAuthError", "Error authenticating with JWT config due to: %s"], ["jwtAuthErrors", "Error authenticating with JWT.\nErrors encountered:\n%s"], ["refreshTokenAuthError", "Error authenticating with the refresh token due to: %s"], ["refreshTokenAuthError.actions", ["The stored refresh token is no longer valid (it may have expired, been revoked, or been rotated). Re-authenticate to the org, then try again."]], ["refreshTokenRotationTimeoutError", "Timed out waiting for another process to finish rotating the refresh token for %s. Another process held the rotation lock for the entire wait without completing. Try again. If it persists, another process is likely stuck mid-refresh: stop any other processes using this org's authentication, then try again."], ["invalidSfdxAuthUrlError", "Invalid SFDX authorization URL. Must be in the format \"force://<clientId>:<clientSecret>:<refreshToken>@<instanceUrl>\". Note that the \"instanceUrl\" inside the SFDX authorization URL doesn\\'t include the protocol (\"https://\"). Run \"org display --target-org\" on an org to see an example of an SFDX authorization URL."], ["orgDataNotAvailableError", "An attempt to refresh the authentication token failed with a 'Data Not Found Error'. The org identified by username %s does not appear to exist. Likely cause is that the org was deleted by another user or has expired."], ["orgDataNotAvailableError.actions", ["Run `sfdx force:org:list --clean` to remove stale org authentications.", "Use `sfdx force:config:set` to update the defaultusername.", "Use `sfdx force:org:create` to create a new org.", "Use `sfdx auth` to authenticate an existing org."]], ["namedOrgNotFound", "No authorization information found for %s."], ["noAliasesFound", "Nothing to set."], ["invalidFormat", "Setting aliases must be in the format <key>=<value> but found: [%s]."], ["invalidJsonCasing", "All JSON input must have heads down camelcase keys. E.g., `{ sfdcLoginUrl: \"https://login.salesforce.com\" }`\nFound \"%s\" at %s"], ["missingClientId", "Client ID is required for JWT authentication."]]));
81
82
  // parses the id field returned from jsForce oauth2 methods to get
82
83
  // user ID and org ID.
83
84
  function parseIdUrl(idUrl) {
@@ -608,7 +609,10 @@ class AuthInfo extends kit_1.AsyncOptionalCreatable {
608
609
  accessToken: (0, ts_types_1.ensureString)(authFields.accessToken),
609
610
  clientId: decryptedApp.clientId,
610
611
  clientSecret: decryptedApp.clientSecret,
611
- refreshToken: decryptedApp.refreshToken,
612
+ // Persist the server-returned refresh token (rotated under RTR), falling back to the one we
613
+ // sent when the response omits it (RTR off). Persisting the old token here would send an
614
+ // invalidated credential on the next refresh once RTR is enabled on this connected app.
615
+ refreshToken: (0, ts_types_1.ensureString)(authFields.refreshToken ?? decryptedApp.refreshToken),
612
616
  oauthFlow: 'web',
613
617
  },
614
618
  },
@@ -834,12 +838,26 @@ class AuthInfo extends kit_1.AsyncOptionalCreatable {
834
838
  if (!options.privateKey && options.privateKeyFile) {
835
839
  options.privateKey = (0, node_path_1.resolve)(options.privateKeyFile);
836
840
  }
837
- if (options.privateKey) {
841
+ // Route to JWT only when there's a privateKey AND no refreshToken. A legitimate JWT auth never
842
+ // carries a refreshToken (authJwt writes none, and `login jwt` deletes-then-recreates the auth file).
843
+ // The edge case that contains BOTH a privateKey AND a refreshToken is when auth was originally JWT
844
+ // and later switched to Web auth. Web auth does not delete the existing auth file, so `AuthInfo.update`
845
+ // merges over the old file. In that case the refreshToken is the intended credential, so fall
846
+ // through to the refresh-token flow instead of misrouting into JWT, which also matches isJwt().
847
+ if (options.privateKey && !options.refreshToken) {
838
848
  authConfig = await this.authJwt(options);
839
849
  }
840
850
  else if (!options.authCode && options.refreshToken) {
841
- // refresh token flow (from sfdxUrl or OAuth refreshFn)
851
+ // refresh token flow (from sfdxUrl or OAuth refreshFn).
852
+ // RTR: this POST rotates the token server-side and invalidates the old one immediately. Persist the
853
+ // rotated token right here, before the enrichment steps below (determineIfDevHub, orgs.read,
854
+ // update/encrypt, determineOrg) run. A throw anywhere in that window would otherwise strand the
855
+ // rotated token in memory while the old one is already dead server-side, permanently breaking the
856
+ // auth until re-login. Saving now closes that window; enrichment and the caller's save() still run
857
+ // and layer the org metadata on top of the already-persisted token.
842
858
  authConfig = await this.buildRefreshTokenConfig(options);
859
+ this.update(authConfig);
860
+ await this.save();
843
861
  }
844
862
  else if (this.options.oauth2 instanceof jsforce_node_1.OAuth2) {
845
863
  // authcode exchange / web auth flow
@@ -854,6 +872,15 @@ class AuthInfo extends kit_1.AsyncOptionalCreatable {
854
872
  await this.stateAggregator.orgs.read(authConfig.username, false, false);
855
873
  // Update the auth fields WITH encryption
856
874
  this.update(authConfig);
875
+ // A web/auth-code or refresh-token authorization is never a JWT one, so it must not carry a
876
+ // privateKey. When this flow overwrites an existing auth file (e.g. the user was JWT-authed for
877
+ // this org, then re-authed via web), the save path merges (Object.assign) over the existing
878
+ // file and would otherwise retain the stale privateKey, which later misroutes refreshFn into
879
+ // the JWT flow. Only clear it when a stale value actually lingers so we don't add an empty key
880
+ // to a fresh authorization.
881
+ if (!authConfig.privateKey && this.getFields().privateKey) {
882
+ this.stateAggregator.orgs.update(this.username, { privateKey: undefined });
883
+ }
857
884
  // Populate Organization metadata (orgEdition, isScratch, isSandbox, etc.) in a single query.
858
885
  await (0, determineOrg_1.determineOrg)(this);
859
886
  }
@@ -883,10 +910,27 @@ class AuthInfo extends kit_1.AsyncOptionalCreatable {
883
910
  this.logger.info('Access token has expired. Updating...');
884
911
  try {
885
912
  const fields = this.getFields(true);
886
- // This method will request the new access token and save to the current AuthInfo instance (but don't persist them!).
887
- await this.initAuthOptions(fields);
888
- // Persist fields with refreshed access token to auth file.
889
- await this.save();
913
+ // JWT auth mints a fresh access token from a locally-signed assertion and consumes no stored credential,
914
+ // so concurrent JWT refreshes are independent and safe. The refresh-token flow is different: every caller
915
+ // refreshes by re-sending the *same persisted refresh token* read from the auth file, and with Refresh
916
+ // Token Rotation enabled the server returns a new refresh token and invalidates the old one *immediately*.
917
+ // So two connections/processes refreshing at once would both send that on-disk token and double-rotate the
918
+ // auth. Serialize just that flow.
919
+ //
920
+ // Gate on `refreshToken` alone (not `refreshToken && !privateKey`): a legitimate JWT auth never carries
921
+ // a refresh token, so this still excludes JWT, and it also covers the fixed edge case where both-fields
922
+ // exist from an original JWT login and switching to web (stale privateKey + real refreshToken), which
923
+ // initAuthOptions routes through the refresh-token flow and which therefore must take the lock too.
924
+ // See the router in initAuthOptions and isJwt().
925
+ if (fields.refreshToken) {
926
+ await this.refreshWithTokenRotationLock(fields.refreshToken);
927
+ }
928
+ else {
929
+ // This method will request the new access token and save to the current AuthInfo instance (but don't persist them!).
930
+ await this.initAuthOptions(fields);
931
+ // Persist fields with refreshed access token to auth file.
932
+ await this.save();
933
+ }
890
934
  // Pass new access token to the jsforce's session-refresh callback for proper propagation:
891
935
  // https://jsforce.github.io/jsforce/types/session_refresh_delegate.SessionRefreshFunc.html
892
936
  const { accessToken } = this.getFields(true);
@@ -901,6 +945,140 @@ class AuthInfo extends kit_1.AsyncOptionalCreatable {
901
945
  return callback(error);
902
946
  }
903
947
  }
948
+ /**
949
+ * Single-flight a refresh-token refresh so two connections/processes can't rotate the same token at once.
950
+ *
951
+ * With Refresh Token Rotation (RTR) enabled, each refresh returns a new refresh token and invalidates the
952
+ * previous one; a second refresh sent with the now-stale refresh token fails and invalidates the current token too.
953
+ * We take a cross-process lock (which also serializes same-process contenders), then re-read the latest
954
+ * tokens from disk: if another actor already rotated while we waited, we adopt their fresh credentials
955
+ * instead of refreshing again with our now-invalid token.
956
+ *
957
+ * Each pass is one call to `tryRotateOrAdopt`, which (1) adopts if disk already holds a rotated token, else
958
+ * (2) tries to acquire the lock and, once held, re-checks-then-refreshes. If we can't acquire (a live holder
959
+ * is mid-rotation, ELOCKED), we do NOT fall back to an unlocked refresh, which would race the holder and double-rotate
960
+ * under RTR; we simply loop back to (1) and re-run the pass (re-read disk, then try to acquire the lock again).
961
+ * `proper-lockfile` only grants the lock on genuine release or genuine staleness (a live
962
+ * holder refreshes its lock mtime ~every 5s and so is never stolen from), so looping converges: a slow holder is waited out,
963
+ * a dead holder's lock crosses the ~10s stale line and is stolen on a later attempt.
964
+ * Only a holder that keeps the lock alive for the whole budget below yields the timeout error.
965
+ *
966
+ * The lock uses a dedicated `<authfile>.token-rotation.lock`, kept separate from ConfigFile's
967
+ * own `<authfile>.lock` write lock, because proper-lockfile is not re-entrant: holding the auth-file lock
968
+ * here and then letting save() re-acquire the same path would self-block.
969
+ *
970
+ * KNOWN LIMITATIONS (all inherent, not bugs):
971
+ *
972
+ * (1) Only actors that take THIS lock are serialized. Within a single CLI release this is a non-issue: the
973
+ * CLI deduplicates @salesforce/core to one version, so every in-process refresher runs this same locking
974
+ * code (and the lock path is derived deterministically, so differing versions that BOTH lock still
975
+ * interoperate). The realistic gap is a 2nd/3rd-party plugin: those install under the CLI's plugin data dir
976
+ * with their OWN non-deduped node_modules and may bundle a core version predating this lock. Such a plugin
977
+ * (or any external tool that refreshes the token directly) won't honor `<authfile>.token-rotation.lock` and
978
+ * can still double-rotate the same auth while we hold it. There is no server-side coordination to prevent this.
979
+ *
980
+ * (2) On the web runtime (Global.isWeb), lockInit is a no-op that takes no lock (matching existing
981
+ * ConfigFile behavior), so nothing is serialized there.
982
+ *
983
+ * (3) A genuinely-stuck holder makes this block for up to the budget below plus one in-flight lock cycle
984
+ * (~40s) before throwing. Because that happens inside a live jsforce session-refresh, an upstream client
985
+ * with a shorter timeout may give up first with a less specific error. Only the pathological stuck case
986
+ * pays this; the common paths return in one lock cycle or via the lock-free adopt.
987
+ *
988
+ * @param heldRefreshToken the refresh token this connection currently holds (read from the auth file); the
989
+ * baseline we compare against disk to detect a rotation, and the token we would send if we do rotate.
990
+ */
991
+ async refreshWithTokenRotationLock(heldRefreshToken) {
992
+ const username = (0, ts_types_1.ensure)(this.getUsername());
993
+ // Cutoff for STARTING another attempt -- not a wall-clock cap on the whole method. Each attempt either
994
+ // finishes (adopt or refresh) or reports contention (ELOCKED); we re-attempt ONLY while contended. One
995
+ // lockInit acquisition cycle is ~10s (its lockRetryOptions retry budget before it throws ELOCKED) and the
996
+ // cutoff is consulted only between attempts, so a genuinely stuck holder pushes the actual time-to-throw
997
+ // to roughly this cutoff plus one in-flight cycle. Sizing it to ~35s (~3.5x a cycle) starts a 3rd attempt
998
+ // at ~20s with room to spare and leaves headroom for a 4th: enough for a dead holder's lock to cross the
999
+ // 10s stale line and be stolen on a later attempt, and for a slow-but-live holder to release.
1000
+ const attemptDeadline = Date.now() + kit_1.Duration.seconds(35).milliseconds;
1001
+ while (Date.now() < attemptDeadline) {
1002
+ // eslint-disable-next-line no-await-in-loop
1003
+ if (await this.tryRotateOrAdopt(username, heldRefreshToken)) {
1004
+ return;
1005
+ }
1006
+ // Otherwise the lock was contended (a live holder is mid-rotation): loop and re-attempt. We never
1007
+ // refresh unlocked, that would race the holder and double-rotate under RTR. No sleep needed: lockInit
1008
+ // already backed off ~10s, and the next attempt re-reads disk before doing anything.
1009
+ }
1010
+ // Past the cutoff with every attempt still contended: a process kept the rotation lock's mtime fresh for
1011
+ // the entire budget without completing. Genuinely stuck, not merely slow or crashed (a crash lets the
1012
+ // lock go stale and be stolen by an attempt above).
1013
+ throw messages.createError('refreshTokenRotationTimeoutError', [username]);
1014
+ }
1015
+ /**
1016
+ * One rotation attempt: adopt an already-rotated token if disk has one, else take the lock and
1017
+ * double-check-then-rotate under it.
1018
+ *
1019
+ * @returns `true` if we adopted or refreshed (caller is done); `false` if the lock was contended
1020
+ * (`ELOCKED`) and the caller should re-attempt. Throws on any non-`ELOCKED` failure.
1021
+ */
1022
+ async tryRotateOrAdopt(username, heldRefreshToken) {
1023
+ // Fast path / adopt: if another connection or process already rotated the token, adopt those fresh
1024
+ // credentials without taking the lock. On the first pass this thins the herd (late arrivals never
1025
+ // contend); on later passes it catches a holder that rotated and released while we were waiting.
1026
+ if (await this.adoptIfAlreadyRotated(username, heldRefreshToken)) {
1027
+ return true;
1028
+ }
1029
+ // proper-lockfile appends `.lock`, so this locks `<authfile>.token-rotation.lock`.
1030
+ const lockPath = `${this.stateAggregator.orgs.getPath(username)}.token-rotation`;
1031
+ let unlock;
1032
+ try {
1033
+ ({ unlock } = await (0, fileLocking_1.lockInit)(lockPath));
1034
+ }
1035
+ catch (err) {
1036
+ // Contended: a live holder is mid-rotation. Report it so the caller re-attempts (never refreshing
1037
+ // unlocked, which would double-rotate under RTR). Any non-ELOCKED error is a real failure.
1038
+ if (err?.code === 'ELOCKED') {
1039
+ return false;
1040
+ }
1041
+ throw err;
1042
+ }
1043
+ // We hold the lock. Run to completion regardless of the caller's cutoff -- we never abandon a rotation we
1044
+ // hold the lock for; the cutoff only gates whether a NEW attempt starts.
1045
+ try {
1046
+ // Double-check under the lock: the holder we queued behind may have rotated while we waited.
1047
+ if (await this.adoptIfAlreadyRotated(username, heldRefreshToken)) {
1048
+ return true;
1049
+ }
1050
+ // No one rotated: perform the refresh (updates this instance in memory) and persist the new token
1051
+ // before we release the lock, so everyone waiting behind us adopts it instead of re-rotating.
1052
+ await this.initAuthOptions(this.getFields(true));
1053
+ await this.save();
1054
+ return true;
1055
+ }
1056
+ finally {
1057
+ await unlock();
1058
+ }
1059
+ }
1060
+ /**
1061
+ * Re-read the on-disk auth (without disturbing this instance's in-memory fields) and, if another
1062
+ * connection/process has already rotated the refresh token, adopt those fresh credentials. Disk is
1063
+ * already current in that case, so no save is needed.
1064
+ *
1065
+ * NOTE: this gates on the refresh token only, not access-token freshness (we don't persist access-token
1066
+ * expiry). If the adopted access token has since expired, jsforce gets a 401 and re-enters refreshFn,
1067
+ * which then rotates under the lock (our refresh token now matches disk, so the double-check falls through
1068
+ * to a real refresh). That is one extra round trip in a narrow case, and still strictly better than the
1069
+ * pre-adopt behavior, where refreshing with our rotated-out token would have failed outright.
1070
+ *
1071
+ * @returns true if a rotated token was adopted; false if the on-disk token still matches ours.
1072
+ */
1073
+ async adoptIfAlreadyRotated(username, heldRefreshToken) {
1074
+ const onDisk = await this.stateAggregator.orgs.peek(username, true);
1075
+ if (onDisk?.refreshToken && onDisk.refreshToken !== heldRefreshToken) {
1076
+ this.logger.info('Refresh token was already rotated by another process; adopting refreshed credentials.');
1077
+ this.update(onDisk);
1078
+ return true;
1079
+ }
1080
+ return false;
1081
+ }
904
1082
  async readJwtKey(keyFile) {
905
1083
  return fs_1.fs.promises.readFile(keyFile, 'utf8');
906
1084
  }
@@ -1009,7 +1187,11 @@ class AuthInfo extends kit_1.AsyncOptionalCreatable {
1009
1187
  accessToken: authFieldsBuilder.access_token,
1010
1188
  instanceUrl: authFieldsBuilder.instance_url,
1011
1189
  loginUrl: fullOptions.loginUrl ?? authFieldsBuilder.instance_url,
1012
- refreshToken: fullOptions.refreshToken,
1190
+ // Refresh Token Rotation (RTR): when the app has RTR enabled, the token endpoint returns a
1191
+ // NEW refresh_token that we must persist, replacing the one we sent. When RTR is off, the
1192
+ // response omits refresh_token, so we keep the existing one.
1193
+ // https://help.salesforce.com/s/articleView?id=sf.remoteaccess_oauth_refresh_token_flow.htm&type=5
1194
+ refreshToken: authFieldsBuilder.refresh_token ?? fullOptions.refreshToken,
1013
1195
  clientId: fullOptions.clientId,
1014
1196
  clientSecret: fullOptions.clientSecret,
1015
1197
  };
@@ -18,6 +18,15 @@ export declare abstract class BaseOrgAccessor<T extends ConfigFile, P extends Co
18
18
  * @param throwOnNotFound throw if file is not found for username
19
19
  */
20
20
  read(username: string, decrypt?: boolean, throwOnNotFound?: boolean): Promise<Nullable<P>>;
21
+ /**
22
+ * Read the current on-disk contents of a username's auth file WITHOUT mutating the cache (unlike `read`,
23
+ * which replaces the cached config/contents). Use this to inspect what another process has persisted
24
+ * without disturbing this instance's in-memory state. Returns null if the file can't be read.
25
+ *
26
+ * @param username username to read
27
+ * @param decrypt if true, decrypt encrypted values
28
+ */
29
+ peek(username: string, decrypt?: boolean): Promise<Nullable<P>>;
21
30
  /**
22
31
  * Read all the auth files under the global state directory
23
32
  *
@@ -50,6 +59,13 @@ export declare abstract class BaseOrgAccessor<T extends ConfigFile, P extends Co
50
59
  * @param username
51
60
  */
52
61
  stat(username: string): Promise<Nullable<Awaited<ReturnType<typeof fs.promises.stat>>>>;
62
+ /**
63
+ * Return the absolute path to the auth file for a given username. Does not require the file to have been
64
+ * read (resolves purely from the username and the global state directory).
65
+ *
66
+ * @param username
67
+ */
68
+ getPath(username: string): string;
53
69
  /**
54
70
  * Returns true if there is an auth file for the given username
55
71
  *
@@ -92,6 +92,25 @@ class BaseOrgAccessor extends kit_1.AsyncOptionalCreatable {
92
92
  return null;
93
93
  }
94
94
  }
95
+ /**
96
+ * Read the current on-disk contents of a username's auth file WITHOUT mutating the cache (unlike `read`,
97
+ * which replaces the cached config/contents). Use this to inspect what another process has persisted
98
+ * without disturbing this instance's in-memory state. Returns null if the file can't be read.
99
+ *
100
+ * @param username username to read
101
+ * @param decrypt if true, decrypt encrypted values
102
+ */
103
+ async peek(username, decrypt = false) {
104
+ try {
105
+ const config = await this.initAuthFile(username, false);
106
+ return config.getContents(decrypt);
107
+ }
108
+ catch (err) {
109
+ const error = sfError_1.SfError.wrap(err);
110
+ this.logger.debug(`Error when peeking auth file for user: ${username} due to: ${error.name}:${error.message}`);
111
+ return null;
112
+ }
113
+ }
95
114
  /**
96
115
  * Read all the auth files under the global state directory
97
116
  *
@@ -134,7 +153,7 @@ class BaseOrgAccessor extends kit_1.AsyncOptionalCreatable {
134
153
  const config = this.configs.get(username);
135
154
  if (throwOnNotFound && config?.keys().length === 0) {
136
155
  ;
137
- const messages = new messages_1.Messages('@salesforce/core', 'core', new Map([["authInfoCreationError", "Must pass a username and/or OAuth options when creating an AuthInfo instance."], ["authInfoOverwriteError", "Cannot create an AuthInfo instance that will overwrite existing auth data."], ["authInfoOverwriteError.actions", ["Create the AuthInfo instance using existing auth data by just passing the username. E.g., `AuthInfo.create({ username: 'my@user.org' });`."]], ["authCodeExchangeError", "Error authenticating with auth code due to: %s"], ["authCodeUsernameRetrievalError", "Could not retrieve the username after successful auth code exchange.\n\nDue to: %s"], ["jwtAuthError", "Error authenticating with JWT config due to: %s"], ["jwtAuthErrors", "Error authenticating with JWT.\nErrors encountered:\n%s"], ["refreshTokenAuthError", "Error authenticating with the refresh token due to: %s"], ["invalidSfdxAuthUrlError", "Invalid SFDX authorization URL. Must be in the format \"force://<clientId>:<clientSecret>:<refreshToken>@<instanceUrl>\". Note that the \"instanceUrl\" inside the SFDX authorization URL doesn\\'t include the protocol (\"https://\"). Run \"org display --target-org\" on an org to see an example of an SFDX authorization URL."], ["orgDataNotAvailableError", "An attempt to refresh the authentication token failed with a 'Data Not Found Error'. The org identified by username %s does not appear to exist. Likely cause is that the org was deleted by another user or has expired."], ["orgDataNotAvailableError.actions", ["Run `sfdx force:org:list --clean` to remove stale org authentications.", "Use `sfdx force:config:set` to update the defaultusername.", "Use `sfdx force:org:create` to create a new org.", "Use `sfdx auth` to authenticate an existing org."]], ["namedOrgNotFound", "No authorization information found for %s."], ["noAliasesFound", "Nothing to set."], ["invalidFormat", "Setting aliases must be in the format <key>=<value> but found: [%s]."], ["invalidJsonCasing", "All JSON input must have heads down camelcase keys. E.g., `{ sfdcLoginUrl: \"https://login.salesforce.com\" }`\nFound \"%s\" at %s"], ["missingClientId", "Client ID is required for JWT authentication."]]));
156
+ const messages = new messages_1.Messages('@salesforce/core', 'core', new Map([["authInfoCreationError", "Must pass a username and/or OAuth options when creating an AuthInfo instance."], ["authInfoOverwriteError", "Cannot create an AuthInfo instance that will overwrite existing auth data."], ["authInfoOverwriteError.actions", ["Create the AuthInfo instance using existing auth data by just passing the username. E.g., `AuthInfo.create({ username: 'my@user.org' });`."]], ["authCodeExchangeError", "Error authenticating with auth code due to: %s"], ["authCodeUsernameRetrievalError", "Could not retrieve the username after successful auth code exchange.\n\nDue to: %s"], ["jwtAuthError", "Error authenticating with JWT config due to: %s"], ["jwtAuthErrors", "Error authenticating with JWT.\nErrors encountered:\n%s"], ["refreshTokenAuthError", "Error authenticating with the refresh token due to: %s"], ["refreshTokenAuthError.actions", ["The stored refresh token is no longer valid (it may have expired, been revoked, or been rotated). Re-authenticate to the org, then try again."]], ["refreshTokenRotationTimeoutError", "Timed out waiting for another process to finish rotating the refresh token for %s. Another process held the rotation lock for the entire wait without completing. Try again. If it persists, another process is likely stuck mid-refresh: stop any other processes using this org's authentication, then try again."], ["invalidSfdxAuthUrlError", "Invalid SFDX authorization URL. Must be in the format \"force://<clientId>:<clientSecret>:<refreshToken>@<instanceUrl>\". Note that the \"instanceUrl\" inside the SFDX authorization URL doesn\\'t include the protocol (\"https://\"). Run \"org display --target-org\" on an org to see an example of an SFDX authorization URL."], ["orgDataNotAvailableError", "An attempt to refresh the authentication token failed with a 'Data Not Found Error'. The org identified by username %s does not appear to exist. Likely cause is that the org was deleted by another user or has expired."], ["orgDataNotAvailableError.actions", ["Run `sfdx force:org:list --clean` to remove stale org authentications.", "Use `sfdx force:config:set` to update the defaultusername.", "Use `sfdx force:org:create` to create a new org.", "Use `sfdx auth` to authenticate an existing org."]], ["namedOrgNotFound", "No authorization information found for %s."], ["noAliasesFound", "Nothing to set."], ["invalidFormat", "Setting aliases must be in the format <key>=<value> but found: [%s]."], ["invalidJsonCasing", "All JSON input must have heads down camelcase keys. E.g., `{ sfdcLoginUrl: \"https://login.salesforce.com\" }`\nFound \"%s\" at %s"], ["missingClientId", "Client ID is required for JWT authentication."]]));
138
157
  throw messages.createError('namedOrgNotFound', [username]);
139
158
  }
140
159
  if (config) {
@@ -177,6 +196,15 @@ class BaseOrgAccessor extends kit_1.AsyncOptionalCreatable {
177
196
  const config = this.configs.get(username);
178
197
  return config ? config.stat() : null;
179
198
  }
199
+ /**
200
+ * Return the absolute path to the auth file for a given username. Does not require the file to have been
201
+ * read (resolves purely from the username and the global state directory).
202
+ *
203
+ * @param username
204
+ */
205
+ getPath(username) {
206
+ return this.parseFilename(username);
207
+ }
180
208
  /**
181
209
  * Returns true if there is an auth file for the given username
182
210
  *
@@ -20,7 +20,7 @@ const node_assert_1 = require("node:assert");
20
20
  const ts_types_1 = require("@salesforce/ts-types");
21
21
  const messages_1 = require("../messages");
22
22
  ;
23
- const coreMessages = new messages_1.Messages('@salesforce/core', 'core', new Map([["authInfoCreationError", "Must pass a username and/or OAuth options when creating an AuthInfo instance."], ["authInfoOverwriteError", "Cannot create an AuthInfo instance that will overwrite existing auth data."], ["authInfoOverwriteError.actions", ["Create the AuthInfo instance using existing auth data by just passing the username. E.g., `AuthInfo.create({ username: 'my@user.org' });`."]], ["authCodeExchangeError", "Error authenticating with auth code due to: %s"], ["authCodeUsernameRetrievalError", "Could not retrieve the username after successful auth code exchange.\n\nDue to: %s"], ["jwtAuthError", "Error authenticating with JWT config due to: %s"], ["jwtAuthErrors", "Error authenticating with JWT.\nErrors encountered:\n%s"], ["refreshTokenAuthError", "Error authenticating with the refresh token due to: %s"], ["invalidSfdxAuthUrlError", "Invalid SFDX authorization URL. Must be in the format \"force://<clientId>:<clientSecret>:<refreshToken>@<instanceUrl>\". Note that the \"instanceUrl\" inside the SFDX authorization URL doesn\\'t include the protocol (\"https://\"). Run \"org display --target-org\" on an org to see an example of an SFDX authorization URL."], ["orgDataNotAvailableError", "An attempt to refresh the authentication token failed with a 'Data Not Found Error'. The org identified by username %s does not appear to exist. Likely cause is that the org was deleted by another user or has expired."], ["orgDataNotAvailableError.actions", ["Run `sfdx force:org:list --clean` to remove stale org authentications.", "Use `sfdx force:config:set` to update the defaultusername.", "Use `sfdx force:org:create` to create a new org.", "Use `sfdx auth` to authenticate an existing org."]], ["namedOrgNotFound", "No authorization information found for %s."], ["noAliasesFound", "Nothing to set."], ["invalidFormat", "Setting aliases must be in the format <key>=<value> but found: [%s]."], ["invalidJsonCasing", "All JSON input must have heads down camelcase keys. E.g., `{ sfdcLoginUrl: \"https://login.salesforce.com\" }`\nFound \"%s\" at %s"], ["missingClientId", "Client ID is required for JWT authentication."]]));
23
+ const coreMessages = new messages_1.Messages('@salesforce/core', 'core', new Map([["authInfoCreationError", "Must pass a username and/or OAuth options when creating an AuthInfo instance."], ["authInfoOverwriteError", "Cannot create an AuthInfo instance that will overwrite existing auth data."], ["authInfoOverwriteError.actions", ["Create the AuthInfo instance using existing auth data by just passing the username. E.g., `AuthInfo.create({ username: 'my@user.org' });`."]], ["authCodeExchangeError", "Error authenticating with auth code due to: %s"], ["authCodeUsernameRetrievalError", "Could not retrieve the username after successful auth code exchange.\n\nDue to: %s"], ["jwtAuthError", "Error authenticating with JWT config due to: %s"], ["jwtAuthErrors", "Error authenticating with JWT.\nErrors encountered:\n%s"], ["refreshTokenAuthError", "Error authenticating with the refresh token due to: %s"], ["refreshTokenAuthError.actions", ["The stored refresh token is no longer valid (it may have expired, been revoked, or been rotated). Re-authenticate to the org, then try again."]], ["refreshTokenRotationTimeoutError", "Timed out waiting for another process to finish rotating the refresh token for %s. Another process held the rotation lock for the entire wait without completing. Try again. If it persists, another process is likely stuck mid-refresh: stop any other processes using this org's authentication, then try again."], ["invalidSfdxAuthUrlError", "Invalid SFDX authorization URL. Must be in the format \"force://<clientId>:<clientSecret>:<refreshToken>@<instanceUrl>\". Note that the \"instanceUrl\" inside the SFDX authorization URL doesn\\'t include the protocol (\"https://\"). Run \"org display --target-org\" on an org to see an example of an SFDX authorization URL."], ["orgDataNotAvailableError", "An attempt to refresh the authentication token failed with a 'Data Not Found Error'. The org identified by username %s does not appear to exist. Likely cause is that the org was deleted by another user or has expired."], ["orgDataNotAvailableError.actions", ["Run `sfdx force:org:list --clean` to remove stale org authentications.", "Use `sfdx force:config:set` to update the defaultusername.", "Use `sfdx force:org:create` to create a new org.", "Use `sfdx auth` to authenticate an existing org."]], ["namedOrgNotFound", "No authorization information found for %s."], ["noAliasesFound", "Nothing to set."], ["invalidFormat", "Setting aliases must be in the format <key>=<value> but found: [%s]."], ["invalidJsonCasing", "All JSON input must have heads down camelcase keys. E.g., `{ sfdcLoginUrl: \"https://login.salesforce.com\" }`\nFound \"%s\" at %s"], ["missingClientId", "Client ID is required for JWT authentication."]]));
24
24
  /** will throw on any upperCase unless they are present in the allowList. Recursively searches the object, returning valid keys */
25
25
  const ensureNoUppercaseKeys = (path) => (allowList = []) => (data) => {
26
26
  const keys = getKeys(data, allowList);
package/messages/core.md CHANGED
@@ -34,6 +34,14 @@ Errors encountered:
34
34
 
35
35
  Error authenticating with the refresh token due to: %s
36
36
 
37
+ # refreshTokenAuthError.actions
38
+
39
+ - The stored refresh token is no longer valid (it may have expired, been revoked, or been rotated). Re-authenticate to the org, then try again.
40
+
41
+ # refreshTokenRotationTimeoutError
42
+
43
+ Timed out waiting for another process to finish rotating the refresh token for %s. Another process held the rotation lock for the entire wait without completing. Try again. If it persists, another process is likely stuck mid-refresh: stop any other processes using this org's authentication, then try again.
44
+
37
45
  # invalidSfdxAuthUrlError
38
46
 
39
47
  Invalid SFDX authorization URL. Must be in the format "force://<clientId>:<clientSecret>:<refreshToken>@<instanceUrl>". Note that the "instanceUrl" inside the SFDX authorization URL doesn\'t include the protocol ("https://"). Run "org display --target-org" on an org to see an example of an SFDX authorization URL.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@salesforce/core",
3
- "version": "9.1.11",
3
+ "version": "9.1.12-qa.1",
4
4
  "description": "Core libraries to interact with SFDX projects, orgs, and APIs.",
5
5
  "main": "lib/index",
6
6
  "types": "lib/index.d.ts",