From 11970c552ccdbf4db299882c889edca41d1555c0 Mon Sep 17 00:00:00 2001 From: Jorge Rangel Date: Thu, 17 Sep 2020 15:33:48 -0500 Subject: [PATCH] feat(IAM Authenticator): add support for optional 'scope' property --- auth/authenticators/iam-authenticator.ts | 20 +++++ auth/token-managers/iam-token-manager.ts | 22 +++++ test/resources/ibm-credentials.env | 11 ++- ...get-authenticator-from-environment.test.js | 15 ++++ test/unit/iam-authenticator.test.js | 17 ++++ test/unit/iam-token-manager.test.js | 86 +++++++++++++++++++ test/unit/read-credentials-file.test.js | 7 ++ test/unit/read-external-sources.test.js | 7 ++ 8 files changed, 184 insertions(+), 1 deletion(-) diff --git a/auth/authenticators/iam-authenticator.ts b/auth/authenticators/iam-authenticator.ts index 219cf6183..656c23c70 100644 --- a/auth/authenticators/iam-authenticator.ts +++ b/auth/authenticators/iam-authenticator.ts @@ -33,6 +33,11 @@ export interface Options extends BaseOptions { * authorization header for IAM token requests. */ clientSecret?: string; + + /** + * The "scope" parameter to use when fetching the bearer token from the IAM token server. + */ + scope?: string; } /** @@ -52,6 +57,7 @@ export class IamAuthenticator extends TokenRequestBasedAuthenticator { private apikey: string; private clientId: string; private clientSecret: string; + private scope: string; /** * @@ -68,6 +74,8 @@ export class IamAuthenticator extends TokenRequestBasedAuthenticator { * authorization header for IAM token requests. * @param {string} [options.clientSecret] The `clientId` and `clientSecret` fields are used to form a "basic" * authorization header for IAM token requests. + * @param {string} [options.scope] The "scope" parameter to use when fetching the bearer token from the + * IAM token server. * @throws {Error} When the configuration options are not valid. */ constructor(options: Options) { @@ -78,6 +86,7 @@ export class IamAuthenticator extends TokenRequestBasedAuthenticator { this.apikey = options.apikey; this.clientId = options.clientId; this.clientSecret = options.clientSecret; + this.scope = options.scope; // the param names are shared between the authenticator and the token // manager so we can just pass along the options object @@ -98,4 +107,15 @@ export class IamAuthenticator extends TokenRequestBasedAuthenticator { // update properties in token manager this.tokenManager.setClientIdAndSecret(clientId, clientSecret); } + + /** + * Setter for the "scope" parameter to use when fetching the bearer token from the IAM token server. + * @param {string} scope A space seperated string that makes up the scope parameter + */ + public setScope(scope: string): void { + this.scope = scope; + + // update properties in token manager + this.tokenManager.setScope(scope); + } } diff --git a/auth/token-managers/iam-token-manager.ts b/auth/token-managers/iam-token-manager.ts index d6916f1ba..9b1919fcf 100644 --- a/auth/token-managers/iam-token-manager.ts +++ b/auth/token-managers/iam-token-manager.ts @@ -35,12 +35,14 @@ function onlyOne(a: any, b: any): boolean { } const CLIENT_ID_SECRET_WARNING = 'Warning: Client ID and Secret must BOTH be given, or the header will not be included.'; +const SCOPE = 'scope'; /** Configuration options for IAM token retrieval. */ interface Options extends JwtTokenManagerOptions { apikey: string; clientId?: string; clientSecret?: string; + scope?: string; } /** @@ -53,6 +55,7 @@ export class IamTokenManager extends JwtTokenManager { private apikey: string; private clientId: string; private clientSecret: string; + private scope: string; /** * @@ -87,12 +90,27 @@ export class IamTokenManager extends JwtTokenManager { if (options.clientSecret) { this.clientSecret = options.clientSecret; } + if (options.scope) { + this.scope = options.scope; + } if (onlyOne(options.clientId, options.clientSecret)) { // tslint:disable-next-line logger.warn(CLIENT_ID_SECRET_WARNING); } } + /** + * Set the IAM `scope` value. + * This value is the form parameter to use when fetching the bearer token + * from the IAM token server. + * + * @param {string} scope - A space seperated string that makes up the scope parameter. + * @returns {void} + */ + public setScope(scope: string): void { + this.scope = scope; + } + /** * Set the IAM `clientId` and `clientSecret` values. * These values are used to compute the Authorization header used @@ -143,6 +161,10 @@ export class IamTokenManager extends JwtTokenManager { } }; + if (this.scope) { + parameters.options.form[SCOPE] = this.scope; + } + return this.requestWrapperInstance.sendRequest(parameters); } } diff --git a/test/resources/ibm-credentials.env b/test/resources/ibm-credentials.env index 4f84ec7e8..4443d61c3 100644 --- a/test/resources/ibm-credentials.env +++ b/test/resources/ibm-credentials.env @@ -9,6 +9,7 @@ TEST_SERVICE_AUTH_DISABLE_SSL=true # service properties TEST_SERVICE_URL=service.com/api TEST_SERVICE_DISABLE_SSL=true +TEST_SERVICE_SCOPE=A B C D # Service1 auth properties configured with IAM and a token containing '=' SERVICE_1_AUTH_TYPE=iam @@ -19,4 +20,12 @@ SERVICE_1_AUTH_URL=https://iamhost/iam/api= SERVICE_1_AUTH_DISABLE_SSL= # Service1 service properties -SERVICE_1_URL=service1.com/api \ No newline at end of file +SERVICE_1_URL=service1.com/api + +# Service2 configured with IAM w/scope +SERVICE_2_AUTH_TYPE=iam +SERVICE_2_APIKEY=V4HXmoUtMjohnsnow=KotN +SERVICE_2_CLIENT_ID=somefake========id +SERVICE_2_CLIENT_SECRET===my-client-secret== +SERVICE_2_AUTH_URL=https://iamhost/iam/api= +SERVICE_2_SCOPE=A B C D \ No newline at end of file diff --git a/test/unit/get-authenticator-from-environment.test.js b/test/unit/get-authenticator-from-environment.test.js index 5535c0bc3..addc7da62 100644 --- a/test/unit/get-authenticator-from-environment.test.js +++ b/test/unit/get-authenticator-from-environment.test.js @@ -89,6 +89,13 @@ describe('Get Authenticator From Environment Module', () => { getAuthenticatorFromEnvironment(SERVICE_NAME); }).toThrow(); }); + + it('should get iam authenticator and set the scope', () => { + setUpIamPayloadWithScope(); + const authenticator = getAuthenticatorFromEnvironment(SERVICE_NAME); + expect(authenticator).toBeInstanceOf(IamAuthenticator); + expect(authenticator.scope).toBe('jon snow'); + }); }); // mock payloads for the read-external-sources module @@ -120,6 +127,14 @@ function setUpIamPayload() { })); } +function setUpIamPayloadWithScope() { + readExternalSourcesMock.mockImplementation(() => ({ + authType: 'iam', + apikey: APIKEY, + scope: 'jon snow', + })); +} + function setUpCp4dPayload() { readExternalSourcesMock.mockImplementation(() => ({ authType: 'cp4d', diff --git a/test/unit/iam-authenticator.test.js b/test/unit/iam-authenticator.test.js index 839c06fc3..9794cbfec 100644 --- a/test/unit/iam-authenticator.test.js +++ b/test/unit/iam-authenticator.test.js @@ -21,6 +21,7 @@ describe('IAM Authenticator', () => { headers: { 'X-My-Header': 'some-value', }, + scope: 'A B C D', }; it('should store all config options on the class', () => { @@ -32,6 +33,7 @@ describe('IAM Authenticator', () => { expect(authenticator.clientSecret).toBe(config.clientSecret); expect(authenticator.disableSslVerification).toBe(config.disableSslVerification); expect(authenticator.headers).toEqual(config.headers); + expect(authenticator.scope).toEqual(config.scope); // should also create a token manager expect(authenticator.tokenManager).toBeInstanceOf(IamTokenManager); @@ -64,6 +66,9 @@ describe('IAM Authenticator', () => { // verify that the original options are kept intact expect(options.headers['X-Some-Header']).toBe('user-supplied header'); + // verify the scope param wasn't set + expect(authenticator.scope).toBeUndefined(); + expect(authenticator.tokenManager.scope).toBeUndefined(); done(); }); @@ -105,4 +110,16 @@ describe('IAM Authenticator', () => { // also, verify that the underlying token manager has been updated expect(authenticator.tokenManager.headers).toEqual(newHeader); }); + + it('should re-set the scope using the setter', () => { + const authenticator = new IamAuthenticator(config); + expect(authenticator.headers).toEqual(config.headers); + + const newScope = 'john snow'; + authenticator.setScope(newScope); + expect(authenticator.scope).toEqual(newScope); + + // also, verify that the underlying token manager has been updated + expect(authenticator.tokenManager.scope).toEqual(newScope); + }); }); diff --git a/test/unit/iam-token-manager.test.js b/test/unit/iam-token-manager.test.js index cf237ba7a..7e6151758 100644 --- a/test/unit/iam-token-manager.test.js +++ b/test/unit/iam-token-manager.test.js @@ -133,6 +133,8 @@ describe('iam_token_manager_v1', function() { const sendRequestArgs = mockSendRequest.mock.calls[0][0]; const authHeader = sendRequestArgs.options.headers.Authorization; expect(authHeader).toBeUndefined(); + const scope = sendRequestArgs.options.form.scope; + expect(scope).toBeUndefined(); done(); }); @@ -149,6 +151,25 @@ describe('iam_token_manager_v1', function() { const sendRequestArgs = mockSendRequest.mock.calls[0][0]; const authHeader = sendRequestArgs.options.headers.Authorization; expect(authHeader).toBe('Basic Zm9vOmJhcg=='); + const scope = sendRequestArgs.options.form.scope; + expect(scope).toBeUndefined(); + done(); + }); + + it('should include scope form param based on scope via ctor', async done => { + const instance = new IamTokenManager({ + apikey: 'abcd-1234', + scope: 'john snow', + }); + + mockSendRequest.mockImplementation(parameters => Promise.resolve(IAM_RESPONSE)); + + await instance.getToken(); + const sendRequestArgs = mockSendRequest.mock.calls[0][0]; + const form = sendRequestArgs.options.form; + expect(form).not.toBeNull(); + const scope = form.scope; + expect(scope).toBe('john snow'); done(); }); @@ -170,6 +191,8 @@ describe('iam_token_manager_v1', function() { const sendRequestArgs = mockSendRequest.mock.calls[0][0]; const authHeader = sendRequestArgs.options.headers.Authorization; expect(authHeader).toBeUndefined(); + const scope = sendRequestArgs.options.form.scope; + expect(scope).toBeUndefined(); done(); }); @@ -190,6 +213,25 @@ describe('iam_token_manager_v1', function() { const sendRequestArgs = mockSendRequest.mock.calls[0][0]; const authHeader = sendRequestArgs.options.headers.Authorization; expect(authHeader).toBeUndefined(); + const scope = sendRequestArgs.options.form.scope; + expect(scope).toBeUndefined(); + done(); + }); + + it('should not include scope form param based on scope via ctor', async done => { + const instance = new IamTokenManager({ + apikey: 'abcd-1234', + scope: null, + }); + + mockSendRequest.mockImplementation(parameters => Promise.resolve(IAM_RESPONSE)); + + await instance.getToken(); + const sendRequestArgs = mockSendRequest.mock.calls[0][0]; + const form = sendRequestArgs.options.form; + expect(form).not.toBeNull(); + const scope = form.scope; + expect(scope).toBeUndefined(); done(); }); @@ -206,6 +248,44 @@ describe('iam_token_manager_v1', function() { const sendRequestArgs = mockSendRequest.mock.calls[0][0]; const authHeader = sendRequestArgs.options.headers.Authorization; expect(authHeader).toBe('Basic Zm9vOmJhcg=='); + const scope = sendRequestArgs.options.form.scope; + expect(scope).toBeUndefined(); + done(); + }); + + it('should include scope form param based on scope via setter', async done => { + const instance = new IamTokenManager({ + apikey: 'abcd-1234', + }); + + instance.setScope('john snow'); + + mockSendRequest.mockImplementation(parameters => Promise.resolve(IAM_RESPONSE)); + + await instance.getToken(); + const sendRequestArgs = mockSendRequest.mock.calls[0][0]; + const form = sendRequestArgs.options.form; + expect(form).not.toBeNull(); + const scope = form.scope; + expect(scope).toBe('john snow'); + done(); + }); + + it('should not include scope form param based on scope via setter', async done => { + const instance = new IamTokenManager({ + apikey: 'abcd-1234', + }); + + instance.setScope(null); + + mockSendRequest.mockImplementation(parameters => Promise.resolve(IAM_RESPONSE)); + + await instance.getToken(); + const sendRequestArgs = mockSendRequest.mock.calls[0][0]; + const form = sendRequestArgs.options.form; + expect(form).not.toBeNull(); + const scope = form.scope; + expect(scope).toBeUndefined(); done(); }); @@ -228,6 +308,8 @@ describe('iam_token_manager_v1', function() { const sendRequestArgs = mockSendRequest.mock.calls[0][0]; const authHeader = sendRequestArgs.options.headers.Authorization; expect(authHeader).toBeUndefined(); + const scope = sendRequestArgs.options.form.scope; + expect(scope).toBeUndefined(); done(); }); @@ -250,6 +332,8 @@ describe('iam_token_manager_v1', function() { const sendRequestArgs = mockSendRequest.mock.calls[0][0]; const authHeader = sendRequestArgs.options.headers.Authorization; expect(authHeader).toBeUndefined(); + const scope = sendRequestArgs.options.form.scope; + expect(scope).toBeUndefined(); done(); }); @@ -266,6 +350,8 @@ describe('iam_token_manager_v1', function() { const sendRequestArgs = mockSendRequest.mock.calls[0][0]; const authHeader = sendRequestArgs.options.headers.Authorization; expect(authHeader).toBeUndefined(); + const scope = sendRequestArgs.options.form.scope; + expect(scope).toBeUndefined(); done(); }); }); diff --git a/test/unit/read-credentials-file.test.js b/test/unit/read-credentials-file.test.js index 2cecf958b..86ee3c90e 100644 --- a/test/unit/read-credentials-file.test.js +++ b/test/unit/read-credentials-file.test.js @@ -83,6 +83,13 @@ describe('read ibm credentials file', () => { expect(obj.SERVICE_1_CLIENT_SECRET).toBe('==my-client-secret=='); expect(obj.SERVICE_1_AUTH_DISABLE_SSL).toBe(''); expect(obj.SERVICE_1_URL).toBe('service1.com/api'); + + expect(obj.SERVICE_2_AUTH_TYPE).toBe('iam'); + expect(obj.SERVICE_2_APIKEY).toBe('V4HXmoUtMjohnsnow=KotN'); + expect(obj.SERVICE_2_AUTH_URL).toBe('https://iamhost/iam/api='); + expect(obj.SERVICE_2_CLIENT_ID).toBe('somefake========id'); + expect(obj.SERVICE_2_CLIENT_SECRET).toBe('==my-client-secret=='); + expect(obj.SERVICE_2_SCOPE).toBe('A B C D'); }); it('should return credentials as an object for alternate filename', () => { diff --git a/test/unit/read-external-sources.test.js b/test/unit/read-external-sources.test.js index aeef8618e..2ef57e8da 100644 --- a/test/unit/read-external-sources.test.js +++ b/test/unit/read-external-sources.test.js @@ -8,6 +8,7 @@ const APIKEY = '123456789'; const USERNAME = 'michael-leaue'; const PASSWORD = 'snarkypuppy123'; const BEARER_TOKEN = 'abc123'; +const SCOPE = 'A B C D'; describe('Read External Sources Module', () => { // setup @@ -45,6 +46,7 @@ describe('Read External Sources Module', () => { // service props expect(properties.disableSsl).toBe(true); expect(properties.url).toBe('service.com/api'); + expect(properties.scope).toBe(SCOPE); }); // env @@ -64,6 +66,7 @@ describe('Read External Sources Module', () => { expect(properties).not.toBeNull(); expect(properties.apikey).toBe(APIKEY); expect(properties.url).toBeDefined(); + expect(properties.scope).toBe(SCOPE); }); it('should parse values containing the "=" character from VCAP_SERVICES', () => { @@ -126,6 +129,7 @@ describe('Read External Sources Module', () => { expect(properties.clientSecret).toBe('==my-client-secret=='); expect(properties.authUrl).toBe('https://iamhost/iam/api='); expect(properties.url).toBe('service1.com/api'); + expect(properties.scope).toBe(SCOPE); }); it('should convert disableSsl values from string to boolean', () => { @@ -160,6 +164,8 @@ function setupEnvVars() { process.env.SERVICE_1_AUTH_URL = 'https://iamhost/iam/api='; // Service1 service properties process.env.SERVICE_1_URL = 'service1.com/api'; + // set a scope value + process.env.SERVICE_1_SCOPE = SCOPE; } function setupIamVcap() { @@ -173,6 +179,7 @@ function setupIamVcap() { iam_role_crn: 'crn:v1:cloud:public:iam::::serviceRole:Manager', iam_serviceid_crn: 'crn:v1:staging:public:iam-identity::a/::serviceid:ServiceID-1234', url: 'https://gateway.watsonplatform.net/test/api', + scope: 'A B C D', }, }, ],