├── .eslintignore ├── .prettierrc.json ├── bin ├── run.cmd ├── dev.cmd ├── run.js └── dev.js ├── .nycrc ├── commitlint.config.cjs ├── .husky ├── commit-msg ├── pre-push └── pre-commit ├── .lintstagedrc.cjs ├── .github ├── PULL_REQUEST_TEMPLATE.md ├── workflows │ ├── automerge.yml │ ├── devScripts.yml │ ├── validate-pr.yml │ ├── test.yml │ ├── create-github-release.yml │ ├── onRelease.yml │ ├── notify-slack-on-pr-open.yml │ └── failureNotifications.yml └── dependabot.yml ├── .images └── vscodeScreenshot.png ├── test ├── tsconfig.json ├── .eslintrc.cjs ├── testHelper.ts ├── commands │ ├── org │ │ ├── list │ │ │ ├── auth.nut.ts │ │ │ └── auth.test.ts │ │ ├── login │ │ │ ├── login.sfdx-url.nut.ts │ │ │ ├── access-token.nut.ts │ │ │ ├── login.jwt.nut.ts │ │ │ ├── login.device.test.ts │ │ │ ├── access-token.test.ts │ │ │ ├── login.jwt.test.ts │ │ │ └── login.sfdx-url.test.ts │ │ ├── logout.nut.ts │ │ └── logout.test.ts │ └── 0-scratch-identify.nut.ts ├── common.test.ts └── hooks │ └── diagnostics.test.ts ├── .editorconfig ├── .git2gus └── config.json ├── .mocharc.json ├── CODEOWNERS ├── tsconfig.json ├── schemas ├── org-logout.json ├── org-list-auth.json ├── org-login-jwt.json ├── org-login-web.json ├── org-login-sfdx__url.json ├── org-login-access__token.json └── org-login-device.json ├── SECURITY.md ├── .eslintrc.cjs ├── src ├── index.ts ├── common.ts ├── commands │ └── org │ │ ├── list │ │ └── auth.ts │ │ ├── login │ │ ├── device.ts │ │ ├── sfdx-url.ts │ │ ├── access-token.ts │ │ ├── jwt.ts │ │ └── web.ts │ │ └── logout.ts └── hooks │ └── diagnostics.ts ├── messages ├── list.md ├── diagnostics.md ├── accesstoken.store.md ├── device.login.md ├── messages.md ├── sfdxurl.store.md ├── jwt.grant.md ├── logout.md └── web.login.md ├── .gitignore ├── .vscode └── launch.json ├── command-snapshot.json ├── CODE_OF_CONDUCT.md └── package.json /.eslintignore: -------------------------------------------------------------------------------- 1 | *.cjs/ 2 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | "@salesforce/prettier-config" 2 | -------------------------------------------------------------------------------- /bin/run.cmd: -------------------------------------------------------------------------------- 1 | @echo off 2 | 3 | node "%~dp0\run" %* -------------------------------------------------------------------------------- /.nycrc: -------------------------------------------------------------------------------- 1 | { 2 | "nyc": { 3 | "extends": "@salesforce/dev-config/nyc" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /commitlint.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { extends: ['@commitlint/config-conventional'] }; 2 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn commitlint --edit 5 | -------------------------------------------------------------------------------- /.husky/pre-push: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn build && yarn test 5 | -------------------------------------------------------------------------------- /.lintstagedrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | '**/*.{js,json,md}?(x)': () => 'npm run reformat' 3 | }; 4 | -------------------------------------------------------------------------------- /bin/dev.cmd: -------------------------------------------------------------------------------- 1 | @echo off 2 | 3 | node --loader ts-node/esm --no-warnings=ExperimentalWarning "%~dp0\dev" %* 4 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### What does this PR do? 2 | 3 | ### What issues does this PR fix or reference? 4 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn lint && yarn pretty-quick --staged 5 | -------------------------------------------------------------------------------- /.images/vscodeScreenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/salesforcecli/plugin-auth/HEAD/.images/vscodeScreenshot.png -------------------------------------------------------------------------------- /test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@salesforce/dev-config/tsconfig-test-strict-esm", 3 | "include": ["./**/*.ts"], 4 | "compilerOptions": { 5 | "skipLibCheck": true, 6 | "strictNullChecks": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [*.md] 11 | trim_trailing_whitespace = false 12 | -------------------------------------------------------------------------------- /bin/run.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | // eslint-disable-next-line node/shebang 4 | async function main() { 5 | const {execute} = await import('@oclif/core') 6 | await execute({dir: import.meta.url}) 7 | } 8 | 9 | await main() 10 | -------------------------------------------------------------------------------- /.git2gus/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "productTag": "a1aB00000004Bx8IAE", 3 | "defaultBuild": "offcore.tooling.56", 4 | "issueTypeLabels": { 5 | "feature": "USER STORY", 6 | "regression": "BUG P1", 7 | "bug": "BUG P3" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /.mocharc.json: -------------------------------------------------------------------------------- 1 | { 2 | "require": ["ts-node/register"], 3 | "watch-extensions": "ts", 4 | "watch-files": ["src", "test"], 5 | "recursive": true, 6 | "reporter": "spec", 7 | "timeout": 10000, 8 | "node-option": ["loader=ts-node/esm"] 9 | } 10 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Techical writers will be added as reviewers on markdown changes. 2 | *.md @salesforcecli/cli-docs 3 | 4 | # Comment line immediately above ownership line is reserved for related other information. Please be careful while editing. 5 | #ECCN:Open Source 6 | -------------------------------------------------------------------------------- /.github/workflows/automerge.yml: -------------------------------------------------------------------------------- 1 | name: automerge 2 | on: 3 | workflow_dispatch: 4 | schedule: 5 | - cron: '42 2,5,8,11 * * *' 6 | 7 | jobs: 8 | automerge: 9 | uses: salesforcecli/github-workflows/.github/workflows/automerge.yml@main 10 | secrets: inherit 11 | -------------------------------------------------------------------------------- /.github/workflows/devScripts.yml: -------------------------------------------------------------------------------- 1 | name: devScripts 2 | on: 3 | workflow_dispatch: 4 | schedule: 5 | - cron: '50 6 * * 0' 6 | 7 | jobs: 8 | update: 9 | uses: salesforcecli/github-workflows/.github/workflows/devScriptsUpdate.yml@main 10 | secrets: inherit 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@salesforce/dev-config/tsconfig-strict-esm", 3 | "compilerOptions": { 4 | "outDir": "lib", 5 | "rootDir": "src", 6 | "strictNullChecks": true, 7 | "skipLibCheck": true 8 | }, 9 | "include": ["./src/**/*.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /bin/dev.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S node --loader ts-node/esm --no-warnings=ExperimentalWarning 2 | // eslint-disable-next-line node/shebang 3 | async function main() { 4 | const {execute} = await import('@oclif/core') 5 | await execute({development: true, dir: import.meta.url}) 6 | } 7 | 8 | await main() 9 | -------------------------------------------------------------------------------- /schemas/org-logout.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema#", 3 | "$ref": "#/definitions/AuthLogoutResults", 4 | "definitions": { 5 | "AuthLogoutResults": { 6 | "type": "array", 7 | "items": { 8 | "type": "string" 9 | } 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /.github/workflows/validate-pr.yml: -------------------------------------------------------------------------------- 1 | name: pr-validation 2 | 3 | on: 4 | pull_request: 5 | types: [opened, reopened, edited] 6 | # only applies to PRs that want to merge to main 7 | branches: [main] 8 | 9 | jobs: 10 | pr-validation: 11 | uses: salesforcecli/github-workflows/.github/workflows/validatePR.yml@main 12 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | ## Security 2 | 3 | Please report any security issue to [security@salesforce.com](mailto:security@salesforce.com) 4 | as soon as it is discovered. This library limits its runtime dependencies in 5 | order to reduce the total cost of ownership as much as can be, but all consumers 6 | should remain vigilant and have their security stakeholders review all third-party 7 | products (3PP) like this one and their dependencies. 8 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2020, salesforce.com, inc. 3 | * All rights reserved. 4 | * Licensed under the BSD 3-Clause license. 5 | * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause 6 | */ 7 | module.exports = { 8 | extends: ['eslint-config-salesforce-typescript', 'eslint-config-salesforce-license', 'plugin:sf-plugin/recommended'], 9 | rules: { 10 | 'jsdoc/newline-after-description': 'off', 11 | }, 12 | }; 13 | -------------------------------------------------------------------------------- /test/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2020, salesforce.com, inc. 3 | * All rights reserved. 4 | * Licensed under the BSD 3-Clause license. 5 | * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause 6 | */ 7 | 8 | module.exports = { 9 | extends: '../.eslintrc.cjs', 10 | // Allow describe and it 11 | env: { mocha: true }, 12 | rules: { 13 | // Allow assert style expressions. i.e. expect(true).to.be.true 14 | 'no-unused-expressions': 'off', 15 | 16 | // Return types are defined by the source code. Allows for quick overwrites. 17 | // '@typescript-eslint/explicit-function-return-type': 'off', 18 | }, 19 | }; 20 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: 'npm' 4 | directory: '/' 5 | schedule: 6 | interval: 'weekly' 7 | day: 'saturday' 8 | versioning-strategy: 'increase' 9 | labels: 10 | - 'dependencies' 11 | open-pull-requests-limit: 5 12 | pull-request-branch-name: 13 | separator: '-' 14 | commit-message: 15 | # cause a release for non-dev-deps 16 | prefix: fix(deps) 17 | # no release for dev-deps 18 | prefix-development: chore(dev-deps) 19 | ignore: 20 | - dependency-name: '@salesforce/dev-scripts' 21 | - dependency-name: '*' 22 | update-types: ['version-update:semver-major'] 23 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2025, Salesforce, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | export default {}; 18 | -------------------------------------------------------------------------------- /messages/list.md: -------------------------------------------------------------------------------- 1 | # summary 2 | 3 | List authorization information about the orgs you created or logged into. 4 | 5 | # description 6 | 7 | This command uses local authorization information that Salesforce CLI caches when you create a scratch org or log into an org. The command doesn't actually connect to the orgs to verify that they're still active. As a result, this command executes very quickly. If you want to view live information about your authorized orgs, such as their connection status, use the "org list" command. 8 | 9 | # examples 10 | 11 | - List local authorization information about your orgs: 12 | 13 | <%= config.bin %> <%= command.id %> 14 | 15 | # noResultsFound 16 | 17 | No results found. 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # -- CLEAN 2 | tmp/ 3 | # use yarn by default, so ignore npm 4 | package-lock.json 5 | 6 | # never checkin npm config 7 | .npmrc 8 | 9 | # debug logs 10 | npm-error.log 11 | yarn-error.log 12 | 13 | 14 | # compile source 15 | lib 16 | 17 | # test artifacts 18 | *xunit.xml 19 | *checkstyle.xml 20 | *unitcoverage 21 | .nyc_output 22 | coverage 23 | test_session* 24 | 25 | # generated docs 26 | docs 27 | 28 | # ignore sfdx-trust files 29 | *.tgz 30 | *.sig 31 | package.json.bak. 32 | 33 | 34 | npm-shrinkwrap.json 35 | oclif.manifest.json 36 | oclif.lock 37 | 38 | # -- CLEAN ALL 39 | *.tsbuildinfo 40 | .eslintcache 41 | .wireit 42 | node_modules 43 | 44 | # -- 45 | # put files here you don't want cleaned with sf-clean 46 | 47 | # os specific files 48 | .DS_Store 49 | .idea 50 | -------------------------------------------------------------------------------- /messages/diagnostics.md: -------------------------------------------------------------------------------- 1 | # sfCryptoV2Support 2 | 3 | Your current installation of Salesforce CLI, including all the plugins you've linked and installed, doesn't yet support v2 crypto. All plugins and libraries must use at least version 6.7.0 of `@salesforce/core` to support v2 crypto. You're generally still able to successfully authenticate with your current CLI installation, but not if you generate a v2 crypto key. 4 | 5 | # sfCryptoV2Unstable 6 | 7 | Your current installation of Salesforce CLI, including all the plugins you've linked and installed, is using v2 crypto without proper library support, which can cause authentication failures. We recommend that you switch back to v1 crypto. 8 | 9 | # sfCryptoV2Desired 10 | 11 | SF_CRYPTO_V2=true is set in your environment, but you're actually using v1 crypto. Your Salesforce CLI installation supports using v2 crypto. If you desire this behavior, follow the instructions in the documentation (provide link). 12 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | on: 3 | push: 4 | branches-ignore: [main] 5 | workflow_dispatch: 6 | 7 | jobs: 8 | yarn-lockfile-check: 9 | uses: salesforcecli/github-workflows/.github/workflows/lockFileCheck.yml@main 10 | # Since the Windows unit tests take much longer, we run the linux unit tests first and then run the windows unit tests in parallel with NUTs 11 | linux-unit-tests: 12 | needs: yarn-lockfile-check 13 | uses: salesforcecli/github-workflows/.github/workflows/unitTestsLinux.yml@main 14 | windows-unit-tests: 15 | needs: linux-unit-tests 16 | uses: salesforcecli/github-workflows/.github/workflows/unitTestsWindows.yml@main 17 | nuts: 18 | needs: linux-unit-tests 19 | uses: salesforcecli/github-workflows/.github/workflows/nut.yml@main 20 | secrets: inherit 21 | strategy: 22 | matrix: 23 | os: [ubuntu-latest, windows-latest] 24 | fail-fast: false 25 | with: 26 | os: ${{ matrix.os }} 27 | -------------------------------------------------------------------------------- /.github/workflows/create-github-release.yml: -------------------------------------------------------------------------------- 1 | name: create-github-release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - prerelease/** 8 | tags-ignore: 9 | - '*' 10 | workflow_dispatch: 11 | inputs: 12 | prerelease: 13 | type: string 14 | description: 'Name to use for the prerelease: beta, dev, etc. NOTE: If this is already set in the package.json, it does not need to be passed in here.' 15 | 16 | jobs: 17 | release: 18 | uses: salesforcecli/github-workflows/.github/workflows/create-github-release.yml@main 19 | secrets: inherit 20 | with: 21 | prerelease: ${{ inputs.prerelease }} 22 | # If this is a push event, we want to skip the release if there are no semantic commits 23 | # However, if this is a manual release (workflow_dispatch), then we want to disable skip-on-empty 24 | # This helps recover from forgetting to add semantic commits ('fix:', 'feat:', etc.) 25 | skip-on-empty: ${{ github.event_name == 'push' }} 26 | -------------------------------------------------------------------------------- /.github/workflows/onRelease.yml: -------------------------------------------------------------------------------- 1 | name: publish 2 | 3 | on: 4 | release: 5 | types: [published] 6 | # support manual release in case something goes wrong and needs to be repeated or tested 7 | workflow_dispatch: 8 | inputs: 9 | tag: 10 | description: tag that needs to publish 11 | type: string 12 | required: true 13 | jobs: 14 | # parses the package.json version and detects prerelease tag (ex: beta from 4.4.4-beta.0) 15 | getDistTag: 16 | outputs: 17 | tag: ${{ steps.distTag.outputs.tag }} 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@v4 21 | with: 22 | ref: ${{ github.event.release.tag_name || inputs.tag }} 23 | - uses: salesforcecli/github-workflows/.github/actions/getPreReleaseTag@main 24 | id: distTag 25 | npm: 26 | uses: salesforcecli/github-workflows/.github/workflows/npmPublish.yml@main 27 | needs: [getDistTag] 28 | with: 29 | ctc: true 30 | sign: true 31 | tag: ${{ needs.getDistTag.outputs.tag || 'latest' }} 32 | githubTag: ${{ github.event.release.tag_name || inputs.tag }} 33 | secrets: inherit 34 | -------------------------------------------------------------------------------- /.github/workflows/notify-slack-on-pr-open.yml: -------------------------------------------------------------------------------- 1 | name: Pull Request Slack Notification 2 | 3 | on: 4 | pull_request: 5 | types: [opened, reopened] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Notify Slack on PR open 12 | env: 13 | WEBHOOK_URL: ${{ secrets.CLI_TEAM_SLACK_WEBHOOK_URL }} 14 | PULL_REQUEST_AUTHOR_ICON_URL: ${{ github.event.pull_request.user.avatar_url }} 15 | PULL_REQUEST_AUTHOR_NAME: ${{ github.event.pull_request.user.login }} 16 | PULL_REQUEST_AUTHOR_PROFILE_URL: ${{ github.event.pull_request.user.html_url }} 17 | PULL_REQUEST_BASE_BRANCH_NAME: ${{ github.event.pull_request.base.ref }} 18 | PULL_REQUEST_COMPARE_BRANCH_NAME: ${{ github.event.pull_request.head.ref }} 19 | PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }} 20 | PULL_REQUEST_REPO: ${{ github.event.pull_request.head.repo.name }} 21 | PULL_REQUEST_TITLE: ${{ github.event.pull_request.title }} 22 | PULL_REQUEST_URL: ${{ github.event.pull_request.html_url }} 23 | uses: salesforcecli/github-workflows/.github/actions/prNotification@main 24 | -------------------------------------------------------------------------------- /messages/accesstoken.store.md: -------------------------------------------------------------------------------- 1 | # summary 2 | 3 | Authorize an org using an existing Salesforce access token. 4 | 5 | # description 6 | 7 | By default, the command runs interactively and asks you for the access token. If you previously authorized the org, the command prompts whether you want to overwrite the local file. Specify --no-prompt to not be prompted. 8 | 9 | To use the command in a CI/CD script, set the SF_ACCESS_TOKEN environment variable to the access token. Then run the command with the --no-prompt parameter. 10 | 11 | # examples 12 | 13 | - Authorize an org on https://mycompany.my.salesforce.com; the command prompts you for the access token: 14 | 15 | <%= config.bin %> <%= command.id %> --instance-url https://mycompany.my.salesforce.com 16 | 17 | - Authorize the org without being prompted; you must have previously set the SF_ACCESS_TOKEN environment variable to the access token: 18 | 19 | <%= config.bin %> <%= command.id %> --instance-url https://dev-hub.my.salesforce.com --no-prompt 20 | 21 | # invalidAccessTokenFormat 22 | 23 | The access token isn't in the correct format. 24 | It should follow this pattern: %s. 25 | 26 | # overwriteAccessTokenAuthUserFile 27 | 28 | A file already exists for user "%s", which is associated with the access token you provided. 29 | Are you sure you want to overwrite the existing file? 30 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "attach", 10 | "name": "Attach", 11 | "port": 9229, 12 | "skipFiles": ["/**"] 13 | }, 14 | { 15 | "name": "Run All Tests", 16 | "type": "node", 17 | "request": "launch", 18 | "protocol": "inspector", 19 | "program": "${workspaceFolder}/node_modules/mocha/bin/_mocha", 20 | "args": ["--inspect", "--no-timeouts", "--colors", "test/**/*.test.ts"], 21 | "env": { 22 | "NODE_ENV": "development", 23 | "SFDX_ENV": "development" 24 | }, 25 | "sourceMaps": true, 26 | "smartStep": true, 27 | "internalConsoleOptions": "openOnSessionStart", 28 | "preLaunchTask": "Compile" 29 | }, 30 | { 31 | "type": "node", 32 | "request": "launch", 33 | "name": "Run Current Test", 34 | "program": "${workspaceFolder}/node_modules/mocha/bin/_mocha", 35 | "args": ["--inspect", "--no-timeouts", "--colors", "${file}"], 36 | "env": { 37 | "NODE_ENV": "development", 38 | "SFDX_ENV": "development" 39 | }, 40 | "sourceMaps": true, 41 | "smartStep": true, 42 | "internalConsoleOptions": "openOnSessionStart", 43 | "preLaunchTask": "Compile" 44 | } 45 | ] 46 | } 47 | -------------------------------------------------------------------------------- /messages/device.login.md: -------------------------------------------------------------------------------- 1 | # summary 2 | 3 | Authorize an org using a device code. 4 | 5 | # description 6 | 7 | Use this command to allow a device to connect to an org. 8 | 9 | When you run this command, it first displays an 8-digit device code and the URL for verifying the code on your org. The default instance URL is https://login.salesforce.com, so if the org you're authorizing is on a different instance, use the --instance-url. The command waits while you complete the verification. Open a browser and navigate to the displayed verification URL, enter the code, then click Connect. If you aren't already logged into your org, log in, and then you're prompted to allow the device to connect to the org. After you successfully authorize the org, you can close the browser window. 10 | 11 | # examples 12 | 13 | - Authorize an org using a device code, give the org the alias TestOrg1, and set it as your default Dev Hub org: 14 | 15 | <%= config.bin %> <%= command.id %> --set-default-dev-hub --alias TestOrg1 16 | 17 | - Authorize an org in which you've created a custom connected app with the specified client ID (consumer key): 18 | 19 | <%= config.bin %> <%= command.id %> --client-id 20 | 21 | - Authorize a sandbox org with the specified instance URL: 22 | 23 | <%= config.bin %> <%= command.id %> --instance-url https://MyDomainName--SandboxName.sandbox.my.salesforce.com 24 | 25 | # actionRequired 26 | 27 | Action Required! 28 | 29 | # enterCode 30 | 31 | Enter %s device code in this verification URL: %s 32 | 33 | # success 34 | 35 | Login successful for %s. You can now close the browser. 36 | -------------------------------------------------------------------------------- /messages/messages.md: -------------------------------------------------------------------------------- 1 | # flags.client-id.summary 2 | 3 | OAuth client ID (also called consumer key) of your custom connected app. 4 | 5 | # flags.set-default-dev-hub.summary 6 | 7 | Set the authenticated org as the default Dev Hub. 8 | 9 | # flags.set-default.summary 10 | 11 | Set the authenticated org as the default that all org-related commands run against. 12 | 13 | # flags.alias.summary 14 | 15 | Alias for the org. 16 | 17 | # flags.instance-url.summary 18 | 19 | URL of the instance that the org lives on. 20 | 21 | # flags.instance-url.description 22 | 23 | If you specify an --instance-url value, this value overrides the sfdcLoginUrl value in your sfdx-project.json file. 24 | 25 | To specify a My Domain URL, use the format "https://.my.salesforce.com". 26 | 27 | To specify a sandbox, set --instance-url to "https://--.sandbox.my.salesforce.com". 28 | 29 | # authorizeCommandSuccess 30 | 31 | Successfully authorized %s with org ID %s 32 | 33 | # warnAuth 34 | 35 | Logging in to a business or production org is not recommended on a demo or shared machine. Please run "%s org:logout --target-org --no-prompt" when finished using this org, which is similar to logging out of the org in the browser. 36 | 37 | Do you want to authorize this org for use with the Salesforce CLI? 38 | 39 | # flags.no-prompt.summary 40 | 41 | Don't prompt for confirmation. 42 | 43 | # clientSecretStdin 44 | 45 | OAuth client secret of personal connected app? Press Enter if it's not required. 46 | 47 | # lightningInstanceUrl 48 | 49 | Invalid instance URL. It should NOT be a lightning domain. 50 | 51 | # accessTokenStdin 52 | 53 | Access token of user to use for authentication 54 | 55 | # noPrompt 56 | 57 | do not prompt for confirmation 58 | -------------------------------------------------------------------------------- /.github/workflows/failureNotifications.yml: -------------------------------------------------------------------------------- 1 | name: failureNotifications 2 | on: 3 | workflow_run: 4 | workflows: 5 | - publish 6 | types: 7 | - completed 8 | jobs: 9 | failure-notify: 10 | runs-on: ubuntu-latest 11 | if: ${{ github.event.workflow_run.conclusion == 'failure' }} 12 | steps: 13 | - name: Announce Failure 14 | id: slack 15 | uses: slackapi/slack-github-action@v1.26.0 16 | env: 17 | # for non-CLI-team-owned plugins, you can send this anywhere you like 18 | SLACK_WEBHOOK_URL: ${{ secrets.CLI_ALERTS_SLACK_WEBHOOK }} 19 | SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK 20 | with: 21 | # Payload can be visually tested here: https://app.slack.com/block-kit-builder/T01GST6QY0G#%7B%22blocks%22:%5B%5D%7D 22 | # Only copy over the "blocks" array to the Block Kit Builder 23 | payload: | 24 | { 25 | "text": "Workflow \"${{ github.event.workflow_run.name }}\" failed in ${{ github.event.workflow_run.repository.name }}", 26 | "blocks": [ 27 | { 28 | "type": "header", 29 | "text": { 30 | "type": "plain_text", 31 | "text": ":bh-alert: Workflow \"${{ github.event.workflow_run.name }}\" failed in ${{ github.event.workflow_run.repository.name }} :bh-alert:" 32 | } 33 | }, 34 | { 35 | "type": "section", 36 | "text": { 37 | "type": "mrkdwn", 38 | "text": "*Repo:* ${{ github.event.workflow_run.repository.html_url }}\n*Workflow name:* `${{ github.event.workflow_run.name }}`\n*Job url:* ${{ github.event.workflow_run.html_url }}" 39 | } 40 | } 41 | ] 42 | } 43 | -------------------------------------------------------------------------------- /schemas/org-list-auth.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema#", 3 | "$ref": "#/definitions/AuthListResults", 4 | "definitions": { 5 | "AuthListResults": { 6 | "type": "array", 7 | "items": { 8 | "type": "object", 9 | "additionalProperties": false, 10 | "properties": { 11 | "alias": { 12 | "type": "string" 13 | }, 14 | "orgId": { 15 | "type": "string" 16 | }, 17 | "username": { 18 | "type": "string" 19 | }, 20 | "oauthMethod": { 21 | "type": "string", 22 | "enum": ["jwt", "web", "token", "unknown"] 23 | }, 24 | "configs": { 25 | "anyOf": [ 26 | { 27 | "type": "array", 28 | "items": { 29 | "type": "string" 30 | } 31 | }, 32 | { 33 | "type": "null" 34 | } 35 | ] 36 | }, 37 | "isScratchOrg": { 38 | "type": "boolean" 39 | }, 40 | "isDevHub": { 41 | "type": "boolean" 42 | }, 43 | "isSandbox": { 44 | "type": "boolean" 45 | }, 46 | "instanceUrl": { 47 | "type": "string" 48 | }, 49 | "accessToken": { 50 | "type": "string" 51 | }, 52 | "error": { 53 | "type": "string" 54 | }, 55 | "isExpired": { 56 | "anyOf": [ 57 | { 58 | "type": "boolean" 59 | }, 60 | { 61 | "type": "string", 62 | "const": "unknown" 63 | } 64 | ] 65 | } 66 | }, 67 | "required": ["alias", "isExpired", "oauthMethod", "orgId", "username"] 68 | } 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /test/testHelper.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2025, Salesforce, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { expect } from 'chai'; 18 | import { AuthFields } from '@salesforce/core'; 19 | import { AnyJson } from '@salesforce/ts-types'; 20 | 21 | export type Result = { 22 | status: number; 23 | result: T & AnyJson; 24 | }; 25 | 26 | export type ErrorResult = { 27 | status: number; 28 | name: string; 29 | message: string; 30 | }; 31 | 32 | type UrlKey = Extract; 33 | 34 | export function expectPropsToExist(auth: AuthFields, ...props: Array): void { 35 | props.forEach((prop) => { 36 | expect(auth[prop]).to.exist; 37 | expect(auth[prop]).to.be.a('string'); 38 | }); 39 | } 40 | 41 | export function expectOrgIdToExist(auth: AuthFields): void { 42 | expect(auth.orgId).to.exist; 43 | expect(auth.orgId?.length).to.equal(18); 44 | } 45 | 46 | export function expectUrlToExist(auth: AuthFields, urlKey: UrlKey): void { 47 | expect(auth[urlKey]).to.exist; 48 | expect(/^https*:\/\//.test(auth[urlKey] ?? '')).to.be.true; 49 | } 50 | 51 | export function expectAccessTokenToExist(auth: AuthFields): void { 52 | expect(auth.accessToken).to.exist; 53 | expect(auth.accessToken?.startsWith((auth.orgId ?? '').substr(0, 15))).to.be.true; 54 | } 55 | 56 | export function parseJson(jsonString: string): Result { 57 | return JSON.parse(jsonString) as Result; 58 | } 59 | 60 | export function parseJsonError(jsonString: string): ErrorResult { 61 | return JSON.parse(jsonString) as ErrorResult; 62 | } 63 | -------------------------------------------------------------------------------- /test/commands/org/list/auth.nut.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2025, Salesforce, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | import { execCmd, TestSession } from '@salesforce/cli-plugins-testkit'; 17 | import { expect } from 'chai'; 18 | import { Env } from '@salesforce/kit'; 19 | import { ensureString, getString } from '@salesforce/ts-types'; 20 | import { expectUrlToExist, expectOrgIdToExist, expectAccessTokenToExist } from '../../../testHelper.js'; 21 | import { AuthListResults } from '../../../../src/commands/org/list/auth.js'; 22 | 23 | describe('org:list:auth NUTs', () => { 24 | let testSession: TestSession; 25 | let username: string; 26 | 27 | before('prepare session and ensure environment variables', async () => { 28 | const env = new Env(); 29 | ensureString(env.getString('TESTKIT_JWT_KEY')); 30 | ensureString(env.getString('TESTKIT_JWT_CLIENT_ID')); 31 | ensureString(env.getString('TESTKIT_HUB_INSTANCE')); 32 | username = ensureString(env.getString('TESTKIT_HUB_USERNAME')); 33 | testSession = await TestSession.create({ 34 | devhubAuthStrategy: 'AUTO', 35 | }); 36 | }); 37 | 38 | after(async () => { 39 | await testSession?.clean(); 40 | }); 41 | 42 | it('should list auth files (json)', () => { 43 | const json = execCmd('org:list:auth --json', { ensureExitCode: 0 }).jsonOutput 44 | ?.result as AuthListResults; 45 | const auth = json[0]; 46 | expectAccessTokenToExist(auth); 47 | expectOrgIdToExist(auth); 48 | expectUrlToExist(auth, 'instanceUrl'); 49 | expect(auth.username).to.equal(username); 50 | }); 51 | 52 | it('should list auth files (human readable)', () => { 53 | const result = execCmd('org:list:auth', { ensureExitCode: 0 }); 54 | const output = getString(result, 'shellOutput.stdout'); 55 | expect(output).to.include(username); 56 | expect(output).to.include('jwt'); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /test/commands/org/list/auth.test.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2025, Salesforce, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { MockTestOrgData, TestContext } from '@salesforce/core/testSetup'; 18 | import { expect } from 'chai'; 19 | import { AuthInfo } from '@salesforce/core'; 20 | import { stubUx } from '@salesforce/sf-plugins-core'; 21 | import ListAuth from '../../../../src/commands/org/list/auth.js'; 22 | 23 | describe('org:list:auth', () => { 24 | const $$ = new TestContext(); 25 | const testData = new MockTestOrgData(); 26 | testData.aliases = ['TestAlias']; 27 | 28 | async function prepareStubs(forceFailure = false): Promise { 29 | await $$.stubAuths(testData); 30 | $$.stubAliases({ TestAlias: testData.username }); 31 | if (forceFailure) { 32 | $$.SANDBOX.stub(AuthInfo, 'create').throws(new Error('decrypt error')); 33 | } 34 | stubUx($$.SANDBOX); 35 | } 36 | 37 | it('should show auth files', async () => { 38 | await prepareStubs(); 39 | const [auths] = await ListAuth.run(['--json']); 40 | expect(auths.alias).to.deep.equal(testData.aliases?.join(',') ?? ''); 41 | expect(auths.username).to.equal(testData.username); 42 | expect(auths.instanceUrl).to.equal(testData.instanceUrl); 43 | expect(auths.orgId).to.equal(testData.orgId); 44 | expect(auths.oauthMethod).to.equal('web'); 45 | }); 46 | 47 | it('should show files with auth errors', async () => { 48 | await prepareStubs(true); 49 | const [auths] = await ListAuth.run(['--json']); 50 | expect(auths.alias).to.deep.equal(testData.aliases?.join(',') ?? ''); 51 | expect(auths.username).to.equal(testData.username); 52 | expect(auths.instanceUrl).to.equal(testData.instanceUrl); 53 | expect(auths.orgId).to.equal(testData.orgId); 54 | expect(auths.oauthMethod).to.equal('unknown'); 55 | expect(auths.error).to.equal('decrypt error'); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /test/commands/org/login/login.sfdx-url.nut.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2025, Salesforce, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | import { execCmd, prepareForAuthUrl, TestSession } from '@salesforce/cli-plugins-testkit'; 17 | import { expect } from 'chai'; 18 | import { Env } from '@salesforce/kit'; 19 | import { ensureString, getString } from '@salesforce/ts-types'; 20 | import { AuthFields } from '@salesforce/core'; 21 | import { 22 | expectAccessTokenToExist, 23 | expectOrgIdToExist, 24 | expectPropsToExist, 25 | expectUrlToExist, 26 | } from '../../../testHelper.js'; 27 | 28 | let testSession: TestSession; 29 | 30 | describe('org:login:sfdx-url NUTs', () => { 31 | const env = new Env(); 32 | let authUrl: string; 33 | let username: string; 34 | 35 | before('prepare session and ensure environment variables', async () => { 36 | ensureString(env.getString('TESTKIT_AUTH_URL')); 37 | username = ensureString(env.getString('TESTKIT_HUB_USERNAME')); 38 | testSession = await TestSession.create(); 39 | authUrl = prepareForAuthUrl(testSession.homeDir); 40 | }); 41 | 42 | after(async () => { 43 | await testSession?.clean(); 44 | }); 45 | 46 | afterEach(() => { 47 | execCmd(`auth:logout -p -o ${username}`, { ensureExitCode: 0 }); 48 | }); 49 | 50 | it('should authorize an org using sfdx-url (json)', () => { 51 | const command = `org:login:sfdx-url -d -f ${authUrl} --json`; 52 | const json = execCmd(command, { ensureExitCode: 0 }).jsonOutput?.result as AuthFields; 53 | 54 | expectPropsToExist(json, 'refreshToken'); 55 | expectAccessTokenToExist(json); 56 | expectOrgIdToExist(json); 57 | expectUrlToExist(json, 'instanceUrl'); 58 | expectUrlToExist(json, 'loginUrl'); 59 | expect(json.username).to.equal(username); 60 | }); 61 | 62 | it('should authorize an org using sfdx-url (human readable)', () => { 63 | const command = `org:login:sfdx-url -d -f ${authUrl}`; 64 | const result = execCmd(command, { ensureExitCode: 0 }); 65 | const output = getString(result, 'shellOutput.stdout'); 66 | expect(output).to.include(`Successfully authorized ${username} with org ID`); 67 | }); 68 | }); 69 | -------------------------------------------------------------------------------- /src/common.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2025, Salesforce, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { Logger, SfdcUrl, SfProject, Messages, SfError, Global, Mode } from '@salesforce/core'; 18 | import { getString, isObject } from '@salesforce/ts-types'; 19 | import { prompts, StandardColors } from '@salesforce/sf-plugins-core'; 20 | 21 | Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); 22 | const messages = Messages.loadMessages('@salesforce/plugin-auth', 'messages'); 23 | 24 | const resolveLoginUrl = async (instanceUrl?: string): Promise => { 25 | const logger = await Logger.child('Common', { tag: 'resolveLoginUrl' }); 26 | const loginUrl = instanceUrl ?? (await getLoginUrl(logger)); 27 | throwIfLightning(loginUrl); 28 | logger.debug(`loginUrl: ${loginUrl}`); 29 | return loginUrl; 30 | }; 31 | 32 | /** try to get url from project if there is one, otherwise use the default production URL */ 33 | const getLoginUrl = async (logger: Logger): Promise => { 34 | try { 35 | const project = await SfProject.resolve(); 36 | const projectJson = await project.resolveProjectConfig(); 37 | return getString(projectJson, 'sfdcLoginUrl', SfdcUrl.PRODUCTION); 38 | } catch (err) { 39 | const message: string = (isObject(err) ? Reflect.get(err, 'message') ?? err : err) as string; 40 | logger.debug(`error occurred while trying to determine loginUrl: ${message}`); 41 | return SfdcUrl.PRODUCTION; 42 | } 43 | }; 44 | const throwIfLightning = (urlString: string): void => { 45 | const url = new SfdcUrl(urlString); 46 | if (url.isLightningDomain() || (url.isInternalUrl() && url.origin.includes('.lightning.'))) { 47 | throw new SfError(messages.getMessage('lightningInstanceUrl'), 'LightningDomain', [ 48 | messages.getMessage('flags.instance-url.description'), 49 | ]); 50 | } 51 | }; 52 | 53 | const shouldExitCommand = async (noPrompt?: boolean): Promise => 54 | Boolean(noPrompt) || Global.getEnvironmentMode() !== Mode.DEMO 55 | ? false 56 | : !(await prompts.confirm({ message: StandardColors.info(messages.getMessage('warnAuth', ['sf'])), ms: 60_000 })); 57 | 58 | export default { 59 | shouldExitCommand, 60 | resolveLoginUrl, 61 | }; 62 | -------------------------------------------------------------------------------- /messages/sfdxurl.store.md: -------------------------------------------------------------------------------- 1 | # summary 2 | 3 | Authorize an org using a Salesforce DX authorization URL stored in a file or through standard input (stdin). 4 | 5 | # description 6 | 7 | You use the Salesforce DX (SFDX) authorization URL to authorize Salesforce CLI to connect to a target org. The URL contains the required data to accomplish the authorization, such as the client ID, client secret, and instance URL. You must specify the SFDX authorization URL in this format: "%s". Replace , , , and with the values specific to your target org. For , don't include a protocol (such as "https://"). Note that although the SFDX authorization URL starts with "force://", it has nothing to do with the actual authorization. Salesforce CLI always communicates with your org using HTTPS. 8 | 9 | To see an example of an SFDX authorization URL, run "org display --verbose" on an org. 10 | 11 | You have three options when creating the authorization file. The easiest option is to redirect the output of the "<%= config.bin %> org display --verbose --json" command into a file. For example, using an org with alias my-org that you've already authorized: 12 | 13 | $ <%= config.bin %> org display --target-org my-org --verbose --json > authFile.json 14 | 15 | The resulting JSON file contains the URL in the "sfdxAuthUrl" property of the "result" object. You can then reference the file when running this command: 16 | 17 | $ <%= config.bin %> <%= command.id %> --sfdx-url-file authFile.json 18 | 19 | NOTE: The "<%= config.bin %> org display --verbose" command displays the refresh token only for orgs authorized with the web server flow, and not the JWT bearer flow. 20 | 21 | You can also create a JSON file that has a top-level property named sfdxAuthUrl whose value is the authorization URL. Finally, you can create a normal text file that includes just the URL and nothing else. 22 | 23 | Alternatively, you can pipe the SFDX authorization URL through standard input by specifying the --sfdx-url-stdin flag. 24 | 25 | # flags.sfdx-url-file.summary 26 | 27 | Path to a file that contains the Salesforce DX authorization URL. 28 | 29 | # flags.sfdx-url-stdin.summary 30 | 31 | Pipe the Salesforce DX authorization URL through standard input (stdin). 32 | 33 | # examples 34 | 35 | - Authorize an org using the SFDX authorization URL in the files/authFile.json file: 36 | 37 | <%= config.bin %> <%= command.id %> --sfdx-url-file files/authFile.json 38 | 39 | - Similar to previous example, but set the org as your default and give it an alias MyDefaultOrg: 40 | 41 | <%= config.bin %> <%= command.id %> --sfdx-url-file files/authFile.json --set-default --alias MyDefaultOrg 42 | 43 | - Pipe the SFDX authorization URL from stdin: 44 | 45 | $ echo url | sf <%= command.id %> --sfdx-url-stdin 46 | -------------------------------------------------------------------------------- /src/commands/org/list/auth.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2025, Salesforce, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { loglevel, SfCommand } from '@salesforce/sf-plugins-core'; 18 | import { AuthInfo, Messages, OrgAuthorization } from '@salesforce/core'; 19 | type AuthListResult = Omit & { alias: string }; 20 | export type AuthListResults = AuthListResult[]; 21 | Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); 22 | const messages = Messages.loadMessages('@salesforce/plugin-auth', 'list'); 23 | 24 | export default class ListAuth extends SfCommand { 25 | public static readonly summary = messages.getMessage('summary'); 26 | public static readonly description = messages.getMessage('description'); 27 | public static readonly examples = messages.getMessages('examples'); 28 | public static readonly deprecateAliases = true; 29 | public static readonly aliases = ['force:auth:list', 'auth:list']; 30 | 31 | public static readonly flags = { 32 | loglevel, 33 | }; 34 | 35 | public async run(): Promise { 36 | await this.parse(ListAuth); 37 | try { 38 | const auths = await AuthInfo.listAllAuthorizations(); 39 | if (auths.length === 0) { 40 | this.log(messages.getMessage('noResultsFound')); 41 | return []; 42 | } 43 | const mappedAuths: AuthListResults = auths.map((auth: OrgAuthorization) => { 44 | const { aliases, ...rest } = auth; 45 | // core3 moved to aliases as a string[], revert to alias as a string 46 | return { ...rest, alias: aliases ? aliases.join(',') : '' }; 47 | }); 48 | 49 | const hasErrors = auths.filter((auth) => !!auth.error).length > 0; 50 | this.table({ 51 | data: mappedAuths.map((auth) => ({ 52 | ALIAS: auth.alias, 53 | USERNAME: auth.username, 54 | 'ORG ID': auth.orgId, 55 | 'INSTANCE URL': auth.instanceUrl, 56 | 'AUTH METHOD': auth.oauthMethod, 57 | ...(hasErrors ? { error: { header: 'ERROR' } } : {}), 58 | })), 59 | title: 'authenticated orgs', 60 | }); 61 | return mappedAuths; 62 | } catch (err) { 63 | this.log(messages.getMessage('noResultsFound')); 64 | return []; 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /messages/jwt.grant.md: -------------------------------------------------------------------------------- 1 | # summary 2 | 3 | Log in to a Salesforce org using a JSON web token (JWT). 4 | 5 | # description 6 | 7 | Use this command in automated environments where you can’t interactively log in with a browser, such as in CI/CD scripts. 8 | 9 | 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. 10 | 11 | Complete these steps before you run this command: 12 | 13 | 1. Create a digital certificate (also called digital signature) and the private key to sign the certificate. You can use your own key and certificate issued by a certification authority. Or use OpenSSL to create a key and a self-signed digital certificate. 14 | 2. Store the private key in a file on your computer. When you run this command, you set the --jwt-key-file flag to this file. 15 | 3. Create a custom connected app in your org using the digital certificate. Make note of the consumer key (also called client id) that’s generated for you. Be sure the username of the user logging in is approved to use the connected app. When you run this command, you set the --client-id flag to the consumer key. 16 | 17 | See https://developer.salesforce.com/docs/atlas.en-us.sfdx_dev.meta/sfdx_dev/sfdx_dev_auth_jwt_flow.htm for more information. 18 | 19 | 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. 20 | 21 | # examples 22 | 23 | - Log into an org with username jdoe@example.org and on the default instance URL (https://login.salesforce.com). The private key is stored in the file /Users/jdoe/JWT/server.key and the command uses the connected app with consumer key (client id) 04580y4051234051. 24 | 25 | <%= config.bin %> <%= command.id %> --username jdoe@example.org --jwt-key-file /Users/jdoe/JWT/server.key --client-id 04580y4051234051 26 | 27 | - Set the org as the default and give it an alias: 28 | 29 | <%= config.bin %> <%= command.id %> --username jdoe@example.org --jwt-key-file /Users/jdoe/JWT/server.key --client-id 04580y4051234051 --alias ci-org --set-default 30 | 31 | - Set the org as the default Dev Hub and give it an alias: 32 | 33 | <%= config.bin %> <%= command.id %> --username jdoe@example.org --jwt-key-file /Users/jdoe/JWT/server.key --client-id 04580y4051234051 --alias ci-dev-hub --set-default-dev-hub 34 | 35 | - Log in to a sandbox using URL https://MyDomainName--SandboxName.sandbox.my.salesforce.com: 36 | 37 | <%= config.bin %> <%= command.id %> --username jdoe@example.org --jwt-key-file /Users/jdoe/JWT/server.key --client-id 04580y4051234051 --alias ci-org --set-default --instance-url https://MyDomainName--SandboxName.sandbox.my.salesforce.com 38 | 39 | # flags.username.summary 40 | 41 | Username of the user logging in. 42 | 43 | # flags.jwt-key-file.summary 44 | 45 | Path to a file containing the private key. 46 | 47 | # JwtGrantError 48 | 49 | We encountered a JSON web token error, which is likely not an issue with Salesforce CLI. Here’s the error: %s 50 | -------------------------------------------------------------------------------- /test/commands/org/login/access-token.nut.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2025, Salesforce, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | import { execCmd, TestSession, prepareForJwt } from '@salesforce/cli-plugins-testkit'; 17 | import { expect } from 'chai'; 18 | import { Env } from '@salesforce/kit'; 19 | import { ensureString, getString } from '@salesforce/ts-types'; 20 | import { AuthFields } from '@salesforce/core'; 21 | import { expectAccessTokenToExist, expectOrgIdToExist, expectUrlToExist } from '../../../testHelper.js'; 22 | 23 | let testSession: TestSession; 24 | 25 | describe('org:login:access-token NUTs', () => { 26 | const env = new Env(); 27 | let username: string; 28 | let instanceUrl: string; 29 | let clientId: string; 30 | let accessToken: string; 31 | 32 | before('prepare session and ensure environment variables', async () => { 33 | username = ensureString(env.getString('TESTKIT_HUB_USERNAME')); 34 | instanceUrl = ensureString(env.getString('TESTKIT_HUB_INSTANCE')); 35 | clientId = ensureString(env.getString('TESTKIT_JWT_CLIENT_ID')); 36 | ensureString(env.getString('TESTKIT_JWT_KEY')); 37 | testSession = await TestSession.create(); 38 | const jwtKeyFilePath = prepareForJwt(testSession.homeDir); 39 | const res = execCmd<{ accessToken: string }>( 40 | `org:login:jwt -f ${jwtKeyFilePath} -i ${clientId} -o ${username} --set-default-dev-hub --instance-url ${instanceUrl} --json`, 41 | { 42 | ensureExitCode: 0, 43 | } 44 | ); 45 | accessToken = res.jsonOutput?.result.accessToken as string; 46 | env.setString('SF_ACCESS_TOKEN', accessToken); 47 | execCmd(`auth:logout -p -o ${username}`, { ensureExitCode: 0 }); 48 | }); 49 | 50 | after(async () => { 51 | await testSession?.clean(); 52 | }); 53 | 54 | afterEach(() => { 55 | execCmd(`auth:logout -p -o ${username}`, { ensureExitCode: 0 }); 56 | }); 57 | 58 | it('should authorize an org using access token (json)', () => { 59 | const command = `org:login:access-token --set-default-dev-hub --instance-url ${instanceUrl} --no-prompt --json`; 60 | const cmdresult = execCmd(command, { ensureExitCode: 0 }); 61 | const json = cmdresult.jsonOutput?.result as AuthFields; 62 | 63 | expectAccessTokenToExist(json); 64 | expectOrgIdToExist(json); 65 | expectUrlToExist(json, 'instanceUrl'); 66 | expectUrlToExist(json, 'loginUrl'); 67 | expect(json.username).to.equal(username); 68 | expect(json.accessToken).to.equal(accessToken); 69 | }); 70 | 71 | it('should authorize an org using access token (human readable)', () => { 72 | const command = `org:login:access-token --set-default-dev-hub --instance-url ${instanceUrl} --no-prompt`; 73 | const result = execCmd(command, { ensureExitCode: 0 }); 74 | const output = getString(result, 'shellOutput.stdout'); 75 | expect(output).to.include(`Successfully authorized ${username} with org ID`); 76 | }); 77 | }); 78 | -------------------------------------------------------------------------------- /messages/logout.md: -------------------------------------------------------------------------------- 1 | # summary 2 | 3 | Log out of a Salesforce org. 4 | 5 | # description 6 | 7 | If you run this command with no flags and no default org set in your config or environment, it first displays a list of orgs you've created or logged into, with none of the orgs selected. Use the arrow keys to scroll through the list and the space bar to select the orgs you want to log out of. Press Enter when you're done; the command asks for a final confirmation before logging out of the selected orgs. 8 | 9 | The process is similar if you specify --all, except that in the initial list of orgs, they're all selected. Use --target-org to logout of a specific org. In both these cases by default, you must still confirm that you want to log out. Use --no-prompt to never be asked for confirmation when also using --all or --target-org. 10 | 11 | Be careful! If you log out of a scratch org without having access to its password, you can't access the scratch org again, either through the CLI or the Salesforce UI. 12 | 13 | Use the --client-app flag to log out of the link you previously created between an authenticated user and a connected app or external client app; you create these links with "org login web --client-app". Run "org display" to get the list of client app names. 14 | 15 | # examples 16 | 17 | - Interactively select the orgs to log out of: 18 | 19 | <%= config.bin %> <%= command.id %> 20 | 21 | - Log out of the org with username me@my.org: 22 | 23 | <%= config.bin %> <%= command.id %> --target-org me@my.org 24 | 25 | - Log out of all orgs after confirmation: 26 | 27 | <%= config.bin %> <%= command.id %> --all 28 | 29 | - Logout of the org with alias my-scratch and don't prompt for confirmation: 30 | 31 | <%= config.bin %> <%= command.id %> --target-org my-scratch --no-prompt 32 | 33 | # flags.target-org.summary 34 | 35 | Username or alias of the target org. 36 | 37 | # flags.all.summary 38 | 39 | Include all authenticated orgs. 40 | 41 | # flags.all.description 42 | 43 | All orgs includes Dev Hubs, sandboxes, DE orgs, and expired, deleted, and unknown-status scratch orgs. 44 | 45 | # flags.client-app.summary 46 | 47 | Client app to log out of. 48 | 49 | # logoutOrgCommandSuccess 50 | 51 | Successfully logged out of orgs: %s 52 | 53 | # logoutClientAppSuccess 54 | 55 | Successfully logged out of "%s" client app for user %s. 56 | 57 | # error.noLinkedApps 58 | 59 | %s doesn't have any linked client apps. 60 | 61 | # error.invalidClientApp 62 | 63 | %s doesn't have a linked client app named "%s". 64 | 65 | # noOrgsFound 66 | 67 | No orgs found to log out of. 68 | 69 | # noOrgsSelected 70 | 71 | No orgs selected for logout. 72 | 73 | # prompt.select-envs 74 | 75 | Select the orgs you want to log out of: 76 | 77 | # prompt.confirm 78 | 79 | Are you sure you want to log out of %d org%s? 80 | 81 | # prompt.confirm-all 82 | 83 | Are you sure you want to log out of all your orgs? 84 | 85 | # prompt.confirm.single 86 | 87 | Are you sure you want to log out of %s? 88 | 89 | # warning 90 | 91 | Warning: If you log out of a scratch org without having access to its password, you can't access this org again, either through the CLI or the Salesforce UI. 92 | 93 | # noOrgSpecifiedWithNoPrompt 94 | 95 | You must specify a target-org (or default target-org config is set) or use --all flag when using the --no-prompt flag. 96 | 97 | # noOrgSpecifiedWithJson 98 | 99 | You must specify a target-org (or default target-org config is set) or use --all flag when using the --json flag. 100 | 101 | # noAuthFoundForTargetOrg 102 | 103 | No authenticated org found with the %s username or alias. 104 | -------------------------------------------------------------------------------- /schemas/org-login-jwt.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema#", 3 | "$ref": "#/definitions/AuthFields", 4 | "definitions": { 5 | "AuthFields": { 6 | "type": "object", 7 | "properties": { 8 | "clientApps": { 9 | "type": "object", 10 | "additionalProperties": { 11 | "type": "object", 12 | "properties": { 13 | "clientId": { 14 | "type": "string" 15 | }, 16 | "clientSecret": { 17 | "type": "string" 18 | }, 19 | "accessToken": { 20 | "type": "string" 21 | }, 22 | "refreshToken": { 23 | "type": "string" 24 | }, 25 | "oauthFlow": { 26 | "type": "string", 27 | "const": "web" 28 | } 29 | }, 30 | "required": ["clientId", "accessToken", "refreshToken", "oauthFlow"], 31 | "additionalProperties": false 32 | } 33 | }, 34 | "accessToken": { 35 | "type": "string" 36 | }, 37 | "alias": { 38 | "type": "string" 39 | }, 40 | "authCode": { 41 | "type": "string" 42 | }, 43 | "clientId": { 44 | "type": "string" 45 | }, 46 | "clientSecret": { 47 | "type": "string" 48 | }, 49 | "created": { 50 | "type": "string" 51 | }, 52 | "createdOrgInstance": { 53 | "type": "string" 54 | }, 55 | "devHubUsername": { 56 | "type": "string" 57 | }, 58 | "instanceUrl": { 59 | "type": "string" 60 | }, 61 | "instanceApiVersion": { 62 | "type": "string" 63 | }, 64 | "instanceApiVersionLastRetrieved": { 65 | "type": "string" 66 | }, 67 | "isDevHub": { 68 | "type": "boolean" 69 | }, 70 | "loginUrl": { 71 | "type": "string" 72 | }, 73 | "orgId": { 74 | "type": "string" 75 | }, 76 | "password": { 77 | "type": "string" 78 | }, 79 | "privateKey": { 80 | "type": "string" 81 | }, 82 | "refreshToken": { 83 | "type": "string" 84 | }, 85 | "scratchAdminUsername": { 86 | "type": "string" 87 | }, 88 | "snapshot": { 89 | "type": "string" 90 | }, 91 | "userId": { 92 | "type": "string" 93 | }, 94 | "username": { 95 | "type": "string" 96 | }, 97 | "usernames": { 98 | "type": "array", 99 | "items": { 100 | "type": "string" 101 | } 102 | }, 103 | "userProfileName": { 104 | "type": "string" 105 | }, 106 | "expirationDate": { 107 | "type": "string" 108 | }, 109 | "tracksSource": { 110 | "type": "boolean" 111 | }, 112 | "name": { 113 | "type": "string" 114 | }, 115 | "instanceName": { 116 | "type": "string" 117 | }, 118 | "namespacePrefix": { 119 | "type": ["string", "null"] 120 | }, 121 | "isSandbox": { 122 | "type": "boolean" 123 | }, 124 | "isScratch": { 125 | "type": "boolean" 126 | }, 127 | "trailExpirationDate": { 128 | "type": ["string", "null"] 129 | } 130 | }, 131 | "additionalProperties": false, 132 | "description": "Fields for authorization, org, and local information." 133 | } 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /schemas/org-login-web.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema#", 3 | "$ref": "#/definitions/AuthFields", 4 | "definitions": { 5 | "AuthFields": { 6 | "type": "object", 7 | "properties": { 8 | "clientApps": { 9 | "type": "object", 10 | "additionalProperties": { 11 | "type": "object", 12 | "properties": { 13 | "clientId": { 14 | "type": "string" 15 | }, 16 | "clientSecret": { 17 | "type": "string" 18 | }, 19 | "accessToken": { 20 | "type": "string" 21 | }, 22 | "refreshToken": { 23 | "type": "string" 24 | }, 25 | "oauthFlow": { 26 | "type": "string", 27 | "const": "web" 28 | } 29 | }, 30 | "required": ["clientId", "accessToken", "refreshToken", "oauthFlow"], 31 | "additionalProperties": false 32 | } 33 | }, 34 | "accessToken": { 35 | "type": "string" 36 | }, 37 | "alias": { 38 | "type": "string" 39 | }, 40 | "authCode": { 41 | "type": "string" 42 | }, 43 | "clientId": { 44 | "type": "string" 45 | }, 46 | "clientSecret": { 47 | "type": "string" 48 | }, 49 | "created": { 50 | "type": "string" 51 | }, 52 | "createdOrgInstance": { 53 | "type": "string" 54 | }, 55 | "devHubUsername": { 56 | "type": "string" 57 | }, 58 | "instanceUrl": { 59 | "type": "string" 60 | }, 61 | "instanceApiVersion": { 62 | "type": "string" 63 | }, 64 | "instanceApiVersionLastRetrieved": { 65 | "type": "string" 66 | }, 67 | "isDevHub": { 68 | "type": "boolean" 69 | }, 70 | "loginUrl": { 71 | "type": "string" 72 | }, 73 | "orgId": { 74 | "type": "string" 75 | }, 76 | "password": { 77 | "type": "string" 78 | }, 79 | "privateKey": { 80 | "type": "string" 81 | }, 82 | "refreshToken": { 83 | "type": "string" 84 | }, 85 | "scratchAdminUsername": { 86 | "type": "string" 87 | }, 88 | "snapshot": { 89 | "type": "string" 90 | }, 91 | "userId": { 92 | "type": "string" 93 | }, 94 | "username": { 95 | "type": "string" 96 | }, 97 | "usernames": { 98 | "type": "array", 99 | "items": { 100 | "type": "string" 101 | } 102 | }, 103 | "userProfileName": { 104 | "type": "string" 105 | }, 106 | "expirationDate": { 107 | "type": "string" 108 | }, 109 | "tracksSource": { 110 | "type": "boolean" 111 | }, 112 | "name": { 113 | "type": "string" 114 | }, 115 | "instanceName": { 116 | "type": "string" 117 | }, 118 | "namespacePrefix": { 119 | "type": ["string", "null"] 120 | }, 121 | "isSandbox": { 122 | "type": "boolean" 123 | }, 124 | "isScratch": { 125 | "type": "boolean" 126 | }, 127 | "trailExpirationDate": { 128 | "type": ["string", "null"] 129 | } 130 | }, 131 | "additionalProperties": false, 132 | "description": "Fields for authorization, org, and local information." 133 | } 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /test/commands/0-scratch-identify.nut.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2025, Salesforce, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import * as path from 'node:path'; 18 | import { expect } from 'chai'; 19 | 20 | import { execCmd, TestSession, prepareForJwt } from '@salesforce/cli-plugins-testkit'; 21 | import { Env } from '@salesforce/kit'; 22 | import { ensureString } from '@salesforce/ts-types'; 23 | import { AuthFields, AuthInfo } from '@salesforce/core'; 24 | import { AuthListResults } from '../../src/commands/org/list/auth.js'; 25 | 26 | describe('verify discovery/id of scratch org', () => { 27 | let testSession: TestSession; 28 | let hubUsername: string; 29 | let orgUsername: string; 30 | let jwtKey: string; 31 | let orgInstanceUrl: string; 32 | 33 | before('prepare session and ensure environment variables', async () => { 34 | const env = new Env(); 35 | ensureString(env.getString('TESTKIT_JWT_KEY')); 36 | ensureString(env.getString('TESTKIT_JWT_CLIENT_ID')); 37 | ensureString(env.getString('TESTKIT_HUB_INSTANCE')); 38 | hubUsername = ensureString(env.getString('TESTKIT_HUB_USERNAME')); 39 | testSession = await TestSession.create({ 40 | devhubAuthStrategy: 'AUTO', 41 | project: { name: 'ScratchIDProject' }, 42 | scratchOrgs: [ 43 | { 44 | setDefault: true, 45 | config: path.join('config', 'project-scratch-def.json'), 46 | }, 47 | ], 48 | }); 49 | 50 | orgUsername = [...testSession.orgs.keys()][0]; 51 | orgInstanceUrl = (testSession.orgs.get(orgUsername)?.instanceUrl ?? 'https://test.salesforce.com').replace( 52 | '.com/', 53 | '.com' 54 | ); 55 | 56 | // we'll need this path for testing 57 | jwtKey = prepareForJwt(testSession.homeDir); 58 | }); 59 | 60 | after(async () => { 61 | await testSession?.clean(); 62 | }); 63 | 64 | it('should have the scratch org in auth files', () => { 65 | const list = execCmd('org:list:auth --json', { ensureExitCode: 0 }).jsonOutput 66 | ?.result as AuthListResults; 67 | const found = !!list.find((r) => r.username === orgUsername); 68 | expect(found).to.be.true; 69 | }); 70 | 71 | it('should logout from the org)', () => { 72 | execCmd(`org:logout -o ${orgUsername} --no-prompt`, { ensureExitCode: 0 }); 73 | }); 74 | 75 | it('should NOT have the scratch org in auth files', () => { 76 | const list = execCmd('org:list:auth --json', { ensureExitCode: 0 }).jsonOutput 77 | ?.result as AuthListResults; 78 | const found = !!list.find((r) => r.username === orgUsername); 79 | expect(found).to.be.false; 80 | }); 81 | 82 | it('should login to the org via jwt grant', async () => { 83 | const env = new Env(); 84 | const command = `org:login:jwt -f ${jwtKey} --username ${orgUsername} --client-id ${env.getString( 85 | 'TESTKIT_JWT_CLIENT_ID' 86 | )} -r ${orgInstanceUrl} --json`; 87 | const output = execCmd(command, { 88 | ensureExitCode: 0, 89 | }).jsonOutput?.result; 90 | const authInfo = await AuthInfo.create({ username: orgUsername }); 91 | expect(output?.username).to.equal(orgUsername); 92 | expect(authInfo?.getFields().devHubUsername).to.equal(hubUsername); 93 | }); 94 | }); 95 | -------------------------------------------------------------------------------- /schemas/org-login-sfdx__url.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema#", 3 | "$ref": "#/definitions/AuthFields", 4 | "definitions": { 5 | "AuthFields": { 6 | "type": "object", 7 | "properties": { 8 | "clientApps": { 9 | "type": "object", 10 | "additionalProperties": { 11 | "type": "object", 12 | "properties": { 13 | "clientId": { 14 | "type": "string" 15 | }, 16 | "clientSecret": { 17 | "type": "string" 18 | }, 19 | "accessToken": { 20 | "type": "string" 21 | }, 22 | "refreshToken": { 23 | "type": "string" 24 | }, 25 | "oauthFlow": { 26 | "type": "string", 27 | "const": "web" 28 | } 29 | }, 30 | "required": ["clientId", "accessToken", "refreshToken", "oauthFlow"], 31 | "additionalProperties": false 32 | } 33 | }, 34 | "accessToken": { 35 | "type": "string" 36 | }, 37 | "alias": { 38 | "type": "string" 39 | }, 40 | "authCode": { 41 | "type": "string" 42 | }, 43 | "clientId": { 44 | "type": "string" 45 | }, 46 | "clientSecret": { 47 | "type": "string" 48 | }, 49 | "created": { 50 | "type": "string" 51 | }, 52 | "createdOrgInstance": { 53 | "type": "string" 54 | }, 55 | "devHubUsername": { 56 | "type": "string" 57 | }, 58 | "instanceUrl": { 59 | "type": "string" 60 | }, 61 | "instanceApiVersion": { 62 | "type": "string" 63 | }, 64 | "instanceApiVersionLastRetrieved": { 65 | "type": "string" 66 | }, 67 | "isDevHub": { 68 | "type": "boolean" 69 | }, 70 | "loginUrl": { 71 | "type": "string" 72 | }, 73 | "orgId": { 74 | "type": "string" 75 | }, 76 | "password": { 77 | "type": "string" 78 | }, 79 | "privateKey": { 80 | "type": "string" 81 | }, 82 | "refreshToken": { 83 | "type": "string" 84 | }, 85 | "scratchAdminUsername": { 86 | "type": "string" 87 | }, 88 | "snapshot": { 89 | "type": "string" 90 | }, 91 | "userId": { 92 | "type": "string" 93 | }, 94 | "username": { 95 | "type": "string" 96 | }, 97 | "usernames": { 98 | "type": "array", 99 | "items": { 100 | "type": "string" 101 | } 102 | }, 103 | "userProfileName": { 104 | "type": "string" 105 | }, 106 | "expirationDate": { 107 | "type": "string" 108 | }, 109 | "tracksSource": { 110 | "type": "boolean" 111 | }, 112 | "name": { 113 | "type": "string" 114 | }, 115 | "instanceName": { 116 | "type": "string" 117 | }, 118 | "namespacePrefix": { 119 | "type": ["string", "null"] 120 | }, 121 | "isSandbox": { 122 | "type": "boolean" 123 | }, 124 | "isScratch": { 125 | "type": "boolean" 126 | }, 127 | "trailExpirationDate": { 128 | "type": ["string", "null"] 129 | } 130 | }, 131 | "additionalProperties": false, 132 | "description": "Fields for authorization, org, and local information." 133 | } 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /schemas/org-login-access__token.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema#", 3 | "$ref": "#/definitions/AuthFields", 4 | "definitions": { 5 | "AuthFields": { 6 | "type": "object", 7 | "properties": { 8 | "clientApps": { 9 | "type": "object", 10 | "additionalProperties": { 11 | "type": "object", 12 | "properties": { 13 | "clientId": { 14 | "type": "string" 15 | }, 16 | "clientSecret": { 17 | "type": "string" 18 | }, 19 | "accessToken": { 20 | "type": "string" 21 | }, 22 | "refreshToken": { 23 | "type": "string" 24 | }, 25 | "oauthFlow": { 26 | "type": "string", 27 | "const": "web" 28 | } 29 | }, 30 | "required": ["clientId", "accessToken", "refreshToken", "oauthFlow"], 31 | "additionalProperties": false 32 | } 33 | }, 34 | "accessToken": { 35 | "type": "string" 36 | }, 37 | "alias": { 38 | "type": "string" 39 | }, 40 | "authCode": { 41 | "type": "string" 42 | }, 43 | "clientId": { 44 | "type": "string" 45 | }, 46 | "clientSecret": { 47 | "type": "string" 48 | }, 49 | "created": { 50 | "type": "string" 51 | }, 52 | "createdOrgInstance": { 53 | "type": "string" 54 | }, 55 | "devHubUsername": { 56 | "type": "string" 57 | }, 58 | "instanceUrl": { 59 | "type": "string" 60 | }, 61 | "instanceApiVersion": { 62 | "type": "string" 63 | }, 64 | "instanceApiVersionLastRetrieved": { 65 | "type": "string" 66 | }, 67 | "isDevHub": { 68 | "type": "boolean" 69 | }, 70 | "loginUrl": { 71 | "type": "string" 72 | }, 73 | "orgId": { 74 | "type": "string" 75 | }, 76 | "password": { 77 | "type": "string" 78 | }, 79 | "privateKey": { 80 | "type": "string" 81 | }, 82 | "refreshToken": { 83 | "type": "string" 84 | }, 85 | "scratchAdminUsername": { 86 | "type": "string" 87 | }, 88 | "snapshot": { 89 | "type": "string" 90 | }, 91 | "userId": { 92 | "type": "string" 93 | }, 94 | "username": { 95 | "type": "string" 96 | }, 97 | "usernames": { 98 | "type": "array", 99 | "items": { 100 | "type": "string" 101 | } 102 | }, 103 | "userProfileName": { 104 | "type": "string" 105 | }, 106 | "expirationDate": { 107 | "type": "string" 108 | }, 109 | "tracksSource": { 110 | "type": "boolean" 111 | }, 112 | "name": { 113 | "type": "string" 114 | }, 115 | "instanceName": { 116 | "type": "string" 117 | }, 118 | "namespacePrefix": { 119 | "type": ["string", "null"] 120 | }, 121 | "isSandbox": { 122 | "type": "boolean" 123 | }, 124 | "isScratch": { 125 | "type": "boolean" 126 | }, 127 | "trailExpirationDate": { 128 | "type": ["string", "null"] 129 | } 130 | }, 131 | "additionalProperties": false, 132 | "description": "Fields for authorization, org, and local information." 133 | } 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /test/commands/org/login/login.jwt.nut.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2025, Salesforce, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | import * as path from 'node:path'; 17 | import { execCmd, TestSession, prepareForJwt } from '@salesforce/cli-plugins-testkit'; 18 | import { expect } from 'chai'; 19 | import { Env } from '@salesforce/kit'; 20 | import { ensureString, getString } from '@salesforce/ts-types'; 21 | import { AuthFields } from '@salesforce/core'; 22 | import { expectUrlToExist, expectOrgIdToExist, expectAccessTokenToExist } from '../../../testHelper.js'; 23 | 24 | let testSession: TestSession; 25 | 26 | describe('org:login:jwt NUTs', () => { 27 | const env = new Env(); 28 | let jwtKey: string; 29 | let username: string; 30 | let instanceUrl: string; 31 | let clientId: string; 32 | 33 | before('prepare session and ensure environment variables', async () => { 34 | username = ensureString(env.getString('TESTKIT_HUB_USERNAME')); 35 | instanceUrl = ensureString(env.getString('TESTKIT_HUB_INSTANCE')); 36 | clientId = ensureString(env.getString('TESTKIT_JWT_CLIENT_ID')); 37 | ensureString(env.getString('TESTKIT_JWT_KEY')); 38 | 39 | testSession = await TestSession.create(); 40 | jwtKey = prepareForJwt(testSession.homeDir); 41 | }); 42 | 43 | after(async () => { 44 | await testSession?.clean(); 45 | }); 46 | 47 | it('should authorize an org using jwt (json)', () => { 48 | const command = `org:login:jwt -d -o ${username} -i ${clientId} -f ${jwtKey} -r ${instanceUrl} --json`; 49 | const json = execCmd(command, { ensureExitCode: 0 }).jsonOutput?.result as AuthFields; 50 | expectAccessTokenToExist(json); 51 | expectOrgIdToExist(json); 52 | expectUrlToExist(json, 'instanceUrl'); 53 | expectUrlToExist(json, 'loginUrl'); 54 | expect(json.privateKey).to.equal(path.join(testSession.homeDir, 'jwtKey')); 55 | expect(json.username).to.equal(username); 56 | execCmd(`auth:logout -p -o ${username}`, { ensureExitCode: 0 }); 57 | }); 58 | 59 | it('should authorize an org using jwt (human readable)', () => { 60 | const command = `org:login:jwt -d -o ${username} -i ${clientId} -f ${jwtKey} -r ${instanceUrl}`; 61 | const result = execCmd(command, { ensureExitCode: 0 }); 62 | const output = getString(result, 'shellOutput.stdout'); 63 | expect(output).to.include(`Successfully authorized ${username} with org ID`); 64 | execCmd(`auth:logout -p -o ${username}`, { ensureExitCode: 0 }); 65 | }); 66 | 67 | it('should throw correct error for JwtAuthError', () => { 68 | const command = `org:login:jwt -d -o ${username} -i incorrect -f ${jwtKey} -r ${instanceUrl} --json`; 69 | const json = execCmd(command).jsonOutput; 70 | expect(json).to.have.property('name', 'JwtGrantError'); 71 | expect(json).to.have.property('exitCode', 1); 72 | expect(json) 73 | .to.have.property('message') 74 | .and.include( 75 | 'We encountered a JSON web token error, which is likely not an issue with Salesforce CLI. Here’s the error: JwtAuthError::Error authenticating with JWT.' 76 | ); 77 | expect(json).to.have.property('stack').and.include('client identifier invalid'); 78 | expect(json).to.have.property('cause').and.include('SfError [JwtAuthError]: Error authenticating with JWT.'); 79 | expect(json).to.have.property('cause').and.include('at AuthInfo.authJwt'); 80 | }); 81 | }); 82 | -------------------------------------------------------------------------------- /command-snapshot.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "alias": ["force:auth:list", "auth:list"], 4 | "command": "org:list:auth", 5 | "flagAliases": [], 6 | "flagChars": [], 7 | "flags": ["flags-dir", "json", "loglevel"], 8 | "plugin": "@salesforce/plugin-auth" 9 | }, 10 | { 11 | "alias": ["force:auth:accesstoken:store", "auth:accesstoken:store"], 12 | "command": "org:login:access-token", 13 | "flagAliases": [ 14 | "instanceurl", 15 | "noprompt", 16 | "setalias", 17 | "setdefaultdevhub", 18 | "setdefaultdevhubusername", 19 | "setdefaultusername" 20 | ], 21 | "flagChars": ["a", "d", "p", "r", "s"], 22 | "flags": [ 23 | "alias", 24 | "flags-dir", 25 | "instance-url", 26 | "json", 27 | "loglevel", 28 | "no-prompt", 29 | "set-default", 30 | "set-default-dev-hub" 31 | ], 32 | "plugin": "@salesforce/plugin-auth" 33 | }, 34 | { 35 | "alias": ["force:auth:device:login", "auth:device:login"], 36 | "command": "org:login:device", 37 | "flagAliases": [ 38 | "clientid", 39 | "instanceurl", 40 | "setalias", 41 | "setdefaultdevhub", 42 | "setdefaultdevhubusername", 43 | "setdefaultusername" 44 | ], 45 | "flagChars": ["a", "d", "i", "r", "s"], 46 | "flags": [ 47 | "alias", 48 | "client-id", 49 | "flags-dir", 50 | "instance-url", 51 | "json", 52 | "loglevel", 53 | "set-default", 54 | "set-default-dev-hub" 55 | ], 56 | "plugin": "@salesforce/plugin-auth" 57 | }, 58 | { 59 | "alias": ["force:auth:jwt:grant", "auth:jwt:grant"], 60 | "command": "org:login:jwt", 61 | "flagAliases": [ 62 | "clientid", 63 | "instanceurl", 64 | "jwtkeyfile", 65 | "keyfile", 66 | "l", 67 | "noprompt", 68 | "setalias", 69 | "setdefaultdevhub", 70 | "setdefaultdevhubusername", 71 | "setdefaultusername", 72 | "u", 73 | "v" 74 | ], 75 | "flagChars": ["a", "d", "f", "i", "o", "p", "r", "s"], 76 | "flags": [ 77 | "alias", 78 | "client-id", 79 | "flags-dir", 80 | "instance-url", 81 | "json", 82 | "jwt-key-file", 83 | "loglevel", 84 | "no-prompt", 85 | "set-default", 86 | "set-default-dev-hub", 87 | "username" 88 | ], 89 | "plugin": "@salesforce/plugin-auth" 90 | }, 91 | { 92 | "alias": ["force:auth:sfdxurl:store", "auth:sfdxurl:store"], 93 | "command": "org:login:sfdx-url", 94 | "flagAliases": [ 95 | "noprompt", 96 | "setalias", 97 | "setdefaultdevhub", 98 | "setdefaultdevhubusername", 99 | "setdefaultusername", 100 | "sfdxurlfile", 101 | "sfdxurlstdin" 102 | ], 103 | "flagChars": ["a", "d", "f", "p", "s", "u"], 104 | "flags": [ 105 | "alias", 106 | "flags-dir", 107 | "json", 108 | "loglevel", 109 | "no-prompt", 110 | "set-default", 111 | "set-default-dev-hub", 112 | "sfdx-url-file", 113 | "sfdx-url-stdin" 114 | ], 115 | "plugin": "@salesforce/plugin-auth" 116 | }, 117 | { 118 | "alias": ["force:auth:web:login", "auth:web:login"], 119 | "command": "org:login:web", 120 | "flagAliases": [ 121 | "clientid", 122 | "instanceurl", 123 | "l", 124 | "noprompt", 125 | "setalias", 126 | "setdefaultdevhub", 127 | "setdefaultdevhubusername", 128 | "setdefaultusername", 129 | "v" 130 | ], 131 | "flagChars": ["a", "b", "c", "d", "i", "p", "r", "s"], 132 | "flags": [ 133 | "alias", 134 | "browser", 135 | "client-app", 136 | "client-id", 137 | "flags-dir", 138 | "instance-url", 139 | "json", 140 | "loglevel", 141 | "no-prompt", 142 | "scopes", 143 | "set-default", 144 | "set-default-dev-hub", 145 | "username" 146 | ], 147 | "plugin": "@salesforce/plugin-auth" 148 | }, 149 | { 150 | "alias": ["force:auth:logout", "auth:logout"], 151 | "command": "org:logout", 152 | "flagAliases": ["noprompt", "targetusername", "u"], 153 | "flagChars": ["a", "c", "o", "p"], 154 | "flags": ["all", "client-app", "flags-dir", "json", "loglevel", "no-prompt", "target-org"], 155 | "plugin": "@salesforce/plugin-auth" 156 | } 157 | ] 158 | -------------------------------------------------------------------------------- /src/commands/org/login/device.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2025, Salesforce, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { 18 | type AuthFields, 19 | AuthInfo, 20 | DeviceOauthService, 21 | Messages, 22 | type OAuth2Config, 23 | type DeviceCodeResponse, 24 | } from '@salesforce/core'; 25 | import { Flags, SfCommand, loglevel, Ux } from '@salesforce/sf-plugins-core'; 26 | import common from '../../../common.js'; 27 | 28 | Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); 29 | const messages = Messages.loadMessages('@salesforce/plugin-auth', 'device.login'); 30 | const commonMessages = Messages.loadMessages('@salesforce/plugin-auth', 'messages'); 31 | 32 | export type DeviceLoginResult = (AuthFields & DeviceCodeResponse) | Record; 33 | 34 | export default class LoginDevice extends SfCommand { 35 | public static readonly summary = messages.getMessage('summary'); 36 | public static readonly description = messages.getMessage('description'); 37 | public static readonly examples = messages.getMessages('examples'); 38 | public static readonly aliases = ['force:auth:device:login', 'auth:device:login']; 39 | public static readonly deprecateAliases = true; 40 | public static readonly hidden = true; 41 | public static readonly deprecated = true; 42 | 43 | public static readonly flags = { 44 | 'client-id': Flags.string({ 45 | char: 'i', 46 | summary: commonMessages.getMessage('flags.client-id.summary'), 47 | deprecateAliases: true, 48 | aliases: ['clientid'], 49 | }), 50 | 'instance-url': Flags.url({ 51 | char: 'r', 52 | summary: commonMessages.getMessage('flags.instance-url.summary'), 53 | description: commonMessages.getMessage('flags.instance-url.description'), 54 | deprecateAliases: true, 55 | aliases: ['instanceurl'], 56 | }), 57 | 'set-default-dev-hub': Flags.boolean({ 58 | char: 'd', 59 | summary: commonMessages.getMessage('flags.set-default-dev-hub.summary'), 60 | deprecateAliases: true, 61 | aliases: ['setdefaultdevhub', 'setdefaultdevhubusername'], 62 | }), 63 | 'set-default': Flags.boolean({ 64 | char: 's', 65 | summary: commonMessages.getMessage('flags.set-default.summary'), 66 | deprecateAliases: true, 67 | aliases: ['setdefaultusername'], 68 | }), 69 | alias: Flags.string({ 70 | char: 'a', 71 | summary: commonMessages.getMessage('flags.alias.summary'), 72 | deprecateAliases: true, 73 | aliases: ['setalias'], 74 | }), 75 | loglevel, 76 | }; 77 | 78 | public async run(): Promise { 79 | const { flags } = await this.parse(LoginDevice); 80 | if (await common.shouldExitCommand(false)) return {}; 81 | 82 | const oauthConfig: OAuth2Config = { 83 | loginUrl: await common.resolveLoginUrl(flags['instance-url']?.href), 84 | clientId: flags['client-id'], 85 | }; 86 | 87 | const deviceOauthService = await DeviceOauthService.create(oauthConfig); 88 | const loginData = await deviceOauthService.requestDeviceLogin(); 89 | 90 | if (this.jsonEnabled()) { 91 | new Ux().log(JSON.stringify(loginData, null, 2)); 92 | } else { 93 | this.log(); 94 | this.warn('Device Oauth flow is deprecated and will be removed mid January 2026\n'); 95 | this.styledHeader(messages.getMessage('actionRequired')); 96 | this.log(messages.getMessage('enterCode', [loginData.user_code, loginData.verification_uri])); 97 | this.log(); 98 | } 99 | 100 | const approval = await deviceOauthService.awaitDeviceApproval(loginData); 101 | if (approval) { 102 | const authInfo = await deviceOauthService.authorizeAndSave(approval); 103 | await authInfo.handleAliasAndDefaultSettings({ 104 | alias: flags.alias, 105 | setDefault: flags['set-default'], 106 | setDefaultDevHub: flags['set-default-dev-hub'], 107 | }); 108 | const fields = authInfo.getFields(true); 109 | await AuthInfo.identifyPossibleScratchOrgs(fields, authInfo); 110 | const successMsg = messages.getMessage('success', [fields.username]); 111 | this.logSuccess(successMsg); 112 | return { ...fields, ...loginData }; 113 | } else { 114 | return {}; 115 | } 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /messages/web.login.md: -------------------------------------------------------------------------------- 1 | # summary 2 | 3 | Log in to a Salesforce org using the web server flow. 4 | 5 | # description 6 | 7 | Opens a Salesforce instance URL in a web browser so you can enter your credentials and log in to your org. After you log in, you can close the browser window. 8 | 9 | Logging into an org authorizes the CLI to run other commands that connect to that org, such as deploying or retrieving metadata. You can log into many types of orgs, such as sandboxes, Dev Hubs, Env Hubs, production orgs, and scratch orgs. 10 | 11 | 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. 12 | 13 | By default, this command uses the global out-of-the-box connected app in your org. If you need more security or control, such as setting the refresh token timeout or specifying IP ranges, create your own connected app using a digital certificate. Make note of the consumer key (also called cliend id) that’s generated for you. Then specify the consumer key with the --client-id flag. 14 | 15 | You can also use this command to link one or more connected or external client apps in an org to an already-authenticated user. Then Salesforce CLI commands that have API-specific requirements, such as new OAuth scopes or JWT-based access tokens, can use these custom client apps rather than the default one. To create the link, you use the --client-app flag to give the link a name and the --username flag to specify the already-authenticated user. Use the --scopes flag to add OAuth scopes if required. After you create the link, you then use the --client-app value in the other command that has the API-specific requirements. An example of a command that uses this feature is "agent preview"; see the "Preview an Agent" section in the "Agentforce Developer Guide" for details and examples. (https://developer.salesforce.com/docs/einstein/genai/guide/agent-dx-preview.html) 16 | 17 | # examples 18 | 19 | - Run the command with no flags to open the default Salesforce login page (https://login.salesforce.com): 20 | 21 | <%= config.bin %> <%= command.id %> 22 | 23 | - Log in to your Dev Hub, set it as your default Dev Hub, and set an alias that you reference later when you create a scratch org: 24 | 25 | <%= config.bin %> <%= command.id %> --set-default-dev-hub --alias dev-hub 26 | 27 | - Log in to a sandbox and set it as your default org: 28 | 29 | <%= config.bin %> <%= command.id %> --instance-url https://MyDomainName--SandboxName.sandbox.my.salesforce.com --set-default 30 | 31 | - Use --browser to specify a specific browser, such as Google Chrome: 32 | 33 | <%= config.bin %> <%= command.id %> --instance-url https://MyDomainName--SandboxName.sandbox.my.salesforce.com --set-default --browser chrome 34 | 35 | - Use your own connected app by specifying its consumer key (also called client ID) and specify additional OAuth scopes: 36 | 37 | <%= config.bin %> <%= command.id %> --instance-url https://MyDomainName--SandboxName.sandbox.my.salesforce.com --set-default --browser chrome --client-id 04580y4051234051 --scopes "sfap_api chatbot_api" 38 | 39 | # flags.browser.summary 40 | 41 | Browser in which to open the org. 42 | 43 | # flags.browser.description 44 | 45 | If you don’t specify --browser, the command uses your default browser. The exact names of the browser applications differ depending on the operating system you're on; check your documentation for details. 46 | 47 | # flags.client-app.summary 48 | 49 | Name to give to the link between the connected app or external client and the already-authenticated user. You can specify any string you want. Must be used with --username. 50 | 51 | # flags.username.summary 52 | 53 | Username of the already-authenticated user to link to the connected app or external client app. Must be used with --client-app. 54 | 55 | # flags.scopes.summary 56 | 57 | Authentication (OAuth) scopes to request. Use the scope's short name; specify multiple scopes using just one flag instance and separated by spaces: --scopes "sfap_api chatbot_api". 58 | 59 | # flags.scopes.invalidFormat 60 | 61 | The --scopes flag must be a space-separated list (example: "api web"). 62 | 63 | # linkedClientApp 64 | 65 | Successfully linked "%s" client app to %s. 66 | 67 | # error.headlessWebAuth 68 | 69 | "org login web" isn't supported when authorizing in a headless environment. Use another OAuth flow, such as the JWT Bearer Flow with the "org login jwt" command. 70 | 71 | # invalidClientId 72 | 73 | Invalid client credentials. Verify the OAuth client secret and ID. %s 74 | 75 | # error.cannotOpenBrowser 76 | 77 | Unable to open the browser you specified (%s). 78 | 79 | # error.cannotOpenBrowser.actions 80 | 81 | - Ensure that %s is installed on your computer. Or specify a different browser using the --browser flag. 82 | -------------------------------------------------------------------------------- /test/commands/org/logout.nut.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2025, Salesforce, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | import { execCmd, TestSession, prepareForJwt } from '@salesforce/cli-plugins-testkit'; 17 | import { expect } from 'chai'; 18 | import { Env } from '@salesforce/kit'; 19 | import { ensureString, getString } from '@salesforce/ts-types'; 20 | import { AuthListResults } from '../../../src/commands/org/list/auth.js'; 21 | 22 | describe('org:logout NUTs', () => { 23 | const env = new Env(); 24 | let testSession: TestSession; 25 | let jwtKey: string; 26 | let username: string; 27 | let instanceUrl: string; 28 | let clientId: string; 29 | 30 | before('prepare session and ensure environment variables', async () => { 31 | username = ensureString(env.getString('TESTKIT_HUB_USERNAME')); 32 | instanceUrl = ensureString(env.getString('TESTKIT_HUB_INSTANCE')); 33 | clientId = ensureString(env.getString('TESTKIT_JWT_CLIENT_ID')); 34 | ensureString(env.getString('TESTKIT_JWT_KEY')); 35 | 36 | testSession = await TestSession.create(); 37 | jwtKey = prepareForJwt(testSession.homeDir); 38 | }); 39 | 40 | after(async () => { 41 | await testSession?.clean(); 42 | }); 43 | 44 | beforeEach(() => { 45 | const command = `org:login:jwt -d -o ${username} -i ${clientId} -f ${jwtKey} -r ${instanceUrl} --json`; 46 | execCmd(command, { ensureExitCode: 0 }); 47 | }); 48 | 49 | it('should remove the org specified by the -o flag (json)', () => { 50 | const json = execCmd(`org:logout -p -o ${username} --json`, { ensureExitCode: 0 }).jsonOutput; 51 | expect(json).to.deep.equal({ 52 | status: 0, 53 | result: [username], 54 | warnings: [], 55 | }); 56 | 57 | const list = execCmd('org:list:auth --json', { ensureExitCode: 0 }).jsonOutput 58 | ?.result as AuthListResults; 59 | const found = !!list.find((r) => r.username === username); 60 | expect(found).to.be.false; 61 | }); 62 | 63 | it('should clear any configs that use the removed username (json)', () => { 64 | execCmd(`config:set target-org=${username} --global`, { ensureExitCode: 0, cli: 'sf' }); 65 | execCmd(`config:set target-dev-hub=${username} --global`, { ensureExitCode: 0, cli: 'sf' }); 66 | const json = execCmd(`org:logout -p -o ${username} --json`, { ensureExitCode: 0 }).jsonOutput; 67 | expect(json).to.deep.equal({ 68 | status: 0, 69 | result: [username], 70 | warnings: [], 71 | }); 72 | 73 | const list = execCmd('org:list:auth --json', { ensureExitCode: 0 }).jsonOutput 74 | ?.result as AuthListResults; 75 | const found = !!list.find((r) => r.username === username); 76 | expect(found).to.be.false; 77 | 78 | const configGetUsername = execCmd>('config:get target-org --json', { 79 | ensureExitCode: 0, 80 | cli: 'sf', 81 | }).jsonOutput?.result as Array<{ key: string }>; 82 | expect(['target-org', 'defaultusername']).to.include(configGetUsername[0].key); 83 | 84 | const configGetDevhub = execCmd>('config:get target-dev-hub --json', { 85 | ensureExitCode: 0, 86 | cli: 'sf', 87 | }).jsonOutput?.result as Array<{ key: string }>; 88 | expect(['target-dev-hub', 'defaultdevhubusername']).to.include(configGetDevhub[0].key); 89 | }); 90 | 91 | it('should remove the org specified by the -o flag (human readable)', () => { 92 | const result = execCmd(`org:logout -p -o ${username}`, { ensureExitCode: 0 }); 93 | const output = getString(result, 'shellOutput.stdout'); 94 | expect(output).to.include(`Successfully logged out of orgs: ${username}`); 95 | }); 96 | 97 | it('should fail if there is no default org and the -o flag is not specified (json)', () => { 98 | const json = execCmd<{ name: string }>('org:logout -p --json', { ensureExitCode: 1 }).jsonOutput; 99 | expect(json?.name).to.equal('NoOrgSpecifiedWithNoPromptError'); 100 | }); 101 | 102 | it('should remove the default username if the -o flag is not specified (json)', () => { 103 | execCmd(`config:set target-org=${username} --global`, { ensureExitCode: 0, cli: 'sf' }); 104 | const json = execCmd('org:logout -p --json', { ensureExitCode: 0 }).jsonOutput; 105 | expect(json).to.deep.equal({ 106 | status: 0, 107 | result: [username], 108 | warnings: [], 109 | }); 110 | 111 | // we expect the config for target-org to be cleared out after the logout 112 | const configGet = execCmd>('config:get target-org --json', { 113 | ensureExitCode: 0, 114 | cli: 'sf', 115 | }).jsonOutput?.result as Array<{ key: string }>; 116 | expect(['target-org', 'defaultusername']).to.include(configGet[0].key); 117 | }); 118 | }); 119 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Salesforce Open Source Community Code of Conduct 2 | 3 | ## About the Code of Conduct 4 | 5 | Equality is a core value at Salesforce. We believe a diverse and inclusive 6 | community fosters innovation and creativity, and are committed to building a 7 | culture where everyone feels included. 8 | 9 | Salesforce open-source projects are committed to providing a friendly, safe, and 10 | welcoming environment for all, regardless of gender identity and expression, 11 | sexual orientation, disability, physical appearance, body size, ethnicity, nationality, 12 | race, age, religion, level of experience, education, socioeconomic status, or 13 | other similar personal characteristics. 14 | 15 | The goal of this code of conduct is to specify a baseline standard of behavior so 16 | that people with different social values and communication styles can work 17 | together effectively, productively, and respectfully in our open source community. 18 | It also establishes a mechanism for reporting issues and resolving conflicts. 19 | 20 | All questions and reports of abusive, harassing, or otherwise unacceptable behavior 21 | in a Salesforce open-source project may be reported by contacting the Salesforce 22 | Open Source Conduct Committee at ossconduct@salesforce.com. 23 | 24 | ## Our Pledge 25 | 26 | In the interest of fostering an open and welcoming environment, we as 27 | contributors and maintainers pledge to making participation in our project and 28 | our community a harassment-free experience for everyone, regardless of gender 29 | identity and expression, sexual orientation, disability, physical appearance, 30 | body size, ethnicity, nationality, race, age, religion, level of experience, education, 31 | socioeconomic status, or other similar personal characteristics. 32 | 33 | ## Our Standards 34 | 35 | Examples of behavior that contributes to creating a positive environment 36 | include: 37 | 38 | - Using welcoming and inclusive language 39 | - Being respectful of differing viewpoints and experiences 40 | - Gracefully accepting constructive criticism 41 | - Focusing on what is best for the community 42 | - Showing empathy toward other community members 43 | 44 | Examples of unacceptable behavior by participants include: 45 | 46 | - The use of sexualized language or imagery and unwelcome sexual attention or 47 | advances 48 | - Personal attacks, insulting/derogatory comments, or trolling 49 | - Public or private harassment 50 | - Publishing, or threatening to publish, others' private information—such as 51 | a physical or electronic address—without explicit permission 52 | - Other conduct which could reasonably be considered inappropriate in a 53 | professional setting 54 | - Advocating for or encouraging any of the above behaviors 55 | 56 | ## Our Responsibilities 57 | 58 | Project maintainers are responsible for clarifying the standards of acceptable 59 | behavior and are expected to take appropriate and fair corrective action in 60 | response to any instances of unacceptable behavior. 61 | 62 | Project maintainers have the right and responsibility to remove, edit, or 63 | reject comments, commits, code, wiki edits, issues, and other contributions 64 | that are not aligned with this Code of Conduct, or to ban temporarily or 65 | permanently any contributor for other behaviors that they deem inappropriate, 66 | threatening, offensive, or harmful. 67 | 68 | ## Scope 69 | 70 | This Code of Conduct applies both within project spaces and in public spaces 71 | when an individual is representing the project or its community. Examples of 72 | representing a project or community include using an official project email 73 | address, posting via an official social media account, or acting as an appointed 74 | representative at an online or offline event. Representation of a project may be 75 | further defined and clarified by project maintainers. 76 | 77 | ## Enforcement 78 | 79 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 80 | reported by contacting the Salesforce Open Source Conduct Committee 81 | at ossconduct@salesforce.com. All complaints will be reviewed and investigated 82 | and will result in a response that is deemed necessary and appropriate to the 83 | circumstances. The committee is obligated to maintain confidentiality with 84 | regard to the reporter of an incident. Further details of specific enforcement 85 | policies may be posted separately. 86 | 87 | Project maintainers who do not follow or enforce the Code of Conduct in good 88 | faith may face temporary or permanent repercussions as determined by other 89 | members of the project's leadership and the Salesforce Open Source Conduct 90 | Committee. 91 | 92 | ## Attribution 93 | 94 | This Code of Conduct is adapted from the [Contributor Covenant][contributor-covenant-home], 95 | version 1.4, available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html. 96 | It includes adaptions and additions from [Go Community Code of Conduct][golang-coc], 97 | [CNCF Code of Conduct][cncf-coc], and [Microsoft Open Source Code of Conduct][microsoft-coc]. 98 | 99 | This Code of Conduct is licensed under the [Creative Commons Attribution 3.0 License][cc-by-3-us]. 100 | 101 | [contributor-covenant-home]: https://www.contributor-covenant.org 'https://www.contributor-covenant.org/' 102 | [golang-coc]: https://golang.org/conduct 103 | [cncf-coc]: https://github.com/cncf/foundation/blob/master/code-of-conduct.md 104 | [microsoft-coc]: https://opensource.microsoft.com/codeofconduct/ 105 | [cc-by-3-us]: https://creativecommons.org/licenses/by/3.0/us/ 106 | -------------------------------------------------------------------------------- /src/commands/org/login/sfdx-url.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2025, Salesforce, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import fs from 'node:fs/promises'; 18 | import { Flags, SfCommand, loglevel } from '@salesforce/sf-plugins-core'; 19 | import { AuthFields, AuthInfo, Messages } from '@salesforce/core'; 20 | import { AnyJson } from '@salesforce/ts-types'; 21 | import { parseJson } from '@salesforce/kit'; 22 | import common from '../../../common.js'; 23 | 24 | Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); 25 | const messages = Messages.loadMessages('@salesforce/plugin-auth', 'sfdxurl.store'); 26 | const commonMessages = Messages.loadMessages('@salesforce/plugin-auth', 'messages'); 27 | 28 | const AUTH_URL_FORMAT = 'force://::@'; 29 | 30 | type AuthJson = AnyJson & { 31 | result?: AnyJson & { sfdxAuthUrl: string }; 32 | sfdxAuthUrl: string; 33 | }; 34 | export default class LoginSfdxUrl extends SfCommand { 35 | public static readonly summary = messages.getMessage('summary'); 36 | public static readonly description = messages.getMessage('description', [AUTH_URL_FORMAT]); 37 | public static readonly examples = messages.getMessages('examples'); 38 | public static readonly aliases = ['force:auth:sfdxurl:store', 'auth:sfdxurl:store']; 39 | public static readonly deprecateAliases = true; 40 | 41 | public static readonly flags = { 42 | 'sfdx-url-file': Flags.file({ 43 | char: 'f', 44 | summary: messages.getMessage('flags.sfdx-url-file.summary'), 45 | required: false, 46 | deprecateAliases: true, 47 | aliases: ['sfdxurlfile'], 48 | exactlyOne: ['sfdx-url-file', 'sfdx-url-stdin'], 49 | }), 50 | 'sfdx-url-stdin': Flags.file({ 51 | char: 'u', 52 | summary: messages.getMessage('flags.sfdx-url-stdin.summary'), 53 | required: false, 54 | deprecateAliases: true, 55 | aliases: ['sfdxurlstdin'], 56 | allowStdin: 'only', 57 | exactlyOne: ['sfdx-url-file', 'sfdx-url-stdin'], 58 | }), 59 | 'set-default-dev-hub': Flags.boolean({ 60 | char: 'd', 61 | summary: commonMessages.getMessage('flags.set-default-dev-hub.summary'), 62 | deprecateAliases: true, 63 | aliases: ['setdefaultdevhub', 'setdefaultdevhubusername'], 64 | }), 65 | 'set-default': Flags.boolean({ 66 | char: 's', 67 | summary: commonMessages.getMessage('flags.set-default.summary'), 68 | deprecateAliases: true, 69 | aliases: ['setdefaultusername'], 70 | }), 71 | alias: Flags.string({ 72 | char: 'a', 73 | summary: commonMessages.getMessage('flags.alias.summary'), 74 | deprecateAliases: true, 75 | aliases: ['setalias'], 76 | }), 77 | 'no-prompt': Flags.boolean({ 78 | char: 'p', 79 | summary: commonMessages.getMessage('flags.no-prompt.summary'), 80 | required: false, 81 | hidden: true, 82 | deprecateAliases: true, 83 | aliases: ['noprompt'], 84 | }), 85 | loglevel, 86 | }; 87 | 88 | public async run(): Promise { 89 | const { flags } = await this.parse(LoginSfdxUrl); 90 | if (await common.shouldExitCommand(flags['no-prompt'])) return {}; 91 | 92 | const authFile = flags['sfdx-url-file']; 93 | const authStdin = flags['sfdx-url-stdin']; 94 | let sfdxAuthUrl: string; 95 | 96 | if (authFile) { 97 | sfdxAuthUrl = authFile.endsWith('.json') ? await getUrlFromJson(authFile) : await fs.readFile(authFile, 'utf8'); 98 | 99 | if (!sfdxAuthUrl) { 100 | throw new Error( 101 | `Error getting the SFDX authorization URL from file ${authFile}. Ensure it meets the description shown in the documentation (--help) for this command.` 102 | ); 103 | } 104 | } else if (authStdin) { 105 | sfdxAuthUrl = authStdin; 106 | } else { 107 | throw new Error('SFDX authorization URL not found.'); 108 | } 109 | 110 | const oauth2Options = AuthInfo.parseSfdxAuthUrl(sfdxAuthUrl); 111 | 112 | const authInfo = await AuthInfo.create({ oauth2Options }); 113 | await authInfo.save(); 114 | 115 | await authInfo.handleAliasAndDefaultSettings({ 116 | alias: flags.alias, 117 | setDefault: flags['set-default'], 118 | setDefaultDevHub: flags['set-default-dev-hub'], 119 | }); 120 | 121 | // ensure the clientSecret field... even if it is empty 122 | const result = { clientSecret: '', ...authInfo.getFields(true) }; 123 | await AuthInfo.identifyPossibleScratchOrgs(result, authInfo); 124 | 125 | const successMsg = commonMessages.getMessage('authorizeCommandSuccess', [result.username, result.orgId]); 126 | this.logSuccess(successMsg); 127 | return result; 128 | } 129 | } 130 | 131 | const getUrlFromJson = async (authFile: string): Promise => { 132 | const jsonContents = await fs.readFile(authFile, 'utf8'); 133 | const authFileJson = parseJson(jsonContents) as AuthJson; 134 | return authFileJson.result?.sfdxAuthUrl ?? authFileJson.sfdxAuthUrl; 135 | }; 136 | -------------------------------------------------------------------------------- /src/commands/org/login/access-token.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2025, Salesforce, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { Flags, loglevel, SfCommand } from '@salesforce/sf-plugins-core'; 18 | import { AuthFields, AuthInfo, Messages, matchesAccessToken, SfError, StateAggregator } from '@salesforce/core'; 19 | import { env } from '@salesforce/kit'; 20 | import { InferredFlags } from '@oclif/core/interfaces'; 21 | 22 | Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); 23 | const messages = Messages.loadMessages('@salesforce/plugin-auth', 'accesstoken.store'); 24 | const commonMessages = Messages.loadMessages('@salesforce/plugin-auth', 'messages'); 25 | 26 | const ACCESS_TOKEN_FORMAT = '"!"'; 27 | 28 | export default class LoginAccessToken extends SfCommand { 29 | public static readonly summary = messages.getMessage('summary'); 30 | public static readonly description = messages.getMessage('description'); 31 | public static readonly examples = messages.getMessages('examples'); 32 | public static readonly deprecateAliases = true; 33 | public static readonly aliases = ['force:auth:accesstoken:store', 'auth:accesstoken:store']; 34 | 35 | public static readonly flags = { 36 | 'instance-url': Flags.url({ 37 | char: 'r', 38 | summary: commonMessages.getMessage('flags.instance-url.summary'), 39 | description: commonMessages.getMessage('flags.instance-url.description'), 40 | required: true, 41 | deprecateAliases: true, 42 | aliases: ['instanceurl'], 43 | }), 44 | 'set-default-dev-hub': Flags.boolean({ 45 | char: 'd', 46 | summary: commonMessages.getMessage('flags.set-default-dev-hub.summary'), 47 | default: false, 48 | deprecateAliases: true, 49 | aliases: ['setdefaultdevhub', 'setdefaultdevhubusername'], 50 | }), 51 | 'set-default': Flags.boolean({ 52 | char: 's', 53 | summary: commonMessages.getMessage('flags.set-default.summary'), 54 | default: false, 55 | deprecateAliases: true, 56 | aliases: ['setdefaultusername'], 57 | }), 58 | alias: Flags.string({ 59 | char: 'a', 60 | summary: commonMessages.getMessage('flags.alias.summary'), 61 | deprecateAliases: true, 62 | aliases: ['setalias'], 63 | }), 64 | 'no-prompt': Flags.boolean({ 65 | char: 'p', 66 | summary: commonMessages.getMessage('flags.no-prompt.summary'), 67 | required: false, 68 | default: false, 69 | deprecateAliases: true, 70 | aliases: ['noprompt'], 71 | }), 72 | loglevel, 73 | }; 74 | 75 | private flags!: InferredFlags; 76 | 77 | public async run(): Promise { 78 | const { flags } = await this.parse(LoginAccessToken); 79 | this.flags = flags; 80 | const instanceUrl = flags['instance-url'].href; 81 | const accessToken = await this.getAccessToken(); 82 | const authInfo = await this.getUserInfo(accessToken, instanceUrl); 83 | return this.storeAuthFromAccessToken(authInfo); 84 | } 85 | 86 | // because stubbed on the test (instead of stubbing in core) 87 | // eslint-disable-next-line class-methods-use-this 88 | private async getUserInfo(accessToken: string, instanceUrl: string): Promise { 89 | return AuthInfo.create({ accessTokenOptions: { accessToken, instanceUrl, loginUrl: instanceUrl } }); 90 | } 91 | 92 | private async storeAuthFromAccessToken(authInfo: AuthInfo): Promise { 93 | if (await this.overwriteAuthInfo(authInfo.getUsername())) { 94 | await this.saveAuthInfo(authInfo); 95 | const successMsg = commonMessages.getMessage('authorizeCommandSuccess', [ 96 | authInfo.getUsername(), 97 | authInfo.getFields(true).orgId, 98 | ]); 99 | this.logSuccess(successMsg); 100 | } 101 | return authInfo.getFields(true); 102 | } 103 | 104 | private async saveAuthInfo(authInfo: AuthInfo): Promise { 105 | await authInfo.save(); 106 | await authInfo.handleAliasAndDefaultSettings({ 107 | alias: this.flags.alias, 108 | setDefault: this.flags['set-default'], 109 | setDefaultDevHub: this.flags['set-default-dev-hub'], 110 | }); 111 | await AuthInfo.identifyPossibleScratchOrgs(authInfo.getFields(true), authInfo); 112 | } 113 | 114 | private async overwriteAuthInfo(username: string): Promise { 115 | if (!this.flags['no-prompt']) { 116 | const stateAggregator = await StateAggregator.getInstance(); 117 | if (await stateAggregator.orgs.exists(username)) { 118 | return this.confirm({ message: messages.getMessage('overwriteAccessTokenAuthUserFile', [username]) }); 119 | } 120 | } 121 | return true; 122 | } 123 | 124 | private async getAccessToken(): Promise { 125 | const accessToken = 126 | env.getString('SF_ACCESS_TOKEN') ?? 127 | env.getString('SFDX_ACCESS_TOKEN') ?? 128 | (this.flags['no-prompt'] === true 129 | ? '' // will throw when validating 130 | : await this.secretPrompt({ message: commonMessages.getMessage('accessTokenStdin') })); 131 | if (!matchesAccessToken(accessToken)) { 132 | throw new SfError(messages.getMessage('invalidAccessTokenFormat', [ACCESS_TOKEN_FORMAT])); 133 | } 134 | return accessToken; 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /test/commands/org/login/login.device.test.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2025, Salesforce, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | /* eslint-disable camelcase */ 18 | 19 | import { type AuthFields, AuthInfo, type DeviceCodeResponse, DeviceOauthService } from '@salesforce/core'; 20 | import { MockTestOrgData, TestContext } from '@salesforce/core/testSetup'; 21 | import { StubbedType, stubInterface, stubMethod } from '@salesforce/ts-sinon'; 22 | import { expect } from 'chai'; 23 | import { SfCommand, stubUx } from '@salesforce/sf-plugins-core'; 24 | import Login from '../../../../src/commands/org/login/device.js'; 25 | 26 | type Options = { 27 | approvalTimesout?: boolean; 28 | approvalFails?: boolean; 29 | }; 30 | 31 | describe('org:login:device', () => { 32 | const $$ = new TestContext(); 33 | 34 | const testData = new MockTestOrgData(); 35 | const mockAction: DeviceCodeResponse = { 36 | device_code: '1234', 37 | interval: 5000, 38 | user_code: '1234', 39 | verification_uri: 'https://login.salesforce.com', 40 | }; 41 | 42 | let authFields: AuthFields; 43 | let authInfoStub: StubbedType; 44 | 45 | async function prepareStubs(options: Options = {}): Promise { 46 | authFields = await testData.getConfig(); 47 | delete authFields.isDevHub; 48 | 49 | authInfoStub = stubInterface($$.SANDBOX, { 50 | getFields: () => authFields, 51 | }); 52 | 53 | stubMethod($$.SANDBOX, DeviceOauthService.prototype, 'requestDeviceLogin').returns(Promise.resolve(mockAction)); 54 | 55 | if (options.approvalFails) { 56 | stubMethod($$.SANDBOX, DeviceOauthService.prototype, 'awaitDeviceApproval').returns(Promise.resolve()); 57 | } 58 | 59 | if (options.approvalTimesout) { 60 | stubMethod($$.SANDBOX, DeviceOauthService.prototype, 'pollForDeviceApproval').throws('polling timeout'); 61 | } else { 62 | stubMethod($$.SANDBOX, DeviceOauthService.prototype, 'pollForDeviceApproval').resolves({ 63 | access_token: '1234', 64 | refresh_token: '1234', 65 | signature: '1234', 66 | scope: '1234', 67 | instance_url: 'https://login.salesforce.com', 68 | id: '1234', 69 | token_type: '1234', 70 | issued_at: '1234', 71 | }); 72 | } 73 | 74 | stubMethod($$.SANDBOX, AuthInfo, 'create').resolves(authInfoStub); 75 | await $$.stubAuths(testData); 76 | stubUx($$.SANDBOX); 77 | stubMethod($$.SANDBOX, SfCommand.prototype, 'logSuccess'); 78 | } 79 | 80 | it('should return auth fields', async () => { 81 | await prepareStubs(); 82 | const response = await Login.run(['--json']); 83 | expect(response.username).to.equal(testData.username); 84 | }); 85 | 86 | it('should return auth fields with instance url', async () => { 87 | await prepareStubs(); 88 | const response = await Login.run(['-r', 'https://login.salesforce.com', '--json']); 89 | expect(response.username).to.equal(testData.username); 90 | }); 91 | 92 | it('should set alias when -a is provided', async () => { 93 | await prepareStubs(); 94 | const response = await Login.run(['-a', 'MyAlias', '--json']); 95 | expect(response.username).to.equal(testData.username); 96 | expect(authInfoStub.handleAliasAndDefaultSettings.callCount).to.equal(1); 97 | }); 98 | 99 | it('should set target-org when -s is provided', async () => { 100 | await prepareStubs(); 101 | const response = await Login.run(['-s', '--json']); 102 | expect(response.username).to.equal(testData.username); 103 | expect(authInfoStub.handleAliasAndDefaultSettings.callCount).to.equal(1); 104 | }); 105 | 106 | it('should set target-dev-hub when -d is provided', async () => { 107 | await prepareStubs(); 108 | const response = await Login.run(['-d', '--json']); 109 | expect(response.username).to.equal(testData.username); 110 | expect(authInfoStub.handleAliasAndDefaultSettings.callCount).to.equal(1); 111 | }); 112 | 113 | it('show required action in human readable output', async () => { 114 | await prepareStubs(); 115 | const styledHeaderSpy = $$.SANDBOX.spy(SfCommand.prototype, 'styledHeader'); 116 | const logSpy = $$.SANDBOX.spy(SfCommand.prototype, 'log'); 117 | await Login.run([]); 118 | expect(styledHeaderSpy.calledOnce).to.be.true; 119 | expect(logSpy.callCount).to.be.greaterThan(0); 120 | }); 121 | 122 | it('should gracefully handle approval timeout', async () => { 123 | await prepareStubs({ approvalTimesout: true }); 124 | try { 125 | const response = await Login.run(['--json']); 126 | expect.fail(`should have thrown: ${JSON.stringify(response)}`); 127 | } catch (e) { 128 | expect((e as Error).name).to.equal('polling timeout'); 129 | } 130 | }); 131 | 132 | it('should gracefully handle failed approval', async () => { 133 | await prepareStubs({ approvalFails: true }); 134 | const response = await Login.run(['--json']); 135 | expect(response).to.deep.equal({}); 136 | }); 137 | 138 | it('should prompt for client secret if client id is provided', async () => { 139 | await prepareStubs(); 140 | $$.SANDBOX.stub(SfCommand.prototype, 'secretPrompt').resolves('1234'); 141 | const response = await Login.run(['-i', 'CoffeeBeans', '--json']); 142 | expect(response.username).to.equal(testData.username); 143 | }); 144 | }); 145 | -------------------------------------------------------------------------------- /src/commands/org/login/jwt.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2025, Salesforce, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { Flags, SfCommand, loglevel } from '@salesforce/sf-plugins-core'; 18 | import { AuthFields, AuthInfo, AuthRemover, Logger, Messages, SfError } from '@salesforce/core'; 19 | import { Interfaces } from '@oclif/core'; 20 | import common from '../../../common.js'; 21 | 22 | Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); 23 | const messages = Messages.loadMessages('@salesforce/plugin-auth', 'jwt.grant'); 24 | const commonMessages = Messages.loadMessages('@salesforce/plugin-auth', 'messages'); 25 | 26 | export default class LoginJwt extends SfCommand { 27 | public static readonly summary = messages.getMessage('summary'); 28 | public static readonly description = messages.getMessage('description'); 29 | public static readonly examples = messages.getMessages('examples'); 30 | public static readonly aliases = ['force:auth:jwt:grant', 'auth:jwt:grant']; 31 | public static readonly deprecateAliases = true; 32 | 33 | public static readonly flags = { 34 | username: Flags.string({ 35 | // eslint-disable-next-line sf-plugin/dash-o 36 | char: 'o', 37 | summary: messages.getMessage('flags.username.summary'), 38 | required: true, 39 | deprecateAliases: true, 40 | aliases: ['u'], 41 | }), 42 | 'jwt-key-file': Flags.file({ 43 | char: 'f', 44 | summary: messages.getMessage('flags.jwt-key-file.summary'), 45 | required: true, 46 | deprecateAliases: true, 47 | aliases: ['jwtkeyfile', 'keyfile'], 48 | }), 49 | 'client-id': Flags.string({ 50 | char: 'i', 51 | summary: commonMessages.getMessage('flags.client-id.summary'), 52 | required: true, 53 | deprecateAliases: true, 54 | aliases: ['clientid'], 55 | }), 56 | 'instance-url': Flags.url({ 57 | char: 'r', 58 | summary: commonMessages.getMessage('flags.instance-url.summary'), 59 | description: commonMessages.getMessage('flags.instance-url.description'), 60 | deprecateAliases: true, 61 | aliases: ['instanceurl', 'l'], 62 | }), 63 | 'set-default-dev-hub': Flags.boolean({ 64 | char: 'd', 65 | summary: commonMessages.getMessage('flags.set-default-dev-hub.summary'), 66 | deprecateAliases: true, 67 | aliases: ['setdefaultdevhub', 'setdefaultdevhubusername', 'v'], 68 | }), 69 | 'set-default': Flags.boolean({ 70 | char: 's', 71 | summary: commonMessages.getMessage('flags.set-default.summary'), 72 | deprecateAliases: true, 73 | aliases: ['setdefaultusername'], 74 | }), 75 | alias: Flags.string({ 76 | char: 'a', 77 | summary: commonMessages.getMessage('flags.alias.summary'), 78 | deprecateAliases: true, 79 | aliases: ['setalias'], 80 | }), 81 | 'no-prompt': Flags.boolean({ 82 | char: 'p', 83 | summary: commonMessages.getMessage('flags.no-prompt.summary'), 84 | required: false, 85 | hidden: true, 86 | deprecateAliases: true, 87 | aliases: ['noprompt'], 88 | }), 89 | loglevel, 90 | }; 91 | private flags!: Interfaces.InferredFlags; 92 | private logger = Logger.childFromRoot(this.constructor.name); 93 | 94 | public async run(): Promise { 95 | const { flags } = await this.parse(LoginJwt); 96 | this.flags = flags; 97 | let result: AuthFields = {}; 98 | 99 | if (await common.shouldExitCommand(flags['no-prompt'])) return {}; 100 | 101 | try { 102 | const authInfo = await this.initAuthInfo(); 103 | await authInfo.handleAliasAndDefaultSettings({ 104 | alias: flags.alias, 105 | setDefault: flags['set-default'], 106 | setDefaultDevHub: flags['set-default-dev-hub'], 107 | }); 108 | result = authInfo.getFields(true); 109 | await AuthInfo.identifyPossibleScratchOrgs(result, authInfo); 110 | } catch (err) { 111 | const msg = err instanceof Error ? `${err.name}::${err.message}` : typeof err === 'string' ? err : 'UNKNOWN'; 112 | throw SfError.create({ 113 | message: messages.getMessage('JwtGrantError', [msg]), 114 | name: 'JwtGrantError', 115 | ...(err instanceof Error ? { cause: err } : {}), 116 | }); 117 | } 118 | 119 | const successMsg = commonMessages.getMessage('authorizeCommandSuccess', [result.username, result.orgId]); 120 | this.logSuccess(successMsg); 121 | return result; 122 | } 123 | 124 | private async initAuthInfo(): Promise { 125 | const oauth2OptionsBase = { 126 | clientId: this.flags['client-id'], 127 | privateKeyFile: this.flags['jwt-key-file'], 128 | }; 129 | 130 | const loginUrl = await common.resolveLoginUrl(this.flags['instance-url']?.href); 131 | 132 | const oauth2Options = loginUrl ? Object.assign(oauth2OptionsBase, { loginUrl }) : oauth2OptionsBase; 133 | 134 | let authInfo: AuthInfo; 135 | try { 136 | authInfo = await AuthInfo.create({ 137 | username: this.flags.username, 138 | oauth2Options, 139 | }); 140 | } catch (error) { 141 | const err = error as SfError; 142 | if (err.name === 'AuthInfoOverwriteError') { 143 | this.logger.debug('Auth file already exists. Removing and starting fresh.'); 144 | const remover = await AuthRemover.create(); 145 | await remover.removeAuth(this.flags.username); 146 | authInfo = await AuthInfo.create({ 147 | username: this.flags.username, 148 | oauth2Options, 149 | }); 150 | } else { 151 | throw err; 152 | } 153 | } 154 | await authInfo.save(); 155 | return authInfo; 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /test/commands/org/login/access-token.test.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2025, Salesforce, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { AuthFields, AuthInfo, StateAggregator } from '@salesforce/core'; 18 | import { assert, expect } from 'chai'; 19 | import { TestContext } from '@salesforce/core/testSetup'; 20 | import { stubPrompter, stubSfCommandUx } from '@salesforce/sf-plugins-core'; 21 | import { env } from '@salesforce/kit'; 22 | import Store from '../../../../src/commands/org/login/access-token.js'; 23 | 24 | describe('org:login:access-token', () => { 25 | const $$ = new TestContext(); 26 | const accessToken = '00Dxx0000000000!xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'; 27 | const authFields = { 28 | accessToken, 29 | instanceUrl: 'https://foo.bar.org.salesforce.com', 30 | loginUrl: 'https://foo.bar.org.salesforce.com', 31 | orgId: '00D000000000000', 32 | username: 'foo@baz.org', 33 | } as const satisfies AuthFields; 34 | 35 | /* eslint-disable camelcase */ 36 | const userInfo = { 37 | preferred_username: 'foo@baz.org', 38 | organization_id: '00D000000000000', 39 | custom_domain: 'https://foo.bar.org.salesforce.com', 40 | }; 41 | /* eslint-enable camelcase */ 42 | let stubSfCommandUxStubs: ReturnType; 43 | let prompterStubs: ReturnType; 44 | 45 | beforeEach(() => { 46 | // @ts-expect-error because private method 47 | $$.SANDBOX.stub(Store.prototype, 'saveAuthInfo').resolves(userInfo); 48 | $$.SANDBOX.stub(AuthInfo.prototype, 'getUsername').returns(authFields.username); 49 | $$.SANDBOX.stub(AuthInfo.prototype, 'getFields').returns({ 50 | accessToken, 51 | orgId: authFields.orgId, 52 | instanceUrl: authFields.instanceUrl, 53 | loginUrl: authFields.loginUrl, 54 | username: authFields.username, 55 | }); 56 | // @ts-expect-error because private method 57 | $$.SANDBOX.stub(Store.prototype, 'getUserInfo').resolves(AuthInfo.prototype); 58 | stubSfCommandUxStubs = stubSfCommandUx($$.SANDBOX); 59 | prompterStubs = stubPrompter($$.SANDBOX); 60 | }); 61 | 62 | it('should return auth fields after successful auth', async () => { 63 | prompterStubs.secret.resolves(accessToken); 64 | 65 | const result = await Store.run(['--instance-url', 'https://foo.bar.org.salesforce.com']); 66 | expect(prompterStubs.secret.callCount).to.equal(1); 67 | expect(stubSfCommandUxStubs.logSuccess.callCount).to.equal(1); 68 | expect(result).to.deep.equal(authFields); 69 | }); 70 | 71 | it('should show invalid access token provided as input', async () => { 72 | prompterStubs.secret.resolves('invalidaccesstokenformat'); 73 | 74 | try { 75 | await Store.run(['--instance-url', 'https://foo.bar.org.salesforce.com']); 76 | assert(false, 'should throw error'); 77 | } catch (e) { 78 | assert(e instanceof Error); 79 | expect(e.message).to.include("The access token isn't in the correct format"); 80 | } 81 | expect(prompterStubs.secret.callCount).to.equal(1); 82 | }); 83 | 84 | it('should show that auth file already exists', async () => { 85 | prompterStubs.secret.resolves(accessToken); 86 | prompterStubs.confirm.resolves(false); 87 | $$.SANDBOX.stub(StateAggregator, 'getInstance').resolves({ 88 | // @ts-expect-error because incomplete interface 89 | orgs: { 90 | exists: () => Promise.resolve(true), 91 | }, 92 | }); 93 | const result = await Store.run(['--instance-url', 'https://foo.bar.org.salesforce.com']); 94 | expect(result).to.deep.equal(authFields); 95 | expect(prompterStubs.secret.callCount).to.equal(1); 96 | expect(prompterStubs.confirm.callCount).to.equal(1); 97 | }); 98 | 99 | it('should show that auth file does not already exist', async () => { 100 | prompterStubs.secret.resolves(accessToken); 101 | $$.SANDBOX.stub(StateAggregator, 'getInstance').resolves({ 102 | // @ts-expect-error because incomplete interface 103 | orgs: { 104 | exists: () => Promise.resolve(false), 105 | }, 106 | }); 107 | const result = await Store.run(['--instance-url', 'https://foo.bar.org.salesforce.com']); 108 | expect(result).to.deep.equal(authFields); 109 | expect(prompterStubs.confirm.callCount).to.equal(0); 110 | }); 111 | 112 | it('should use env var SF_ACCESS_TOKEN as input to the store command', async () => { 113 | $$.SANDBOX.stub(env, 'getString') 114 | .withArgs('SF_ACCESS_TOKEN') 115 | .returns(accessToken) 116 | .withArgs('SFDX_ACCESS_TOKEN') 117 | // @ts-expect-error not sure why TS thinks a string is required. getString can return undefined 118 | .returns(undefined); 119 | 120 | const result = await Store.run(['--instance-url', 'https://foo.bar.org.salesforce.com']); 121 | expect(result).to.deep.equal(authFields); 122 | // no prompts needed when using Env 123 | expect(prompterStubs.confirm.callCount).to.equal(0); 124 | expect(prompterStubs.secret.callCount).to.equal(0); 125 | }); 126 | 127 | it('should use env var SFDX_ACCESS_TOKEN as input to the store command', async () => { 128 | $$.SANDBOX.stub(env, 'getString') 129 | .withArgs('SFDX_ACCESS_TOKEN') 130 | .returns(accessToken) 131 | .withArgs('SF_ACCESS_TOKEN') 132 | // @ts-expect-error not sure why TS thinks a string is required. getString can return undefined 133 | .returns(undefined); 134 | 135 | const result = await Store.run(['--instance-url', 'https://foo.bar.org.salesforce.com']); 136 | expect(result).to.deep.equal(authFields); 137 | // no prompts needed when using Env 138 | expect(prompterStubs.confirm.callCount).to.equal(0); 139 | expect(prompterStubs.secret.callCount).to.equal(0); 140 | }); 141 | }); 142 | -------------------------------------------------------------------------------- /schemas/org-login-device.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema#", 3 | "$ref": "#/definitions/DeviceLoginResult", 4 | "definitions": { 5 | "DeviceLoginResult": { 6 | "anyOf": [ 7 | { 8 | "type": "object", 9 | "additionalProperties": { 10 | "$ref": "#/definitions/Optional%3CAnyJson%3E" 11 | }, 12 | "properties": { 13 | "device_code": { 14 | "type": "string" 15 | }, 16 | "interval": { 17 | "type": "number" 18 | }, 19 | "user_code": { 20 | "type": "string" 21 | }, 22 | "verification_uri": { 23 | "type": "string" 24 | }, 25 | "clientApps": { 26 | "type": "object", 27 | "additionalProperties": { 28 | "type": "object", 29 | "properties": { 30 | "clientId": { 31 | "type": "string" 32 | }, 33 | "clientSecret": { 34 | "type": "string" 35 | }, 36 | "accessToken": { 37 | "type": "string" 38 | }, 39 | "refreshToken": { 40 | "type": "string" 41 | }, 42 | "oauthFlow": { 43 | "type": "string", 44 | "const": "web" 45 | } 46 | }, 47 | "required": ["clientId", "accessToken", "refreshToken", "oauthFlow"], 48 | "additionalProperties": false 49 | } 50 | }, 51 | "accessToken": { 52 | "type": "string" 53 | }, 54 | "alias": { 55 | "type": "string" 56 | }, 57 | "authCode": { 58 | "type": "string" 59 | }, 60 | "clientId": { 61 | "type": "string" 62 | }, 63 | "clientSecret": { 64 | "type": "string" 65 | }, 66 | "created": { 67 | "type": "string" 68 | }, 69 | "createdOrgInstance": { 70 | "type": "string" 71 | }, 72 | "devHubUsername": { 73 | "type": "string" 74 | }, 75 | "instanceUrl": { 76 | "type": "string" 77 | }, 78 | "instanceApiVersion": { 79 | "type": "string" 80 | }, 81 | "instanceApiVersionLastRetrieved": { 82 | "type": "string" 83 | }, 84 | "isDevHub": { 85 | "type": "boolean" 86 | }, 87 | "loginUrl": { 88 | "type": "string" 89 | }, 90 | "orgId": { 91 | "type": "string" 92 | }, 93 | "password": { 94 | "type": "string" 95 | }, 96 | "privateKey": { 97 | "type": "string" 98 | }, 99 | "refreshToken": { 100 | "type": "string" 101 | }, 102 | "scratchAdminUsername": { 103 | "type": "string" 104 | }, 105 | "snapshot": { 106 | "type": "string" 107 | }, 108 | "userId": { 109 | "type": "string" 110 | }, 111 | "username": { 112 | "type": "string" 113 | }, 114 | "usernames": { 115 | "type": "array", 116 | "items": { 117 | "type": "string" 118 | } 119 | }, 120 | "userProfileName": { 121 | "type": "string" 122 | }, 123 | "expirationDate": { 124 | "type": "string" 125 | }, 126 | "tracksSource": { 127 | "type": "boolean" 128 | }, 129 | "name": { 130 | "type": "string" 131 | }, 132 | "instanceName": { 133 | "type": "string" 134 | }, 135 | "namespacePrefix": { 136 | "type": ["string", "null"] 137 | }, 138 | "isSandbox": { 139 | "type": "boolean" 140 | }, 141 | "isScratch": { 142 | "type": "boolean" 143 | }, 144 | "trailExpirationDate": { 145 | "type": ["string", "null"] 146 | } 147 | }, 148 | "required": ["device_code", "interval", "user_code", "verification_uri"] 149 | }, 150 | { 151 | "type": "object", 152 | "additionalProperties": { 153 | "not": {} 154 | } 155 | } 156 | ] 157 | }, 158 | "Optional": { 159 | "anyOf": [ 160 | { 161 | "$ref": "#/definitions/AnyJson" 162 | }, 163 | { 164 | "not": {} 165 | } 166 | ], 167 | "description": "A union type for either the parameterized type `T` or `undefined` -- the opposite of {@link NonOptional } ." 168 | }, 169 | "AnyJson": { 170 | "anyOf": [ 171 | { 172 | "$ref": "#/definitions/JsonPrimitive" 173 | }, 174 | { 175 | "$ref": "#/definitions/JsonCollection" 176 | } 177 | ], 178 | "description": "Any valid JSON value." 179 | }, 180 | "JsonPrimitive": { 181 | "type": ["null", "boolean", "number", "string"], 182 | "description": "Any valid JSON primitive value." 183 | }, 184 | "JsonCollection": { 185 | "anyOf": [ 186 | { 187 | "$ref": "#/definitions/JsonMap" 188 | }, 189 | { 190 | "$ref": "#/definitions/JsonArray" 191 | } 192 | ], 193 | "description": "Any valid JSON collection value." 194 | }, 195 | "JsonMap": { 196 | "type": "object", 197 | "additionalProperties": { 198 | "$ref": "#/definitions/Optional%3CAnyJson%3E" 199 | }, 200 | "properties": {}, 201 | "description": "Any JSON-compatible object." 202 | }, 203 | "JsonArray": { 204 | "type": "array", 205 | "items": { 206 | "$ref": "#/definitions/AnyJson" 207 | }, 208 | "description": "Any JSON-compatible array." 209 | } 210 | } 211 | } 212 | -------------------------------------------------------------------------------- /test/commands/org/logout.test.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2025, Salesforce, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { AuthRemover, ConfigContents, Global, Mode, Messages } from '@salesforce/core'; 18 | import { MockTestOrgData, TestContext } from '@salesforce/core/testSetup'; 19 | import { expect } from 'chai'; 20 | import { stubPrompter, stubUx } from '@salesforce/sf-plugins-core'; 21 | import Logout from '../../../src/commands/org/logout.js'; 22 | 23 | Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); 24 | const messages = Messages.loadMessages('@salesforce/plugin-auth', 'logout'); 25 | 26 | type Options = { 27 | authFiles?: string[]; 28 | 'target-org'?: string; 29 | 'target-dev-hub'?: string; 30 | aliases?: { 31 | [key: string]: string; 32 | }; 33 | authInfoConfigFails?: boolean; 34 | authInfoConfigDoesNotExist?: boolean; 35 | }; 36 | 37 | describe('org:logout', () => { 38 | const $$ = new TestContext(); 39 | const testOrg1 = new MockTestOrgData(); 40 | const testOrg2 = new MockTestOrgData(); 41 | const testOrg3 = new MockTestOrgData(); 42 | let promptStub: ReturnType; 43 | let authRemoverSpy: sinon.SinonSpy; 44 | 45 | async function prepareStubs(options: Options = {}): Promise { 46 | const authInfo = await testOrg1.getConfig(); 47 | promptStub = stubPrompter($$.SANDBOX); 48 | authRemoverSpy = $$.SANDBOX.spy(AuthRemover.prototype, 'removeAuth'); 49 | 50 | if (!options.authInfoConfigDoesNotExist) { 51 | await $$.stubAuths(testOrg1, testOrg2, testOrg3); 52 | } 53 | 54 | if (options['target-org']) { 55 | $$.setConfigStubContents('Config', { contents: { 'target-org': options['target-org'] } }); 56 | } else { 57 | $$.setConfigStubContents('Config', { contents: {} }); 58 | } 59 | 60 | if (options.aliases) { 61 | $$.stubAliases(options.aliases); 62 | } 63 | 64 | stubUx($$.SANDBOX); 65 | 66 | return authInfo; 67 | } 68 | 69 | it('should throw error when both -a and -o are specified', async () => { 70 | await prepareStubs(); 71 | try { 72 | await Logout.run(['-a', '-o', testOrg1.username, '--json']); 73 | } catch (e) { 74 | const error = e as Error; 75 | expect(error.name).to.equal('Error'); 76 | expect(error.message).to.include('cannot also be provided when using --all'); 77 | } 78 | }); 79 | 80 | it('should remove target-org when neither -a nor -o are specified', async () => { 81 | await prepareStubs({ 'target-org': testOrg1.username }); 82 | const response = await Logout.run(['-p', '--json']); 83 | 84 | expect(response).to.deep.equal([testOrg1.username]); 85 | expect(authRemoverSpy.callCount).to.equal(1); 86 | }); 87 | 88 | it('should remove username specified by -o', async () => { 89 | await prepareStubs({ 'target-org': testOrg1.username }); 90 | const response = await Logout.run(['-p', '-o', testOrg1.username, '--json']); 91 | expect(response).to.deep.equal([testOrg1.username]); 92 | expect(authRemoverSpy.callCount).to.equal(1); 93 | }); 94 | 95 | it('should remove all usernames when -a is specified', async () => { 96 | await prepareStubs(); 97 | const response = await Logout.run(['-p', '-a', '--json']); 98 | expect(response).to.deep.equal([testOrg1.username, testOrg2.username, testOrg3.username]); 99 | expect(authRemoverSpy.callCount).to.equal(3); 100 | }); 101 | 102 | it('should remove all usernames when in demo mode', async () => { 103 | await prepareStubs(); 104 | $$.SANDBOX.stub(Global, 'getEnvironmentMode').returns(Mode.DEMO); 105 | const response = await Logout.run(['-p', '-a', '--json']); 106 | expect(response).to.deep.equal([testOrg1.username, testOrg2.username, testOrg3.username]); 107 | expect(authRemoverSpy.callCount).to.equal(3); 108 | }); 109 | 110 | it('should throw error if no target-org', async () => { 111 | await prepareStubs(); 112 | try { 113 | const response = await Logout.run(['-p', '--json']); 114 | expect.fail(`should have thrown error. Response: ${JSON.stringify(response)}`); 115 | } catch (e) { 116 | expect((e as Error).name).to.equal('NoOrgSpecifiedWithNoPromptError'); 117 | expect(authRemoverSpy.callCount).to.equal(0); 118 | } 119 | }); 120 | 121 | describe('prompts', () => { 122 | it('shows correct prompt for single org', async () => { 123 | await prepareStubs(); 124 | promptStub.confirm.resolves(false); 125 | await Logout.run(['-o', testOrg1.username]); 126 | expect(promptStub.confirm.args[0][0].message).to.equal( 127 | messages.getMessage('prompt.confirm.single', [testOrg1.username]) 128 | ); 129 | }); 130 | it('should do nothing when prompt is answered with no', async () => { 131 | await prepareStubs(); 132 | promptStub.confirm.resolves(false); 133 | const response = await Logout.run(['-o', testOrg1.username]); 134 | expect(response).to.deep.equal([]); 135 | }); 136 | }); 137 | 138 | it('should remove auth when alias is specified', async () => { 139 | await prepareStubs({ aliases: { TestAlias: testOrg1.username } }); 140 | const response = await Logout.run(['-p', '-o', 'TestAlias', '--json']); 141 | expect(response).to.deep.equal([testOrg1.username]); 142 | }); 143 | 144 | it('should remove auth when target-org and target-dev-hub have same alias', async () => { 145 | await prepareStubs({ 146 | 'target-org': 'TestAlias', 147 | 'target-dev-hub': 'TestAlias', 148 | aliases: { TestAlias: testOrg1.username }, 149 | }); 150 | const response = await Logout.run(['-p', '--json']); 151 | expect(response).to.deep.equal([testOrg1.username]); 152 | }); 153 | 154 | it('should remove auth when target-org is alias', async () => { 155 | await prepareStubs({ 156 | 'target-org': 'TestAlias', 157 | aliases: { TestAlias: testOrg1.username }, 158 | }); 159 | const response = await Logout.run(['-p', '--json']); 160 | expect(response).to.deep.equal([testOrg1.username]); 161 | }); 162 | 163 | it('should fail when the auth file does not exist', async () => { 164 | await prepareStubs({ 165 | 'target-org': testOrg2.username, 166 | aliases: { TestAlias: testOrg1.username }, 167 | authInfoConfigDoesNotExist: true, 168 | }); 169 | try { 170 | await Logout.run(['-p', '-o', testOrg1.username, '--json']); 171 | expect.fail('Expected error to be thrown'); 172 | } catch (e) { 173 | expect((e as Error).name).to.equal('NoAuthFoundForTargetOrgError'); 174 | expect((e as Error).message).to.include('No authenticated org found'); 175 | } 176 | }); 177 | }); 178 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@salesforce/plugin-auth", 3 | "description": "plugin for sf auth commands", 4 | "version": "3.9.26", 5 | "author": "Salesforce", 6 | "bugs": "https://github.com/forcedotcom/cli/issues", 7 | "dependencies": { 8 | "@inquirer/checkbox": "^2.5.0", 9 | "@inquirer/select": "^2.5.0", 10 | "@oclif/core": "^4", 11 | "@salesforce/core": "^8.24.0", 12 | "@salesforce/kit": "^3.2.4", 13 | "@salesforce/plugin-info": "^3.4.100", 14 | "@salesforce/sf-plugins-core": "^12.2.6", 15 | "@salesforce/ts-types": "^2.0.11", 16 | "open": "^10.2.0" 17 | }, 18 | "devDependencies": { 19 | "@oclif/plugin-command-snapshot": "^5.3.8", 20 | "@salesforce/cli-plugins-testkit": "^5.3.41", 21 | "@salesforce/dev-scripts": "^11.0.4", 22 | "@salesforce/plugin-command-reference": "^3.1.79", 23 | "@salesforce/ts-sinon": "^1.4.31", 24 | "eslint-plugin-sf-plugin": "^1.20.33", 25 | "oclif": "^4.22.55", 26 | "ts-node": "^10.9.2", 27 | "typescript": "^5.5.4" 28 | }, 29 | "engines": { 30 | "node": ">=18.0.0" 31 | }, 32 | "files": [ 33 | "/lib", 34 | "/messages", 35 | "/oclif.manifest.json" 36 | ], 37 | "homepage": "https://github.com/salesforcecli/plugin-auth", 38 | "keywords": [ 39 | "force", 40 | "salesforce", 41 | "salesforcedx", 42 | "sf", 43 | "sf-plugin", 44 | "sfdx", 45 | "sfdx-plugin" 46 | ], 47 | "license": "Apache-2.0", 48 | "oclif": { 49 | "commands": "./lib/commands", 50 | "additionalHelpFlags": [ 51 | "-h" 52 | ], 53 | "bin": "sf", 54 | "devPlugins": [ 55 | "@oclif/plugin-help", 56 | "@oclif/plugin-command-snapshot", 57 | "@salesforce/plugin-command-reference" 58 | ], 59 | "topics": { 60 | "org": { 61 | "external": true, 62 | "subtopics": { 63 | "login": { 64 | "description": "Authorize an org for use with Salesforce CLI.", 65 | "longDescription": "Use the auth commands to authorize a Salesforce org for use with the Salesforce CLI.", 66 | "subtopics": { 67 | "jwt": { 68 | "description": "authorize an org using JWT" 69 | }, 70 | "sfdx-url": { 71 | "description": "authorize an org using sfdxurl" 72 | }, 73 | "web": { 74 | "description": "authorize an org using a web browser" 75 | }, 76 | "access-token": { 77 | "description": "authorize an org using an access token" 78 | }, 79 | "device": { 80 | "description": "authorize an org using a device code" 81 | } 82 | } 83 | }, 84 | "list": { 85 | "description": "List authorized orgs." 86 | } 87 | } 88 | } 89 | }, 90 | "hooks": { 91 | "sf-doctor-@salesforce/plugin-auth": "./lib/hooks/diagnostics" 92 | }, 93 | "flexibleTaxonomy": true, 94 | "topicSeparator": " " 95 | }, 96 | "repository": "salesforcecli/plugin-auth", 97 | "scripts": { 98 | "build": "wireit", 99 | "clean": "sf-clean", 100 | "clean-all": "sf-clean all", 101 | "compile": "wireit", 102 | "docs": "sf-docs", 103 | "fix-license": "eslint src test --fix --rule \"header/header: [2]\"", 104 | "format": "wireit", 105 | "link-check": "wireit", 106 | "lint": "wireit", 107 | "postpack": "sf-clean --ignore-signing-artifacts", 108 | "prepack": "sf-prepack", 109 | "prepare": "sf-install", 110 | "reformat": "prettier --config .prettierrc --write './*.{js,json,md}' './**/*.{ts,json,md}'", 111 | "test": "wireit", 112 | "test:nuts": "nyc mocha \"**/*.nut.ts\" --slow 4500 --timeout 1200000 --parallel --jobs 20", 113 | "test:only": "wireit", 114 | "version": "oclif readme" 115 | }, 116 | "publishConfig": { 117 | "access": "public" 118 | }, 119 | "wireit": { 120 | "build": { 121 | "dependencies": [ 122 | "compile", 123 | "lint" 124 | ] 125 | }, 126 | "compile": { 127 | "command": "tsc -p . --pretty --incremental", 128 | "files": [ 129 | "src/**/*.ts", 130 | "**/tsconfig.json", 131 | "messages/**" 132 | ], 133 | "output": [ 134 | "lib/**", 135 | "*.tsbuildinfo" 136 | ], 137 | "clean": "if-file-deleted" 138 | }, 139 | "format": { 140 | "command": "prettier --write \"+(src|test|schemas)/**/*.+(ts|js|json)|command-snapshot.json\"", 141 | "files": [ 142 | "src/**/*.ts", 143 | "test/**/*.ts", 144 | "schemas/**/*.json", 145 | "command-snapshot.json", 146 | ".prettier*" 147 | ], 148 | "output": [] 149 | }, 150 | "lint": { 151 | "command": "eslint src test --color --cache --cache-location .eslintcache", 152 | "files": [ 153 | "src/**/*.ts", 154 | "test/**/*.ts", 155 | "messages/**", 156 | "**/.eslint*", 157 | "**/tsconfig.json" 158 | ], 159 | "output": [] 160 | }, 161 | "test:compile": { 162 | "command": "tsc -p \"./test\" --pretty", 163 | "files": [ 164 | "test/**/*.ts", 165 | "**/tsconfig.json" 166 | ], 167 | "output": [] 168 | }, 169 | "test": { 170 | "dependencies": [ 171 | "test:compile", 172 | "test:only", 173 | "test:command-reference", 174 | "test:deprecation-policy", 175 | "lint", 176 | "test:json-schema", 177 | "link-check" 178 | ] 179 | }, 180 | "test:only": { 181 | "command": "nyc mocha \"test/**/*.test.ts\"", 182 | "env": { 183 | "FORCE_COLOR": "2" 184 | }, 185 | "files": [ 186 | "test/**/*.ts", 187 | "src/**/*.ts", 188 | "**/tsconfig.json", 189 | ".mocha*", 190 | "!*.nut.ts", 191 | ".nycrc" 192 | ], 193 | "output": [] 194 | }, 195 | "test:command-reference": { 196 | "command": "node --loader ts-node/esm --no-warnings=ExperimentalWarning \"./bin/dev.js\" commandreference:generate --erroronwarnings", 197 | "files": [ 198 | "src/**/*.ts", 199 | "messages/**", 200 | "package.json" 201 | ], 202 | "output": [ 203 | "tmp/root" 204 | ] 205 | }, 206 | "test:deprecation-policy": { 207 | "command": "node --loader ts-node/esm --no-warnings=ExperimentalWarning \"./bin/dev.js\" snapshot:compare", 208 | "files": [ 209 | "src/**/*.ts" 210 | ], 211 | "output": [], 212 | "dependencies": [ 213 | "compile" 214 | ] 215 | }, 216 | "test:json-schema": { 217 | "command": "node --loader ts-node/esm --no-warnings=ExperimentalWarning \"./bin/dev.js\" schema:compare", 218 | "files": [ 219 | "src/**/*.ts", 220 | "schemas" 221 | ], 222 | "output": [] 223 | }, 224 | "link-check": { 225 | "command": "node -e \"process.exit(process.env.CI ? 0 : 1)\" || linkinator \"**/*.md\" --skip \"CHANGELOG.md|node_modules|test/|confluence.internal.salesforce.com|my.salesforce.com|localhost|%s\" --markdown --retry --directory-listing --verbosity error", 226 | "files": [ 227 | "./*.md", 228 | "./!(CHANGELOG).md", 229 | "messages/**/*.md" 230 | ], 231 | "output": [] 232 | } 233 | }, 234 | "exports": "./lib/index.js", 235 | "type": "module" 236 | } 237 | -------------------------------------------------------------------------------- /test/common.test.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2025, Salesforce, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { ConfigContents, SfdcUrl, Global, Mode } from '@salesforce/core'; 18 | import { assert, expect } from 'chai'; 19 | import { TestContext, uniqid } from '@salesforce/core/testSetup'; 20 | import common from '../src/common.js'; 21 | 22 | const projectSetup = async ($$: TestContext, inProject = true, contents?: ConfigContents): Promise => { 23 | $$.inProject(inProject); 24 | if (inProject) { 25 | if (contents) { 26 | $$.setConfigStubContents('SfProjectJson', contents); 27 | if ($$.configStubs.SfProjectJson) { 28 | // eslint-disable-next-line @typescript-eslint/require-await 29 | $$.configStubs.SfProjectJson.retrieveContents = async (): Promise => contents; 30 | } 31 | } 32 | } 33 | return Promise.resolve(); 34 | }; 35 | 36 | describe('common unit tests', () => { 37 | const $$ = new TestContext(); 38 | beforeEach(() => { 39 | // force a new id for each test so a unique project is used 40 | $$.id = uniqid(); 41 | }); 42 | describe('production url', () => { 43 | it('should return production URL if not in a dx project', async () => { 44 | await projectSetup($$, false); 45 | const loginUrl = await common.resolveLoginUrl(undefined); 46 | expect(loginUrl).to.equal(SfdcUrl.PRODUCTION); 47 | }); 48 | it('should return production URL if project with property sfdcLoginUrl absent', async () => { 49 | await projectSetup($$, true, { 50 | packageDirectories: [ 51 | { 52 | path: 'force-app', 53 | default: true, 54 | }, 55 | ], 56 | sourceApiVersion: '50.0', 57 | }); 58 | const loginUrl = await common.resolveLoginUrl(undefined); 59 | expect(loginUrl).to.equal(SfdcUrl.PRODUCTION); 60 | }); 61 | it('should return production URL if project with property sfdcLoginUrl present', async () => { 62 | await projectSetup($$, true, { 63 | packageDirectories: [ 64 | { 65 | path: 'force-app', 66 | default: true, 67 | }, 68 | ], 69 | sfdcLoginUrl: 'https://login.salesforce.com', 70 | sourceApiVersion: '50.0', 71 | }); 72 | const loginUrl = await common.resolveLoginUrl(undefined); 73 | expect(loginUrl).to.equal(SfdcUrl.PRODUCTION); 74 | }); 75 | it('should throw on lightning login URL in sfdcLoginUrl property', async () => { 76 | await projectSetup($$, true, { 77 | packageDirectories: [ 78 | { 79 | path: 'force-app', 80 | default: true, 81 | }, 82 | ], 83 | sfdcLoginUrl: 'https://shanedevhub.lightning.force.com', 84 | sourceApiVersion: '50.0', 85 | }); 86 | try { 87 | await common.resolveLoginUrl(undefined); 88 | expect.fail('This test is failing because it is expecting an error that is never thrown'); 89 | } catch (error) { 90 | assert(error instanceof Error); 91 | expect(error.name).to.equal('LightningDomain'); 92 | } 93 | }); 94 | it('should throw on lightning login URL passed in to resolveLoginUrl()', async () => { 95 | await projectSetup($$, true); 96 | try { 97 | await common.resolveLoginUrl('https://shanedevhub.lightning.force.com'); 98 | expect.fail('This test is failing because it is expecting an error that is never thrown'); 99 | } catch (error) { 100 | assert(error instanceof Error); 101 | expect(error.name).to.equal('LightningDomain'); 102 | } 103 | }); 104 | 105 | it('should allow a domain containing lightning in its login URL', async () => { 106 | await projectSetup($$, true); 107 | const loginUrl = await common.resolveLoginUrl('https://mycompany-lightning.my.salesforce.com'); 108 | expect(loginUrl).equals('https://mycompany-lightning.my.salesforce.com'); 109 | }); 110 | 111 | it('should throw on internal lightning login URL passed in to resolveLoginUrl()', async () => { 112 | await projectSetup($$, true); 113 | try { 114 | await common.resolveLoginUrl('https://dro000000osjp2a0.test1.lightning.pc-rnd.force.com/'); 115 | expect.fail('This test is failing because it is expecting an error that is never thrown'); 116 | } catch (error) { 117 | assert(error instanceof Error); 118 | expect(error.name).to.equal('LightningDomain'); 119 | } 120 | }); 121 | }); 122 | describe('custom login url', () => { 123 | const INSTANCE_URL_1 = 'https://example.com'; 124 | const INSTANCE_URL_2 = 'https://some.other.com'; 125 | 126 | it('should return custom login URL if not in a dx project and instance-url given', async () => { 127 | await projectSetup($$, false); 128 | const loginUrl = await common.resolveLoginUrl(INSTANCE_URL_1); 129 | expect(loginUrl).to.equal(INSTANCE_URL_1); 130 | }); 131 | it('should return custom login URL if project with property sfdcLoginUrl absent and instance-url given', async () => { 132 | await projectSetup($$, true, { 133 | contents: { 134 | packageDirectories: [ 135 | { 136 | path: 'force-app', 137 | default: true, 138 | }, 139 | ], 140 | sourceApiVersion: '50.0', 141 | }, 142 | }); 143 | const loginUrl = await common.resolveLoginUrl(INSTANCE_URL_1); 144 | expect(loginUrl).to.equal(INSTANCE_URL_1); 145 | }); 146 | it('should return custom login URL if project with property sfdcLoginUrl present and not equal to production URL', async () => { 147 | await projectSetup($$, true, { 148 | packageDirectories: [ 149 | { 150 | path: 'force-app', 151 | default: true, 152 | }, 153 | ], 154 | sfdcLoginUrl: INSTANCE_URL_2, 155 | sourceApiVersion: '50.0', 156 | }); 157 | const loginUrl = await common.resolveLoginUrl(undefined); 158 | expect(loginUrl).to.equal(INSTANCE_URL_2); 159 | }); 160 | it('should return custom login URL 1 if project with property sfdcLoginUrl equal to custom url 2', async () => { 161 | await projectSetup($$, true, { 162 | contents: { 163 | packageDirectories: [ 164 | { 165 | path: 'force-app', 166 | default: true, 167 | }, 168 | ], 169 | sfdcLoginUrl: INSTANCE_URL_2, 170 | sourceApiVersion: '50.0', 171 | }, 172 | }); 173 | const loginUrl = await common.resolveLoginUrl(INSTANCE_URL_1); 174 | expect(loginUrl).to.equal(INSTANCE_URL_1); 175 | }); 176 | }); 177 | 178 | describe('shouldExitCommand', () => { 179 | it('should not exit if noPrompt is true', async () => { 180 | const shouldExit = await common.shouldExitCommand(true); 181 | expect(shouldExit).to.be.false; 182 | }); 183 | it('should not exit if not DEMO mode', async () => { 184 | $$.SANDBOX.stub(Global, 'getEnvironmentMode').returns(Mode.PRODUCTION); 185 | const shouldExit = await common.shouldExitCommand(); 186 | expect(shouldExit).to.be.false; 187 | }); 188 | }); 189 | }); 190 | -------------------------------------------------------------------------------- /test/commands/org/login/login.jwt.test.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2025, Salesforce, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { AuthFields, AuthInfo, SfError } from '@salesforce/core'; 18 | import { MockTestOrgData, TestContext } from '@salesforce/core/testSetup'; 19 | import { StubbedType, stubInterface } from '@salesforce/ts-sinon'; 20 | import { expect } from 'chai'; 21 | import { stubUx } from '@salesforce/sf-plugins-core'; 22 | import LoginJwt from '../../../../src/commands/org/login/jwt.js'; 23 | 24 | type Options = { 25 | authInfoCreateFails?: boolean; 26 | existingAuth?: boolean; 27 | }; 28 | 29 | describe('org:login:jwt', () => { 30 | const $$ = new TestContext(); 31 | 32 | const testData = new MockTestOrgData(); 33 | let authFields: AuthFields; 34 | let authInfoStub: StubbedType; 35 | 36 | async function prepareStubs(options: Options = {}): Promise { 37 | authFields = await testData.getConfig(); 38 | delete authFields.isDevHub; 39 | 40 | authInfoStub = stubInterface($$.SANDBOX, { 41 | getFields: () => authFields, 42 | }); 43 | 44 | await $$.stubAuths(testData); 45 | 46 | if (options.authInfoCreateFails) { 47 | $$.SANDBOX.stub(AuthInfo, 'create').throws(new Error('invalid client id')); 48 | } else if (options.existingAuth) { 49 | $$.SANDBOX.stub(AuthInfo, 'create') 50 | .onFirstCall() 51 | .throws(new SfError('auth exists', 'AuthInfoOverwriteError')) 52 | .onSecondCall() 53 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 54 | // @ts-ignore 55 | .resolves(authInfoStub); 56 | } else { 57 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 58 | // @ts-ignore 59 | $$.SANDBOX.stub(AuthInfo, 'create').resolves(authInfoStub); 60 | } 61 | 62 | stubUx($$.SANDBOX); 63 | } 64 | 65 | it('should return auth fields', async () => { 66 | await prepareStubs(); 67 | const response = await LoginJwt.run(['-u', testData.username, '-f', 'path/to/key.json', '-i', '123456', '--json']); 68 | expect(response.username).to.equal(testData.username); 69 | }); 70 | 71 | it('should set alias when -a is provided', async () => { 72 | await prepareStubs(); 73 | await LoginJwt.run(['-u', testData.username, '-f', 'path/to/key.json', '-i', '123456', '-a', 'MyAlias', '--json']); 74 | expect(authInfoStub.handleAliasAndDefaultSettings.callCount).to.equal(1); 75 | }); 76 | 77 | it('should set target-org to alias when -s and -a are provided', async () => { 78 | await prepareStubs(); 79 | await LoginJwt.run([ 80 | '-u', 81 | testData.username, 82 | '-f', 83 | 'path/to/key.json', 84 | '-i', 85 | '123456', 86 | '-a', 87 | 'MyAlias', 88 | '-s', 89 | '--json', 90 | ]); 91 | expect(authInfoStub.handleAliasAndDefaultSettings.callCount).to.equal(1); 92 | expect(authInfoStub.handleAliasAndDefaultSettings.args[0]).to.deep.equal([ 93 | { 94 | alias: 'MyAlias', 95 | setDefaultDevHub: undefined, 96 | setDefault: true, 97 | }, 98 | ]); 99 | }); 100 | 101 | it('should set target-org to username when -s is provided', async () => { 102 | await prepareStubs(); 103 | await LoginJwt.run(['-u', testData.username, '-f', 'path/to/key.json', '-i', '123456', '-s', '--json']); 104 | expect(authInfoStub.handleAliasAndDefaultSettings.callCount).to.equal(1); 105 | expect(authInfoStub.handleAliasAndDefaultSettings.args[0]).to.deep.equal([ 106 | { 107 | alias: undefined, 108 | setDefaultDevHub: undefined, 109 | setDefault: true, 110 | }, 111 | ]); 112 | }); 113 | 114 | it('should set target-dev-hub to alias when -d and -a are provided', async () => { 115 | await prepareStubs(); 116 | await LoginJwt.run([ 117 | '-u', 118 | testData.username, 119 | '-f', 120 | 'path/to/key.json', 121 | '-i', 122 | '123456', 123 | '-a', 124 | 'MyAlias', 125 | '-d', 126 | '--json', 127 | ]); 128 | expect(authInfoStub.handleAliasAndDefaultSettings.callCount).to.equal(1); 129 | expect(authInfoStub.handleAliasAndDefaultSettings.args[0]).to.deep.equal([ 130 | { 131 | alias: 'MyAlias', 132 | setDefaultDevHub: true, 133 | setDefault: undefined, 134 | }, 135 | ]); 136 | }); 137 | 138 | it('should set target-dev-hub to username when -d is provided', async () => { 139 | await prepareStubs(); 140 | await LoginJwt.run(['-u', testData.username, '-f', 'path/to/key.json', '-i', '123456', '-d', '--json']); 141 | expect(authInfoStub.handleAliasAndDefaultSettings.callCount).to.equal(1); 142 | expect(authInfoStub.handleAliasAndDefaultSettings.args[0]).to.deep.equal([ 143 | { 144 | alias: undefined, 145 | setDefaultDevHub: true, 146 | setDefault: undefined, 147 | }, 148 | ]); 149 | }); 150 | 151 | it('should set target-org and target-dev-hub to username when -d and -s are provided', async () => { 152 | await prepareStubs(); 153 | await LoginJwt.run(['-u', testData.username, '-f', 'path/to/key.json', '-i', '123456', '-d', '-s', '--json']); 154 | expect(authInfoStub.setAlias.callCount).to.equal(0); 155 | expect(authInfoStub.handleAliasAndDefaultSettings.callCount).to.equal(1); 156 | expect(authInfoStub.handleAliasAndDefaultSettings.args[0]).to.deep.equal([ 157 | { 158 | alias: undefined, 159 | setDefaultDevHub: true, 160 | setDefault: true, 161 | }, 162 | ]); 163 | }); 164 | 165 | it('should set target-org and target-dev-hub to alias when -a, -d, and -s are provided', async () => { 166 | await prepareStubs(); 167 | await LoginJwt.run([ 168 | '-u', 169 | testData.username, 170 | '-f', 171 | 'path/to/key.json', 172 | '-i', 173 | '123456', 174 | '-d', 175 | '-s', 176 | '-a', 177 | 'MyAlias', 178 | '--json', 179 | ]); 180 | expect(authInfoStub.handleAliasAndDefaultSettings.callCount).to.equal(1); 181 | expect(authInfoStub.handleAliasAndDefaultSettings.args[0]).to.deep.equal([ 182 | { 183 | alias: 'MyAlias', 184 | setDefaultDevHub: true, 185 | setDefault: true, 186 | }, 187 | ]); 188 | }); 189 | 190 | it('should throw an error when client id is invalid', async () => { 191 | await prepareStubs({ authInfoCreateFails: true }); 192 | try { 193 | await LoginJwt.run(['-u', testData.username, '-f', 'path/to/key.json', '-i', '123456INVALID', '--json']); 194 | expect.fail('Should have thrown an error'); 195 | } catch (e) { 196 | expect(e).to.be.instanceOf(Error); 197 | const jwtAuthError = e as SfError; 198 | expect(jwtAuthError.message).to.include('We encountered a JSON web token error'); 199 | expect(jwtAuthError.message).to.include('invalid client id'); 200 | expect(jwtAuthError.cause, 'JwtAuthErrors should include original error as the cause').to.be.ok; 201 | } 202 | }); 203 | 204 | it('should not throw an error when the authorization already exists', async () => { 205 | await prepareStubs({ existingAuth: true }); 206 | try { 207 | await LoginJwt.run(['-u', testData.username, '-f', 'path/to/key.json', '-i', '123456', '--json']); 208 | } catch (e) { 209 | expect.fail('Should not have thrown an error'); 210 | } 211 | }); 212 | }); 213 | -------------------------------------------------------------------------------- /test/commands/org/login/login.sfdx-url.test.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2025, Salesforce, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import fs from 'node:fs/promises'; 18 | import { AuthFields, AuthInfo } from '@salesforce/core'; 19 | import { MockTestOrgData, TestContext } from '@salesforce/core/testSetup'; 20 | import { expect } from 'chai'; 21 | import { StubbedType, stubInterface, stubMethod } from '@salesforce/ts-sinon'; 22 | import { stubUx } from '@salesforce/sf-plugins-core'; 23 | import LoginSfdxUrl from '../../../../src/commands/org/login/sfdx-url.js'; 24 | 25 | type Options = { 26 | authInfoCreateFails?: boolean; 27 | existingAuth?: boolean; 28 | fileDoesNotExist?: boolean; 29 | }; 30 | 31 | describe('org:login:sfdx-url', () => { 32 | const $$ = new TestContext(); 33 | const testData = new MockTestOrgData(); 34 | let authFields: AuthFields; 35 | let authInfoStub: StubbedType; 36 | const keyPathTxt = 'path/to/key.txt'; 37 | const keyPathJson = 'path/to/key.json'; 38 | 39 | async function prepareStubs(options: Options = {}): Promise { 40 | authFields = await testData.getConfig(); 41 | $$.stubAliases({}); 42 | delete authFields.isDevHub; 43 | 44 | authInfoStub = stubInterface($$.SANDBOX, { 45 | getFields: () => authFields, 46 | }); 47 | 48 | await $$.stubAuths(testData); 49 | 50 | if (!options.fileDoesNotExist) { 51 | $$.SANDBOX.stub(fs, 'readFile') 52 | .callThrough() 53 | .withArgs(keyPathTxt, 'utf8') 54 | .resolves('force://PlatformCLI::CoffeeAndBacon@su0503.my.salesforce.com'); 55 | } 56 | 57 | if (options.authInfoCreateFails) { 58 | $$.SANDBOX.stub(AuthInfo, 'create').throws(new Error('invalid client id')); 59 | } else { 60 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 61 | // @ts-ignore 62 | $$.SANDBOX.stub(AuthInfo, 'create').resolves(authInfoStub); 63 | } 64 | 65 | stubUx($$.SANDBOX); 66 | } 67 | 68 | it('should return auth fields', async () => { 69 | await prepareStubs(); 70 | const response = await LoginSfdxUrl.run(['-f', keyPathTxt, '--json']); 71 | expect(response.username).to.equal(testData.username); 72 | }); 73 | 74 | it('should return auth fields when passing in a json file', async () => { 75 | await prepareStubs({ fileDoesNotExist: true }); 76 | $$.SANDBOX.stub(fs, 'readFile') 77 | .callThrough() 78 | .withArgs(keyPathJson, 'utf8') 79 | .resolves( 80 | JSON.stringify({ 81 | sfdxAuthUrl: 'force://PlatformCLI::CoffeeAndBacon@su0503.my.salesforce.com', 82 | }) 83 | ); 84 | 85 | const response = await LoginSfdxUrl.run(['-f', keyPathJson, '--json']); 86 | expect(response.username).to.equal(testData.username); 87 | }); 88 | 89 | it("should error out when it doesn't find a url in a JSON file", async () => { 90 | await prepareStubs({ fileDoesNotExist: true }); 91 | $$.SANDBOX.stub(fs, 'readFile') 92 | .callThrough() 93 | .withArgs(keyPathJson, 'utf8') 94 | .resolves( 95 | JSON.stringify({ 96 | notASfdxAuthUrl: 'force://PlatformCLI::CoffeeAndBacon@su0503.my.salesforce.com', 97 | }) 98 | ); 99 | 100 | try { 101 | const response = await LoginSfdxUrl.run(['-f', keyPathJson, '--json']); 102 | expect.fail(`Should have thrown an error. Response: ${JSON.stringify(response)}`); 103 | } catch (e) { 104 | expect((e as Error).message).to.includes('Error getting the SFDX authorization URL from file'); 105 | } 106 | }); 107 | 108 | it('should set alias when -a is provided', async () => { 109 | await prepareStubs(); 110 | await LoginSfdxUrl.run(['-f', keyPathTxt, '-a', 'MyAlias', '--json']); 111 | expect(authInfoStub.handleAliasAndDefaultSettings.callCount).to.equal(1); 112 | }); 113 | 114 | it('should set target-org to alias when -s and -a are provided', async () => { 115 | await prepareStubs(); 116 | await LoginSfdxUrl.run(['-f', keyPathTxt, '-a', 'MyAlias', '-s', '--json']); 117 | expect(authInfoStub.handleAliasAndDefaultSettings.callCount).to.equal(1); 118 | expect(authInfoStub.handleAliasAndDefaultSettings.args[0]).to.deep.equal([ 119 | { 120 | alias: 'MyAlias', 121 | setDefaultDevHub: undefined, 122 | setDefault: true, 123 | }, 124 | ]); 125 | }); 126 | 127 | it('should set target-org to username when -s is provided', async () => { 128 | await prepareStubs(); 129 | await LoginSfdxUrl.run(['-f', keyPathTxt, '-s', '--json']); 130 | expect(authInfoStub.setAlias.callCount).to.equal(0); 131 | expect(authInfoStub.handleAliasAndDefaultSettings.callCount).to.equal(1); 132 | expect(authInfoStub.handleAliasAndDefaultSettings.args[0]).to.deep.equal([ 133 | { 134 | alias: undefined, 135 | setDefaultDevHub: undefined, 136 | setDefault: true, 137 | }, 138 | ]); 139 | }); 140 | 141 | it('should set target-dev-hub to alias when -d and -a are provided', async () => { 142 | await prepareStubs(); 143 | await LoginSfdxUrl.run(['-f', keyPathTxt, '-a', 'MyAlias', '-d', '--json']); 144 | expect(authInfoStub.handleAliasAndDefaultSettings.callCount).to.equal(1); 145 | expect(authInfoStub.handleAliasAndDefaultSettings.args[0]).to.deep.equal([ 146 | { 147 | alias: 'MyAlias', 148 | setDefaultDevHub: true, 149 | setDefault: undefined, 150 | }, 151 | ]); 152 | }); 153 | 154 | it('should set target-dev-hub to username when -d is provided', async () => { 155 | await prepareStubs(); 156 | await LoginSfdxUrl.run(['-f', keyPathTxt, '-d', '--json']); 157 | expect(authInfoStub.handleAliasAndDefaultSettings.callCount).to.equal(1); 158 | expect(authInfoStub.handleAliasAndDefaultSettings.args[0]).to.deep.equal([ 159 | { 160 | alias: undefined, 161 | setDefaultDevHub: true, 162 | setDefault: undefined, 163 | }, 164 | ]); 165 | }); 166 | 167 | it('should set target-org and target-dev-hub to username when -d and -s are provided', async () => { 168 | await prepareStubs(); 169 | await LoginSfdxUrl.run(['-f', keyPathTxt, '-d', '-s', '--json']); 170 | expect(authInfoStub.handleAliasAndDefaultSettings.callCount).to.equal(1); 171 | expect(authInfoStub.handleAliasAndDefaultSettings.args[0]).to.deep.equal([ 172 | { 173 | alias: undefined, 174 | setDefaultDevHub: true, 175 | setDefault: true, 176 | }, 177 | ]); 178 | }); 179 | 180 | it('should set target-org and target-dev-hub to alias when -a, -d, and -s are provided', async () => { 181 | await prepareStubs(); 182 | await LoginSfdxUrl.run(['-f', keyPathTxt, '-d', '-s', '-a', 'MyAlias', '--json']); 183 | expect(authInfoStub.handleAliasAndDefaultSettings.callCount).to.equal(1); 184 | expect(authInfoStub.handleAliasAndDefaultSettings.args[0]).to.deep.equal([ 185 | { 186 | alias: 'MyAlias', 187 | setDefaultDevHub: true, 188 | setDefault: true, 189 | }, 190 | ]); 191 | }); 192 | 193 | it('should error out when neither file or url are provided', async () => { 194 | await prepareStubs(); 195 | try { 196 | const response = await LoginSfdxUrl.run([]); 197 | expect.fail(`Should have thrown an error. Response: ${JSON.stringify(response)}`); 198 | } catch (e) { 199 | expect((e as Error).message).to.includes( 200 | 'Exactly one of the following must be provided: --sfdx-url-file, --sfdx-url-stdin' 201 | ); 202 | } 203 | }); 204 | 205 | it('should return auth fields when using stdin', async () => { 206 | await prepareStubs(); 207 | const sfdxAuthUrl = 'force://PlatformCLI::CoffeeAndBacon@su0503.my.salesforce.com'; 208 | const flagOutput = { 209 | flags: { 210 | 'no-prompt': false, 211 | 'sfdx-url-file': '', 212 | 'sfdx-url-stdin': sfdxAuthUrl, 213 | }, 214 | }; 215 | stubMethod($$.SANDBOX, LoginSfdxUrl.prototype, 'parse').resolves(flagOutput); 216 | const response = await LoginSfdxUrl.run(['-u', '-']); 217 | expect(response.username).to.equal(testData.username); 218 | }); 219 | }); 220 | -------------------------------------------------------------------------------- /src/hooks/diagnostics.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2025, Salesforce, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | import childProcess from 'node:child_process'; 17 | import { ExecOptions, PromiseWithChild } from 'node:child_process'; 18 | import util from 'node:util'; 19 | import { join } from 'node:path'; 20 | import fs from 'node:fs'; 21 | import { Global, Lifecycle, Logger, Messages } from '@salesforce/core'; 22 | import { SfDoctor, SfDoctorDiagnosis } from '@salesforce/plugin-info'; 23 | import { asString, isString } from '@salesforce/ts-types'; 24 | import { parseJsonMap } from '@salesforce/kit'; 25 | 26 | type HookFunction = (options: { doctor: SfDoctor }) => Promise<[void]>; 27 | type PromisifiedExec = (command: string, options?: ExecOptions) => PromiseWithChild<{ stdout: string; stderr: string }>; 28 | 29 | let logger: Logger; 30 | const getLogger = (): Logger => { 31 | if (!logger) { 32 | logger = Logger.childFromRoot('plugin-auth-diagnostics'); 33 | } 34 | return logger; 35 | }; 36 | 37 | const pluginName = '@salesforce/plugin-auth'; 38 | Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); 39 | const messages = Messages.loadMessages(pluginName, 'diagnostics'); 40 | 41 | let exec: PromisifiedExec; 42 | 43 | export const hook: HookFunction = async (options) => { 44 | getLogger().debug(`Running SfDoctor diagnostics for ${pluginName}`); 45 | exec = util.promisify(childProcess.exec); 46 | try { 47 | await exec('npm -v'); 48 | return await Promise.all([cryptoVersionTest(options.doctor)]); 49 | } catch (e: unknown) { 50 | const errMsg = e instanceof Error ? e.message : isString(e) ? e : 'unknown'; 51 | getLogger().warn(`Unable to run SfDoctor diagnostics for ${pluginName} due to: ${errMsg}`); 52 | return Promise.resolve([undefined]); 53 | } 54 | }; 55 | 56 | type NpmExplanationDeps = { 57 | type: string; 58 | name: string; 59 | spec: string; 60 | from: NpmExplanation; 61 | }; 62 | type NpmExplanation = { 63 | name: string; 64 | version: string; 65 | location: string; 66 | isWorkspace: string; 67 | dependents: NpmExplanationDeps[]; 68 | }; 69 | 70 | // ============================ 71 | // *** DIAGNOSTIC TESTS *** 72 | // ============================ 73 | 74 | // Detects if the auth key used is crypto v1 or v2 75 | // Detects if the SF_CRYPTO_V2 env var is set and if it matches the key crypto version 76 | const cryptoVersionTest = async (doctor: SfDoctor): Promise => { 77 | getLogger().debug('Running Crypto Version tests'); 78 | 79 | const sfCryptoV2Support = await supportsCliV2Crypto(doctor); 80 | let cryptoVersion: 'unknown' | 'v1' | 'v2' = 'unknown'; 81 | 82 | const sfCryptoV2 = process.env.SF_CRYPTO_V2; 83 | 84 | const isUsingGenericKeychain = 85 | process.platform === 'win32' || 86 | process.env.SF_USE_GENERIC_UNIX_KEYCHAIN?.toLowerCase() === 'true' || 87 | process.env.USE_GENERIC_UNIX_KEYCHAIN?.toLowerCase() === 'true'; 88 | 89 | // If the CLI is using key.json, we can read the file and get the key length 90 | // to discover the crypto version being used. If not, then we can't detect it. 91 | if (isUsingGenericKeychain) { 92 | try { 93 | const keyFile = join(Global.DIR, 'key.json'); 94 | const key = asString(parseJsonMap(fs.readFileSync(keyFile, 'utf8'))?.key); 95 | cryptoVersion = key?.length === 64 ? 'v2' : key?.length === 32 ? 'v1' : 'unknown'; 96 | } catch (e: unknown) { 97 | const errMsg = e instanceof Error ? e.message : isString(e) ? e : 'unknown'; 98 | getLogger().debug(`Could not detect key size due to:\n${errMsg}`); 99 | } 100 | } 101 | 102 | doctor.addPluginData(pluginName, { 103 | sfCryptoV2, 104 | isUsingGenericKeychain, 105 | sfCryptoV2Support, 106 | cryptoVersion, 107 | }); 108 | 109 | const testName1 = `[${pluginName}] CLI supports v2 crypto`; 110 | let status1 = 'pass'; 111 | if (!sfCryptoV2Support) { 112 | status1 = 'fail'; 113 | doctor.addSuggestion(messages.getMessage('sfCryptoV2Support')); 114 | } 115 | void Lifecycle.getInstance().emit('Doctor:diagnostic', { testName: testName1, status: status1 }); 116 | 117 | // Only do this test if we know they are using v2 crypto 118 | if (cryptoVersion === 'v2') { 119 | const testName2 = `[${pluginName}] CLI using stable v2 crypto`; 120 | let status2 = 'pass'; 121 | if (!sfCryptoV2Support) { 122 | status2 = 'fail'; 123 | doctor.addSuggestion(messages.getMessage('sfCryptoV2Unstable')); 124 | } 125 | void Lifecycle.getInstance().emit('Doctor:diagnostic', { testName: testName2, status: status2 }); 126 | } 127 | 128 | // Only do this test if we know they are using v1 crypto 129 | if (cryptoVersion === 'v1') { 130 | const testName3 = `[${pluginName}] CLI using stable v1 crypto`; 131 | let status3 = 'pass'; 132 | if (sfCryptoV2?.toLowerCase() === 'true') { 133 | // They have SF_CRYPTO_V2=true but are using v1 crypto. They might not know this 134 | // or know how to generate a v2 key. 135 | if (sfCryptoV2Support) { 136 | status3 = 'warn'; 137 | doctor.addSuggestion(messages.getMessage('sfCryptoV2Desired')); 138 | } 139 | } 140 | void Lifecycle.getInstance().emit('Doctor:diagnostic', { testName: testName3, status: status3 }); 141 | } 142 | }; 143 | 144 | // Inspect CLI install and plugins to ensure all versions of `@salesforce/core` can support v2 crypto. 145 | // This uses `npm explain @salesforce/core` to ensure all versions are greater than 6.6.0. 146 | const supportsCliV2Crypto = async (doctor: SfDoctor): Promise => { 147 | const diagnosis: SfDoctorDiagnosis = doctor.getDiagnosis(); 148 | let coreSupportsV2 = false; 149 | let pluginsSupportV2 = false; 150 | let linksSupportsV2 = false; 151 | 152 | const { root, dataDir } = diagnosis.cliConfig; 153 | // check core CLI 154 | if (root?.length) { 155 | try { 156 | const { stdout } = await exec('npm explain @salesforce/core --json', { cwd: root }); 157 | const coreExplanation = JSON.parse(stdout) as NpmExplanation[]; 158 | coreSupportsV2 = coreExplanation.every((exp) => exp?.version > '6.6.0'); 159 | } catch (e: unknown) { 160 | const errMsg = e instanceof Error ? e.message : isString(e) ? e : 'unknown'; 161 | getLogger().debug(`Cannot determine CLI v2 crypto core support due to: ${errMsg}`); 162 | } 163 | } 164 | // check installed plugins 165 | if (dataDir?.length) { 166 | try { 167 | const { stdout } = await exec('npm explain @salesforce/core --json', { cwd: dataDir }); 168 | const pluginsExplanation = JSON.parse(stdout) as NpmExplanation[]; 169 | pluginsSupportV2 = pluginsExplanation?.length ? pluginsExplanation.every((exp) => exp?.version > '6.6.0') : true; 170 | } catch (e: unknown) { 171 | const errMsg = e instanceof Error ? e.message : isString(e) ? e : 'unknown'; 172 | if (errMsg.includes('No dependencies found matching @salesforce/core')) { 173 | pluginsSupportV2 = true; 174 | } else { 175 | getLogger().debug(`Cannot determine CLI v2 crypto installed plugins support due to: ${errMsg}`); 176 | } 177 | } 178 | } 179 | // check linked plugins 180 | const pluginVersions = diagnosis?.versionDetail?.pluginVersions; 181 | const linkedPlugins = pluginVersions.filter((pv) => pv.includes('(link)')); 182 | if (linkedPlugins?.length) { 183 | try { 184 | const coreVersionChecks = await Promise.all( 185 | linkedPlugins.map(async (pluginEntry) => { 186 | // last entry is the path. E.g., "auth 3.3.17 (link) /Users/me/dev/plugin-auth", 187 | const pluginPath = pluginEntry.split(' ')?.pop(); 188 | if (pluginPath?.length) { 189 | const { stdout } = await exec('npm explain @salesforce/core --json', { cwd: pluginPath }); 190 | const linksExplanation = JSON.parse(stdout) as NpmExplanation[]; 191 | return linksExplanation?.every((exp) => exp?.version > '6.6.0'); 192 | } 193 | return true; 194 | }) 195 | ); 196 | linksSupportsV2 = !coreVersionChecks.includes(false); 197 | } catch (e: unknown) { 198 | const errMsg = e instanceof Error ? e.message : isString(e) ? e : 'unknown'; 199 | getLogger().debug(`Cannot determine CLI v2 crypto linked plugins support due to: ${errMsg}`); 200 | } 201 | } else { 202 | linksSupportsV2 = true; 203 | } 204 | 205 | return coreSupportsV2 && pluginsSupportV2 && linksSupportsV2; 206 | }; 207 | -------------------------------------------------------------------------------- /src/commands/org/login/web.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2025, Salesforce, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import open, { apps, AppName } from 'open'; 18 | import { Flags, SfCommand, loglevel } from '@salesforce/sf-plugins-core'; 19 | import { AuthFields, AuthInfo, Logger, Messages, OAuth2Config, SfError, WebOAuthServer } from '@salesforce/core'; 20 | import { Env } from '@salesforce/kit'; 21 | import common from '../../../common.js'; 22 | 23 | Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); 24 | const messages = Messages.loadMessages('@salesforce/plugin-auth', 'web.login'); 25 | const commonMessages = Messages.loadMessages('@salesforce/plugin-auth', 'messages'); 26 | 27 | export type ExecuteLoginFlowParams = { 28 | oauthConfig: OAuth2Config; 29 | browser?: string; 30 | scopes?: string; 31 | } & ({ clientApp: { name: string; username: string } } | { clientApp?: undefined }); 32 | 33 | export default class LoginWeb extends SfCommand { 34 | public static readonly summary = messages.getMessage('summary'); 35 | public static readonly description = messages.getMessage('description'); 36 | public static readonly examples = messages.getMessages('examples'); 37 | public static readonly deprecateAliases = true; 38 | public static readonly aliases = ['force:auth:web:login', 'auth:web:login']; 39 | 40 | public static readonly flags = { 41 | browser: Flags.option({ 42 | char: 'b', 43 | summary: messages.getMessage('flags.browser.summary'), 44 | description: messages.getMessage('flags.browser.description'), 45 | options: ['chrome', 'edge', 'firefox'], // These are ones supported by "open" package 46 | })(), 47 | 'client-id': Flags.string({ 48 | char: 'i', 49 | summary: commonMessages.getMessage('flags.client-id.summary'), 50 | deprecateAliases: true, 51 | aliases: ['clientid'], 52 | }), 53 | 'instance-url': Flags.url({ 54 | char: 'r', 55 | summary: commonMessages.getMessage('flags.instance-url.summary'), 56 | description: commonMessages.getMessage('flags.instance-url.description'), 57 | deprecateAliases: true, 58 | aliases: ['instanceurl', 'l'], 59 | }), 60 | 'set-default-dev-hub': Flags.boolean({ 61 | char: 'd', 62 | summary: commonMessages.getMessage('flags.set-default-dev-hub.summary'), 63 | deprecateAliases: true, 64 | aliases: ['setdefaultdevhubusername', 'setdefaultdevhub', 'v'], 65 | }), 66 | 'set-default': Flags.boolean({ 67 | char: 's', 68 | summary: commonMessages.getMessage('flags.set-default.summary'), 69 | deprecateAliases: true, 70 | aliases: ['setdefaultusername'], 71 | }), 72 | alias: Flags.string({ 73 | char: 'a', 74 | summary: commonMessages.getMessage('flags.alias.summary'), 75 | deprecateAliases: true, 76 | aliases: ['setalias'], 77 | }), 78 | 'no-prompt': Flags.boolean({ 79 | char: 'p', 80 | summary: commonMessages.getMessage('flags.no-prompt.summary'), 81 | required: false, 82 | hidden: true, 83 | deprecateAliases: true, 84 | aliases: ['noprompt'], 85 | }), 86 | loglevel, 87 | 'client-app': Flags.string({ 88 | char: 'c', 89 | summary: messages.getMessage('flags.client-app.summary'), 90 | dependsOn: ['username'], 91 | }), 92 | username: Flags.string({ 93 | summary: messages.getMessage('flags.username.summary'), 94 | dependsOn: ['client-app'], 95 | }), 96 | scopes: Flags.string({ 97 | summary: messages.getMessage('flags.scopes.summary'), 98 | parse: async (input: string) => { 99 | if (input.includes(',')) { 100 | throw new SfError(messages.getMessage('flags.scopes.invalidFormat')); 101 | } 102 | return Promise.resolve(input); 103 | }, 104 | }), 105 | }; 106 | 107 | private logger = Logger.childFromRoot(this.constructor.name); 108 | 109 | public async run(): Promise { 110 | const { flags } = await this.parse(LoginWeb); 111 | if (isContainerMode()) { 112 | throw new SfError(messages.getMessage('error.headlessWebAuth')); 113 | } 114 | 115 | if (await common.shouldExitCommand(flags['no-prompt'])) return {}; 116 | 117 | // Add ca/eca to already existing auth info. 118 | if (flags['client-app'] && flags.username) { 119 | // 1. get username authinfo 120 | const userAuthInfo = await AuthInfo.create({ 121 | username: flags.username, 122 | }); 123 | 124 | const authFields = userAuthInfo.getFields(true); 125 | 126 | // 2. web-auth and save name, clientId, accessToken, and refreshToken in `apps` object 127 | const oauthConfig: OAuth2Config = { 128 | loginUrl: authFields.loginUrl, 129 | clientId: flags['client-id'], 130 | ...{ clientSecret: await this.secretPrompt({ message: commonMessages.getMessage('clientSecretStdin') }) }, 131 | }; 132 | 133 | await this.executeLoginFlow({ 134 | oauthConfig, 135 | browser: flags.browser, 136 | clientApp: { name: flags['client-app'], username: flags.username }, 137 | scopes: flags.scopes, 138 | }); 139 | 140 | this.logSuccess(messages.getMessage('linkedClientApp', [flags['client-app'], flags.username])); 141 | return userAuthInfo.getFields(true); 142 | } 143 | 144 | const oauthConfig: OAuth2Config = { 145 | loginUrl: await common.resolveLoginUrl(flags['instance-url']?.href), 146 | clientId: flags['client-id'], 147 | ...(flags['client-id'] 148 | ? { clientSecret: await this.secretPrompt({ message: commonMessages.getMessage('clientSecretStdin') }) } 149 | : {}), 150 | }; 151 | 152 | try { 153 | const authInfo = await this.executeLoginFlow({ 154 | oauthConfig, 155 | browser: flags.browser, 156 | clientApp: undefined, 157 | scopes: flags.scopes, 158 | }); 159 | await authInfo.handleAliasAndDefaultSettings({ 160 | alias: flags.alias, 161 | setDefault: flags['set-default'], 162 | setDefaultDevHub: flags['set-default-dev-hub'], 163 | }); 164 | const fields = authInfo.getFields(true); 165 | await AuthInfo.identifyPossibleScratchOrgs(fields, authInfo); 166 | 167 | const successMsg = commonMessages.getMessage('authorizeCommandSuccess', [fields.username, fields.orgId]); 168 | this.logSuccess(successMsg); 169 | return fields; 170 | } catch (err) { 171 | Logger.childFromRoot('LoginWebCommand').debug(err); 172 | if (err instanceof SfError && err.name === 'AuthCodeExchangeError') { 173 | err.message = messages.getMessage('invalidClientId', [err.message]); 174 | } 175 | throw err; 176 | } 177 | } 178 | 179 | // leave it because it's stubbed in the test 180 | // eslint-disable-next-line class-methods-use-this 181 | private async executeLoginFlow({ 182 | oauthConfig, 183 | browser, 184 | clientApp, 185 | scopes, 186 | }: ExecuteLoginFlowParams): Promise { 187 | // The server handles 2 possible auth scenarios: 188 | // a. 1st time auth, creates auth file. 189 | // b. Add CA/ECA to existing auth. 190 | const oauthServer = await WebOAuthServer.create({ 191 | oauthConfig: { 192 | ...oauthConfig, 193 | scope: scopes, 194 | }, 195 | clientApp: clientApp?.name, 196 | username: clientApp?.username, 197 | }); 198 | await oauthServer.start(); 199 | const browserApp = browser && browser in apps ? (browser as AppName) : undefined; 200 | const openOptions = browserApp ? { app: { name: apps[browserApp] }, wait: false } : { wait: false }; 201 | this.logger.debug(`Opening browser ${browserApp ?? ''}`); 202 | // the following `childProcess` wrapper is needed to catch when `open` fails to open a browser. 203 | await open(oauthServer.getAuthorizationUrl(), openOptions).then( 204 | (childProcess) => 205 | new Promise((resolve, reject) => { 206 | // https://nodejs.org/api/child_process.html#event-exit 207 | childProcess.on('exit', (code) => { 208 | if (code && code > 0) { 209 | this.logger.debug(`Failed to open browser ${browserApp ?? ''}`); 210 | reject(messages.createError('error.cannotOpenBrowser', [browserApp], [browserApp])); 211 | } 212 | // If the process exited, code is the final exit code of the process, otherwise null. 213 | // resolve on null just to be safe, worst case the browser didn't open and the CLI just hangs. 214 | if (code === null || code === 0) { 215 | this.logger.debug(`Successfully opened browser ${browserApp ?? ''}`); 216 | resolve(childProcess); 217 | } 218 | }); 219 | }) 220 | ); 221 | return oauthServer.authorizeAndSave(); 222 | } 223 | } 224 | 225 | const isContainerMode = (): boolean => { 226 | const env = new Env(); 227 | const containerMode = env.getBoolean('SF_CONTAINER_MODE') || env.getBoolean('SFDX_CONTAINER_MODE'); 228 | const codeBuilder = env.getBoolean('CODE_BUILDER'); 229 | return containerMode && !codeBuilder; 230 | }; 231 | -------------------------------------------------------------------------------- /src/commands/org/logout.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2025, Salesforce, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import os from 'node:os'; 18 | import { 19 | AuthInfo, 20 | AuthRemover, 21 | ConfigAggregator, 22 | Global, 23 | Messages, 24 | Mode, 25 | OrgAuthorization, 26 | OrgConfigProperties, 27 | } from '@salesforce/core'; 28 | import checkbox, { Separator } from '@inquirer/checkbox'; 29 | import { Flags, loglevel, SfCommand, StandardColors } from '@salesforce/sf-plugins-core'; 30 | 31 | Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); 32 | const messages = Messages.loadMessages('@salesforce/plugin-auth', 'logout'); 33 | const commonMessages = Messages.loadMessages('@salesforce/plugin-auth', 'messages'); 34 | type Choice = { name: string; value: OrgAuthorization }; 35 | 36 | export type AuthLogoutResults = string[]; 37 | 38 | export default class Logout extends SfCommand { 39 | public static readonly summary = messages.getMessage('summary'); 40 | public static readonly description = messages.getMessage('description'); 41 | public static readonly examples = messages.getMessages('examples'); 42 | 43 | public static readonly deprecateAliases = true; 44 | public static readonly aliases = ['force:auth:logout', 'auth:logout']; 45 | 46 | public static readonly flags = { 47 | 'target-org': Flags.string({ 48 | summary: messages.getMessage('flags.target-org.summary'), 49 | char: 'o', 50 | aliases: ['targetusername', 'u'], 51 | deprecateAliases: true, 52 | }), 53 | 'client-app': Flags.string({ 54 | char: 'c', 55 | summary: messages.getMessage('flags.client-app.summary'), 56 | dependsOn: ['target-org'], 57 | }), 58 | all: Flags.boolean({ 59 | char: 'a', 60 | summary: messages.getMessage('flags.all.summary'), 61 | description: messages.getMessage('flags.all.description'), 62 | required: false, 63 | default: false, 64 | exclusive: ['target-org'], 65 | }), 66 | 'no-prompt': Flags.boolean({ 67 | char: 'p', 68 | summary: commonMessages.getMessage('flags.no-prompt.summary'), 69 | required: false, 70 | deprecateAliases: true, 71 | aliases: ['noprompt'], 72 | }), 73 | loglevel, 74 | }; 75 | 76 | // eslint-disable-next-line complexity 77 | public async run(): Promise { 78 | const { flags } = await this.parse(Logout); 79 | const targetUsername = 80 | flags['target-org'] ?? 81 | ((await ConfigAggregator.create()).getInfo(OrgConfigProperties.TARGET_ORG).value as string); 82 | 83 | // if no-prompt, there must be a resolved target-org or --all 84 | if (flags['no-prompt'] && !targetUsername && !flags.all) { 85 | throw messages.createError('noOrgSpecifiedWithNoPrompt'); 86 | } 87 | 88 | if (flags['client-app']) { 89 | return this.logoutClientApp(flags['client-app'], targetUsername); 90 | } 91 | 92 | if (this.jsonEnabled() && !targetUsername && !flags.all) { 93 | throw messages.createError('noOrgSpecifiedWithJson'); 94 | } 95 | const shouldFindAllAuths = 96 | targetUsername && !flags.all 97 | ? false 98 | : flags.all || Global.getEnvironmentMode() === Mode.DEMO || !flags['no-prompt']; 99 | 100 | const orgAuths = shouldFindAllAuths 101 | ? await AuthInfo.listAllAuthorizations() 102 | : targetUsername 103 | ? (await AuthInfo.listAllAuthorizations()).filter( 104 | (orgAuth) => orgAuth.username === targetUsername || !!orgAuth.aliases?.includes(targetUsername) 105 | ) 106 | : []; 107 | 108 | if (orgAuths.length === 0) { 109 | if (flags['target-org']) { 110 | // user specified a target org but it was not resolved, throw error 111 | throw messages.createError('noAuthFoundForTargetOrg', [flags['target-org']]); 112 | } 113 | this.info(messages.getMessage('noOrgsFound')); 114 | return []; 115 | } 116 | const skipPrompt = flags['no-prompt'] || this.jsonEnabled(); 117 | 118 | const selectedOrgs = this.maybeWarnScratchOrgs( 119 | skipPrompt ? orgAuths : await promptForOrgsToRemove(orgAuths, flags.all) 120 | ); 121 | 122 | if (skipPrompt || (await this.confirm({ message: getOrgConfirmationMessage(selectedOrgs, orgAuths.length) }))) { 123 | const remover = await AuthRemover.create(); 124 | const loggedOutUsernames = selectedOrgs.map((org) => org.username); 125 | await Promise.all(loggedOutUsernames.map((username) => remover.removeAuth(username))); 126 | this.logSuccess(messages.getMessage('logoutOrgCommandSuccess', [loggedOutUsernames.join(os.EOL)])); 127 | return loggedOutUsernames; 128 | } else { 129 | this.info(messages.getMessage('noOrgsSelected')); 130 | return []; 131 | } 132 | } 133 | 134 | private async logoutClientApp(clientApp: string, username: string): Promise { 135 | const authInfo = await AuthInfo.create({ 136 | username, 137 | }); 138 | 139 | const authFields = authInfo.getFields(true); 140 | if (!authFields.clientApps) { 141 | throw messages.createError('error.noLinkedApps', [username]); 142 | } 143 | 144 | if (authFields.clientApps && !(clientApp in authFields.clientApps)) { 145 | throw messages.createError('error.invalidClientApp', [username, clientApp]); 146 | } 147 | 148 | // if logging out of the last client app, remove the whole `clientApps` object from the auth fields 149 | if (Object.keys(authFields.clientApps).length === 1) { 150 | await authInfo.save({ 151 | clientApps: undefined, 152 | }); 153 | } else { 154 | // just remove the specific client app entry 155 | delete authFields.clientApps[clientApp]; 156 | 157 | await authInfo.save({ 158 | clientApps: authFields.clientApps, 159 | }); 160 | } 161 | 162 | this.logSuccess(messages.getMessage('logoutClientAppSuccess', [clientApp, username])); 163 | return [clientApp]; 164 | } 165 | 166 | /** Warning about logging out of a scratch org and losing access to it */ 167 | private maybeWarnScratchOrgs(orgs: OrgAuthorization[]): OrgAuthorization[] { 168 | if (orgs.some((org) => org.isScratchOrg)) { 169 | this.warn(messages.getMessage('warning')); 170 | } 171 | return orgs; 172 | } 173 | } 174 | 175 | const promptForOrgsToRemove = async (orgAuths: OrgAuthorization[], all: boolean): Promise => 176 | orgAuths.length === 1 177 | ? orgAuths 178 | : checkbox({ 179 | message: messages.getMessage('prompt.select-envs'), 180 | // pick the orgs to delete - if flags.all - set each org to selected 181 | // otherwise prompt the user to select the orgs to delete 182 | choices: buildChoices(orgAuths, all), 183 | loop: true, 184 | }); 185 | 186 | const getOrgConfirmationMessage = (selectedOrgs: OrgAuthorization[], originalOrgCount: number): string => { 187 | if (selectedOrgs.length === 1) { 188 | return messages.getMessage('prompt.confirm.single', [selectedOrgs[0].username]); 189 | } 190 | return selectedOrgs.length === originalOrgCount 191 | ? messages.getMessage('prompt.confirm-all') 192 | : messages.getMessage('prompt.confirm', [selectedOrgs.length, selectedOrgs.length > 1 ? 's' : '']); 193 | }; 194 | 195 | /** A whole bunch of custom formatting to make the list look nicer */ 196 | const buildChoices = (orgAuths: OrgAuthorization[], all: boolean): Array => { 197 | const maxUsernameLength = Math.max('Username'.length, ...orgAuths.map((orgAuth) => orgAuth.username.length)); 198 | const maxAliasLength = Math.max( 199 | 'Aliases'.length, 200 | ...orgAuths.map((orgAuth) => (orgAuth.aliases ? orgAuth.aliases.join(',') : '').length) 201 | ); 202 | const maxConfigLength = Math.max( 203 | 'Configs'.length, 204 | ...orgAuths.map((orgAuth) => (orgAuth.configs ? orgAuth.configs.join(',') : '').length) 205 | ); 206 | const maxTypeLength = Math.max( 207 | 'Type'.length, 208 | ...orgAuths.map((orgAuth) => { 209 | if (orgAuth.isScratchOrg) { 210 | return 'Scratch'.length; 211 | } 212 | if (orgAuth.isDevHub) { 213 | return 'DevHub'.length; 214 | } 215 | if (orgAuth.isSandbox) { 216 | return 'Sandbox'.length; 217 | } 218 | return 0; 219 | }) 220 | ); 221 | const choices = orgAuths 222 | .map((orgAuth) => { 223 | const aliasString = (orgAuth.aliases ? orgAuth.aliases.join(',') : '').padEnd(maxAliasLength, ' '); 224 | const configString = (orgAuth.configs ? orgAuth.configs.join(',') : '').padEnd(maxConfigLength, ' '); 225 | const typeString = StandardColors.info( 226 | (orgAuth.isScratchOrg ? 'Scratch' : orgAuth.isDevHub ? 'DevHub' : orgAuth.isSandbox ? 'Sandbox' : '').padEnd( 227 | maxTypeLength, 228 | ' ' 229 | ) 230 | ); 231 | // username - aliases - configs 232 | const key = `${orgAuth.username.padEnd( 233 | maxUsernameLength 234 | )} | ${typeString} | ${aliasString} | ${StandardColors.warning(configString)}`; 235 | return { name: key, value: orgAuth, checked: all, short: `${os.EOL}${orgAuth.username}` }; 236 | }) 237 | .sort((a, b) => a.value.username.localeCompare(b.value.username)); 238 | const userHeader = `${'Username'.padEnd(maxUsernameLength, ' ')}`; 239 | const aliasHeader = `${'Aliases'.padEnd(maxAliasLength, ' ')}`; 240 | const configHeader = `${'Configs'.padEnd(maxConfigLength, ' ')}`; 241 | const typeHeader = `${'Type'.padEnd(maxTypeLength, ' ')}`; 242 | return [new Separator(` ${userHeader} | ${typeHeader} | ${aliasHeader} | ${configHeader}`), ...choices]; 243 | }; 244 | -------------------------------------------------------------------------------- /test/hooks/diagnostics.test.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2025, Salesforce, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | import util from 'node:util'; 17 | import fs from 'node:fs'; 18 | import { expect } from 'chai'; 19 | import { fromStub, StubbedType, stubInterface, stubMethod } from '@salesforce/ts-sinon'; 20 | import { SfDoctor } from '@salesforce/plugin-info'; 21 | import { Lifecycle, Messages } from '@salesforce/core'; 22 | import { TestContext } from '@salesforce/core/testSetup'; 23 | import { hook } from '../../src/hooks/diagnostics.js'; 24 | 25 | const pluginName = '@salesforce/plugin-auth'; 26 | 27 | Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); 28 | const messages = Messages.loadMessages(pluginName, 'diagnostics'); 29 | 30 | describe('Doctor diagnostics', () => { 31 | const sandbox = new TestContext().SANDBOX; 32 | 33 | const key64 = '3e33abf2880f9ce618108343e98298183e33abf2880f9ce618108343e9829818'; 34 | const key32 = '3e33abf2880f9ce618108343e9829818'; 35 | 36 | const sfCryptoV2Orig = process.env.SF_CRYPTO_V2; 37 | const genericKeychainOrig = process.env.SF_USE_GENERIC_UNIX_KEYCHAIN; 38 | 39 | let doctorMock: SfDoctor; 40 | let doctorStubbedType: StubbedType; 41 | let addPluginDataStub: sinon.SinonStub; 42 | let addSuggestionStub: sinon.SinonStub; 43 | let lifecycleEmitStub: sinon.SinonStub; 44 | 45 | beforeEach(() => { 46 | doctorStubbedType = stubInterface(sandbox, { 47 | getDiagnosis: () => ({ 48 | cliConfig: { root: 'rootPath', dataDir: 'dataDir/path' }, 49 | versionDetail: { 50 | pluginVersions: ['my-plugin 3.3.17 (link) /Users/me/dev/my-plugin'], 51 | }, 52 | }), 53 | }); 54 | doctorMock = fromStub(doctorStubbedType); 55 | lifecycleEmitStub = stubMethod(sandbox, Lifecycle.prototype, 'emit'); 56 | 57 | // Shortening these for brevity in tests. 58 | addPluginDataStub = doctorStubbedType.addPluginData; 59 | addSuggestionStub = doctorStubbedType.addSuggestion; 60 | }); 61 | 62 | afterEach(() => { 63 | if (sfCryptoV2Orig !== undefined) { 64 | process.env.SF_CRYPTO_V2 = sfCryptoV2Orig; 65 | } else { 66 | delete process.env.SF_CRYPTO_V2; 67 | } 68 | if (genericKeychainOrig !== undefined) { 69 | process.env.SF_USE_GENERIC_UNIX_KEYCHAIN = genericKeychainOrig; 70 | } else { 71 | delete process.env.SF_USE_GENERIC_UNIX_KEYCHAIN; 72 | } 73 | sandbox.restore(); 74 | }); 75 | 76 | it('should fail when CLI does not support v2 crypto', async () => { 77 | sandbox.stub(util, 'promisify').returns(() => ({ stdout: JSON.stringify([{ version: '6.5.0' }]) })); 78 | process.env.SF_USE_GENERIC_UNIX_KEYCHAIN = 'false'; 79 | 80 | await hook({ doctor: doctorMock }); 81 | 82 | expect(addPluginDataStub.callCount, 'Expected doctor.addPluginData() to be called once').to.equal(1); 83 | expect(addPluginDataStub.args[0][0]).to.equal(pluginName); 84 | // The generic keychain is used on Windows necessarily, so expect true rather 85 | // than using the value of process.env.SF_USE_GENERIC_UNIX_KEYCHAIN 86 | expect(addPluginDataStub.args[0][1]).to.deep.equal({ 87 | isUsingGenericKeychain: process.platform === 'win32' || false, 88 | sfCryptoV2Support: false, 89 | cryptoVersion: 'unknown', 90 | sfCryptoV2: undefined, 91 | }); 92 | expect(addSuggestionStub.callCount, 'Expected doctor.addSuggestion() to be called once').to.equal(1); 93 | expect(addSuggestionStub.args[0][0]).to.equal(messages.getMessage('sfCryptoV2Support')); 94 | expect(lifecycleEmitStub.called).to.be.true; 95 | expect(lifecycleEmitStub.args[0][0]).to.equal('Doctor:diagnostic'); 96 | expect(lifecycleEmitStub.args[0][1]).to.deep.equal({ 97 | testName: `[${pluginName}] CLI supports v2 crypto`, 98 | status: 'fail', 99 | }); 100 | }); 101 | 102 | it('should pass when CLI supports v2 crypto', async () => { 103 | sandbox.stub(util, 'promisify').returns(() => ({ stdout: JSON.stringify([{ version: '6.7.0' }]) })); 104 | process.env.SF_USE_GENERIC_UNIX_KEYCHAIN = 'false'; 105 | await hook({ doctor: doctorMock }); 106 | 107 | expect(addPluginDataStub.callCount, 'Expected doctor.addPluginData() to be called once').to.equal(1); 108 | expect(addPluginDataStub.args[0][0]).to.equal(pluginName); 109 | // The generic keychain is used on Windows necessarily, so expect true rather 110 | // than using the value of process.env.SF_USE_GENERIC_UNIX_KEYCHAIN 111 | expect(addPluginDataStub.args[0][1]).to.deep.equal({ 112 | isUsingGenericKeychain: process.platform === 'win32' || false, 113 | sfCryptoV2Support: true, 114 | cryptoVersion: 'unknown', 115 | sfCryptoV2: undefined, 116 | }); 117 | expect(addSuggestionStub.callCount, 'Expected doctor.addSuggestion() NOT to be called').to.equal(0); 118 | expect(lifecycleEmitStub.called).to.be.true; 119 | expect(lifecycleEmitStub.args[0][0]).to.equal('Doctor:diagnostic'); 120 | expect(lifecycleEmitStub.args[0][1]).to.deep.equal({ 121 | testName: `[${pluginName}] CLI supports v2 crypto`, 122 | status: 'pass', 123 | }); 124 | }); 125 | 126 | it('should fail when v2 crypto is used without v2 crypto CLI support', async () => { 127 | sandbox.stub(util, 'promisify').returns(() => ({ stdout: JSON.stringify([{ version: '6.5.0' }]) })); 128 | process.env.SF_USE_GENERIC_UNIX_KEYCHAIN = 'true'; 129 | sandbox.stub(fs, 'readFileSync').returns(JSON.stringify({ key: key64 })); 130 | 131 | await hook({ doctor: doctorMock }); 132 | 133 | expect(addPluginDataStub.callCount, 'Expected doctor.addPluginData() to be called once').to.equal(1); 134 | expect(addPluginDataStub.args[0][0]).to.equal(pluginName); 135 | expect(addPluginDataStub.args[0][1]).to.deep.equal({ 136 | isUsingGenericKeychain: true, 137 | sfCryptoV2Support: false, 138 | cryptoVersion: 'v2', 139 | sfCryptoV2: undefined, 140 | }); 141 | expect(addSuggestionStub.callCount, 'Expected doctor.addSuggestion() to be called twice').to.equal(2); 142 | expect(addSuggestionStub.args[0][0]).to.equal(messages.getMessage('sfCryptoV2Support')); 143 | expect(lifecycleEmitStub.called).to.be.true; 144 | expect(lifecycleEmitStub.args[0][0]).to.equal('Doctor:diagnostic'); 145 | expect(lifecycleEmitStub.args[0][1]).to.deep.equal({ 146 | testName: `[${pluginName}] CLI supports v2 crypto`, 147 | status: 'fail', 148 | }); 149 | expect(addSuggestionStub.args[1][0]).to.equal(messages.getMessage('sfCryptoV2Unstable')); 150 | expect(lifecycleEmitStub.args[1][0]).to.equal('Doctor:diagnostic'); 151 | expect(lifecycleEmitStub.args[1][1]).to.deep.equal({ 152 | testName: `[${pluginName}] CLI using stable v2 crypto`, 153 | status: 'fail', 154 | }); 155 | }); 156 | 157 | it('should pass when v2 crypto is used with v2 crypto CLI support', async () => { 158 | sandbox.stub(util, 'promisify').returns(() => ({ stdout: JSON.stringify([{ version: '6.7.0' }]) })); 159 | process.env.SF_USE_GENERIC_UNIX_KEYCHAIN = 'true'; 160 | sandbox.stub(fs, 'readFileSync').returns(JSON.stringify({ key: key64 })); 161 | 162 | await hook({ doctor: doctorMock }); 163 | 164 | expect(addPluginDataStub.callCount, 'Expected doctor.addPluginData() to be called once').to.equal(1); 165 | expect(addPluginDataStub.args[0][0]).to.equal(pluginName); 166 | expect(addPluginDataStub.args[0][1]).to.deep.equal({ 167 | isUsingGenericKeychain: true, 168 | sfCryptoV2Support: true, 169 | cryptoVersion: 'v2', 170 | sfCryptoV2: undefined, 171 | }); 172 | expect(addSuggestionStub.callCount, 'Expected doctor.addSuggestion() NOT to be called').to.equal(0); 173 | expect(lifecycleEmitStub.callCount, 'Expected Lifecycle.emit() to be called twice').to.equal(2); 174 | expect(lifecycleEmitStub.args[0][0]).to.equal('Doctor:diagnostic'); 175 | expect(lifecycleEmitStub.args[0][1]).to.deep.equal({ 176 | testName: `[${pluginName}] CLI supports v2 crypto`, 177 | status: 'pass', 178 | }); 179 | expect(lifecycleEmitStub.args[1][0]).to.equal('Doctor:diagnostic'); 180 | expect(lifecycleEmitStub.args[1][1]).to.deep.equal({ 181 | testName: `[${pluginName}] CLI using stable v2 crypto`, 182 | status: 'pass', 183 | }); 184 | }); 185 | 186 | it('should warn when (known) v1 crypto is used with SF_CRYPTO_V2=true and v2 crypto CLI support', async () => { 187 | sandbox.stub(util, 'promisify').returns(() => ({ stdout: JSON.stringify([{ version: '6.7.0' }]) })); 188 | process.env.SF_USE_GENERIC_UNIX_KEYCHAIN = 'true'; 189 | process.env.SF_CRYPTO_V2 = 'true'; 190 | sandbox.stub(fs, 'readFileSync').returns(JSON.stringify({ key: key32 })); 191 | 192 | await hook({ doctor: doctorMock }); 193 | 194 | expect(addPluginDataStub.callCount, 'Expected doctor.addPluginData() to be called once').to.equal(1); 195 | expect(addPluginDataStub.args[0][0]).to.equal(pluginName); 196 | expect(addPluginDataStub.args[0][1]).to.deep.equal({ 197 | isUsingGenericKeychain: true, 198 | sfCryptoV2Support: true, 199 | cryptoVersion: 'v1', 200 | sfCryptoV2: 'true', 201 | }); 202 | expect(addSuggestionStub.callCount, 'Expected doctor.addSuggestion() to be called once').to.equal(1); 203 | expect(addSuggestionStub.args[0][0]).to.equal(messages.getMessage('sfCryptoV2Desired')); 204 | expect(lifecycleEmitStub.called).to.be.true; 205 | expect(lifecycleEmitStub.args[1][0]).to.equal('Doctor:diagnostic'); 206 | expect(lifecycleEmitStub.args[1][1]).to.deep.equal({ 207 | testName: `[${pluginName}] CLI using stable v1 crypto`, 208 | status: 'warn', 209 | }); 210 | }); 211 | 212 | it('should pass when (known) v1 crypto is used without SF_CRYPTO_V2', async () => { 213 | sandbox.stub(util, 'promisify').returns(() => ({ stdout: JSON.stringify([{ version: '6.7.0' }]) })); 214 | process.env.SF_USE_GENERIC_UNIX_KEYCHAIN = 'true'; 215 | sandbox.stub(fs, 'readFileSync').returns(JSON.stringify({ key: key32 })); 216 | 217 | await hook({ doctor: doctorMock }); 218 | 219 | expect(addPluginDataStub.callCount, 'Expected doctor.addPluginData() to be called once').to.equal(1); 220 | expect(addPluginDataStub.args[0][0]).to.equal(pluginName); 221 | expect(addPluginDataStub.args[0][1]).to.deep.equal({ 222 | isUsingGenericKeychain: true, 223 | sfCryptoV2Support: true, 224 | cryptoVersion: 'v1', 225 | sfCryptoV2: undefined, 226 | }); 227 | expect(addSuggestionStub.callCount, 'Expected doctor.addSuggestion() NOT to be called').to.equal(0); 228 | expect(lifecycleEmitStub.callCount, 'Expected Lifecycle.emit() to be called twice').to.equal(2); 229 | expect(lifecycleEmitStub.args[0][0]).to.equal('Doctor:diagnostic'); 230 | expect(lifecycleEmitStub.args[0][1]).to.deep.equal({ 231 | testName: `[${pluginName}] CLI supports v2 crypto`, 232 | status: 'pass', 233 | }); 234 | expect(lifecycleEmitStub.args[1][0]).to.equal('Doctor:diagnostic'); 235 | expect(lifecycleEmitStub.args[1][1]).to.deep.equal({ 236 | testName: `[${pluginName}] CLI using stable v1 crypto`, 237 | status: 'pass', 238 | }); 239 | }); 240 | }); 241 | --------------------------------------------------------------------------------