Skip to content

Commit a20e043

Browse files
feat: org login web support for multi auth W-18394868 (#1306)
* feat: `org login web` support for multi auth * chore: add scopes flag * chore: update schemas * chore: bump core * chore: review * chore: rename apps -> clientApps * chore: logout * fix: messages (#1322) * chore: bump core --------- Co-authored-by: Juliet Shackell <63259011+jshackell-sfdc@users.noreply.github.com>
1 parent a8e7052 commit a20e043

13 files changed

Lines changed: 279 additions & 34 deletions

File tree

command-snapshot.json

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -128,27 +128,30 @@
128128
"setdefaultusername",
129129
"v"
130130
],
131-
"flagChars": ["a", "b", "d", "i", "p", "r", "s"],
131+
"flagChars": ["a", "b", "c", "d", "i", "p", "r", "s"],
132132
"flags": [
133133
"alias",
134134
"browser",
135+
"client-app",
135136
"client-id",
136137
"flags-dir",
137138
"instance-url",
138139
"json",
139140
"loglevel",
140141
"no-prompt",
142+
"scopes",
141143
"set-default",
142-
"set-default-dev-hub"
144+
"set-default-dev-hub",
145+
"username"
143146
],
144147
"plugin": "@salesforce/plugin-auth"
145148
},
146149
{
147150
"alias": ["force:auth:logout", "auth:logout"],
148151
"command": "org:logout",
149152
"flagAliases": ["noprompt", "targetusername", "u"],
150-
"flagChars": ["a", "o", "p"],
151-
"flags": ["all", "flags-dir", "json", "loglevel", "no-prompt", "target-org"],
153+
"flagChars": ["a", "c", "o", "p"],
154+
"flags": ["all", "client-app", "flags-dir", "json", "loglevel", "no-prompt", "target-org"],
152155
"plugin": "@salesforce/plugin-auth"
153156
}
154157
]

messages/logout.md

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ The process is similar if you specify --all, except that in the initial list of
1010

1111
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.
1212

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+
1315
# examples
1416

1517
- Interactively select the orgs to log out of:
@@ -40,10 +42,26 @@ Include all authenticated orgs.
4042

4143
All orgs includes Dev Hubs, sandboxes, DE orgs, and expired, deleted, and unknown-status scratch orgs.
4244

45+
# flags.client-app.summary
46+
47+
Client app to log out of.
48+
4349
# logoutOrgCommandSuccess
4450

4551
Successfully logged out of orgs: %s
4652

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+
4765
# noOrgsFound
4866

4967
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
82100

83101
# warning.NoAuthFoundForTargetOrg
84102

85-
No authenticated org found with the %s username or alias.
103+
No authenticated org found with the %s username or alias.
86104

87105
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.

messages/web.login.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,22 @@ Browser in which to open the org.
4242

4343
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.
4444

45+
# flags.client-app.summary
46+
47+
Name of the connected app or external client app to link to the user.
48+
49+
# flags.username.summary
50+
51+
Username to link client app to.
52+
53+
# flags.scopes.summary
54+
55+
Authentication scopes to request.
56+
57+
# linkedClientApp
58+
59+
Successfully linked "%s" client app to %s.
60+
4561
# deviceWarning
4662

4763
"<%= config.bin %> <%= command.id %>" doesn't work when authorizing to a headless environment. Use "<%= config.bin %> org login device" instead.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
"@inquirer/checkbox": "^2.5.0",
99
"@inquirer/select": "^2.5.0",
1010
"@oclif/core": "^4",
11-
"@salesforce/core": "^8.12.0",
11+
"@salesforce/core": "^8.13.0",
1212
"@salesforce/kit": "^3.2.3",
1313
"@salesforce/plugin-info": "^3.4.65",
1414
"@salesforce/sf-plugins-core": "^12.2.2",

schemas/org-login-access__token.json

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,32 @@
55
"AuthFields": {
66
"type": "object",
77
"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+
},
834
"accessToken": {
935
"type": "string"
1036
},

schemas/org-login-device.json

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,32 @@
2222
"verification_uri": {
2323
"type": "string"
2424
},
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+
},
2551
"accessToken": {
2652
"type": "string"
2753
},

schemas/org-login-jwt.json

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,32 @@
55
"AuthFields": {
66
"type": "object",
77
"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+
},
834
"accessToken": {
935
"type": "string"
1036
},

schemas/org-login-sfdx__url.json

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,32 @@
55
"AuthFields": {
66
"type": "object",
77
"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+
},
834
"accessToken": {
935
"type": "string"
1036
},

schemas/org-login-web.json

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,32 @@
55
"AuthFields": {
66
"type": "object",
77
"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+
},
834
"accessToken": {
935
"type": "string"
1036
},

src/commands/org/login/web.ts

Lines changed: 58 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,18 @@ export default class LoginWeb extends SfCommand<AuthFields> {
6969
aliases: ['noprompt'],
7070
}),
7171
loglevel,
72+
'client-app': Flags.string({
73+
char: 'c',
74+
summary: messages.getMessage('flags.client-app.summary'),
75+
dependsOn: ['username'],
76+
}),
77+
username: Flags.string({
78+
summary: messages.getMessage('flags.username.summary'),
79+
dependsOn: ['client-app'],
80+
}),
81+
scopes: Flags.string({
82+
summary: messages.getMessage('flags.scopes.summary'),
83+
}),
7284
};
7385

7486
private logger = Logger.childFromRoot(this.constructor.name);
@@ -81,6 +93,28 @@ export default class LoginWeb extends SfCommand<AuthFields> {
8193

8294
if (await common.shouldExitCommand(flags['no-prompt'])) return {};
8395

96+
// Add ca/eca to already existing auth info.
97+
if (flags['client-app'] && flags.username) {
98+
// 1. get username authinfo
99+
const userAuthInfo = await AuthInfo.create({
100+
username: flags.username,
101+
});
102+
103+
const authFields = userAuthInfo.getFields(true);
104+
105+
// 2. web-auth and save name, clientId, accessToken, and refreshToken in `apps` object
106+
const oauthConfig: OAuth2Config = {
107+
loginUrl: authFields.loginUrl,
108+
clientId: flags['client-id'],
109+
...{ clientSecret: await this.secretPrompt({ message: commonMessages.getMessage('clientSecretStdin') }) },
110+
};
111+
112+
await this.executeLoginFlow(oauthConfig, flags.browser, flags['client-app'], flags.username, flags.scopes);
113+
114+
this.logSuccess(messages.getMessage('linkedClientApp', [flags['client-app'], flags.username]));
115+
return userAuthInfo.getFields(true);
116+
}
117+
84118
const oauthConfig: OAuth2Config = {
85119
loginUrl: await common.resolveLoginUrl(flags['instance-url']?.href),
86120
clientId: flags['client-id'],
@@ -113,26 +147,42 @@ export default class LoginWeb extends SfCommand<AuthFields> {
113147

114148
// leave it because it's stubbed in the test
115149
// eslint-disable-next-line class-methods-use-this
116-
private async executeLoginFlow(oauthConfig: OAuth2Config, browser?: string): Promise<AuthInfo> {
117-
const oauthServer = await WebOAuthServer.create({ oauthConfig });
150+
private async executeLoginFlow(
151+
oauthConfig: OAuth2Config,
152+
browser?: string,
153+
app?: string,
154+
username?: string,
155+
scopes?: string
156+
): Promise<AuthInfo> {
157+
// The server handles 2 possible auth scenarios:
158+
// a. 1st time auth, creates auth file.
159+
// b. Add CA/ECA to existing auth.
160+
const oauthServer = await WebOAuthServer.create({
161+
oauthConfig: {
162+
...oauthConfig,
163+
scope: scopes,
164+
},
165+
clientApp: app,
166+
username,
167+
});
118168
await oauthServer.start();
119-
const app = browser && browser in apps ? (browser as AppName) : undefined;
120-
const openOptions = app ? { app: { name: apps[app] }, wait: false } : { wait: false };
121-
this.logger.debug(`Opening browser ${app ?? ''}`);
169+
const browserApp = browser && browser in apps ? (browser as AppName) : undefined;
170+
const openOptions = browserApp ? { app: { name: apps[browserApp] }, wait: false } : { wait: false };
171+
this.logger.debug(`Opening browser ${browserApp ?? ''}`);
122172
// the following `childProcess` wrapper is needed to catch when `open` fails to open a browser.
123173
await open(oauthServer.getAuthorizationUrl(), openOptions).then(
124174
(childProcess) =>
125175
new Promise((resolve, reject) => {
126176
// https://nodejs.org/api/child_process.html#event-exit
127177
childProcess.on('exit', (code) => {
128178
if (code && code > 0) {
129-
this.logger.debug(`Failed to open browser ${app ?? ''}`);
130-
reject(messages.createError('error.cannotOpenBrowser', [app], [app]));
179+
this.logger.debug(`Failed to open browser ${browserApp ?? ''}`);
180+
reject(messages.createError('error.cannotOpenBrowser', [browserApp], [browserApp]));
131181
}
132182
// If the process exited, code is the final exit code of the process, otherwise null.
133183
// resolve on null just to be safe, worst case the browser didn't open and the CLI just hangs.
134184
if (code === null || code === 0) {
135-
this.logger.debug(`Successfully opened browser ${app ?? ''}`);
185+
this.logger.debug(`Successfully opened browser ${browserApp ?? ''}`);
136186
resolve(childProcess);
137187
}
138188
});

0 commit comments

Comments
 (0)