From c78862bc574b293654c5c0c9228542a9cf7cd108 Mon Sep 17 00:00:00 2001 From: James Simone <16430727+jamessimone@users.noreply.github.com> Date: Thu, 13 Aug 2026 12:25:01 -0400 Subject: [PATCH 1/4] feat: adds client credentials org login command --- README.md | 87 ++++ command-snapshot.json | 20 + messages/client.credentials.md | 57 +++ package.json | 3 + schemas/org-login-client__credentials.json | 135 ++++++ src/commands/org/login/client-credentials.ts | 215 ++++++++++ .../login/login.client-credentials.test.ts | 393 ++++++++++++++++++ 7 files changed, 910 insertions(+) create mode 100644 messages/client.credentials.md create mode 100644 schemas/org-login-client__credentials.json create mode 100644 src/commands/org/login/client-credentials.ts create mode 100644 test/commands/org/login/login.client-credentials.test.ts diff --git a/README.md b/README.md index acd3af94..0359b9eb 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,7 @@ sfdx plugins - [`sf org list auth`](#sf-org-list-auth) - [`sf org login access-token`](#sf-org-login-access-token) +- [`sf org login client-credentials`](#sf-org-login-client-credentials) - [`sf org login jwt`](#sf-org-login-jwt) - [`sf org login sfdx-url`](#sf-org-login-sfdx-url) - [`sf org login web`](#sf-org-login-web) @@ -166,6 +167,92 @@ FLAG DESCRIPTIONS _See code: [src/commands/org/login/access-token.ts](https://github.com/salesforcecli/plugin-auth/blob/5.0.4/src/commands/org/login/access-token.ts)_ +## `sf org login client-credentials` + +Log in to a Salesforce org using the OAuth 2.0 client credentials flow. + +``` +USAGE + $ sf org login client-credentials -o --client-secret -i [--json] [--flags-dir ] [-r ] [-d] + [-s] [-a ] + +FLAGS + -a, --alias= Alias for the org. + -d, --set-default-dev-hub Set the authenticated org as the default Dev Hub. + -i, --client-id= (required) OAuth client ID (also called consumer key) of your custom connected app. + -o, --username= (required) Username of the user logging in. + -r, --instance-url= URL of the instance that the org lives on. + -s, --set-default Set the authenticated org as the default that all org-related commands run against. + --client-secret= (required) OAuth client secret (also called consumer secret) of your custom connected + app. + +GLOBAL FLAGS + --flags-dir= Import flag values from a directory. + --json Format output as json. + +DESCRIPTION + Log in to a Salesforce org using the OAuth 2.0 client credentials flow. + + Use this command in automated environments where you can’t interactively log in with a browser, such as in CI/CD + scripts. + + Logging into an org authorizes the CLI to run other commands that connect to that org, such as deploying or retrieving + a project. You can log into many types of orgs, such as sandboxes, Dev Hubs, Env Hubs, production orgs, and scratch + orgs. + + Complete these steps before you run this command: + + 1. Create a connected app or external client app in your org. Enable the client credentials flow and choose the user + that the integration runs as. + 2. Make note of the consumer key (also called client id) and consumer secret (also called client secret) that are + generated for you. When you run this command, you set the --client-id flag to the consumer key and the --client-secret + flag to the consumer secret. + 3. Use your org’s My Domain URL with --instance-url. The client credentials flow doesn’t support login.salesforce.com + or test.salesforce.com. + + See https://help.salesforce.com/s/articleView?id=sf.remoteaccess_oauth_client_credentials_flow.htm for more + information. + + We recommend that you set an alias when you log into an org. Aliases make it easy to later reference this org when + running commands that require it. If you don’t set an alias, you use the username that you specified when you logged + in to the org. If you run multiple commands that reference the same org, consider setting the org as your default. Use + --set-default for your default scratch org or sandbox, or --set-default-dev-hub for your default Dev Hub. + +EXAMPLES + Log into an org with username jdoe@example.org. The command uses the connected app with consumer key (client id) + 04580y4051234051 and the consumer secret. + + $ sf org login client-credentials --username jdoe@example.org --client-secret very-secret --client-id \ + 04580y4051234051 --instance-url https://MyDomainName.my.salesforce.com + + Set the org as the default and give it an alias: + + $ sf org login client-credentials --username jdoe@example.org --client-secret very-secret --client-id \ + 04580y4051234051 --instance-url https://MyDomainName.my.salesforce.com --alias ci-org --set-default + + Set the org as the default Dev Hub and give it an alias: + + $ sf org login client-credentials --username jdoe@example.org --client-secret very-secret --client-id \ + 04580y4051234051 --instance-url https://MyDomainName.my.salesforce.com --alias ci-dev-hub --set-default-dev-hub + + Log in to a sandbox using URL https://MyDomainName--SandboxName.sandbox.my.salesforce.com: + + $ sf org login client-credentials --username jdoe@example.org --client-secret very-secret --client-id \ + 04580y4051234051 --alias ci-org --set-default --instance-url \ + https://MyDomainName--SandboxName.sandbox.my.salesforce.com + +FLAG DESCRIPTIONS + -r, --instance-url= URL of the instance that the org lives on. + + If you specify an --instance-url value, this value overrides the sfdcLoginUrl value in your sfdx-project.json file. + + To specify a My Domain URL, use the format "https://.my.salesforce.com". + + To specify a sandbox, set --instance-url to "https://--.sandbox.my.salesforce.com". +``` + +_See code: [src/commands/org/login/client-credentials.ts](https://github.com/salesforcecli/plugin-auth/blob/5.0.4/src/commands/org/login/client-credentials.ts)_ + ## `sf org login jwt` Log in to a Salesforce org using a JSON web token (JWT). diff --git a/command-snapshot.json b/command-snapshot.json index 1d95bc20..552f175d 100644 --- a/command-snapshot.json +++ b/command-snapshot.json @@ -31,6 +31,26 @@ ], "plugin": "@salesforce/plugin-auth" }, + { + "alias": [], + "command": "org:login:client-credentials", + "flagAliases": [], + "flagChars": ["a", "d", "i", "o", "p", "r", "s"], + "flags": [ + "alias", + "client-id", + "client-secret", + "flags-dir", + "instance-url", + "json", + "loglevel", + "no-prompt", + "set-default", + "set-default-dev-hub", + "username" + ], + "plugin": "@salesforce/plugin-auth" + }, { "alias": ["force:auth:jwt:grant", "auth:jwt:grant"], "command": "org:login:jwt", diff --git a/messages/client.credentials.md b/messages/client.credentials.md new file mode 100644 index 00000000..7f1e8a30 --- /dev/null +++ b/messages/client.credentials.md @@ -0,0 +1,57 @@ +# summary + +Log in to a Salesforce org using the OAuth 2.0 client credentials flow. + +# description + +Use this command in automated environments where you can’t interactively log in with a browser, such as in CI/CD scripts. + +Logging into an org authorizes the CLI to run other commands that connect to that org, such as deploying or retrieving a project. You can log into many types of orgs, such as sandboxes, Dev Hubs, Env Hubs, production orgs, and scratch orgs. + +Complete these steps before you run this command: + + 1. Create a connected app or external client app in your org. Enable the client credentials flow and choose the user that the integration runs as. + 2. Make note of the consumer key (also called client id) and consumer secret (also called client secret) that are generated for you. When you run this command, you set the --client-id flag to the consumer key and the --client-secret flag to the consumer secret. + 3. Use your org’s My Domain URL with --instance-url. The client credentials flow doesn’t support login.salesforce.com or test.salesforce.com. + +See https://help.salesforce.com/s/articleView?id=sf.remoteaccess_oauth_client_credentials_flow.htm for more information. + +We recommend that you set an alias when you log into an org. Aliases make it easy to later reference this org when running commands that require it. If you don’t set an alias, you use the username that you specified when you logged in to the org. If you run multiple commands that reference the same org, consider setting the org as your default. Use --set-default for your default scratch org or sandbox, or --set-default-dev-hub for your default Dev Hub. + +# examples + +- Log into an org with username jdoe@example.org. The command uses the connected app with a fake consumer key (client id) 04580y4051234051 and the consumer secret. + + <%= config.bin %> <%= command.id %> --username jdoe@example.org --client-secret very-secret --client-id 04580y4051234051 --instance-url https://MyDomainName.my.salesforce.com + +- Set the org as the default and give it an alias: + + <%= config.bin %> <%= command.id %> --username jdoe@example.org --client-secret very-secret --client-id 04580y4051234051 --instance-url https://MyDomainName.my.salesforce.com --alias ci-org --set-default + +- Set the org as the default Dev Hub and give it an alias: + + <%= config.bin %> <%= command.id %> --username jdoe@example.org --client-secret very-secret --client-id 04580y4051234051 --instance-url https://MyDomainName.my.salesforce.com --alias ci-dev-hub --set-default-dev-hub + +- Log in to a sandbox using URL https://MyDomainName--SandboxName.sandbox.my.salesforce.com: + + <%= config.bin %> <%= command.id %> --username jdoe@example.org --client-secret very-secret --client-id 04580y4051234051 --alias ci-org --set-default --instance-url https://MyDomainName--SandboxName.sandbox.my.salesforce.com + +# flags.username.summary + +Username of the user logging in. + +# flags.client-secret.summary + +OAuth client secret (also called consumer secret) of your custom connected app. + +# ClientCredentialsGrantError + +We encountered a client credentials error, which is likely not an issue with Salesforce CLI. Here’s the error: %s + +# httpsRequired + +The client credentials flow requires an HTTPS instance URL. Use your org’s My Domain URL, such as https://MyDomainName.my.salesforce.com. + +# invalidTokenResponse + +The authorization server returned an incomplete token response. diff --git a/package.json b/package.json index 6f603479..20359f17 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,9 @@ "description": "Authorize an org for use with Salesforce CLI.", "longDescription": "Use the auth commands to authorize a Salesforce org for use with the Salesforce CLI.", "subtopics": { + "client-credentials": { + "description": "authorize an org using OAuth client credentials" + }, "jwt": { "description": "authorize an org using JWT" }, diff --git a/schemas/org-login-client__credentials.json b/schemas/org-login-client__credentials.json new file mode 100644 index 00000000..1fa992c1 --- /dev/null +++ b/schemas/org-login-client__credentials.json @@ -0,0 +1,135 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$ref": "#/definitions/AuthFields", + "definitions": { + "AuthFields": { + "type": "object", + "properties": { + "clientApps": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "clientId": { + "type": "string" + }, + "clientSecret": { + "type": "string" + }, + "accessToken": { + "type": "string" + }, + "refreshToken": { + "type": "string" + }, + "oauthFlow": { + "type": "string", + "const": "web" + } + }, + "required": ["clientId", "accessToken", "refreshToken", "oauthFlow"], + "additionalProperties": false + } + }, + "accessToken": { + "type": "string" + }, + "alias": { + "type": "string" + }, + "authCode": { + "type": "string" + }, + "clientId": { + "type": "string" + }, + "clientSecret": { + "type": "string" + }, + "created": { + "type": "string" + }, + "createdOrgInstance": { + "type": "string" + }, + "devHubUsername": { + "type": "string" + }, + "instanceUrl": { + "type": "string" + }, + "instanceApiVersion": { + "type": "string" + }, + "instanceApiVersionLastRetrieved": { + "type": "string" + }, + "isDevHub": { + "type": "boolean" + }, + "loginUrl": { + "type": "string" + }, + "orgId": { + "type": "string" + }, + "password": { + "type": "string" + }, + "privateKey": { + "type": "string" + }, + "refreshToken": { + "type": "string" + }, + "snapshot": { + "type": "string" + }, + "userId": { + "type": "string" + }, + "username": { + "type": "string" + }, + "usernames": { + "type": "array", + "items": { + "type": "string" + } + }, + "userProfileName": { + "type": "string" + }, + "expirationDate": { + "type": "string" + }, + "tracksSource": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "instanceName": { + "type": "string" + }, + "namespacePrefix": { + "type": ["string", "null"] + }, + "isSandbox": { + "type": "boolean" + }, + "isScratch": { + "type": "boolean" + }, + "trailExpirationDate": { + "type": ["string", "null"] + }, + "orgEdition": { + "type": "string" + } + }, + "additionalProperties": false, + "description": "Fields for authorization, org, and local information." + } + } +} diff --git a/src/commands/org/login/client-credentials.ts b/src/commands/org/login/client-credentials.ts new file mode 100644 index 00000000..1991c8f5 --- /dev/null +++ b/src/commands/org/login/client-credentials.ts @@ -0,0 +1,215 @@ +/* + * 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 { Flags, SfCommand, loglevel } from '@salesforce/sf-plugins-core'; +import { AuthFields, AuthInfo, AuthRemover, envVars, Logger, Messages, SfError } from '@salesforce/core'; +import { Interfaces } from '@oclif/core'; +import common from '../../../common.js'; + +Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); +const messages = Messages.loadMessages('@salesforce/plugin-auth', 'client.credentials'); +const commonMessages = Messages.loadMessages('@salesforce/plugin-auth', 'messages'); +const secretsMessages = Messages.loadMessages('@salesforce/plugin-auth', 'secrets-redacted'); + +type ClientCredentialsTokenResponse = { + accessToken: string; + instanceUrl: string; +}; + +export default class LoginClientCredentials 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 = { + username: Flags.string({ + // eslint-disable-next-line sf-plugin/dash-o + char: 'o', + summary: messages.getMessage('flags.username.summary'), + required: true, + }), + 'client-secret': Flags.string({ + summary: messages.getMessage('flags.client-secret.summary'), + required: true, + }), + 'client-id': Flags.string({ + char: 'i', + summary: commonMessages.getMessage('flags.client-id.summary'), + required: true, + }), + 'instance-url': Flags.url({ + char: 'r', + summary: commonMessages.getMessage('flags.instance-url.summary'), + description: commonMessages.getMessage('flags.instance-url.description'), + }), + 'set-default-dev-hub': Flags.boolean({ + char: 'd', + summary: commonMessages.getMessage('flags.set-default-dev-hub.summary'), + }), + 'set-default': Flags.boolean({ + char: 's', + summary: commonMessages.getMessage('flags.set-default.summary'), + }), + alias: Flags.string({ + char: 'a', + summary: commonMessages.getMessage('flags.alias.summary'), + }), + 'no-prompt': Flags.boolean({ + char: 'p', + summary: commonMessages.getMessage('flags.no-prompt.summary'), + required: false, + hidden: true, + }), + loglevel, + }; + private flags!: Interfaces.InferredFlags; + private logger = Logger.childFromRoot(this.constructor.name); + + public async run(): Promise { + const { flags } = await this.parse(LoginClientCredentials); + this.flags = flags; + let result: AuthFields = {}; + + if (await common.shouldExitCommand(flags['no-prompt'])) return {}; + + try { + const authInfo = await this.initAuthInfo(); + await authInfo.handleAliasAndDefaultSettings({ + alias: flags.alias, + setDefault: flags['set-default'], + setDefaultDevHub: flags['set-default-dev-hub'], + }); + result = authInfo.getFields(true); + await AuthInfo.identifyPossibleScratchOrgs(result, authInfo); + } catch (err) { + const msg = err instanceof Error ? `${err.name}::${err.message}` : typeof err === 'string' ? err : 'UNKNOWN'; + throw SfError.create({ + message: messages.getMessage('ClientCredentialsGrantError', [msg]), + name: 'ClientCredentialsGrantError', + ...(err instanceof Error ? { cause: err } : {}), + }); + } + + const successMsg = commonMessages.getMessage('authorizeCommandSuccess', [result.username, result.orgId]); + this.logSuccess(successMsg); + + // TODO: Remove env var workaround + if (this.jsonEnabled()) { + if (envVars.getBoolean('SF_TEMP_SHOW_SECRETS', false)) { + this.warn(secretsMessages.getMessage('temp.envVarIsSet', ['sf org login client-credentials'])); + } else { + this.warn(secretsMessages.getMessage('temp.envVarWorkaround', ['sf org login client-credentials'])); + } + } + + return common.redactAuthFields(result); + } + + private async initAuthInfo(): Promise { + const loginUrl = await common.resolveLoginUrl(this.flags['instance-url']?.href); + const token = await this.requestClientCredentialsToken(loginUrl); + + const accessTokenOptions = { + accessToken: token.accessToken, + instanceUrl: token.instanceUrl, + loginUrl, + }; + + let authInfo: AuthInfo; + try { + authInfo = await AuthInfo.create({ + username: this.flags.username, + accessTokenOptions, + }); + } catch (error) { + const err = error as SfError; + if (err.name === 'AuthInfoOverwriteError') { + this.logger.debug('Auth file already exists. Removing and starting fresh.'); + const remover = await AuthRemover.create(); + await remover.removeAuth(this.flags.username); + authInfo = await AuthInfo.create({ + username: this.flags.username, + accessTokenOptions, + }); + } else { + throw err; + } + } + await authInfo.save({ + clientId: this.flags['client-id'], + clientSecret: this.flags['client-secret'], + }); + return authInfo; + } + + /** + * Exchange the connected app client id and secret for an access token. + * The secret is sent in the POST body, never the query string. + */ + private async requestClientCredentialsToken(loginUrl: string): Promise { + const base = loginUrl.endsWith('/') ? loginUrl : `${loginUrl}/`; + const tokenUrl = new URL('services/oauth2/token', base); + if (tokenUrl.protocol !== 'https:') { + throw new SfError(messages.getMessage('httpsRequired'), 'ClientCredentialsAuthError'); + } + + const body = new URLSearchParams(); + body.set('grant_type', 'client_credentials'); + body.set('client_id', this.flags['client-id']); + body.set('client_secret', this.flags['client-secret']); + + const response = await fetch(tokenUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body, + }); + + const payload = await parseTokenResponse(response); + if (!response.ok) { + const detail = payload.errorDescription ?? payload.error ?? `HTTP ${response.status}`; + throw new SfError(detail, 'ClientCredentialsAuthError'); + } + if (!payload.accessToken || !payload.instanceUrl) { + throw new SfError(messages.getMessage('invalidTokenResponse'), 'ClientCredentialsAuthError'); + } + + return { + accessToken: payload.accessToken, + instanceUrl: payload.instanceUrl, + }; + } +} + +type TokenResponseBody = { + accessToken?: string; + instanceUrl?: string; + error?: string; + errorDescription?: string; +}; + +const parseTokenResponse = async (response: Response): Promise => { + try { + const raw = (await response.json()) as Record; + return { + accessToken: typeof raw.access_token === 'string' ? raw.access_token : undefined, + instanceUrl: typeof raw.instance_url === 'string' ? raw.instance_url : undefined, + error: typeof raw.error === 'string' ? raw.error : undefined, + errorDescription: typeof raw.error_description === 'string' ? raw.error_description : undefined, + }; + } catch { + return {}; + } +}; diff --git a/test/commands/org/login/login.client-credentials.test.ts b/test/commands/org/login/login.client-credentials.test.ts new file mode 100644 index 00000000..22a37165 --- /dev/null +++ b/test/commands/org/login/login.client-credentials.test.ts @@ -0,0 +1,393 @@ +/* + * 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 { AuthFields, AuthInfo, SfError } from '@salesforce/core'; +import { MockTestOrgData, TestContext } from '@salesforce/core/testSetup'; +import { StubbedType, stubInterface } from '@salesforce/ts-sinon'; +import { expect } from 'chai'; +import { stubUx } from '@salesforce/sf-plugins-core'; +import LoginClientCredentials from '../../../../src/commands/org/login/client-credentials.js'; + +type Options = { + authInfoCreateFails?: boolean; + existingAuth?: boolean; + tokenRequestFails?: boolean; +}; + +describe('org:login:client-credentials', () => { + const $$ = new TestContext(); + + const testData = new MockTestOrgData(); + const clientSecret = 'very-secret'; + const instanceUrl = 'https://MyDomainName.my.salesforce.com'; + let authFields: AuthFields; + let authInfoStub: StubbedType; + let fetchStub: { callCount: number; firstCall: { args: unknown[] } }; + + const jsonResponse = (body: unknown, ok = true, status = 200): Response => + ({ + ok, + status, + json: () => Promise.resolve(body), + } as Response); + + async function prepareStubs(options: Options = {}): Promise { + authFields = await testData.getConfig(); + delete authFields.isDevHub; + + authInfoStub = stubInterface($$.SANDBOX, { + getFields: () => authFields, + }); + + await $$.stubAuths(testData); + + /* eslint-disable camelcase */ + if (options.tokenRequestFails) { + fetchStub = $$.SANDBOX.stub(globalThis, 'fetch').resolves( + jsonResponse({ error: 'invalid_client', error_description: 'client identifier invalid' }, false, 400) + ); + } else { + fetchStub = $$.SANDBOX.stub(globalThis, 'fetch').resolves( + jsonResponse({ + access_token: '00Dxx0000000000!token', + instance_url: instanceUrl, + }) + ); + } + /* eslint-enable camelcase */ + + if (options.authInfoCreateFails) { + $$.SANDBOX.stub(AuthInfo, 'create').throws(new Error('invalid client id')); + } else if (options.existingAuth) { + $$.SANDBOX.stub(AuthInfo, 'create') + .onFirstCall() + .throws(new SfError('auth exists', 'AuthInfoOverwriteError')) + .onSecondCall() + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + .resolves(authInfoStub); + } else if (!options.tokenRequestFails) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + $$.SANDBOX.stub(AuthInfo, 'create').resolves(authInfoStub); + } + + stubUx($$.SANDBOX); + } + + it('should return auth fields', async () => { + await prepareStubs(); + const response = await LoginClientCredentials.run([ + '-o', + testData.username, + '--client-secret', + clientSecret, + '-i', + '123456', + '-r', + instanceUrl, + '--json', + ]); + expect(response.username).to.equal(testData.username); + }); + + it('should request a token with client credentials in the POST body, not the URL', async () => { + await prepareStubs(); + await LoginClientCredentials.run([ + '-o', + testData.username, + '--client-secret', + clientSecret, + '-i', + '123456', + '-r', + instanceUrl, + '--json', + ]); + expect(fetchStub.callCount).to.equal(1); + const [url, init] = fetchStub.firstCall.args as [URL, RequestInit]; + expect(url.pathname).to.equal('/services/oauth2/token'); + expect(url.search).to.equal(''); + expect(String(url)).to.not.include(clientSecret); + expect(init.method).to.equal('POST'); + expect(init.headers).to.deep.equal({ 'Content-Type': 'application/x-www-form-urlencoded' }); + const body = String(init.body); + expect(body).to.include('grant_type=client_credentials'); + expect(body).to.include('client_id=123456'); + expect(body).to.include(`client_secret=${clientSecret}`); + }); + + it('should set alias when -a is provided', async () => { + await prepareStubs(); + await LoginClientCredentials.run([ + '-o', + testData.username, + '--client-secret', + clientSecret, + '-i', + '123456', + '-r', + instanceUrl, + '-a', + 'MyAlias', + '--json', + ]); + expect(authInfoStub.handleAliasAndDefaultSettings.callCount).to.equal(1); + }); + + it('should set target-org to alias when -s and -a are provided', async () => { + await prepareStubs(); + await LoginClientCredentials.run([ + '-o', + testData.username, + '--client-secret', + clientSecret, + '-i', + '123456', + '-r', + instanceUrl, + '-a', + 'MyAlias', + '-s', + '--json', + ]); + expect(authInfoStub.handleAliasAndDefaultSettings.callCount).to.equal(1); + expect(authInfoStub.handleAliasAndDefaultSettings.args[0]).to.deep.equal([ + { + alias: 'MyAlias', + setDefaultDevHub: undefined, + setDefault: true, + }, + ]); + }); + + it('should set target-org to username when -s is provided', async () => { + await prepareStubs(); + await LoginClientCredentials.run([ + '-o', + testData.username, + '--client-secret', + clientSecret, + '-i', + '123456', + '-r', + instanceUrl, + '-s', + '--json', + ]); + expect(authInfoStub.handleAliasAndDefaultSettings.callCount).to.equal(1); + expect(authInfoStub.handleAliasAndDefaultSettings.args[0]).to.deep.equal([ + { + alias: undefined, + setDefaultDevHub: undefined, + setDefault: true, + }, + ]); + }); + + it('should set target-dev-hub to alias when -d and -a are provided', async () => { + await prepareStubs(); + await LoginClientCredentials.run([ + '-o', + testData.username, + '--client-secret', + clientSecret, + '-i', + '123456', + '-r', + instanceUrl, + '-a', + 'MyAlias', + '-d', + '--json', + ]); + expect(authInfoStub.handleAliasAndDefaultSettings.callCount).to.equal(1); + expect(authInfoStub.handleAliasAndDefaultSettings.args[0]).to.deep.equal([ + { + alias: 'MyAlias', + setDefaultDevHub: true, + setDefault: undefined, + }, + ]); + }); + + it('should set target-dev-hub to username when -d is provided', async () => { + await prepareStubs(); + await LoginClientCredentials.run([ + '-o', + testData.username, + '--client-secret', + clientSecret, + '-i', + '123456', + '-r', + instanceUrl, + '-d', + '--json', + ]); + expect(authInfoStub.handleAliasAndDefaultSettings.callCount).to.equal(1); + expect(authInfoStub.handleAliasAndDefaultSettings.args[0]).to.deep.equal([ + { + alias: undefined, + setDefaultDevHub: true, + setDefault: undefined, + }, + ]); + }); + + it('should set target-org and target-dev-hub to username when -d and -s are provided', async () => { + await prepareStubs(); + await LoginClientCredentials.run([ + '-o', + testData.username, + '--client-secret', + clientSecret, + '-i', + '123456', + '-r', + instanceUrl, + '-d', + '-s', + '--json', + ]); + expect(authInfoStub.setAlias.callCount).to.equal(0); + expect(authInfoStub.handleAliasAndDefaultSettings.callCount).to.equal(1); + expect(authInfoStub.handleAliasAndDefaultSettings.args[0]).to.deep.equal([ + { + alias: undefined, + setDefaultDevHub: true, + setDefault: true, + }, + ]); + }); + + it('should set target-org and target-dev-hub to alias when -a, -d, and -s are provided', async () => { + await prepareStubs(); + await LoginClientCredentials.run([ + '-o', + testData.username, + '--client-secret', + clientSecret, + '-i', + '123456', + '-r', + instanceUrl, + '-d', + '-s', + '-a', + 'MyAlias', + '--json', + ]); + expect(authInfoStub.handleAliasAndDefaultSettings.callCount).to.equal(1); + expect(authInfoStub.handleAliasAndDefaultSettings.args[0]).to.deep.equal([ + { + alias: 'MyAlias', + setDefaultDevHub: true, + setDefault: true, + }, + ]); + }); + + it('should throw an error when client id is invalid', async () => { + await prepareStubs({ tokenRequestFails: true }); + try { + await LoginClientCredentials.run([ + '-o', + testData.username, + '--client-secret', + clientSecret, + '-i', + '123456INVALID', + '-r', + instanceUrl, + '--json', + ]); + expect.fail('Should have thrown an error'); + } catch (e) { + expect(e).to.be.instanceOf(Error); + const authError = e as SfError; + expect(authError.message).to.include('We encountered a client credentials error'); + expect(authError.message).to.include('client identifier invalid'); + expect(authError.cause, 'ClientCredentialsGrantError should include original error as the cause').to.be.ok; + } + }); + + it('should throw an error when AuthInfo.create fails', async () => { + await prepareStubs({ authInfoCreateFails: true }); + try { + await LoginClientCredentials.run([ + '-o', + testData.username, + '--client-secret', + clientSecret, + '-i', + '123456', + '-r', + instanceUrl, + '--json', + ]); + expect.fail('Should have thrown an error'); + } catch (e) { + expect(e).to.be.instanceOf(Error); + const authError = e as SfError; + expect(authError.message).to.include('We encountered a client credentials error'); + expect(authError.message).to.include('invalid client id'); + expect(authError.cause, 'ClientCredentialsGrantError should include original error as the cause').to.be.ok; + } + }); + + it('should not throw an error when the authorization already exists', async () => { + await prepareStubs({ existingAuth: true }); + try { + await LoginClientCredentials.run([ + '-o', + testData.username, + '--client-secret', + clientSecret, + '-i', + '123456', + '-r', + instanceUrl, + '--json', + ]); + } catch (e) { + expect.fail('Should not have thrown an error'); + } + }); + + it('should throw an error when the instance URL is not HTTPS', async () => { + await prepareStubs(); + try { + await LoginClientCredentials.run([ + '-o', + testData.username, + '--client-secret', + clientSecret, + '-i', + '123456', + '-r', + 'http://MyDomainName.my.salesforce.com', + '--json', + ]); + expect.fail('Should have thrown an error'); + } catch (e) { + expect(e).to.be.instanceOf(Error); + const authError = e as SfError; + expect(authError.message).to.include('We encountered a client credentials error'); + expect(authError.message).to.include('requires an HTTPS instance URL'); + } + }); +}); From bfe4c112df777abcd8f8f04b92615cd11d29f943 Mon Sep 17 00:00:00 2001 From: James Simone <16430727+jamessimone@users.noreply.github.com> Date: Thu, 13 Aug 2026 15:51:18 -0400 Subject: [PATCH 2/4] feat: initial code review feedback --- README.md | 21 +- command-snapshot.json | 6 +- messages/client.credentials.md | 20 +- src/commands/org/login/client-credentials.ts | 105 ++--- .../login/login.client-credentials.test.ts | 402 ++++-------------- test/hooks/diagnostics.test.ts | 2 + 6 files changed, 133 insertions(+), 423 deletions(-) diff --git a/README.md b/README.md index 0359b9eb..c740805d 100644 --- a/README.md +++ b/README.md @@ -173,8 +173,7 @@ Log in to a Salesforce org using the OAuth 2.0 client credentials flow. ``` USAGE - $ sf org login client-credentials -o --client-secret -i [--json] [--flags-dir ] [-r ] [-d] - [-s] [-a ] + $ SF_CLIENT_SECRET=mySecretKey sf org login client-credentials -o -i [--json] [--flags-dir ] [-r ] [-d] [-s] [-a ] FLAGS -a, --alias= Alias for the org. @@ -183,8 +182,6 @@ FLAGS -o, --username= (required) Username of the user logging in. -r, --instance-url= URL of the instance that the org lives on. -s, --set-default Set the authenticated org as the default that all org-related commands run against. - --client-secret= (required) OAuth client secret (also called consumer secret) of your custom connected - app. GLOBAL FLAGS --flags-dir= Import flag values from a directory. @@ -205,8 +202,8 @@ DESCRIPTION 1. Create a connected app or external client app in your org. Enable the client credentials flow and choose the user that the integration runs as. 2. Make note of the consumer key (also called client id) and consumer secret (also called client secret) that are - generated for you. When you run this command, you set the --client-id flag to the consumer key and the --client-secret - flag to the consumer secret. + generated for you. Set the consumer secret in the SF_CLIENT_SECRET environment variable. When you run this command, + set the --client-id flag to the consumer key. 3. Use your org’s My Domain URL with --instance-url. The client credentials flow doesn’t support login.salesforce.com or test.salesforce.com. @@ -219,25 +216,25 @@ DESCRIPTION --set-default for your default scratch org or sandbox, or --set-default-dev-hub for your default Dev Hub. EXAMPLES - Log into an org with username jdoe@example.org. The command uses the connected app with consumer key (client id) - 04580y4051234051 and the consumer secret. + Log into an org with username jdoe@example.org. Set the connected app consumer secret as an environment variable, then + run the command with its consumer key (client id) 04580y4051234051. - $ sf org login client-credentials --username jdoe@example.org --client-secret very-secret --client-id \ + $ SF_CLIENT_SECRET=very-secret sf org login client-credentials --username jdoe@example.org --client-id \ 04580y4051234051 --instance-url https://MyDomainName.my.salesforce.com Set the org as the default and give it an alias: - $ sf org login client-credentials --username jdoe@example.org --client-secret very-secret --client-id \ + $ SF_CLIENT_SECRET=very-secret sf org login client-credentials --username jdoe@example.org --client-id \ 04580y4051234051 --instance-url https://MyDomainName.my.salesforce.com --alias ci-org --set-default Set the org as the default Dev Hub and give it an alias: - $ sf org login client-credentials --username jdoe@example.org --client-secret very-secret --client-id \ + $ SF_CLIENT_SECRET=very-secret sf org login client-credentials --username jdoe@example.org --client-id \ 04580y4051234051 --instance-url https://MyDomainName.my.salesforce.com --alias ci-dev-hub --set-default-dev-hub Log in to a sandbox using URL https://MyDomainName--SandboxName.sandbox.my.salesforce.com: - $ sf org login client-credentials --username jdoe@example.org --client-secret very-secret --client-id \ + $ SF_CLIENT_SECRET=very-secret sf org login client-credentials --username jdoe@example.org --client-id \ 04580y4051234051 --alias ci-org --set-default --instance-url \ https://MyDomainName--SandboxName.sandbox.my.salesforce.com diff --git a/command-snapshot.json b/command-snapshot.json index 552f175d..aa2e1d26 100644 --- a/command-snapshot.json +++ b/command-snapshot.json @@ -35,19 +35,17 @@ "alias": [], "command": "org:login:client-credentials", "flagAliases": [], - "flagChars": ["a", "d", "i", "o", "p", "r", "s"], + "flagChars": ["a", "d", "i", "p", "r", "s"], "flags": [ "alias", "client-id", - "client-secret", "flags-dir", "instance-url", "json", "loglevel", "no-prompt", "set-default", - "set-default-dev-hub", - "username" + "set-default-dev-hub" ], "plugin": "@salesforce/plugin-auth" }, diff --git a/messages/client.credentials.md b/messages/client.credentials.md index 7f1e8a30..be7048f9 100644 --- a/messages/client.credentials.md +++ b/messages/client.credentials.md @@ -11,7 +11,7 @@ Logging into an org authorizes the CLI to run other commands that connect to tha Complete these steps before you run this command: 1. Create a connected app or external client app in your org. Enable the client credentials flow and choose the user that the integration runs as. - 2. Make note of the consumer key (also called client id) and consumer secret (also called client secret) that are generated for you. When you run this command, you set the --client-id flag to the consumer key and the --client-secret flag to the consumer secret. + 2. Make note of the consumer key (also called client id) and consumer secret (also called client secret) that are generated for you. Set the consumer secret in the SF_CLIENT_SECRET environment variable. When you run this command, set the --client-id flag to the consumer key so the secret isn't passed as a command argument. 3. Use your org’s My Domain URL with --instance-url. The client credentials flow doesn’t support login.salesforce.com or test.salesforce.com. See https://help.salesforce.com/s/articleView?id=sf.remoteaccess_oauth_client_credentials_flow.htm for more information. @@ -20,30 +20,26 @@ We recommend that you set an alias when you log into an org. Aliases make it eas # examples -- Log into an org with username jdoe@example.org. The command uses the connected app with a fake consumer key (client id) 04580y4051234051 and the consumer secret. +- Log into an org with username jdoe@example.org. Set the connected app consumer secret as an environment variable, then run the command with its fake consumer key (client id) 04580y4051234051. - <%= config.bin %> <%= command.id %> --username jdoe@example.org --client-secret very-secret --client-id 04580y4051234051 --instance-url https://MyDomainName.my.salesforce.com + SF_CLIENT_SECRET=very-secret <%= config.bin %> <%= command.id %> --username jdoe@example.org --client-id 04580y4051234051 --instance-url https://MyDomainName.my.salesforce.com - Set the org as the default and give it an alias: - <%= config.bin %> <%= command.id %> --username jdoe@example.org --client-secret very-secret --client-id 04580y4051234051 --instance-url https://MyDomainName.my.salesforce.com --alias ci-org --set-default + SF_CLIENT_SECRET=very-secret <%= config.bin %> <%= command.id %> --username jdoe@example.org --client-id 04580y4051234051 --instance-url https://MyDomainName.my.salesforce.com --alias ci-org --set-default - Set the org as the default Dev Hub and give it an alias: - <%= config.bin %> <%= command.id %> --username jdoe@example.org --client-secret very-secret --client-id 04580y4051234051 --instance-url https://MyDomainName.my.salesforce.com --alias ci-dev-hub --set-default-dev-hub + SF_CLIENT_SECRET=very-secret <%= config.bin %> <%= command.id %> --username jdoe@example.org --client-id 04580y4051234051 --instance-url https://MyDomainName.my.salesforce.com --alias ci-dev-hub --set-default-dev-hub - Log in to a sandbox using URL https://MyDomainName--SandboxName.sandbox.my.salesforce.com: - <%= config.bin %> <%= command.id %> --username jdoe@example.org --client-secret very-secret --client-id 04580y4051234051 --alias ci-org --set-default --instance-url https://MyDomainName--SandboxName.sandbox.my.salesforce.com + SF_CLIENT_SECRET=very-secret <%= config.bin %> <%= command.id %> --username jdoe@example.org --client-id 04580y4051234051 --alias ci-org --set-default --instance-url https://MyDomainName--SandboxName.sandbox.my.salesforce.com # flags.username.summary Username of the user logging in. -# flags.client-secret.summary - -OAuth client secret (also called consumer secret) of your custom connected app. - # ClientCredentialsGrantError We encountered a client credentials error, which is likely not an issue with Salesforce CLI. Here’s the error: %s @@ -52,6 +48,10 @@ We encountered a client credentials error, which is likely not an issue with Sal The client credentials flow requires an HTTPS instance URL. Use your org’s My Domain URL, such as https://MyDomainName.my.salesforce.com. +# clientSecretMissingResponse + +The client secret environment variable was not set, aborting login call. + # invalidTokenResponse The authorization server returned an incomplete token response. diff --git a/src/commands/org/login/client-credentials.ts b/src/commands/org/login/client-credentials.ts index 1991c8f5..ea76c58d 100644 --- a/src/commands/org/login/client-credentials.ts +++ b/src/commands/org/login/client-credentials.ts @@ -15,14 +15,15 @@ */ import { Flags, SfCommand, loglevel } from '@salesforce/sf-plugins-core'; -import { AuthFields, AuthInfo, AuthRemover, envVars, Logger, Messages, SfError } from '@salesforce/core'; +import { AuthFields, Messages, SfError } from '@salesforce/core'; +import { env } from '@salesforce/kit'; import { Interfaces } from '@oclif/core'; import common from '../../../common.js'; +import AccessToken from './access-token.js'; Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); const messages = Messages.loadMessages('@salesforce/plugin-auth', 'client.credentials'); const commonMessages = Messages.loadMessages('@salesforce/plugin-auth', 'messages'); -const secretsMessages = Messages.loadMessages('@salesforce/plugin-auth', 'secrets-redacted'); type ClientCredentialsTokenResponse = { accessToken: string; @@ -35,16 +36,6 @@ export default class LoginClientCredentials extends SfCommand { public static readonly examples = messages.getMessages('examples'); public static readonly flags = { - username: Flags.string({ - // eslint-disable-next-line sf-plugin/dash-o - char: 'o', - summary: messages.getMessage('flags.username.summary'), - required: true, - }), - 'client-secret': Flags.string({ - summary: messages.getMessage('flags.client-secret.summary'), - required: true, - }), 'client-id': Flags.string({ char: 'i', summary: commonMessages.getMessage('flags.client-id.summary'), @@ -76,24 +67,21 @@ export default class LoginClientCredentials extends SfCommand { loglevel, }; private flags!: Interfaces.InferredFlags; - private logger = Logger.childFromRoot(this.constructor.name); + + private static getClientSecret(): string { + const clientSecret = env.getString('SF_CLIENT_SECRET'); + if (!clientSecret) { + throw new SfError(messages.getMessage('clientSecretMissingResponse')); + } + return clientSecret; + } public async run(): Promise { const { flags } = await this.parse(LoginClientCredentials); this.flags = flags; - let result: AuthFields = {}; - - if (await common.shouldExitCommand(flags['no-prompt'])) return {}; try { - const authInfo = await this.initAuthInfo(); - await authInfo.handleAliasAndDefaultSettings({ - alias: flags.alias, - setDefault: flags['set-default'], - setDefaultDevHub: flags['set-default-dev-hub'], - }); - result = authInfo.getFields(true); - await AuthInfo.identifyPossibleScratchOrgs(result, authInfo); + return await this.performClientCredentialsLogin(); } catch (err) { const msg = err instanceof Error ? `${err.name}::${err.message}` : typeof err === 'string' ? err : 'UNKNOWN'; throw SfError.create({ @@ -101,58 +89,29 @@ export default class LoginClientCredentials extends SfCommand { name: 'ClientCredentialsGrantError', ...(err instanceof Error ? { cause: err } : {}), }); + } finally { + env.unset('SF_ACCESS_TOKEN'); } - - const successMsg = commonMessages.getMessage('authorizeCommandSuccess', [result.username, result.orgId]); - this.logSuccess(successMsg); - - // TODO: Remove env var workaround - if (this.jsonEnabled()) { - if (envVars.getBoolean('SF_TEMP_SHOW_SECRETS', false)) { - this.warn(secretsMessages.getMessage('temp.envVarIsSet', ['sf org login client-credentials'])); - } else { - this.warn(secretsMessages.getMessage('temp.envVarWorkaround', ['sf org login client-credentials'])); - } - } - - return common.redactAuthFields(result); } - private async initAuthInfo(): Promise { + private async performClientCredentialsLogin(): Promise { const loginUrl = await common.resolveLoginUrl(this.flags['instance-url']?.href); - const token = await this.requestClientCredentialsToken(loginUrl); - - const accessTokenOptions = { - accessToken: token.accessToken, - instanceUrl: token.instanceUrl, - loginUrl, - }; - - let authInfo: AuthInfo; - try { - authInfo = await AuthInfo.create({ - username: this.flags.username, - accessTokenOptions, - }); - } catch (error) { - const err = error as SfError; - if (err.name === 'AuthInfoOverwriteError') { - this.logger.debug('Auth file already exists. Removing and starting fresh.'); - const remover = await AuthRemover.create(); - await remover.removeAuth(this.flags.username); - authInfo = await AuthInfo.create({ - username: this.flags.username, - accessTokenOptions, - }); - } else { - throw err; - } - } - await authInfo.save({ - clientId: this.flags['client-id'], - clientSecret: this.flags['client-secret'], - }); - return authInfo; + const tokenResponse = await this.requestClientCredentialsToken(loginUrl); + + env.setString('SF_ACCESS_TOKEN', tokenResponse.accessToken); + + // the AccessToken command, and other commands in general + // don't play nice with extra args being passed to them, so we strip out + // the one extra flag prior to calling that command + const accessTokenArgs = this.argv.filter( + (arg, index, args) => + !['-i', '--client-id'].includes(arg) && + !arg.startsWith('-i=') && + !arg.startsWith('--client-id=') && + !['-i', '--client-id'].includes(args[index - 1] ?? '') + ); + const response = await new AccessToken(accessTokenArgs, this.config).run(); + return response; } /** @@ -169,7 +128,7 @@ export default class LoginClientCredentials extends SfCommand { const body = new URLSearchParams(); body.set('grant_type', 'client_credentials'); body.set('client_id', this.flags['client-id']); - body.set('client_secret', this.flags['client-secret']); + body.set('client_secret', LoginClientCredentials.getClientSecret()); const response = await fetch(tokenUrl, { method: 'POST', diff --git a/test/commands/org/login/login.client-credentials.test.ts b/test/commands/org/login/login.client-credentials.test.ts index 22a37165..f006f7b7 100644 --- a/test/commands/org/login/login.client-credentials.test.ts +++ b/test/commands/org/login/login.client-credentials.test.ts @@ -14,28 +14,46 @@ * limitations under the License. */ -import { AuthFields, AuthInfo, SfError } from '@salesforce/core'; -import { MockTestOrgData, TestContext } from '@salesforce/core/testSetup'; -import { StubbedType, stubInterface } from '@salesforce/ts-sinon'; +import { AuthFields, SfError } from '@salesforce/core'; +import { TestContext } from '@salesforce/core/testSetup'; +import type { SinonStub } from 'sinon'; import { expect } from 'chai'; -import { stubUx } from '@salesforce/sf-plugins-core'; import LoginClientCredentials from '../../../../src/commands/org/login/client-credentials.js'; +import AccessToken from '../../../../src/commands/org/login/access-token.js'; type Options = { - authInfoCreateFails?: boolean; - existingAuth?: boolean; tokenRequestFails?: boolean; }; describe('org:login:client-credentials', () => { const $$ = new TestContext(); - - const testData = new MockTestOrgData(); const clientSecret = 'very-secret'; const instanceUrl = 'https://MyDomainName.my.salesforce.com'; - let authFields: AuthFields; - let authInfoStub: StubbedType; + const authFields = { username: 'jdoe@example.org' } as AuthFields; let fetchStub: { callCount: number; firstCall: { args: unknown[] } }; + let accessTokenRunStub: SinonStub; + let originalClientSecret: string | undefined; + let originalAccessToken: string | undefined; + + beforeEach(() => { + originalClientSecret = process.env.SF_CLIENT_SECRET; + originalAccessToken = process.env.SF_ACCESS_TOKEN; + process.env.SF_CLIENT_SECRET = clientSecret; + accessTokenRunStub = $$.SANDBOX.stub(AccessToken.prototype, 'run').resolves(authFields); + }); + + afterEach(() => { + if (originalClientSecret !== undefined) { + process.env.SF_CLIENT_SECRET = originalClientSecret; + } else { + delete process.env.SF_CLIENT_SECRET; + } + if (originalAccessToken !== undefined) { + process.env.SF_ACCESS_TOKEN = originalAccessToken; + } else { + delete process.env.SF_ACCESS_TOKEN; + } + }); const jsonResponse = (body: unknown, ok = true, status = 200): Response => ({ @@ -44,79 +62,61 @@ describe('org:login:client-credentials', () => { json: () => Promise.resolve(body), } as Response); - async function prepareStubs(options: Options = {}): Promise { - authFields = await testData.getConfig(); - delete authFields.isDevHub; + const prepareStubs = (options: Options = {}): void => { + /* eslint-disable camelcase */ + fetchStub = $$.SANDBOX.stub(globalThis, 'fetch').resolves( + options.tokenRequestFails + ? jsonResponse({ error: 'invalid_client', error_description: 'client identifier invalid' }, false, 400) + : jsonResponse({ + access_token: '00Dxx0000000000!token', + instance_url: instanceUrl, + }) + ); + /* eslint-enable camelcase */ + }; - authInfoStub = stubInterface($$.SANDBOX, { - getFields: () => authFields, - }); + it('should return the access-token login response', async () => { + prepareStubs(); + const response = await LoginClientCredentials.run(['-i', '123456', '-r', instanceUrl, '--json']); - await $$.stubAuths(testData); + expect(response).to.equal(authFields); + expect(accessTokenRunStub.callCount).to.equal(1); + expect(process.env.SF_ACCESS_TOKEN).to.be.undefined; + }); - /* eslint-disable camelcase */ - if (options.tokenRequestFails) { - fetchStub = $$.SANDBOX.stub(globalThis, 'fetch').resolves( - jsonResponse({ error: 'invalid_client', error_description: 'client identifier invalid' }, false, 400) - ); - } else { - fetchStub = $$.SANDBOX.stub(globalThis, 'fetch').resolves( - jsonResponse({ - access_token: '00Dxx0000000000!token', - instance_url: instanceUrl, - }) - ); - } - /* eslint-enable camelcase */ + it('should omit the short client-id flag before delegating to the access-token command', async () => { + prepareStubs(); + await LoginClientCredentials.run(['-i', '123456', '-r', instanceUrl, '--set-default', '--json']); - if (options.authInfoCreateFails) { - $$.SANDBOX.stub(AuthInfo, 'create').throws(new Error('invalid client id')); - } else if (options.existingAuth) { - $$.SANDBOX.stub(AuthInfo, 'create') - .onFirstCall() - .throws(new SfError('auth exists', 'AuthInfoOverwriteError')) - .onSecondCall() - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - .resolves(authInfoStub); - } else if (!options.tokenRequestFails) { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - $$.SANDBOX.stub(AuthInfo, 'create').resolves(authInfoStub); - } + const delegatedCommand = accessTokenRunStub.firstCall.thisValue as { argv: string[] }; + expect(delegatedCommand.argv).to.deep.equal(['-r', instanceUrl, '--set-default', '--json']); + }); + + it('should omit the long client-id flag before delegating to the access-token command', async () => { + prepareStubs(); + await LoginClientCredentials.run(['--client-id', '123456', '-r', instanceUrl, '--alias', 'ci-org', '--json']); - stubUx($$.SANDBOX); - } + const delegatedCommand = accessTokenRunStub.firstCall.thisValue as { argv: string[] }; + expect(delegatedCommand.argv).to.deep.equal(['-r', instanceUrl, '--alias', 'ci-org', '--json']); + }); - it('should return auth fields', async () => { - await prepareStubs(); - const response = await LoginClientCredentials.run([ - '-o', - testData.username, - '--client-secret', - clientSecret, - '-i', - '123456', - '-r', - instanceUrl, - '--json', - ]); - expect(response.username).to.equal(testData.username); + it('should throw an error when the client secret environment variable is missing', async () => { + delete process.env.SF_CLIENT_SECRET; + try { + await LoginClientCredentials.run(['-i', '123456', '-r', instanceUrl, '--json']); + expect.fail('Should have thrown an error'); + } catch (e) { + expect(e).to.be.instanceOf(Error); + const authError = e as SfError; + expect(authError.message).to.include('The client secret environment variable was not set'); + } + expect(accessTokenRunStub.callCount).to.equal(0); }); it('should request a token with client credentials in the POST body, not the URL', async () => { - await prepareStubs(); - await LoginClientCredentials.run([ - '-o', - testData.username, - '--client-secret', - clientSecret, - '-i', - '123456', - '-r', - instanceUrl, - '--json', - ]); + prepareStubs(); + await LoginClientCredentials.run(['-i', '123456', '-r', instanceUrl, '--json']); + expect(fetchStub.callCount).to.equal(1); const [url, init] = fetchStub.firstCall.args as [URL, RequestInit]; expect(url.pathname).to.equal('/services/oauth2/token'); @@ -130,191 +130,10 @@ describe('org:login:client-credentials', () => { expect(body).to.include(`client_secret=${clientSecret}`); }); - it('should set alias when -a is provided', async () => { - await prepareStubs(); - await LoginClientCredentials.run([ - '-o', - testData.username, - '--client-secret', - clientSecret, - '-i', - '123456', - '-r', - instanceUrl, - '-a', - 'MyAlias', - '--json', - ]); - expect(authInfoStub.handleAliasAndDefaultSettings.callCount).to.equal(1); - }); - - it('should set target-org to alias when -s and -a are provided', async () => { - await prepareStubs(); - await LoginClientCredentials.run([ - '-o', - testData.username, - '--client-secret', - clientSecret, - '-i', - '123456', - '-r', - instanceUrl, - '-a', - 'MyAlias', - '-s', - '--json', - ]); - expect(authInfoStub.handleAliasAndDefaultSettings.callCount).to.equal(1); - expect(authInfoStub.handleAliasAndDefaultSettings.args[0]).to.deep.equal([ - { - alias: 'MyAlias', - setDefaultDevHub: undefined, - setDefault: true, - }, - ]); - }); - - it('should set target-org to username when -s is provided', async () => { - await prepareStubs(); - await LoginClientCredentials.run([ - '-o', - testData.username, - '--client-secret', - clientSecret, - '-i', - '123456', - '-r', - instanceUrl, - '-s', - '--json', - ]); - expect(authInfoStub.handleAliasAndDefaultSettings.callCount).to.equal(1); - expect(authInfoStub.handleAliasAndDefaultSettings.args[0]).to.deep.equal([ - { - alias: undefined, - setDefaultDevHub: undefined, - setDefault: true, - }, - ]); - }); - - it('should set target-dev-hub to alias when -d and -a are provided', async () => { - await prepareStubs(); - await LoginClientCredentials.run([ - '-o', - testData.username, - '--client-secret', - clientSecret, - '-i', - '123456', - '-r', - instanceUrl, - '-a', - 'MyAlias', - '-d', - '--json', - ]); - expect(authInfoStub.handleAliasAndDefaultSettings.callCount).to.equal(1); - expect(authInfoStub.handleAliasAndDefaultSettings.args[0]).to.deep.equal([ - { - alias: 'MyAlias', - setDefaultDevHub: true, - setDefault: undefined, - }, - ]); - }); - - it('should set target-dev-hub to username when -d is provided', async () => { - await prepareStubs(); - await LoginClientCredentials.run([ - '-o', - testData.username, - '--client-secret', - clientSecret, - '-i', - '123456', - '-r', - instanceUrl, - '-d', - '--json', - ]); - expect(authInfoStub.handleAliasAndDefaultSettings.callCount).to.equal(1); - expect(authInfoStub.handleAliasAndDefaultSettings.args[0]).to.deep.equal([ - { - alias: undefined, - setDefaultDevHub: true, - setDefault: undefined, - }, - ]); - }); - - it('should set target-org and target-dev-hub to username when -d and -s are provided', async () => { - await prepareStubs(); - await LoginClientCredentials.run([ - '-o', - testData.username, - '--client-secret', - clientSecret, - '-i', - '123456', - '-r', - instanceUrl, - '-d', - '-s', - '--json', - ]); - expect(authInfoStub.setAlias.callCount).to.equal(0); - expect(authInfoStub.handleAliasAndDefaultSettings.callCount).to.equal(1); - expect(authInfoStub.handleAliasAndDefaultSettings.args[0]).to.deep.equal([ - { - alias: undefined, - setDefaultDevHub: true, - setDefault: true, - }, - ]); - }); - - it('should set target-org and target-dev-hub to alias when -a, -d, and -s are provided', async () => { - await prepareStubs(); - await LoginClientCredentials.run([ - '-o', - testData.username, - '--client-secret', - clientSecret, - '-i', - '123456', - '-r', - instanceUrl, - '-d', - '-s', - '-a', - 'MyAlias', - '--json', - ]); - expect(authInfoStub.handleAliasAndDefaultSettings.callCount).to.equal(1); - expect(authInfoStub.handleAliasAndDefaultSettings.args[0]).to.deep.equal([ - { - alias: 'MyAlias', - setDefaultDevHub: true, - setDefault: true, - }, - ]); - }); - - it('should throw an error when client id is invalid', async () => { - await prepareStubs({ tokenRequestFails: true }); + it('should wrap token request errors', async () => { + prepareStubs({ tokenRequestFails: true }); try { - await LoginClientCredentials.run([ - '-o', - testData.username, - '--client-secret', - clientSecret, - '-i', - '123456INVALID', - '-r', - instanceUrl, - '--json', - ]); + await LoginClientCredentials.run(['-i', '123456INVALID', '-r', instanceUrl, '--json']); expect.fail('Should have thrown an error'); } catch (e) { expect(e).to.be.instanceOf(Error); @@ -323,71 +142,6 @@ describe('org:login:client-credentials', () => { expect(authError.message).to.include('client identifier invalid'); expect(authError.cause, 'ClientCredentialsGrantError should include original error as the cause').to.be.ok; } - }); - - it('should throw an error when AuthInfo.create fails', async () => { - await prepareStubs({ authInfoCreateFails: true }); - try { - await LoginClientCredentials.run([ - '-o', - testData.username, - '--client-secret', - clientSecret, - '-i', - '123456', - '-r', - instanceUrl, - '--json', - ]); - expect.fail('Should have thrown an error'); - } catch (e) { - expect(e).to.be.instanceOf(Error); - const authError = e as SfError; - expect(authError.message).to.include('We encountered a client credentials error'); - expect(authError.message).to.include('invalid client id'); - expect(authError.cause, 'ClientCredentialsGrantError should include original error as the cause').to.be.ok; - } - }); - - it('should not throw an error when the authorization already exists', async () => { - await prepareStubs({ existingAuth: true }); - try { - await LoginClientCredentials.run([ - '-o', - testData.username, - '--client-secret', - clientSecret, - '-i', - '123456', - '-r', - instanceUrl, - '--json', - ]); - } catch (e) { - expect.fail('Should not have thrown an error'); - } - }); - - it('should throw an error when the instance URL is not HTTPS', async () => { - await prepareStubs(); - try { - await LoginClientCredentials.run([ - '-o', - testData.username, - '--client-secret', - clientSecret, - '-i', - '123456', - '-r', - 'http://MyDomainName.my.salesforce.com', - '--json', - ]); - expect.fail('Should have thrown an error'); - } catch (e) { - expect(e).to.be.instanceOf(Error); - const authError = e as SfError; - expect(authError.message).to.include('We encountered a client credentials error'); - expect(authError.message).to.include('requires an HTTPS instance URL'); - } + expect(accessTokenRunStub.callCount).to.equal(0); }); }); diff --git a/test/hooks/diagnostics.test.ts b/test/hooks/diagnostics.test.ts index adbe90e4..e59bb023 100644 --- a/test/hooks/diagnostics.test.ts +++ b/test/hooks/diagnostics.test.ts @@ -75,6 +75,7 @@ describe('Doctor diagnostics', () => { it('should fail when CLI does not support v2 crypto', async () => { sandbox.stub(util, 'promisify').returns(() => ({ stdout: JSON.stringify([{ version: '6.5.0' }]) })); + sandbox.stub(fs, 'readFileSync').throws(new Error('key file is unavailable')); process.env.SF_USE_GENERIC_UNIX_KEYCHAIN = 'false'; await hook({ doctor: doctorMock }); @@ -101,6 +102,7 @@ describe('Doctor diagnostics', () => { it('should pass when CLI supports v2 crypto', async () => { sandbox.stub(util, 'promisify').returns(() => ({ stdout: JSON.stringify([{ version: '6.7.0' }]) })); + sandbox.stub(fs, 'readFileSync').throws(new Error('key file is unavailable')); process.env.SF_USE_GENERIC_UNIX_KEYCHAIN = 'false'; await hook({ doctor: doctorMock }); From d8c3c323265b578304f89ffa8e58c5d7f395e814 Mon Sep 17 00:00:00 2001 From: James Simone <16430727+jamessimone@users.noreply.github.com> Date: Thu, 13 Aug 2026 18:36:32 -0400 Subject: [PATCH 3/4] feat: further cleanup by persisting any possibly prior-stored access tokens --- src/commands/org/login/client-credentials.ts | 46 +++++++++++++------ .../login/login.client-credentials.test.ts | 14 ++++++ 2 files changed, 47 insertions(+), 13 deletions(-) diff --git a/src/commands/org/login/client-credentials.ts b/src/commands/org/login/client-credentials.ts index ea76c58d..d5162780 100644 --- a/src/commands/org/login/client-credentials.ts +++ b/src/commands/org/login/client-credentials.ts @@ -30,6 +30,8 @@ type ClientCredentialsTokenResponse = { instanceUrl: string; }; +const PREFERRED_ENV_ACCESS_TOKEN_NAME = 'SF_ACCESS_TOKEN'; + export default class LoginClientCredentials extends SfCommand { public static readonly summary = messages.getMessage('summary'); public static readonly description = messages.getMessage('description'); @@ -67,6 +69,7 @@ export default class LoginClientCredentials extends SfCommand { loglevel, }; private flags!: Interfaces.InferredFlags; + private priorEnvAccessToken?: string; private static getClientSecret(): string { const clientSecret = env.getString('SF_CLIENT_SECRET'); @@ -81,6 +84,10 @@ export default class LoginClientCredentials extends SfCommand { this.flags = flags; try { + // cache any previously-stored access token; we will temporarily override that value (if any) + // when authenticating here, and then restore the prior value (or wipe out the variable entirely) + // in the finally block + this.priorEnvAccessToken = env.getString(PREFERRED_ENV_ACCESS_TOKEN_NAME); return await this.performClientCredentialsLogin(); } catch (err) { const msg = err instanceof Error ? `${err.name}::${err.message}` : typeof err === 'string' ? err : 'UNKNOWN'; @@ -90,7 +97,7 @@ export default class LoginClientCredentials extends SfCommand { ...(err instanceof Error ? { cause: err } : {}), }); } finally { - env.unset('SF_ACCESS_TOKEN'); + env.setString(PREFERRED_ENV_ACCESS_TOKEN_NAME, this.priorEnvAccessToken); } } @@ -98,19 +105,9 @@ export default class LoginClientCredentials extends SfCommand { const loginUrl = await common.resolveLoginUrl(this.flags['instance-url']?.href); const tokenResponse = await this.requestClientCredentialsToken(loginUrl); - env.setString('SF_ACCESS_TOKEN', tokenResponse.accessToken); + env.setString(PREFERRED_ENV_ACCESS_TOKEN_NAME, tokenResponse.accessToken); - // the AccessToken command, and other commands in general - // don't play nice with extra args being passed to them, so we strip out - // the one extra flag prior to calling that command - const accessTokenArgs = this.argv.filter( - (arg, index, args) => - !['-i', '--client-id'].includes(arg) && - !arg.startsWith('-i=') && - !arg.startsWith('--client-id=') && - !['-i', '--client-id'].includes(args[index - 1] ?? '') - ); - const response = await new AccessToken(accessTokenArgs, this.config).run(); + const response = await new AccessToken(this.transformClientCredArgsIntoAccessArgs(), this.config).run(); return response; } @@ -150,6 +147,29 @@ export default class LoginClientCredentials extends SfCommand { instanceUrl: payload.instanceUrl, }; } + + private transformClientCredArgsIntoAccessArgs(): string[] { + // the AccessToken command, and other commands in general + // don't play nice with extra args being passed to them, so we strip out + // the one extra flag prior to calling that command after verifying the other flags match + const clientCredentialFlagsWithoutClientId = new Set(Object.keys(AccessToken.flags)); + if ( + Object.keys(LoginClientCredentials.flags) + .filter((flagName) => flagName !== 'client-id') + .find((flagName) => !clientCredentialFlagsWithoutClientId.has(flagName)) + ) { + throw new SfError('Access token login flags and client credential flags have diverged'); + } + + const accessTokenArgs = this.argv.filter( + (arg, index, args) => + !['-i', '--client-id'].includes(arg) && + !arg.startsWith('-i=') && + !arg.startsWith('--client-id=') && + !['-i', '--client-id'].includes(args[index - 1] ?? '') + ); + return accessTokenArgs; + } } type TokenResponseBody = { diff --git a/test/commands/org/login/login.client-credentials.test.ts b/test/commands/org/login/login.client-credentials.test.ts index f006f7b7..c2f83d7c 100644 --- a/test/commands/org/login/login.client-credentials.test.ts +++ b/test/commands/org/login/login.client-credentials.test.ts @@ -84,6 +84,20 @@ describe('org:login:client-credentials', () => { expect(process.env.SF_ACCESS_TOKEN).to.be.undefined; }); + it('should temporarily replace and then restore an existing access token', async () => { + const priorAccessToken = 'prior-access-token'; + process.env.SF_ACCESS_TOKEN = priorAccessToken; + accessTokenRunStub.callsFake(() => { + expect(process.env.SF_ACCESS_TOKEN).to.equal('00Dxx0000000000!token'); + return Promise.resolve(authFields); + }); + prepareStubs(); + + await LoginClientCredentials.run(['-i', '123456', '-r', instanceUrl, '--json']); + + expect(process.env.SF_ACCESS_TOKEN).to.equal(priorAccessToken); + }); + it('should omit the short client-id flag before delegating to the access-token command', async () => { prepareStubs(); await LoginClientCredentials.run(['-i', '123456', '-r', instanceUrl, '--set-default', '--json']); From 95123a19b4657a7c4eb15b9c638b761946494621 Mon Sep 17 00:00:00 2001 From: James Simone <16430727+jamessimone@users.noreply.github.com> Date: Fri, 14 Aug 2026 08:31:04 -0400 Subject: [PATCH 4/4] feat: makes instance-url parameter required since client credentials flow requires it --- README.md | 24 ++++---------------- messages/client.credentials.md | 16 ++----------- src/commands/org/login/client-credentials.ts | 2 +- 3 files changed, 8 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index c740805d..a0374488 100644 --- a/README.md +++ b/README.md @@ -211,39 +211,25 @@ DESCRIPTION information. We recommend that you set an alias when you log into an org. Aliases make it easy to later reference this org when - running commands that require it. If you don’t set an alias, you use the username that you specified when you logged - in to the org. If you run multiple commands that reference the same org, consider setting the org as your default. Use + running commands that require it. If you don’t set an alias, the username for the user associated with the client credentials flow is used. + If you run multiple commands that reference the same org, consider setting the org as your default. Use --set-default for your default scratch org or sandbox, or --set-default-dev-hub for your default Dev Hub. EXAMPLES - Log into an org with username jdoe@example.org. Set the connected app consumer secret as an environment variable, then - run the command with its consumer key (client id) 04580y4051234051. - - $ SF_CLIENT_SECRET=very-secret sf org login client-credentials --username jdoe@example.org --client-id \ - 04580y4051234051 --instance-url https://MyDomainName.my.salesforce.com - Set the org as the default and give it an alias: - $ SF_CLIENT_SECRET=very-secret sf org login client-credentials --username jdoe@example.org --client-id \ + $ SF_CLIENT_SECRET=very-secret sf org login client-credentials --client-id \ 04580y4051234051 --instance-url https://MyDomainName.my.salesforce.com --alias ci-org --set-default Set the org as the default Dev Hub and give it an alias: - $ SF_CLIENT_SECRET=very-secret sf org login client-credentials --username jdoe@example.org --client-id \ + $ SF_CLIENT_SECRET=very-secret sf org login client-credentials --client-id \ 04580y4051234051 --instance-url https://MyDomainName.my.salesforce.com --alias ci-dev-hub --set-default-dev-hub - Log in to a sandbox using URL https://MyDomainName--SandboxName.sandbox.my.salesforce.com: - - $ SF_CLIENT_SECRET=very-secret sf org login client-credentials --username jdoe@example.org --client-id \ - 04580y4051234051 --alias ci-org --set-default --instance-url \ - https://MyDomainName--SandboxName.sandbox.my.salesforce.com - FLAG DESCRIPTIONS -r, --instance-url= URL of the instance that the org lives on. - If you specify an --instance-url value, this value overrides the sfdcLoginUrl value in your sfdx-project.json file. - - To specify a My Domain URL, use the format "https://.my.salesforce.com". + To specify a production environment My Domain URL, use the format "https://.my.salesforce.com". To specify a sandbox, set --instance-url to "https://--.sandbox.my.salesforce.com". ``` diff --git a/messages/client.credentials.md b/messages/client.credentials.md index be7048f9..a9d93b47 100644 --- a/messages/client.credentials.md +++ b/messages/client.credentials.md @@ -20,25 +20,13 @@ We recommend that you set an alias when you log into an org. Aliases make it eas # examples -- Log into an org with username jdoe@example.org. Set the connected app consumer secret as an environment variable, then run the command with its fake consumer key (client id) 04580y4051234051. - - SF_CLIENT_SECRET=very-secret <%= config.bin %> <%= command.id %> --username jdoe@example.org --client-id 04580y4051234051 --instance-url https://MyDomainName.my.salesforce.com - - Set the org as the default and give it an alias: - SF_CLIENT_SECRET=very-secret <%= config.bin %> <%= command.id %> --username jdoe@example.org --client-id 04580y4051234051 --instance-url https://MyDomainName.my.salesforce.com --alias ci-org --set-default + SF_CLIENT_SECRET=very-secret <%= config.bin %> <%= command.id %> --client-id 04580y4051234051 --instance-url https://MyDomainName.my.salesforce.com --alias ci-org --set-default - Set the org as the default Dev Hub and give it an alias: - SF_CLIENT_SECRET=very-secret <%= config.bin %> <%= command.id %> --username jdoe@example.org --client-id 04580y4051234051 --instance-url https://MyDomainName.my.salesforce.com --alias ci-dev-hub --set-default-dev-hub - -- Log in to a sandbox using URL https://MyDomainName--SandboxName.sandbox.my.salesforce.com: - - SF_CLIENT_SECRET=very-secret <%= config.bin %> <%= command.id %> --username jdoe@example.org --client-id 04580y4051234051 --alias ci-org --set-default --instance-url https://MyDomainName--SandboxName.sandbox.my.salesforce.com - -# flags.username.summary - -Username of the user logging in. + SF_CLIENT_SECRET=very-secret <%= config.bin %> <%= command.id %> --client-id 04580y4051234051 --instance-url https://MyDomainName.my.salesforce.com --alias ci-dev-hub --set-default-dev-hub # ClientCredentialsGrantError diff --git a/src/commands/org/login/client-credentials.ts b/src/commands/org/login/client-credentials.ts index d5162780..1c700bcd 100644 --- a/src/commands/org/login/client-credentials.ts +++ b/src/commands/org/login/client-credentials.ts @@ -47,6 +47,7 @@ export default class LoginClientCredentials extends SfCommand { char: 'r', summary: commonMessages.getMessage('flags.instance-url.summary'), description: commonMessages.getMessage('flags.instance-url.description'), + required: true, }), 'set-default-dev-hub': Flags.boolean({ char: 'd', @@ -63,7 +64,6 @@ export default class LoginClientCredentials extends SfCommand { 'no-prompt': Flags.boolean({ char: 'p', summary: commonMessages.getMessage('flags.no-prompt.summary'), - required: false, hidden: true, }), loglevel,