diff --git a/command-snapshot.json b/command-snapshot.json index 1c5140eb..b56050f2 100644 --- a/command-snapshot.json +++ b/command-snapshot.json @@ -128,18 +128,21 @@ "setdefaultusername", "v" ], - "flagChars": ["a", "b", "d", "i", "p", "r", "s"], + "flagChars": ["a", "b", "c", "d", "i", "p", "r", "s"], "flags": [ "alias", "browser", + "client-app", "client-id", "flags-dir", "instance-url", "json", "loglevel", "no-prompt", + "scopes", "set-default", - "set-default-dev-hub" + "set-default-dev-hub", + "username" ], "plugin": "@salesforce/plugin-auth" }, @@ -147,8 +150,8 @@ "alias": ["force:auth:logout", "auth:logout"], "command": "org:logout", "flagAliases": ["noprompt", "targetusername", "u"], - "flagChars": ["a", "o", "p"], - "flags": ["all", "flags-dir", "json", "loglevel", "no-prompt", "target-org"], + "flagChars": ["a", "c", "o", "p"], + "flags": ["all", "client-app", "flags-dir", "json", "loglevel", "no-prompt", "target-org"], "plugin": "@salesforce/plugin-auth" } ] diff --git a/messages/logout.md b/messages/logout.md index 293baaf8..21cb1ec0 100644 --- a/messages/logout.md +++ b/messages/logout.md @@ -10,6 +10,8 @@ The process is similar if you specify --all, except that in the initial list of 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. +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. + # examples - Interactively select the orgs to log out of: @@ -40,10 +42,26 @@ Include all authenticated orgs. All orgs includes Dev Hubs, sandboxes, DE orgs, and expired, deleted, and unknown-status scratch orgs. +# flags.client-app.summary + +Client app to log out of. + # logoutOrgCommandSuccess Successfully logged out of orgs: %s +# logoutClientAppSuccess + +Successfully logged out of "%s" client app for user %s. + +# error.noLinkedApps + +%s doesn't have any linked client apps. + +# error.invalidClientApp + +%s doesn't have a linked client app named "%s". + # noOrgsFound No orgs found to log out of. @@ -82,6 +100,6 @@ You must specify a target-org (or default target-org config is set) or use --all # warning.NoAuthFoundForTargetOrg -No authenticated org found with the %s username or alias. +No authenticated org found with the %s username or alias. NOTE: Starting September 2025, this warning will be converted to an error. As a result, the exit code when you try to log out of an unauthenticated org will change from 0 to 1. diff --git a/messages/web.login.md b/messages/web.login.md index 1176f1c7..b22cdfbd 100644 --- a/messages/web.login.md +++ b/messages/web.login.md @@ -42,6 +42,22 @@ Browser in which to open the org. 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. +# flags.client-app.summary + +Name of the connected app or external client app to link to the user. + +# flags.username.summary + +Username to link client app to. + +# flags.scopes.summary + +Authentication scopes to request. + +# linkedClientApp + +Successfully linked "%s" client app to %s. + # deviceWarning "<%= config.bin %> <%= command.id %>" doesn't work when authorizing to a headless environment. Use "<%= config.bin %> org login device" instead. diff --git a/package.json b/package.json index 40abd6ef..44493e61 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "@inquirer/checkbox": "^2.5.0", "@inquirer/select": "^2.5.0", "@oclif/core": "^4", - "@salesforce/core": "^8.12.0", + "@salesforce/core": "^8.13.0", "@salesforce/kit": "^3.2.3", "@salesforce/plugin-info": "^3.4.65", "@salesforce/sf-plugins-core": "^12.2.2", diff --git a/schemas/org-login-access__token.json b/schemas/org-login-access__token.json index 23f3c1da..e7d10ce0 100644 --- a/schemas/org-login-access__token.json +++ b/schemas/org-login-access__token.json @@ -5,6 +5,32 @@ "AuthFields": { "type": "object", "properties": { + "clientApps": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "clientId": { + "type": "string" + }, + "clientSecret": { + "type": "string" + }, + "accessToken": { + "type": "string" + }, + "refreshToken": { + "type": "string" + }, + "oauthFlow": { + "type": "string", + "const": "web" + } + }, + "required": ["clientId", "accessToken", "refreshToken", "oauthFlow"], + "additionalProperties": false + } + }, "accessToken": { "type": "string" }, diff --git a/schemas/org-login-device.json b/schemas/org-login-device.json index a3c447be..160350f6 100644 --- a/schemas/org-login-device.json +++ b/schemas/org-login-device.json @@ -22,6 +22,32 @@ "verification_uri": { "type": "string" }, + "clientApps": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "clientId": { + "type": "string" + }, + "clientSecret": { + "type": "string" + }, + "accessToken": { + "type": "string" + }, + "refreshToken": { + "type": "string" + }, + "oauthFlow": { + "type": "string", + "const": "web" + } + }, + "required": ["clientId", "accessToken", "refreshToken", "oauthFlow"], + "additionalProperties": false + } + }, "accessToken": { "type": "string" }, diff --git a/schemas/org-login-jwt.json b/schemas/org-login-jwt.json index 23f3c1da..e7d10ce0 100644 --- a/schemas/org-login-jwt.json +++ b/schemas/org-login-jwt.json @@ -5,6 +5,32 @@ "AuthFields": { "type": "object", "properties": { + "clientApps": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "clientId": { + "type": "string" + }, + "clientSecret": { + "type": "string" + }, + "accessToken": { + "type": "string" + }, + "refreshToken": { + "type": "string" + }, + "oauthFlow": { + "type": "string", + "const": "web" + } + }, + "required": ["clientId", "accessToken", "refreshToken", "oauthFlow"], + "additionalProperties": false + } + }, "accessToken": { "type": "string" }, diff --git a/schemas/org-login-sfdx__url.json b/schemas/org-login-sfdx__url.json index 23f3c1da..e7d10ce0 100644 --- a/schemas/org-login-sfdx__url.json +++ b/schemas/org-login-sfdx__url.json @@ -5,6 +5,32 @@ "AuthFields": { "type": "object", "properties": { + "clientApps": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "clientId": { + "type": "string" + }, + "clientSecret": { + "type": "string" + }, + "accessToken": { + "type": "string" + }, + "refreshToken": { + "type": "string" + }, + "oauthFlow": { + "type": "string", + "const": "web" + } + }, + "required": ["clientId", "accessToken", "refreshToken", "oauthFlow"], + "additionalProperties": false + } + }, "accessToken": { "type": "string" }, diff --git a/schemas/org-login-web.json b/schemas/org-login-web.json index 23f3c1da..e7d10ce0 100644 --- a/schemas/org-login-web.json +++ b/schemas/org-login-web.json @@ -5,6 +5,32 @@ "AuthFields": { "type": "object", "properties": { + "clientApps": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "clientId": { + "type": "string" + }, + "clientSecret": { + "type": "string" + }, + "accessToken": { + "type": "string" + }, + "refreshToken": { + "type": "string" + }, + "oauthFlow": { + "type": "string", + "const": "web" + } + }, + "required": ["clientId", "accessToken", "refreshToken", "oauthFlow"], + "additionalProperties": false + } + }, "accessToken": { "type": "string" }, diff --git a/src/commands/org/login/web.ts b/src/commands/org/login/web.ts index a64822d9..6a80115c 100644 --- a/src/commands/org/login/web.ts +++ b/src/commands/org/login/web.ts @@ -69,6 +69,18 @@ export default class LoginWeb extends SfCommand { aliases: ['noprompt'], }), loglevel, + 'client-app': Flags.string({ + char: 'c', + summary: messages.getMessage('flags.client-app.summary'), + dependsOn: ['username'], + }), + username: Flags.string({ + summary: messages.getMessage('flags.username.summary'), + dependsOn: ['client-app'], + }), + scopes: Flags.string({ + summary: messages.getMessage('flags.scopes.summary'), + }), }; private logger = Logger.childFromRoot(this.constructor.name); @@ -81,6 +93,28 @@ export default class LoginWeb extends SfCommand { if (await common.shouldExitCommand(flags['no-prompt'])) return {}; + // Add ca/eca to already existing auth info. + if (flags['client-app'] && flags.username) { + // 1. get username authinfo + const userAuthInfo = await AuthInfo.create({ + username: flags.username, + }); + + const authFields = userAuthInfo.getFields(true); + + // 2. web-auth and save name, clientId, accessToken, and refreshToken in `apps` object + const oauthConfig: OAuth2Config = { + loginUrl: authFields.loginUrl, + clientId: flags['client-id'], + ...{ clientSecret: await this.secretPrompt({ message: commonMessages.getMessage('clientSecretStdin') }) }, + }; + + await this.executeLoginFlow(oauthConfig, flags.browser, flags['client-app'], flags.username, flags.scopes); + + this.logSuccess(messages.getMessage('linkedClientApp', [flags['client-app'], flags.username])); + return userAuthInfo.getFields(true); + } + const oauthConfig: OAuth2Config = { loginUrl: await common.resolveLoginUrl(flags['instance-url']?.href), clientId: flags['client-id'], @@ -113,12 +147,28 @@ export default class LoginWeb extends SfCommand { // leave it because it's stubbed in the test // eslint-disable-next-line class-methods-use-this - private async executeLoginFlow(oauthConfig: OAuth2Config, browser?: string): Promise { - const oauthServer = await WebOAuthServer.create({ oauthConfig }); + private async executeLoginFlow( + oauthConfig: OAuth2Config, + browser?: string, + app?: string, + username?: string, + scopes?: string + ): Promise { + // The server handles 2 possible auth scenarios: + // a. 1st time auth, creates auth file. + // b. Add CA/ECA to existing auth. + const oauthServer = await WebOAuthServer.create({ + oauthConfig: { + ...oauthConfig, + scope: scopes, + }, + clientApp: app, + username, + }); await oauthServer.start(); - const app = browser && browser in apps ? (browser as AppName) : undefined; - const openOptions = app ? { app: { name: apps[app] }, wait: false } : { wait: false }; - this.logger.debug(`Opening browser ${app ?? ''}`); + const browserApp = browser && browser in apps ? (browser as AppName) : undefined; + const openOptions = browserApp ? { app: { name: apps[browserApp] }, wait: false } : { wait: false }; + this.logger.debug(`Opening browser ${browserApp ?? ''}`); // the following `childProcess` wrapper is needed to catch when `open` fails to open a browser. await open(oauthServer.getAuthorizationUrl(), openOptions).then( (childProcess) => @@ -126,13 +176,13 @@ export default class LoginWeb extends SfCommand { // https://nodejs.org/api/child_process.html#event-exit childProcess.on('exit', (code) => { if (code && code > 0) { - this.logger.debug(`Failed to open browser ${app ?? ''}`); - reject(messages.createError('error.cannotOpenBrowser', [app], [app])); + this.logger.debug(`Failed to open browser ${browserApp ?? ''}`); + reject(messages.createError('error.cannotOpenBrowser', [browserApp], [browserApp])); } // If the process exited, code is the final exit code of the process, otherwise null. // resolve on null just to be safe, worst case the browser didn't open and the CLI just hangs. if (code === null || code === 0) { - this.logger.debug(`Successfully opened browser ${app ?? ''}`); + this.logger.debug(`Successfully opened browser ${browserApp ?? ''}`); resolve(childProcess); } }); diff --git a/src/commands/org/logout.ts b/src/commands/org/logout.ts index bc1f6ddd..2030eb74 100644 --- a/src/commands/org/logout.ts +++ b/src/commands/org/logout.ts @@ -41,6 +41,11 @@ export default class Logout extends SfCommand { aliases: ['targetusername', 'u'], deprecateAliases: true, }), + 'client-app': Flags.string({ + char: 'c', + summary: messages.getMessage('flags.client-app.summary'), + dependsOn: ['target-org'], + }), all: Flags.boolean({ char: 'a', summary: messages.getMessage('flags.all.summary'), @@ -59,6 +64,7 @@ export default class Logout extends SfCommand { loglevel, }; + // eslint-disable-next-line complexity public async run(): Promise { const { flags } = await this.parse(Logout); const targetUsername = @@ -70,6 +76,10 @@ export default class Logout extends SfCommand { throw messages.createError('noOrgSpecifiedWithNoPrompt'); } + if (flags['client-app']) { + return this.logoutClientApp(flags['client-app'], targetUsername); + } + if (this.jsonEnabled() && !targetUsername && !flags.all) { throw messages.createError('noOrgSpecifiedWithJson'); } @@ -114,6 +124,38 @@ export default class Logout extends SfCommand { } } + private async logoutClientApp(clientApp: string, username: string): Promise { + const authInfo = await AuthInfo.create({ + username, + }); + + const authFields = authInfo.getFields(true); + if (!authFields.clientApps) { + throw messages.createError('error.noLinkedApps', [username]); + } + + if (authFields.clientApps && !(clientApp in authFields.clientApps)) { + throw messages.createError('error.invalidClientApp', [username, clientApp]); + } + + // if logging out of the last client app, remove the whole `clientApps` object from the auth fields + if (Object.keys(authFields.clientApps).length === 1) { + await authInfo.save({ + clientApps: undefined, + }); + } else { + // just remove the specific client app entry + delete authFields.clientApps[clientApp]; + + await authInfo.save({ + clientApps: authFields.clientApps, + }); + } + + this.logSuccess(messages.getMessage('logoutClientAppSuccess', [clientApp, username])); + return [clientApp]; + } + /** Warning about logging out of a scratch org and losing access to it */ private maybeWarnScratchOrgs(orgs: OrgAuthorization[]): OrgAuthorization[] { if (orgs.some((org) => org.isScratchOrg)) { diff --git a/test/commands/org/login/login.jwt.test.ts b/test/commands/org/login/login.jwt.test.ts index 7eda80a1..66fd49c5 100644 --- a/test/commands/org/login/login.jwt.test.ts +++ b/test/commands/org/login/login.jwt.test.ts @@ -184,7 +184,7 @@ describe('org:login:jwt', () => { await LoginJwt.run(['-u', testData.username, '-f', 'path/to/key.json', '-i', '123456INVALID', '--json']); expect.fail('Should have thrown an error'); } catch (e) { - expect(e).to.be.instanceOf(SfError); + expect(e).to.be.instanceOf(Error); const jwtAuthError = e as SfError; expect(jwtAuthError.message).to.include('We encountered a JSON web token error'); expect(jwtAuthError.message).to.include('invalid client id'); diff --git a/yarn.lock b/yarn.lock index 968884d4..85e2504c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1688,10 +1688,10 @@ strip-ansi "6.0.1" ts-retry-promise "^0.8.1" -"@salesforce/core@^8.10.0", "@salesforce/core@^8.11.0", "@salesforce/core@^8.11.1", "@salesforce/core@^8.11.2", "@salesforce/core@^8.12.0", "@salesforce/core@^8.5.1", "@salesforce/core@^8.8.0": - version "8.12.0" - resolved "https://registry.yarnpkg.com/@salesforce/core/-/core-8.12.0.tgz#a458cc3e39f4e7df57d94f0deaaa0fd0660b18c9" - integrity sha512-LJIjoQ3UQJ1r/xxdQcaG5bU8MfxeO/LJhrfK/7LZeHVtp1iOIgedbwPuVNzTzYciDWh8elborarrPM4uWjtu5g== +"@salesforce/core@^8.10.0", "@salesforce/core@^8.11.0", "@salesforce/core@^8.11.1", "@salesforce/core@^8.11.2", "@salesforce/core@^8.13.0", "@salesforce/core@^8.5.1", "@salesforce/core@^8.8.0": + version "8.13.0" + resolved "https://registry.yarnpkg.com/@salesforce/core/-/core-8.13.0.tgz#ffc00a776c60a401d4385abfeb6cd7fee0d90f3b" + integrity sha512-FyAn0UGa93D0N++8poeJt7yEaWQH++qxrv/Wf4TjNaUCLoh19g57lrXuos3qDJPr8Ut4x6QjVxEc49XLy+vBkw== dependencies: "@jsforce/jsforce-node" "^3.8.2" "@salesforce/kit" "^3.2.2" @@ -2248,21 +2248,7 @@ "@smithy/types" "^4.2.0" tslib "^2.6.2" -"@smithy/signature-v4@^5.0.2": - version "5.0.2" - resolved "https://registry.yarnpkg.com/@smithy/signature-v4/-/signature-v4-5.0.2.tgz#363854e946fbc5bc206ff82e79ada5d5c14be640" - integrity sha512-Mz+mc7okA73Lyz8zQKJNyr7lIcHLiPYp0+oiqiMNc/t7/Kf2BENs5d63pEj7oPqdjaum6g0Fc8wC78dY1TgtXw== - dependencies: - "@smithy/is-array-buffer" "^4.0.0" - "@smithy/protocol-http" "^5.1.0" - "@smithy/types" "^4.2.0" - "@smithy/util-hex-encoding" "^4.0.0" - "@smithy/util-middleware" "^4.0.2" - "@smithy/util-uri-escape" "^4.0.0" - "@smithy/util-utf8" "^4.0.0" - tslib "^2.6.2" - -"@smithy/signature-v4@^5.1.0": +"@smithy/signature-v4@^5.0.2", "@smithy/signature-v4@^5.1.0": version "5.1.0" resolved "https://registry.yarnpkg.com/@smithy/signature-v4/-/signature-v4-5.1.0.tgz#2c56e5b278482b04383d84ea2c07b7f0a8eb8f63" integrity sha512-4t5WX60sL3zGJF/CtZsUQTs3UrZEDO2P7pEaElrekbLqkWPYkgqNW1oeiNYC6xXifBnT9dVBOnNQRvOE9riU9w==