UNPKG

@salesforce/core

Version:

Core libraries to interact with SFDX projects, orgs, and APIs.

1,027 lines 49.4 kB
"use strict"; /* * Copyright (c) 2020, salesforce.com, inc. * All rights reserved. * Licensed under the BSD 3-Clause license. * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ /* 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 (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); __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.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 fs = __importStar(require("node:fs")); 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 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 connection_1 = require("./connection"); const orgConfigProperties_1 = require("./orgConfigProperties"); const org_1 = require("./org"); ; 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."]])); // 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: '', }; /** * 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 { orgId, instanceUrl, devHubUsername, expirationDate, isDevHub } = authInfo.getFields(); final.push({ aliases, configs, username, instanceUrl, isScratchOrg: Boolean(devHubUsername), isDevHub: isDevHub ?? false, // eslint-disable-next-line no-await-in-loop isSandbox: await stateAggregator.sandboxes.hasFile(orgId), orgId: orgId, accessToken: authInfo.getConnectionOptions().accessToken, oauthMethod: authInfo.isJwt() ? 'jwt' : authInfo.isOauth() ? 'web' : 'token', isExpired: Boolean(devHubUsername) && expirationDate ? new Date((0, ts_types_1.ensureString)(expirationDate)).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: (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) { // 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' }); // return if we already know the hub org, we know it is a devhub or prod-like, or no orgId present if (Boolean(fields.isDevHub) || Boolean(fields.devHubUsername) || !fields.orgId) return; logger.debug('getting devHubs and prod orgs to identify scratch orgs and sandboxes'); // TODO: return if url is not sandbox-like to avoid constantly asking about production orgs // TODO: someday we make this easier by asking the org if it is a scratch org const hubAuthInfos = await AuthInfo.getDevHubAuthInfos(); // Get a list of org auths that are known not to be scratch orgs or sandboxes. const possibleProdOrgs = await AuthInfo.listAllAuthorizations((orgAuth) => orgAuth && !orgAuth.isScratchOrg && !orgAuth.isSandbox); logger.debug(`found ${hubAuthInfos.length} DevHubs`); logger.debug(`found ${possibleProdOrgs.length} possible prod orgs`); if (hubAuthInfos.length === 0 && possibleProdOrgs.length === 0) { return; } // ask all those orgs if they know this orgId await Promise.all([ ...hubAuthInfos.map(async (hubAuthInfo) => { try { const soi = await AuthInfo.queryScratchOrg(hubAuthInfo.username, fields.orgId); // if any return a result logger.debug(`found orgId ${fields.orgId} 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) => { await AuthInfo.identifyPossibleSandbox(pOrgAuthInfo, fields, orgAuthInfo, logger); }), ]); } /** * 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; } try { const prodOrg = await org_1.Org.create({ aliasOrUsername: possibleProdOrg.username }); const sbxProcess = await prodOrg.querySandboxProcessByOrgId(fields.orgId); if (!sbxProcess?.SandboxInfoId) { return; } 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); } } catch (err) { logger.debug(`${fields.orgId} is not a sandbox of ${possibleProdOrg.username}`); } } /** * 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} 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.matchesAccessToken)(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)) { 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. */ getConnectionOptions() { const decryptedCopy = this.getFields(true); const { accessToken, instanceUrl, loginUrl } = decryptedCopy; 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), }; } 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) */ 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.matchesAccessToken)(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) { 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); } if (options.privateKey) { authConfig = await this.authJwt(options); } else if (!options.authCode && options.refreshToken) { // refresh token flow (from sfdxUrl or OAuth refreshFn) authConfig = await this.buildRefreshTokenConfig(options); } 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)); const namespacePrefix = await this.getNamespacePrefix((0, ts_types_1.ensureString)(authConfig.instanceUrl), (0, ts_types_1.ensureString)(authConfig.accessToken)); if (namespacePrefix) { authConfig.namespacePrefix = namespacePrefix; } if (authConfig.username) await this.stateAggregator.orgs.read(authConfig.username, false, false); // Update the auth fields WITH encryption this.update(authConfig); } 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); // 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 await callback(messages.createError('orgDataNotAvailableError', [this.getUsername()], [], error)); } return await callback(error); } } async readJwtKey(keyFile) { return 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, refreshToken: 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) { // eslint-disable-next-line no-param-reassign oauth2.redirectUri = this.getRedirectUri(); } if (!oauth2.clientId) { // eslint-disable-next-line no-param-reassign 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}`); authFields = await oauth2.requestToken((0, ts_types_1.ensure)(options.authCode)); } 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); } async getNamespacePrefix(instanceUrl, accessToken) { // Make a REST call for the Organization 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 namespacePrefixOrgUrl = `${baseUrl.toString()}/services/data/${apiVersion}/query?q=Select%20Namespaceprefix%20FROM%20Organization`; const headers = Object.assign({ Authorization: `Bearer ${accessToken}` }, connection_1.SFDX_HTTP_HEADERS); try { const res = await new transport_1.default().httpRequest({ url: namespacePrefixOrgUrl, method: 'GET', headers }); if (res.statusCode >= 400) { return; } const namespacePrefix = JSON.parse(res.body); return (0, ts_types_1.ensureString)(namespacePrefix.records[0]?.NamespacePrefix); } catch (err) { /* Doesn't have a namespace */ return; } } /** * 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