@salesforce/core 9.1.11 → 9.1.12-qa.0
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.
package/lib/org/authInfo.d.ts
CHANGED
|
@@ -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;
|
package/lib/org/authInfo.js
CHANGED
|
@@ -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 retry. For example, run \"sf org login web --alias <your-alias>\""]], ["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. Retry the command. If it persists, another \"sf\" process is likely stuck mid-refresh: stop any other running \"sf\" processes, then retry."], ["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
|
-
|
|
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,11 +838,23 @@ 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
|
-
|
|
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 CAUTION: this POST rotates the token server-side and invalidates the old one immediately.
|
|
853
|
+
// Everything from here until the caller's save() (determineIfDevHub, orgs.read, update/encrypt,
|
|
854
|
+
// determineOrg) runs on borrowed time -- a throw in that window strands the rotated token on the
|
|
855
|
+
// wire while the old one is already dead server-side, permanently breaking the auth until re-login.
|
|
856
|
+
// determineIfDevHub and determineOrg swallow their own errors, so the practical window is small,
|
|
857
|
+
// but it is non-zero. A save-early seam for this branch would close it (tracked as a follow-up).
|
|
842
858
|
authConfig = await this.buildRefreshTokenConfig(options);
|
|
843
859
|
}
|
|
844
860
|
else if (this.options.oauth2 instanceof jsforce_node_1.OAuth2) {
|
|
@@ -854,6 +870,15 @@ class AuthInfo extends kit_1.AsyncOptionalCreatable {
|
|
|
854
870
|
await this.stateAggregator.orgs.read(authConfig.username, false, false);
|
|
855
871
|
// Update the auth fields WITH encryption
|
|
856
872
|
this.update(authConfig);
|
|
873
|
+
// A web/auth-code or refresh-token authorization is never a JWT one, so it must not carry a
|
|
874
|
+
// privateKey. When this flow overwrites an existing auth file (e.g. the user was JWT-authed for
|
|
875
|
+
// this org, then re-authed via web), the save path merges (Object.assign) over the existing
|
|
876
|
+
// file and would otherwise retain the stale privateKey, which later misroutes refreshFn into
|
|
877
|
+
// the JWT flow. Only clear it when a stale value actually lingers so we don't add an empty key
|
|
878
|
+
// to a fresh authorization.
|
|
879
|
+
if (!authConfig.privateKey && this.getFields().privateKey) {
|
|
880
|
+
this.stateAggregator.orgs.update(this.username, { privateKey: undefined });
|
|
881
|
+
}
|
|
857
882
|
// Populate Organization metadata (orgEdition, isScratch, isSandbox, etc.) in a single query.
|
|
858
883
|
await (0, determineOrg_1.determineOrg)(this);
|
|
859
884
|
}
|
|
@@ -883,10 +908,27 @@ class AuthInfo extends kit_1.AsyncOptionalCreatable {
|
|
|
883
908
|
this.logger.info('Access token has expired. Updating...');
|
|
884
909
|
try {
|
|
885
910
|
const fields = this.getFields(true);
|
|
886
|
-
//
|
|
887
|
-
|
|
888
|
-
//
|
|
889
|
-
|
|
911
|
+
// JWT auth mints a fresh access token from a locally-signed assertion and consumes no stored credential,
|
|
912
|
+
// so concurrent JWT refreshes are independent and safe. The refresh-token flow is different: every caller
|
|
913
|
+
// refreshes by re-sending the *same persisted refresh token* read from the auth file, and with Refresh
|
|
914
|
+
// Token Rotation enabled the server returns a new refresh token and invalidates the old one *immediately*.
|
|
915
|
+
// So two connections/processes refreshing at once would both send that on-disk token and double-rotate the
|
|
916
|
+
// auth. Serialize just that flow.
|
|
917
|
+
//
|
|
918
|
+
// Gate on `refreshToken` alone (not `refreshToken && !privateKey`): a legitimate JWT auth never carries
|
|
919
|
+
// a refresh token, so this still excludes JWT, and it also covers the fixed edge case where both-fields
|
|
920
|
+
// exist from an original JWT login and switching to web (stale privateKey + real refreshToken), which
|
|
921
|
+
// initAuthOptions routes through the refresh-token flow and which therefore must take the lock too.
|
|
922
|
+
// See the router in initAuthOptions and isJwt().
|
|
923
|
+
if (fields.refreshToken) {
|
|
924
|
+
await this.refreshWithTokenRotationLock(fields.refreshToken);
|
|
925
|
+
}
|
|
926
|
+
else {
|
|
927
|
+
// This method will request the new access token and save to the current AuthInfo instance (but don't persist them!).
|
|
928
|
+
await this.initAuthOptions(fields);
|
|
929
|
+
// Persist fields with refreshed access token to auth file.
|
|
930
|
+
await this.save();
|
|
931
|
+
}
|
|
890
932
|
// Pass new access token to the jsforce's session-refresh callback for proper propagation:
|
|
891
933
|
// https://jsforce.github.io/jsforce/types/session_refresh_delegate.SessionRefreshFunc.html
|
|
892
934
|
const { accessToken } = this.getFields(true);
|
|
@@ -901,6 +943,140 @@ class AuthInfo extends kit_1.AsyncOptionalCreatable {
|
|
|
901
943
|
return callback(error);
|
|
902
944
|
}
|
|
903
945
|
}
|
|
946
|
+
/**
|
|
947
|
+
* Single-flight a refresh-token refresh so two connections/processes can't rotate the same token at once.
|
|
948
|
+
*
|
|
949
|
+
* With Refresh Token Rotation (RTR) enabled, each refresh returns a new refresh token and invalidates the
|
|
950
|
+
* previous one; a second refresh sent with the now-stale refresh token fails and invalidates the current token too.
|
|
951
|
+
* We take a cross-process lock (which also serializes same-process contenders), then re-read the latest
|
|
952
|
+
* tokens from disk: if another actor already rotated while we waited, we adopt their fresh credentials
|
|
953
|
+
* instead of refreshing again with our now-invalid token.
|
|
954
|
+
*
|
|
955
|
+
* Each pass is one call to `tryRotateOrAdopt`, which (1) adopts if disk already holds a rotated token, else
|
|
956
|
+
* (2) tries to acquire the lock and, once held, re-checks-then-refreshes. If we can't acquire (a live holder
|
|
957
|
+
* is mid-rotation, ELOCKED), we do NOT fall back to an unlocked refresh, which would race the holder and double-rotate
|
|
958
|
+
* under RTR; we simply loop back to (1) and re-run the pass (re-read disk, then try to acquire the lock again).
|
|
959
|
+
* `proper-lockfile` only grants the lock on genuine release or genuine staleness (a live
|
|
960
|
+
* holder refreshes its lock mtime ~every 5s and so is never stolen from), so looping converges: a slow holder is waited out,
|
|
961
|
+
* a dead holder's lock crosses the ~10s stale line and is stolen on a later attempt.
|
|
962
|
+
* Only a holder that keeps the lock alive for the whole budget below yields the timeout error.
|
|
963
|
+
*
|
|
964
|
+
* The lock uses a dedicated `<authfile>.token-rotation.lock`, kept separate from ConfigFile's
|
|
965
|
+
* own `<authfile>.lock` write lock, because proper-lockfile is not re-entrant: holding the auth-file lock
|
|
966
|
+
* here and then letting save() re-acquire the same path would self-block.
|
|
967
|
+
*
|
|
968
|
+
* KNOWN LIMITATIONS (all inherent, not bugs):
|
|
969
|
+
*
|
|
970
|
+
* (1) Only actors that take THIS lock are serialized. Within a single CLI release this is a non-issue: the
|
|
971
|
+
* CLI deduplicates @salesforce/core to one version, so every in-process refresher runs this same locking
|
|
972
|
+
* code (and the lock path is derived deterministically, so differing versions that BOTH lock still
|
|
973
|
+
* interoperate). The realistic gap is a 2nd/3rd-party plugin: those install under the CLI's plugin data dir
|
|
974
|
+
* with their OWN non-deduped node_modules and may bundle a core version predating this lock. Such a plugin
|
|
975
|
+
* (or any external tool that refreshes the token directly) won't honor `<authfile>.token-rotation.lock` and
|
|
976
|
+
* can still double-rotate the same auth while we hold it. There is no server-side coordination to prevent this.
|
|
977
|
+
*
|
|
978
|
+
* (2) On the web runtime (Global.isWeb), lockInit is a no-op that takes no lock (matching existing
|
|
979
|
+
* ConfigFile behavior), so nothing is serialized there.
|
|
980
|
+
*
|
|
981
|
+
* (3) A genuinely-stuck holder makes this block for up to the budget below plus one in-flight lock cycle
|
|
982
|
+
* (~40s) before throwing. Because that happens inside a live jsforce session-refresh, an upstream client
|
|
983
|
+
* with a shorter timeout may give up first with a less specific error. Only the pathological stuck case
|
|
984
|
+
* pays this; the common paths return in one lock cycle or via the lock-free adopt.
|
|
985
|
+
*
|
|
986
|
+
* @param heldRefreshToken the refresh token this connection currently holds (read from the auth file); the
|
|
987
|
+
* baseline we compare against disk to detect a rotation, and the token we would send if we do rotate.
|
|
988
|
+
*/
|
|
989
|
+
async refreshWithTokenRotationLock(heldRefreshToken) {
|
|
990
|
+
const username = (0, ts_types_1.ensure)(this.getUsername());
|
|
991
|
+
// Cutoff for STARTING another attempt -- not a wall-clock cap on the whole method. Each attempt either
|
|
992
|
+
// finishes (adopt or refresh) or reports contention (ELOCKED); we re-attempt ONLY while contended. One
|
|
993
|
+
// lockInit acquisition cycle is ~10s (its lockRetryOptions retry budget before it throws ELOCKED) and the
|
|
994
|
+
// cutoff is consulted only between attempts, so a genuinely stuck holder pushes the actual time-to-throw
|
|
995
|
+
// to roughly this cutoff plus one in-flight cycle. Sizing it to ~35s (~3.5x a cycle) starts a 3rd attempt
|
|
996
|
+
// at ~20s with room to spare and leaves headroom for a 4th: enough for a dead holder's lock to cross the
|
|
997
|
+
// 10s stale line and be stolen on a later attempt, and for a slow-but-live holder to release.
|
|
998
|
+
const attemptDeadline = Date.now() + kit_1.Duration.seconds(35).milliseconds;
|
|
999
|
+
while (Date.now() < attemptDeadline) {
|
|
1000
|
+
// eslint-disable-next-line no-await-in-loop
|
|
1001
|
+
if (await this.tryRotateOrAdopt(username, heldRefreshToken)) {
|
|
1002
|
+
return;
|
|
1003
|
+
}
|
|
1004
|
+
// Otherwise the lock was contended (a live holder is mid-rotation): loop and re-attempt. We never
|
|
1005
|
+
// refresh unlocked, that would race the holder and double-rotate under RTR. No sleep needed: lockInit
|
|
1006
|
+
// already backed off ~10s, and the next attempt re-reads disk before doing anything.
|
|
1007
|
+
}
|
|
1008
|
+
// Past the cutoff with every attempt still contended: a process kept the rotation lock's mtime fresh for
|
|
1009
|
+
// the entire budget without completing. Genuinely stuck, not merely slow or crashed (a crash lets the
|
|
1010
|
+
// lock go stale and be stolen by an attempt above).
|
|
1011
|
+
throw messages.createError('refreshTokenRotationTimeoutError', [username]);
|
|
1012
|
+
}
|
|
1013
|
+
/**
|
|
1014
|
+
* One rotation attempt: adopt an already-rotated token if disk has one, else take the lock and
|
|
1015
|
+
* double-check-then-rotate under it.
|
|
1016
|
+
*
|
|
1017
|
+
* @returns `true` if we adopted or refreshed (caller is done); `false` if the lock was contended
|
|
1018
|
+
* (`ELOCKED`) and the caller should re-attempt. Throws on any non-`ELOCKED` failure.
|
|
1019
|
+
*/
|
|
1020
|
+
async tryRotateOrAdopt(username, heldRefreshToken) {
|
|
1021
|
+
// Fast path / adopt: if another connection or process already rotated the token, adopt those fresh
|
|
1022
|
+
// credentials without taking the lock. On the first pass this thins the herd (late arrivals never
|
|
1023
|
+
// contend); on later passes it catches a holder that rotated and released while we were waiting.
|
|
1024
|
+
if (await this.adoptIfAlreadyRotated(username, heldRefreshToken)) {
|
|
1025
|
+
return true;
|
|
1026
|
+
}
|
|
1027
|
+
// proper-lockfile appends `.lock`, so this locks `<authfile>.token-rotation.lock`.
|
|
1028
|
+
const lockPath = `${this.stateAggregator.orgs.getPath(username)}.token-rotation`;
|
|
1029
|
+
let unlock;
|
|
1030
|
+
try {
|
|
1031
|
+
({ unlock } = await (0, fileLocking_1.lockInit)(lockPath));
|
|
1032
|
+
}
|
|
1033
|
+
catch (err) {
|
|
1034
|
+
// Contended: a live holder is mid-rotation. Report it so the caller re-attempts (never refreshing
|
|
1035
|
+
// unlocked, which would double-rotate under RTR). Any non-ELOCKED error is a real failure.
|
|
1036
|
+
if (err?.code === 'ELOCKED') {
|
|
1037
|
+
return false;
|
|
1038
|
+
}
|
|
1039
|
+
throw err;
|
|
1040
|
+
}
|
|
1041
|
+
// We hold the lock. Run to completion regardless of the caller's cutoff -- we never abandon a rotation we
|
|
1042
|
+
// hold the lock for; the cutoff only gates whether a NEW attempt starts.
|
|
1043
|
+
try {
|
|
1044
|
+
// Double-check under the lock: the holder we queued behind may have rotated while we waited.
|
|
1045
|
+
if (await this.adoptIfAlreadyRotated(username, heldRefreshToken)) {
|
|
1046
|
+
return true;
|
|
1047
|
+
}
|
|
1048
|
+
// No one rotated: perform the refresh (updates this instance in memory) and persist the new token
|
|
1049
|
+
// before we release the lock, so everyone waiting behind us adopts it instead of re-rotating.
|
|
1050
|
+
await this.initAuthOptions(this.getFields(true));
|
|
1051
|
+
await this.save();
|
|
1052
|
+
return true;
|
|
1053
|
+
}
|
|
1054
|
+
finally {
|
|
1055
|
+
await unlock();
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
/**
|
|
1059
|
+
* Re-read the on-disk auth (without disturbing this instance's in-memory fields) and, if another
|
|
1060
|
+
* connection/process has already rotated the refresh token, adopt those fresh credentials. Disk is
|
|
1061
|
+
* already current in that case, so no save is needed.
|
|
1062
|
+
*
|
|
1063
|
+
* NOTE: this gates on the refresh token only, not access-token freshness (we don't persist access-token
|
|
1064
|
+
* expiry). If the adopted access token has since expired, jsforce gets a 401 and re-enters refreshFn,
|
|
1065
|
+
* which then rotates under the lock (our refresh token now matches disk, so the double-check falls through
|
|
1066
|
+
* to a real refresh). That is one extra round trip in a narrow case, and still strictly better than the
|
|
1067
|
+
* pre-adopt behavior, where refreshing with our rotated-out token would have failed outright.
|
|
1068
|
+
*
|
|
1069
|
+
* @returns true if a rotated token was adopted; false if the on-disk token still matches ours.
|
|
1070
|
+
*/
|
|
1071
|
+
async adoptIfAlreadyRotated(username, heldRefreshToken) {
|
|
1072
|
+
const onDisk = await this.stateAggregator.orgs.peek(username, true);
|
|
1073
|
+
if (onDisk?.refreshToken && onDisk.refreshToken !== heldRefreshToken) {
|
|
1074
|
+
this.logger.info('Refresh token was already rotated by another process; adopting refreshed credentials.');
|
|
1075
|
+
this.update(onDisk);
|
|
1076
|
+
return true;
|
|
1077
|
+
}
|
|
1078
|
+
return false;
|
|
1079
|
+
}
|
|
904
1080
|
async readJwtKey(keyFile) {
|
|
905
1081
|
return fs_1.fs.promises.readFile(keyFile, 'utf8');
|
|
906
1082
|
}
|
|
@@ -1009,7 +1185,11 @@ class AuthInfo extends kit_1.AsyncOptionalCreatable {
|
|
|
1009
1185
|
accessToken: authFieldsBuilder.access_token,
|
|
1010
1186
|
instanceUrl: authFieldsBuilder.instance_url,
|
|
1011
1187
|
loginUrl: fullOptions.loginUrl ?? authFieldsBuilder.instance_url,
|
|
1012
|
-
|
|
1188
|
+
// Refresh Token Rotation (RTR): when the app has RTR enabled, the token endpoint returns a
|
|
1189
|
+
// NEW refresh_token that we must persist, replacing the one we sent. When RTR is off, the
|
|
1190
|
+
// response omits refresh_token, so we keep the existing one.
|
|
1191
|
+
// https://help.salesforce.com/s/articleView?id=sf.remoteaccess_oauth_refresh_token_flow.htm&type=5
|
|
1192
|
+
refreshToken: authFieldsBuilder.refresh_token ?? fullOptions.refreshToken,
|
|
1013
1193
|
clientId: fullOptions.clientId,
|
|
1014
1194
|
clientSecret: fullOptions.clientSecret,
|
|
1015
1195
|
};
|
|
@@ -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 retry. For example, run \"sf org login web --alias <your-alias>\""]], ["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. Retry the command. If it persists, another \"sf\" process is likely stuck mid-refresh: stop any other running \"sf\" processes, then retry."], ["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 retry. For example, run \"sf org login web --alias <your-alias>\""]], ["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. Retry the command. If it persists, another \"sf\" process is likely stuck mid-refresh: stop any other running \"sf\" processes, then retry."], ["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 retry. For example, run "sf org login web --alias <your-alias>"
|
|
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. Retry the command. If it persists, another "sf" process is likely stuck mid-refresh: stop any other running "sf" processes, then retry.
|
|
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.
|