@salesforce/core
Version:
Core libraries to interact with SFDX projects, orgs, and APIs.
1,335 lines • 70.3 kB
JavaScript
"use strict";
/*
* Copyright 2026, Salesforce, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* eslint-disable class-methods-use-this */
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.AuthInfo = exports.CODE_BUILDER_CONNECTED_APP_INFO = exports.DEFAULT_CONNECTED_APP_INFO = void 0;
const node_crypto_1 = require("node:crypto");
const node_path_1 = require("node:path");
const os = __importStar(require("node:os"));
const kit_1 = require("@salesforce/kit");
const ts_types_1 = require("@salesforce/ts-types");
const jsforce_node_1 = require("@jsforce/jsforce-node");
const transport_1 = __importDefault(require("@jsforce/jsforce-node/lib/transport"));
const jwt = __importStar(require("jsonwebtoken"));
const fs_1 = require("../fs/fs");
const config_1 = require("../config/config");
const configAggregator_1 = require("../config/configAggregator");
const logger_1 = require("../logger/logger");
const sfError_1 = require("../sfError");
const sfdc_1 = require("../util/sfdc");
const stateAggregator_1 = require("../stateAggregator/stateAggregator");
const filters_1 = require("../logger/filters");
const messages_1 = require("../messages");
const sfdcUrl_1 = require("../util/sfdcUrl");
const findSuggestion_1 = require("../util/findSuggestion");
const fileLocking_1 = require("../util/fileLocking");
const connection_1 = require("./connection");
const determineOrg_1 = require("./determineOrg");
const org_1 = require("./org");
const orgConfigProperties_1 = require("./orgConfigProperties");
;
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."]]));
// parses the id field returned from jsForce oauth2 methods to get
// user ID and org ID.
function parseIdUrl(idUrl) {
const idUrls = idUrl.split('/');
const userId = idUrls.pop();
const orgId = idUrls.pop();
return {
userId,
orgId,
url: idUrl,
};
}
exports.DEFAULT_CONNECTED_APP_INFO = {
clientId: 'PlatformCLI',
clientSecret: '',
};
exports.CODE_BUILDER_CONNECTED_APP_INFO = {
clientId: 'CodeBuilder',
clientSecret: '',
};
/**
* Handles persistence and fetching of user authentication information using
* JWT, OAuth, or refresh tokens. Sets up the refresh flows that jsForce will
* use to keep tokens active. An AuthInfo can also be created with an access
* token, but AuthInfos created with access tokens can't be persisted to disk.
*
* **See** [Authorization](https://developer.salesforce.com/docs/atlas.en-us.sfdx_dev.meta/sfdx_dev/sfdx_dev_auth.htm)
*
* **See** [Salesforce DX Usernames and Orgs](https://developer.salesforce.com/docs/atlas.en-us.sfdx_dev.meta/sfdx_dev/sfdx_dev_cli_usernames_orgs.htm)
*
* ```
* // Creating a new authentication file.
* const authInfo = await AuthInfo.create({
* username: myAdminUsername,
* oauth2Options: {
* loginUrl, authCode, clientId, clientSecret
* }
* );
* authInfo.save();
*
* // Creating an authorization info with an access token.
* const authInfo = await AuthInfo.create({
* username: accessToken
* });
*
* // Using an existing authentication file.
* const authInfo = await AuthInfo.create({
* username: myAdminUsername
* });
*
* // Using the AuthInfo
* const connection = await Connection.create({ authInfo });
* ```
*/
class AuthInfo extends kit_1.AsyncOptionalCreatable {
// Possibly overridden in create
usingAccessToken = false;
// Initialized in init
logger;
stateAggregator;
username;
options;
/**
* Constructor
* **Do not directly construct instances of this class -- use {@link AuthInfo.create} instead.**
*
* @param options The options for the class instance
*/
constructor(options) {
super(options);
this.options = options ?? {};
}
/**
* Returns the default instance url
*
* @returns {string}
*/
static getDefaultInstanceUrl() {
const configuredInstanceUrl = configAggregator_1.ConfigAggregator.getValue(orgConfigProperties_1.OrgConfigProperties.ORG_INSTANCE_URL)?.value;
return configuredInstanceUrl ?? sfdcUrl_1.SfdcUrl.PRODUCTION;
}
/**
* Get a list of all authorizations based on auth files stored in the global directory.
* One can supply a filter (see @param orgAuthFilter) and calling this function without
* a filter will return all authorizations.
*
* @param orgAuthFilter A predicate function that returns true for those org authorizations that are to be retained.
*
* @returns {Promise<OrgAuthorization[]>}
*/
static async listAllAuthorizations(orgAuthFilter = (orgAuth) => !!orgAuth) {
const stateAggregator = await stateAggregator_1.StateAggregator.getInstance();
const config = (await configAggregator_1.ConfigAggregator.create()).getConfigInfo();
const orgs = await stateAggregator.orgs.readAll();
const final = [];
for (const org of orgs) {
const username = (0, ts_types_1.ensureString)(org.username);
const aliases = stateAggregator.aliases.getAll(username) ?? undefined;
// Get a list of configuration values that are set to either the username or one
// of the aliases
const configs = config
.filter((c) => aliases.includes(c.value) || c.value === username)
.map((c) => c.key);
try {
// prevent ConfigFile collision bug
// eslint-disable-next-line no-await-in-loop
const authInfo = await AuthInfo.create({ username });
const authFields = authInfo.getFields();
const { orgId, instanceUrl, devHubUsername, expirationDate, isDevHub } = authFields;
const isScratchOrg = Boolean(devHubUsername) || Boolean(authFields[org_1.Org.Fields.IS_SCRATCH]);
const expDate = expirationDate ?? authFields[org_1.Org.Fields.TRIAL_EXPIRATION_DATE];
// eslint-disable-next-line no-await-in-loop
const hasSandboxFile = await stateAggregator.sandboxes.hasFile(orgId);
const isSandbox = Boolean(authFields[org_1.Org.Fields.IS_SANDBOX]) || hasSandboxFile;
final.push({
aliases,
configs,
username,
instanceUrl,
isScratchOrg,
isDevHub: isDevHub ?? false,
isSandbox,
orgId: orgId,
accessToken: authInfo.getConnectionOptions().accessToken,
oauthMethod: authInfo.isJwt() ? 'jwt' : authInfo.isOauth() ? 'web' : 'token',
isExpired: isScratchOrg && expDate ? new Date((0, ts_types_1.ensureString)(expDate)).getTime() < new Date().getTime() : 'unknown',
});
}
catch (err) {
final.push({
aliases,
configs,
username,
orgId: org.orgId,
instanceUrl: org.instanceUrl,
accessToken: undefined,
oauthMethod: 'unknown',
error: err.message,
isExpired: 'unknown',
});
}
}
return final.filter(orgAuthFilter);
}
/**
* Returns true if one or more authentications are persisted.
*/
static async hasAuthentications() {
try {
const auths = await (await stateAggregator_1.StateAggregator.getInstance()).orgs.list();
return !(0, kit_1.isEmpty)(auths);
}
catch (err) {
const error = err;
if (error.name === 'OrgDataNotAvailableError' || error.code === 'ENOENT') {
return false;
}
throw error;
}
}
/**
* Get the authorization URL.
*
* @param options The options to generate the URL.
*/
static getAuthorizationUrl(options, oauth2) {
// Unless explicitly turned off, use a code verifier for enhanced security
const oauth2Verifier = oauth2 ?? new jsforce_node_1.OAuth2({ useVerifier: true, ...options });
// The state parameter allows the redirectUri callback listener to ignore request
// that don't contain the state value.
const params = {
state: options.state ?? (0, node_crypto_1.randomBytes)(Math.ceil(6)).toString('hex'),
prompt: 'login',
// Default connected app is 'refresh_token api web'
scope: options.scope ?? kit_1.env.getString('SFDX_AUTH_SCOPES', 'refresh_token api web'),
};
return oauth2Verifier.getAuthorizationUrl(params);
}
/**
* Parse a sfdx auth url, usually obtained by `authInfo.getSfdxAuthUrl`.
*
* @example
* ```
* await AuthInfo.create(AuthInfo.parseSfdxAuthUrl(sfdxAuthUrl));
* ```
* @param sfdxAuthUrl
*/
static parseSfdxAuthUrl(sfdxAuthUrl) {
const match = sfdxAuthUrl.match(/^force:\/\/([a-zA-Z0-9._-]+={0,2}):([a-zA-Z0-9._-]*={0,2}):([a-zA-Z0-9._-]+={0,2})@([a-zA-Z0-9:._-]+)/);
if (!match) {
throw new sfError_1.SfError(messages.getMessage('invalidSfdxAuthUrlError'), 'INVALID_SFDX_AUTH_URL');
}
const [, clientId, clientSecret, refreshToken, loginUrl] = match;
return {
clientId,
clientSecret,
refreshToken,
loginUrl: `https://${loginUrl}`,
};
}
/**
* Given a set of decrypted fields and an authInfo, determine if the org belongs to an available
* dev hub, or if the org is a sandbox of another CLI authed production org.
*
* @param fields
* @param orgAuthInfo
*/
static async identifyPossibleScratchOrgs(fields, orgAuthInfo) {
if (kit_1.env.getBoolean('SF_SKIP_SCRATCH_ORG_CHECK')) {
(await logger_1.Logger.child('Common', { tag: 'identifyPossibleScratchOrgs' })).debug('Skipping scratch org identification due to SF_SKIP_SCRATCH_ORG_CHECK');
return;
}
// fields property is passed in because the consumers of this method have performed the decrypt.
// This is so we don't have to call authInfo.getFields(true) and decrypt again OR accidentally save an
// authInfo before it is necessary.
const logger = await logger_1.Logger.child('Common', { tag: 'identifyPossibleScratchOrgs' });
await (0, determineOrg_1.determineOrg)(orgAuthInfo);
// re-read fields after determineOrg may have updated them
const updatedFields = orgAuthInfo.getFields();
// return if we already know the hub org, we know it is a devhub or prod-like, or no orgId present
if (Boolean(updatedFields.isDevHub) || Boolean(updatedFields.devHubUsername) || !updatedFields.orgId)
return;
if (updatedFields.isSandbox) {
logger.debug('determineOrg already identified org as sandbox, skipping expensive org scan');
return;
}
// for scratch orgs, skip the full listAllAuthorizations + sandbox identification and
// instead do a lightweight DevHub lookup using raw configs (no AuthInfo creation/decryption)
if (updatedFields.isScratch) {
logger.debug('determineOrg identified org as scratch, doing lightweight DevHub lookup');
const stateAggregator = await stateAggregator_1.StateAggregator.getInstance();
const allConfigs = await stateAggregator.orgs.readAll();
const devHubUsernames = allConfigs.filter((c) => c.isDevHub).map((c) => (0, ts_types_1.ensureString)(c.username));
for (const hubUsername of devHubUsernames) {
try {
// eslint-disable-next-line no-await-in-loop
const soi = await AuthInfo.queryScratchOrg(hubUsername, updatedFields.orgId);
// eslint-disable-next-line no-await-in-loop
await orgAuthInfo.save({
...fields,
devHubUsername: hubUsername,
expirationDate: soi.ExpirationDate,
isScratch: true,
});
logger.debug(`set ${hubUsername} as devhub for scratch org ${orgAuthInfo.getUsername()}`);
return;
}
catch (error) {
if (error instanceof Error && error.name === 'NoActiveScratchOrgFound') {
logger.debug(`devhub ${hubUsername} does not own this scratch org`);
}
else {
logger.debug(`Error connecting to devhub ${hubUsername}`, error);
}
}
}
return;
}
logger.debug('getting devHubs and prod orgs to identify scratch orgs and sandboxes');
// TODO: someday we make this easier by asking the org if it is a scratch org
const allOrgs = await AuthInfo.listAllAuthorizations();
const hubAuthInfos = allOrgs.filter((org) => org.isDevHub);
// skip sandbox identification if the instance URL is not sandbox-like
// enhanced domains: *.sandbox.my.salesforce.com; pre-enhanced My Domain: company--sbxname.my.salesforce.com
const isSandboxLikeUrl = fields.instanceUrl != null && (fields.instanceUrl.includes('.sandbox.') || fields.instanceUrl.includes('--'));
const possibleProdOrgs = isSandboxLikeUrl ? allOrgs.filter((org) => !org.isScratchOrg && !org.isSandbox) : [];
if (!isSandboxLikeUrl) {
logger.debug('instance URL is not sandbox-like, skipping sandbox identification');
}
logger.debug(`found ${hubAuthInfos.length} DevHubs`);
logger.debug(`found ${possibleProdOrgs.length} possible prod orgs`);
if (hubAuthInfos.length === 0 && possibleProdOrgs.length === 0) {
return;
}
let identified = false;
// ask all those orgs if they know this orgId, but stop once one claims it
await Promise.all([
...hubAuthInfos.map(async (hubAuthInfo) => {
if (identified)
return;
try {
const soi = await AuthInfo.queryScratchOrg(hubAuthInfo.username, fields.orgId);
if (identified)
return;
identified = true;
logger.debug(`found orgId ${fields.orgId ?? '<undefined>'} in devhub ${hubAuthInfo.username}`);
try {
await orgAuthInfo.save({
...fields,
devHubUsername: hubAuthInfo.username,
expirationDate: soi.ExpirationDate,
isScratch: true,
});
logger.debug(`set ${hubAuthInfo.username} as devhub and expirationDate ${soi.ExpirationDate} for scratch org ${orgAuthInfo.getUsername()}`);
}
catch (error) {
logger.debug(`error updating auth file for ${orgAuthInfo.getUsername()}`, error);
}
}
catch (error) {
if (error instanceof Error && error.name === 'NoActiveScratchOrgFound') {
logger.error(`devhub ${hubAuthInfo.username} has no scratch orgs`, error);
}
else {
logger.error(`Error connecting to devhub ${hubAuthInfo.username}`, error);
}
}
}),
...possibleProdOrgs.map(async (pOrgAuthInfo) => {
if (identified)
return;
const found = await AuthInfo.identifyPossibleSandbox(pOrgAuthInfo, fields, orgAuthInfo, logger);
if (found)
identified = true;
}),
]);
}
/**
* Find all dev hubs available in the local environment.
*/
static async getDevHubAuthInfos() {
return AuthInfo.listAllAuthorizations((possibleHub) => possibleHub?.isDevHub ?? false);
}
static async identifyPossibleSandbox(possibleProdOrg, fields, orgAuthInfo, logger) {
if (!fields.orgId) {
return false;
}
try {
const prodOrg = await org_1.Org.create({ aliasOrUsername: possibleProdOrg.username });
const sbxProcess = await prodOrg.querySandboxProcessByOrgId(fields.orgId);
if (!sbxProcess?.SandboxInfoId) {
return false;
}
logger.debug(`${fields.orgId} is a sandbox of ${possibleProdOrg.username}`);
try {
await orgAuthInfo.save({
...fields,
isScratch: false,
isSandbox: true,
});
}
catch (err) {
logger.debug(`error updating auth file for: ${orgAuthInfo.getUsername()}`, err);
throw err; // rethrow; don't want a sandbox config file with an invalid auth file
}
try {
// set the sandbox config value
const sfSandbox = {
sandboxUsername: fields.username,
sandboxOrgId: fields.orgId,
prodOrgUsername: possibleProdOrg.username,
sandboxName: sbxProcess.SandboxName,
sandboxProcessId: sbxProcess.Id,
sandboxInfoId: sbxProcess.SandboxInfoId,
timestamp: new Date().toISOString(),
};
const stateAggregator = await stateAggregator_1.StateAggregator.getInstance();
stateAggregator.sandboxes.set(fields.orgId, sfSandbox);
logger.debug(`writing sandbox auth file for: ${orgAuthInfo.getUsername()} with ID: ${fields.orgId}`);
await stateAggregator.sandboxes.write(fields.orgId);
}
catch (e) {
logger.debug(`error writing sandbox auth file for: ${orgAuthInfo.getUsername()}`, e);
}
return true;
}
catch (err) {
logger.debug(`${fields.orgId} is not a sandbox of ${possibleProdOrg.username}`);
return false;
}
}
/**
* Checks active scratch orgs to match by the ScratchOrg field (the 15-char org id)
* if you pass an 18-char scratchOrgId, it will be trimmed to 15-char for query purposes
* Throws is no matching scratch org is found
*/
static async queryScratchOrg(devHubUsername, scratchOrgId) {
const devHubOrg = await org_1.Org.create({ aliasOrUsername: devHubUsername });
const trimmedId = (0, sfdc_1.trimTo15)(scratchOrgId);
const conn = devHubOrg.getConnection();
const data = await conn.query(`select Id, ExpirationDate, ScratchOrg from ScratchOrgInfo where ScratchOrg = '${trimmedId}' and Status = 'Active'`);
// where ScratchOrg='00DDE00000485Lg' will return a record for both 00DDE00000485Lg and 00DDE00000485LG.
// this is our way of enforcing case sensitivity on a 15-char Id (which is unfortunately how ScratchOrgInfo stores it)
const result = data.records.filter((r) => r.ScratchOrg === trimmedId)[0];
if (result)
return result;
throw new sfError_1.SfError(`DevHub ${devHubUsername ?? '<undefined>'} has no active scratch orgs that match ${trimmedId}`, 'NoActiveScratchOrgFound');
}
/**
* Get the username.
*/
getUsername() {
return this.username;
}
/**
* Returns true if `this` is using the JWT flow.
*/
isJwt() {
const { refreshToken, privateKey } = this.getFields();
return !refreshToken && !!privateKey;
}
/**
* Returns true if `this` is using an access token flow.
*/
isAccessTokenFlow() {
const { refreshToken, privateKey } = this.getFields();
return !refreshToken && !privateKey;
}
/**
* Returns true if `this` is using the oauth flow.
*/
isOauth() {
return !this.isAccessTokenFlow() && !this.isJwt();
}
/**
* Returns true if `this` is using the refresh token flow.
*/
isRefreshTokenFlow() {
const { refreshToken, authCode } = this.getFields();
return !authCode && !!refreshToken;
}
/**
* Updates the cache and persists the authentication fields (encrypted).
*
* @param authData New data to save.
*/
async save(authData) {
this.update(authData);
const username = (0, ts_types_1.ensure)(this.getUsername());
if ((0, sfdc_1.matchesOpaqueAccessToken)(username)) {
this.logger.debug('Username is an accesstoken. Skip saving authinfo to disk.');
return this;
}
await this.stateAggregator.orgs.write(username);
this.logger.info(`Saved auth info for username: ${username}`);
return this;
}
/**
* Update the authorization fields, encrypting sensitive fields, but do not persist.
* For convenience `this` object is returned.
*
* @param authData Authorization fields to update.
*/
update(authData) {
if (authData && (0, ts_types_1.isPlainObject)(authData)) {
if (authData.instanceApiVersion && !(0, sfdc_1.validateApiVersion)(authData.instanceApiVersion)) {
this.logger.warn(`Ignoring invalid instanceApiVersion "${authData.instanceApiVersion}" (expected format: "XX.0")`);
authData.instanceApiVersion = undefined;
}
this.username = authData.username ?? this.username;
this.stateAggregator.orgs.update(this.username, authData);
this.logger.info(`Updated auth info for username: ${this.username}`);
}
return this;
}
/**
* Get the auth fields (decrypted) needed to make a connection.
*
* @param clientApp Name of the CA/ECA associated with the user.
*/
getConnectionOptions(clientApp) {
const decryptedCopy = this.getFields(true);
const { accessToken, instanceUrl, loginUrl } = decryptedCopy;
// return main app auth fields
if (!clientApp) {
if (this.isAccessTokenFlow()) {
this.logger.info('Returning fields for a connection using access token.');
// Just auth with the accessToken
return { accessToken, instanceUrl, loginUrl };
}
if (this.isJwt()) {
this.logger.info('Returning fields for a connection using JWT config.');
return {
accessToken,
instanceUrl,
refreshFn: this.refreshFn.bind(this),
};
}
// @TODO: figure out loginUrl and redirectUri (probably get from config class)
//
// redirectUri: org.config.getOauthCallbackUrl()
// loginUrl: this.fields.instanceUrl || this.config.getAppConfig().sfdcLoginUrl
this.logger.info('Returning fields for a connection using OAuth config.');
// Decrypt a user provided client secret or use the default.
return {
oauth2: {
loginUrl: instanceUrl ?? sfdcUrl_1.SfdcUrl.PRODUCTION,
clientId: this.getClientId(),
redirectUri: this.getRedirectUri(),
},
accessToken,
instanceUrl,
refreshFn: this.refreshFn.bind(this),
};
}
if (!decryptedCopy.clientApps) {
throw new sfError_1.SfError(`${this.username} does not have any client app linked.`);
}
if (!(clientApp in decryptedCopy.clientApps)) {
throw new sfError_1.SfError(`${this.username} does not have a "${clientApp}" client app linked.`);
}
const decryptedApp = decryptedCopy.clientApps[clientApp];
return {
oauth2: {
loginUrl: instanceUrl ?? sfdcUrl_1.SfdcUrl.PRODUCTION,
clientId: decryptedApp.clientId,
redirectUri: this.getRedirectUri(),
},
accessToken: decryptedApp.accessToken,
instanceUrl,
// Specific refreshFn for AuthInfo's clientApps.
//
// Each client app stores the oauth flow used for its initial auth, here we ensure each refresh returns
// a token, update the auth file with it and send it back to jsforce's through the callback.
refreshFn: async (_conn, callback) => {
// This only handles refresh for web flow.
// When more flows are supported for client apps, check the `app.oauthFlow` field to set the appropiate refresh helper.
const authFields = await this.buildRefreshTokenConfig({
clientId: decryptedApp.clientId,
clientSecret: decryptedApp.clientSecret,
refreshToken: decryptedApp.refreshToken,
loginUrl: instanceUrl,
});
await this.save({
clientApps: {
...decryptedCopy.clientApps,
[clientApp]: {
accessToken: (0, ts_types_1.ensureString)(authFields.accessToken),
clientId: decryptedApp.clientId,
clientSecret: decryptedApp.clientSecret,
// Persist the server-returned refresh token (rotated under RTR), falling back to the one we
// sent when the response omits it (RTR off). Persisting the old token here would send an
// invalidated credential on the next refresh once RTR is enabled on this connected app.
refreshToken: (0, ts_types_1.ensureString)(authFields.refreshToken ?? decryptedApp.refreshToken),
oauthFlow: 'web',
},
},
});
await callback(null, authFields.accessToken);
},
};
}
getClientId() {
return this.getFields()?.clientId ?? exports.DEFAULT_CONNECTED_APP_INFO.clientId;
}
getRedirectUri() {
return 'http://localhost:1717/OauthRedirect';
}
/**
* Get the authorization fields.
*
* @param decrypt Decrypt the fields.
*
* Returns a ReadOnly object of the fields. If you need to modify the fields, use AuthInfo.update()
*/
getFields(decrypt) {
return this.stateAggregator.orgs.get(this.username, decrypt) ?? {};
}
/**
* Get the org front door (used for web based oauth flows)
*
* @deprecated Will be removed in the next major version. Use the `Org.getFrontDoorUrl()` method instead.
*/
getOrgFrontDoorUrl() {
const authFields = this.getFields(true);
const base = (0, ts_types_1.ensureString)(authFields.instanceUrl).replace(/\/+$/, '');
const accessToken = (0, ts_types_1.ensureString)(authFields.accessToken);
return `${base}/secur/frontdoor.jsp?sid=${accessToken}`;
}
/**
* Returns true if this org is using access token auth.
*/
isUsingAccessToken() {
return this.usingAccessToken;
}
/**
* Get the SFDX Auth URL.
*
* **See** [SFDX Authorization](https://developer.salesforce.com/docs/atlas.en-us.sfdx_cli_reference.meta/sfdx_cli_reference/cli_reference_force_auth.htm#cli_reference_force_auth)
*/
getSfdxAuthUrl() {
const { clientId, clientSecret, refreshToken, instanceUrl } = this.getFields(true);
// host includes an optional port on the instanceUrl
const url = new URL((0, ts_types_1.ensure)(instanceUrl, 'undefined instanceUrl')).host;
const clientIdAndSecret = clientId ? `${clientId}:${clientSecret ?? ''}` : '';
const token = (0, ts_types_1.ensure)(refreshToken, 'undefined refreshToken');
return `force://${clientIdAndSecret}:${token}@${url}`;
}
/**
* Convenience function to handle typical side effects encountered when dealing with an AuthInfo.
* Given the values supplied in parameter sideEffects, this function will set auth alias, default auth
* and default dev hub.
*
* @param sideEffects - instance of AuthSideEffects
*/
async handleAliasAndDefaultSettings(sideEffects) {
if (Boolean(sideEffects.alias) ||
sideEffects.setDefault ||
sideEffects.setDefaultDevHub ||
typeof sideEffects.setTracksSource === 'boolean') {
if (sideEffects.alias)
await this.setAlias(sideEffects.alias);
if (sideEffects.setDefault)
await this.setAsDefault({ org: true });
if (sideEffects.setDefaultDevHub)
await this.setAsDefault({ devHub: true });
if (typeof sideEffects.setTracksSource === 'boolean') {
await this.save({ tracksSource: sideEffects.setTracksSource });
}
else {
await this.save();
}
}
}
/**
* Set the target-env (default) or the target-dev-hub to the alias if
* it exists otherwise to the username. Method will try to set the local
* config first but will default to global config if that fails.
*
* @param options
*/
async setAsDefault(options = { org: true }) {
let config;
// if we fail to create the local config, default to the global config
try {
config = await config_1.Config.create({ isGlobal: false });
}
catch {
config = await config_1.Config.create({ isGlobal: true });
}
const username = (0, ts_types_1.ensureString)(this.getUsername());
const alias = this.stateAggregator.aliases.get(username);
const value = alias ?? username;
if (options.org) {
config.set(orgConfigProperties_1.OrgConfigProperties.TARGET_ORG, value);
}
if (options.devHub) {
config.set(orgConfigProperties_1.OrgConfigProperties.TARGET_DEV_HUB, value);
}
await config.write();
}
/**
* Sets the provided alias to the username
*
* @param alias alias to set
*/
async setAlias(alias) {
return this.stateAggregator.aliases.setAndSave(alias, this.getUsername());
}
/**
* Initializes an instance of the AuthInfo class.
*/
async init() {
this.stateAggregator = await stateAggregator_1.StateAggregator.getInstance();
const username = this.options.username;
const authOptions = this.options.oauth2Options ?? this.options.accessTokenOptions;
// Must specify either username and/or options
if (!username && !authOptions) {
throw messages.createError('authInfoCreationError');
}
// If a username AND oauth options, ensure an authorization for the username doesn't
// already exist. Throw if it does so we don't overwrite the authorization.
if (username && authOptions) {
if (await this.stateAggregator.orgs.hasFile(username)) {
throw messages.createError('authInfoOverwriteError');
}
}
const oauthUsername = username ?? authOptions?.username;
if (oauthUsername) {
this.username = oauthUsername;
await this.stateAggregator.orgs.read(oauthUsername, false, false);
} // Else it will be set in initAuthOptions below.
// If the username is an access token, use that for auth and don't persist
if ((0, ts_types_1.isString)(oauthUsername) && (0, sfdc_1.matchesOpaqueAccessToken)(oauthUsername)) {
// Need to initAuthOptions the logger and authInfoCrypto since we don't call init()
this.logger = await logger_1.Logger.child('AuthInfo');
const aggregator = await configAggregator_1.ConfigAggregator.create();
const instanceUrl = this.getInstanceUrl(aggregator, authOptions);
this.update({
accessToken: oauthUsername,
instanceUrl,
orgId: oauthUsername.split('!')[0],
loginUrl: instanceUrl,
});
this.usingAccessToken = true;
}
// If a username with NO oauth options, ensure authorization already exist.
else if (username && !authOptions && !(await this.stateAggregator.orgs.exists(username))) {
const likeName = (0, findSuggestion_1.findSuggestion)(username, [
...(await this.stateAggregator.orgs.list()).map((f) => f.split('.json')[0]),
...Object.keys(this.stateAggregator.aliases.getAll()),
]);
throw sfError_1.SfError.create({
name: 'NamedOrgNotFoundError',
message: messages.getMessage('namedOrgNotFound', [username]),
actions: likeName === ''
? undefined
: [`It looks like you mistyped the username or alias. Did you mean "${likeName}"?`],
});
}
else {
await this.initAuthOptions(authOptions);
}
}
getInstanceUrl(aggregator, options) {
const instanceUrl = options?.instanceUrl ?? aggregator.getPropertyValue(orgConfigProperties_1.OrgConfigProperties.ORG_INSTANCE_URL);
return instanceUrl ?? sfdcUrl_1.SfdcUrl.PRODUCTION;
}
/**
* Initialize this AuthInfo instance with the specified options. If options are not provided, initialize it from cache
* or by reading from the persistence store. For convenience `this` object is returned.
*
* @param options Options to be used for creating an OAuth2 instance.
*
* **Throws** *{@link SfError}{ name: 'NamedOrgNotFoundError' }* Org information does not exist.
* @returns {Promise<AuthInfo>}
*/
async initAuthOptions(options) {
this.logger = await logger_1.Logger.child('AuthInfo');
// If options were passed, use those before checking cache and reading an auth file.
let authConfig;
if (options) {
options = structuredClone(options);
if (this.isTokenOptions(options)) {
authConfig = options;
const userInfo = await this.retrieveUserInfo((0, ts_types_1.ensureString)(options.instanceUrl), (0, ts_types_1.ensureString)(options.accessToken));
this.update({ username: userInfo?.username, orgId: userInfo?.organizationId });
}
else {
if (this.options.parentUsername) {
if (process.env.SF_SCRATCH_SIGNUP_CONNECTED_APP) {
// When the signup app is overridden, use it as the clientId for the auth code exchange.
// Skip loading parent fields entirely — we don't need the parent's privateKey/clientSecret
// and the parent org may not be in the StateAggregator cache.
options.clientId = process.env.SF_SCRATCH_SIGNUP_CONNECTED_APP;
if (process.env.SFDX_CLIENT_SECRET) {
options.clientSecret = process.env.SFDX_CLIENT_SECRET;
}
}
else {
const parentFields = await this.loadDecryptedAuthFromConfig(this.options.parentUsername);
options.clientId = parentFields.clientId;
if (process.env.SFDX_CLIENT_SECRET) {
options.clientSecret = process.env.SFDX_CLIENT_SECRET;
}
else {
// Grab whatever flow is defined
Object.assign(options, {
clientSecret: parentFields.clientSecret,
privateKey: parentFields.privateKey ? (0, node_path_1.resolve)(parentFields.privateKey) : parentFields.privateKey,
});
}
}
}
// jwt flow
// Support both sfdx and jsforce private key values
if (!options.privateKey && options.privateKeyFile) {
options.privateKey = (0, node_path_1.resolve)(options.privateKeyFile);
}
// Route to JWT only when there's a privateKey AND no refreshToken. A legitimate JWT auth never
// carries a refreshToken (authJwt writes none, and `login jwt` deletes-then-recreates the auth file).
// The edge case that contains BOTH a privateKey AND a refreshToken is when auth was originally JWT
// and later switched to Web auth. Web auth does not delete the existing auth file, so `AuthInfo.update`
// merges over the old file. In that case the refreshToken is the intended credential, so fall
// through to the refresh-token flow instead of misrouting into JWT, which also matches isJwt().
if (options.privateKey && !options.refreshToken) {
authConfig = await this.authJwt(options);
}
else if (!options.authCode && options.refreshToken) {
// refresh token flow (from sfdxUrl or OAuth refreshFn).
// RTR: this POST rotates the token server-side and invalidates the old one immediately. Persist the
// rotated token right here, before the enrichment steps below (determineIfDevHub, orgs.read,
// update/encrypt, determineOrg) run. A throw anywhere in that window would otherwise strand the
// rotated token in memory while the old one is already dead server-side, permanently breaking the
// auth until re-login. Saving now closes that window; enrichment and the caller's save() still run
// and layer the org metadata on top of the already-persisted token.
authConfig = await this.buildRefreshTokenConfig(options);
this.update(authConfig);
await this.save();
}
else if (this.options.oauth2 instanceof jsforce_node_1.OAuth2) {
// authcode exchange / web auth flow
authConfig = await this.exchangeToken(options, this.options.oauth2);
}
else {
authConfig = await this.exchangeToken(options);
}
}
authConfig.isDevHub = await this.determineIfDevHub((0, ts_types_1.ensureString)(authConfig.instanceUrl), (0, ts_types_1.ensureString)(authConfig.accessToken));
if (authConfig.username)
await this.stateAggregator.orgs.read(authConfig.username, false, false);
// Update the auth fields WITH encryption
this.update(authConfig);
// A web/auth-code or refresh-token authorization is never a JWT one, so it must not carry a
// privateKey. When this flow overwrites an existing auth file (e.g. the user was JWT-authed for
// this org, then re-authed via web), the save path merges (Object.assign) over the existing
// file and would otherwise retain the stale privateKey, which later misroutes refreshFn into
// the JWT flow. Only clear it when a stale value actually lingers so we don't add an empty key
// to a fresh authorization.
if (!authConfig.privateKey && this.getFields().privateKey) {
this.stateAggregator.orgs.update(this.username, { privateKey: undefined });
}
// Populate Organization metadata (orgEdition, isScratch, isSandbox, etc.) in a single query.
await (0, determineOrg_1.determineOrg)(this);
}
return this;
}
// eslint-disable-next-line @typescript-eslint/require-await
async loadDecryptedAuthFromConfig(username) {
// Fetch from the persisted auth file
const authInfo = this.stateAggregator.orgs.get(username, true);
if (!authInfo) {
throw messages.createError('namedOrgNotFound', [username]);
}
return authInfo;
}
isTokenOptions(options) {
// Although OAuth2Config does not contain refreshToken, privateKey, or privateKeyFile, a JS consumer could still pass those in
// which WILL have an access token as well, but it should be considered an OAuth2Config at that point.
return ('accessToken' in options &&
!('refreshToken' in options) &&
!('privateKey' in options) &&
!('privateKeyFile' in options) &&
!('authCode' in options));
}
// A callback function for a connection to refresh an access token. This is used
// both for a JWT connection and an OAuth connection.
async refreshFn(_conn, callback) {
this.logger.info('Access token has expired. Updating...');
try {
const fields = this.getFields(true);
// JWT auth mints a fresh access token from a locally-signed assertion and consumes no stored credential,
// so concurrent JWT refreshes are independent and safe. The refresh-token flow is different: every caller
// refreshes by re-sending the *same persisted refresh token* read from the auth file, and with Refresh
// Token Rotation enabled the server returns a new refresh token and invalidates the old one *immediately*.
// So two connections/processes refreshing at once would both send that on-disk token and double-rotate the
// auth. Serialize just that flow.
//
// Gate on `refreshToken` alone (not `refreshToken && !privateKey`): a legitimate JWT auth never carries
// a refresh token, so this still excludes JWT, and it also covers the fixed edge case where both-fields
// exist from an original JWT login and switching to web (stale privateKey + real refreshToken), which
// initAuthOptions routes through the refresh-token flow and which therefore must take the lock too.
// See the router in initAuthOptions and isJwt().
if (fields.refreshToken) {
await this.refreshWithTokenRotationLock(fields.refreshToken);
}
else {
// This method will request the new access token and save to the current AuthInfo instance (but don't persist them!).
await this.initAuthOptions(fields);
// Persist fields with refreshed access token to auth file.
await this.save();
}
// Pass new access token to the jsforce's session-refresh callback for proper propagation:
// https://jsforce.github.io/jsforce/types/session_refresh_delegate.SessionRefreshFunc.html
const { accessToken } = this.getFields(true);
return await callback(null, accessToken);
}
catch (err) {
const error = err;
if (error?.message?.includes('Data Not Available')) {
// Set cause to keep original stacktrace
return callback(messages.createError('orgDataNotAvailableError', [this.getUsername()], [], error));
}
return callback(error);
}
}
/**
* Single-flight a refresh-token refresh so two connections/processes can't rotate the same token at once.
*
* With Refresh Token Rotation (RTR) enabled, each refresh returns a new refresh token and invalidates the
* previous one; a second refresh sent with the now-stale refresh token fails and invalidates the current token too.
* We take a cross-process lock (which also serializes same-process contenders), then re-read the latest
* tokens from disk: if another actor already rotated while we waited, we adopt their fresh credentials
* instead of refreshing again with our now-invalid token.
*
* Each pass is one call to `tryRotateOrAdopt`, which (1) adopts if disk already holds a rotated token, else
* (2) tries to acquire the lock and, once held, re-checks-then-refreshes. If we can't acquire (a live holder
* is mid-rotation, ELOCKED), we do NOT fall back to an unlocked refresh, which would race the holder and double-rotate
* under RTR; we simply loop back to (1) and re-run the pass (re-read disk, then try to acquire the lock again).
* `proper-lockfile` only grants the lock on genuine release or genuine staleness (a live
* holder refreshes its lock mtime ~every 5s and so is never stolen from), so looping converges: a slow holder is waited out,
* a dead holder's lock crosses the ~10s stale line and is stolen on a later attempt.
* Only a holder that keeps the lock alive for the whole budget below yields the timeout error.
*
* The lock uses a dedicated `<authfile>.token-rotation.lock`, kept separate from ConfigFile's
* own `<authfile>.lock` write lock, because proper-lockfile is not re-entrant: holding the auth-file lock
* here and then letting save() re-acquire the same path would self-block.
*
* KNOWN LIMITATIONS (all inherent, not bugs):
*
* (1) Only actors that take THIS lock are serialized. Within a single CLI release this is a non-issue: the
* CLI deduplicates @salesforce/core to one version, so every in-process refresher runs this same locking
* code (and the lock path is derived deterministically, so differing versions that BOTH lock still
* interoperate). The realistic gap is a 2nd/3rd-party plugin: those install under the CLI's plugin data dir
* with their OWN non-deduped node_modules and may bundle a core version predating this lock. Such a plugin
* (or any external tool that refreshes the token directly) won't honor `<authfile>.token-rotation.lock` and
* can still double-rotate the same auth while we hold it. There is no server-side coordination to prevent this.
*
* (2) On the web runtime (Global.isWeb), lockInit is a no-op that takes no lock (matching existing
* ConfigFile behavior), so nothing is serialized there.
*
* (3) A genuinely-stuck holder makes this block for up to the budget below plus one in-flight lock cycle
* (~40s) before throwing. Because that happens inside a live jsforce session-refresh, an upstream client
* with a shorter timeout may give up first with a less specific error. Only the pathological stuck case
* pays this; the common paths return in one lock cycle or via the lock-free adopt.
*
* @param heldRefreshToken the refresh token this connection currently holds (read from the auth file); the
* baseline we compare against disk to detect a rotation, and the token we would send if we do rotate.
*/
async refreshWithTokenRotationLock(heldRefreshToken) {
const username = (0, ts_types_1.ensure)(this.getUsername());
// Cutoff for STARTING another attempt -- not a wall-clock cap on the whole method. Each attempt either
// finishes (adopt or refresh) or reports contention (ELOCKED); we re-attempt ONLY while contended. One
// lockInit acquisition cycle is ~10s (its lockRetryOptions retry budget before it throws ELOCKED) and the
// cutoff is consulted only between attempts, so a genuinely stuck holder pushes the actual time-to-throw
// to roughly this cutoff plus one in-flight cycle. Sizing it to ~35s (~3.5x a cycle) starts a 3rd attempt
// at ~20s with room to spare and leaves headroom for a 4th: enough for a dead holder's lock to cross the
// 10s stale line and be stolen on a later attempt, and for a slow-but-live holder to release.
const attemptDeadline = Date.now() + kit_1.Duration.seconds(35).milliseconds;
while (Date.now() < attemptDeadline) {
// eslint-disable-next-line no-await-in-loop
if (await this.tryRotateOrAdopt(username, heldRefreshToken)) {
return;
}
// Otherwise the lock was contended (a live holder is mid-rotation): loop and re-attempt. We never
// refresh unlocked, that would race the holder and double-rotate under RTR. No sleep needed: lockInit
// already backed off ~10s, and the next attempt re-reads disk before doing anything.
}
// Past the cutoff with every attempt still contended: a process kept the rotation lock's mtime fresh for
// the entire budget without completing. Genuinely stuck, not merely slow or crashed (a crash lets the
// lock go stale and be stolen by an attempt above).
throw messages.createError('refreshTokenRotationTimeoutError', [username]);
}
/**
* One rotation attempt: adopt an already-rotated token if disk has one, else take the lock and
* double-check-then-rotate under it.
*
* @returns `true` if we adopted or refreshed (caller is done); `false` if the lock was contended
* (`ELOCKED`) and the caller should re-attempt. Throws on any non-`ELOCKED` failure.
*/
async tryRotateOrAdopt(username, heldRefreshToken) {
// Fast path / adopt: if another connection or process already rotated the token, adopt those fresh
// credentials without taking the lock. On the first pass this thins the herd (late arrivals never
// contend); on later passes it catches a holder that rotated and released while we were waiting.
if (await this.adoptIfAlreadyRotated(username, heldRefreshToken)) {
return true;
}
// proper-lockfile appends `.lock`, so this locks `<authfile>.token-rotation.lock`.
const lockPath = `${this.stateAggregator.orgs.getPath(username)}.token-rotation`;
let unlock;
try {
({ unlock } = await (0, fileLocking_1.lockInit)(lockPath));
}
catch (err) {
// Contended: a live holder is mid-rotation. Report it so the caller re-attempts (never refreshing
// unlocked, which would double-rotate under RTR). Any non-ELOCKED error is a real failure.
if (err?.code === 'ELOCKED') {
return false;
}
throw err;
}
// We hold the lock. Run to completion regardless of the caller's cutoff -- we never abandon a rotation we
// hold the lock for; the cutoff only gates whether a NEW attempt starts.
try {
// Double-check under the lock: the holder we queued behind may have rotated while we waited.
if (await this.adoptIfAlreadyRotated(username, heldRefreshToken)) {
return true;
}
// No one rotated: perform the refresh (updates this instance in memory) and persist the new token
// before we release the lock, so everyone waiting behind us adopts it instead of re-rotating.
await this.initAuthOptions(this.getFields(true));
await this.save();
return true;
}
finally {
await unlock();
}
}
/**
* Re-read the on-disk auth (without disturbing this instance's in-memory fields) and, if another
* connection/process has already rotated the refresh token, adopt those fresh credentials. Disk is
* already current in that case, so no save is needed.
*
* NOTE: this gates on the refresh token only, not access-token freshness (we don't persist access-token
* expiry). If the adopted access token has since expired, jsforce gets a 401 and re-enters refreshFn,
* which then rotates under the lock (our refresh token now matches disk, so the double-check falls through
* to a real refresh). That is one extra round trip in a narrow case, and still strictly better than the
* pre-adopt behavior, where refreshing with our rotated-out token would have failed outright.
*
* @returns true if a rotated token was adopted; false if the on-disk token still matches ours.
*/
async adoptIfAlreadyRotated(username, heldRefreshToken) {
const onDisk = await this.stateAggregator.orgs.peek(username, true);
if (onDisk?.refreshToken && onDisk.refreshToken !== heldRefreshToken) {
this.logger.info('Refresh token was already rotated by another process; adopting refreshed credentials.');
this.update(onDisk);
return true;
}
return false;
}
async readJwtKey(keyFile) {
return fs_1.fs.promises.readFile(keyFile, 'utf8');
}
// Build OAuth config for a JWT auth flow
async authJwt(options) {
if (!options.clientId) {
throw messages.createError('missingClientId');
}
const privateKeyContents = await this.readJwtKey((0, ts_types_1.ensureString)(options.privateKey));
const { loginUrl = sfdcUrl_1.SfdcUrl.PRODUCTION } = options;
const url = new sfdcUrl_1.SfdcUrl(loginUrl);
const createdOrgInstance = (this.getFields().createdOrgInstance ?? '').trim().toLowerCase();
const audienceUrl = await url.getJwtAudienceUrl(createdOrgInstance);
let authFieldsBuilder;
const authErrors = [];
// given that we can no longer depend on instance names or URls to determine audience, let's try them all
const loginAndAudienceUrls = (0, sfdcUrl_1.getLoginAudienceCombos)(audienceUrl, loginUrl);
for (const [login, audience] of loginAndAudienceUrls) {
try {
// sequentially, in probabilistic order
// eslint-disable-next-line no-await-in-loop
authFieldsBuilder = await this.tryJwtAuth(options.clientId, login, audience, privateKeyContents);
break;
}
catch (err) {
const error = err;
const message = error.message.includes('audience')
? `${error.message} [audience=${audience} login=${login}]`
: error.message;
authErrors.push(message);
}
}
if (!authFieldsBuilder) {
// messages.createError expects names to end in `error` and this one says Errors so do it manually.
throw new sfError_1.SfError(messages.getMessage('jwtAuthErrors', [authErrors.join('\n')]), 'JwtAuthError');
}
const authFields = {
accessToken: (0, ts_types_1.asString)(authFieldsBuilder.access_token),
orgId: parseIdUrl((0, ts_types_1.ensureString)(authFieldsBuilder.id)).orgId,
loginUrl: options.loginUrl,
privateKey: options.privateKey,
clientId: options.clientId,
};
const instanceUrl = (0, ts_types_1.ensureString)(authFieldsBuilder.instance_url);
const sfdcUrl = new sfdcUrl_1.SfdcUrl(instanceUrl);
try {
// Check if the url is resolvable. This can fail when my-domains have not been replicated.
await sfdcUrl.lookup();
authFields.instanceUrl = instanceUrl;
}
catch (err) {
this.logger.debug(
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
`Instance URL [${authFieldsBuilder.instance_url}] is not available. DNS lookup failed. Using loginUrl [${options.loginUrl}] instead. This may result in a "Destination URL not reset" error.`);
authFields.instanceUrl = options.loginUrl;
}
return authFields;
}
async tryJwtAuth(clientId, loginUrl, audienceUrl, privateKeyContents) {
const jwtToken = jwt.sign({
iss: clientId,
sub: this.getUsername(),
aud: audienceUrl,
exp: Date.now() + 300,
}, privateKeyContents, {
algorithm: 'RS256',
});
const oauth2 = new jsforce_node_1.OAuth2({ loginUrl });
return (0, ts_types_1.ensureJsonMap)(await oauth2.requestToken({
// eslint-disable-next-line camelcase
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
assertion: jwtToken,
}));
}
// Build OAuth config for a refresh token auth flow
async buildRefreshTokenConfig(options) {
const fullOptions = {
...options,
redirectUri: options.redirectUri ?? this.getRedirectUri(),
// Ideally, this would be removed at some point in the distant future when all auth files
// now have the clientId stored in it.
...(options.clientId
? {}
: { clientId: exports.DEFAULT_CONNECTED_APP_INFO.clientId, clientSecret: exports.DEFAULT_CONNECTED_APP_INFO.clientSecret }),
};
const oauth2 = new jsforce_node_1.OAuth2(fullOptions);
let authFieldsBuilder;
try {
authFieldsBuilder = await oauth2.refreshToken((0, ts_types_1.ensure)(fullOptions.refreshToken));
}
catch (err) {
const cause = err instanceof Error ? err : sfError_1.SfError.wrap(err);
throw messages.createError('refreshTokenAuthError', [cause.message], undefined, cause);
}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const { orgId } = parseIdUrl(authFieldsBuilder.id);
let username = this.getUsername();
if (!username) {
const userInfo = await this.retrieveUserInfo(authFieldsBuilder.instance_url, authFieldsBuilder.access_token);
username = (0, ts_types_1.ensureString)(userInfo?.username);
}
return {
orgId,
username,
accessToken: authFieldsBuilder.access_token,
instanceUrl: authFieldsBuilder.instance_url,
loginUrl: fullOptions.loginUrl ?? authFieldsBuilder.instance_url,
// Refresh Token Rotation (RTR): when the app has RTR enabled, the token endpoint returns a
// NEW refresh_token that we must persist, replacing the one we sent. When RTR is off, the
// response omits refresh_token, so we keep the existing one.
// https://help.salesforce.com/s/articleView?id=sf.remoteaccess_oauth_refresh_token_flow.htm&type=5
refreshToken: authFieldsBuilder.refresh_token ?? fullOptions.refreshToken,
clientId: fullOptions.clientId,
clientSecret: fullOptions.clientSecret,
};
}
/**
* Performs an authCode exchange but the Oauth2 feature of jsforce is extended to include a code_challenge
*
* @param options The oauth options
* @param oauth2 The oauth2 extension that includes a code_challenge
*/
async exchangeToken(options, oauth2 = new jsforce_node_1.OAuth2(options)) {
if (!oauth2.redirectUri) {
oauth2.redirectUri = this.getRedirectUri();
}
if (!oauth2.clientId) {
oauth2.clientId = this.getClientId();
}
// Exchange the auth code for an access token and refresh token.
let authFields;
try {
this.logger.debug(`Exchanging auth code for access token using loginUrl: ${options.loginUrl ?? '<undefined>'}`);
authFields = await oauth2.requestToken((0, ts_types_1.ensure)(options.authCode));
this.logger.debug(`Successfully requested an access token with scopes: "${authFields.scope}".`);
}
catch (err) {
const msg = err instanceof Error ? `${err.name}::${err.message}` : typeof err === 'string' ? err : 'UNKNOWN';
const redacted = (0, filters_1.filterSecrets)(options);
throw sfError_1.SfError.create({
message: messages.getMessage('authCodeExchangeError', [msg]),
name: 'AuthCodeExchangeError',
...(err instanceof Error ? { cause: err } : {}),
data: ((0, ts_types_1.isArray)(redacted) ? redacted[0] : redacted),
});
}
const { orgId } = parseIdUrl(authFields.id);
let username = this.getUsername();
// Only need to query for the username if it isn't known. For example, a new auth code exchange
// rather than refreshing a token on an existing connection.
if (!username) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const userInfo = await this.retrieveUserInfo(authFields.instance_url, authFields.access_token);
username = userInfo?.username;
}
return {
accessToken: authFields.access_token,
instanceUrl: authFields.instance_url,
orgId,
username,
loginUrl: options.loginUrl ?? authFields.instance_url,
refreshToken: authFields.refresh_token,
clientId: options.clientId,
clientSecret: options.clientSecret,
};
}
async retrieveUserInfo(instanceUrl, accessToken) {
// Make a REST call for the username directly. Normally this is done via a connection
// but we don't want to create circular dependencies or lots of snowflakes
// within this file to support it.
const apiVersion = 'v51.0'; // hardcoding to v51.0 just for this call is okay.
const instance = (0, ts_types_1.ensure)(instanceUrl);
const baseUrl = new sfdcUrl_1.SfdcUrl(instance);
const userInfoUrl = `${baseUrl.toString()}services/oauth2/userinfo`;
const headers = Object.assign({ Authorization: `Bearer ${accessToken}` }, connection_1.SFDX_HTTP_HEADERS);
try {
this.logger.info(`Sending request for Username after successful auth code exchange to URL: ${userInfoUrl}`);
let response = await new transport_1.default().httpRequest({ url: userInfoUrl, method: 'GET', headers });
if (response.statusCode >= 400) {
this.throwUserGetException(response);
}
else {
const userInfoJson = (0, kit_1.parseJsonMap)(response.body);
const url = `${baseUrl.toString()}services/data/${apiVersion}/sobjects/User/${userInfoJson.user_id}`;
this.logger.info(`Sending request for User SObject after successful auth code exchange to URL: ${url}`);
response = await new transport_1.default().httpRequest({ url, method: 'GET', headers });
if (response.statusCode >= 400) {
this.throwUserGetException(response);
}
else {
// eslint-disable-next-line camelcase
userInfoJson.preferred_username = (0, kit_1.parseJsonMap)(response.body).Username;
}
return { username: userInfoJson.preferred_username, organizationId: userInfoJson.organization_id };
}
}
catch (err) {
throw messages.createError('authCodeUsernameRetrievalError', [err.message]);
}
}
/**
* Given an error while getting the User object, handle different possibilities of response.body.
*
* @param response
* @private
*/
throwUserGetException(response) {
let errorMsg = '';
const bodyAsString = response.body ?? JSON.stringify({ message: 'UNKNOWN', errorCode: 'UNKNOWN' });
try {
const body = (0, kit_1.parseJson)(bodyAsString);
if ((0, ts_types_1.isArray)(body)) {
errorMsg = body.map((line) => line.message ?? line.errorCode ?? 'UNKNOWN').join(os.EOL);
}
else {
errorMsg = body.message ?? body.errorCode ?? 'UNKNOWN';
}
}
catch (err) {
errorMsg = `${bodyAsString}`;
}
throw new sfError_1.SfError(errorMsg);
}
/**
* Returns `true` if the org is a Dev Hub.
*
* Check access to the ScratchOrgInfo object to determine if the org is a dev hub.
*/
async determineIfDevHub(instanceUrl, accessToken) {
// Make a REST call for the ScratchOrgInfo obj directly. Normally this is done via a connection
// but we don't want to create circular dependencies or lots of snowflakes
// within this file to support it.
const apiVersion = 'v51.0'; // hardcoding to v51.0 just for this call is okay.
const instance = (0, ts_types_1.ensure)(instanceUrl);
const baseUrl = new sfdcUrl_1.SfdcUrl(instance);
const scratchOrgInfoUrl = `${baseUrl.toString()}services/data/${apiVersion}/query?q=SELECT%20Id%20FROM%20ScratchOrgInfo%20limit%201`;
const headers = Object.assign({ Authorization: `Bearer ${accessToken}` }, connection_1.SFDX_HTTP_HEADERS);
try {
const res = await new transport_1.default().httpRequest({ url: scratchOrgInfoUrl, method: 'GET', headers });
if (res.statusCode >= 400) {
return false;
}
return true;
}
catch (err) {
/* Not a dev hub */
return false;
}
}
}
exports.AuthInfo = AuthInfo;
//# sourceMappingURL=authInfo.js.map