From 9b212264bafe458c95ae22fce11298c706d23393 Mon Sep 17 00:00:00 2001 From: Thomas Date: Wed, 15 Jan 2020 07:44:16 -0700 Subject: [PATCH] feat: create authinfo with a parent authinfo impl #202 --- src/authInfo.ts | 107 +++++++++++++++++++++++++------------- test/unit/authInfoTest.ts | 73 +++++++++++++++++++++++--- 2 files changed, 136 insertions(+), 44 deletions(-) diff --git a/src/authInfo.ts b/src/authInfo.ts index dc66d95e5c..dbb4b46002 100644 --- a/src/authInfo.ts +++ b/src/authInfo.ts @@ -648,6 +648,23 @@ export class AuthInfo extends AsyncCreatable { if (this.isTokenOptions(options)) { authConfig = options; } else { + if (this.options.parentUsername) { + const parentUserFields = await this.loadAuthFromConfig(this.options.parentUsername); + const parentFields = this.authInfoCrypto.decryptFields(parentUserFields); + + options.clientId = parentFields.clientId; + + if (process.env.SFDX_CLIENT_SECRET) { + options.clientSecret = process.env.SFDX_CLIENT_SECRET; + } else { + // Grab whatever flow is defined + Object.assign(options, { + clientSecret: parentFields.clientSecret, + privateKey: parentFields.privateKey + }); + } + } + // jwt flow // Support both sfdx and jsforce private key values if (!options.privateKey && options.privateKeyFile) { @@ -673,24 +690,7 @@ export class AuthInfo extends AsyncCreatable { this.update(authConfig); } else { const username = ensure(this.getUsername()); - if (AuthInfo.cache.has(username)) { - authConfig = ensure(AuthInfo.cache.get(username)); - } else { - // Fetch from the persisted auth file - try { - const config: AuthInfoConfig = await AuthInfoConfig.create({ - ...AuthInfoConfig.getOptions(username), - throwOnNotFound: true - }); - authConfig = config.toObject(); - } catch (e) { - if (e.code === 'ENOENT') { - throw SfdxError.create('@salesforce/core', 'core', 'NamedOrgNotFound', [username]); - } else { - throw e; - } - } - } + authConfig = await this.loadAuthFromConfig(username); // Update the auth fields WITHOUT encryption (already encrypted) this.update(authConfig, false); } @@ -701,6 +701,27 @@ export class AuthInfo extends AsyncCreatable { return this; } + private async loadAuthFromConfig(username: string): Promise { + if (AuthInfo.cache.has(username)) { + return ensure(AuthInfo.cache.get(username)); + } else { + // Fetch from the persisted auth file + try { + const config: AuthInfoConfig = await AuthInfoConfig.create({ + ...AuthInfoConfig.getOptions(username), + throwOnNotFound: true + }); + return config.toObject(); + } catch (e) { + if (e.code === 'ENOENT') { + throw SfdxError.create('@salesforce/core', 'core', 'NamedOrgNotFound', [username]); + } else { + throw e; + } + } + } + } + private isTokenOptions(options: OAuth2Options | AccessTokenOptions): options is AccessTokenOptions { // Although OAuth2Options does not contain refreshToken, privateKey, or privateKeyFile, a JS consumer could still pass those in // which WILL have an access token as well, but it should be considered an OAuth2Options at that point. @@ -781,9 +802,7 @@ export class AuthInfo extends AsyncCreatable { authFields.instanceUrl = instanceUrl; } catch (err) { this.logger.debug( - `Instance URL [${_authFields.instance_url}] is not available. DNS lookup failed. Using loginUrl [${ - options.loginUrl - }] instead. This may result in a "Destination URL not reset" error.` + `Instance URL [${_authFields.instance_url}] is not available. DNS lookup failed. Using loginUrl [${options.loginUrl}] instead. This may result in a "Destination URL not reset" error.` ); authFields.instanceUrl = options.loginUrl; } @@ -840,21 +859,26 @@ export class AuthInfo extends AsyncCreatable { // @ts-ignore TODO: need better typings for jsforce const { userId, orgId } = _parseIdUrl(_authFields.id); - // Make a REST call for the username directly. Normally this is done via a connection - // but we don't want to create circular dependencies or lots of snowflakes - // within this file to support it. - const apiVersion = 'v42.0'; // hardcoding to v42.0 just for this call is okay. - const instance = ensure(getString(_authFields, 'instance_url')); - const url = `${instance}/services/data/${apiVersion}/sobjects/User/${userId}`; - const headers = Object.assign({ Authorization: `Bearer ${_authFields.access_token}` }, SFDX_HTTP_HEADERS); - - let username: Optional; - try { - this.logger.info(`Sending request for Username after successful auth code exchange to URL: ${url}`); - const response = await new Transport().httpRequest({ url, headers }); - username = asString(parseJsonMap(response.body).Username); - } catch (err) { - throw SfdxError.create('@salesforce/core', 'core', 'AuthCodeUsernameRetrievalError', [orgId, err.message]); + let username: Optional = this.getUsername(); + + // Only need to query for the username if it isn't known. For example, a new auth code exchange + // rather than refreshing a token on an existing connection. + if (!username) { + // Make a REST call for the username directly. Normally this is done via a connection + // but we don't want to create circular dependencies or lots of snowflakes + // within this file to support it. + const apiVersion = 'v42.0'; // hardcoding to v42.0 just for this call is okay. + const instance = ensure(getString(_authFields, 'instance_url')); + const url = `${instance}/services/data/${apiVersion}/sobjects/User/${userId}`; + const headers = Object.assign({ Authorization: `Bearer ${_authFields.access_token}` }, SFDX_HTTP_HEADERS); + + try { + this.logger.info(`Sending request for Username after successful auth code exchange to URL: ${url}`); + const response = await new Transport().httpRequest({ url, headers }); + username = asString(parseJsonMap(response.body).Username); + } catch (err) { + throw SfdxError.create('@salesforce/core', 'core', 'AuthCodeUsernameRetrievalError', [orgId, err.message]); + } } return { @@ -865,7 +889,9 @@ export class AuthInfo extends AsyncCreatable { username, // @ts-ignore TODO: need better typings for jsforce loginUrl: options.loginUrl || _authFields.instance_url, - refreshToken: _authFields.refresh_token + refreshToken: _authFields.refresh_token, + clientId: options.clientId, + clientSecret: options.clientSecret }; } @@ -902,5 +928,12 @@ export namespace AuthInfo { accessTokenOptions?: AccessTokenOptions; oauth2?: OAuth2; + + /** + * In certain situations, a new auth info wants to use the connected app + * information from another parent org. Typically for scratch org or sandbox + * creation. + */ + parentUsername?: string; } } diff --git a/test/unit/authInfoTest.ts b/test/unit/authInfoTest.ts index f54b819a3a..c680dfbe2e 100644 --- a/test/unit/authInfoTest.ts +++ b/test/unit/authInfoTest.ts @@ -403,6 +403,62 @@ describe('AuthInfo', () => { expect(authInfo.isOauth(), 'authInfo.isOauth() should be false').to.be.false; }); + it('should return an AuthInfo instance when passed a parent username', async () => { + stubMethod($$.SANDBOX, ConfigAggregator.prototype, 'loadProperties').callsFake(async () => {}); + stubMethod($$.SANDBOX, ConfigAggregator.prototype, 'getPropertyValue').returns(testMetadata.instanceUrl); + + // Stub the http request (OAuth2.refreshToken()) + // This will be called for both, and we want to make sure the clientSecrete is the + // same for both. + _postParmsStub.callsFake(params => { + expect(params.client_secret).to.deep.equal(testMetadata.clientSecret); + return { + access_token: testMetadata.accessToken, + instance_url: testMetadata.instanceUrl, + refresh_token: testMetadata.refreshToken, + id: '00DAuthInfoTest_orgId/005AuthInfoTest_userId' + }; + }); + + const parentUsername = 'test@test.com'; + await AuthInfo.create({ + username: parentUsername, + oauth2Options: { + clientId: testMetadata.clientId, + clientSecret: testMetadata.clientSecret, + loginUrl: testMetadata.instanceUrl, + authCode: testMetadata.authCode + } + }); + + const authInfo = await AuthInfo.create({ + username: testMetadata.username, + parentUsername, + oauth2Options: { + loginUrl: testMetadata.instanceUrl, + authCode: testMetadata.authCode + } + }); + + expect(_postParmsStub.calledTwice).to.true; + expect(authInfo.isAccessTokenFlow(), 'authInfo.isAccessTokenFlow() should be false').to.be.false; + expect(authInfo.isRefreshTokenFlow(), 'authInfo.isRefreshTokenFlow() should be false').to.be.true; + expect(authInfo.isJwt(), 'authInfo.isJwt() should be false').to.be.false; + expect(authInfo.isOauth(), 'authInfo.isOauth() should be true').to.be.true; + + const expectedAuthConfig = { + accessToken: testMetadata.accessToken, + instanceUrl: testMetadata.instanceUrl, + username: testMetadata.username, + orgId: '00DAuthInfoTest_orgId', + loginUrl: testMetadata.instanceUrl, + refreshToken: testMetadata.refreshToken, + clientId: testMetadata.clientId, + clientSecret: testMetadata.clientSecret + }; + expect(authInfoUpdate.secondCall.args[0]).to.deep.equal(expectedAuthConfig); + }); + it('should return an AuthInfo instance when passed an access token and instanceUrl for the access token flow', async () => { stubMethod($$.SANDBOX, ConfigAggregator.prototype, 'loadProperties').callsFake(async () => {}); stubMethod($$.SANDBOX, ConfigAggregator.prototype, 'getPropertyValue').returns(testMetadata.instanceUrl); @@ -918,6 +974,9 @@ describe('AuthInfo', () => { // Create the refresh token AuthInfo instance const authInfo = await AuthInfo.create({ oauth2Options: authCodeConfig }); + // Ensure we query for the username + expect(Transport.prototype.httpRequest.called).to.be.true; + // Verify the returned AuthInfo instance const authInfoConnOpts = authInfo.getConnectionOptions(); expect(authInfoConnOpts).to.have.property('accessToken', authResponse.access_token); @@ -954,7 +1013,11 @@ describe('AuthInfo', () => { username, orgId: authResponse.id.split('/')[0], loginUrl: authCodeConfig.loginUrl, - refreshToken: authResponse.refresh_token + refreshToken: authResponse.refresh_token, + // These need to be passed in by the consumer. Since they are not, they will show up as undefined. + // In a non-test environment, the exchange will fail because no clientId is supplied. + clientId: undefined, + clientSecret: undefined }; expect(authInfoUpdate.firstCall.args[0]).to.deep.equal(expectedAuthConfig); }); @@ -1298,9 +1361,7 @@ describe('AuthInfo', () => { }); expect(authInfo.getSfdxAuthUrl()).to.contain( - `force://SalesforceDevelopmentExperience:1384510088588713504:${ - testMetadata.refreshToken - }@mydevhub.localhost.internal.salesforce.com:6109` + `force://SalesforceDevelopmentExperience:1384510088588713504:${testMetadata.refreshToken}@mydevhub.localhost.internal.salesforce.com:6109` ); }); @@ -1330,9 +1391,7 @@ describe('AuthInfo', () => { delete authInfo.getFields().clientSecret; expect(authInfo.getSfdxAuthUrl()).to.contain( - `force://SalesforceDevelopmentExperience::${ - testMetadata.refreshToken - }@mydevhub.localhost.internal.salesforce.com:6109` + `force://SalesforceDevelopmentExperience::${testMetadata.refreshToken}@mydevhub.localhost.internal.salesforce.com:6109` ); }); });