diff --git a/command-snapshot.json b/command-snapshot.json index ea7d9577..32fac485 100644 --- a/command-snapshot.json +++ b/command-snapshot.json @@ -1,4 +1,20 @@ [ + { + "alias": [], + "command": "org:auth:show-access-token", + "flagAliases": [], + "flagChars": ["o", "p"], + "flags": ["flags-dir", "json", "no-prompt", "target-org"], + "plugin": "@salesforce/plugin-org" + }, + { + "alias": [], + "command": "org:auth:show-sfdx-auth-url", + "flagAliases": [], + "flagChars": ["o", "p"], + "flags": ["flags-dir", "json", "no-prompt", "target-org"], + "plugin": "@salesforce/plugin-org" + }, { "alias": [], "command": "org:create:agent-user", diff --git a/messages/org.auth.show-access-token.md b/messages/org.auth.show-access-token.md new file mode 100644 index 00000000..ce7b03c5 --- /dev/null +++ b/messages/org.auth.show-access-token.md @@ -0,0 +1,41 @@ +# summary + +Show the current access token for an org. + +# description + +Because access tokens are sensitive credentials that grant full access to an org, this command prompts for confirmation before revealing the token. Skip confirmation by specifying either the --no-prompt or --json flag. + +# flags.no-prompt.summary + +Skip the security warning and reveal the access token without confirmation. + +# prompt.show-access-token + +You're about to reveal the access token for "%s". This token grants full access to the org with your current permissions. Sharing or logging this token is equivalent to sharing your credentials. Do you want to continue? + +# warning.show-access-token + +This command exposes a sensitive Access Token that allows for subsequent activity using your current authenticated session. Sharing this information is equivalent to logging someone in under the current credential, resulting in unintended access and escalation of privilege. For additional information about org authorization, review https://developer.salesforce.com/docs/atlas.en-us.sfdx_dev.meta/sfdx_dev/sfdx_dev_auth.htm. + +# error.noAccessToken + +No access token found for "%s". The org may need to be re-authenticated. + +# examples + +- Show the access token for the default org: + + <%= config.bin %> <%= command.id %> + +- Show the access token for an org with alias "my-org": + + <%= config.bin %> <%= command.id %> --target-org my-org + +- Show the access token without the confirmation prompt: + + <%= config.bin %> <%= command.id %> --target-org my-org --no-prompt + +- Get the access token as JSON for use in scripts: + + <%= config.bin %> <%= command.id %> --target-org my-org --json diff --git a/messages/org.auth.show-sfdx-auth-url.md b/messages/org.auth.show-sfdx-auth-url.md new file mode 100644 index 00000000..f3b74987 --- /dev/null +++ b/messages/org.auth.show-sfdx-auth-url.md @@ -0,0 +1,41 @@ +# summary + +Show the SFDX Auth URL for an org. + +# description + +The SFDX Auth URL contains a refresh token that provides persistent access to the org without requiring re-authentication. This URL is only available for orgs authenticated via a web-based OAuth flow. Because this URL is equivalent to a permanent login credential, this command prompts for confirmation before revealing it. Skip confirmation by specifying either the --no-prompt or --json flag. + +# flags.no-prompt.summary + +Skip the security warning and reveal the SFDX Auth URL without confirmation. + +# prompt.show-sfdx-auth-url + +You're about to reveal the SFDX Auth URL for "%s". This URL contains a refresh token that grants persistent access to the org without re-authentication. Anyone with this URL can authenticate to the org with your permissions. Do you want to continue? + +# warning.show-sfdx-auth-url + +This command exposes a sensitive SFDX Auth URL containing a refresh token that grants persistent access to the org. Unlike an access token, this credential does not expire and allows re-authentication without user interaction. Sharing this URL is equivalent to giving permanent login access to the org. For additional information about org authorization, review https://developer.salesforce.com/docs/atlas.en-us.sfdx_dev.meta/sfdx_dev/sfdx_dev_auth_url.htm. + +# error.noRefreshToken + +An SFDX Auth URL is not available for "%s". This URL is only available for orgs authenticated via a web-based login flow. Re-authenticate to the org using "sf org login web" to make it available. + +# examples + +- Show the SFDX Auth URL for the default org: + + <%= config.bin %> <%= command.id %> + +- Show the SFDX Auth URL for an org with alias "my-org": + + <%= config.bin %> <%= command.id %> --target-org my-org + +- Show the SFDX Auth URL without the confirmation prompt: + + <%= config.bin %> <%= command.id %> --target-org my-org --no-prompt + +- Get the SFDX Auth URL as JSON for use in scripts: + + <%= config.bin %> <%= command.id %> --target-org my-org --json diff --git a/package.json b/package.json index d991e9a4..19719419 100644 --- a/package.json +++ b/package.json @@ -80,6 +80,9 @@ }, "disable": { "description": "Disable source tracking in an org." + }, + "auth": { + "description": "Commands for printing sensitive auth info." } }, "external": true diff --git a/schemas/org-auth-show__access__token.json b/schemas/org-auth-show__access__token.json new file mode 100644 index 00000000..e66c9e2a --- /dev/null +++ b/schemas/org-auth-show__access__token.json @@ -0,0 +1,16 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$ref": "#/definitions/OrgAuthShowAccessTokenResult", + "definitions": { + "OrgAuthShowAccessTokenResult": { + "type": "object", + "properties": { + "accessToken": { + "type": "string" + } + }, + "required": ["accessToken"], + "additionalProperties": false + } + } +} diff --git a/schemas/org-auth-show__sfdx__auth__url.json b/schemas/org-auth-show__sfdx__auth__url.json new file mode 100644 index 00000000..a11cd77e --- /dev/null +++ b/schemas/org-auth-show__sfdx__auth__url.json @@ -0,0 +1,16 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$ref": "#/definitions/OrgAuthShowSfdxAuthUrlResult", + "definitions": { + "OrgAuthShowSfdxAuthUrlResult": { + "type": "object", + "properties": { + "sfdxAuthUrl": { + "type": "string" + } + }, + "required": ["sfdxAuthUrl"], + "additionalProperties": false + } + } +} diff --git a/src/commands/org/auth/show-access-token.ts b/src/commands/org/auth/show-access-token.ts new file mode 100644 index 00000000..8137d41c --- /dev/null +++ b/src/commands/org/auth/show-access-token.ts @@ -0,0 +1,81 @@ +/* + * 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. + */ + +import { SfCommand, Flags } from '@salesforce/sf-plugins-core'; +import { AuthInfo, Messages, SfError } from '@salesforce/core'; + +Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); +const messages = Messages.loadMessages('@salesforce/plugin-org', 'org.auth.show-access-token'); + +export type OrgAuthShowAccessTokenResult = { + accessToken: string; +}; + +export default class OrgAuthShowAccessToken extends SfCommand { + public static readonly summary = messages.getMessage('summary'); + public static readonly description = messages.getMessage('description'); + public static readonly examples = messages.getMessages('examples'); + + public static readonly flags = { + 'target-org': Flags.requiredOrg(), + 'no-prompt': Flags.boolean({ + summary: messages.getMessage('flags.no-prompt.summary'), + char: 'p', + }), + }; + + public async run(): Promise { + const { flags } = await this.parse(OrgAuthShowAccessToken); + + const org = flags['target-org']; + const username = org.getUsername(); + try { + // The auth file can have a stale access token. Refresh it before getting the fields + await org.refreshAuth(); + } catch (error) { + // Even if this fails, we want to display the information we can read from the auth file + this.warn('Unable to refresh auth for org. Access token may be stale.'); + } + + // Don't ask for confirmation if --json or --no-prompt is passed + if (!this.jsonEnabled() && !flags['no-prompt']) { + const confirmed = await this.confirm({ + message: messages.getMessage('prompt.show-access-token', [username]), + ms: 30_000, + }); + if (!confirmed) { + throw new SfError('Show access token confirmation denied or timed out.'); + } + } else { + // Note: We don't show this warning if the user has already been prompted + this.warn(messages.getMessage('warning.show-access-token')); + } + + const authInfo = await AuthInfo.create({ username }); + const { accessToken } = authInfo.getFields(true); + + if (!accessToken) { + throw messages.createError('error.noAccessToken', [username]); + } + + this.table({ + overflow: 'wrap', + data: [{ key: 'Access Token', value: accessToken }], + }); + + return { accessToken }; + } +} diff --git a/src/commands/org/auth/show-sfdx-auth-url.ts b/src/commands/org/auth/show-sfdx-auth-url.ts new file mode 100644 index 00000000..6d7f23c0 --- /dev/null +++ b/src/commands/org/auth/show-sfdx-auth-url.ts @@ -0,0 +1,74 @@ +/* + * 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. + */ + +import { SfCommand, Flags } from '@salesforce/sf-plugins-core'; +import { AuthInfo, Messages, SfError } from '@salesforce/core'; + +Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); +const messages = Messages.loadMessages('@salesforce/plugin-org', 'org.auth.show-sfdx-auth-url'); + +export type OrgAuthShowSfdxAuthUrlResult = { + sfdxAuthUrl: string; +}; + +export default class OrgAuthShowSfdxAuthUrl extends SfCommand { + public static readonly summary = messages.getMessage('summary'); + public static readonly description = messages.getMessage('description'); + public static readonly examples = messages.getMessages('examples'); + + public static readonly flags = { + 'target-org': Flags.requiredOrg(), + 'no-prompt': Flags.boolean({ + summary: messages.getMessage('flags.no-prompt.summary'), + char: 'p', + }), + }; + + public async run(): Promise { + const { flags } = await this.parse(OrgAuthShowSfdxAuthUrl); + + const org = flags['target-org']; + const username = org.getUsername(); + + if (!this.jsonEnabled() && !flags['no-prompt']) { + const confirmed = await this.confirm({ + message: messages.getMessage('prompt.show-sfdx-auth-url', [username]), + ms: 30_000, + }); + if (!confirmed) { + throw new SfError('Show SFDX auth URL confirmation denied or timed out.'); + } + } else { + this.warn(messages.getMessage('warning.show-sfdx-auth-url')); + } + + const authInfo = await AuthInfo.create({ username }); + const { refreshToken } = authInfo.getFields(true); + + if (!refreshToken) { + throw messages.createError('error.noRefreshToken', [username]); + } + + const sfdxAuthUrl = authInfo.getSfdxAuthUrl(); + + this.table({ + overflow: 'wrap', + data: [{ key: 'SFDX Auth URL', value: sfdxAuthUrl }], + }); + + return { sfdxAuthUrl }; + } +} diff --git a/test/nut/org/auth/show-access-token.nut.ts b/test/nut/org/auth/show-access-token.nut.ts new file mode 100644 index 00000000..60ed7117 --- /dev/null +++ b/test/nut/org/auth/show-access-token.nut.ts @@ -0,0 +1,119 @@ +/* + * 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. + */ +import { join } from 'node:path'; +import { execCmd, TestSession } from '@salesforce/cli-plugins-testkit'; +import { assert, expect } from 'chai'; +import { accessTokenRegex } from '@salesforce/core'; +import { OrgAuthShowAccessTokenResult } from '../../../../src/commands/org/auth/show-access-token.js'; + +describe('org auth show-access-token NUTs', () => { + let session: TestSession; + let scratchUsername: string; + + before(async () => { + session = await TestSession.create({ + project: { name: 'showAccessToken' }, + devhubAuthStrategy: 'AUTO', + scratchOrgs: [ + { + config: join('config', 'project-scratch-def.json'), + setDefault: true, + }, + ], + }); + + const defaultOrg = session.orgs.get('default'); + assert(defaultOrg?.username); + scratchUsername = defaultOrg.username; + }); + + after(async () => { + await session?.clean(); + }); + + describe('--json --no-prompt', () => { + it('returns an access token matching the expected pattern', () => { + const result = execCmd( + `org auth show-access-token --target-org ${scratchUsername} --no-prompt --json`, + { ensureExitCode: 0 } + ).jsonOutput?.result; + assert(result); + expect(result).to.have.property('accessToken'); + // NOTE: We assert truthiness instead of values so that a failure diff does not expose an access token. + expect(accessTokenRegex.test(result.accessToken), 'accessToken should match the expected format').to.be.true; + }); + + it('includes the security warning in json output', () => { + const output = execCmd( + `org auth show-access-token --target-org ${scratchUsername} --no-prompt --json`, + { ensureExitCode: 0 } + ).jsonOutput; + assert(output); + expect(output.warnings).to.be.an('array'); + expect(output.warnings?.some((w) => w.includes('Access Token'))).to.be.true; + }); + }); + + describe('--json (without --no-prompt)', () => { + it('returns an access token without prompting', () => { + const result = execCmd( + `org auth show-access-token --target-org ${scratchUsername} --json`, + { ensureExitCode: 0 } + ).jsonOutput?.result; + assert(result); + expect(result).to.have.property('accessToken'); + expect(accessTokenRegex.test(result.accessToken), 'accessToken should match the expected format').to.be.true; + }); + }); + + describe('--no-prompt (without --json)', () => { + it('outputs a table containing the access token', () => { + const output = execCmd(`org auth show-access-token --target-org ${scratchUsername} --no-prompt`, { + ensureExitCode: 0, + }).shellOutput.stdout; + expect(output).to.include('Access Token'); + expect(accessTokenRegex.test(output), 'table output should contain a valid access token').to.be.true; + }); + + it('includes the security warning in stderr', () => { + const stderr = execCmd(`org auth show-access-token --target-org ${scratchUsername} --no-prompt`, { + ensureExitCode: 0, + }).shellOutput.stderr; + expect(stderr).to.include('Access Token'); + }); + }); + + describe('default org resolution', () => { + it('uses the default target-org when no --target-org is specified', () => { + const result = execCmd('org auth show-access-token --no-prompt --json', { + ensureExitCode: 0, + }).jsonOutput?.result; + assert(result); + expect(result).to.have.property('accessToken'); + expect(accessTokenRegex.test(result.accessToken), 'accessToken should match the expected format').to.be.true; + }); + }); + + describe('errors', () => { + it('fails when target org does not exist', () => { + const output = execCmd('org auth show-access-token --target-org nonexistent@user.org --no-prompt --json', { + ensureExitCode: 1, + }).jsonOutput; + assert(output); + expect(output.status).to.equal(1); + }); + }); +}); diff --git a/test/nut/org/auth/show-sfdx-auth-url.nut.ts b/test/nut/org/auth/show-sfdx-auth-url.nut.ts new file mode 100644 index 00000000..e8fed556 --- /dev/null +++ b/test/nut/org/auth/show-sfdx-auth-url.nut.ts @@ -0,0 +1,124 @@ +/* + * 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. + */ +import { join } from 'node:path'; +import { execCmd, TestSession } from '@salesforce/cli-plugins-testkit'; +import { assert, expect } from 'chai'; +import { sfdxAuthUrlRegex } from '@salesforce/core'; +import { OrgAuthShowSfdxAuthUrlResult } from '../../../../src/commands/org/auth/show-sfdx-auth-url.js'; + +describe('org auth show-sfdx-auth-url NUTs', () => { + let session: TestSession; + let scratchUsername: string; + + before(async () => { + session = await TestSession.create({ + project: { name: 'showAuthUrl' }, + // ------------------------------ + // NOTE: This will fail locally + // unless you have set the + // TESTKIT_AUTH_URL env var + // ------------------------------ + devhubAuthStrategy: 'AUTH_URL', + scratchOrgs: [ + { + config: join('config', 'project-scratch-def.json'), + setDefault: true, + }, + ], + }); + + const defaultOrg = session.orgs.get('default'); + assert(defaultOrg?.username); + scratchUsername = defaultOrg.username; + }); + + after(async () => { + await session?.clean(); + }); + + describe('--json --no-prompt', () => { + it('returns an SFDX auth URL matching the expected pattern', () => { + const result = execCmd( + `org auth show-sfdx-auth-url --target-org ${scratchUsername} --no-prompt --json`, + { ensureExitCode: 0 } + ).jsonOutput?.result; + assert(result); + expect(result).to.have.property('sfdxAuthUrl'); + // NOTE: We assert truthiness instead of values so that a failure diff does not expose credentials. + expect(sfdxAuthUrlRegex.test(result.sfdxAuthUrl), 'sfdxAuthUrl should match the expected format').to.be.true; + }); + + it('includes the security warning in json output', () => { + const output = execCmd( + `org auth show-sfdx-auth-url --target-org ${scratchUsername} --no-prompt --json`, + { ensureExitCode: 0 } + ).jsonOutput; + assert(output); + expect(output.warnings).to.be.an('array'); + expect(output.warnings?.some((w) => w.includes('Auth URL'))).to.be.true; + }); + }); + + describe('--json (without --no-prompt)', () => { + it('returns an SFDX auth URL without prompting', () => { + const result = execCmd( + `org auth show-sfdx-auth-url --target-org ${scratchUsername} --json`, + { ensureExitCode: 0 } + ).jsonOutput?.result; + assert(result); + expect(result).to.have.property('sfdxAuthUrl'); + expect(sfdxAuthUrlRegex.test(result.sfdxAuthUrl), 'sfdxAuthUrl should match the expected format').to.be.true; + }); + }); + + describe('--no-prompt (without --json)', () => { + it('outputs a table containing the auth URL', () => { + const output = execCmd(`org auth show-sfdx-auth-url --target-org ${scratchUsername} --no-prompt`, { + ensureExitCode: 0, + }).shellOutput.stdout; + expect(output).to.include('SFDX Auth URL'); + expect(sfdxAuthUrlRegex.test(output), 'table output should contain a valid SFDX auth URL').to.be.true; + }); + + it('includes the security warning in stderr', () => { + const stderr = execCmd(`org auth show-sfdx-auth-url --target-org ${scratchUsername} --no-prompt`, { + ensureExitCode: 0, + }).shellOutput.stderr; + expect(stderr).to.include('Auth URL'); + }); + }); + + describe('default org resolution', () => { + it('uses the default target-org when no --target-org is specified', () => { + const result = execCmd('org auth show-sfdx-auth-url --no-prompt --json', { + ensureExitCode: 0, + }).jsonOutput?.result; + assert(result); + expect(result).to.have.property('sfdxAuthUrl'); + expect(sfdxAuthUrlRegex.test(result.sfdxAuthUrl), 'sfdxAuthUrl should match the expected format').to.be.true; + }); + }); + + describe('errors', () => { + it('fails when target org does not exist', () => { + const output = execCmd('org auth show-sfdx-auth-url --target-org nonexistent@user.org --no-prompt --json', { + ensureExitCode: 1, + }).jsonOutput; + assert(output); + expect(output.status).to.equal(1); + }); + }); +}); diff --git a/test/unit/org/auth/show-access-token.test.ts b/test/unit/org/auth/show-access-token.test.ts new file mode 100644 index 00000000..1f0ad8bd --- /dev/null +++ b/test/unit/org/auth/show-access-token.test.ts @@ -0,0 +1,176 @@ +/* + * 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. + */ +import { expect } from 'chai'; +import { MockTestOrgData, TestContext } from '@salesforce/core/testSetup'; +import { Messages, Org, SfError } from '@salesforce/core'; +import { stubSfCommandUx, stubPrompter } from '@salesforce/sf-plugins-core'; +import OrgAuthShowAccessToken from '../../../../src/commands/org/auth/show-access-token.js'; + +Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); +const messages = Messages.loadMessages('@salesforce/plugin-org', 'org.auth.show-access-token'); + +describe('org auth show-access-token', () => { + const $$ = new TestContext(); + let testOrg: MockTestOrgData; + let sfCommandUxStubs: ReturnType; + let prompterStubs: ReturnType; + + beforeEach(() => { + testOrg = new MockTestOrgData(); + testOrg.orgId = '00Dxx0000000000'; + sfCommandUxStubs = stubSfCommandUx($$.SANDBOX); + prompterStubs = stubPrompter($$.SANDBOX); + }); + + afterEach(() => { + $$.restore(); + }); + + describe('interactive (no --json, no --no-prompt)', () => { + it('prompts with the correct message including the username', async () => { + await $$.stubAuths(testOrg); + prompterStubs.confirm.resolves(true); + await OrgAuthShowAccessToken.run(['--target-org', testOrg.username]); + expect(prompterStubs.confirm.callCount).to.equal(1); + expect(prompterStubs.confirm.firstCall.args[0]).to.deep.equal({ + message: messages.getMessage('prompt.show-access-token', [testOrg.username]), + ms: 30_000, + }); + }); + + it('returns the access token when user confirms', async () => { + await $$.stubAuths(testOrg); + prompterStubs.confirm.resolves(true); + const result = await OrgAuthShowAccessToken.run(['--target-org', testOrg.username]); + expect(result).to.have.property('accessToken', testOrg.accessToken); + }); + + it('displays the access token in a table when user confirms', async () => { + await $$.stubAuths(testOrg); + prompterStubs.confirm.resolves(true); + await OrgAuthShowAccessToken.run(['--target-org', testOrg.username]); + const data = sfCommandUxStubs.table.firstCall.args[0].data; + expect(data).to.deep.include({ key: 'Access Token', value: testOrg.accessToken }); + }); + + it('throws when user denies the prompt', async () => { + await $$.stubAuths(testOrg); + prompterStubs.confirm.resolves(false); + try { + await OrgAuthShowAccessToken.run(['--target-org', testOrg.username]); + expect.fail('Expected command to throw'); + } catch (e) { + const err = e as SfError; + expect(err.message).to.equal('Show access token confirmation denied or timed out.'); + } + }); + + it('does not emit the security warning when prompting', async () => { + await $$.stubAuths(testOrg); + prompterStubs.confirm.resolves(true); + await OrgAuthShowAccessToken.run(['--target-org', testOrg.username]); + const warnCalls = sfCommandUxStubs.warn.getCalls().flatMap((c) => c.args); + expect(warnCalls).to.not.include(messages.getMessage('warning.show-access-token')); + }); + }); + + describe('--no-prompt', () => { + it('skips the confirm prompt', async () => { + await $$.stubAuths(testOrg); + await OrgAuthShowAccessToken.run(['--target-org', testOrg.username, '--no-prompt']); + expect(prompterStubs.confirm.callCount).to.equal(0); + }); + + it('emits the security warning', async () => { + await $$.stubAuths(testOrg); + await OrgAuthShowAccessToken.run(['--target-org', testOrg.username, '--no-prompt']); + const warnCalls = sfCommandUxStubs.warn.getCalls().flatMap((c) => c.args); + expect(warnCalls).to.include(messages.getMessage('warning.show-access-token')); + }); + + it('returns the access token', async () => { + await $$.stubAuths(testOrg); + const result = await OrgAuthShowAccessToken.run(['--target-org', testOrg.username, '--no-prompt']); + expect(result).to.have.property('accessToken', testOrg.accessToken); + }); + + it('displays the access token in a table', async () => { + await $$.stubAuths(testOrg); + await OrgAuthShowAccessToken.run(['--target-org', testOrg.username, '--no-prompt']); + const data = sfCommandUxStubs.table.firstCall.args[0].data; + expect(data).to.deep.include({ key: 'Access Token', value: testOrg.accessToken }); + }); + }); + + describe('--json', () => { + it('skips the confirm prompt', async () => { + await $$.stubAuths(testOrg); + await OrgAuthShowAccessToken.run(['--target-org', testOrg.username, '--json']); + expect(prompterStubs.confirm.callCount).to.equal(0); + }); + + it('emits the security warning', async () => { + await $$.stubAuths(testOrg); + await OrgAuthShowAccessToken.run(['--target-org', testOrg.username, '--json']); + const warnCalls = sfCommandUxStubs.warn.getCalls().flatMap((c) => c.args); + expect(warnCalls).to.include(messages.getMessage('warning.show-access-token')); + }); + + it('returns the access token', async () => { + await $$.stubAuths(testOrg); + const result = await OrgAuthShowAccessToken.run(['--target-org', testOrg.username, '--json']); + expect(result).to.have.property('accessToken', testOrg.accessToken); + }); + }); + + describe('--json --no-prompt', () => { + it('skips the confirm prompt and emits the security warning', async () => { + await $$.stubAuths(testOrg); + const result = await OrgAuthShowAccessToken.run(['--target-org', testOrg.username, '--json', '--no-prompt']); + expect(prompterStubs.confirm.callCount).to.equal(0); + expect(result).to.have.property('accessToken', testOrg.accessToken); + const warnCalls = sfCommandUxStubs.warn.getCalls().flatMap((c) => c.args); + expect(warnCalls).to.include(messages.getMessage('warning.show-access-token')); + }); + }); + + describe('error: no access token', () => { + it('throws the noAccessToken error with the username', async () => { + // @ts-expect-error testing the case where accessToken is missing + testOrg.accessToken = undefined; + await $$.stubAuths(testOrg); + try { + await OrgAuthShowAccessToken.run(['--target-org', testOrg.username, '--no-prompt']); + expect.fail('Expected command to throw'); + } catch (e) { + const err = e as SfError; + expect(err.message).to.equal(messages.getMessage('error.noAccessToken', [testOrg.username])); + } + }); + }); + + describe('auth refresh failure', () => { + it('warns but continues when auth refresh fails', async () => { + await $$.stubAuths(testOrg); + $$.SANDBOX.stub(Org.prototype, 'refreshAuth').rejects(new Error('refresh failed')); + prompterStubs.confirm.resolves(true); + const result = await OrgAuthShowAccessToken.run(['--target-org', testOrg.username]); + expect(result).to.have.property('accessToken', testOrg.accessToken); + const warnCalls = sfCommandUxStubs.warn.getCalls().flatMap((c) => c.args); + expect(warnCalls).to.include('Unable to refresh auth for org. Access token may be stale.'); + }); + }); +}); diff --git a/test/unit/org/auth/show-sfdx-auth-url.test.ts b/test/unit/org/auth/show-sfdx-auth-url.test.ts new file mode 100644 index 00000000..7e5779b2 --- /dev/null +++ b/test/unit/org/auth/show-sfdx-auth-url.test.ts @@ -0,0 +1,172 @@ +/* + * 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. + */ +import { expect } from 'chai'; +import { MockTestOrgData, TestContext } from '@salesforce/core/testSetup'; +import { Messages, SfError } from '@salesforce/core'; +import { stubSfCommandUx, stubPrompter } from '@salesforce/sf-plugins-core'; +import OrgAuthShowSfdxAuthUrl from '../../../../src/commands/org/auth/show-sfdx-auth-url.js'; + +Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); +const messages = Messages.loadMessages('@salesforce/plugin-org', 'org.auth.show-sfdx-auth-url'); + +const refreshToken = 'mock.refresh_token'; + +describe('org auth show-sfdx-auth-url', () => { + const $$ = new TestContext(); + let testOrg: MockTestOrgData; + let sfCommandUxStubs: ReturnType; + let prompterStubs: ReturnType; + + beforeEach(() => { + testOrg = new MockTestOrgData(); + testOrg.orgId = '00Dxx0000000000'; + testOrg.refreshToken = refreshToken; + sfCommandUxStubs = stubSfCommandUx($$.SANDBOX); + prompterStubs = stubPrompter($$.SANDBOX); + }); + + afterEach(() => { + $$.restore(); + }); + + describe('interactive (no --json, no --no-prompt)', () => { + it('prompts with the correct message including the username', async () => { + await $$.stubAuths(testOrg); + prompterStubs.confirm.resolves(true); + await OrgAuthShowSfdxAuthUrl.run(['--target-org', testOrg.username]); + expect(prompterStubs.confirm.callCount).to.equal(1); + expect(prompterStubs.confirm.firstCall.args[0]).to.deep.equal({ + message: messages.getMessage('prompt.show-sfdx-auth-url', [testOrg.username]), + ms: 30_000, + }); + }); + + it('returns the sfdxAuthUrl when user confirms', async () => { + await $$.stubAuths(testOrg); + prompterStubs.confirm.resolves(true); + const result = await OrgAuthShowSfdxAuthUrl.run(['--target-org', testOrg.username]); + expect(result).to.have.property('sfdxAuthUrl'); + expect(result.sfdxAuthUrl).to.include(refreshToken); + }); + + it('displays the auth URL in a table when user confirms', async () => { + await $$.stubAuths(testOrg); + prompterStubs.confirm.resolves(true); + await OrgAuthShowSfdxAuthUrl.run(['--target-org', testOrg.username]); + const data = sfCommandUxStubs.table.firstCall.args[0].data; + expect(data[0].key).to.equal('SFDX Auth URL'); + expect(data[0].value).to.include(refreshToken); + }); + + it('throws when user denies the prompt', async () => { + await $$.stubAuths(testOrg); + prompterStubs.confirm.resolves(false); + try { + await OrgAuthShowSfdxAuthUrl.run(['--target-org', testOrg.username]); + expect.fail('Expected command to throw'); + } catch (e) { + const err = e as SfError; + expect(err.message).to.equal('Show SFDX auth URL confirmation denied or timed out.'); + } + }); + + it('does not emit the security warning when prompting', async () => { + await $$.stubAuths(testOrg); + prompterStubs.confirm.resolves(true); + await OrgAuthShowSfdxAuthUrl.run(['--target-org', testOrg.username]); + const warnCalls = sfCommandUxStubs.warn.getCalls().flatMap((c) => c.args); + expect(warnCalls).to.not.include(messages.getMessage('warning.show-sfdx-auth-url')); + }); + }); + + describe('--no-prompt', () => { + it('skips the confirm prompt', async () => { + await $$.stubAuths(testOrg); + await OrgAuthShowSfdxAuthUrl.run(['--target-org', testOrg.username, '--no-prompt']); + expect(prompterStubs.confirm.callCount).to.equal(0); + }); + + it('emits the security warning', async () => { + await $$.stubAuths(testOrg); + await OrgAuthShowSfdxAuthUrl.run(['--target-org', testOrg.username, '--no-prompt']); + const warnCalls = sfCommandUxStubs.warn.getCalls().flatMap((c) => c.args); + expect(warnCalls).to.include(messages.getMessage('warning.show-sfdx-auth-url')); + }); + + it('returns the sfdxAuthUrl', async () => { + await $$.stubAuths(testOrg); + const result = await OrgAuthShowSfdxAuthUrl.run(['--target-org', testOrg.username, '--no-prompt']); + expect(result).to.have.property('sfdxAuthUrl'); + expect(result.sfdxAuthUrl).to.include(refreshToken); + }); + + it('displays the auth URL in a table', async () => { + await $$.stubAuths(testOrg); + await OrgAuthShowSfdxAuthUrl.run(['--target-org', testOrg.username, '--no-prompt']); + const data = sfCommandUxStubs.table.firstCall.args[0].data; + expect(data[0].key).to.equal('SFDX Auth URL'); + expect(data[0].value).to.include(refreshToken); + }); + }); + + describe('--json', () => { + it('skips the confirm prompt', async () => { + await $$.stubAuths(testOrg); + await OrgAuthShowSfdxAuthUrl.run(['--target-org', testOrg.username, '--json']); + expect(prompterStubs.confirm.callCount).to.equal(0); + }); + + it('emits the security warning', async () => { + await $$.stubAuths(testOrg); + await OrgAuthShowSfdxAuthUrl.run(['--target-org', testOrg.username, '--json']); + const warnCalls = sfCommandUxStubs.warn.getCalls().flatMap((c) => c.args); + expect(warnCalls).to.include(messages.getMessage('warning.show-sfdx-auth-url')); + }); + + it('returns the sfdxAuthUrl', async () => { + await $$.stubAuths(testOrg); + const result = await OrgAuthShowSfdxAuthUrl.run(['--target-org', testOrg.username, '--json']); + expect(result).to.have.property('sfdxAuthUrl'); + expect(result.sfdxAuthUrl).to.include(refreshToken); + }); + }); + + describe('--json --no-prompt', () => { + it('skips the confirm prompt and emits the security warning', async () => { + await $$.stubAuths(testOrg); + const result = await OrgAuthShowSfdxAuthUrl.run(['--target-org', testOrg.username, '--json', '--no-prompt']); + expect(prompterStubs.confirm.callCount).to.equal(0); + expect(result).to.have.property('sfdxAuthUrl'); + expect(result.sfdxAuthUrl).to.include(refreshToken); + const warnCalls = sfCommandUxStubs.warn.getCalls().flatMap((c) => c.args); + expect(warnCalls).to.include(messages.getMessage('warning.show-sfdx-auth-url')); + }); + }); + + describe('error: no refresh token', () => { + it('throws the noRefreshToken error with the username', async () => { + testOrg.refreshToken = undefined; + await $$.stubAuths(testOrg); + try { + await OrgAuthShowSfdxAuthUrl.run(['--target-org', testOrg.username, '--no-prompt']); + expect.fail('Expected command to throw'); + } catch (e) { + const err = e as SfError; + expect(err.message).to.equal(messages.getMessage('error.noRefreshToken', [testOrg.username])); + } + }); + }); +});