From f6ede06d8e8de452442f88f56d66938c784816ce Mon Sep 17 00:00:00 2001 From: Dustin Popp Date: Fri, 4 Oct 2024 12:16:22 -0500 Subject: [PATCH] feat(IamAssumeAuthenticator): add new authentication type for iam assume This commit introduces the new IamAssumeAuthenticator which will fetch an IAM access token using the IAM getToken operation's "assume" grant type. The resulting access token allows the application to assume the identity of a trusted profile, similar to the "sudo" feature of Linux. Signed-off-by: Dustin Popp --- .secrets.baseline | 96 +++++- Authentication.md | 167 ++++++++-- auth/authenticators/authenticator.ts | 2 + .../iam-assume-authenticator.ts | 100 ++++++ auth/authenticators/index.ts | 5 +- .../token-managers/container-token-manager.ts | 2 +- .../iam-assume-token-manager.ts | 130 ++++++++ auth/token-managers/iam-token-manager.ts | 4 +- auth/token-managers/index.ts | 6 +- .../get-authenticator-from-environment.ts | 3 + auth/utils/helpers.ts | 64 ++-- etc/ibm-cloud-sdk-core.api.md | 26 +- .../iam-assume-authenticator.test.js | 49 +++ test/unit/iam-assume-authenticator.test.js | 138 ++++++++ test/unit/iam-assume-token-manager.test.js | 311 ++++++++++++++++++ 15 files changed, 1037 insertions(+), 66 deletions(-) create mode 100644 auth/authenticators/iam-assume-authenticator.ts create mode 100644 auth/token-managers/iam-assume-token-manager.ts create mode 100644 test/integration/iam-assume-authenticator.test.js create mode 100644 test/unit/iam-assume-authenticator.test.js create mode 100644 test/unit/iam-assume-token-manager.test.js diff --git a/.secrets.baseline b/.secrets.baseline index 3f8ed82c0..344aaf659 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -3,7 +3,7 @@ "files": "package-lock.json|^.secrets.baseline$", "lines": null }, - "generated_at": "2024-08-29T14:54:57Z", + "generated_at": "2024-10-04T17:22:24Z", "plugins_used": [ { "name": "AWSKeyDetector" @@ -70,7 +70,39 @@ "hashed_secret": "91dfd9ddb4198affc5c194cd8ce6d338fde470e2", "is_secret": false, "is_verified": false, - "line_number": 74, + "line_number": 75, + "type": "Secret Keyword", + "verified_result": null + }, + { + "hashed_secret": "4f51cde3ac0a5504afa4bc06859b098366592c19", + "is_secret": false, + "is_verified": false, + "line_number": 236, + "type": "Secret Keyword", + "verified_result": null + }, + { + "hashed_secret": "e87559ed7decb62d0733ae251ae58d42a55291d8", + "is_secret": false, + "is_verified": false, + "line_number": 238, + "type": "Secret Keyword", + "verified_result": null + }, + { + "hashed_secret": "12f4a68ed3d0863e56497c9cdb1e2e4e91d5cb68", + "is_secret": false, + "is_verified": false, + "line_number": 302, + "type": "Secret Keyword", + "verified_result": null + }, + { + "hashed_secret": "c837b75d7cd93ef9c2243ca28d6e5156259fd253", + "is_secret": false, + "is_verified": false, + "line_number": 306, "type": "Secret Keyword", "verified_result": null }, @@ -78,7 +110,7 @@ "hashed_secret": "98635b2eaa2379f28cd6d72a38299f286b81b459", "is_secret": false, "is_verified": false, - "line_number": 433, + "line_number": 558, "type": "Secret Keyword", "verified_result": null }, @@ -86,7 +118,7 @@ "hashed_secret": "47fcf185ee7e15fe05cae31fbe9e4ebe4a06a40d", "is_secret": false, "is_verified": false, - "line_number": 543, + "line_number": 668, "type": "Secret Keyword", "verified_result": null } @@ -96,7 +128,7 @@ "hashed_secret": "bc2f74c22f98f7b6ffbc2f67453dbfa99bce9a32", "is_secret": false, "is_verified": false, - "line_number": 207, + "line_number": 214, "type": "Secret Keyword", "verified_result": null } @@ -116,7 +148,7 @@ "hashed_secret": "fdee05598fdd57ff8e9ae29e92c25a04f2c52fa6", "is_secret": false, "is_verified": false, - "line_number": 39, + "line_number": 41, "type": "Secret Keyword", "verified_result": null } @@ -239,6 +271,16 @@ "verified_result": null } ], + "auth/token-managers/iam-assume-token-manager.ts": [ + { + "hashed_secret": "faed0c503983c5ab06e19630096d39ebfafef86a", + "is_secret": false, + "is_verified": false, + "line_number": 108, + "type": "Secret Keyword", + "verified_result": null + } + ], "auth/token-managers/iam-request-based-token-manager.ts": [ { "hashed_secret": "f84f793e0af9ade37c8b927bc5091e98f35bf821", @@ -314,7 +356,7 @@ "hashed_secret": "6947818ac409551f11fbaa78f0ea6391960aa5b8", "is_secret": false, "is_verified": false, - "line_number": 50, + "line_number": 51, "type": "Secret Keyword", "verified_result": null } @@ -334,7 +376,7 @@ "hashed_secret": "45c43fe97e3a06ab078b0eeff6fbe622cc417a25", "is_secret": false, "is_verified": false, - "line_number": 266, + "line_number": 286, "type": "Secret Keyword", "verified_result": null } @@ -455,6 +497,42 @@ "verified_result": null } ], + "test/unit/iam-assume-authenticator.test.js": [ + { + "hashed_secret": "9cea46b39bd44a1ef9f3e71bfe9e45c24d3300f6", + "is_secret": false, + "is_verified": false, + "line_number": 33, + "type": "Secret Keyword", + "verified_result": null + }, + { + "hashed_secret": "5c5a15a8b0b3e154d77746945e563ba40100681b", + "is_secret": false, + "is_verified": false, + "line_number": 37, + "type": "Secret Keyword", + "verified_result": null + } + ], + "test/unit/iam-assume-token-manager.test.js": [ + { + "hashed_secret": "a0da30f332dd7b7a26d1c0b4da5437fcd90bf49b", + "is_secret": false, + "is_verified": false, + "line_number": 34, + "type": "Secret Keyword", + "verified_result": null + }, + { + "hashed_secret": "9cea46b39bd44a1ef9f3e71bfe9e45c24d3300f6", + "is_secret": false, + "is_verified": false, + "line_number": 148, + "type": "Secret Keyword", + "verified_result": null + } + ], "test/unit/iam-authenticator.test.js": [ { "hashed_secret": "257368587362aab7f1180b4a5fe550ec26053e05", @@ -582,7 +660,7 @@ } ] }, - "version": "0.13.1+ibm.62.dss", + "version": "0.13.1+ibm.56.dss", "word_list": { "file": null, "hash": null diff --git a/Authentication.md b/Authentication.md index 4514d0c8d..7f0e1bbb1 100644 --- a/Authentication.md +++ b/Authentication.md @@ -2,7 +2,8 @@ The node-sdk-core project supports the following types of authentication: - Basic Authentication - Bearer Token Authentication -- Identity and Access Management (IAM) Authentication +- Identity and Access Management (IAM) Authentication (grant type: apikey) +- Identity and Access Management (IAM) Authentication (grant type: assume) - Container Authentication - VPC Instance Authentication - Cloud Pak for Data Authentication @@ -16,7 +17,7 @@ which authentication types are supported for that service. The node-sdk-core allows an authenticator to be specified in one of two ways: 1. programmatically - the SDK user invokes the appropriate function(s) to create an instance of the -desired authenticator and then passes the authenticator instance when constructing an instance of the service. +desired authenticator and then passes the authenticator instance when constructing an instance of the service client. 2. configuration - the SDK user provides external configuration information (in the form of environment variables or a credentials file) to indicate the type of authenticator, along with the configuration of the necessary properties for that authenticator. The SDK user then invokes the configuration-based authenticator factory to construct an instance @@ -28,7 +29,7 @@ which will include the following: - The properties associated with the authenticator - An example of how to construct the authenticator programmatically - An example of how to configure the authenticator through the use of external -configuration information. The configuration examples below will use +configuration information. The configuration examples below will use environment variables, although the same properties could be specified in a credentials file instead. @@ -143,16 +144,16 @@ const service = ExampleServiceV1.newInstance(options); Note that the use of external configuration is not as useful with the `BearerTokenAuthenticator` as it is for other authenticator types because bearer tokens typically need to be obtained and refreshed -programmatically since they normally have a relatively short lifespan before they expire. This +programmatically since they normally have a relatively short lifespan before they expire. This authenticator type is intended for situations in which the application will be managing the bearer token itself in terms of initial acquisition and refreshing as needed. -## Identity and Access Management (IAM) Authentication -The `IamAuthenticator` will accept a user-supplied api key and will perform +## Identity and Access Management (IAM) Authentication (grant type: apikey) +The `IamAuthenticator` will accept a user-supplied apikey and will perform the necessary interactions with the IAM token service to obtain a suitable -bearer token for the specified api key. The authenticator will also obtain -a new bearer token when the current token expires. The bearer token is +bearer token for the specified apikey. The authenticator will also obtain +a new bearer token when the current token expires. The bearer token is then added to each outbound request in the `Authorization` header in the form: ``` @@ -161,7 +162,7 @@ form: ### Properties -- apikey: (required) the IAM api key +- apikey: (required) the IAM apikey to be used to obtain an IAM access token. - url: (optional) The base endpoint URL of the IAM token service. The default value of this property is the "prod" IAM token service endpoint @@ -178,13 +179,13 @@ endpoint as well (`https://iam.test.cloud.ibm.com`). - clientId/clientSecret: (optional) The `clientId` and `clientSecret` fields are used to form a "basic auth" Authorization header for interactions with the IAM token server. If neither field -is specified, then no Authorization header will be sent with token server requests. These fields +is specified, then no Authorization header will be sent with token server requests. These fields are optional, but must be specified together. - scope: (optional) the scope to be associated with the IAM access token. If not specified, then no scope wil be associated with the access token. -- disableSslVerification: (optional) A flag that indicates whether verificaton of the server's SSL +- disableSslVerification: (optional) A flag that indicates whether verification of the server's SSL certificate should be disabled or not. The default value is `false`. - headers: (optional) A set of key/value pairs that will be sent as HTTP headers in requests @@ -228,6 +229,130 @@ const service = ExampleServiceV1.newInstance(options); ``` +## Identity and Access Management (IAM) Authentication (grant type: assume) +The `IamAssumeAuthenticator` performs a two-step token fetch sequence to obtain +a bearer token that allows the application to assume the identity of a trusted profile: +1. First, the authenticator obtains an initial bearer token using grant type +`urn:ibm:params:oauth:grant-type:apikey`. +This initial token will reflect the identity associated with the input apikey. +2. Second, the authenticator uses the grant type `urn:ibm:params:oauth:grant-type:assume` to obtain a bearer token +that reflects the identity of the trusted profile, passing in the initial bearer token +from the first step, along with the trusted profile-related inputs. + +The authenticator will also obtain a new bearer token when the current token expires. +The bearer token is then added to each outbound request in the `Authorization` header in the +form: +``` + Authorization: Bearer +``` + +### Properties + +- apikey: (required) the IAM apikey to be used to obtain the initial IAM access token. + +- iamProfileCrn: (optional) the Cloud Resource Name (CRN) associated with the trusted profile +for which an access token should be fetched. +Exactly one of iamProfileCrn, iamProfileId or iamProfileName must be specified. + +- iamProfileId: (optional) the ID associated with the trusted profile +for which an access token should be fetched. +Exactly one of iamProfileCrn, iamProfileId or iamProfileName must be specified. + +- iamProfileName: (optional) the name associated with the trusted profile +for which an access token should be fetched. When specifying this property, you must also +specify the iamAccountId property as well. +Exactly one of iamProfileCrn, iamProfileId or iamProfileName must be specified. + +- iamAccountId: (optional) the ID associated with the IAM account that contains the trusted profile +referenced by the iamProfileName property. The imaAccountId property must be specified if and only if +the iamProfileName property is specified. + +- url: (optional) The base endpoint URL of the IAM token service. +The default value of this property is the "prod" IAM token service endpoint +(`https://iam.cloud.ibm.com`). +Make sure that you use an IAM token service endpoint that is appropriate for the +location of the service being used by your application. +For example, if you are using an instance of a service in the "production" environment +(e.g. `https://resource-controller.cloud.ibm.com`), +then the default "prod" IAM token service endpoint should suffice. +However, if your application is using an instance of a service in the "staging" environment +(e.g. `https://resource-controller.test.cloud.ibm.com`), +then you would also need to configure the authenticator to use the IAM token service "staging" +endpoint as well (`https://iam.test.cloud.ibm.com`). + +- clientId/clientSecret: (optional) The `clientId` and `clientSecret` fields are used to form a +"basic auth" Authorization header for interactions with the IAM token server when fetching the +initial IAM access token. These fields are optional, but must be specified together. + +- scope: (optional) the scope to be used when obtaining the initial IAM access token. +If not specified, then no scope will be associated with the access token. + +- disableSSLVerification: (optional) A flag that indicates whether verification of the server's SSL +certificate should be disabled or not. The default value is `false`. + +- headers: (optional) A set of key/value pairs that will be sent as HTTP headers in requests +made to the IAM token service. + +### Usage Notes +- The IamAssumeAuthenticator is used to obtain an access token (a bearer token) from the IAM token service +that allows an application to "assume" the identity of a trusted profile. + +- The authenticator first uses the apikey, url, clientId/clientSecret, scope, disableSSLVerification, and headers +properties to obtain an initial access token by invoking the IAM `getToken` +(grant_type=`urn:ibm:params:oauth:grant-type:apikey`) operation. + +- The authenticator then uses the initial access token along with the url, iamProfileCrn, iamProfileId, +iamProfileName, iamAccountId, disableSSLVerification, and headers properties to obtain an access token by invoking +the IAM `getToken` (grant_type=`urn:ibm:params:oauth:grant-type:assume`) operation. +The access token resulting from this second step will reflect the identity of the specified trusted profile. + +- When providing the trusted profile information, you must specify exactly one of: iamProfileCrn, iamProfileId +or iamProfileName. If you specify iamProfileCrn or iamProfileId, then the trusted profile must exist in the same account that is +associated with the input apikey. If you specify iamProfileName, then you must also specify the iamAccountId property +to indicate the IAM account in which the named trusted profile can be found. + +### Programming example +```js +const { IamAssumeAuthenticator } = require('ibm-cloud-sdk-core'); +const ExampleServiceV1 = require('/example-service/v1'); + +// Create the authenticator. +const authenticator = new IamAssumeAuthenticator({ + apikey: 'myapikey', + iamProfileId: 'myprofile-1', +}); + +const options = { + authenticator, +}; + +// Create the service instance. +const service = new ExampleServiceV1(options); + +// 'service' can now be used to invoke operations. +``` + +### Configuration example +External configuration: +``` +export EXAMPLE_SERVICE_AUTH_TYPE=iamAssume +export EXAMPLE_SERVICE_APIKEY=myapikey +export EXAMPLE_SERVICE_IAM_PROFILE_ID=myprofile-1 +``` +Application code: +```js +const ExampleServiceV1 = require('/example-service/v1'); + +const options = { + serviceName: 'example_service', +}; + +const service = ExampleServiceV1.newInstance(options); + +// 'service' can now be used to invoke operations. +``` + + ## Container Authentication The `ContainerAuthenticator` is intended to be used by application code running inside a compute resource managed by the IBM Kubernetes Service (IKS) @@ -236,7 +361,7 @@ within the compute resource's local file system. The CR token is similar to an IAM apikey except that it is managed automatically by the compute resource provider (IKS). This allows the application developer to: -- avoid storing credentials in application code, configuraton files or a password vault +- avoid storing credentials in application code, configuration files or a password vault - avoid managing or rotating credentials The `ContainerAuthenticator` will retrieve the CR token from @@ -280,13 +405,13 @@ endpoint as well (`https://iam.test.cloud.ibm.com`). - clientId/clientSecret: (optional) The `clientId` and `clientSecret` fields are used to form a "basic auth" Authorization header for interactions with the IAM token service. If neither field -is specified, then no Authorization header will be sent with token server requests. These fields +is specified, then no Authorization header will be sent with token server requests. These fields are optional, but must be specified together. - scope: (optional) the scope to be associated with the IAM access token. If not specified, then no scope will be associated with the access token. -- disableSslVerification: (optional) A flag that indicates whether verificaton of the server's SSL +- disableSslVerification: (optional) A flag that indicates whether verification of the server's SSL certificate should be disabled or not. The default value is `false`. - headers: (optional) A set of key/value pairs that will be sent as HTTP headers in requests @@ -342,7 +467,7 @@ The compute resource identity feature allows you to assign a trusted IAM profile This, in turn, allows applications running within the compute resource to take on this identity when interacting with IAM-secured IBM Cloud services. This results in a simplified security model that allows the application developer to: -- avoid storing credentials in application code, configuraton files or a password vault +- avoid storing credentials in application code, configuration files or a password vault - avoid managing or rotating credentials The `VpcInstanceAuthenticator` will invoke the appropriate operations on the compute resource's locally-available @@ -361,11 +486,11 @@ The IAM access token is added to each outbound request in the `Authorization` he - iamProfileId: (optional) the id of the linked trusted IAM profile to be used when obtaining the IAM access token. - url: (optional) The VPC Instance Metadata Service's base URL. -The default value of this property is `http://169.254.169.254`. However, if the VPC Instance Metadata Service is configured +The default value of this property is `http://169.254.169.254`. However, if the VPC Instance Metadata Service is configured with the HTTP Secure Protocol setting (`https`), then you should configure this property to be `https://api.metadata.cloud.ibm.com`. Usage Notes: -1. At most one of `iamProfileCrn` or `iamProfileId` may be specified. The specified value must map +1. At most one of `iamProfileCrn` or `iamProfileId` may be specified. The specified value must map to a trusted IAM profile that has been linked to the compute resource (virtual server instance). 2. If both `iamProfileCrn` and `iamProfileId` are specified, then an error occurs. @@ -413,11 +538,11 @@ const service = ExampleServiceV1.newInstance(options); ``` -## Cloud Pak for Data Authentication +## Cloud Pak for Data Authentication The `CloudPakForDataAuthenticator` will accept a user-supplied username value, along with either a password or apikey, and will perform the necessary interactions with the Cloud Pak for Data token service to obtain a suitable -bearer token. The authenticator will also obtain a new bearer token when the current token expires. +bearer token. The authenticator will also obtain a new bearer token when the current token expires. The bearer token is then added to each outbound request in the `Authorization` header in the form: ``` @@ -436,7 +561,7 @@ Exactly one of password or apikey should be specified. - url: (required) The URL representing the Cloud Pak for Data token service endpoint's base URL string. This value should not include the `/v1/authorize` path portion. -- disableSslVerification: (optional) A flag that indicates whether verificaton of the server's SSL +- disableSslVerification: (optional) A flag that indicates whether verification of the server's SSL certificate should be disabled or not. The default value is `false`. - headers: (optional) A set of key/value pairs that will be sent as HTTP headers in requests @@ -505,7 +630,7 @@ form: - url: (required) The URL representing the MCSP token service endpoint's base URL string. Do not include the operation path (e.g. `/siusermgr/api/1.0/apikeys/token`) as part of this property's value. -- disableSSLVerification: (optional) A flag that indicates whether verificaton of the server's SSL +- disableSSLVerification: (optional) A flag that indicates whether verification of the server's SSL certificate should be disabled or not. The default value is `false`. - headers: (optional) A set of key/value pairs that will be sent as HTTP headers in requests diff --git a/auth/authenticators/authenticator.ts b/auth/authenticators/authenticator.ts index 94becdd96..f23c018de 100644 --- a/auth/authenticators/authenticator.ts +++ b/auth/authenticators/authenticator.ts @@ -32,6 +32,8 @@ export class Authenticator implements AuthenticatorInterface { static AUTHTYPE_IAM = 'iam'; + static AUTHTYPE_IAM_ASSUME = 'iamAssume'; + static AUTHTYPE_CONTAINER = 'container'; static AUTHTYPE_CP4D = 'cp4d'; diff --git a/auth/authenticators/iam-assume-authenticator.ts b/auth/authenticators/iam-assume-authenticator.ts new file mode 100644 index 000000000..563a5ca08 --- /dev/null +++ b/auth/authenticators/iam-assume-authenticator.ts @@ -0,0 +1,100 @@ +/** + * (C) Copyright IBM Corp. 2024. + * + * 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 { IamAssumeTokenManager } from '../token-managers'; +import { IamRequestOptions, IamRequestBasedAuthenticator } from './iam-request-based-authenticator'; + +/** Configuration options for IAM Assume authentication. */ +export interface Options extends IamRequestOptions { + /** The IAM api key */ + apikey: string; + + /** + * Specify exactly one of [iamProfileId, iamProfileCrn, or iamProfileName] to + * identify the trusted profile whose identity should be used. If iamProfileId + * or iamProfileCrn is used, the trusted profile must exist in the same account. + * If and only if IAMProfileName is used, then iamAccountId must also be + * specified to indicate the account that contains the trusted profile. + */ + iamProfileId?: string; + iamProfileCrn?: string; + iamProfileName?: string; + + /** + * If and only if iamProfileName is used to specify the trusted profile, then + * iamAccountId must also be specified to indicate the account that contains + * the trusted profile. + */ + iamAccountId?: string; +} + +/** + * The IamAssumeAuthenticator obtains an IAM access token using the IAM "get-token" + * operation's "assume" grant type. The authenticator obtains an initial IAM access + * token from a user-supplied apikey, then exchanges this initial IAM access token + * for another IAM access token that has "assumed the identity" of the specified + * trusted profile. + * + * The bearer token will be sent as an Authorization header in the form: + * + * Authorization: Bearer \ + */ +export class IamAssumeAuthenticator extends IamRequestBasedAuthenticator { + protected tokenManager: IamAssumeTokenManager; + + /** + * + * Create a new IamAssumeAuthenticator instance. + * + * @param options - Configuration options for IAM authentication. + * This should be an object containing these fields: + * - apikey: (required) the IAM api key for initial token request + * - iamProfileId: (optional) the ID of the trusted profile to use + * - iamProfileCrn: (optional) the CRN of the trusted profile to use + * - iamProfileName: (optional) the name of the trusted profile to use (must be specified with iamAccountId) + * - iamAccountId: (optional) the ID of the account the trusted profile is in (must be specified with iamProfileName) + * - url: (optional) the endpoint URL for the token service + * - disableSslVerification: (optional) a flag that indicates whether verification of the token server's SSL certificate + * should be disabled or not + * - headers: (optional) a set of HTTP headers to be sent with each request to the token service + * - clientId: (optional) the "clientId" and "clientSecret" fields are used to form a Basic + * Authorization header to be included in each request to the token service + * - clientSecret: (optional) the "clientId" and "clientSecret" fields are used to form a Basic + * Authorization header to be included in each request to the token service + * - scope: (optional) the "scope" parameter to use when fetching the bearer token from the token service + * + * @throws Error: the configuration options are not valid. + */ + constructor(options: Options) { + super(options); + + // The param names are shared between the authenticator and the token + // manager so we can just pass along the options object. This will + // also perform input validation on the options. + this.tokenManager = new IamAssumeTokenManager(options); + } + + /** + * Returns the authenticator's type ('iamAssume'). + * + * @returns a string that indicates the authenticator's type + */ + // eslint-disable-next-line class-methods-use-this + public authenticationType(): string { + return Authenticator.AUTHTYPE_IAM_ASSUME; + } +} diff --git a/auth/authenticators/index.ts b/auth/authenticators/index.ts index c25b6e92a..fc0770db3 100644 --- a/auth/authenticators/index.ts +++ b/auth/authenticators/index.ts @@ -20,7 +20,8 @@ * * Basic Authentication * Bearer Token - * Identity and Access Management (IAM) + * Identity and Access Management (IAM, grant type: apikey) + * Identity and Access Management (IAM, grant type: assume) * Container (IKS, etc) * VPC Instance * Cloud Pak for Data @@ -36,6 +37,7 @@ * BearerTokenAuthenticator: Authenticator for passing supplied bearer token to service endpoint. * CloudPakForDataAuthenticator: Authenticator for passing CP4D authentication information to service endpoint. * IAMAuthenticator: Authenticator for passing IAM authentication information to service endpoint. + * IAMAssumeAuthenticator: Authenticator for passing IAM authentication information to service endpoint, assuming a trusted profile. * ContainerAuthenticator: Authenticator for passing IAM authentication to a service, based on a token living on the container. * VpcInstanceAuthenticator: Authenticator that uses the VPC Instance Metadata Service API to retrieve an IAM token. * McspAuthenticator: Authenticator for passing MCSP authentication to a service endpoint. @@ -54,3 +56,4 @@ export { IamRequestBasedAuthenticator } from './iam-request-based-authenticator' export { TokenRequestBasedAuthenticator } from './token-request-based-authenticator'; export { VpcInstanceAuthenticator } from './vpc-instance-authenticator'; export { McspAuthenticator } from './mcsp-authenticator'; +export { IamAssumeAuthenticator } from './iam-assume-authenticator'; diff --git a/auth/token-managers/container-token-manager.ts b/auth/token-managers/container-token-manager.ts index 96f903491..9c829a6d0 100644 --- a/auth/token-managers/container-token-manager.ts +++ b/auth/token-managers/container-token-manager.ts @@ -115,7 +115,7 @@ export class ContainerTokenManager extends IamRequestBasedTokenManager { /** * Request an IAM token using a compute resource token. */ - protected async requestToken(): Promise { + protected requestToken(): Promise { this.formData.cr_token = this.getCrToken(); // these member variables can be reset, set them in the form data right diff --git a/auth/token-managers/iam-assume-token-manager.ts b/auth/token-managers/iam-assume-token-manager.ts new file mode 100644 index 000000000..15dbfa1b2 --- /dev/null +++ b/auth/token-managers/iam-assume-token-manager.ts @@ -0,0 +1,130 @@ +/** + * (C) Copyright IBM Corp. 2024. + * + * 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 { onlyOne, validateInput } from '../utils/helpers'; +import { buildUserAgent } from '../../lib/build-user-agent'; +import { IamRequestBasedTokenManager, IamRequestOptions } from './iam-request-based-token-manager'; +import { IamTokenManager } from './iam-token-manager'; + +/** Configuration options for IAM Assume token retrieval. */ +interface Options extends IamRequestOptions { + apikey: string; + iamProfileId?: string; + iamProfileCrn?: string; + iamProfileName?: string; + iamAccountId?: string; +} + +/** + * The IamAssumeTokenManager takes an api key, along with trusted profile information, and performs + * the necessary interactions with the IAM token service to obtain and store a suitable bearer token + * that "assumes" the identify of the trusted profile. + */ +export class IamAssumeTokenManager extends IamRequestBasedTokenManager { + protected requiredOptions = ['apikey']; + + private iamProfileId: string; + + private iamProfileCrn: string; + + private iamProfileName: string; + + private iamAccountId: string; + + private iamDelegate: IamTokenManager; + + /** + * + * Create a new IamAssumeTokenManager instance. + * + * @param options - Configuration options. + * This should be an object containing these fields: + * - apikey: (required) the IAM api key + * - iamProfileId: (optional) the ID of the trusted profile to use + * - iamProfileCrn: (optional) the CRN of the trusted profile to use + * - iamProfileName: (optional) the name of the trusted profile to use (must be specified with iamAccountId) + * - iamAccountId: (optional) the ID of the account the trusted profile is in (must be specified with iamProfileName) + * - url: (optional) the endpoint URL for the IAM token service (default value: "https://iam.cloud.ibm.com") + * - disableSslVerification: (optional) a flag that indicates whether verification of the token server's SSL certificate + * should be disabled or not + * - headers: (optional) a set of HTTP headers to be sent with each request to the token service + * - clientId: (optional) the "clientId" and "clientSecret" fields are used to form a Basic + * Authorization header to be included in each request to the token service + * - clientSecret: (optional) the "clientId" and "clientSecret" fields are used to form a Basic + * Authorization header to be included in each request to the token service + * - scope: (optional) the "scope" parameter to use when fetching the bearer token from the token service + * + * @throws Error: the configuration options are not valid. + */ + constructor(options: Options) { + super(options); + + // This just verifies that the API key is provided and is free of common issues. + validateInput(options, this.requiredOptions); + + // This validates the assume-specific fields. + // Only one of the following three options may be specified. + if (!onlyOne(options.iamProfileId, options.iamProfileCrn, options.iamProfileName)) { + throw new Error( + 'Exactly one of `iamProfileName`, `iamProfileCrn`, or `iamProfileId` must be specified.' + ); + } + + // `iamAccountId` may only be specified if `iamProfileName` is also specified. + if (Boolean(options.iamProfileName) !== Boolean(options.iamAccountId)) { + throw new Error( + '`iamProfileName` and `iamAccountId` must be provided together, or not at all.' + ); + } + + // Set class variables from options. If they are 'undefined' in options, + // they won't be changed, as they are 'undefined' to begin with. + this.iamProfileId = options.iamProfileId; + this.iamProfileCrn = options.iamProfileCrn; + this.iamProfileName = options.iamProfileName; + this.iamAccountId = options.iamAccountId; + this.iamDelegate = options.iamDelegate; + + // Create an instance of the IamTokenManager, which will be used to obtain + // an IAM access token for use in the "assume" token exchange. Most option + // names are shared between these token manager, and extraneous options will + // be ignored, so we can pass the options structure to that constructor as-is. + this.iamDelegate = new IamTokenManager(options); + + // Set the grant type and user agent for this flavor of authentication. + this.formData.grant_type = 'urn:ibm:params:oauth:grant-type:assume'; + this.userAgent = buildUserAgent('iam-assume-authenticator'); + } + + /** + * Request an IAM token using a standard access token and a trusted profile. + */ + protected async requestToken(): Promise { + // First, retrieve a standard IAM access token from the delegate and set it in the form data. + this.formData.access_token = await this.iamDelegate.getToken(); + + if (this.iamProfileCrn) { + this.formData.profile_crn = this.iamProfileCrn; + } else if (this.iamProfileId) { + this.formData.profile_id = this.iamProfileId; + } else { + this.formData.profile_name = this.iamProfileName; + this.formData.account = this.iamAccountId; + } + + return super.requestToken(); + } +} diff --git a/auth/token-managers/iam-token-manager.ts b/auth/token-managers/iam-token-manager.ts index 4710010af..35cddd33c 100644 --- a/auth/token-managers/iam-token-manager.ts +++ b/auth/token-managers/iam-token-manager.ts @@ -24,8 +24,8 @@ interface Options extends IamRequestOptions { } /** - * The IAMTokenManager takes an api key and performs the necessary interactions with - * the IAM token service to obtain and store a suitable bearer token. Additionally, the IAMTokenManager + * The IamTokenManager takes an api key and performs the necessary interactions with + * the IAM token service to obtain and store a suitable bearer token. Additionally, the IamTokenManager * will retrieve bearer tokens via basic auth using a supplied "clientId" and "clientSecret" pair. */ export class IamTokenManager extends IamRequestBasedTokenManager { diff --git a/auth/token-managers/index.ts b/auth/token-managers/index.ts index 3836a0ec3..e151b91ef 100644 --- a/auth/token-managers/index.ts +++ b/auth/token-managers/index.ts @@ -17,8 +17,8 @@ /** * @module token-managers * The ibm-cloud-sdk-core module supports the following types of token authentication: - * - * Identity and Access Management (IAM) + * Identity and Access Management (IAM, grant type: apikey) + * Identity and Access Management (IAM, grant type: assume) * Cloud Pak for Data * Container (IKS, etc) * VPC Instance @@ -29,6 +29,7 @@ * * classes: * IamTokenManager: Token Manager of IAM via apikey. + * IamAssumeTokenManager: Token Manager of IAM via apikey and trusted profile. * Cp4dTokenManager: Token Manager of CloudPak for data. * ContainerTokenManager: Token manager of IAM via compute resource token. * VpcInstanceTokenManager: Token manager of VPC Instance Metadata Service API tokens. @@ -44,3 +45,4 @@ export { JwtTokenManager, JwtTokenManagerOptions } from './jwt-token-manager'; export { TokenManager, TokenManagerOptions } from './token-manager'; export { VpcInstanceTokenManager } from './vpc-instance-token-manager'; export { McspTokenManager } from './mcsp-token-manager'; +export { IamAssumeTokenManager } from './iam-assume-token-manager'; diff --git a/auth/utils/get-authenticator-from-environment.ts b/auth/utils/get-authenticator-from-environment.ts index 5601917c2..601587cc8 100644 --- a/auth/utils/get-authenticator-from-environment.ts +++ b/auth/utils/get-authenticator-from-environment.ts @@ -20,6 +20,7 @@ import { BearerTokenAuthenticator, CloudPakForDataAuthenticator, IamAuthenticator, + IamAssumeAuthenticator, ContainerAuthenticator, NoAuthAuthenticator, VpcInstanceAuthenticator, @@ -95,6 +96,8 @@ export function getAuthenticatorFromEnvironment(serviceName: string): Authentica authenticator = new CloudPakForDataAuthenticator(credentials); } else if (authType === Authenticator.AUTHTYPE_IAM.toLowerCase()) { authenticator = new IamAuthenticator(credentials); + } else if (authType === Authenticator.AUTHTYPE_IAM_ASSUME.toLowerCase()) { + authenticator = new IamAssumeAuthenticator(credentials); } else if (authType === Authenticator.AUTHTYPE_CONTAINER.toLowerCase()) { authenticator = new ContainerAuthenticator(credentials); } else if (authType === Authenticator.AUTHTYPE_VPC.toLowerCase()) { diff --git a/auth/utils/helpers.ts b/auth/utils/helpers.ts index 11963ea09..b98e8ab1e 100644 --- a/auth/utils/helpers.ts +++ b/auth/utils/helpers.ts @@ -1,5 +1,5 @@ /** - * (C) Copyright IBM Corp. 2019, 2022. + * (C) Copyright IBM Corp. 2019, 2024. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -91,20 +91,6 @@ export function getCurrentTime(): number { return Math.floor(Date.now() / 1000); } -/** - * Checks for only one of two elements being defined. - * Returns true if a is defined and b is undefined, - * or vice versa. Returns false if both are defined - * or both are undefined. - * - * @param a - The first object - * @param b - The second object - * @returns true if and only if exactly one of a or b is defined - */ -export function onlyOne(a: any, b: any): boolean { - return Boolean((a && !b) || (b && !a)); -} - /** * Removes a given suffix if it exists. * @@ -122,25 +108,49 @@ export function removeSuffix(str: string, suffix: string): string { } /** - * Checks for at least one of two elements being defined. + * Checks that exactly one of the arguments provided is defined. + * Returns true if one argument is defined. Returns false if no + * argument are defined or if 2 or more are defined. * - * @param a - the first object - * @param b - the second object - * @returns true if a or b is defined; false if both are undefined + * @param args - The spread of arguments to check + * @returns true if and only if exactly one argument is defined */ -export function atLeastOne(a: any, b: any): boolean { - return Boolean(a || b); +export function onlyOne(...args: any): boolean { + return countDefinedArgs(args) === 1; } /** - * Verifies that both properties are not specified. + * Checks for at least one of the given elements being defined. * - * @param a - The first object - * @param b - The second object + * @param args - The spread of arguments to check + * @returns true if one or more are defined; false if all are undefined + */ +export function atLeastOne(...args: any): boolean { + return countDefinedArgs(args) >= 1; +} + +/** + * Verifies that no more than one of the given elements are defined. + * Returns true if one or none are defined, and false otherwise. * - * @returns false if a and b are both defined, true otherwise + * @param args - The spread of arguments to check + * @returns false if more than one elements are defined, true otherwise + */ +export function atMostOne(...args: any): boolean { + return countDefinedArgs(args) <= 1; +} +/** + * Takes a list of anything (intended to be the arguments passed to one of the + * argument checking functions above) and returns how many elements in that + * list are not undefined. */ -export function atMostOne(a: any, b: any): boolean { - return Boolean(!(a && b)); +function countDefinedArgs(args: any[]) { + return args.reduce((total, arg) => { + if (arg) { + total += 1; + } + + return total; + }, 0); } diff --git a/etc/ibm-cloud-sdk-core.api.md b/etc/ibm-cloud-sdk-core.api.md index 5def7987a..a3b3e493c 100644 --- a/etc/ibm-cloud-sdk-core.api.md +++ b/etc/ibm-cloud-sdk-core.api.md @@ -13,10 +13,10 @@ import { OutgoingHttpHeaders } from 'http'; import { Stream } from 'stream'; // @public -export function atLeastOne(a: any, b: any): boolean; +export function atLeastOne(...args: any): boolean; // @public -export function atMostOne(a: any, b: any): boolean; +export function atMostOne(...args: any): boolean; // @public export class Authenticator implements AuthenticatorInterface { @@ -34,6 +34,8 @@ export class Authenticator implements AuthenticatorInterface { // (undocumented) static AUTHTYPE_IAM: string; // (undocumented) + static AUTHTYPE_IAM_ASSUME: string; + // (undocumented) static AUTHTYPE_MCSP: string; // (undocumented) static AUTHTYPE_NOAUTH: string; @@ -224,6 +226,24 @@ export function getNewLogger(moduleName: string): SDKLogger; // @public export function getQueryParam(urlStr: string, param: string): string; +// @public +export class IamAssumeAuthenticator extends IamRequestBasedAuthenticator { + // Warning: (ae-forgotten-export) The symbol "Options_14" needs to be exported by the entry point index.d.ts + constructor(options: Options_14); + authenticationType(): string; + // (undocumented) + protected tokenManager: IamAssumeTokenManager; +} + +// @public +export class IamAssumeTokenManager extends IamRequestBasedTokenManager { + // Warning: (ae-forgotten-export) The symbol "Options_13" needs to be exported by the entry point index.d.ts + constructor(options: Options_13); + protected requestToken(): Promise; + // (undocumented) + protected requiredOptions: string[]; +} + // @public export class IamAuthenticator extends IamRequestBasedAuthenticator { // Warning: (ae-forgotten-export) The symbol "Options_6" needs to be exported by the entry point index.d.ts @@ -343,7 +363,7 @@ export class NoAuthAuthenticator extends Authenticator { } // @public -export function onlyOne(a: any, b: any): boolean; +export function onlyOne(...args: any): boolean; // @public export const qs: { diff --git a/test/integration/iam-assume-authenticator.test.js b/test/integration/iam-assume-authenticator.test.js new file mode 100644 index 000000000..9daa113b1 --- /dev/null +++ b/test/integration/iam-assume-authenticator.test.js @@ -0,0 +1,49 @@ +/** + * (C) Copyright IBM Corp. 2024. + * + * 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. + */ + +const { getAuthenticatorFromEnvironment } = require('../../dist'); + +// Note: Only the unit tests are run by default. +// +// In order to test with a live IAM server, create file "iamassume.env" in the project root. +// It should look something like this: +// +// ASSUMETEST_AUTH_URL= e.g. https://iam.cloud.ibm.com +// ASSUMETEST_AUTH_TYPE=iamAssume +// ASSUMETEST_APIKEY= +// ASSUMETEST_IAM_PROFILE_ID= +// +// Then run this command from the project root: +// npm run jest test/integration/iam-assume-authenticator.test.js + +describe('IAM Assume Authenticator - Integration Test', () => { + process.env.IBM_CREDENTIALS_FILE = `${__dirname}/../../iamassume.env`; + + it('should retrieve an IAM access token successfully', async () => { + // Set up environment. + const authenticator = getAuthenticatorFromEnvironment('assumetest'); + + // Build a mock request. + const requestOptions = {}; + + // Authenticate the request. + await authenticator.authenticate(requestOptions); + + // Check for proper authentication. + expect(requestOptions.headers.Authorization).toBeDefined(); + expect(requestOptions.headers.Authorization.startsWith('Bearer')).toBe(true); + }); +}); diff --git a/test/unit/iam-assume-authenticator.test.js b/test/unit/iam-assume-authenticator.test.js new file mode 100644 index 000000000..699716e72 --- /dev/null +++ b/test/unit/iam-assume-authenticator.test.js @@ -0,0 +1,138 @@ +/** + * (C) Copyright IBM Corp. 2024. + * + * 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. + */ + +const { Authenticator, IamAssumeAuthenticator } = require('../../dist/auth'); +const { IamAssumeTokenManager } = require('../../dist/auth'); + +// Mock the `getToken` method in the token manager - dont make any rest calls. +const fakeToken = 'iam-acess-token'; +const mockedTokenManager = new IamAssumeTokenManager({ + apikey: 'some-key', + iamProfileId: 'some-id', +}); + +const getTokenSpy = jest + .spyOn(mockedTokenManager, 'getToken') + .mockImplementation(() => Promise.resolve(fakeToken)); + +describe('Container Authenticator', () => { + const config = { + apikey: 'some-key', + iamProfileId: 'some-id', + url: 'iam.staging.com', + clientId: 'my-id', + clientSecret: 'my-secret', + disableSslVerification: true, + headers: { + 'X-My-Header': 'some-value', + }, + scope: 'A B C D', + }; + + it('should store all config options on the class', () => { + const authenticator = new IamAssumeAuthenticator(config); + + expect(authenticator.authenticationType()).toEqual(Authenticator.AUTHTYPE_IAM_ASSUME); + 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); + expect(authenticator.scope).toEqual(config.scope); + + // Should also create a token manager. Note that the options like `iamProfileId` + // aren't stored in the authenticator, but in the token manager. + expect(authenticator.tokenManager).toBeInstanceOf(IamAssumeTokenManager); + expect(authenticator.tokenManager.iamProfileId).toBe(config.iamProfileId); + }); + + it('should throw an error when no api key is provided', () => { + expect(() => { + const unused = new IamAssumeAuthenticator(); + }).toThrow('Missing required parameters: apikey'); + }); + + it('should throw an error when no profile information is provided', () => { + expect(() => { + const unused = new IamAssumeAuthenticator({ apikey: config.apikey }); + }).toThrow( + 'Exactly one of `iamProfileName`, `iamProfileCrn`, or `iamProfileId` must be specified.' + ); + }); + + it('should throw an error when too much profile information is provided', () => { + expect(() => { + const unused = new IamAssumeAuthenticator({ + apikey: config.apikey, + iamProfileId: 'some-id', + iamProfileCrn: 'some-crn', + }); + }).toThrow( + 'Exactly one of `iamProfileName`, `iamProfileCrn`, or `iamProfileId` must be specified.' + ); + }); + + it('should throw an error when a profile name is provided without an account id', () => { + expect(() => { + const unused = new IamAssumeAuthenticator({ + apikey: config.apikey, + iamProfileName: 'some-id', + }); + }).toThrow('`iamProfileName` and `iamAccountId` must be provided together, or not at all'); + }); + + it('should throw an error when an account id is provided without a profile name', () => { + expect(() => { + const unused = new IamAssumeAuthenticator({ + apikey: config.apikey, + iamProfileId: 'some-id', + iamAccountId: 'some-account', + }); + }).toThrow('`iamProfileName` and `iamAccountId` must be provided together, or not at all'); + }); + + // "End to end" style test, to make sure this authenticator ingregates properly with parent classes. + it('should update the options and resolve with `null` when `authenticate` is called', async () => { + const authenticator = new IamAssumeAuthenticator({ + apikey: config.apikey, + iamProfileId: config.iamProfileId, + }); + + // Vverride the created token manager with the mocked one. + authenticator.tokenManager = mockedTokenManager; + + const options = { headers: { 'X-Some-Header': 'user-supplied header' } }; + const result = await authenticator.authenticate(options); + + expect(result).toBeUndefined(); + 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'); + }); + + it('should return the refresh token stored in the token manager', () => { + const token = 'some-token'; + const authenticator = new IamAssumeAuthenticator({ + apikey: config.apikey, + iamProfileId: config.iamProfileId, + }); + expect(authenticator.tokenManager.refreshToken).toBeUndefined(); + authenticator.tokenManager.refreshToken = token; + expect(authenticator.getRefreshToken()).toEqual(token); + }); +}); diff --git a/test/unit/iam-assume-token-manager.test.js b/test/unit/iam-assume-token-manager.test.js new file mode 100644 index 000000000..da92ae637 --- /dev/null +++ b/test/unit/iam-assume-token-manager.test.js @@ -0,0 +1,311 @@ +/* eslint-disable no-alert, no-console */ + +/** + * (C) Copyright IBM Corp. 2024. + * + * 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. + */ + +jest.mock('jsonwebtoken/decode'); +const decode = require('jsonwebtoken/decode'); + +decode.mockImplementation(() => ({ exp: 100, iat: 100 })); + +const path = require('path'); +const { IamAssumeTokenManager, IamTokenManager } = require('../../dist/auth'); +const { RequestWrapper } = require('../../dist/lib/request-wrapper'); +const { getRequestOptions } = require('./utils'); +const { getCurrentTime } = require('../../dist/auth/utils/helpers'); +const logger = require('../../dist/lib/logger').default; + +// make sure no actual requests are sent +jest.mock('../../dist/lib/request-wrapper'); + +const IAM_APIKEY = 'some-apikey'; +const IAM_PROFILE_NAME = 'some-name'; +const IAM_PROFILE_CRN = 'some-crn'; +const IAM_PROFILE_ID = 'some-id'; +const IAM_ACCOUNT_ID = 'some-account'; +const ACCESS_TOKEN = 'access-token'; +const OTHER_ACCESS_TOKEN = 'other-access-token'; +const IAM_RESPONSE = { + result: { + access_token: ACCESS_TOKEN, + token_type: 'Bearer', + expires_in: 3600, + expiration: getCurrentTime() + 3600, + }, + status: 200, +}; +const OTHER_IAM_RESPONSE = { + result: { + access_token: OTHER_ACCESS_TOKEN, + token_type: 'Bearer', + expires_in: 3600, + expiration: getCurrentTime() + 3600, + }, + status: 200, +}; + +describe('IAM Assume Token Manager', () => { + const sendRequestMock = jest.fn(); + sendRequestMock.mockResolvedValue(IAM_RESPONSE); + RequestWrapper.mockImplementation(() => ({ + sendRequest: sendRequestMock, + })); + afterAll(() => { + sendRequestMock.mockRestore(); + }); + + describe('constructor', () => { + it('should throw an error is no api key is provided', () => { + expect(() => new IamAssumeTokenManager()).toThrow('Missing required parameters: apikey'); + }); + + it('should throw an error is no profile information is provided', () => { + expect(() => new IamAssumeTokenManager({ apikey: 'some-key' })).toThrow( + 'Exactly one of `iamProfileName`, `iamProfileCrn`, or `iamProfileId` must be specified.' + ); + }); + + it('should throw an error if both profile name and id are provided', () => { + expect( + () => + new IamAssumeTokenManager({ + apikey: 'some-key', + iamProfileName: 'some-name', + iamProfileId: 'some-id', + }) + ).toThrow( + 'Exactly one of `iamProfileName`, `iamProfileCrn`, or `iamProfileId` must be specified.' + ); + }); + + it('should throw an error if both profile name and crn are provided', () => { + expect( + () => + new IamAssumeTokenManager({ + apikey: 'some-key', + iamProfileName: 'some-name', + iamProfileCrn: 'some-crn', + }) + ).toThrow( + 'Exactly one of `iamProfileName`, `iamProfileCrn`, or `iamProfileId` must be specified.' + ); + }); + + it('should throw an error if both profile crn and id are provided', () => { + expect( + () => + new IamAssumeTokenManager({ + apikey: 'some-key', + iamProfileId: 'some-id', + iamProfileCrn: 'some-crn', + }) + ).toThrow( + 'Exactly one of `iamProfileName`, `iamProfileCrn`, or `iamProfileId` must be specified.' + ); + }); + + it('should throw an error if profile name, crn, and id are all provided', () => { + expect( + () => + new IamAssumeTokenManager({ + apikey: 'some-key', + iamProfileName: 'some-name', + iamProfileId: 'some-id', + iamProfileCrn: 'some-crn', + }) + ).toThrow( + 'Exactly one of `iamProfileName`, `iamProfileCrn`, or `iamProfileId` must be specified.' + ); + }); + + it('should throw an error if profile name is provided without an account id', () => { + expect( + () => + new IamAssumeTokenManager({ + apikey: 'some-key', + iamProfileName: 'some-name', + }) + ).toThrow('`iamProfileName` and `iamAccountId` must be provided together, or not at all'); + }); + + it('should throw an error if account id is provided without a profile name', () => { + expect( + () => + new IamAssumeTokenManager({ + apikey: 'some-key', + iamProfileId: 'some-id', + iamAccountId: 'some-account', + }) + ).toThrow('`iamProfileName` and `iamAccountId` must be provided together, or not at all'); + }); + + it('should create an iam token manager delegate with the apikey', () => { + const instance = new IamAssumeTokenManager({ + apikey: IAM_APIKEY, + iamProfileId: IAM_PROFILE_ID, + }); + + expect(instance.iamDelegate).toBeInstanceOf(IamTokenManager); + expect(instance.iamDelegate.apikey).toBe(IAM_APIKEY); + }); + + it('should set given profile id', () => { + const instance = new IamAssumeTokenManager({ + apikey: IAM_APIKEY, + iamProfileId: IAM_PROFILE_ID, + }); + + expect(instance.iamProfileId).toBe(IAM_PROFILE_ID); + }); + + it('should set given profile crn', () => { + const instance = new IamAssumeTokenManager({ + apikey: IAM_APIKEY, + iamProfileCrn: IAM_PROFILE_CRN, + }); + + expect(instance.iamProfileCrn).toBe(IAM_PROFILE_CRN); + }); + + it('should set given profile name and account id', () => { + const instance = new IamAssumeTokenManager({ + apikey: IAM_APIKEY, + iamProfileName: IAM_PROFILE_NAME, + iamAccountId: IAM_ACCOUNT_ID, + }); + + expect(instance.iamProfileName).toBe(IAM_PROFILE_NAME); + expect(instance.iamAccountId).toBe(IAM_ACCOUNT_ID); + }); + + it('should initialize form data', () => { + const instance = new IamAssumeTokenManager({ + apikey: IAM_APIKEY, + iamProfileCrn: IAM_PROFILE_CRN, + }); + + const { formData } = instance; + expect(formData).toBeDefined(); + expect(formData.grant_type).toBe('urn:ibm:params:oauth:grant-type:assume'); + }); + + it('should initialize user agent field', () => { + const instance = new IamAssumeTokenManager({ + apikey: IAM_APIKEY, + iamProfileCrn: IAM_PROFILE_CRN, + }); + + expect(instance.userAgent).toMatch('iam-assume-authenticator'); + }); + }); + + describe('requestToken', () => { + it('should add profile id to the form data', async () => { + const instance = new IamAssumeTokenManager({ + apikey: IAM_APIKEY, + iamProfileId: IAM_PROFILE_ID, + }); + + await instance.requestToken(); + + // Also, ensure we set the access token we received from the IAM token manager. + expect(instance.formData.access_token).toBe(ACCESS_TOKEN); + expect(instance.formData.profile_id).toBe(IAM_PROFILE_ID); + + // Expect other profile information to not be set. + expect(instance.formData.profile_crn).toBeUndefined(); + expect(instance.formData.profile_name).toBeUndefined(); + expect(instance.formData.account).toBeUndefined(); + }); + + it('should add profile crn to the form data', async () => { + const instance = new IamAssumeTokenManager({ + apikey: IAM_APIKEY, + iamProfileCrn: IAM_PROFILE_CRN, + }); + + await instance.requestToken(); + + expect(instance.formData.profile_crn).toBe(IAM_PROFILE_CRN); + expect(instance.formData.access_token).toBe(ACCESS_TOKEN); + + // Expect other profile information to not be set. + expect(instance.formData.profile_id).toBeUndefined(); + expect(instance.formData.profile_name).toBeUndefined(); + expect(instance.formData.account).toBeUndefined(); + }); + + it('should add profile name and account id to the form data', async () => { + const instance = new IamAssumeTokenManager({ + apikey: IAM_APIKEY, + iamProfileName: IAM_PROFILE_NAME, + iamAccountId: IAM_ACCOUNT_ID, + }); + + await instance.requestToken(); + + expect(instance.formData.profile_name).toBe(IAM_PROFILE_NAME); + expect(instance.formData.account).toBe(IAM_ACCOUNT_ID); + expect(instance.formData.access_token).toBe(ACCESS_TOKEN); + + // Expect other profile information to not be set. + expect(instance.formData.profile_crn).toBeUndefined(); + expect(instance.formData.profile_id).toBeUndefined(); + }); + + it('should set User-Agent header', async () => { + const instance = new IamAssumeTokenManager({ + apikey: IAM_APIKEY, + iamProfileCrn: IAM_PROFILE_CRN, + }); + + await instance.requestToken(); + + // The first request (index 0) will be the IAM token manager's request. + // Verify it is called as well. + const iamRequestOptions = getRequestOptions(sendRequestMock, 0); + expect(iamRequestOptions.headers).toBeDefined(); + expect(iamRequestOptions.headers['User-Agent']).toMatch( + /^ibm-node-sdk-core\/iam-authenticator.*$/ + ); + + // Then, look at the second request (index 1) to see the + // agent for the IAM Assume token manager. + const assumeRequestOptions = getRequestOptions(sendRequestMock, 1); + expect(assumeRequestOptions.headers).toBeDefined(); + expect(assumeRequestOptions.headers['User-Agent']).toMatch( + /^ibm-node-sdk-core\/iam-assume-authenticator.*$/ + ); + }); + + it('use getToken to invoke requestToken', async () => { + // Verify we're seeing two different results - one from the IAM + // token manager and one from the IAM Assume token manager. + sendRequestMock.mockResolvedValueOnce(IAM_RESPONSE); + sendRequestMock.mockResolvedValueOnce(OTHER_IAM_RESPONSE); + + const instance = new IamAssumeTokenManager({ + apikey: IAM_APIKEY, + iamProfileName: IAM_PROFILE_NAME, + iamAccountId: IAM_ACCOUNT_ID, + }); + + const accessToken = await instance.getToken(); + + expect(accessToken).toBe(OTHER_ACCESS_TOKEN); + }); + }); +});