8000 feat(auth): link connected apps to a user W-18394868 (#1188) · forcedotcom/sfdx-core@9c369a4 · GitHub
[go: up one dir, main page]

Skip to content

Commit 9c369a4

Browse files
feat(auth): link conn 8000 ected apps to a user W-18394868 (#1188)
* chore: rebase * chore: lint * chore: remove puppetter * chore(release): 8.12.1-dev.0 [skip ci] * chore: review * chore: review * fix: merge previous apps * chore: real runtime check * chore: rename apps -> clientApps * fix: refresh properly writes client apps info --------- Co-authored-by: svc-cli-bot <Svc_cli_bot@salesforce.com>
1 parent 40e32ac commit 9c369a4

File tree

7 files changed

+252
-31
lines changed

7 files changed

+252
-31
lines changed

messages/auth.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ HTTP response contains html content. Check that the org exists and can be reache
3232

3333
The device authorization request timed out. After executing force:auth:device:login, you must approve access to the device within 10 minutes. This can happen if the URL wasn’t copied into the browser, login was not attempted, or the 2FA process was not completed within 10 minutes. Request authorization again.
3434

35+
# error.missingWebOauthServer.options
36+
37+
You must specify both an `clientApp` and `username` if you intend the server to link a connected app to a user.
38+
3539
# serverErrorHTMLResponse
3640

3741
<html><head><style>body {background-color:#F4F6F9; font-family: Arial, sans-serif; font-size: 0.8125rem; line-height: 1.5rem; color: #16325c;} #center {margin: auto; width: 370px; padding: 100px 0px 20px;} #logo-container {margin-left: auto; margin-right: auto; text-align: center;} #logo {max-width: 180px; max-height: 113px; margin-bottom: 2rem; border: 0;} #header {font-size: 1.5rem; text-align: center; margin-bottom: 1rem;} #message {background-color: #FFFFFF; margin: 0px auto; padding: 1.25rem; border-radius: 0.25rem; border: 1px solid #D8DDE6;} #footer {height: 24px; width: 370px; text-align: center; font-size: .75rem; position: absolute; bottom: 10;}</style></head><body><div id="center"><div id="logo-container"><img id="logo" aria-hidden="true" name="logo" alt="Salesforce" src="data:image/svg+xml;base64,%s"></div><div id="header">%s</div><div id="message">%s<br/><br/>This is most likely <b>not</b> an error with the Salesforce CLI. Please ensure all information is accurate and try again.</div><div id="footer">&copy; %s Salesforce, Inc. All rights reserved.</div></div></body></html>

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@salesforce/core",
3-
"version": "8.12.0",
3+
"version": "8.12.1-dev.0",
44
"description": "Core libraries to interact with SFDX projects, orgs, and APIs.",
55
"main": "lib/index",
66
"types": "lib/index.d.ts",

src/org/authInfo.ts

Lines changed: 86 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,15 @@ const messages = Messages.loadMessages('@salesforce/core', 'core');
5050
* Fields for authorization, org, and local information.
5151
*/
5252
export type AuthFields = {
53+
clientApps?: {
54+
[key: string]: {
55+
clientId: string;
56+
clientSecret?: string;
57+
accessToken: string;
58+
refreshToken: string;
59+
oauthFlow: 'web';
60+
};
61+
};
5362
accessToken?: string;
5463
alias?: string;
5564
authCode?: string;
@@ -607,41 +616,94 @@ export class AuthInfo extends AsyncOptionalCreatable<AuthInfo.Options> {
607616

608617
/**
609618
* Get the auth fields (decrypted) needed to make a connection.
619+
*
620+
* @param clientApp Name of the CA/ECA associated with the user.
610621
*/
611-
public getConnectionOptions(): ConnectionOptions {
622+
public getConnectionOptions(clientApp?: string): ConnectionOptions {
612623
const decryptedCopy = this.getFields(true);
613624
const { accessToken, instanceUrl, loginUrl } = decryptedCopy;
614625

615-
if (this.isAccessTokenFlow()) {
616-
this.logger.info('Returning fields for a connection using access token.');
626+
// return main app auth fields
627+
if (!clientApp) {
628+
if (this.isAccessTokenFlow()) {
629+
this.logger.info('Returning fields for a connection using access token.');
617630

618-
// Just auth with the accessToken
619-
return { accessToken, instanceUrl, loginUrl };
620-
}
621-
if (this.isJwt()) {
622-
this.logger.info('Returning fields for a connection using JWT config.');
631+
// Just auth with the accessToken
632+
return { accessToken, instanceUrl, loginUrl };
633+
}
634+
if (this.isJwt()) {
635+
this.logger.info('Returning fields for a connection using JWT config.');
636+
return {
637+
accessToken,
638+
instanceUrl,
639+
refreshFn: this.refreshFn.bind(this),
640+
};
641+
}
642+
// @TODO: figure out loginUrl and redirectUri (probably get from config class)
643+
//
644+
// redirectUri: org.config.getOauthCallbackUrl()
645+
// loginUrl: this.fields.instanceUrl || this.config.getAppConfig().sfdcLoginUrl
646+
this.logger.info('Returning fields for a connection using OAuth config.');
647+
648+
// Decrypt a user provided client secret or use the default.
623649
return {
650+
oauth2: {
651+
loginUrl: instanceUrl ?? SfdcUrl.PRODUCTION,
652+
clientId: this.getClientId(),
653+
redirectUri: this.getRedirectUri(),
654+
},
624655
accessToken,
625656
instanceUrl,
626657
refreshFn: this.refreshFn.bind(this),
627658
};
628659
}
629-
// @TODO: figure out loginUrl and redirectUri (probably get from config class)
630-
//
631-
// redirectUri: org.config.getOauthCallbackUrl()
632-
// loginUrl: this.fields.instanceUrl || this.config.getAppConfig().sfdcLoginUrl
633-
this.logger.info('Returning fields for a connection using OAuth config.');
634660

635-
// Decrypt a user provided client secret or use the default.
661+
if (!decryptedCopy.clientApps) {
662+
throw new SfError(`${this.username} does not have any client app linked.`);
663+
}
664+
665+
if (!(clientApp in decryptedCopy.clientApps)) {
666+
throw new SfError(`${this.username} does not have a "${clientApp}" client app linked.`);
667+
}
668+
669+
const decryptedApp = decryptedCopy.clientApps[clientApp];
670+
636671
return {
637672
oauth2: {
638673
loginUrl: instanceUrl ?? SfdcUrl.PRODUCTION,
639-
clientId: this.getClientId(),
674+
clientId: decryptedApp.clientId,
640675
redirectUri: this.getRedirectUri(),
641676
},
642-
accessToken,
677+
accessToken: decryptedApp.accessToken,
643678
instanceUrl,
644-
refreshFn: this.refreshFn.bind(this),
679+
// Specific refreshFn for AuthInfo's clientApps.
680+
//
681+
// Each client app stores the oauth flow used for its initial auth, here we ensure each refresh returns
682+
// a token, update the auth file with it and send it back to jsforce's through the callback.
683+
refreshFn: async (_conn, callback): Promise<void> => {
684+
// This only handles refresh for web flow.
685+
// When more flows are supported for client apps, check the `app.oauthFlow` field to set the appropiate refresh helper.
686+
const authFields = await this.buildRefreshTokenConfig({
687+
clientId: decryptedApp.clientId,
688+
clientSecret: decryptedApp.clientSecret,
689+
refreshToken: decryptedApp.refreshToken,
690+
loginUrl: instanceUrl,
691+
});
692+
693+
await this.save({
694+
clientApps: {
695+
...decryptedCopy.clientApps,
696+
[clientApp]: {
697+
accessToken: ensureString(authFields.accessToken),
698+
clientId: decryptedApp.clientId,
699+
clientSecret: decryptedApp.clientSecret,
700+
refreshToken: decryptedApp.refreshToken,
701+
oauthFlow: 'web',
702+
},
703+
},
704+
});
705+
await callback(null, authFields.accessToken);
706+
},
645707
};
646708
}
647709

@@ -1277,6 +1339,13 @@ export namespace AuthInfo {
12771339
* OAuth options.
12781340
*/
12791341
oauth2Options?: JwtOAuth2Config;
1342+
clientApps?: Array<{
1343+
name: string;
1344+
accessToken: string;
1345+
refreshToken: string;
1346+
clientId: string;
1347+
clientSecret?: string;
1348+
}>;
12801349
/**
12811350
* Options for the access token auth.
12821351
*/

src/org/connection.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ export class Connection<S extends Schema = Schema> extends JSForceConnection<S>
118118
callOptions: {
119119
client: clientId,
120120
},
121-
...options.authInfo.getConnectionOptions(),
121+
...options.authInfo.getConnectionOptions(options.clientApp),
122122
// this assertion is questionable, but has existed before core7
123123
} as ConnectionConfig<S>;
124124

@@ -495,6 +495,10 @@ export namespace Connection {
495495< F438 div class="diff-text-inner"> * Additional connection parameters.
496496
*/
497497
connectionOptions?: ConnectionConfig<S>;
498+
/**
499+
* Client app to use for auth info credentials.
500+
*/
501+
clientApp?: string;
498502
};
499503
}
500504

src/webOAuthServer.ts

Lines changed: 92 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { Socket } from 'node:net';
1414
import { EventEmitter } from 'node:events';
1515
import { OAuth2 } from '@jsforce/jsforce-node';
1616
import { AsyncCreatable, Env, set, toNumber } from '@salesforce/kit';
17-
import { asString, get, Nullable } from '@salesforce/ts-types';
17+
import { asString, ensureString, get, Nullable } from '@salesforce/ts-types';
1818
import { Logger } from './logger/logger';
1919
import { AuthInfo, DEFAULT_CONNECTED_APP_INFO } from './org/authInfo';
2020
import { SfError } from './sfError';
@@ -52,10 +52,24 @@ export class WebOAuthServer extends AsyncCreatable<WebOAuthServer.Options> {
5252
private oauth2!: OAuth2;
5353
private oauthConfig: JwtOAuth2Config;
5454
private oauthError = new Error('Oauth Error');
55+
private clientApp?: string;
56+
private username?: string;
5557

5658
public constructor(options: WebOAuthServer.Options) {
5759
super(options);
5860
this.oauthConfig = options.oauthConfig;
61+
62+
// runtime check due to TS's loose type validation when using union types.
63+
if (Object.hasOwn(options, 'username') && !Object.hasOwn(options, 'clientApp')) {
64+
throw messages.createError('error.missingWebOauthServer.options');
65+
}
66+
if (Object.hasOwn(options, 'clientApp') && !Object.hasOwn(options, 'username')) {
67+
throw messages.createError('error.missingWebOauthServer.options');
68+
}
69+
if ('clientApp' in options) {
70+
this.clientApp = options.clientApp;
71+
this.username = options.username;
72+
}
5973
}
6074

6175
/**
@@ -95,14 +109,54 @@ export class WebOAuthServer extends AsyncCreatable<WebOAuthServer.Options> {
95109
this.executeOauthRequest()
96110
.then(async (response) => {
97111
try {
98-
const authInfo = await AuthInfo.create({
99-
oauth2Options: this.oauthConfig,
100-
oauth2: this.oauth2,
101-
});
102-
await authInfo.save();
103-
await this.webServer.handleSuccess(response);
104-
response.end();
105-
resolve(authInfo);
112+
// Link client app to an existing auth file.
113+
if (this.clientApp) {
114+
const authInfo = await AuthInfo.create({
115+
oauth2Options: this.oauthConfig,
116+
oauth2: this.oauth2,
117+
});
118+
const authFields = authInfo.getFields(true);
119+
120+
// get user authInfo and save client app creds in `clientApps`
121+
const userAuthInfo = await AuthInfo.create({
122+
username: this.username,
123+
});
124+
125+
const decryptedCopy = userAuthInfo.getFields(true);
126+
127+
if (decryptedCopy.clientApps && this.clientApp in decryptedCopy.clientApps) {
128+
throw new SfError(
129+
`The username ${this.username} is already linked to a client app named "${this.clientApp}". Please authenticate again with a different client app name.`
130+
);
131+
}
132+
133+
await userAuthInfo.save({
134+
clientApps: {
135+
...userAuthInfo.getFields(true).clientApps,
136+
[this.clientApp]: {
137+
clientId: ensureString(authFields.clientId),
138+
clientSecret: authFields.clientSecret,
139+
accessToken: ensureString(authFields.accessToken),
140+
refreshToken: ensureString(authFields.refreshToken),
141+
oauthFlow: 'web',
142+
},
143+
},
144+
});
145+
146+
await this.webServer.handleSuccess(response);
147+
response.end();
148+
resolve(authInfo);
149+
} else {
150+
// new auth, create new file.
151+
const authInfo = await AuthInfo.create({
152+
oauth2Options: this.oauthConfig,
153+
oauth2: this.oauth2,
154+
});
155+
await authInfo.save();
156+
await this.webServer.handleSuccess(response);
157+
response.end();
158+
resolve(authInfo);
159+
}
106160
} catch (err) {
107161
this.oauthError = err as Error;
108162
await this.webServer.handleError(response);
@@ -276,9 +330,35 @@ export class WebOAuthServer extends AsyncCreatable<WebOAuthServer.Options> {
276330
}
277331

278332
export namespace WebOAuthServer {
279-
export type Options = {
280-
oauthConfig: JwtOAuth2Config;
281-
};
333+
export type Options =
334+
| {
335+
oauthConfig: JwtOAuth2Config & {
336+
/**
337+
* OAuth scopes to be requested for the access token.
338+
*
339+
* This should be a string with each scope separated by spaces:
340+
* "refresh_token sfap_api chatbot_api web api"
341+
*
342+
* If not specified, all scopes assigned to the connected app are requested.
343+
*/
344+
scope?: string;
345+
};
346+
}
347+
| {
348+
oauthConfig: JwtOAuth2Config & {
349+
/**
350+
* OAuth scopes to be requested for the access token.
351+
*
352+
* This should be a string with each scope separated by spaces:
353+
* "refresh_token sfap_api chatbot_api web api"
354+
*
355+
* If not specified, all scopes assigned to the connected app are requested.
356+
*/
357+
scope?: string;
358+
};
359+
clientApp: string;
360+
username: string;
361+
};
282362

283363
export type Request = http.IncomingMessage & {
284364
query: { code: string; state: string; error?: string; error_description?: string };

test/unit/org/authInfo.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,50 @@ describe('AuthInfo', () => {
187187
// double check the stringified objects don't have secrets.
188188
expect(strObj).does.not.include(decryptedRefreshToken);
189189
});
190+
191+
it('should return app-specific connection options when app parameter is provided', () => {
192+
const clientApp = 'agent-jwt-app';
193+
const clientAppConfig = {
194+
accessToken: 'app-access-token',
195+
clientId: 'app-client-id',
196+
clientSecret: 'app-client-secret',
197+
refreshToken: 'app-refresh-token',
198+
oauthFlow: 'web' as const,
199+
};
200+
201+
// Set up the apps field in auth info
202+
authInfo.update({
203+
clientApps: {
204+
[clientApp]: clientAppConfig,
205+
},
206+
});
207+
208+
const fields = authInfo.getConnectionOptions(clientApp);
209+
210+
// Verify app-specific connection options
211+
expect(fields.oauth2).to.have.property('clientId', 'app-client-id');
212+
expect(fields.oauth2).to.have.property('redirectUri');
213+
expect(fields.accessToken).to.equal('app-access-token');
214+
expect(fields).to.have.property('instanceUrl');
215+
expect(fields).to.have.property('refreshFn').and.is.a('function');
216+
});
217+
218+
it('should throw error when app does not exist', () => {
219+
const clientApp = 'NonExistentApp';
220+
authInfo.update({
221+
clientApps: {
222+
SomeOtherApp: {
223+
accessToken: 'token',
224+
clientId: 'client',
225+
refreshToken: 'refresh',
226+
oauthFlow: 'web' as const,
227+
},
228+
},
229+
});
230+
expect(() => authInfo.getConnectionOptions(clientApp)).to.throw(
231+
`${authInfo.getUsername()} does not have a "${clientApp}" client app linked.`
232+
);
233+
});
190234
});
191235

192236
describe('AuthInfo', () => {

test/unit/webOauthServer.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -414,6 +414,26 @@ describe('WebOauthServer', () => {
414414
expect(actual).to.equal(true);
415415
});
416416
});
417+
418+
describe('runtime checks', () => {
419+
it('should fail if missing required params', async () => {
420+
try {
421+
await WebOAuthServer.create({ oauthConfig: {}, clientApp: 'agents' });
422+
expect.fail();
423+
} catch (err) {
424+
const e = err as Error;
425+
expect(e.name).to.equal('OptionsError');
426+
}
427+
428+
try {
429+
await WebOAuthServer.create({ oauthConfig: {}, username: 'johndoe@acme.com' });
430+
expect.fail();
431+
} catch (err) {
432+
const e = err as Error;
433+
expect(e.name).to.equal('OptionsError');
434+
}
435+
});
436+
});
417437
});
418438

419439
describe('WebServer', () => {

0 commit comments

Comments
 (0)
0