Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions command-snapshot.json
Original file line number Diff line number Diff line change
Expand Up @@ -128,27 +128,30 @@
"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"
},
{
"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"
}
]
20 changes: 19 additions & 1 deletion messages/logout.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
16 changes: 16 additions & 0 deletions messages/web.login.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
26 changes: 26 additions & 0 deletions schemas/org-login-access__token.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
26 changes: 26 additions & 0 deletions schemas/org-login-device.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
26 changes: 26 additions & 0 deletions schemas/org-login-jwt.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
26 changes: 26 additions & 0 deletions schemas/org-login-sfdx__url.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
26 changes: 26 additions & 0 deletions schemas/org-login-web.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
66 changes: 58 additions & 8 deletions src/commands/org/login/web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,18 @@ export default class LoginWeb extends SfCommand<AuthFields> {
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);
Expand All @@ -81,6 +93,28 @@ export default class LoginWeb extends SfCommand<AuthFields> {

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'],
Expand Down Expand Up @@ -113,26 +147,42 @@ export default class LoginWeb extends SfCommand<AuthFields> {

// 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<AuthInfo> {
const oauthServer = await WebOAuthServer.create({ oauthConfig });
private async executeLoginFlow(
oauthConfig: OAuth2Config,
browser?: string,
app?: string,
username?: string,
scopes?: string
): Promise<AuthInfo> {
// 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) =>
new Promise((resolve, reject) => {
// 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);
}
});
Expand Down
Loading
Loading