diff --git a/.eslintignore b/.eslintignore index 866e70bbc..76f823928 100644 --- a/.eslintignore +++ b/.eslintignore @@ -8,6 +8,6 @@ node_modules/ **/*v*.js !test/**/*.js lib/*.js -auth/*.js +auth/**/*.js index.js scripts/typedoc/ \ No newline at end of file diff --git a/.gitignore b/.gitignore index 127f68f6f..900488897 100644 --- a/.gitignore +++ b/.gitignore @@ -9,7 +9,7 @@ doc/ .env .eslintcache lib/*.js -auth/*.js +auth/**/*.js iam-token-manager/*.js index.js .nyc_output diff --git a/auth/authenticators/authenticator-interface.ts b/auth/authenticators/authenticator-interface.ts new file mode 100644 index 000000000..e32fb379a --- /dev/null +++ b/auth/authenticators/authenticator-interface.ts @@ -0,0 +1,31 @@ +/** + * Copyright 2019 IBM Corp. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { OutgoingHttpHeaders } from 'http'; + +// This could just be the type for the `baseOptions` field of the Base Service +// but to avoid a circular dependency or a refactor, this will do for now +export interface AuthenticateOptions { + headers?: OutgoingHttpHeaders; + [propName: string]: any; +} + +// callback can send one arg, error or null +export type AuthenticateCallback = (result: null | Error) => void; + +export interface AuthenticatorInterface { + authenticate(options: AuthenticateOptions, callback: AuthenticateCallback): void +} diff --git a/auth/authenticators/authenticator.ts b/auth/authenticators/authenticator.ts new file mode 100644 index 000000000..bd657fc6d --- /dev/null +++ b/auth/authenticators/authenticator.ts @@ -0,0 +1,55 @@ +/** + * Copyright 2019 IBM Corp. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { OutgoingHttpHeaders } from 'http'; +import { getMissingParams } from '../../lib/helper'; +import { checkCredentials } from '../utils/helpers'; // just using '../utils' here leads to a test failure. need to open an issue against typescript +import { AuthenticateCallback, AuthenticateOptions, AuthenticatorInterface } from './authenticator-interface'; + +export class Authenticator implements AuthenticatorInterface { + /** + * Base Authenticator Class + * + * Provides the Base Authenticator class for others to extend. + */ + constructor() { + if (!(this instanceof Authenticator)) { + throw new Error( + 'the "new" keyword is required to create authenticator instances' + ); + } + } + + public authenticate(options: AuthenticateOptions, callback: AuthenticateCallback): void { + throw new Error('Should be implemented by subclass!'); + } + + protected validate(options: any, requiredOptions: string[]): void { + // check for required params + const missingParamsError = getMissingParams(options, requiredOptions); + if (missingParamsError) { + throw missingParamsError; + } + + // check certain credentials for common user errors: username, password, and apikey + // note: will only apply to certain authenticators + const credsToCheck = ['username', 'password', 'apikey'] + const credentialProblems = checkCredentials(options, credsToCheck); + if (credentialProblems) { + throw new Error(credentialProblems); + } + } +} \ No newline at end of file diff --git a/auth/authenticators/basic-authenticator.ts b/auth/authenticators/basic-authenticator.ts new file mode 100644 index 000000000..c2d898977 --- /dev/null +++ b/auth/authenticators/basic-authenticator.ts @@ -0,0 +1,58 @@ +/** + * Copyright 2019 IBM Corp. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import extend = require('extend'); +import { computeBasicAuthHeader } from '../utils'; +import { Authenticator } from './authenticator'; +import { AuthenticateCallback, AuthenticateOptions, AuthenticatorInterface } from './authenticator-interface'; + +export type Options = { + username?: string; + password?: string; +} + +export class BasicAuthenticator extends Authenticator implements AuthenticatorInterface { + protected requiredOptions = ['username', 'password']; + private username: string; + private password: string; + + /** + * Basic Authenticator Class + * + * Handles the Basic Authentication pattern. + * + * @param {Object} options + * @param {String} options.username + * @param {String} options.password + * @constructor + */ + constructor(options: Options) { + super(); + + this.validate(options, this.requiredOptions); + + this.username = options.username; + this.password = options.password; + } + + public authenticate(options: AuthenticateOptions, callback: AuthenticateCallback): void { + const authHeaderString = computeBasicAuthHeader(this.username, this.password); + const authHeader = { Authorization: authHeaderString } + + options.headers = extend(true, {}, options.headers, authHeader); + callback(null); + } +} \ No newline at end of file diff --git a/auth/authenticators/bearer-token-authenticator.ts b/auth/authenticators/bearer-token-authenticator.ts new file mode 100644 index 000000000..355816799 --- /dev/null +++ b/auth/authenticators/bearer-token-authenticator.ts @@ -0,0 +1,55 @@ +/** + * Copyright 2019 IBM Corp. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import extend = require('extend'); +import { Authenticator } from './authenticator'; +import { AuthenticateCallback, AuthenticateOptions, AuthenticatorInterface } from './authenticator-interface'; + +export type Options = { + bearerToken: string; +} + +export class BearerTokenAuthenticator extends Authenticator implements AuthenticatorInterface { + protected requiredOptions = ['bearerToken']; + private bearerToken: string; + + /** + * Bearer Token Authenticator Class + * + * Handles the Bearer Token pattern. + * + * @param {Object} options + * @param {String} options.bearerToken - bearer token to pass in header + * @constructor + */ + constructor(options: Options) { + super(); + + this.validate(options, this.requiredOptions); + + this.bearerToken = options.bearerToken; + } + + public setBearerToken(bearerToken: string): void { + this.bearerToken = bearerToken; + } + + public authenticate(options: AuthenticateOptions, callback: AuthenticateCallback): void { + const authHeader = { Authorization: `Bearer ${this.bearerToken}` }; + options.headers = extend(true, {}, options.headers, authHeader); + callback(null); + } +} diff --git a/auth/authenticators/cloud-pak-for-data-authenticator.ts b/auth/authenticators/cloud-pak-for-data-authenticator.ts new file mode 100644 index 000000000..ffb5e649e --- /dev/null +++ b/auth/authenticators/cloud-pak-for-data-authenticator.ts @@ -0,0 +1,53 @@ +/** + * Copyright 2019 IBM Corp. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { OutgoingHttpHeaders } from 'http'; +import { Cp4dTokenManager } from '../token-managers'; +import { BaseOptions, TokenRequestBasedAuthenticator } from './token-request-based-authenticator'; + +export interface Options extends BaseOptions { + username: string; + password: string; + url: string; +} + +export class CloudPakForDataAuthenticator extends TokenRequestBasedAuthenticator { + protected requiredOptions = ['username', 'password', 'url']; + private username: string; + private password: string; + + /** + * Cloud Pak for Data Authenticator Class + * + * Handles the CP4D authentication pattern: + * A username and password are provided and used to retrieve a bearer token. + * + * @param {Object} options + * @constructor + */ + constructor(options: Options) { + super(options); + + this.validate(options, this.requiredOptions); + + this.username = options.username; + this.password = options.password; + + // the param names are shared between the authenticator and the token manager + // so we can just pass along the options object + this.tokenManager = new Cp4dTokenManager(options); + } +} diff --git a/auth/authenticators/iam-authenticator.ts b/auth/authenticators/iam-authenticator.ts new file mode 100644 index 000000000..442d03810 --- /dev/null +++ b/auth/authenticators/iam-authenticator.ts @@ -0,0 +1,69 @@ +/** + * Copyright 2019 IBM Corp. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { OutgoingHttpHeaders } from 'http'; +import { IamTokenManager } from '../token-managers'; +import { BaseOptions, TokenRequestBasedAuthenticator } from './token-request-based-authenticator'; + +export interface Options extends BaseOptions { + apikey: string; + clientId?: string; + clientSecret?: string; +} + +export class IamAuthenticator extends TokenRequestBasedAuthenticator { + protected requiredOptions = ['apikey']; + private apikey: string; + private clientId: string; + private clientSecret: string; + + /** + * IAM Authenticator Class + * + * Handles the IAM authentication pattern. + * + * @param {Object} options + * @constructor + */ + constructor(options: Options) { + super(options); + + this.validate(options, this.requiredOptions); + + this.apikey = options.apikey; + this.clientId = options.clientId; + this.clientSecret = options.clientSecret; + + // the param names are shared between the authenticator and the token manager + // so we can just pass along the options object + this.tokenManager = new IamTokenManager(options); + } + + /** + * Setter for the Client ID and the Client Secret. Both should be provided. + * + * @param {string} clientId + * @param {string} clientSecret + * @returns {void} + */ + public setClientIdAndSecret(clientId: string, clientSecret: string): void { + this.clientId = clientId; + this.clientSecret = clientSecret; + + // update properties in token manager + this.tokenManager.setClientIdAndSecret(clientId, clientSecret); + } +} diff --git a/auth/authenticators/index.ts b/auth/authenticators/index.ts new file mode 100644 index 000000000..df07af511 --- /dev/null +++ b/auth/authenticators/index.ts @@ -0,0 +1,24 @@ +/** + * Copyright 2019 IBM Corp. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { AuthenticatorInterface } from './authenticator-interface'; +export { Authenticator } from './authenticator'; +export { BasicAuthenticator } from './basic-authenticator'; +export { BearerTokenAuthenticator } from './bearer-token-authenticator'; +export { CloudPakForDataAuthenticator } from './cloud-pak-for-data-authenticator'; +export { IamAuthenticator } from './iam-authenticator'; +export { NoauthAuthenticator } from './no-auth-authenticator'; +export { TokenRequestBasedAuthenticator } from './token-request-based-authenticator'; diff --git a/auth/authenticators/no-auth-authenticator.ts b/auth/authenticators/no-auth-authenticator.ts new file mode 100644 index 000000000..827796471 --- /dev/null +++ b/auth/authenticators/no-auth-authenticator.ts @@ -0,0 +1,36 @@ +/** + * Copyright 2019 IBM Corp. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Authenticator } from './authenticator'; +import { AuthenticateCallback, AuthenticateOptions, AuthenticatorInterface } from './authenticator-interface'; + +export class NoauthAuthenticator extends Authenticator implements AuthenticatorInterface { + /** + * Noauth Authenticator Class + * + * Provides a way to use a service without specifying credentials. + * + * @constructor + */ + constructor() { + super(); + } + + public authenticate(options: AuthenticateOptions, callback: AuthenticateCallback): void { + // immediately proceed to request. it will probably fail + callback(null); + } +} diff --git a/auth/authenticators/token-request-based-authenticator.ts b/auth/authenticators/token-request-based-authenticator.ts new file mode 100644 index 000000000..d0fa3d9aa --- /dev/null +++ b/auth/authenticators/token-request-based-authenticator.ts @@ -0,0 +1,98 @@ +/** + * Copyright 2019 IBM Corp. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import extend = require('extend'); +import { OutgoingHttpHeaders } from 'http'; +import { JwtTokenManager } from '../token-managers'; +import { Authenticator } from './authenticator'; +import { AuthenticateCallback, AuthenticateOptions, AuthenticatorInterface } from './authenticator-interface'; + +export type BaseOptions = { + headers?: OutgoingHttpHeaders; + disableSslVerification?: boolean; + url?: string; + /** Allow additional request config parameters */ + [propName: string]: any; +} + +export class TokenRequestBasedAuthenticator extends Authenticator implements AuthenticatorInterface { + protected tokenManager: any; + protected url: string; + protected headers: OutgoingHttpHeaders; + protected disableSslVerification: boolean; + + /** + * Request Based Authenticator Class + * + * Handles authentication patterns that invoke requests for bearer tokens. + * + * @param {Object} options + * @constructor + */ + constructor(options: BaseOptions) { + super(); + + this.disableSslVerification = Boolean(options.disableSslVerification); + this.url = options.url; + + // default to empty object + this.headers = options.headers || {}; + + // this class must be extended by a subclass - the JwtTokenManager + // will fail upon requesting a token + this.tokenManager = new JwtTokenManager(options); + } + + /** + * Setter for the disableSslVerification property. + * + * @param {boolean} value - the new value for the disableSslVerification property + * @returns {void} + */ + public setDisableSslVerification(value: boolean): void { + // if they try to pass in a non-boolean value, + // use the "truthy-ness" of the value + this.disableSslVerification = Boolean(value); + this.tokenManager.disableSslVerification = this.disableSslVerification; // could use a setter here + } + + /** + * Set a completely new set of headers. Should we have a method to add/remove a single header? + * + * @param {OutgoingHttpHeaders} headers - the new set of headers as an object + * @returns {void} + */ + public setHeaders(headers: OutgoingHttpHeaders): void { + if (typeof headers !== 'object') { + // do nothing, for now + return; + } + this.headers = headers; + this.tokenManager.headers = this.headers; + } + + public authenticate(options: AuthenticateOptions, callback: AuthenticateCallback): void { + this.tokenManager.getToken((err, token) => { + if (err) { + callback(err); + } else { + const authHeader = { Authorization: `Bearer ${token}` }; + options.headers = extend(true, {}, options.headers, authHeader); + callback(null); + } + }); + } +} diff --git a/auth/index.ts b/auth/index.ts index 4edd6966b..0fcd01731 100644 --- a/auth/index.ts +++ b/auth/index.ts @@ -14,7 +14,6 @@ * limitations under the License. */ -export { IamTokenManagerV1 } from './iam-token-manager-v1'; -export { Icp4dTokenManagerV1 } from './icp4d-token-manager-v1'; -export { JwtTokenManagerV1 } from './jwt-token-manager-v1'; +export * from './authenticators'; +export * from './token-managers'; export * from './utils'; diff --git a/auth/icp4d-token-manager-v1.ts b/auth/token-managers/cp4d-token-manager.ts similarity index 63% rename from auth/icp4d-token-manager-v1.ts rename to auth/token-managers/cp4d-token-manager.ts index e62cc53ba..403eea5d8 100644 --- a/auth/icp4d-token-manager-v1.ts +++ b/auth/token-managers/cp4d-token-manager.ts @@ -15,20 +15,21 @@ */ import extend = require('extend'); -import { JwtTokenManagerV1 } from './jwt-token-manager-v1'; -import { computeBasicAuthHeader } from './utils'; +import { getMissingParams } from '../../lib/helper'; +import { computeBasicAuthHeader } from '../utils'; +import { JwtTokenManager } from './jwt-token-manager'; +// we should make these options extend the ones from the base class export type Options = { url: string; - accessToken?: string; username?: string; password?: string; disableSslVerification?: boolean; } // this interface is a representation of the response -// object from the ICP4D authentication service -export interface IcpTokenData { +// object from the CP4D authentication service +export interface CpdTokenData { username: string; role: string; permissions: string[]; @@ -41,7 +42,7 @@ export interface IcpTokenData { accessToken: string; } -export class Icp4dTokenManagerV1 extends JwtTokenManagerV1 { +export class Cp4dTokenManager extends JwtTokenManager { private username: string; private password: string; @@ -54,7 +55,7 @@ export class Icp4dTokenManagerV1 extends JwtTokenManagerV1 { * @param {String} options.username * @param {String} options.password * @param {String} options.accessToken - user-managed access token - * @param {String} options.url - URL for the ICP4D cluster + * @param {String} options.url - URL for the CP4D cluster * @param {Boolean} options.disableSslVerification - disable SSL verification for token request * @constructor */ @@ -63,21 +64,22 @@ export class Icp4dTokenManagerV1 extends JwtTokenManagerV1 { this.tokenName = 'accessToken'; - if (this.url) { - this.url = this.url + '/v1/preauth/validateAuth'; - } else if (!this.userAccessToken) { - // url is not needed if the user specifies their own access token - throw new Error('`url` is a required parameter for Icp4dTokenManagerV1'); + // check for required params + const requiredOptions = ['username', 'password', 'url']; + const missingParamsError = getMissingParams(options, requiredOptions); + if (missingParamsError) { + throw missingParamsError; } - if (options.username) { - this.username = options.username; - } - if (options.password) { - this.password = options.password; + const tokenApiPath = '/v1/preauth/validateAuth'; + + // do not append the path if user already has + if (this.url && !this.url.endsWith(tokenApiPath)) { + this.url = this.url + tokenApiPath; } - // username and password are required too, unless there's access token - this.rejectUnauthorized = !options.disableSslVerification; + + this.username = options.username; + this.password = options.password; } /** @@ -88,20 +90,23 @@ export class Icp4dTokenManagerV1 extends JwtTokenManagerV1 { * @param {Object} The response if request is successful, null otherwise */ /** - * Request an ICP token using a basic auth header. + * Request an CP4D token using a basic auth header. * * @param {requestTokenCallback} callback - The callback that handles the response. * @returns {void} */ protected requestToken(callback: Function): void { + // these cannot be overwritten + const requiredHeaders = { + Authorization: computeBasicAuthHeader(this.username, this.password), + }; + const parameters = { options: { url: this.url, method: 'GET', - headers: { - Authorization: computeBasicAuthHeader(this.username, this.password), - }, - rejectUnauthorized: this.rejectUnauthorized, + headers: extend(true, {}, this.headers, requiredHeaders), + rejectUnauthorized: !this.disableSslVerification, } }; this.requestWrapperInstance.sendRequest(parameters, callback); diff --git a/auth/iam-token-manager-v1.ts b/auth/token-managers/iam-token-manager.ts similarity index 60% rename from auth/iam-token-manager-v1.ts rename to auth/token-managers/iam-token-manager.ts index 955445560..5ac146d4e 100644 --- a/auth/iam-token-manager-v1.ts +++ b/auth/token-managers/iam-token-manager.ts @@ -15,8 +15,10 @@ */ import extend = require('extend'); -import { JwtTokenManagerV1 } from './jwt-token-manager-v1'; -import { computeBasicAuthHeader } from './utils'; +import { OutgoingHttpHeaders } from 'http'; +import { getMissingParams } from '../../lib/helper'; +import { computeBasicAuthHeader } from '../utils'; +import { JwtTokenManager } from './jwt-token-manager'; /** * Check for only one of two elements being defined. @@ -32,16 +34,15 @@ function onlyOne(a: any, b: any): boolean { return Boolean((a && !b) || (b && !a)); } -const CLIENT_ID_SECRET_WARNING = 'Warning: Client ID and Secret must BOTH be given, or the defaults will be used.'; +const CLIENT_ID_SECRET_WARNING = 'Warning: Client ID and Secret must BOTH be given, or the header will not be included.'; export type Options = { + apikey: string; url?: string; - iamUrl?: string; - iamApikey?: string; - accessToken?: string; - iamAccessToken?: string; - iamClientId?: string; - iamClientSecret?: string; + clientId?: string; + clientSecret?: string; + disableSslVerification?: boolean; + headers?: OutgoingHttpHeaders; } // this interface is a representation of the response @@ -55,10 +56,10 @@ export interface IamTokenData { expiration: number; } -export class IamTokenManagerV1 extends JwtTokenManagerV1 { - private iamApikey: string; - private iamClientId: string; - private iamClientSecret: string; +export class IamTokenManager extends JwtTokenManager { + private apikey: string; + private clientId: string; + private clientSecret: string; /** * IAM Token Manager Service @@ -66,7 +67,7 @@ export class IamTokenManagerV1 extends JwtTokenManagerV1 { * Retreives and stores IAM access tokens. * * @param {Object} options - * @param {String} options.iamApikey + * @param {String} options.apikey * @param {String} options.iamAccessToken * @param {String} options.iamUrl - url of the iam api to retrieve tokens from * @constructor @@ -74,21 +75,24 @@ export class IamTokenManagerV1 extends JwtTokenManagerV1 { constructor(options: Options) { super(options); - this.url = this.url || options.iamUrl || 'https://iam.cloud.ibm.com/identity/token'; - - if (options.iamApikey) { - this.iamApikey = options.iamApikey; - } - if (options.iamAccessToken) { - this.userAccessToken = options.iamAccessToken; + // check for required params + const requiredOptions = ['apikey']; + const missingParamsError = getMissingParams(options, requiredOptions); + if (missingParamsError) { + throw missingParamsError; } - if (options.iamClientId) { - this.iamClientId = options.iamClientId; + + this.apikey = options.apikey; + + this.url = this.url || 'https://iam.cloud.ibm.com/identity/token'; + + if (options.clientId) { + this.clientId = options.clientId; } - if (options.iamClientSecret) { - this.iamClientSecret = options.iamClientSecret; + if (options.clientSecret) { + this.clientSecret = options.clientSecret; } - if (onlyOne(options.iamClientId, options.iamClientSecret)) { + if (onlyOne(options.clientId, options.clientSecret)) { // tslint:disable-next-line console.log(CLIENT_ID_SECRET_WARNING); } @@ -98,17 +102,17 @@ export class IamTokenManagerV1 extends JwtTokenManagerV1 { * Set the IAM 'client_id' and 'client_secret' values. * These values are used to compute the Authorization header used * when retrieving the IAM access token. - * If these values are not set, then a default Authorization header - * will be used when interacting with the IAM token server. + * If these values are not set, no Authorization header will be + * set on the request (it is not required). * - * @param {string} iamClientId - The client id - * @param {string} iamClientSecret - The client secret + * @param {string} clientId - The client id + * @param {string} clientSecret - The client secret * @returns {void} */ - public setIamAuthorizationInfo(iamClientId: string, iamClientSecret: string): void { - this.iamClientId = iamClientId; - this.iamClientSecret = iamClientSecret; - if (onlyOne(iamClientId, iamClientSecret)) { + public setClientIdAndSecret(clientId: string, clientSecret: string): void { + this.clientId = clientId; + this.clientSecret = clientSecret; + if (onlyOne(clientId, clientSecret)) { // tslint:disable-next-line console.log(CLIENT_ID_SECRET_WARNING); } @@ -128,29 +132,27 @@ export class IamTokenManagerV1 extends JwtTokenManagerV1 { * @returns {void} */ protected requestToken(callback: Function): void { - // Use bx:bx as default auth header creds. - let clientId = 'bx'; - let clientSecret = 'bx'; + // these cannot be overwritten + const requiredHeaders = { + 'Content-type': 'application/x-www-form-urlencoded', + } as OutgoingHttpHeaders; // If both the clientId and secret were specified by the user, then use them. - if (this.iamClientId && this.iamClientSecret) { - clientId = this.iamClientId; - clientSecret = this.iamClientSecret; + if (this.clientId && this.clientSecret) { + requiredHeaders.Authorization = computeBasicAuthHeader(this.clientId, this.clientSecret); } const parameters = { options: { url: this.url, method: 'POST', - headers: { - 'Content-type': 'application/x-www-form-urlencoded', - Authorization: computeBasicAuthHeader(clientId, clientSecret), - }, + headers: extend(true, {}, this.headers, requiredHeaders), form: { grant_type: 'urn:ibm:params:oauth:grant-type:apikey', - apikey: this.iamApikey, + apikey: this.apikey, response_type: 'cloud_iam' }, + rejectUnauthorized: !this.disableSslVerification, } }; this.requestWrapperInstance.sendRequest(parameters, callback); diff --git a/iam-token-manager/v1.ts b/auth/token-managers/index.ts similarity index 68% rename from iam-token-manager/v1.ts rename to auth/token-managers/index.ts index 953eaf14d..819e35afe 100644 --- a/iam-token-manager/v1.ts +++ b/auth/token-managers/index.ts @@ -1,5 +1,5 @@ /** - * Copyright 2015 IBM Corp. All Rights Reserved. + * Copyright 2019 IBM Corp. All Rights Reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,11 +14,6 @@ * limitations under the License. */ -/** - * @module iam-token-manager-v1 - */ - -// Exporting the module here for compatibility. To be removed in major release. - -import { IamTokenManagerV1 } from '../auth/iam-token-manager-v1'; -export { IamTokenManagerV1 }; + export { IamTokenManager } from './iam-token-manager'; + export { Cp4dTokenManager } from './cp4d-token-manager'; + export { JwtTokenManager } from './jwt-token-manager'; diff --git a/auth/jwt-token-manager-v1.ts b/auth/token-managers/jwt-token-manager.ts similarity index 80% rename from auth/jwt-token-manager-v1.ts rename to auth/token-managers/jwt-token-manager.ts index ddf765b7b..a097b3211 100644 --- a/auth/jwt-token-manager-v1.ts +++ b/auth/token-managers/jwt-token-manager.ts @@ -15,23 +15,25 @@ */ import extend = require('extend'); +import { OutgoingHttpHeaders } from 'http'; import jwt = require('jsonwebtoken'); -import { RequestWrapper } from '../lib/requestwrapper'; +import { RequestWrapper } from '../../lib/requestwrapper'; function getCurrentTime(): number { return Math.floor(Date.now() / 1000); } export type Options = { - accessToken?: string; url?: string; + /** Allow additional request config parameters */ + [propName: string]: any; } -export class JwtTokenManagerV1 { +export class JwtTokenManager { protected url: string; protected tokenName: string; - protected userAccessToken: string; - protected rejectUnauthorized: boolean; // for icp4d only + protected disableSslVerification: boolean; + protected headers: OutgoingHttpHeaders; protected requestWrapperInstance; private tokenInfo: any; private expireTime: number; @@ -57,11 +59,12 @@ export class JwtTokenManagerV1 { this.url = options.url; } - if (options.accessToken) { - this.userAccessToken = options.accessToken; - } + // request options + this.disableSslVerification = Boolean(options.disableSslVerification); + this.headers = options.headers || {}; - this.requestWrapperInstance = new RequestWrapper(); + // any config options for the internal request library, like `proxy`, will be passed here + this.requestWrapperInstance = new RequestWrapper(options); } /** @@ -75,11 +78,8 @@ export class JwtTokenManagerV1 { * @param {Function} cb - callback function that the token will be passed to */ public getToken(cb: Function) { - if (this.userAccessToken) { - // 1. use user-managed token - return cb(null, this.userAccessToken); - } else if (!this.tokenInfo[this.tokenName] || this.isTokenExpired()) { - // 2. request a new token + if (!this.tokenInfo[this.tokenName] || this.isTokenExpired()) { + // 1. request a new token this.requestToken((err, tokenResponse) => { if (!err) { try { @@ -93,27 +93,11 @@ export class JwtTokenManagerV1 { return cb(err, this.tokenInfo[this.tokenName] || null); }); } else { - // 3. use valid, sdk-managed token + // 2. use valid, managed token return cb(null, this.tokenInfo[this.tokenName]); } } - /** - * Set a self-managed access token. - * The access token should be valid and not yet expired. - * - * By using this method, you accept responsibility for managing the - * access token yourself. You must set a new access token before this - * one expires. Failing to do so will result in authentication errors - * after this token expires. - * - * @param {string} accessToken - A valid, non-expired access token - * @returns {void} - */ - public setAccessToken(accessToken: string): void { - this.userAccessToken = accessToken; - } - /** * Request a JWT using an API key. * diff --git a/auth/utils/get-authenticator-from-environment.ts b/auth/utils/get-authenticator-from-environment.ts new file mode 100644 index 000000000..8ebb8fc1d --- /dev/null +++ b/auth/utils/get-authenticator-from-environment.ts @@ -0,0 +1,78 @@ +/** + * Copyright 2019 IBM Corp. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import camelcase = require('camelcase'); +import extend = require('extend'); + +import { + BasicAuthenticator, + BearerTokenAuthenticator, + CloudPakForDataAuthenticator, + IamAuthenticator, + NoauthAuthenticator, +} from '../authenticators'; + +import { readExternalSources } from './read-external-sources'; + +export function getAuthenticatorFromEnvironment(serviceName: string) { + if (!serviceName) { + throw new Error('Service name is required.'); + } + + // construct the credentials object from the environment + const credentials = readExternalSources(serviceName); + + if (credentials === null) { + throw new Error('Unable to create an authenticator from the environment.'); + } + + // remove client-level properties + delete credentials.url; + delete credentials.disableSsl; + + // convert "auth" properties to their proper keys + if (credentials.authUrl) { + credentials.url = credentials.authUrl; + delete credentials.authUrl; + } + + if (credentials.authDisableSsl) { + credentials.disableSslVerification = credentials.authDisableSsl; + delete credentials.authDisableSsl; + } + + // create and return the appropriate authenticator + let authenticator; + + switch (credentials.authType) { + case 'noauth': + authenticator = new NoauthAuthenticator(); + break; + case 'basic': + authenticator = new BasicAuthenticator(credentials); + break; + case 'bearerToken': + authenticator = new BearerTokenAuthenticator(credentials); + break; + case 'cp4d': + authenticator = new CloudPakForDataAuthenticator(credentials); + break; + default: // default the authentication type to iam + authenticator = new IamAuthenticator(credentials); + } + + return authenticator; +} diff --git a/auth/utils/helpers.ts b/auth/utils/helpers.ts new file mode 100644 index 000000000..c33517970 --- /dev/null +++ b/auth/utils/helpers.ts @@ -0,0 +1,56 @@ +/** + * Copyright 2019 IBM Corp. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Compute and return a Basic Authorization header from a username and password. + * + * @param {string} username - The username or client id + * @param {string} password - The password or client secret + * @returns {string} - A Basic Auth header with format "Basic " + */ +export function computeBasicAuthHeader(username: string, password: string): string { + const encodedCreds = Buffer.from(`${username}:${password}`).toString('base64'); + return `Basic ${encodedCreds}`; +} + +// returns true if the string has a curly bracket or quote as the first or last character +// these are common user-issues that we should handle before they get a network error +function badCharAtAnEnd(value: string): boolean { + return value.startsWith('{') || value.startsWith('"') || value.endsWith('}') || value.endsWith('"'); +} + +/** + * Checks credentials for common user mistakes of copying {, }, or " characters from the documentation + * + * @param {object} obj - The options object holding credentials + * @param {string[]} credsToCheck - An array containing the keys of the credentials to check for problems + * @returns {string | null} - Returns a string with the error message if there were problems, null if not + */ +export function checkCredentials(obj: any, credsToCheck: string[]) : string | null { + let errorMessage = ''; + credsToCheck.forEach(cred => { + if (obj[cred] && badCharAtAnEnd(obj[cred])) { + errorMessage += `The ${cred} shouldn't start or end with curly brackets or quotes. Be sure to remove any {, }, or "`; + } + }); + + if (errorMessage.length) { + errorMessage += 'Revise these credentials - they should not start or end with curly brackets or quotes.'; + return errorMessage; + } else { + return null; + } +} diff --git a/auth/utils.ts b/auth/utils/index.ts similarity index 55% rename from auth/utils.ts rename to auth/utils/index.ts index 7fe2fb4e3..23f8e7739 100644 --- a/auth/utils.ts +++ b/auth/utils/index.ts @@ -14,14 +14,7 @@ * limitations under the License. */ -/** - * Compute and return a Basic Authorization header from a username and password. - * - * @param {string} username - The username or client id - * @param {string} password - The password or client secret - * @returns {string} - A Basic Auth header with format "Basic " - */ -export function computeBasicAuthHeader(username: string, password: string): string { - const encodedCreds = Buffer.from(`${username}:${password}`).toString('base64'); - return `Basic ${encodedCreds}`; -} +export * from './helpers'; +export * from './read-credentials-file'; +export { getAuthenticatorFromEnvironment } from './get-authenticator-from-environment'; +export { readExternalSources } from './read-external-sources'; diff --git a/lib/read-credentials-file.ts b/auth/utils/read-credentials-file.ts similarity index 100% rename from lib/read-credentials-file.ts rename to auth/utils/read-credentials-file.ts diff --git a/auth/utils/read-external-sources.ts b/auth/utils/read-external-sources.ts new file mode 100644 index 000000000..3ea043dc9 --- /dev/null +++ b/auth/utils/read-external-sources.ts @@ -0,0 +1,109 @@ +/** + * Copyright 2019 IBM Corp. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import camelcase = require('camelcase'); +import extend = require('extend'); +import isEmpty = require('lodash.isempty'); +import vcapServices = require('vcap_services'); +import { readCredentialsFile } from './read-credentials-file'; + +/* + * Read properties stored in external sources like Environment Variables, + * the credentials file, VCAP services, etc. and return them as an + * object. The keys of this object will have the service name prefix removed + * and will be converted to lower camel case. + * + * Only one source will be used at a time. + */ + +export function readExternalSources(serviceName: string) { + if (!serviceName) { + throw new Error('Service name is required.'); + } + + return getProperties(serviceName); +} + +function getProperties(serviceName: string): any { + // Try to get properties from external sources, with the following priority: + // 1. Credentials file (ibm-credentials.env) + // 2. Environment variables + // 3. VCAP Services (Cloud Foundry) + + // only get properties from one source, return null if none found + let properties = null; + + properties = filterPropertiesByServiceName(readCredentialsFile(), serviceName); + + if (isEmpty(properties)) { + properties = filterPropertiesByServiceName(process.env, serviceName); + } + + if (isEmpty(properties)) { + properties = getCredentialsFromCloud(serviceName); + } + + return properties; +} + +/** + * Pulls credentials from env properties + * + * Property checked is uppercase service.name suffixed by _USERNAME and _PASSWORD + * + * For example, if service.name is speech_to_text, + * env properties are SPEECH_TO_TEXT_USERNAME and SPEECH_TO_TEXT_PASSWORD + * + * @param {Object} envObj - the object containing the credentials keyed by environment variables + * @returns {Credentials} + */ +function filterPropertiesByServiceName(envObj: any, serviceName: string): any { + const credentials = {} as any; + const name: string = serviceName.toUpperCase().replace(/-/g, '_') + '_'; // append the underscore that must follow the service name + + // filter out properties that don't begin with the service name + Object.keys(envObj).forEach(key => { + if (key.startsWith(name)) { + const propName = camelcase(key.substring(name.length)); // remove the name from the front of the string and make camelcase + credentials[propName] = envObj[key]; + } + }); + + // all env variables are parsed as strings, convert disable ssl vars to boolean + if (typeof credentials.disableSsl === 'string') { + credentials.disableSsl = credentials.disableSsl === 'true'; + } + + if (typeof credentials.authDisableSsl === 'string') { + credentials.authDisableSsl = credentials.authDisableSsl === 'true'; + } + + return credentials; +} + +/** + * Pulls credentials from VCAP_SERVICES env property that IBM Cloud sets + * + */ +function getCredentialsFromCloud(serviceName: string): any { + const credentials = vcapServices.getCredentials(serviceName); + // infer authentication type from credentials in a simple manner + // iam is used as the default later + if (credentials.username || credentials.password) { + credentials.authType = 'basic'; + } + return credentials; +} \ No newline at end of file diff --git a/index.ts b/index.ts index 422f8dcb4..f1ccc6a8c 100644 --- a/index.ts +++ b/index.ts @@ -19,8 +19,7 @@ */ export { BaseService } from './lib/base_service'; -export { IamTokenManagerV1 } from './auth/iam-token-manager-v1'; -export { Icp4dTokenManagerV1 } from './auth/icp4d-token-manager-v1'; +export * from './auth'; export * from './lib/helper'; export { default as qs } from './lib/querystring'; export { default as contentType } from './lib/content-type'; diff --git a/lib/base_service.ts b/lib/base_service.ts index bf654f219..a3241e52a 100644 --- a/lib/base_service.ts +++ b/lib/base_service.ts @@ -15,141 +15,38 @@ */ import extend = require('extend'); +import { OutgoingHttpHeaders } from 'http'; import semver = require('semver'); import vcapServices = require('vcap_services'); -import { computeBasicAuthHeader, IamTokenManagerV1, Icp4dTokenManagerV1 } from '../auth'; +import { AuthenticatorInterface, checkCredentials, readExternalSources } from '../auth'; import { stripTrailingSlash } from './helper'; -import { readCredentialsFile } from './read-credentials-file'; import { RequestWrapper } from './requestwrapper'; -// custom interfaces -export interface HeaderOptions { - 'X-Watson-Learning-Opt-Out'?: boolean; - [key: string]: any; -} - export interface UserOptions { url?: string; version?: string; - username?: string; - password?: string; - apikey?: string; - use_unauthenticated?: boolean; - headers?: HeaderOptions; - token?: string; - icp4d_access_token?: string; - icp4d_url?: string; - iam_access_token?: string; - iam_apikey?: string; - iam_url?: string; - iam_client_id?: string; - iam_client_secret?: string; - authentication_type?: string; - disable_ssl_verification?: boolean; + headers?: OutgoingHttpHeaders; + disableSslVerification?: boolean; + authenticator: AuthenticatorInterface; /** Allow additional request config parameters */ [propName: string]: any; } export interface BaseServiceOptions extends UserOptions { - headers: HeaderOptions; - url: string; qs: any; - rejectUnauthorized?: boolean; -} - -export interface Credentials { - username?: string; - password?: string; - url?: string; - icp4d_access_token?: string; - icp4d_url?: string; - iam_access_token?: string; - iam_apikey?: string; - iam_url?: string; - iam_client_id?: string; - iam_client_secret?: string; - authentication_type?: string; -} - -function hasCredentials(obj: any): boolean { - return ( - obj && - ((obj.username && obj.password) || - obj.iam_access_token || - obj.iam_apikey || - obj.icp4d_access_token) - ); -} - -function isForICP(cred: string): boolean { - return cred && cred.startsWith('icp-'); -} - -function isForICP4D(obj: any): boolean { - return obj && (obj.authentication_type === 'icp4d' || obj.icp4d_access_token); -} - -function hasBasicCredentials(obj: any): boolean { - return obj && obj.username && obj.password && !usesBasicForIam(obj); -} - -function hasIamCredentials(obj: any): boolean { - return obj && (obj.iam_apikey || obj.iam_access_token) && !isForICP(obj.iam_apikey); -} - -// returns true if the user provides basic auth creds with the intention -// of using IAM auth -function usesBasicForIam(obj: any): boolean { - return obj.username === 'apikey' && !isForICP(obj.password); -} - -// returns true if the string has a curly bracket or quote as the first or last character -// these are common user-issues that we should handle before they get a network error -function badCharAtAnEnd(value: string): boolean { - return value.startsWith('{') || value.startsWith('"') || value.endsWith('}') || value.endsWith('"'); -} - -// checks credentials for common user mistakes of copying {, }, or " characters from the documentation -function checkCredentials(obj: any) { - let errorMessage = ''; - const credsToCheck = ['url', 'username', 'password', 'iam_apikey']; - credsToCheck.forEach(cred => { - if (obj[cred] && badCharAtAnEnd(obj[cred])) { - errorMessage += `The ${cred} shouldn't start or end with curly brackets or quotes. Be sure to remove any {, }, or "`; - } - }); - - if (errorMessage.length) { - errorMessage += 'Revise these credentials - they should not start or end with curly brackets or quotes.'; - return errorMessage; - } else { - return null; - } } export class BaseService { static URL: string; name: string; serviceVersion: string; - protected _options: BaseServiceOptions; - protected serviceDefaults: object; - protected tokenManager; + protected baseOptions: BaseServiceOptions; + private authenticator: AuthenticatorInterface; private requestWrapperInstance; /** * Internal base class that other services inherit from * @param {UserOptions} options - * @param {string} [options.iam_apikey] - api key used to retrieve an iam access token - * @param {string} [options.iam_access_token] - iam access token provided and managed by user - * @param {string} [options.iam_url] - url for iam service api, needed for services in staging - * @param {string} [options.iam_client_id] - client id (username) for request to iam service - * @param {string} [options.iam_client_secret] - secret (password) for request to iam service - * @param {string} [options.icp4d_access_token] - icp for data access token provided and managed by user - * @param {string} [options.icp4d_url] - icp for data base url - used for authentication - * @param {string} [options.authentication_type] - authentication pattern to be used. can be iam, basic, or icp4d - * @param {string} [options.username] - required unless use_unauthenticated is set - * @param {string} [options.password] - required unless use_unauthenticated is set - * @param {boolean} [options.use_unauthenticated] - skip credential requirement * @param {HeaderOptions} [options.headers] * @param {boolean} [options.headers.X-Watson-Learning-Opt-Out=false] - opt-out of data collection * @param {string} [options.url] - override default service base url @@ -161,331 +58,86 @@ export class BaseService { */ constructor(userOptions: UserOptions) { if (!(this instanceof BaseService)) { - // it might be better to just create a new instance and return that.. - // but that can't be done here, it has to be done in each individual service. - // So this is still a good failsafe even in that case. throw new Error( - 'the "new" keyword is required to create Watson service instances' + 'the "new" keyword is required to create service instances' ); } - // deprecate node versions 6 and 8 - // these will be removed in v5 - const isNodeSix = semver.satisfies(process.version, '6.x'); - const isNodeEight = semver.satisfies(process.version, '8.x'); - if (isNodeSix) { - console.warn('Node v6 will not be supported in the SDK in the next major release (09/2019). Version 6 reached end of life in April 2019.'); - } - if (isNodeEight) { - console.warn('Node v8 will not be supported in the SDK in the next major release (09/2019). Version 8 reaches end of life on 31 December 2019.'); - } - + const _options = {} as BaseServiceOptions; const options = extend({}, userOptions); - const _options = this.initCredentials(options); - if (options.url) { _options.url = stripTrailingSlash(options.url); } + // check url for common user errors + const credentialProblems = checkCredentials(options, ['url']); + if (credentialProblems) { + throw new Error(credentialProblems); + } + + // if disableSslVerification is not explicity set to the boolean value `true`, + // force it to be false + if (options.disableSslVerification !== true) { + options.disableSslVerification = false; + } + const serviceClass = this.constructor as typeof BaseService; - this._options = extend( + this.baseOptions = extend( { qs: {}, url: serviceClass.URL }, - this.serviceDefaults, options, + this.readOptionsFromExternalConfig(), _options ); - // rejectUnauthorized should only be false if disable_ssl_verification is true - // used to disable ssl checking for icp - this._options.rejectUnauthorized = !options.disable_ssl_verification; + this.requestWrapperInstance = new RequestWrapper(this.baseOptions); - if (_options.authentication_type === 'iam' || hasIamCredentials(_options)) { - this.tokenManager = new IamTokenManagerV1({ - iamApikey: _options.iam_apikey, - accessToken: _options.iam_access_token, - url: _options.iam_url, - iamClientId: _options.iam_client_id, - iamClientSecret: _options.iam_client_secret, - }); - } else if (usesBasicForIam(_options)) { - this.tokenManager = new IamTokenManagerV1({ - iamApikey: _options.password, - url: _options.iam_url, - iamClientId: _options.iam_client_id, - iamClientSecret: _options.iam_client_secret, - }); - } else if (isForICP4D(_options)) { - if (!_options.icp4d_url && !_options.icp4d_access_token) { - throw new Error('`icp4d_url` is required when using an SDK-managed token for ICP4D.'); - } - this.tokenManager = new Icp4dTokenManagerV1({ - url: _options.icp4d_url, - username: _options.username, - password: _options.password, - accessToken: _options.icp4d_access_token, - disableSslVerification: options.disable_ssl_verification, - }); - } else { - this.tokenManager = null; + // set authenticator + if (!options.authenticator) { + throw new Error('Authenticator must be set.'); } - this.requestWrapperInstance = new RequestWrapper(this._options); + this.authenticator = options.authenticator; } /** - * Retrieve this service's credentials - useful for passing to the authorization service + * Get the instance of the authenticator set on the service. * - * Only returns a URL when token auth is used. - * - * @returns {Credentials} + * @returns {Authenticator} */ - public getServiceCredentials(): Credentials { - const credentials = {} as Credentials; - if (this._options.username) { - credentials.username = this._options.username; - } - if (this._options.password) { - credentials.password = this._options.password; - } - if (this._options.url) { - credentials.url = this._options.url; - } - if (this._options.iam_access_token) { - credentials.iam_access_token = this._options.iam_access_token; - } - if (this._options.iam_apikey) { - credentials.iam_apikey = this._options.iam_apikey; - } - if (this._options.iam_url) { - credentials.iam_url = this._options.iam_url; - } - if (this._options.iam_client_id) { - credentials.iam_client_id = this._options.iam_client_id; - } - if (this._options.iam_client_secret) { - credentials.iam_client_secret = this._options.iam_client_secret; - } - if (this._options.icp4d_access_token) { - credentials.icp4d_access_token = this._options.icp4d_access_token; - } - if (this._options.icp4d_url) { - credentials.icp4d_url = this._options.icp4d_url; - } - if (this._options.authentication_type) { - credentials.authentication_type = this._options.authentication_type; - } - return credentials; - } - - /** - * Set an IAM access token to use when authenticating with the service. - * The access token should be valid and not yet expired. - * - * By using this method, you accept responsibility for managing the - * access token yourself. You must set a new access token before this - * one expires. Failing to do so will result in authentication errors - * after this token expires. - * - * @param {string} access_token - A valid, non-expired IAM access token - * @returns {void} - */ - public setAccessToken(access_token: string) { // tslint:disable-line variable-name - if (this.tokenManager) { - this.tokenManager.setAccessToken(access_token); - } else if (this._options.authentication_type === 'icp4d') { - this.tokenManager = new Icp4dTokenManagerV1({ - accessToken: access_token, - url: this._options.icp4d_url, - disableSslVerification: this._options.disable_ssl_verification, - }); - } else { - this.tokenManager = new IamTokenManagerV1({ - accessToken: access_token, - }); - } + public getAuthenticator(): any { + return this.authenticator; } /** - * Guarantee that the next request you make will be IAM authenticated. This - * performs any requests necessary to get a valid IAM token so that if your - * next request involves a streaming operation, it will not be interrupted. - * - * @param {Function} callback - callback function to return flow of execution - * - * @returns {void} - */ - protected preAuthenticate(callback): void { - if (Boolean(this.tokenManager)) { - return this.tokenManager.getToken((err, token) => { - if (err) { - callback(err); - } - callback(null); - }); - } else { - callback(null); - } - } - - /** - * Wrapper around `sendRequest` that determines whether or not IAM tokens - * are being used to authenticate the request. If so, the token is - * retrieved by the token manager. + * Wrapper around `sendRequest` that enforces the request will be authenticated. * * @param {Object} parameters - service request options passed in by user * @param {Function} callback - callback function to pass the response back to * @returns {ReadableStream|undefined} */ protected createRequest(parameters, callback) { - if (Boolean(this.tokenManager)) { - return this.tokenManager.getToken((err, accessToken) => { - if (err) { - return callback(err); - } - parameters.defaultOptions.headers.Authorization = - `Bearer ${accessToken}`; - return this.requestWrapperInstance.sendRequest(parameters, callback); - }); - } else { - return this.requestWrapperInstance.sendRequest(parameters, callback); - } + this.authenticator.authenticate(parameters.defaultOptions, err => { + err ? callback(err) : this.requestWrapperInstance.sendRequest(parameters, callback); + }); } - /** - * @private - * @param {UserOptions} options - * @returns {BaseServiceOptions} - */ - private initCredentials(options: UserOptions): BaseServiceOptions { - let _options: BaseServiceOptions = {} as BaseServiceOptions; - if (options.token) { - options.headers = options.headers || {}; - options.headers['X-Watson-Authorization-Token'] = options.token; - _options = extend(_options, options); - return _options; - } - // Get credentials from environment properties or IBM Cloud, - // but prefer credentials provided programatically - _options = extend( - {}, - this.getCredentialsFromCloud(this.name), - this.getCredentialsFromEnvironment(process.env, this.name), - this.getCredentialsFromEnvironment(readCredentialsFile(), this.name), - options, - _options - ); + private readOptionsFromExternalConfig() { + const results = {} as any; + const properties = readExternalSources(this.name); - // make authentication_type non-case-sensitive - if (typeof _options.authentication_type === 'string') { - _options.authentication_type = _options.authentication_type.toLowerCase(); - } + if (properties !== null) { + // the user can define two client-level variables in the credentials file: url and disableSsl + const { url, disableSsl } = properties; - if (!_options.use_unauthenticated) { - if (!hasCredentials(_options)) { - const errorMessage = 'Insufficient credentials provided in ' + - 'constructor argument. Refer to the documentation for the ' + - 'required parameters. Common examples are username/password and ' + - 'iam_apikey.'; - throw new Error(errorMessage); - } - // handle iam_apikey containing an ICP api key - if (isForICP(_options.iam_apikey)) { - _options.username = 'apikey'; - _options.password = _options.iam_apikey; - // remove apikey so code doesnt confuse credentials as iam - delete _options.iam_apikey; - delete options.iam_apikey; + if (url) { + results.url = url; } - - if (!hasIamCredentials(_options) && !usesBasicForIam(_options) && !isForICP4D(_options)) { - if (_options.authentication_type === 'basic' || hasBasicCredentials(_options)) { - // Calculate and add Authorization header to base options - const encodedCredentials = computeBasicAuthHeader(_options.username, _options.password); - const authHeader = { Authorization: `${encodedCredentials}` }; - _options.headers = extend(authHeader, _options.headers); - } + if (disableSsl === true) { + results.disableSslVerification = disableSsl; } } - // check credentials for common user errors - const credentialProblems = checkCredentials(_options); - if (credentialProblems) { - throw new Error(credentialProblems); - } - return _options; - } - /** - * Pulls credentials from env properties - * - * Property checked is uppercase service.name suffixed by _USERNAME and _PASSWORD - * - * For example, if service.name is speech_to_text, - * env properties are SPEECH_TO_TEXT_USERNAME and SPEECH_TO_TEXT_PASSWORD - * - * @private - * @param {string} name - the service snake case name - * @returns {Credentials} - */ - private getCredentialsFromEnvironment(envObj: any, name: string): Credentials { - if (name === 'watson_vision_combined') { - return this.getCredentialsFromEnvironment(envObj, 'visual_recognition'); - } - // Case handling for assistant - should look for assistant env variables before conversation - if (name === 'conversation' && (envObj[`ASSISTANT_USERNAME`] || envObj[`ASSISTANT_IAM_APIKEY`])) { - return this.getCredentialsFromEnvironment(envObj, 'assistant'); - } - const _name: string = name.toUpperCase(); - // https://github.com/watson-developer-cloud/node-sdk/issues/605 - const nameWithUnderscore: string = _name.replace(/-/g, '_'); - const username: string = envObj[`${_name}_USERNAME`] || envObj[`${nameWithUnderscore}_USERNAME`]; - const password: string = envObj[`${_name}_PASSWORD`] || envObj[`${nameWithUnderscore}_PASSWORD`]; - const apiKey: string = envObj[`${_name}_API_KEY`] || envObj[`${nameWithUnderscore}_API_KEY`]; - const url: string = envObj[`${_name}_URL`] || envObj[`${nameWithUnderscore}_URL`]; - const iamAccessToken: string = envObj[`${_name}_IAM_ACCESS_TOKEN`] || envObj[`${nameWithUnderscore}_IAM_ACCESS_TOKEN`]; - const iamApiKey: string = envObj[`${_name}_IAM_APIKEY`] || envObj[`${nameWithUnderscore}_IAM_APIKEY`]; - const iamUrl: string = envObj[`${_name}_IAM_URL`] || envObj[`${nameWithUnderscore}_IAM_URL`]; - const iamClientId: string = envObj[`${_name}_IAM_CLIENT_ID`] || envObj[`${_name}_IAM_CLIENT_ID`]; - const iamClientSecret: string = envObj[`${_name}_IAM_CLIENT_SECRET`] || envObj[`${_name}_IAM_CLIENT_SECRET`]; - const icp4dAccessToken: string = envObj[`${_name}_ICP4D_ACCESS_TOKEN`] || envObj[`${nameWithUnderscore}_ICP4D_ACCESS_TOKEN`]; - const icp4dUrl: string = envObj[`${_name}_ICP4D_URL`] || envObj[`${nameWithUnderscore}_ICP4D_URL`]; - const authenticationType: string = envObj[`${_name}_AUTHENTICATION_TYPE`] || envObj[`${nameWithUnderscore}_AUTHENTICATION_TYPE`]; - return { - username, - password, - url, - iam_access_token: iamAccessToken, - iam_apikey: iamApiKey, - iam_url: iamUrl, - iam_client_id: iamClientId, - iam_client_secret: iamClientSecret, - icp4d_access_token: icp4dAccessToken, - icp4d_url: icp4dUrl, - authentication_type: authenticationType, - }; - } - /** - * Pulls credentials from VCAP_SERVICES env property that IBM Cloud sets - * @param {string} vcap_services_name - * @private - * @returns {Credentials} - */ - private getCredentialsFromCloud(vcapServicesName: string): Credentials { - let credentials: Credentials; - let temp: any; - if (this.name === 'visual_recognition') { - temp = vcapServices.getCredentials('watson_vision_combined'); - } if (this.name === 'assistant') { - temp = vcapServices.getCredentials('conversation'); - } else { - temp = vcapServices.getCredentials(vcapServicesName); - } - // convert an iam apikey to use the identifier iam_apikey - if (temp.apikey && temp.iam_apikey_name) { - temp.iam_apikey = temp.apikey; - delete temp.apikey; - } - credentials = temp; - return credentials; + return results; } } diff --git a/lib/requestwrapper.ts b/lib/requestwrapper.ts index 470338159..d2ce46e96 100644 --- a/lib/requestwrapper.ts +++ b/lib/requestwrapper.ts @@ -40,7 +40,9 @@ export class RequestWrapper { // defaults here const axiosConfig = { httpsAgent: new https.Agent({ - rejectUnauthorized: axiosOptions.rejectUnauthorized + // disableSslVerification is the parameter we expose to the user, + // it is the opposite of rejectUnauthorized + rejectUnauthorized: !axiosOptions.disableSslVerification }), maxContentLength: Infinity, headers: { diff --git a/migration-guide.md b/migration-guide.md index a995412e5..099e99f3b 100644 --- a/migration-guide.md +++ b/migration-guide.md @@ -7,3 +7,27 @@ Node versions 6 and 8 are no longer supported. ### Callback arguments The old callback argument structure of `(error, body, response)` has been changed to `(error, response)`. The body is available under the `result` property of the response. The `data` property has been removed in favor of `result`. + +### Authentication +Credentials are no longer passed in as constructor parameters. Rather, a single `Authenticator` is instantiated and passed in to the constructor. Example: + +```js +const authenticator = new IamAuthenticator({ + apikey: 'abc-123', +}); + +const service = new MyService({ + authenticator, +}); +``` + +- The method `getServiceCredentials` has been removed from the `BaseService` class. This is replaced by `getAuthenticator`, which returns the authenticator instance. +- Reading credentials from external sources (like environment variables) no longer happens for _credentials_ by default if none are passed to the `Authenticator` (The service URL can still be read automatically). The method `getAuthenticatorFromEnvironment` will return an `Authenticator` by reading from the external sources. + - Note that this method will only read from _one external source at a time_. It will not combine credentials from multiple sources, which was the behavior previously. + +#### Token Managers +- `Icp4dTokenManagerV1` renamed to `Cp4dTokenManager` +- `IamTokenManagerV1` renamed to `IamTokenManager` +- `JwtTokenManagerV1` renamed to `JwtTokenManager` +- Token managers no longer support the `accessToken` parameter. There is no need for a token manager when a user is managing their own token. This behavior is replaced by the `BearerTokenAuthenticator` class. +- In the IAM Token Manager: the method `setAuthorizationInfo` is renamed to `setClientIdAndSecret` diff --git a/package-lock.json b/package-lock.json index fb7c87a47..43dd5bdc1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1936,10 +1936,9 @@ } }, "camelcase": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-4.1.0.tgz", - "integrity": "sha1-1UVjW+HjPFQmScaRc+Xeas+uNN0=", - "dev": true + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==" }, "camelcase-keys": { "version": "4.2.0", @@ -1950,6 +1949,14 @@ "camelcase": "^4.1.0", "map-obj": "^2.0.0", "quick-lru": "^1.0.0" + }, + "dependencies": { + "camelcase": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-4.1.0.tgz", + "integrity": "sha1-1UVjW+HjPFQmScaRc+Xeas+uNN0=", + "dev": true + } } }, "capture-exit": { @@ -5586,6 +5593,11 @@ "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", "integrity": "sha1-bC4XHbKiV82WgC/UOwGyDV9YcPY=" }, + "lodash.isempty": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.isempty/-/lodash.isempty-4.4.0.tgz", + "integrity": "sha1-b4bL7di+TsmHvpqvM8loTbGzHn4=" + }, "lodash.isinteger": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", @@ -12266,6 +12278,14 @@ "dev": true, "requires": { "camelcase": "^4.1.0" + }, + "dependencies": { + "camelcase": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-4.1.0.tgz", + "integrity": "sha1-1UVjW+HjPFQmScaRc+Xeas+uNN0=", + "dev": true + } } } } diff --git a/package.json b/package.json index 004d60eb0..7727850b9 100644 --- a/package.json +++ b/package.json @@ -56,12 +56,14 @@ "@types/is-stream": "~1.1.0", "@types/node": "~10.3.5", "axios": "^0.18.0", + "camelcase": "^5.3.1", "dotenv": "^6.2.0", "extend": "~3.0.2", "file-type": "^7.7.1", "form-data": "^2.3.3", "isstream": "~0.1.2", "jsonwebtoken": "^8.5.1", + "lodash.isempty": "^4.4.0", "mime-types": "~2.1.18", "object.omit": "~3.0.0", "object.pick": "~1.3.0", diff --git a/scripts/typedoc/generate_typedoc.sh b/scripts/typedoc/generate_typedoc.sh index 779d26192..6a749fe0d 100644 --- a/scripts/typedoc/generate_typedoc.sh +++ b/scripts/typedoc/generate_typedoc.sh @@ -1,6 +1,6 @@ ./node_modules/.bin/typedoc --mode file --theme ./scripts/typedoc/theme --excludeExternals \ - --out ./doc ./iam-token-manager/v1.ts \ + --out ./doc \ ./lib/base_service.ts ./lib/content-type.ts \ ./lib/helper.ts ./lib/querystring.ts \ - ./lib/read-credentials-file.ts ./lib/requestwrapper.ts \ + ./lib/requestwrapper.ts \ ./lib/stream-to-promise.ts --target "ES5" diff --git a/test/resources/ibm-credentials.env b/test/resources/ibm-credentials.env index 891884667..0994fc9fa 100644 --- a/test/resources/ibm-credentials.env +++ b/test/resources/ibm-credentials.env @@ -1,2 +1,11 @@ -TEST_USERNAME=123456789 -TEST_PASSWORD=abcd \ No newline at end of file +# auth properties +TEST_SERVICE_AUTH_TYPE=iam +TEST_SERVICE_APIKEY=12345 +TEST_SERVICE_AUTH_URL=iam.staging.com/api +TEST_SERVICE_CLIENT_ID=my-id +TEST_SERVICE_CLIENT_SECRET=my-secret +TEST_SERVICE_AUTH_DISABLE_SSL=true + +# service properties +TEST_SERVICE_URL=service.com/api +TEST_SERVICE_DISABLE_SSL=true diff --git a/test/unit/authenticator.test.js b/test/unit/authenticator.test.js new file mode 100644 index 000000000..9a9319087 --- /dev/null +++ b/test/unit/authenticator.test.js @@ -0,0 +1,15 @@ +'use strict'; + +const { Authenticator } = require('../../auth'); + +describe('Authenticator', () => { + it('should throw if "new" keyword is not used to create an instance', () => { + expect(() => { + // prettier-ignore + // eslint-disable-next-line new-cap + Authenticator(); + }).toThrow(); + }); + + // relying on individual authenticator tests to test the rest of this implementation +}); diff --git a/test/unit/base-service.test.js b/test/unit/base-service.test.js new file mode 100644 index 000000000..1b285819e --- /dev/null +++ b/test/unit/base-service.test.js @@ -0,0 +1,290 @@ +'use strict'; + +const util = require('util'); + +// create a mock for the read-external-sources module +const readExternalSourcesModule = require('../../auth/utils/read-external-sources'); +const readExternalSourcesMock = (readExternalSourcesModule.readExternalSources = jest.fn()); + +// mock the request wrapper +const requestWrapperLocation = '../../lib/requestwrapper'; +jest.mock(requestWrapperLocation); +const { RequestWrapper } = require(requestWrapperLocation); +const sendRequestMock = jest.fn(); + +RequestWrapper.mockImplementation(() => { + return { + sendRequest: sendRequestMock, + }; +}); + +// mock the authenticator +const noauthLocation = '../../auth/authenticators/no-auth-authenticator'; +jest.mock(noauthLocation); +const { NoauthAuthenticator } = require(noauthLocation); +const authenticateMock = jest.fn(); + +NoauthAuthenticator.mockImplementation(() => { + return { + authenticate: authenticateMock, + }; +}); + +// mocks need to happen before this is imported +const { BaseService } = require('../../lib/base_service'); + +// constants +const NO_OP = () => {}; +const DEFAULT_URL = 'https://gateway.watsonplatform.net/test/api'; +const DEFAULT_NAME = 'test'; +const AUTHENTICATOR = new NoauthAuthenticator(); +const EMPTY_OBJECT = {}; // careful that nothing is ever added to this object + +setupFakeService(); // set up the TestService "class" + +describe('Base Service', () => { + // setup + beforeEach(() => { + // set defualt mocks, these may be overridden in the individual tests + readExternalSourcesMock.mockImplementation(() => EMPTY_OBJECT); + authenticateMock.mockImplementation((_, callback) => callback(null)); + }); + + afterEach(() => { + // clear and the metadata attached to the mocks + sendRequestMock.mockClear(); + RequestWrapper.mockClear(); + // also, reset the implementation of the readExternalSourcesMock + readExternalSourcesMock.mockReset(); + authenticateMock.mockReset(); + }); + + // tests + it('should throw an error if the instance is not instantiated with new', () => { + expect(() => { + // prettier-ignore + // eslint-disable-next-line new-cap + BaseService({ + authenticator: AUTHENTICATOR, + }); + }).toThrow(); + }); + + it('should strip trailing slash of url during instantiation', () => { + const testService = new TestService({ + authenticator: AUTHENTICATOR, + version: 'v1', + url: 'https://example.ibm.com/', + }); + + expect(testService.baseOptions.url).toBe('https://example.ibm.com'); + }); + + it('should throw an error if an authenticator is not passed in', () => { + expect(() => new TestService()).toThrow(); + }); + + it('should return the stored authenticator with getAuthenticator', () => { + const testService = new TestService({ + authenticator: AUTHENTICATOR, + }); + + expect(testService.getAuthenticator()).toEqual(AUTHENTICATOR); + }); + + it('should store disableSslVerification when set', () => { + const testService = new TestService({ + authenticator: AUTHENTICATOR, + disableSslVerification: true, + }); + + expect(testService.baseOptions.disableSslVerification).toBe(true); + }); + + it('should default disableSslVerification to false', () => { + const testService = new TestService({ + authenticator: AUTHENTICATOR, + disableSslVerification: 'true', // "truthy" values should not set this property + }); + + expect(testService.baseOptions.disableSslVerification).toBe(false); + }); + + it('should pass user given options to the request wrapper', () => { + new TestService({ + authenticator: AUTHENTICATOR, + proxy: false, + }); + + expect(RequestWrapper.mock.calls[0][0]).toEqual({ + authenticator: AUTHENTICATOR, + disableSslVerification: false, + proxy: false, + url: DEFAULT_URL, + qs: EMPTY_OBJECT, + }); + }); + + it('should create the qs object', () => { + const testService = new TestService({ + authenticator: AUTHENTICATOR, + }); + + expect(testService.baseOptions.qs).toBeDefined(); + expect(testService.baseOptions.qs).toEqual(EMPTY_OBJECT); + }); + + it('should use the default service url', () => { + const testService = new TestService({ + authenticator: AUTHENTICATOR, + }); + + expect(testService.baseOptions.url).toBe(DEFAULT_URL); + }); + + it('should read url and disableSslVerification from env', () => { + const url = 'abc123.com'; + const disableSsl = true; + + readExternalSourcesMock.mockImplementation(() => ({ + url, + disableSsl, + })); + + const testService = new TestService({ + authenticator: AUTHENTICATOR, + }); + + const fromCredsFile = testService.readOptionsFromExternalConfig(); + + expect(fromCredsFile.url).toBe(url); + expect(fromCredsFile.disableSslVerification).toBe(disableSsl); + expect(readExternalSourcesMock).toHaveBeenCalled(); + expect(readExternalSourcesMock.mock.calls[0][0]).toBe(DEFAULT_NAME); + }); + + it('should build the base options from different sources', () => { + readExternalSourcesMock.mockImplementation(() => ({ + disableSsl: true, + })); + + const testService = new TestService({ + authenticator: AUTHENTICATOR, + url: 'withtrailingslash.com/api/', + proxy: false, + }); + + expect(testService.baseOptions).toEqual({ + url: 'withtrailingslash.com/api', + disableSslVerification: true, + proxy: false, + qs: EMPTY_OBJECT, + authenticator: AUTHENTICATOR, + }); + expect(readExternalSourcesMock).toHaveBeenCalled(); + expect(readExternalSourcesMock.mock.calls[0][0]).toBe(DEFAULT_NAME); + }); + + it('should send the default options to the authenticate method', () => { + const testService = new TestService({ + authenticator: AUTHENTICATOR, + }); + + const parameters = { + defaultOptions: { + url: DEFAULT_URL, + Accept: 'application/json', + }, + options: { + url: '/v2/assistants/{assistant_id}/sessions', + method: 'POST', + path: { + id: '123', + }, + }, + }; + + testService.createRequest(parameters, NO_OP); + const args = authenticateMock.mock.calls[0]; + + expect(authenticateMock).toHaveBeenCalled(); + expect(args[0]).toEqual(parameters.defaultOptions); + expect(args[1]).toBeInstanceOf(Function); + }); + + it('should call sendRequest on authenticate() success', () => { + const testService = new TestService({ + authenticator: AUTHENTICATOR, + }); + + const parameters = { + defaultOptions: { + url: DEFAULT_URL, + Accept: 'application/json', + }, + options: { + url: '/v2/assistants/{assistant_id}/sessions', + method: 'POST', + path: { + id: '123', + }, + }, + }; + + testService.createRequest(parameters, NO_OP); + + expect(authenticateMock).toHaveBeenCalled(); + expect(sendRequestMock).toHaveBeenCalled(); + + const args = sendRequestMock.mock.calls[0]; + expect(args[0]).toEqual(parameters); + expect(args[1]).toBe(NO_OP); + expect(testService.requestWrapperInstance.sendRequest).toBe(sendRequestMock); // verify it is calling the instance + }); + + it('should send error back to user on authenticate() failure', done => { + const testService = new TestService({ + authenticator: AUTHENTICATOR, + }); + + // note that the NoauthAuthenticator can't actually call the callback with an error, + // but others can + const fakeError = new Error('token request failed'); + authenticateMock.mockImplementation((_, callback) => callback(fakeError)); + + testService.createRequest(EMPTY_OBJECT, err => { + expect(err).toBe(fakeError); + expect(authenticateMock).toHaveBeenCalled(); + done(); + }); + }); + + it('readOptionsFromExternalConfig should return an empty object if no properties are found', () => { + readExternalSourcesMock.mockImplementation(() => null); + const testService = new TestService({ + authenticator: AUTHENTICATOR, + }); + + expect(testService.readOptionsFromExternalConfig()).toEqual(EMPTY_OBJECT); + }); + + it('should check url for common problems', () => { + expect(() => { + new TestService({ + authenticator: AUTHENTICATOR, + url: 'myapi.com/{instanceId}', + }); + }).toThrow(/Revise these credentials/); + }); +}); + +function TestService(options) { + BaseService.call(this, options); +} + +function setupFakeService() { + util.inherits(TestService, BaseService); + TestService.prototype.name = DEFAULT_NAME; + TestService.prototype.version = 'v1'; + TestService.URL = DEFAULT_URL; +} diff --git a/test/unit/baseService.test.js b/test/unit/baseService.test.js deleted file mode 100644 index 61af22fd1..000000000 --- a/test/unit/baseService.test.js +++ /dev/null @@ -1,639 +0,0 @@ -'use strict'; - -jest.mock('../../lib/requestwrapper'); -const { RequestWrapper } = require('../../lib/requestwrapper'); -const BaseService = require('../../lib/base_service').BaseService; -const mockSendRequest = jest.fn(); - -RequestWrapper.mockImplementation(() => { - return { - sendRequest: mockSendRequest, - }; -}); -const util = require('util'); - -function TestService(options) { - BaseService.call(this, options); -} -util.inherits(TestService, BaseService); -TestService.prototype.name = 'test'; -TestService.prototype.version = 'v1'; -TestService.URL = 'https://gateway.watsonplatform.net/test/api'; - -const responseMessage = 'response'; - -describe('BaseService', function() { - let env; - beforeEach(function() { - env = process.env; - process.env = {}; - RequestWrapper.mockClear(); - }); - afterEach(function() { - process.env = env; - }); - - it('should fail to instantiate if the instance is not instantiated with new', () => { - expect(() => { - // prettier-ignore - // eslint-disable-next-line new-cap - BaseService({ - use_unauthenticated: true, - version: 'v1', - }); - }).toThrow(); - }); - - it('should strip trailing slash of url during instantiation', () => { - const testService = new TestService({ - use_unauthenticated: true, - version: 'v1', - url: 'https://example.ibm.com/', - }); - expect(testService.getServiceCredentials().url).toBe('https://example.ibm.com'); - }); - - it('should not fail without credentials if use_unauthenticated is true', function() { - expect(function() { - new TestService({ - use_unauthenticated: true, - version: 'v1', - }); - }).not.toThrow(); - }); - - it('should fail without credentials if use_unauthenticated is false', function() { - expect(function() { - new TestService({ - use_unauthenticated: false, - version: 'v1', - }); - }).toThrow(/Insufficient credentials/); - }); - - it('should check for missing authentication', function() { - expect(function() { - new TestService({ - version: 'v1', - username: 'user', - }); - }).toThrow(/password/); - - expect(function() { - new TestService({ - version: 'v1', - password: 'pass', - }); - }).toThrow(/username/); - - expect(function() { - new TestService({ - password: 'pass', - username: 'user', - version: 'v1', - }); - }).not.toThrow(); - }); - - it('should support token auth', function() { - const instance = new BaseService({ token: 'foo' }); - expect(instance._options.headers['X-Watson-Authorization-Token']).toBe('foo'); - }); - - it('should return all credentials with getServiceCredentials', function() { - const instance = new TestService({ - username: 'test', - password: 'test', - url: 'test', - iam_access_token: 'test', - iam_apikey: 'test', - iam_url: 'test', - iam_client_id: 'test', - iam_client_secret: 'test', - icp4d_access_token: 'test', - icp4d_url: 'test', - authentication_type: 'test', - }); - const creds = instance.getServiceCredentials(); - - expect(creds.username).toBeDefined(); - expect(creds.password).toBeDefined(); - expect(creds.url).toBeDefined(); - expect(creds.iam_access_token).toBeDefined(); - expect(creds.iam_apikey).toBeDefined(); - expect(creds.iam_url).toBeDefined(); - expect(creds.iam_client_id).toBeDefined(); - expect(creds.iam_client_secret).toBeDefined(); - expect(creds.icp4d_access_token).toBeDefined(); - expect(creds.icp4d_url).toBeDefined(); - expect(creds.authentication_type).toBeDefined(); - }); - - it('should return hard-coded credentials', function() { - const instance = new TestService({ username: 'user', password: 'pass' }); - const actual = instance.getServiceCredentials(); - const expected = { - username: 'user', - password: 'pass', - url: 'https://gateway.watsonplatform.net/test/api', - }; - expect(actual).toEqual(expected); - }); - - it('should return all credentials and url from the environment', function() { - process.env.TEST_USERNAME = 'test'; - process.env.TEST_PASSWORD = 'test'; - process.env.TEST_URL = 'http://foo'; - process.env.TEST_API_KEY = 'test'; - process.env.TEST_IAM_ACCESS_TOKEN = 'test'; - process.env.TEST_IAM_APIKEY = 'test'; - process.env.TEST_IAM_URL = 'test'; - process.env.TEST_IAM_CLIENT_ID = 'test'; - process.env.TEST_IAM_CLIENT_SECRET = 'test'; - process.env.TEST_ICP4D_ACCESS_TOKEN = 'test'; - process.env.TEST_ICP4D_URL = 'test'; - process.env.TEST_AUTHENTICATION_TYPE = 'test'; - - const instance = new TestService(); - const actual = instance.getServiceCredentials(); - const expected = { - username: 'test', - password: 'test', - url: 'http://foo', - iam_access_token: 'test', - iam_apikey: 'test', - iam_url: 'test', - iam_client_id: 'test', - iam_client_secret: 'test', - icp4d_access_token: 'test', - icp4d_url: 'test', - authentication_type: 'test', - }; - expect(actual).toEqual(expected); - }); - - it('should allow mixing credentials from the environment and the default url', function() { - process.env.TEST_USERNAME = 'env_user'; - process.env.TEST_PASSWORD = 'env_pass'; - const instance = new TestService(); - const actual = instance.getServiceCredentials(); - const expected = { - username: 'env_user', - password: 'env_pass', - url: 'https://gateway.watsonplatform.net/test/api', - }; - expect(actual).toEqual(expected); - }); - - it('should return credentials from VCAP_SERVICES', function() { - process.env.VCAP_SERVICES = JSON.stringify({ - test: [ - { - credentials: { - password: 'vcap_pass', - url: 'https://gateway.watsonplatform.net/test/api', - username: 'vcap_user', - }, - }, - ], - }); - const instance = new TestService(); - const actual = instance.getServiceCredentials(); - const expected = { - username: 'vcap_user', - password: 'vcap_pass', - url: 'https://gateway.watsonplatform.net/test/api', - }; - expect(actual).toEqual(expected); - }); - - it('should handle iam apikey credential from VCAP_SERVICES', function() { - process.env.VCAP_SERVICES = JSON.stringify({ - test: [ - { - credentials: { - apikey: '123456789', - iam_apikey_description: 'Auto generated apikey...', - iam_apikey_name: 'auto-generated-apikey-111-222-333', - iam_role_crn: 'crn:v1:bluemix:public:iam::::serviceRole:Manager', - iam_serviceid_crn: 'crn:v1:staging:public:iam-identity::a/::serviceid:ServiceID-1234', - url: 'https://gateway.watsonplatform.net/test/api', - }, - }, - ], - }); - const instance = new TestService(); - const actual = instance.getServiceCredentials(); - const expected = { - iam_apikey: '123456789', - url: 'https://gateway.watsonplatform.net/test/api', - }; - expect(actual).toEqual(expected); - expect(instance.tokenManager).toBeDefined(); - expect(instance.tokenManager).not.toBeNull(); - }); - - it('should prefer hard-coded credentials over ibm credentials file', function() { - process.env.IBM_CREDENTIALS_FILE = __dirname + '/../resources/ibm-credentials.env'; - const instance = new TestService({ username: 'user', password: 'pass' }); - const actual = instance.getServiceCredentials(); - const expected = { - username: 'user', - password: 'pass', - url: 'https://gateway.watsonplatform.net/test/api', - }; - expect(actual).toEqual(expected); - }); - - it('should prefer ibm credentials file over environment properties', function() { - process.env.IBM_CREDENTIALS_FILE = __dirname + '/../resources/ibm-credentials.env'; - process.env.TEST_USERNAME = 'env_user'; - process.env.TEST_PASSWORD = 'env_pass'; - const instance = new TestService(); - const actual = instance.getServiceCredentials(); - const expected = { - username: '123456789', - password: 'abcd', - url: 'https://gateway.watsonplatform.net/test/api', - }; - expect(actual).toEqual(expected); - }); - - it('should prefer environment properties over vcap_services', function() { - process.env.VCAP_SERVICES = JSON.stringify({ - test: [ - { - credentials: { - password: 'vcap_pass', - url: 'https://gateway.watsonplatform.net/test/api', - username: 'vcap_user', - }, - }, - ], - }); - process.env.TEST_USERNAME = 'env_user'; - process.env.TEST_PASSWORD = 'env_pass'; - const instance = new TestService(); - const actual = instance.getServiceCredentials(); - const expected = { - username: 'env_user', - password: 'env_pass', - url: 'https://gateway.watsonplatform.net/test/api', - }; - expect(actual).toEqual(expected); - }); - - it('should set authorization header after getting a token from the token manager', function(done) { - const instance = new TestService({ iam_apikey: 'abcd-1234' }); - const accessToken = '567890'; - const parameters = { - defaultOptions: { - headers: {}, - }, - }; - - const getTokenMock = jest.spyOn(instance.tokenManager, 'getToken'); - getTokenMock.mockImplementation(cb => { - cb(null, accessToken); - }); - - mockSendRequest.mockImplementation((param, callback) => { - callback(null, responseMessage); - }); - - instance.createRequest(parameters, function(err, res) { - const authHeader = mockSendRequest.mock.calls[0][0].defaultOptions.headers.Authorization; - expect(`Bearer ${accessToken}`).toBe(authHeader); - expect(res).toBe(responseMessage); - - getTokenMock.mockReset(); - mockSendRequest.mockClear(); - done(); - }); - }); - - it('should send an error back to the user if the token request went bad', function(done) { - const instance = new TestService({ iam_apikey: 'abcd-1234' }); - const errorMessage = 'Error in the token request.'; - - const getTokenMock = jest.spyOn(instance.tokenManager, 'getToken'); - getTokenMock.mockImplementation(cb => { - cb(errorMessage); - }); - - instance.createRequest({}, function(err, res) { - expect(err).toBe(errorMessage); - expect(mockSendRequest).not.toHaveBeenCalled(); - getTokenMock.mockReset(); - done(); - }); - }); - - it('should call sendRequest right away if token manager is null', function(done) { - const instance = new TestService({ username: 'user', password: 'pass' }); - instance.createRequest({}, function(err, res) { - expect(res).toBe(responseMessage); - expect(instance.tokenManager).toBeNull(); - done(); - }); - }); - - it('should pass all credentials to token manager when given iam creds', function() { - const instance = new TestService({ - iam_apikey: 'key1234', - iam_access_token: 'real-token-84', - iam_url: 'iam.com/api', - iam_client_id: 'abc', - iam_client_secret: 'abc', - }); - - expect(instance.tokenManager).toBeDefined(); - expect(instance.tokenManager).not.toBeNull(); - expect(instance.tokenManager.iamApikey).toBeDefined(); - expect(instance.tokenManager.userAccessToken).toBeDefined(); - expect(instance.tokenManager.url).toBeDefined(); - expect(instance.tokenManager.iamClientId).toBeDefined(); - expect(instance.tokenManager.iamClientSecret).toBeDefined(); - }); - - it('should pass all credentials to token manager when given iam with basic', function() { - const instance = new TestService({ - username: 'apikey', - password: 'key1234', - iam_url: 'iam.com/api', - iam_client_id: 'abc', - iam_client_secret: 'abc', - }); - - expect(instance.tokenManager).toBeDefined(); - expect(instance.tokenManager).not.toBeNull(); - expect(instance.tokenManager.iamApikey).toBeDefined(); - expect(instance.tokenManager.url).toBeDefined(); - expect(instance.tokenManager.iamClientId).toBeDefined(); - expect(instance.tokenManager.iamClientSecret).toBeDefined(); - }); - - it('should not fail if setAccessToken is called and token manager is null', function() { - const instance = new TestService({ username: 'user', password: 'pass' }); - expect(instance.tokenManager).toBeNull(); - - instance.setAccessToken('abcd-1234'); - expect(instance.tokenManager).toBeDefined(); - expect(instance.tokenManager).not.toBeNull(); - }); - - it('should create an icp4d token manager if setAccessToken is called and auth type is `icp4d`', function() { - const instance = new TestService({ - username: 'user', - password: 'pass', - url: 'service.com', - }); - expect(instance.tokenManager).toBeNull(); - - // this is sort of a bizarre use case... - instance._options.authentication_type = 'icp4d'; - - instance.setAccessToken('abcd-1234'); - expect(instance.tokenManager).toBeDefined(); - expect(instance.tokenManager).not.toBeNull(); - }); - - it('should create a token manager instance if env variables specify iam credentials', function() { - process.env.TEST_IAM_APIKEY = 'test1234'; - const instance = new TestService(); - const actual = instance.getServiceCredentials(); - const expected = { - iam_apikey: 'test1234', - url: 'https://gateway.watsonplatform.net/test/api', - }; - expect(actual).toEqual(expected); - expect(instance.tokenManager).toBeDefined(); - expect(instance.tokenManager).not.toBeNull(); - }); - - it('should create a token manager instance if username is `apikey` and use the password as the API key', function() { - const apikey = 'abcd-1234'; - const instance = new TestService({ - username: 'apikey', - password: apikey, - }); - expect(instance.tokenManager).toBeDefined(); - expect(instance.tokenManager).not.toBeNull(); - expect(instance.tokenManager.iamApikey).toBe(apikey); - expect(instance._options.headers).toBeUndefined(); - }); - - it('should create an iam token manager instance if authentication_type is `iam`', function() { - const apikey = 'abcd-1234'; - const instance = new TestService({ - authentication_type: 'iam', - iam_apikey: apikey, - }); - expect(instance.tokenManager).toBeDefined(); - expect(instance.tokenManager).not.toBeNull(); - expect(instance.tokenManager.iamApikey).toBe(apikey); - expect(instance._options.headers).toBeUndefined(); - }); - - it('should create an icp4d token manager instance if authentication_type is `icp4d`', function() { - const instance = new TestService({ - authentication_type: 'ICP4D', // using all caps to prove case insensitivity - username: 'test', - password: 'test', - url: 'service.com/api', - icp4d_url: 'host.com:1234', - }); - expect(instance.tokenManager).toBeDefined(); - expect(instance.tokenManager).not.toBeNull(); - expect(instance._options.headers).toBeUndefined(); - }); - - it('should create an icp4d token manager instance if given icp4d_access_token', function() { - const instance = new TestService({ - icp4d_access_token: 'test', - url: 'service.com', - }); - expect(instance.tokenManager).toBeDefined(); - expect(instance.tokenManager).not.toBeNull(); - expect(instance._options.headers).toBeUndefined(); - }); - - it('should throw an error if an icp4d url is not given when using sdk-managed tokens', function() { - expect(() => { - new TestService({ - username: 'test', - password: 'test', - authentication_type: 'icp4d', - url: 'service.com', - }); - }).toThrow(); - }); - - it('should not throw an error if an icp4d url is missing when using user-managed tokens', function() { - const instance = new TestService({ - icp4d_access_token: 'test', - url: 'service.com', - }); - expect(instance.tokenManager).toBeDefined(); - expect(instance.tokenManager).not.toBeNull(); - expect(instance._options.headers).toBeUndefined(); - }); - - it('should not create a basic auth header if iam creds are given', function() { - const apikey = 'abcd-1234'; - const instance = new TestService({ - iam_apikey: apikey, - username: 'notarealuser', - password: 'badpassword1', - }); - expect(instance.tokenManager).toBeDefined(); - expect(instance.tokenManager).not.toBeNull(); - expect(instance.tokenManager.iamApikey).toBe(apikey); - expect(instance._options.headers).toBeUndefined(); - }); - - it('should create a basic auth header if username is `apikey` and password starts with `icp-`', function() { - const instance = new TestService({ - username: 'apikey', - password: 'icp-1234', - }); - const authHeader = instance._options.headers.Authorization; - expect(instance.tokenManager).toBeNull(); - expect(authHeader.startsWith('Basic')).toBe(true); - }); - - it('should set rejectUnauthorized to `false` if `disable_ssl_verification` is `true`', function() { - const instance = new TestService({ - username: 'apikey', - password: 'icp-1234', - disable_ssl_verification: true, - }); - expect(instance._options.rejectUnauthorized).toBe(false); - }); - - it('should set rejectUnauthorized to `true` if `disable_ssl_verification` is `false`', function() { - const instance = new TestService({ - username: 'apikey', - password: 'icp-1234', - disable_ssl_verification: false, - }); - expect(instance._options.rejectUnauthorized).toBe(true); - }); - - it('should set rejectUnauthorized to `true` if `disable_ssl_verification` is not set', function() { - const instance = new TestService({ - username: 'apikey', - password: 'icp-1234', - }); - expect(instance._options.rejectUnauthorized).toBe(true); - }); - - it('should convert an iam_apikey that starts with "icp-" to basic auth', () => { - const icpKey = 'icp-1234'; - const instance = new TestService({ - iam_apikey: icpKey, - username: 'thiswillbegone', - password: 'thiswillbegone', - }); - - const authHeader = instance._options.headers.Authorization; - - expect(instance._options.username).toBe('apikey'); - expect(instance._options.password).toBe(icpKey); - expect(instance._options.iam_apikey).not.toBeDefined(); - expect(instance.tokenManager).toBeNull(); - expect(authHeader.startsWith('Basic')).toBe(true); - }); - - it('should convert authentication_type to lower case', function() { - const instance = new TestService({ - authentication_type: 'ICP4D', - icp4d_access_token: 'abc123', - url: 'someservice.com', - }); - - expect(instance._options.authentication_type).toBe('icp4d'); - }); - - describe('check credentials for common problems', function() { - function assertConstructorThrows(params) { - expect(() => { - new TestService(params); - }).toThrowError( - 'Revise these credentials - they should not start or end with curly brackets or quotes.' - ); - } - - it('should throw when username starts with {', function() { - assertConstructorThrows({ - username: '{batman}', - password: 'goodpass', - }); - }); - - it('should throw when username starts with "', function() { - assertConstructorThrows({ - username: '"', - password: 'goodpass', - }); - }); - - it('should throw when password starts with {', function() { - assertConstructorThrows({ - username: 'batman', - password: '{badpass}', - }); - }); - - it('should throw when password starts with "', function() { - assertConstructorThrows({ - username: 'batman', - password: '"badpass"', - }); - }); - - it('should throw when iam_apikey starts with {', function() { - assertConstructorThrows({ - iam_apikey: '{abc123}', - }); - }); - - it('should throw when iam_apikey starts with "', function() { - assertConstructorThrows({ - iam_apikey: '""', - url: '{watson-url}/some-api/v1/endpoint', - }); - }); - }); -}); diff --git a/test/unit/basic-authenticator.test.js b/test/unit/basic-authenticator.test.js new file mode 100644 index 000000000..936fcf7bc --- /dev/null +++ b/test/unit/basic-authenticator.test.js @@ -0,0 +1,55 @@ +'use strict'; + +const { BasicAuthenticator } = require('../../auth'); + +const USERNAME = 'dave'; +const PASSWORD = 'grohl'; +const CONFIG = { + username: USERNAME, + password: PASSWORD, +}; + +describe('Basic Authenticator', () => { + it('should store the username and password on the class', () => { + const authenticator = new BasicAuthenticator(CONFIG); + + expect(authenticator.username).toBe(USERNAME); + expect(authenticator.password).toBe(PASSWORD); + }); + + it('should throw an error when username is not provided', () => { + expect(() => { + new BasicAuthenticator({ password: PASSWORD }); + }).toThrow(); + }); + + it('should throw an error when password is not provided', () => { + expect(() => { + new BasicAuthenticator({ username: USERNAME }); + }).toThrow(); + }); + + it('should throw an error when username has a bad character', () => { + expect(() => { + new BasicAuthenticator({ username: '""', password: PASSWORD }); + }).toThrow(/Revise these credentials/); + }); + + it('should throw an error when password has a bad character', () => { + expect(() => { + new BasicAuthenticator({ username: USERNAME, password: '{some-password}' }); + }).toThrow(/Revise these credentials/); + }); + + it('should update the options and send `null` in the callback', done => { + const authenticator = new BasicAuthenticator(CONFIG); + + const options = {}; + + authenticator.authenticate(options, err => { + expect(err).toBeNull(); + expect(options.headers.Authorization).toBe('Basic ZGF2ZTpncm9obA=='); + done(); + }); + }); +}); diff --git a/test/unit/bearer-token-authenticator.test.js b/test/unit/bearer-token-authenticator.test.js new file mode 100644 index 000000000..708789b87 --- /dev/null +++ b/test/unit/bearer-token-authenticator.test.js @@ -0,0 +1,42 @@ +'use strict'; + +const { BearerTokenAuthenticator } = require('../../auth'); + +describe('Bearer Token Authenticator', () => { + const config = { + bearerToken: 'thisisthetoken', + }; + + it('should store the bearer token on the class', () => { + const authenticator = new BearerTokenAuthenticator(config); + + expect(authenticator.bearerToken).toBe(config.bearerToken); + }); + + it('should throw an error when bearer token is not provided', () => { + expect(() => { + new BearerTokenAuthenticator(); + }).toThrow(); + }); + + it('should update the options and send `null` in the callback', done => { + const authenticator = new BearerTokenAuthenticator(config); + + const options = {}; + + authenticator.authenticate(options, err => { + expect(err).toBeNull(); + expect(options.headers.Authorization).toBe(`Bearer ${config.bearerToken}`); + done(); + }); + }); + + it('should re-set the bearer token using the setter', () => { + const authenticator = new BearerTokenAuthenticator(config); + expect(authenticator.bearerToken).toBe(config.bearerToken); + + const newToken = 'updatedtoken'; + authenticator.setBearerToken(newToken); + expect(authenticator.bearerToken).toBe(newToken); + }); +}); diff --git a/test/unit/cp4d-authenticator.test.js b/test/unit/cp4d-authenticator.test.js new file mode 100644 index 000000000..4afbd2dae --- /dev/null +++ b/test/unit/cp4d-authenticator.test.js @@ -0,0 +1,124 @@ +'use strict'; + +const { CloudPakForDataAuthenticator } = require('../../auth'); +const { Cp4dTokenManager } = require('../../auth'); + +const USERNAME = 'danmullen'; +const PASSWORD = 'gogators'; +const URL = 'myicp.com:1234'; +const CONFIG = { + username: USERNAME, + password: PASSWORD, + url: URL, + disableSslVerification: true, + headers: { + 'X-My-Header': 'some-value', + }, +}; + +// mock the `getToken` method in the token manager - dont make any rest calls +const fakeToken = 'iam-acess-token'; +const mockedTokenManager = new Cp4dTokenManager({ + username: USERNAME, + password: PASSWORD, + url: URL, +}); +const getTokenSpy = jest.spyOn(mockedTokenManager, 'getToken').mockImplementation(callback => { + callback(null, fakeToken); +}); + +describe('CP4D Authenticator', () => { + it('should store all CONFIG options on the class', () => { + const authenticator = new CloudPakForDataAuthenticator(CONFIG); + + expect(authenticator.username).toBe(CONFIG.username); + expect(authenticator.password).toBe(CONFIG.password); + expect(authenticator.url).toBe(CONFIG.url); + expect(authenticator.disableSslVerification).toBe(CONFIG.disableSslVerification); + expect(authenticator.headers).toEqual(CONFIG.headers); + + // should also create a token manager + expect(authenticator.tokenManager).toBeInstanceOf(Cp4dTokenManager); + }); + + it('should throw an error when username is not provided', () => { + expect(() => { + new CloudPakForDataAuthenticator({ password: PASSWORD }); + }).toThrow(); + }); + + it('should throw an error when password is not provided', () => { + expect(() => { + new CloudPakForDataAuthenticator({ username: USERNAME }); + }).toThrow(); + }); + + it('should throw an error when url is not provided', () => { + expect(() => { + new CloudPakForDataAuthenticator({ password: PASSWORD, username: USERNAME }); + }).toThrow(); + }); + + it('should throw an error when username has a bad character', () => { + expect(() => { + new CloudPakForDataAuthenticator({ + username: '""', + password: PASSWORD, + url: URL, + }); + }).toThrow(/Revise these credentials/); + }); + + it('should throw an error when password has a bad character', () => { + expect(() => { + new CloudPakForDataAuthenticator({ + username: USERNAME, + password: '{some-password}', + url: URL, + }); + }).toThrow(/Revise these credentials/); + }); + + it('should update the options and send `null` in the callback', done => { + const authenticator = new CloudPakForDataAuthenticator(CONFIG); + + // override the created token manager with the mocked one + authenticator.tokenManager = mockedTokenManager; + + const options = { headers: { 'X-Some-Header': 'user-supplied header' } }; + + authenticator.authenticate(options, err => { + expect(err).toBeNull(); + expect(options.headers.Authorization).toBe(`Bearer ${fakeToken}`); + expect(getTokenSpy).toHaveBeenCalled(); + + // verify that the original options are kept intact + expect(options.headers['X-Some-Header']).toBe('user-supplied header'); + done(); + }); + }); + + it('should re-set disableSslVerification using the setter', () => { + const authenticator = new CloudPakForDataAuthenticator(CONFIG); + expect(authenticator.disableSslVerification).toBe(CONFIG.disableSslVerification); + + const newValue = false; + authenticator.setDisableSslVerification(newValue); + expect(authenticator.disableSslVerification).toBe(newValue); + + // also, verify that the underlying token manager has been updated + expect(authenticator.tokenManager.disableSslVerification).toBe(newValue); + }); + + it('should re-set the headers using the setter', () => { + const authenticator = new CloudPakForDataAuthenticator(CONFIG); + expect(authenticator.headers).toEqual(CONFIG.headers); + + const newHeader = { 'X-New-Header': 'updated-header' }; + authenticator.setHeaders(newHeader); + expect(authenticator.headers).toEqual(newHeader); + + // also, verify that the underlying token manager has been updated + expect(authenticator.tokenManager.headers).toEqual(newHeader); + }); +}); diff --git a/test/unit/cp4d-token-manager.test.js b/test/unit/cp4d-token-manager.test.js new file mode 100644 index 000000000..8bd3fe2ef --- /dev/null +++ b/test/unit/cp4d-token-manager.test.js @@ -0,0 +1,116 @@ +/* eslint-disable no-alert, no-console */ +'use strict'; + +const { Cp4dTokenManager } = require('../../auth'); + +// mock sendRequest +jest.mock('../../lib/requestwrapper'); +const { RequestWrapper } = require('../../lib/requestwrapper'); +const mockSendRequest = jest.fn(); +RequestWrapper.mockImplementation(() => { + return { + sendRequest: mockSendRequest, + }; +}); + +const USERNAME = 'sherlock'; +const PASSWORD = 'holmes'; +const URL = 'tokenservice.com'; +const FULL_URL = 'tokenservice.com/v1/preauth/validateAuth'; + +describe('CP4D Token Manager', () => { + describe('constructor', () => { + it('should initialize base variables', () => { + const instance = new Cp4dTokenManager({ + url: 'tokenservice.com', + username: USERNAME, + password: PASSWORD, + }); + + expect(instance.tokenName).toBe('accessToken'); + expect(instance.url).toBe(FULL_URL); + expect(instance.username).toBe(USERNAME); + expect(instance.password).toBe(PASSWORD); + expect(instance.disableSslVerification).toBe(false); + }); + + it('should not append the token path if supplied by user', () => { + const url = FULL_URL; + const instance = new Cp4dTokenManager({ + url, + username: USERNAME, + password: PASSWORD, + }); + + expect(instance.url).toBe(url); + }); + + it('should set disableSslVerification', () => { + const instance = new Cp4dTokenManager({ + username: USERNAME, + password: PASSWORD, + url: URL, + disableSslVerification: true, + }); + + expect(instance.disableSslVerification).toBe(true); + }); + + it('should throw an error if `url` is not given', () => { + expect( + () => + new Cp4dTokenManager({ + username: USERNAME, + password: PASSWORD, + }) + ).toThrow(); + }); + + it('should throw an error if `username` is not given', () => { + expect( + () => + new Cp4dTokenManager({ + password: PASSWORD, + url: URL, + }) + ).toThrow(); + }); + + it('should throw an error if `password` is not given', () => { + expect( + () => + new Cp4dTokenManager({ + username: 'abc', + url: URL, + }) + ).toThrow(); + }); + }); + + describe('requestToken', () => { + it('should call sendRequest with all request options', () => { + const noop = () => {}; + const instance = new Cp4dTokenManager({ + url: URL, + username: USERNAME, + password: PASSWORD, + }); + + instance.requestToken(noop); + + // extract arguments sendRequest was called with + const params = mockSendRequest.mock.calls[0][0]; + const callback = mockSendRequest.mock.calls[0][1]; + + expect(mockSendRequest).toHaveBeenCalled(); + expect(params.options).toBeDefined(); + expect(params.options.url).toBe(FULL_URL); + expect(params.options.method).toBe('GET'); + expect(params.options.rejectUnauthorized).toBe(true); + expect(params.options.headers).toBeDefined(); + + expect(params.options.headers.Authorization).toBe('Basic c2hlcmxvY2s6aG9sbWVz'); + expect(callback).toBe(noop); + }); + }); +}); diff --git a/test/unit/get-authenticator-from-environment.test.js b/test/unit/get-authenticator-from-environment.test.js new file mode 100644 index 000000000..3c7584ed4 --- /dev/null +++ b/test/unit/get-authenticator-from-environment.test.js @@ -0,0 +1,133 @@ +'use strict'; + +const { + getAuthenticatorFromEnvironment, + BasicAuthenticator, + BearerTokenAuthenticator, + CloudPakForDataAuthenticator, + IamAuthenticator, + NoauthAuthenticator, +} = require('../../auth'); + +// create a mock for the read-external-sources module +const readExternalSourcesModule = require('../../auth/utils/read-external-sources'); +const readExternalSourcesMock = (readExternalSourcesModule.readExternalSources = jest.fn()); + +const SERVICE_NAME = 'dummy'; +const APIKEY = '123456789'; +const TOKEN_URL = 'get-token.com/api'; + +describe('Get Authenticator From Environment Module', () => { + afterEach(() => { + readExternalSourcesMock.mockReset(); + }); + + it('should throw an error when the service name is not provided', () => { + expect(() => getAuthenticatorFromEnvironment()).toThrow(); + }); + + it('should throw an error when read-external-sources payload is null', () => { + readExternalSourcesMock.mockImplementation(() => null); + expect(() => getAuthenticatorFromEnvironment(SERVICE_NAME)).toThrow(); + }); + + it('should get noauth authenticator', () => { + setUpNoauthPayload(); + const authenticator = getAuthenticatorFromEnvironment(SERVICE_NAME); + expect(authenticator).toBeInstanceOf(NoauthAuthenticator); + expect(readExternalSourcesMock).toHaveBeenCalled(); + }); + + it('should get basic authenticator', () => { + setUpBasicPayload(); + readExternalSourcesMock.mockImplementation(() => ({ + authType: 'basic', + username: 'a', + password: 'b', + })); + const authenticator = getAuthenticatorFromEnvironment(SERVICE_NAME); + expect(authenticator).toBeInstanceOf(BasicAuthenticator); + }); + + it('should get bearer token authenticator', () => { + setUpBearerTokenPayload(); + const authenticator = getAuthenticatorFromEnvironment(SERVICE_NAME); + expect(authenticator).toBeInstanceOf(BearerTokenAuthenticator); + }); + + it('should get iam authenticator', () => { + setUpIamPayload(); + const authenticator = getAuthenticatorFromEnvironment(SERVICE_NAME); + expect(authenticator).toBeInstanceOf(IamAuthenticator); + }); + + it('should get cp4d authenticator', () => { + setUpCp4dPayload(); + const authenticator = getAuthenticatorFromEnvironment(SERVICE_NAME); + expect(authenticator).toBeInstanceOf(CloudPakForDataAuthenticator); + }); + + it('should throw away service properties and use auth properties', () => { + setUpAuthPropsPayload(); + const authenticator = getAuthenticatorFromEnvironment(SERVICE_NAME); + expect(authenticator).toBeInstanceOf(IamAuthenticator); + expect(authenticator.apikey).toBe(APIKEY); + expect(authenticator.disableSslVerification).toBe(true); + expect(authenticator.url).toBe(TOKEN_URL); + }); + + it('should default to iam when auth type is not provided', () => { + readExternalSourcesMock.mockImplementation(() => ({ apikey: APIKEY })); + const authenticator = getAuthenticatorFromEnvironment(SERVICE_NAME); + expect(authenticator).toBeInstanceOf(IamAuthenticator); + expect(authenticator.apikey).toBe(APIKEY); + }); +}); + +// mock payloads for the read-external-sources module +function setUpNoauthPayload() { + readExternalSourcesMock.mockImplementation(() => ({ + authType: 'noauth', + })); +} + +function setUpBasicPayload() { + readExternalSourcesMock.mockImplementation(() => ({ + authType: 'basic', + username: 'a', + password: 'b', + })); +} + +function setUpBearerTokenPayload() { + readExternalSourcesMock.mockImplementation(() => ({ + authType: 'bearerToken', + bearerToken: 'a', + })); +} + +function setUpIamPayload() { + readExternalSourcesMock.mockImplementation(() => ({ + authType: 'iam', + apikey: APIKEY, + })); +} + +function setUpCp4dPayload() { + readExternalSourcesMock.mockImplementation(() => ({ + authType: 'cp4d', + username: 'a', + password: 'b', + authUrl: TOKEN_URL, + })); +} + +function setUpAuthPropsPayload() { + readExternalSourcesMock.mockImplementation(() => ({ + apikey: APIKEY, + authUrl: TOKEN_URL, + authDisableSsl: true, + url: 'thisshouldbethrownaway.com', + disableSsl: false, + })); +} diff --git a/test/unit/iam-authenticator.test.js b/test/unit/iam-authenticator.test.js new file mode 100644 index 000000000..fcc3e2814 --- /dev/null +++ b/test/unit/iam-authenticator.test.js @@ -0,0 +1,108 @@ +'use strict'; + +const { IamAuthenticator } = require('../../auth'); +const { IamTokenManager } = require('../../auth'); + +// mock the `getToken` method in the token manager - dont make any rest calls +const fakeToken = 'iam-acess-token'; +const mockedTokenManager = new IamTokenManager({ apikey: '123' }); +const getTokenSpy = jest.spyOn(mockedTokenManager, 'getToken').mockImplementation(callback => { + callback(null, fakeToken); +}); + +describe('IAM Authenticator', () => { + const config = { + apikey: 'myapikey123', + url: 'iam.staging.com', + clientId: 'my-id', + clientSecret: 'my-secret', + disableSslVerification: true, + headers: { + 'X-My-Header': 'some-value', + }, + }; + + it('should store all config options on the class', () => { + const authenticator = new IamAuthenticator(config); + + expect(authenticator.apikey).toBe(config.apikey); + expect(authenticator.url).toBe(config.url); + expect(authenticator.clientId).toBe(config.clientId); + expect(authenticator.clientSecret).toBe(config.clientSecret); + expect(authenticator.disableSslVerification).toBe(config.disableSslVerification); + expect(authenticator.headers).toEqual(config.headers); + + // should also create a token manager + expect(authenticator.tokenManager).toBeInstanceOf(IamTokenManager); + }); + + it('should throw an error when apikey is not provided', () => { + expect(() => { + new IamAuthenticator(); + }).toThrow(); + }); + + it('should throw an error when username has a bad character', () => { + expect(() => { + new IamAuthenticator({ apikey: '""' }); + }).toThrow(/Revise these credentials/); + }); + + it('should update the options and send `null` in the callback', done => { + const authenticator = new IamAuthenticator({ apikey: 'testjustanapikey' }); + + // override the created token manager with the mocked one + authenticator.tokenManager = mockedTokenManager; + + const options = { headers: { 'X-Some-Header': 'user-supplied header' } }; + + authenticator.authenticate(options, err => { + expect(err).toBeNull(); + expect(options.headers.Authorization).toBe(`Bearer ${fakeToken}`); + expect(getTokenSpy).toHaveBeenCalled(); + + // verify that the original options are kept intact + expect(options.headers['X-Some-Header']).toBe('user-supplied header'); + done(); + }); + }); + + it('should re-set the client id and secret using the setter', () => { + const authenticator = new IamAuthenticator(config); + expect(authenticator.clientId).toBe(config.clientId); + + const newClientId = 'updated-id'; + const newClientSecret = 'updated-secret'; + authenticator.setClientIdAndSecret(newClientId, newClientSecret); + expect(authenticator.clientId).toBe(newClientId); + expect(authenticator.clientSecret).toBe(newClientSecret); + + // also, verify that the underlying token manager has been updated + expect(authenticator.tokenManager.clientId).toBe(newClientId); + expect(authenticator.tokenManager.clientSecret).toBe(newClientSecret); + }); + + it('should re-set disableSslVerification using the setter', () => { + const authenticator = new IamAuthenticator(config); + expect(authenticator.disableSslVerification).toBe(config.disableSslVerification); + + const newValue = false; + authenticator.setDisableSslVerification(newValue); + expect(authenticator.disableSslVerification).toBe(newValue); + + // also, verify that the underlying token manager has been updated + expect(authenticator.tokenManager.disableSslVerification).toBe(newValue); + }); + + it('should re-set the headers using the setter', () => { + const authenticator = new IamAuthenticator(config); + expect(authenticator.headers).toEqual(config.headers); + + const newHeader = { 'X-New-Header': 'updated-header' }; + authenticator.setHeaders(newHeader); + expect(authenticator.headers).toEqual(newHeader); + + // also, verify that the underlying token manager has been updated + expect(authenticator.tokenManager.headers).toEqual(newHeader); + }); +}); diff --git a/test/unit/iamTokenManager.test.js b/test/unit/iam-token-manager.test.js similarity index 67% rename from test/unit/iamTokenManager.test.js rename to test/unit/iam-token-manager.test.js index bf34cf518..d3fe09218 100644 --- a/test/unit/iamTokenManager.test.js +++ b/test/unit/iam-token-manager.test.js @@ -9,7 +9,7 @@ jwt.decode = jest.fn(() => { return { exp: 100, iat: 100 }; }); -const { IamTokenManagerV1 } = require('../../iam-token-manager/v1'); // testing compatibility +const { IamTokenManager } = require('../../auth'); const mockSendRequest = jest.fn(); RequestWrapper.mockImplementation(() => { @@ -19,7 +19,7 @@ RequestWrapper.mockImplementation(() => { }); const CLIENT_ID_SECRET_WARNING = - 'Warning: Client ID and Secret must BOTH be given, or the defaults will be used.'; + 'Warning: Client ID and Secret must BOTH be given, or the header will not be included.'; describe('iam_token_manager_v1', function() { beforeEach(() => { @@ -30,20 +30,12 @@ describe('iam_token_manager_v1', function() { mockSendRequest.mockRestore(); }); - it('should return an access token given by the user', function(done) { - const userManagedToken = 'abcd-1234'; - const instance = new IamTokenManagerV1({ iamAccessToken: userManagedToken }); - const requestMock = jest.spyOn(instance, 'requestToken'); - - instance.getToken(function(err, token) { - expect(token).toBe(userManagedToken); - expect(requestMock).not.toHaveBeenCalled(); - done(); - }); + it('should throw an error if apikey is not provided', () => { + expect(() => new IamTokenManager()).toThrow(); }); it('should turn an iam apikey into an access token', function(done) { - const instance = new IamTokenManagerV1({ iamApikey: 'abcd-1234' }); + const instance = new IamTokenManager({ apikey: 'abcd-1234' }); const accessToken = '9012'; const iamResponse = { @@ -65,7 +57,7 @@ describe('iam_token_manager_v1', function() { }); it('should refresh an expired access token', function(done) { - const instance = new IamTokenManagerV1({ iamApikey: 'abcd-1234' }); + const instance = new IamTokenManager({ apikey: 'abcd-1234' }); const requestMock = jest.spyOn(instance, 'requestToken'); const currentTokenInfo = { @@ -99,7 +91,7 @@ describe('iam_token_manager_v1', function() { }); it('should use a valid access token if one is stored', function(done) { - const instance = new IamTokenManagerV1({ iamApikey: 'abcd-1234' }); + const instance = new IamTokenManager({ apikey: 'abcd-1234' }); const requestMock = jest.spyOn(instance, 'requestToken'); const accessToken = '1234'; @@ -122,31 +114,8 @@ describe('iam_token_manager_v1', function() { }); }); - it('should return a user-managed access token if one is set post-construction', function(done) { - const instance = new IamTokenManagerV1({ iamApikey: 'abcd-1234' }); - const requestMock = jest.spyOn(instance, 'requestToken'); - - const accessToken = '9012'; - const currentTokenInfo = { - access_token: '1234', - refresh_token: '5678', - token_type: 'Bearer', - expires_in: 3600, - expiration: Math.floor(Date.now() / 1000) + 3000, - }; - - instance.tokenInfo = currentTokenInfo; - instance.setAccessToken(accessToken); - - instance.getToken(function(err, token) { - expect(token).toBe(accessToken); - expect(requestMock).not.toHaveBeenCalled(); - done(); - }); - }); - it('should refresh an access token without expires_in field', function(done) { - const instance = new IamTokenManagerV1({ iamApikey: 'abcd-1234' }); + const instance = new IamTokenManager({ apikey: 'abcd-1234' }); const requestMock = jest.spyOn(instance, 'requestToken'); const currentTokenInfo = { @@ -179,7 +148,7 @@ describe('iam_token_manager_v1', function() { }); it('should request a new token when refresh token does not have expiration field', function(done) { - const instance = new IamTokenManagerV1({ iamApikey: 'abcd-1234' }); + const instance = new IamTokenManager({ apikey: 'abcd-1234' }); const currentTokenInfo = { access_token: '1234', @@ -208,8 +177,8 @@ describe('iam_token_manager_v1', function() { }); }); - it('should use the default Authorization header - no clientid, no secret', function(done) { - const instance = new IamTokenManagerV1({ iamApikey: 'abcd-1234' }); + it('should not specify an Authorization header if user provides no clientid, no secret', function(done) { + const instance = new IamTokenManager({ apikey: 'abcd-1234' }); mockSendRequest.mockImplementation((parameters, _callback) => { _callback(null, { access_token: 'abcd' }); @@ -218,16 +187,16 @@ describe('iam_token_manager_v1', function() { instance.getToken(function() { const sendRequestArgs = mockSendRequest.mock.calls[0][0]; const authHeader = sendRequestArgs.options.headers.Authorization; - expect(authHeader).toBe('Basic Yng6Yng='); + expect(authHeader).not.toBeDefined(); done(); }); }); - it('should use a non-default Authorization header - client id and secret via ctor', function(done) { - const instance = new IamTokenManagerV1({ - iamApikey: 'abcd-1234', - iamClientId: 'foo', - iamClientSecret: 'bar', + it('should use an Authorization header based on client id and secret via ctor', function(done) { + const instance = new IamTokenManager({ + apikey: 'abcd-1234', + clientId: 'foo', + clientSecret: 'bar', }); mockSendRequest.mockImplementation((parameters, _callback) => { @@ -237,17 +206,17 @@ describe('iam_token_manager_v1', function() { instance.getToken(function() { const sendRequestArgs = mockSendRequest.mock.calls[0][0]; const authHeader = sendRequestArgs.options.headers.Authorization; - expect(authHeader).not.toBe('Basic Yng6Yng='); + expect(authHeader).toBe('Basic Zm9vOmJhcg=='); done(); }); }); - it('should use the default Authorization header - clientid only via ctor', function(done) { + it('should not use an Authorization header - clientid only via ctor', function(done) { jest.spyOn(console, 'log').mockImplementation(() => {}); - const instance = new IamTokenManagerV1({ - iamApikey: 'abcd-1234', - iamClientId: 'foo', + const instance = new IamTokenManager({ + apikey: 'abcd-1234', + clientId: 'foo', }); // verify warning was triggered @@ -262,16 +231,16 @@ describe('iam_token_manager_v1', function() { instance.getToken(function() { const sendRequestArgs = mockSendRequest.mock.calls[0][0]; const authHeader = sendRequestArgs.options.headers.Authorization; - expect(authHeader).toBe('Basic Yng6Yng='); + expect(authHeader).not.toBeDefined(); done(); }); }); - it('should use the default Authorization header, secret only via ctor', function(done) { + it('should not use an Authorization header - secret only via ctor', function(done) { jest.spyOn(console, 'log').mockImplementation(() => {}); - const instance = new IamTokenManagerV1({ - iamApikey: 'abcd-1234', - iamClientSecret: 'bar', + const instance = new IamTokenManager({ + apikey: 'abcd-1234', + clientSecret: 'bar', }); // verify warning was triggered @@ -286,17 +255,17 @@ describe('iam_token_manager_v1', function() { instance.getToken(function() { const sendRequestArgs = mockSendRequest.mock.calls[0][0]; const authHeader = sendRequestArgs.options.headers.Authorization; - expect(authHeader).toBe('Basic Yng6Yng='); + expect(authHeader).not.toBeDefined(); done(); }); }); - it('should use a non-default Authorization header - client id and secret via setter', function(done) { - const instance = new IamTokenManagerV1({ - iamApikey: 'abcd-1234', + it('should use an Authorization header based on client id and secret via setter', function(done) { + const instance = new IamTokenManager({ + apikey: 'abcd-1234', }); - instance.setIamAuthorizationInfo('foo', 'bar'); + instance.setClientIdAndSecret('foo', 'bar'); mockSendRequest.mockImplementation((parameters, _callback) => { _callback(null, { access_token: 'abcd' }); @@ -305,19 +274,19 @@ describe('iam_token_manager_v1', function() { instance.getToken(function() { const sendRequestArgs = mockSendRequest.mock.calls[0][0]; const authHeader = sendRequestArgs.options.headers.Authorization; - expect(authHeader).not.toBe('Basic Yng6Yng='); + expect(authHeader).toBe('Basic Zm9vOmJhcg=='); done(); }); }); - it('should use the default Authorization header - clientid only via setter', function(done) { - const instance = new IamTokenManagerV1({ - iamApikey: 'abcd-1234', + it('should not use an Authorization header -- clientid only via setter', function(done) { + const instance = new IamTokenManager({ + apikey: 'abcd-1234', }); jest.spyOn(console, 'log').mockImplementation(() => {}); - instance.setIamAuthorizationInfo('foo', null); + instance.setClientIdAndSecret('foo', null); // verify warning was triggered expect(console.log).toHaveBeenCalled(); @@ -331,19 +300,19 @@ describe('iam_token_manager_v1', function() { instance.getToken(function() { const sendRequestArgs = mockSendRequest.mock.calls[0][0]; const authHeader = sendRequestArgs.options.headers.Authorization; - expect(authHeader).toBe('Basic Yng6Yng='); + expect(authHeader).not.toBeDefined(); done(); }); }); - it('should use the default Authorization header, secret only via setter', function(done) { - const instance = new IamTokenManagerV1({ - iamApikey: 'abcd-1234', + it('should not use an Authorization header - secret only via setter', function(done) { + const instance = new IamTokenManager({ + apikey: 'abcd-1234', }); jest.spyOn(console, 'log').mockImplementation(() => {}); - instance.setIamAuthorizationInfo(null, 'bar'); + instance.setClientIdAndSecret(null, 'bar'); // verify warning was triggered expect(console.log).toHaveBeenCalled(); @@ -357,17 +326,17 @@ describe('iam_token_manager_v1', function() { instance.getToken(function() { const sendRequestArgs = mockSendRequest.mock.calls[0][0]; const authHeader = sendRequestArgs.options.headers.Authorization; - expect(authHeader).toBe('Basic Yng6Yng='); + expect(authHeader).not.toBeDefined(); done(); }); }); - it('should use the default Authorization header, nulls passed to setter', function(done) { - const instance = new IamTokenManagerV1({ - iamApikey: 'abcd-1234', + it('should not use an Authorization header - nulls passed to setter', function(done) { + const instance = new IamTokenManager({ + apikey: 'abcd-1234', }); - instance.setIamAuthorizationInfo(null, null); + instance.setClientIdAndSecret(null, null); mockSendRequest.mockImplementation((parameters, _callback) => { _callback(null, { access_token: 'abcd' }); @@ -376,7 +345,7 @@ describe('iam_token_manager_v1', function() { instance.getToken(function() { const sendRequestArgs = mockSendRequest.mock.calls[0][0]; const authHeader = sendRequestArgs.options.headers.Authorization; - expect(authHeader).toBe('Basic Yng6Yng='); + expect(authHeader).not.toBeDefined(); done(); }); }); diff --git a/test/unit/icp4dTokenManager.v1.test.js b/test/unit/icp4dTokenManager.v1.test.js deleted file mode 100644 index b37fbddb8..000000000 --- a/test/unit/icp4dTokenManager.v1.test.js +++ /dev/null @@ -1,74 +0,0 @@ -/* eslint-disable no-alert, no-console */ -'use strict'; - -const { Icp4dTokenManagerV1 } = require('../../auth'); - -// mock sendRequest -jest.mock('../../lib/requestwrapper'); -const { RequestWrapper } = require('../../lib/requestwrapper'); -const mockSendRequest = jest.fn(); -RequestWrapper.mockImplementation(() => { - return { - sendRequest: mockSendRequest, - }; -}); - -describe('icp4d_token_manager_v1', () => { - describe('constructor', () => { - it('should initialize base variables', () => { - const instance = new Icp4dTokenManagerV1({ - url: 'tokenservice.com', - username: 'sherlock', - password: 'holmes', - accessToken: 'abc123', - }); - - expect(instance.tokenName).toBe('accessToken'); - expect(instance.url).toBe('tokenservice.com/v1/preauth/validateAuth'); - expect(instance.username).toBe('sherlock'); - expect(instance.password).toBe('holmes'); - expect(instance.rejectUnauthorized).toBe(true); - expect(instance.userAccessToken).toBe('abc123'); - }); - - it('should set rejectUnauthorized based on disableSslVerification', () => { - const instance = new Icp4dTokenManagerV1({ - url: 'tokenservice.com', - disableSslVerification: true, - }); - - expect(instance.rejectUnauthorized).toBe(false); - }); - - it('should throw an error if `url` is not given', () => { - expect(() => new Icp4dTokenManagerV1()).toThrow(); - }); - - it('should not throw an error if `url` is not given but using user-managed access token', () => { - expect(() => new Icp4dTokenManagerV1({ accessToken: 'token' })).not.toThrow(); - }); - }); - - describe('requestToken', () => { - it('should call sendRequest with all request options', () => { - const noop = () => {}; - const instance = new Icp4dTokenManagerV1({ url: 'tokenservice.com' }); - instance.requestToken(noop); - - // extract arguments sendRequest was called with - const params = mockSendRequest.mock.calls[0][0]; - const callback = mockSendRequest.mock.calls[0][1]; - - expect(mockSendRequest).toHaveBeenCalled(); - expect(params.options).toBeDefined(); - expect(params.options.url).toBe('tokenservice.com/v1/preauth/validateAuth'); - expect(params.options.method).toBe('GET'); - expect(params.options.rejectUnauthorized).toBe(true); - expect(params.options.headers).toBeDefined(); - - // encoding of undefined:undefined - expect(params.options.headers.Authorization).toBe('Basic dW5kZWZpbmVkOnVuZGVmaW5lZA=='); - expect(callback).toBe(noop); - }); - }); -}); diff --git a/test/unit/jwtTokenManager.v1.test.js b/test/unit/jwt-token-manager.test.js similarity index 80% rename from test/unit/jwtTokenManager.v1.test.js rename to test/unit/jwt-token-manager.test.js index 9c249b16a..7c7799ca4 100644 --- a/test/unit/jwtTokenManager.v1.test.js +++ b/test/unit/jwt-token-manager.test.js @@ -1,7 +1,7 @@ /* eslint-disable no-alert, no-console */ 'use strict'; -const { JwtTokenManagerV1 } = require('../../auth'); +const { JwtTokenManager } = require('../../auth'); const jwt = require('jsonwebtoken'); function getCurrentTime() { @@ -13,27 +13,22 @@ const ACCESS_TOKEN = 'abc123'; describe('iam_token_manager_v1', () => { it('should initialize base variables', () => { const url = 'service.com'; - const instance = new JwtTokenManagerV1({ url, accessToken: ACCESS_TOKEN }); + const instance = new JwtTokenManager({ url }); expect(instance.url).toBe(url); - expect(instance.userAccessToken).toBe(ACCESS_TOKEN); expect(instance.tokenName).toBe('access_token'); expect(instance.tokenInfo).toEqual({}); expect(instance.requestWrapperInstance).toBeDefined(); }); - describe('getToken', () => { - it('should return user-managed token if present', done => { - const instance = new JwtTokenManagerV1({ accessToken: ACCESS_TOKEN }); - instance.getToken((err, res) => { - expect(err).toBeNull(); - expect(res).toBe(ACCESS_TOKEN); - done(); - }); - }); + it('should pass all options to the request wrapper instance', () => { + const instance = new JwtTokenManager({ proxy: false }); + expect(instance.requestWrapperInstance.axiosInstance.defaults.proxy).toBe(false); + }); + describe('getToken', () => { it('should request a token if no token is stored', done => { - const instance = new JwtTokenManagerV1(); + const instance = new JwtTokenManager(); const saveTokenInfoSpy = jest.spyOn(instance, 'saveTokenInfo'); const decodeSpy = jest @@ -59,7 +54,7 @@ describe('iam_token_manager_v1', () => { }); it('should request a token if token is stored but expired', done => { - const instance = new JwtTokenManagerV1(); + const instance = new JwtTokenManager(); instance.tokenInfo.access_token = '987zxc'; const saveTokenInfoSpy = jest.spyOn(instance, 'saveTokenInfo'); @@ -86,7 +81,7 @@ describe('iam_token_manager_v1', () => { }); it('should not save token info if token request returned an error', done => { - const instance = new JwtTokenManagerV1(); + const instance = new JwtTokenManager(); const saveTokenInfoSpy = jest.spyOn(instance, 'saveTokenInfo'); const requestTokenSpy = jest @@ -106,7 +101,7 @@ describe('iam_token_manager_v1', () => { }); it('should catch lower level errors and send through callback', done => { - const instance = new JwtTokenManagerV1(); + const instance = new JwtTokenManager(); const saveTokenInfoSpy = jest.spyOn(instance, 'saveTokenInfo'); // because there is no access token, calling `saveTokenInfo` will @@ -126,7 +121,7 @@ describe('iam_token_manager_v1', () => { }); it('should use an sdk-managed token if present and not expired', done => { - const instance = new JwtTokenManagerV1(); + const instance = new JwtTokenManager(); instance.tokenInfo.access_token = ACCESS_TOKEN; instance.expireTime = getCurrentTime() + 1000; instance.getToken((err, res) => { @@ -137,18 +132,8 @@ describe('iam_token_manager_v1', () => { }); }); - it('should set userAccessToken with setAccessToken', () => { - const instance = new JwtTokenManagerV1(); - - expect(instance.url).toBe(undefined); - expect(instance.userAccessToken).toBe(undefined); - - instance.setAccessToken(ACCESS_TOKEN); - expect(instance.userAccessToken).toBe(ACCESS_TOKEN); - }); - it('should callback with error if requestToken is not overriden', done => { - const instance = new JwtTokenManagerV1(); + const instance = new JwtTokenManager(); instance.requestToken((err, res) => { expect(err).toBeInstanceOf(Error); @@ -159,28 +144,28 @@ describe('iam_token_manager_v1', () => { describe('isTokenExpired', () => { it('should return true if current time is past expire time', () => { - const instance = new JwtTokenManagerV1(); + const instance = new JwtTokenManager(); instance.expireTime = getCurrentTime() - 1000; expect(instance.isTokenExpired()).toBe(true); }); it('should return false if current time has not reached expire time', () => { - const instance = new JwtTokenManagerV1(); + const instance = new JwtTokenManager(); instance.expireTime = getCurrentTime() + 1000; expect(instance.isTokenExpired()).toBe(false); }); it('should return true if expire time has not been set', () => { - const instance = new JwtTokenManagerV1(); + const instance = new JwtTokenManager(); expect(instance.isTokenExpired()).toBe(true); }); }); describe('saveTokenInfo', () => { it('should save information to object state', () => { - const instance = new JwtTokenManagerV1(); + const instance = new JwtTokenManager(); const expireTime = 100; instance.calculateTimeForNewToken = jest.fn(token => expireTime); @@ -194,7 +179,7 @@ describe('iam_token_manager_v1', () => { }); it('should throw an error when access token is undefined', () => { - const instance = new JwtTokenManagerV1(); + const instance = new JwtTokenManager(); const tokenResponse = {}; expect(() => instance.saveTokenInfo(tokenResponse)).toThrow(); @@ -203,7 +188,7 @@ describe('iam_token_manager_v1', () => { describe('calculateTimeForNewToken', () => { it('should calculate time for new token based on valid jwt', () => { - const instance = new JwtTokenManagerV1(); + const instance = new JwtTokenManager(); const decodeSpy = jest .spyOn(jwt, 'decode') .mockImplementation(token => ({ iat: 0, exp: 100 })); @@ -213,7 +198,7 @@ describe('iam_token_manager_v1', () => { }); it('should throw an error if token is not a valid jwt', () => { - const instance = new JwtTokenManagerV1(); + const instance = new JwtTokenManager(); expect(() => instance.calculateTimeForNewToken()).toThrow(); }); }); diff --git a/test/unit/noauth-authenticator.test.js b/test/unit/noauth-authenticator.test.js new file mode 100644 index 000000000..2b98ff0fb --- /dev/null +++ b/test/unit/noauth-authenticator.test.js @@ -0,0 +1,13 @@ +'use strict'; + +const { NoauthAuthenticator } = require('../../auth'); + +describe('Noauth Authenticator', () => { + it('should call callback on authenticate', done => { + const authenticator = new NoauthAuthenticator(); + authenticator.authenticate({}, err => { + expect(err).toBeNull(); + done(); + }); + }); +}); diff --git a/test/unit/read-external-sources.test.js b/test/unit/read-external-sources.test.js new file mode 100644 index 000000000..885b24272 --- /dev/null +++ b/test/unit/read-external-sources.test.js @@ -0,0 +1,164 @@ +'use strict'; + +const { readExternalSources } = require('../../auth'); + +// constants +const SERVICE_NAME = 'test_service'; +const APIKEY = '123456789'; +const USERNAME = 'michael-leaue'; +const PASSWORD = 'snarkypuppy123'; +const BEARER_TOKEN = 'abc123'; + +describe('Read External Sources Module', () => { + // setup + let env; + + beforeEach(() => { + // remove all environment vars + env = process.env; + process.env = {}; + }); + + afterEach(() => { + // restore environment vars + process.env = env; + }); + + // tests + it('should throw an error if service name is not given', () => { + expect(() => readExternalSources()).toThrow(); + }); + + // creds file + it('should return an object from the credentials file', () => { + setupCredsFile(); + const properties = readExternalSources(SERVICE_NAME); + expect(properties).not.toBeNull(); + // auth props + expect(properties.authType).toBe('iam'); + expect(properties.apikey).toBe('12345'); + expect(properties.authUrl).toBe('iam.staging.com/api'); + expect(properties.clientId).toBe('my-id'); + expect(properties.clientSecret).toBe('my-secret'); + expect(properties.authDisableSsl).toBe(true); + + // service props + expect(properties.disableSsl).toBe(true); + expect(properties.url).toBe('service.com/api'); + }); + + // env + it('should return an object from environment variables', () => { + setupEnvVars(); + const properties = readExternalSources(SERVICE_NAME); + expect(properties).not.toBeNull(); + expect(properties.authType).toBe('basic'); + expect(properties.username).toBe(USERNAME); + expect(properties.password).toBe(PASSWORD); + }); + + // vcap + it('should return an iam object from VCAP_SERVICES', () => { + setupIamVcap(); + const properties = readExternalSources(SERVICE_NAME); + expect(properties).not.toBeNull(); + expect(properties.apikey).toBe(APIKEY); + expect(properties.url).toBeDefined(); + }); + + it('should set authentication type for basic auth object from VCAP_SERVICES', () => { + setupBasicVcap(); + const properties = readExternalSources(SERVICE_NAME); + expect(properties).not.toBeNull(); + expect(properties.username).toBeDefined(); + expect(properties.password).toBeDefined(); + expect(properties.url).toBeDefined(); + expect(properties.authType).toBe('basic'); + }); + + it('should prefer creds file over env vars', () => { + setupCredsFile(); + setupEnvVars(); + const properties = readExternalSources(SERVICE_NAME); + expect(properties).not.toBeNull(); + // expect the properties in the credentials file + expect(properties.authType).toBe('iam'); + expect(properties.apikey).toBe('12345'); + }); + + it('should prefer env vars over vcap', () => { + setupEnvVars(); + setupIamVcap(); + const properties = readExternalSources(SERVICE_NAME); + expect(properties).not.toBeNull(); + expect(properties.authType).toBe('basic'); + expect(properties.username).toBe(USERNAME); + expect(properties.password).toBe(PASSWORD); + expect(properties.bearerToken).toBe(BEARER_TOKEN); + }); + + it('should convert a dash separated name to underscore separated', () => { + setupEnvVars(); + const properties = readExternalSources('Test-Service'); + expect(properties).not.toBeNull(); + expect(properties.authType).toBe('basic'); + expect(properties.username).toBe(USERNAME); + expect(properties.password).toBe(PASSWORD); + }); + + it('should convert disableSsl values from string to boolean', () => { + process.env.TEST_SERVICE_DISABLE_SSL = 'true'; + process.env.TEST_SERVICE_AUTH_DISABLE_SSL = 'true'; + const properties = readExternalSources(SERVICE_NAME); + expect(typeof properties.disableSsl).toBe('boolean'); + expect(typeof properties.authDisableSsl).toBe('boolean'); + }); +}); + +// helper functions for setting up process.env + +function setupCredsFile() { + // this file contains all possible iam creds + process.env.IBM_CREDENTIALS_FILE = __dirname + '/../resources/ibm-credentials.env'; +} + +function setupEnvVars() { + // the service name matches what is in the credentials file + // to test priority between the two + process.env.TEST_SERVICE_AUTH_TYPE = 'basic'; + process.env.TEST_SERVICE_USERNAME = USERNAME; + process.env.TEST_SERVICE_PASSWORD = PASSWORD; + // just for coverage on all potential auth properties + process.env.TEST_SERVICE_BEARER_TOKEN = BEARER_TOKEN; +} + +function setupIamVcap() { + process.env.VCAP_SERVICES = JSON.stringify({ + test_service: [ + { + credentials: { + apikey: APIKEY, + iam_apikey_description: 'Auto generated apikey...', + iam_apikey_name: 'auto-generated-apikey-111-222-333', + 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', + }, + }, + ], + }); +} + +function setupBasicVcap() { + process.env.VCAP_SERVICES = JSON.stringify({ + test_service: [ + { + credentials: { + password: 'vcap_pass', + url: 'https://gateway.watsonplatform.net/test/api', + username: 'vcap_user', + }, + }, + ], + }); +} diff --git a/test/unit/readCredentialsFile.test.js b/test/unit/readCredentialsFile.test.js index bb53c98c7..9bce20d9c 100644 --- a/test/unit/readCredentialsFile.test.js +++ b/test/unit/readCredentialsFile.test.js @@ -1,10 +1,7 @@ 'use strict'; -const readCredentialsFunctions = require('../../lib/read-credentials-file'); -const constructFilepath = readCredentialsFunctions.constructFilepath; -const fileExistsAtPath = readCredentialsFunctions.fileExistsAtPath; -const readCredentialsFile = readCredentialsFunctions.readCredentialsFile; const fs = require('fs'); +const { constructFilepath, fileExistsAtPath, readCredentialsFile } = require('../../auth'); describe('browser scenario', () => { const existSync = fs.existsSync; @@ -70,8 +67,14 @@ describe('read ibm credentials file', () => { describe('read credentials file', () => { it('should return credentials as an object if file exists', () => { const obj = readCredentialsFile(); - expect(obj.TEST_USERNAME).toBe('123456789'); - expect(obj.TEST_PASSWORD).toBe('abcd'); + expect(obj.TEST_SERVICE_AUTH_TYPE).toBe('iam'); + expect(obj.TEST_SERVICE_APIKEY).toBe('12345'); + expect(obj.TEST_SERVICE_AUTH_URL).toBe('iam.staging.com/api'); + expect(obj.TEST_SERVICE_CLIENT_ID).toBe('my-id'); + expect(obj.TEST_SERVICE_CLIENT_SECRET).toBe('my-secret'); + expect(obj.TEST_SERVICE_AUTH_DISABLE_SSL).toBe('true'); + expect(obj.TEST_SERVICE_URL).toBe('service.com/api'); + expect(obj.TEST_SERVICE_DISABLE_SSL).toBe('true'); }); it('should return credentials as an object for alternate filename', () => { diff --git a/test/unit/request-token-based-authenticator.test.js b/test/unit/request-token-based-authenticator.test.js new file mode 100644 index 000000000..979a0b9a5 --- /dev/null +++ b/test/unit/request-token-based-authenticator.test.js @@ -0,0 +1,51 @@ +'use strict'; + +const { TokenRequestBasedAuthenticator } = require('../../auth'); +const { JwtTokenManager } = require('../../auth'); + +describe('Request Based Token Authenticator', () => { + const config = { + url: 'auth.com', + disableSslVerification: true, + headers: { + 'X-My-Header': 'some-value', + }, + }; + + it('should store all config options on the class', () => { + const authenticator = new TokenRequestBasedAuthenticator(config); + + expect(authenticator.url).toBe(config.url); + expect(authenticator.disableSslVerification).toBe(config.disableSslVerification); + expect(authenticator.headers).toEqual(config.headers); + + // should also create a token manager + expect(authenticator.tokenManager).toBeInstanceOf(JwtTokenManager); + }); + + it('should not re-set headers when a non-object is passed to the setter', () => { + const authenticator = new TokenRequestBasedAuthenticator(config); + expect(authenticator.headers).toEqual(config.headers); + + const badHeader = 42; + authenticator.setHeaders(badHeader); + expect(authenticator.headers).toEqual(config.headers); + + // also, verify that the underlying token manager has been updated + expect(authenticator.tokenManager.headers).toEqual(config.headers); + }); + + it('should call the callback in authenticate with an error if the token request fails', done => { + const authenticator = new TokenRequestBasedAuthenticator(config); + const fakeError = new Error('fake error'); + const getTokenSpy = jest + .spyOn(authenticator.tokenManager, 'getToken') + .mockImplementation(cb => cb(fakeError)); + + authenticator.authenticate({}, err => { + expect(getTokenSpy).toHaveBeenCalled(); + expect(err).toBe(fakeError); + done(); + }); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 0881180bb..46437fb56 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -57,8 +57,7 @@ }, "include": [ "./lib/*.ts", - "./iam-token-manager/*.ts", - "./auth/*.ts", + "./auth/**/*.ts", "index.ts" ] }