Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 1 addition & 5 deletions messages/messages.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,6 @@ This command will expose sensitive information that allows for subsequent activi
Sharing this information is equivalent to logging someone in under the current credential, resulting in unintended access and escalation of privilege.
For additional information, please review the authorization section of the https://developer.salesforce.com/docs/atlas.en-us.sfdx_dev.meta/sfdx_dev/sfdx_dev_auth_web_flow.htm.

# BehaviorChangeWarning

Starting in August 2025, this command will generate single-use URLs when you specify either the --json or --url-only (-r) flag. These URLs can be used only one time; subsequent use won't allow you to log in to the org.

# SingleAccessFrontdoorError

Failed to generate a single-use frontdoor URL.
Failed to generate a frontdoor URL.
69 changes: 15 additions & 54 deletions src/commands/org/open.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,23 +5,20 @@
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/

import path from 'node:path';
import {
Flags,
loglevel,
orgApiVersionFlagWithDeprecations,
requiredOrgFlagWithDeprecations,
} from '@salesforce/sf-plugins-core';
import { Connection, Messages } from '@salesforce/core';
import { Messages, Org } from '@salesforce/core';
import { MetadataResolver } from '@salesforce/source-deploy-retrieve';
import { env } from '@salesforce/kit';
import { buildFrontdoorUrl } from '../../shared/orgOpenUtils.js';
import { OrgOpenCommandBase } from '../../shared/orgOpenCommandBase.js';
import { type OrgOpenOutput } from '../../shared/orgTypes.js';

Messages.importMessagesDirectoryFromMetaUrl(import.meta.url);
const messages = Messages.loadMessages('@salesforce/plugin-org', 'open');
const sharedMessages = Messages.loadMessages('@salesforce/plugin-org', 'messages');

export class OrgOpenCommand extends OrgOpenCommandBase<OrgOpenOutput> {
public static readonly summary = messages.getMessage('summary');
Expand Down Expand Up @@ -68,75 +65,39 @@ export class OrgOpenCommand extends OrgOpenCommandBase<OrgOpenOutput> {
};

public async run(): Promise<OrgOpenOutput> {
this.warn(sharedMessages.getMessage('BehaviorChangeWarning'));
const { flags } = await this.parse(OrgOpenCommand);
this.org = flags['target-org'];
this.connection = this.org.getConnection(flags['api-version']);

const singleUseEnvVar: boolean = env.getBoolean('SF_SINGLE_USE_ORG_OPEN_URL');
const singleUseMode = singleUseEnvVar ? singleUseEnvVar : !(flags['url-only'] || this.jsonEnabled());
const [frontDoorUrl, retUrl] = await Promise.all([
buildFrontdoorUrl(this.org, this.connection, singleUseMode),
flags['source-file'] ? generateFileUrl(flags['source-file'], this.connection) : flags.path,
buildFrontdoorUrl(this.org),
flags['source-file'] ? generateFileUrl(flags['source-file'], this.org) : flags.path,
]);

return this.openOrgUI(flags, frontDoorUrl, retUrl);
}
}

const generateFileUrl = async (file: string, conn: Connection): Promise<string> => {
const generateFileUrl = async (file: string, org: Org): Promise<string> => {
try {
const metadataResolver = new MetadataResolver();
const components = metadataResolver.getComponentsFromPath(file);
const typeName = components[0]?.type?.name;

switch (typeName) {
case 'Bot':
return `AiCopilot/copilotStudio.app#/copilot/builder?copilotId=${await botFileNameToId(conn, file)}`;
case 'ApexPage':
return `/apex/${path.basename(file).replace('.page-meta.xml', '').replace('.page', '')}`;
case 'Flow':
return `/builder_platform_interaction/flowBuilder.app?flowId=${await flowFileNameToId(conn, file)}`;
case 'FlexiPage':
return `/visualEditor/appBuilder.app?pageId=${await flexiPageFilenameToId(conn, file)}`;
default:
return 'lightning/setup/FlexiPageList/home';
if (!typeName) {
throw new Error(`Unable to determine metadata type for file: ${file}`);
}

return await org.getMetadataUIURL(typeName, file);
} catch (error) {
if (error instanceof Error && error.name === 'FlowIdNotFoundError') {
if (
error instanceof Error &&
(error.message.includes('FlowIdNotFound') ||
error.message.includes('CustomObjectIdNotFound') ||
error.message.includes('ApexClassIdNotFound'))
) {
throw error;
}
return 'lightning/setup/FlexiPageList/home';
}
};

const botFileNameToId = async (conn: Connection, filePath: string): Promise<string> =>
(
await conn.singleRecordQuery<{ Id: string }>(
`SELECT id FROM BotDefinition WHERE DeveloperName='${path.basename(filePath, '.bot-meta.xml')}'`
)
).Id;

/** query flexipage via toolingPAI to get its ID (starts with 0M0) */
const flexiPageFilenameToId = async (conn: Connection, filePath: string): Promise<string> =>
(
await conn.singleRecordQuery<{ Id: string }>(
`SELECT id FROM flexipage WHERE DeveloperName='${path.basename(filePath, '.flexipage-meta.xml')}'`,
{ tooling: true }
)
).Id;

/** query the rest API to turn a flow's filepath into a FlowId (starts with 301) */
const flowFileNameToId = async (conn: Connection, filePath: string): Promise<string> => {
try {
const flow = await conn.singleRecordQuery<{ DurableId: string }>(
`SELECT DurableId FROM FlowVersionView WHERE FlowDefinitionView.ApiName = '${path.basename(
filePath,
'.flow-meta.xml'
)}' ORDER BY VersionNumber DESC LIMIT 1`
);
return flow.DurableId;
} catch (error) {
throw messages.createError('FlowIdNotFound', [filePath]);
return '';
}
};
2 changes: 1 addition & 1 deletion src/commands/org/open/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ export class OrgOpenAgent extends OrgOpenCommandBase<OrgOpenOutput> {
this.connection = this.org.getConnection(flags['api-version']);

const [frontDoorUrl, retUrl] = await Promise.all([
buildFrontdoorUrl(this.org, this.connection, true),
buildFrontdoorUrl(this.org),
buildRetUrl(this.connection, flags['api-name']),
]);

Expand Down
61 changes: 12 additions & 49 deletions src/shared/orgOpenCommandBase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,12 @@
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/

import path from 'node:path';
import fs from 'node:fs';
import { platform, tmpdir } from 'node:os';
import { execSync } from 'node:child_process';
import isWsl from 'is-wsl';
import { platform } from 'node:os';
import { apps } from 'open';
import { SfCommand } from '@salesforce/sf-plugins-core';
import { Connection, Messages, Org, SfdcUrl, SfError } from '@salesforce/core';
import { env, sleep } from '@salesforce/kit';
import utils, { fileCleanup, getFileContents, handleDomainError } from './orgOpenUtils.js';
import { env } from '@salesforce/kit';
import utils, { handleDomainError } from './orgOpenUtils.js';
import { type OrgOpenOutput } from './orgTypes.js';

Messages.importMessagesDirectoryFromMetaUrl(import.meta.url);
Expand Down Expand Up @@ -46,8 +42,8 @@ export abstract class OrgOpenCommandBase<T> extends SfCommand<T> {
// NOTE: Deliberate use of `||` here since getBoolean() defaults to false, and we need to consider both env vars.
const containerMode = env.getBoolean('SF_CONTAINER_MODE') || env.getBoolean('SFDX_CONTAINER_MODE');

// security warning only for --json OR --url-only OR containerMode
if (flags['url-only'] || this.jsonEnabled() || containerMode) {
// security warning only for --url-only OR containerMode
if (flags['url-only'] || containerMode) {
const sharedMessages = Messages.loadMessages('@salesforce/plugin-org', 'messages');
this.warn(sharedMessages.getMessage('SecurityWarning'));
this.log('');
Expand Down Expand Up @@ -76,46 +72,13 @@ export abstract class OrgOpenCommandBase<T> extends SfCommand<T> {
handleDomainError(err, url, env);
}

if (this.jsonEnabled()) {
// TODO: remove this code path once the org open behavior changes on August 2025 (see W-17661469)
// create a local html file that contains the POST stuff.
const tempFilePath = path.join(tmpdir(), `org-open-${new Date().valueOf()}.html`);
await fs.promises.writeFile(
tempFilePath,
getFileContents(
this.connection.accessToken as string,
this.connection.instanceUrl,
// the path flag is URI-encoded in its `parse` func.
// For the form redirect to work we need it decoded.
flags.path ? decodeURIComponent(flags.path) : retUrl
)
);
const filePathUrl = isWsl
? 'file:///' + execSync(`wslpath -m ${tempFilePath}`).toString().trim()
: `file:///${tempFilePath}`;
const cp = await utils.openUrl(filePathUrl, {
...(flags.browser ? { app: { name: apps[flags.browser] } } : {}),
...(flags.private ? { newInstance: platform() === 'darwin', app: { name: apps.browserPrivate } } : {}),
});
cp.on('error', (err) => {
fileCleanup(tempFilePath);
throw SfError.wrap(err);
});
// so we don't delete the file while the browser is still using it
// open returns when the CP is spawned, but there's not way to know if the browser is still using the file
await sleep(platform() === 'win32' || isWsl ? 7000 : 5000);
fileCleanup(tempFilePath);
} else {
// it means we generated a one-time use frontdoor url
// so the workaround to create a local html file is not needed
const cp = await utils.openUrl(url, {
...(flags.browser ? { app: { name: apps[flags.browser] } } : {}),
...(flags.private ? { newInstance: platform() === 'darwin', app: { name: apps.browserPrivate } } : {}),
});
cp.on('error', (err) => {
throw SfError.wrap(err);
});
}
const cp = await utils.openUrl(url, {
...(flags.browser ? { app: { name: apps[flags.browser] } } : {}),
...(flags.private ? { newInstance: platform() === 'darwin', app: { name: apps.browserPrivate } } : {}),
});
cp.on('error', (err) => {
throw SfError.wrap(err);
});

return output;
}
Expand Down
34 changes: 9 additions & 25 deletions src/shared/orgOpenUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import { rmSync } from 'node:fs';
import { ChildProcess } from 'node:child_process';
import open, { Options } from 'open';
import { Connection, Logger, Messages, Org, SfError } from '@salesforce/core';
import { Logger, Messages, Org, SfError } from '@salesforce/core';
import { Duration, Env } from '@salesforce/kit';

Messages.importMessagesDirectoryFromMetaUrl(import.meta.url);
Expand All @@ -20,35 +20,19 @@ export const openUrl = async (url: string, options: Options): Promise<ChildProce
export const fileCleanup = (tempFilePath: string): void =>
rmSync(tempFilePath, { force: true, maxRetries: 3, recursive: true });

type SingleAccessUrlRes = { frontdoor_uri: string | undefined };

/**
* This method generates and returns a frontdoor url for the given org.
*
* @param org org for which we generate the frontdoor url.
* @param conn the Connection for the given Org.
* @param singleUseUrl if true returns a single-use url frontdoor url.
*/
export const buildFrontdoorUrl = async (org: Org, conn: Connection, singleUseUrl: boolean): Promise<string> => {
await org.refreshAuth(); // we need a live accessToken for the frontdoor url
const accessToken = conn.accessToken;
if (!accessToken) {
throw new SfError('NoAccessToken', 'NoAccessToken');
}
if (singleUseUrl) {
try {
const response: SingleAccessUrlRes = await conn.requestGet('/services/oauth2/singleaccess');
if (response.frontdoor_uri) return response.frontdoor_uri;
throw new SfError(sharedMessages.getMessage('SingleAccessFrontdoorError')).setData(response);
} catch (e) {
if (e instanceof SfError) throw e;
const err = e as Error;
throw new SfError(sharedMessages.getMessage('SingleAccessFrontdoorError'), err.message);
}
} else {
// TODO: remove this code path once the org open behavior changes on August 2025 (see W-17661469)
const instanceUrlClean = org.getField<string>(Org.Fields.INSTANCE_URL).replace(/\/$/, '');
return `${instanceUrlClean}/secur/frontdoor.jsp?sid=${accessToken}`;

export const buildFrontdoorUrl = async (org: Org): Promise<string> => {
try {
return await org.getFrontDoorUrl();
} catch (e) {
if (e instanceof SfError) throw e;
const err = e as Error;
throw new SfError(sharedMessages.getMessage('SingleAccessFrontdoorError'), err.message);
}
};

Expand Down
2 changes: 1 addition & 1 deletion test/nut/listAndDisplay.nut.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,7 @@ describe('Org Command NUT', () => {
expect(result).to.include({ orgId: aliasUserOrgId, username: aliasedUsername });
expect(result)
.to.property('url')
.to.include(`retURL=${encodeURIComponent(decodeURIComponent('foo/bar/baz'))}`);
.to.include(`startURL=${encodeURIComponent(decodeURIComponent('foo/bar/baz'))}`);
});
});
});
8 changes: 4 additions & 4 deletions test/nut/open.nut.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,13 @@ describe('test org:open command', () => {
defaultUserOrgId = defaultOrg.orgId as string;
});

it('should produce the default URL for a flexipage resource when it not in org in json', () => {
it('should produce the frontdoor default URL for a flexipage resource when it not in org in json', () => {
const result = execCmd<OrgOpenOutput>(`force:source:open -f ${flexiPagePath} --urlonly --json`, {
ensureExitCode: 0,
}).jsonOutput?.result;
assert(result);
expect(result).to.include({ orgId: defaultUserOrgId, username: defaultUsername });
expect(result.url).to.include('lightning/setup/FlexiPageList/home');
expect(result.url).to.include('secur/frontdoor.jsp');
});

it('should produce the URL for a flexipage resource in json', async () => {
Expand All @@ -63,7 +63,7 @@ describe('test org:open command', () => {
}).jsonOutput?.result;
assert(result);
expect(result).to.include({ orgId: defaultUserOrgId, username: defaultUsername });
expect(result.url).to.include('/visualEditor/appBuilder.app?pageId');
expect(result.url).to.include('secur/frontdoor.jsp');
});

it('should produce the URL for an existing flow', () => {
Expand All @@ -72,7 +72,7 @@ describe('test org:open command', () => {
}).jsonOutput?.result;
assert(result);
expect(result).to.include({ orgId: defaultUserOrgId, username: defaultUsername });
expect(result.url).to.include('/builder_platform_interaction/flowBuilder.app?flowId=301');
expect(result.url).to.include('secur/frontdoor.jsp');
});

it("should produce the org's frontdoor url when edition of file is not supported", async () => {
Expand Down
Loading
Loading