Skip to content

Commit a9bf347

Browse files
feat: use UI Bridge API to generate a single-use frontdoor (#1375)
* fix(org open): use UI Bridge API to generate a single-use frontdoor * fix: generates a one-time use URL only when neither the --orl-only nor the --json flags are passed * fix: add warning message to org open command * fix: improve error handling * fix: better UIT * fix: add environment variable SF_SINGLE_USE_ORG_OPEN_URL
1 parent a0baefd commit a9bf347

File tree

6 files changed

+140
-31
lines changed

6 files changed

+140
-31
lines changed

messages/messages.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,11 @@
33
This command will expose sensitive information that allows for subsequent activity using your current authenticated session.
44
Sharing this information is equivalent to logging someone in under the current credential, resulting in unintended access and escalation of privilege.
55
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.
6+
7+
# BehaviorChangeWarning
8+
9+
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.
10+
11+
# SingleAccessFrontdoorError
12+
13+
Failed to generate a single-use frontdoor URL.

src/commands/org/open.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,14 @@ import {
1414
} from '@salesforce/sf-plugins-core';
1515
import { Connection, Messages } from '@salesforce/core';
1616
import { MetadataResolver } from '@salesforce/source-deploy-retrieve';
17+
import { env } from '@salesforce/kit';
1718
import { buildFrontdoorUrl } from '../../shared/orgOpenUtils.js';
1819
import { OrgOpenCommandBase } from '../../shared/orgOpenCommandBase.js';
1920
import { type OrgOpenOutput } from '../../shared/orgTypes.js';
2021

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

2426
export class OrgOpenCommand extends OrgOpenCommandBase<OrgOpenOutput> {
2527
public static readonly summary = messages.getMessage('summary');
@@ -66,12 +68,15 @@ export class OrgOpenCommand extends OrgOpenCommandBase<OrgOpenOutput> {
6668
};
6769

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

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

src/commands/org/open/agent.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ export class OrgOpenAgent extends OrgOpenCommandBase<OrgOpenOutput> {
5353
this.connection = this.org.getConnection(flags['api-version']);
5454

5555
const [frontDoorUrl, retUrl] = await Promise.all([
56-
buildFrontdoorUrl(this.org, this.connection),
56+
buildFrontdoorUrl(this.org, this.connection, true),
5757
buildRetUrl(this.connection, flags.name),
5858
]);
5959

src/shared/orgOpenCommandBase.ts

Lines changed: 39 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -74,33 +74,46 @@ export abstract class OrgOpenCommandBase<T> extends SfCommand<T> {
7474
handleDomainError(err, url, env);
7575
}
7676

77-
// create a local html file that contains the POST stuff.
78-
const tempFilePath = path.join(tmpdir(), `org-open-${new Date().valueOf()}.html`);
79-
await fs.promises.writeFile(
80-
tempFilePath,
81-
getFileContents(
82-
this.connection.accessToken as string,
83-
this.connection.instanceUrl,
84-
// the path flag is URI-encoded in its `parse` func.
85-
// For the form redirect to work we need it decoded.
86-
flags.path ? decodeURIComponent(flags.path) : retUrl
87-
)
88-
);
89-
const filePathUrl = isWsl
90-
? 'file:///' + execSync(`wslpath -m ${tempFilePath}`).toString().trim()
91-
: `file:///${tempFilePath}`;
92-
const cp = await utils.openUrl(filePathUrl, {
93-
...(flags.browser ? { app: { name: apps[flags.browser] } } : {}),
94-
...(flags.private ? { newInstance: platform() === 'darwin', app: { name: apps.browserPrivate } } : {}),
95-
});
96-
cp.on('error', (err) => {
77+
if (this.jsonEnabled()) {
78+
// TODO: remove this code path once the org open behavior changes on August 2025 (see W-17661469)
79+
// create a local html file that contains the POST stuff.
80+
const tempFilePath = path.join(tmpdir(), `org-open-${new Date().valueOf()}.html`);
81+
await fs.promises.writeFile(
82+
tempFilePath,
83+
getFileContents(
84+
this.connection.accessToken as string,
85+
this.connection.instanceUrl,
86+
// the path flag is URI-encoded in its `parse` func.
87+
// For the form redirect to work we need it decoded.
88+
flags.path ? decodeURIComponent(flags.path) : retUrl
89+
)
90+
);
91+
const filePathUrl = isWsl
92+
? 'file:///' + execSync(`wslpath -m ${tempFilePath}`).toString().trim()
93+
: `file:///${tempFilePath}`;
94+
const cp = await utils.openUrl(filePathUrl, {
95+
...(flags.browser ? { app: { name: apps[flags.browser] } } : {}),
96+
...(flags.private ? { newInstance: platform() === 'darwin', app: { name: apps.browserPrivate } } : {}),
97+
});
98+
cp.on('error', (err) => {
99+
fileCleanup(tempFilePath);
100+
throw SfError.wrap(err);
101+
});
102+
// so we don't delete the file while the browser is still using it
103+
// open returns when the CP is spawned, but there's not way to know if the browser is still using the file
104+
await sleep(platform() === 'win32' || isWsl ? 7000 : 5000);
97105
fileCleanup(tempFilePath);
98-
throw SfError.wrap(err);
99-
});
100-
// so we don't delete the file while the browser is still using it
101-
// open returns when the CP is spawned, but there's not way to know if the browser is still using the file
102-
await sleep(platform() === 'win32' || isWsl ? 7000 : 5000);
103-
fileCleanup(tempFilePath);
106+
} else {
107+
// it means we generated a one-time use frontdoor url
108+
// so the workaround to create a local html file is not needed
109+
const cp = await utils.openUrl(url, {
110+
...(flags.browser ? { app: { name: apps[flags.browser] } } : {}),
111+
...(flags.private ? { newInstance: platform() === 'darwin', app: { name: apps.browserPrivate } } : {}),
112+
});
113+
cp.on('error', (err) => {
114+
throw SfError.wrap(err);
115+
});
116+
}
104117

105118
return output;
106119
}

src/shared/orgOpenUtils.ts

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,20 +13,43 @@ import { Duration, Env } from '@salesforce/kit';
1313

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

1718
export const openUrl = async (url: string, options: Options): Promise<ChildProcess> => open(url, options);
1819

1920
export const fileCleanup = (tempFilePath: string): void =>
2021
rmSync(tempFilePath, { force: true, maxRetries: 3, recursive: true });
2122

22-
export const buildFrontdoorUrl = async (org: Org, conn: Connection): Promise<string> => {
23+
type SingleAccessUrlRes = { frontdoor_uri: string | undefined };
24+
25+
/**
26+
* This method generates and returns a frontdoor url for the given org.
27+
*
28+
* @param org org for which we generate the frontdoor url.
29+
* @param conn the Connection for the given Org.
30+
* @param singleUseUrl if true returns a single-use url frontdoor url.
31+
*/
32+
export const buildFrontdoorUrl = async (org: Org, conn: Connection, singleUseUrl: boolean): Promise<string> => {
2333
await org.refreshAuth(); // we need a live accessToken for the frontdoor url
2434
const accessToken = conn.accessToken;
2535
if (!accessToken) {
2636
throw new SfError('NoAccessToken', 'NoAccessToken');
2737
}
28-
const instanceUrlClean = org.getField<string>(Org.Fields.INSTANCE_URL).replace(/\/$/, '');
29-
return `${instanceUrlClean}/secur/frontdoor.jsp?sid=${accessToken}`;
38+
if (singleUseUrl) {
39+
try {
40+
const response: SingleAccessUrlRes = await conn.requestGet('/services/oauth2/singleaccess');
41+
if (response.frontdoor_uri) return response.frontdoor_uri;
42+
throw new SfError(sharedMessages.getMessage('SingleAccessFrontdoorError')).setData(response);
43+
} catch (e) {
44+
if (e instanceof SfError) throw e;
45+
const err = e as Error;
46+
throw new SfError(sharedMessages.getMessage('SingleAccessFrontdoorError'), err.message);
47+
}
48+
} else {
49+
// TODO: remove this code path once the org open behavior changes on August 2025 (see W-17661469)
50+
const instanceUrlClean = org.getField<string>(Org.Fields.INSTANCE_URL).replace(/\/$/, '');
51+
return `${instanceUrlClean}/secur/frontdoor.jsp?sid=${accessToken}`;
52+
}
3053
};
3154

3255
export const handleDomainError = (err: unknown, url: string, env: Env): string => {

test/unit/org/open.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ describe('org:open', () => {
2828
const testPath = '/lightning/whatever';
2929
const expectedDefaultUrl = `${testOrg.instanceUrl}/secur/frontdoor.jsp?sid=${testOrg.accessToken}`;
3030
const expectedUrl = `${expectedDefaultUrl}&retURL=${encodeURIComponent(testPath)}`;
31+
const singleUseToken = (Math.random() + 1).toString(36).substring(2); // random string to simulate a single-use token
32+
const expectedDefaultSingleUseUrl = `${testOrg.instanceUrl}/secur/frontdoor.jsp?otp=${singleUseToken}`;
3133

3234
let sfCommandUxStubs: ReturnType<typeof stubSfCommandUx>;
3335

@@ -45,6 +47,13 @@ describe('org:open', () => {
4547
stubSpinner($$.SANDBOX);
4648
await $$.stubAuths(testOrg);
4749
spies.set('open', stubMethod($$.SANDBOX, utils, 'openUrl').resolves(new EventEmitter()));
50+
spies.set(
51+
'requestGet',
52+
stubMethod($$.SANDBOX, Connection.prototype, 'requestGet').resolves({
53+
// eslint-disable-next-line camelcase
54+
frontdoor_uri: expectedDefaultSingleUseUrl,
55+
})
56+
);
4857
});
4958

5059
afterEach(() => {
@@ -157,6 +166,55 @@ describe('org:open', () => {
157166
expect(response.url).to.equal(expectedUrl);
158167
delete process.env.FORCE_OPEN_URL;
159168
});
169+
170+
it('generates a single-use frontdoor url when neither --url-only nor --json flag are passed in', async () => {
171+
spies.set('resolver', stubMethod($$.SANDBOX, SfdcUrl.prototype, 'checkLightningDomain').resolves('1.1.1.1'));
172+
const response = await OrgOpenCommand.run(['--targetusername', testOrg.username]);
173+
expect(response.url).to.equal(expectedDefaultSingleUseUrl);
174+
// verify we called to the correct endpoint to generate the single-use AT
175+
expect(spies.get('requestGet').callCount).to.equal(1);
176+
expect(spies.get('requestGet').args[0][0]).to.deep.equal('/services/oauth2/singleaccess');
177+
});
178+
179+
it('honors SF_SINGLE_USE_ORG_OPEN_URL env var and generates a single-use frontdoor url even if --url-only or --json flag are passed in', async () => {
180+
process.env.SF_SINGLE_USE_ORG_OPEN_URL = 'true';
181+
spies.set('resolver', stubMethod($$.SANDBOX, SfdcUrl.prototype, 'checkLightningDomain').resolves('1.1.1.1'));
182+
const response = await OrgOpenCommand.run(['--targetusername', testOrg.username, '--json', '--url-only']);
183+
expect(response.url).to.equal(expectedDefaultSingleUseUrl);
184+
// verify we called to the correct endpoint to generate the single-use AT
185+
expect(spies.get('requestGet').callCount).to.equal(1);
186+
expect(spies.get('requestGet').args[0][0]).to.deep.equal('/services/oauth2/singleaccess');
187+
delete process.env.SF_SINGLE_USE_ORG_OPEN_URL;
188+
});
189+
190+
it('handles api error', async () => {
191+
$$.SANDBOX.restore();
192+
const mockError = new Error('Invalid_Scope');
193+
$$.SANDBOX.stub(Connection.prototype, 'requestGet').throws(mockError);
194+
try {
195+
await OrgOpenCommand.run(['--targetusername', testOrg.username]);
196+
expect.fail('should have thrown Invalid_Scope');
197+
} catch (e) {
198+
assert(e instanceof SfError, 'should be an SfError');
199+
expect(e.name).to.equal('Invalid_Scope');
200+
expect(e.message).to.equal(sharedMessages.getMessage('SingleAccessFrontdoorError'));
201+
}
202+
});
203+
204+
it('handles invalid responde from api', async () => {
205+
$$.SANDBOX.restore();
206+
$$.SANDBOX.stub(Connection.prototype, 'requestGet').resolves({
207+
invalid: 'some invalid response',
208+
});
209+
try {
210+
await OrgOpenCommand.run(['--targetusername', testOrg.username]);
211+
expect.fail('should have thrown Invalid_Scope');
212+
} catch (e) {
213+
assert(e instanceof SfError, 'should be an SfError');
214+
expect(e.message).to.equal(sharedMessages.getMessage('SingleAccessFrontdoorError'));
215+
expect(e.data).to.contain({ invalid: 'some invalid response' });
216+
}
217+
});
160218
});
161219

162220
describe('domain resolution, with callout', () => {
@@ -263,6 +321,7 @@ describe('org:open', () => {
263321
)
264322
);
265323
expect(sfCommandUxStubs.warn.calledOnceWith(sharedMessages.getMessage('SecurityWarning')));
324+
expect(sfCommandUxStubs.warn.calledOnceWith(sharedMessages.getMessage('BehaviorChangeWarning')));
266325

267326
expect(spies.get('resolver').callCount).to.equal(1);
268327
expect(spies.get('open').callCount).to.equal(1);
@@ -275,6 +334,7 @@ describe('org:open', () => {
275334
await OrgOpenCommand.run(['--targetusername', testOrg.username, '--path', testPath, '-b', testBrowser]);
276335

277336
expect(sfCommandUxStubs.warn(sharedMessages.getMessage('SecurityWarning')));
337+
expect(sfCommandUxStubs.warn.calledOnceWith(sharedMessages.getMessage('BehaviorChangeWarning')));
278338
expect(
279339
sfCommandUxStubs.logSuccess.calledOnceWith(
280340
messages.getMessage('humanSuccessNoUrl', [testOrg.orgId, testOrg.username])

0 commit comments

Comments
 (0)