From df85f1516a6c1d5c7884f2614dc70404709c4423 Mon Sep 17 00:00:00 2001 From: Phil Adams Date: Tue, 15 Oct 2024 09:12:13 -0500 Subject: [PATCH] feat(IamAssumeAuthenticator): introduce new authenticator type (#229) * feat(IamAssumeAuthenticator): introduce new authenticator type 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: Phil Adams --- .secrets.baseline | 234 +++++++-- Authentication.md | 161 +++++- README.md | 3 +- core/authenticator_factory.go | 2 + core/authenticator_factory_test.go | 32 +- core/common_test.go | 5 + core/constants.go | 3 + core/iam_assume_authenticator.go | 519 +++++++++++++++++++ core/iam_assume_authenticator_test.go | 702 ++++++++++++++++++++++++++ resources/my-credentials.env | 6 + 10 files changed, 1611 insertions(+), 56 deletions(-) create mode 100644 core/iam_assume_authenticator.go create mode 100644 core/iam_assume_authenticator_test.go diff --git a/.secrets.baseline b/.secrets.baseline index 8602e32..0496cbe 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -3,7 +3,7 @@ "files": "go.sum|package-lock.json|^.secrets.baseline$", "lines": null }, - "generated_at": "2024-07-29T15:43:37Z", + "generated_at": "2024-10-09T21:09:28Z", "plugins_used": [ { "name": "AWSKeyDetector" @@ -82,7 +82,7 @@ "hashed_secret": "91dfd9ddb4198affc5c194cd8ce6d338fde470e2", "is_secret": false, "is_verified": false, - "line_number": 82, + "line_number": 83, "type": "Secret Keyword", "verified_result": null }, @@ -90,7 +90,39 @@ "hashed_secret": "e0d246cf37df7d1a561ed649d108dd14f36f28bf", "is_secret": false, "is_verified": false, - "line_number": 242, + "line_number": 243, + "type": "Secret Keyword", + "verified_result": null + }, + { + "hashed_secret": "4f51cde3ac0a5504afa4bc06859b098366592c19", + "is_secret": false, + "is_verified": false, + "line_number": 312, + "type": "Secret Keyword", + "verified_result": null + }, + { + "hashed_secret": "e87559ed7decb62d0733ae251ae58d42a55291d8", + "is_secret": false, + "is_verified": false, + "line_number": 314, + "type": "Secret Keyword", + "verified_result": null + }, + { + "hashed_secret": "12f4a68ed3d0863e56497c9cdb1e2e4e91d5cb68", + "is_secret": false, + "is_verified": false, + "line_number": 381, + "type": "Secret Keyword", + "verified_result": null + }, + { + "hashed_secret": "c837b75d7cd93ef9c2243ca28d6e5156259fd253", + "is_secret": false, + "is_verified": false, + "line_number": 385, "type": "Secret Keyword", "verified_result": null }, @@ -98,7 +130,7 @@ "hashed_secret": "98635b2eaa2379f28cd6d72a38299f286b81b459", "is_secret": false, "is_verified": false, - "line_number": 549, + "line_number": 694, "type": "Secret Keyword", "verified_result": null }, @@ -106,7 +138,7 @@ "hashed_secret": "47fcf185ee7e15fe05cae31fbe9e4ebe4a06a40d", "is_secret": false, "is_verified": false, - "line_number": 692, + "line_number": 837, "type": "Secret Keyword", "verified_result": null } @@ -116,7 +148,7 @@ "hashed_secret": "bc2f74c22f98f7b6ffbc2f67453dbfa99bce9a32", "is_secret": false, "is_verified": false, - "line_number": 849, + "line_number": 857, "type": "Secret Keyword", "verified_result": null } @@ -126,7 +158,7 @@ "hashed_secret": "1f5e25be9b575e9f5d39c82dfd1d9f4d73f1975c", "is_secret": false, "is_verified": false, - "line_number": 1249, + "line_number": 1289, "type": "Secret Keyword", "verified_result": null }, @@ -134,7 +166,7 @@ "hashed_secret": "84ba4ce8a59ed2d6e90726d57cdc4a927d3672b2", "is_secret": false, "is_verified": false, - "line_number": 1486, + "line_number": 1532, "type": "Secret Keyword", "verified_result": null }, @@ -142,7 +174,7 @@ "hashed_secret": "62cdb7020ff920e5aa642c3d4066950dd1f01f4d", "is_secret": false, "is_verified": false, - "line_number": 1529, + "line_number": 1578, "type": "Secret Keyword", "verified_result": null }, @@ -150,7 +182,7 @@ "hashed_secret": "ec7ec9d8ff520250fd5ca955c6474c6d70022407", "is_secret": false, "is_verified": false, - "line_number": 1537, + "line_number": 1586, "type": "JSON Web Token", "verified_result": null }, @@ -158,7 +190,7 @@ "hashed_secret": "40ce4379f5763c05b71c88f9a371809fdbce6a21", "is_secret": false, "is_verified": false, - "line_number": 1631, + "line_number": 1683, "type": "Secret Keyword", "verified_result": null }, @@ -166,7 +198,7 @@ "hashed_secret": "9addbf544119efa4a64223b649750a510f0d463f", "is_secret": false, "is_verified": false, - "line_number": 1657, + "line_number": 1710, "type": "Secret Keyword", "verified_result": null } @@ -176,7 +208,7 @@ "hashed_secret": "62cdb7020ff920e5aa642c3d4066950dd1f01f4d", "is_secret": false, "is_verified": false, - "line_number": 69, + "line_number": 75, "type": "Secret Keyword", "verified_result": null }, @@ -184,7 +216,7 @@ "hashed_secret": "1f5e25be9b575e9f5d39c82dfd1d9f4d73f1975c", "is_secret": false, "is_verified": false, - "line_number": 93, + "line_number": 99, "type": "Secret Keyword", "verified_result": null }, @@ -192,7 +224,7 @@ "hashed_secret": "edbd5e119f94badb9f99a67ac6ff4c7a5204ad61", "is_secret": false, "is_verified": false, - "line_number": 100, + "line_number": 106, "type": "Secret Keyword", "verified_result": null }, @@ -200,7 +232,7 @@ "hashed_secret": "84ba4ce8a59ed2d6e90726d57cdc4a927d3672b2", "is_secret": false, "is_verified": false, - "line_number": 107, + "line_number": 113, "type": "Secret Keyword", "verified_result": null } @@ -255,18 +287,18 @@ "verified_result": null }, { - "hashed_secret": "333f0f8814d63e7268f80e1e65e7549137d2350c", + "hashed_secret": "f2e7745f43b0ef0e2c2faf61d6c6a28be2965750", "is_secret": false, "is_verified": false, - "line_number": 87, + "line_number": 91, "type": "Secret Keyword", "verified_result": null }, { - "hashed_secret": "f2e7745f43b0ef0e2c2faf61d6c6a28be2965750", + "hashed_secret": "333f0f8814d63e7268f80e1e65e7549137d2350c", "is_secret": false, "is_verified": false, - "line_number": 91, + "line_number": 95, "type": "Secret Keyword", "verified_result": null } @@ -276,7 +308,7 @@ "hashed_secret": "fed915afaba64ebcdfeb805d59ea09a33275c423", "is_secret": false, "is_verified": false, - "line_number": 159, + "line_number": 163, "type": "Secret Keyword", "verified_result": null }, @@ -284,7 +316,7 @@ "hashed_secret": "c1bd026029d704c1543f56c9b0817395bec76165", "is_secret": false, "is_verified": false, - "line_number": 163, + "line_number": 167, "type": "Secret Keyword", "verified_result": null } @@ -294,7 +326,7 @@ "hashed_secret": "1e95707b2d2cc9086c651c60bb323bb85522b334", "is_secret": false, "is_verified": false, - "line_number": 150, + "line_number": 159, "type": "Secret Keyword", "verified_result": null }, @@ -302,7 +334,7 @@ "hashed_secret": "8f36b0b2a722f2194f977507740562b96011c312", "is_secret": false, "is_verified": false, - "line_number": 250, + "line_number": 265, "type": "Secret Keyword", "verified_result": null } @@ -320,7 +352,7 @@ "hashed_secret": "d4c3d66fd0c38547a3c7a4c6bdc29c36911bc030", "is_secret": false, "is_verified": false, - "line_number": 46, + "line_number": 47, "type": "Secret Keyword", "verified_result": null }, @@ -328,7 +360,7 @@ "hashed_secret": "8318df9ecda039deac9868adf1944a29a95c7114", "is_secret": false, "is_verified": false, - "line_number": 49, + "line_number": 50, "type": "Secret Keyword", "verified_result": null } @@ -356,7 +388,7 @@ "hashed_secret": "c8f0df25bade89c1873f5f01b85bcfb921443ac6", "is_secret": false, "is_verified": false, - "line_number": 41, + "line_number": 42, "type": "JSON Web Token", "verified_result": null }, @@ -364,7 +396,7 @@ "hashed_secret": "f0048c1e535178d8ba9760fd4139c2554ac53d99", "is_secret": false, "is_verified": false, - "line_number": 225, + "line_number": 226, "type": "Secret Keyword", "verified_result": null }, @@ -372,7 +404,7 @@ "hashed_secret": "d16fe0356edbf4177de06fc6cb5122837d5cd203", "is_secret": false, "is_verified": false, - "line_number": 243, + "line_number": 244, "type": "Secret Keyword", "verified_result": null }, @@ -380,7 +412,7 @@ "hashed_secret": "10ef99be8df801b05b5933e121e85385edf6b98a", "is_secret": false, "is_verified": false, - "line_number": 666, + "line_number": 667, "type": "Secret Keyword", "verified_result": null } @@ -398,7 +430,7 @@ "hashed_secret": "84ed7427f222c7a1f43567e1bb3058365a81bbcb", "is_secret": false, "is_verified": false, - "line_number": 304, + "line_number": 307, "type": "Secret Keyword", "verified_result": null }, @@ -406,7 +438,7 @@ "hashed_secret": "d4a9d12d425a0edaf333f49c6004b6d417eeb87b", "is_secret": false, "is_verified": false, - "line_number": 305, + "line_number": 308, "type": "Secret Keyword", "verified_result": null } @@ -495,12 +527,128 @@ "verified_result": null } ], + "core/iam_assume_authenticator.go": [ + { + "hashed_secret": "d7c931824fedea3f78d340f1b8fda515c70feb7a", + "is_secret": false, + "is_verified": false, + "line_number": 89, + "type": "Secret Keyword", + "verified_result": null + }, + { + "hashed_secret": "8b142a91cfb6e617618ad437cedf74a6745f8926", + "is_secret": false, + "is_verified": false, + "line_number": 152, + "type": "Secret Keyword", + "verified_result": null + }, + { + "hashed_secret": "405cb38253c31450648e72b9baa9b65520e2611d", + "is_secret": false, + "is_verified": false, + "line_number": 219, + "type": "Secret Keyword", + "verified_result": null + }, + { + "hashed_secret": "1e6a4b0c5125198267f34138a57367b2d9bbc0ed", + "is_secret": false, + "is_verified": false, + "line_number": 221, + "type": "Secret Keyword", + "verified_result": null + } + ], + "core/iam_assume_authenticator_test.go": [ + { + "hashed_secret": "fd08cd887ed1de2f2d3e175117ff607ca65187ae", + "is_secret": false, + "is_verified": false, + "line_number": 38, + "type": "Secret Keyword", + "verified_result": null + }, + { + "hashed_secret": "1f7e33de15e22de9d2eaf502df284ed25ca40018", + "is_secret": false, + "is_verified": false, + "line_number": 40, + "type": "Secret Keyword", + "verified_result": null + }, + { + "hashed_secret": "d6b642ea800b498549bc60e32c1f9bd8e66e905c", + "is_secret": false, + "is_verified": false, + "line_number": 43, + "type": "JSON Web Token", + "verified_result": null + }, + { + "hashed_secret": "c9278bb515f6e7c65c02339c0aa9ea7c86f6b8de", + "is_secret": false, + "is_verified": false, + "line_number": 44, + "type": "JSON Web Token", + "verified_result": null + }, + { + "hashed_secret": "ad32cebed1e18a69fa35a39996f088fcb969c6ca", + "is_secret": false, + "is_verified": false, + "line_number": 45, + "type": "JSON Web Token", + "verified_result": null + }, + { + "hashed_secret": "f8daac1d1753f216f96a2a36a5635bd774059dc6", + "is_secret": false, + "is_verified": false, + "line_number": 46, + "type": "JSON Web Token", + "verified_result": null + }, + { + "hashed_secret": "1f5e25be9b575e9f5d39c82dfd1d9f4d73f1975c", + "is_secret": false, + "is_verified": false, + "line_number": 214, + "type": "Secret Keyword", + "verified_result": null + }, + { + "hashed_secret": "99bf13055833bfc07c808d9a11a1f974403ca285", + "is_secret": false, + "is_verified": false, + "line_number": 233, + "type": "Secret Keyword", + "verified_result": null + }, + { + "hashed_secret": "3624c26c4d2524f1ecbeb1a6b2ccdd2ebea7398b", + "is_secret": false, + "is_verified": false, + "line_number": 238, + "type": "Secret Keyword", + "verified_result": null + }, + { + "hashed_secret": "32e8612d8ca77c7ea8374aa7918db8e5df9252ed", + "is_secret": false, + "is_verified": false, + "line_number": 653, + "type": "Secret Keyword", + "verified_result": null + } + ], "core/iam_authenticator.go": [ { "hashed_secret": "7a5d27bcb7a1e98b6e1bfca4df223ed578a47283", "is_secret": false, "is_verified": false, - "line_number": 97, + "line_number": 99, "type": "Secret Keyword", "verified_result": null }, @@ -508,7 +656,7 @@ "hashed_secret": "c2df5d3d760ff42f33fb38e2534d4c1b7ddde3ab", "is_secret": false, "is_verified": false, - "line_number": 97, + "line_number": 99, "type": "Secret Keyword", "verified_result": null }, @@ -516,7 +664,7 @@ "hashed_secret": "8b142a91cfb6e617618ad437cedf74a6745f8926", "is_secret": false, "is_verified": false, - "line_number": 137, + "line_number": 139, "type": "Secret Keyword", "verified_result": null } @@ -642,7 +790,7 @@ "hashed_secret": "347cd9c53ff77d41a7b22aa56c7b4efaf54658e3", "is_secret": false, "is_verified": false, - "line_number": 296, + "line_number": 300, "type": "Secret Keyword", "verified_result": null } @@ -676,7 +824,7 @@ "hashed_secret": "65e496a8c40e0364f378688b5e612a2386ad38d1", "is_secret": false, "is_verified": false, - "line_number": 647, + "line_number": 651, "type": "Secret Keyword", "verified_result": null }, @@ -684,7 +832,7 @@ "hashed_secret": "4c809455939f19c33c732b56a8417e509f4885e8", "is_secret": false, "is_verified": false, - "line_number": 648, + "line_number": 652, "type": "Secret Keyword", "verified_result": null }, @@ -692,7 +840,7 @@ "hashed_secret": "32e8612d8ca77c7ea8374aa7918db8e5df9252ed", "is_secret": false, "is_verified": false, - "line_number": 670, + "line_number": 674, "type": "Secret Keyword", "verified_result": null } @@ -702,7 +850,7 @@ "hashed_secret": "0266262f439c732a31b9353ced05c9e777a07c54", "is_secret": false, "is_verified": false, - "line_number": 664, + "line_number": 665, "type": "Secret Keyword", "verified_result": null }, @@ -710,7 +858,7 @@ "hashed_secret": "dda38b7b4fbbf4824f4154c855a49d2e3bae6dd1", "is_secret": false, "is_verified": false, - "line_number": 672, + "line_number": 673, "type": "Secret Keyword", "verified_result": null } @@ -820,7 +968,7 @@ "hashed_secret": "f2e7745f43b0ef0e2c2faf61d6c6a28be2965750", "is_secret": false, "is_verified": false, - "line_number": 90, + "line_number": 95, "type": "Secret Keyword", "verified_result": null }, @@ -828,7 +976,7 @@ "hashed_secret": "9e2659aa7e2b335ec6bdcf180f3b6f41f5191af5", "is_secret": false, "is_verified": false, - "line_number": 96, + "line_number": 102, "type": "Secret Keyword", "verified_result": null } diff --git a/Authentication.md b/Authentication.md index f1a62b6..a63ce49 100644 --- a/Authentication.md +++ b/Authentication.md @@ -2,7 +2,8 @@ The go-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 @@ -181,7 +182,7 @@ authenticator type is intended for situations in which the application will be m token itself in terms of initial acquisition and refreshing as needed. -## Identity and Access Management (IAM) Authentication +## Identity and Access Management (IAM) Authentication (grant type: apikey) The `IamAuthenticator` will accept a user-supplied apikey or refresh token and will perform the necessary interactions with the IAM token service to obtain a suitable bearer token for the specified apikey or refresh token. The authenticator will also obtain @@ -223,7 +224,7 @@ 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 @@ -304,6 +305,150 @@ if err != nil { ``` +## 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 IAMAccountID 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. + +- Client: (Optional) The `http.Client` object used to invoke token service requests. If not specified +by the user, a suitable default Client will be constructed. + +### 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 +```go +import { + "github.com/IBM/go-sdk-core/v5/core" + "/exampleservicev1" +} +... +// Create the authenticator. +authenticator, err := core.NewIamAssumeAuthenticatorBuilder(). + SetApiKey("myapikey"). + SetIAMProfileID("myprofile-1"). + Build() +if err != nil { + panic(err) +} + +// Create the service options struct. +options := &exampleservicev1.ExampleServiceV1Options{ + Authenticator: authenticator, +} + +// Construct the service instance. +service, err := exampleservicev1.NewExampleServiceV1(options) +if err != nil { + panic(err) +} + +// '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: +```go +import { + "/exampleservicev1" +} +... + +// Create the service options struct. +options := &exampleservicev1.ExampleServiceV1Options{ + ServiceName: "example_service", +} + +// Construct the service instance. +service, err := exampleservicev1.NewExampleServiceV1UsingExternalConfig(options) +if err != nil { + panic(err) +} + +// '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) @@ -312,7 +457,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 @@ -362,7 +507,7 @@ 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 @@ -436,7 +581,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 @@ -552,7 +697,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 @@ -640,7 +785,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/README.md b/README.md index da16a57..3a14acf 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,8 @@ go get -u github.com/IBM/go-sdk-core/... The go-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 diff --git a/core/authenticator_factory.go b/core/authenticator_factory.go index cd1a63c..586e38d 100644 --- a/core/authenticator_factory.go +++ b/core/authenticator_factory.go @@ -53,6 +53,8 @@ func GetAuthenticatorFromEnvironment(credentialKey string) (authenticator Authen authenticator, err = newBearerTokenAuthenticatorFromMap(properties) } else if strings.EqualFold(authType, AUTHTYPE_IAM) { authenticator, err = newIamAuthenticatorFromMap(properties) + } else if strings.EqualFold(authType, AUTHTYPE_IAM_ASSUME) { + authenticator, err = newIamAssumeAuthenticatorFromMap(properties) } else if strings.EqualFold(authType, AUTHTYPE_CONTAINER) { authenticator, err = newContainerAuthenticatorFromMap(properties) } else if strings.EqualFold(authType, AUTHTYPE_VPC) { diff --git a/core/authenticator_factory_test.go b/core/authenticator_factory_test.go index fb9ca16..c9d6b48 100644 --- a/core/authenticator_factory_test.go +++ b/core/authenticator_factory_test.go @@ -1,4 +1,4 @@ -//go:build all || fast +//go:build all || slow || auth package core @@ -27,9 +27,9 @@ var ( authFactoryTestLogLevel LogLevel = LevelError ) -// Note: the following functions are used from the config_utils_test.go file: -// setTestEnvironment() -// setTestVCAP() +// Note: the following functions are used from other files: +// setTestEnvironment() (common_test.go) +// setTestVCAP() (config_utils_test.go) func TestGetAuthenticatorFromEnvironment1(t *testing.T) { GetLogger().SetLogLevel(authFactoryTestLogLevel) @@ -136,6 +136,19 @@ func TestGetAuthenticatorFromEnvironment1(t *testing.T) { assert.Equal(t, "my-api-key", mcspAuth.ApiKey) assert.Equal(t, "https://mcsp.ibm.com", mcspAuth.URL) assert.True(t, mcspAuth.DisableSSLVerification) + + // Iam Assume Authenticator. + authenticator, err = GetAuthenticatorFromEnvironment("service11") + assert.Nil(t, err) + assert.NotNil(t, authenticator) + assert.Equal(t, AUTHTYPE_IAM_ASSUME, authenticator.AuthenticationType()) + iamAssume, ok := authenticator.(*IamAssumeAuthenticator) + assert.True(t, ok) + assert.NotNil(t, iamAssume) + assert.Equal(t, "my-api-key", iamAssume.iamDelegate.ApiKey) + assert.Equal(t, "iam-profile-1", iamAssume.iamProfileID) + assert.Equal(t, "https://iamassume.ibm.com", iamAssume.url) + assert.True(t, iamAssume.disableSSLVerification) } func TestGetAuthenticatorFromEnvironment2(t *testing.T) { @@ -236,6 +249,17 @@ func TestGetAuthenticatorFromEnvironment2(t *testing.T) { assert.Equal(t, "my-api-key", mcspAuth.ApiKey) assert.Equal(t, "https://mcsp.ibm.com", mcspAuth.URL) assert.True(t, mcspAuth.DisableSSLVerification) + + authenticator, err = GetAuthenticatorFromEnvironment("service15") + assert.Nil(t, err) + assert.NotNil(t, authenticator) + assert.Equal(t, AUTHTYPE_IAM_ASSUME, authenticator.AuthenticationType()) + iamAssume, ok := authenticator.(*IamAssumeAuthenticator) + assert.True(t, ok) + assert.NotNil(t, iamAssume) + assert.Equal(t, "my-apikey", iamAssume.iamDelegate.ApiKey) + assert.Equal(t, "https://iam.assume.ibm.com", iamAssume.url) + assert.False(t, iamAssume.disableSSLVerification) } func TestGetAuthenticatorFromEnvironment3(t *testing.T) { diff --git a/core/common_test.go b/core/common_test.go index 0ab5ef7..89e553f 100644 --- a/core/common_test.go +++ b/core/common_test.go @@ -90,6 +90,11 @@ var testEnvironment = map[string]string{ "SERVICE14_AUTH_URL": "https://mcsp.ibm.com", "SERVICE14_APIKEY": "my-api-key", "SERVICE14_AUTH_DISABLE_SSL": "true", + "SERVICE15_AUTH_URL": "https://iam.assume.ibm.com", + "SERVICE15_AUTH_TYPE": "IAMAssUME", + "SERVICE15_APIKEY": "my-apikey", + "SERVICE15_IAM_PROFILE_NAME": "profile-1", + "SERVICE15_IAM_ACCOUNT_ID": "account-1", } // setTestEnvironment sets the environment variables described in our map. diff --git a/core/constants.go b/core/constants.go index 9be7178..b6f5a7e 100644 --- a/core/constants.go +++ b/core/constants.go @@ -20,6 +20,7 @@ const ( AUTHTYPE_BEARER_TOKEN = "bearerToken" AUTHTYPE_NOAUTH = "noAuth" AUTHTYPE_IAM = "iam" + AUTHTYPE_IAM_ASSUME = "iamAssume" AUTHTYPE_CP4D = "cp4d" AUTHTYPE_CONTAINER = "container" AUTHTYPE_VPC = "vpc" @@ -52,6 +53,7 @@ const ( PROPNAME_IAM_PROFILE_CRN = "IAM_PROFILE_CRN" PROPNAME_IAM_PROFILE_NAME = "IAM_PROFILE_NAME" PROPNAME_IAM_PROFILE_ID = "IAM_PROFILE_ID" + PROPNAME_IAM_ACCOUNT_ID = "IAM_ACCOUNT_ID" // SSL error SSL_CERTIFICATION_ERROR = "x509: certificate" @@ -83,6 +85,7 @@ const ( ERRORMSG_IAM_GETTOKEN_ERROR = "IAM 'get token' error, status code %d received from '%s': %s" // #nosec G101 ERRORMSG_UNABLE_RETRIEVE_IITOKEN = "unable to retrieve instance identity token value: %s" // #nosec G101 ERRORMSG_VPCMDS_OPERATION_ERROR = "VPC metadata service error, status code %d received from '%s': %s" + ERRORMSG_ACCOUNTID_PROP_ERROR = "IAMAccountID must be specified if and only if IAMProfileName is specified" // The name of this module - matches the value in the go.mod file. MODULE_NAME = "github.com/IBM/go-sdk-core/v5" diff --git a/core/iam_assume_authenticator.go b/core/iam_assume_authenticator.go new file mode 100644 index 0000000..0132b41 --- /dev/null +++ b/core/iam_assume_authenticator.go @@ -0,0 +1,519 @@ +package core + +// (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 ( + "crypto/tls" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httputil" + "strconv" + "strings" + "sync" + "time" +) + +// 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 resulting IAM access token is added to each outbound request +// in an Authorization header of the form: +// +// Authorization: Bearer +type IamAssumeAuthenticator struct { + + // 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 URL representing the IAM token server's endpoint; If not specified, + // a suitable default value will be used [optional]. + url string + urlInit sync.Once + + // A flag that indicates whether verification of the server's SSL certificate + // should be disabled; defaults to false [optional]. + disableSSLVerification bool + + // A set of key/value pairs that will be sent as HTTP headers in requests + // made to the token server [optional]. + headers map[string]string + + // The http.Client object used to invoke token server requests. + // If not specified by the user, a suitable default Client will be constructed [optional]. + client *http.Client + clientInit sync.Once + + // The User-Agent header value to be included with each token request. + userAgent string + userAgentInit sync.Once + + // The cached token and expiration time. + tokenData *iamTokenData + + // Mutex to make the tokenData field thread safe. + tokenDataMutex sync.Mutex + + // An IamAuthenticator instance used to obtain the user's IAM access token from the apikey. + iamDelegate *IamAuthenticator +} + +const ( + iamGrantTypeAssume = "urn:ibm:params:oauth:grant-type:assume" +) + +var ( + iamAssumeRequestTokenMutex sync.Mutex +) + +// IamAssumeAuthenticatorBuilder is used to construct an IamAssumeAuthenticator instance. +type IamAssumeAuthenticatorBuilder struct { + + // Properties needed to construct an IamAuthenticator instance. + IamAuthenticator + + // Properties needed to construct an IamAssumeAuthenticator instance. + IamAssumeAuthenticator +} + +// NewIamAssumeAuthenticatorBuilder returns a new builder struct that +// can be used to construct an IamAssumeAuthenticator instance. +func NewIamAssumeAuthenticatorBuilder() *IamAssumeAuthenticatorBuilder { + return &IamAssumeAuthenticatorBuilder{} +} + +// SetIAMProfileID sets the iamProfileID field in the builder. +func (builder *IamAssumeAuthenticatorBuilder) SetIAMProfileID(s string) *IamAssumeAuthenticatorBuilder { + builder.IamAssumeAuthenticator.iamProfileID = s + return builder +} + +// SetIAMProfileCRN sets the iamProfileCRN field in the builder. +func (builder *IamAssumeAuthenticatorBuilder) SetIAMProfileCRN(s string) *IamAssumeAuthenticatorBuilder { + builder.IamAssumeAuthenticator.iamProfileCRN = s + return builder +} + +// SetIAMProfileName sets the iamProfileName field in the builder. +func (builder *IamAssumeAuthenticatorBuilder) SetIAMProfileName(s string) *IamAssumeAuthenticatorBuilder { + builder.IamAssumeAuthenticator.iamProfileName = s + return builder +} + +// SetIAMAccountID sets the iamAccountID field in the builder. +func (builder *IamAssumeAuthenticatorBuilder) SetIAMAccountID(s string) *IamAssumeAuthenticatorBuilder { + builder.IamAssumeAuthenticator.iamAccountID = s + return builder +} + +// SetApiKey sets the ApiKey field in the builder. +func (builder *IamAssumeAuthenticatorBuilder) SetApiKey(s string) *IamAssumeAuthenticatorBuilder { + builder.IamAuthenticator.ApiKey = s + return builder +} + +// SetURL sets the url field in the builder. +func (builder *IamAssumeAuthenticatorBuilder) SetURL(s string) *IamAssumeAuthenticatorBuilder { + builder.IamAuthenticator.URL = s + builder.IamAssumeAuthenticator.url = s + return builder +} + +// SetClientIDSecret sets the ClientId and ClientSecret fields in the builder. +func (builder *IamAssumeAuthenticatorBuilder) SetClientIDSecret(clientID, clientSecret string) *IamAssumeAuthenticatorBuilder { + builder.IamAuthenticator.ClientId = clientID + builder.IamAuthenticator.ClientSecret = clientSecret + return builder +} + +// SetDisableSSLVerification sets the DisableSSLVerification field in the builder. +func (builder *IamAssumeAuthenticatorBuilder) SetDisableSSLVerification(b bool) *IamAssumeAuthenticatorBuilder { + builder.IamAuthenticator.DisableSSLVerification = b + builder.IamAssumeAuthenticator.disableSSLVerification = b + return builder +} + +// SetScope sets the Scope field in the builder. +func (builder *IamAssumeAuthenticatorBuilder) SetScope(s string) *IamAssumeAuthenticatorBuilder { + builder.IamAuthenticator.Scope = s + return builder +} + +// SetHeaders sets the Headers field in the builder. +func (builder *IamAssumeAuthenticatorBuilder) SetHeaders(headers map[string]string) *IamAssumeAuthenticatorBuilder { + builder.IamAuthenticator.Headers = headers + builder.IamAssumeAuthenticator.headers = headers + return builder +} + +// SetClient sets the Client field in the builder. +func (builder *IamAssumeAuthenticatorBuilder) SetClient(client *http.Client) *IamAssumeAuthenticatorBuilder { + builder.IamAuthenticator.Client = client + builder.IamAssumeAuthenticator.client = client + return builder +} + +// Build() returns a validated instance of the IamAssumeAuthenticator with the config that was set in the builder. +func (builder *IamAssumeAuthenticatorBuilder) Build() (*IamAssumeAuthenticator, error) { + err := builder.IamAuthenticator.Validate() + if err != nil { + return nil, RepurposeSDKProblem(err, "validation-failed") + } + + err = builder.IamAssumeAuthenticator.Validate() + if err != nil { + return nil, RepurposeSDKProblem(err, "validation-failed") + } + + // If we passed validation, then save our IamAuthenticator instance. + builder.IamAssumeAuthenticator.iamDelegate = &builder.IamAuthenticator + + return &builder.IamAssumeAuthenticator, nil +} + +// NewBuilder returns an IamAssumeAuthenticatorBuilder instance configured with the contents of "authenticator". +func (authenticator *IamAssumeAuthenticator) NewBuilder() *IamAssumeAuthenticatorBuilder { + builder := &IamAssumeAuthenticatorBuilder{} + + builder.IamAssumeAuthenticator.iamProfileCRN = authenticator.iamProfileCRN + builder.IamAssumeAuthenticator.iamProfileID = authenticator.iamProfileID + builder.IamAssumeAuthenticator.iamProfileName = authenticator.iamProfileName + builder.IamAssumeAuthenticator.iamAccountID = authenticator.iamAccountID + builder.IamAssumeAuthenticator.url = authenticator.url + builder.IamAssumeAuthenticator.headers = authenticator.headers + builder.IamAssumeAuthenticator.disableSSLVerification = authenticator.disableSSLVerification + builder.IamAssumeAuthenticator.client = authenticator.client + + builder.IamAuthenticator.URL = authenticator.url + builder.IamAuthenticator.Client = authenticator.client + builder.IamAuthenticator.Headers = authenticator.headers + builder.IamAuthenticator.DisableSSLVerification = authenticator.disableSSLVerification + if authenticator.iamDelegate != nil { + builder.IamAuthenticator.ApiKey = authenticator.iamDelegate.ApiKey + builder.IamAuthenticator.ClientId = authenticator.iamDelegate.ClientId + builder.IamAuthenticator.ClientSecret = authenticator.iamDelegate.ClientSecret + builder.IamAuthenticator.Scope = authenticator.iamDelegate.Scope + } + + return builder +} + +// Validate will verify the authenticator's configuration. +func (authenticator *IamAssumeAuthenticator) Validate() error { + var numParams int + if authenticator.iamProfileCRN != "" { + numParams++ + } + if authenticator.iamProfileID != "" { + numParams++ + } + if authenticator.iamProfileName != "" { + numParams++ + } + + // 1. The user should specify exactly one of iamProfileID, iamProfileCRN, or iamProfileName + if numParams != 1 { + err := fmt.Errorf(ERRORMSG_EXCLUSIVE_PROPS_ERROR, "iamProfileCRN, iamProfileID", "iamProfileName") + return SDKErrorf(err, "", "exc-props", getComponentInfo()) + } + + // 2. The user should specify iamAccountID if and only if iamProfileName is also specified. + if (authenticator.iamProfileName == "") != (authenticator.iamAccountID == "") { + err := errors.New(ERRORMSG_ACCOUNTID_PROP_ERROR) + return SDKErrorf(err, "", "both-props", getComponentInfo()) + } + + return nil +} + +// client returns the authenticator's http client after potentially initializing it. +func (authenticator *IamAssumeAuthenticator) getClient() *http.Client { + authenticator.clientInit.Do(func() { + if authenticator.client == nil { + authenticator.client = DefaultHTTPClient() + authenticator.client.Timeout = time.Second * 30 + + // If the user told us to disable SSL verification, then do it now. + if authenticator.disableSSLVerification { + transport := &http.Transport{ + // #nosec G402 + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + } + authenticator.client.Transport = transport + } + } + }) + return authenticator.client +} + +// getUserAgent returns the User-Agent header value to be included in each token request invoked by the authenticator. +func (authenticator *IamAssumeAuthenticator) getUserAgent() string { + authenticator.userAgentInit.Do(func() { + authenticator.userAgent = fmt.Sprintf("%s/%s-%s %s", sdkName, "iam-assume-authenticator", __VERSION__, SystemInfo()) + }) + return authenticator.userAgent +} + +// newIamAssumeAuthenticatorFromMap constructs a new IamAssumeAuthenticator instance from a map. +func newIamAssumeAuthenticatorFromMap(properties map[string]string) (authenticator *IamAssumeAuthenticator, err error) { + if properties == nil { + err := errors.New(ERRORMSG_PROPS_MAP_NIL) + return nil, SDKErrorf(err, "", "missing-props", getComponentInfo()) + } + + disableSSL, err := strconv.ParseBool(properties[PROPNAME_AUTH_DISABLE_SSL]) + if err != nil { + disableSSL = false + } + + authenticator, err = NewIamAssumeAuthenticatorBuilder(). + SetIAMProfileID(properties[PROPNAME_IAM_PROFILE_ID]). + SetIAMProfileCRN(properties[PROPNAME_IAM_PROFILE_CRN]). + SetIAMProfileName(properties[PROPNAME_IAM_PROFILE_NAME]). + SetIAMAccountID(properties[PROPNAME_IAM_ACCOUNT_ID]). + SetApiKey(properties[PROPNAME_APIKEY]). + SetURL(properties[PROPNAME_AUTH_URL]). + SetClientIDSecret(properties[PROPNAME_CLIENT_ID], properties[PROPNAME_CLIENT_SECRET]). + SetDisableSSLVerification(disableSSL). + SetScope(properties[PROPNAME_SCOPE]). + Build() + + return +} + +// AuthenticationType returns the authentication type for this authenticator. +func (*IamAssumeAuthenticator) AuthenticationType() string { + return AUTHTYPE_IAM_ASSUME +} + +// Authenticate adds IAM authentication information to the request. +// +// The IAM access token will be added to the request's headers in the form: +// +// Authorization: Bearer +func (authenticator *IamAssumeAuthenticator) Authenticate(request *http.Request) error { + token, err := authenticator.GetToken() + if err != nil { + return RepurposeSDKProblem(err, "get-token-fail") + } + + request.Header.Set("Authorization", "Bearer "+token) + GetLogger().Debug("Authenticated outbound request (type=%s)\n", authenticator.AuthenticationType()) + return nil +} + +// getURL returns the authenticator's URL property after potentially initializing it. +func (authenticator *IamAssumeAuthenticator) getURL() string { + authenticator.urlInit.Do(func() { + if authenticator.url == "" { + // If URL was not specified, then use the default IAM endpoint. + authenticator.url = defaultIamTokenServerEndpoint + } else { + // Canonicalize the URL by removing the operation path if it was specified by the user. + authenticator.url = strings.TrimSuffix(authenticator.url, iamAuthOperationPathGetToken) + } + }) + return authenticator.url +} + +// getTokenData returns the tokenData field from the authenticator. +func (authenticator *IamAssumeAuthenticator) getTokenData() *iamTokenData { + authenticator.tokenDataMutex.Lock() + defer authenticator.tokenDataMutex.Unlock() + + return authenticator.tokenData +} + +// setTokenData sets the given iamTokenData to the tokenData field of the authenticator. +func (authenticator *IamAssumeAuthenticator) setTokenData(tokenData *iamTokenData) { + authenticator.tokenDataMutex.Lock() + defer authenticator.tokenDataMutex.Unlock() + + authenticator.tokenData = tokenData +} + +// GetToken returns an access token to be used in an Authorization header. +// Whenever a new token is needed (when a token doesn't yet exist, needs to be refreshed, +// or the existing token has expired), a new access token is fetched from the token server. +func (authenticator *IamAssumeAuthenticator) GetToken() (string, error) { + if authenticator.getTokenData() == nil || !authenticator.getTokenData().isTokenValid() { + GetLogger().Debug("Performing synchronous token fetch...") + // synchronously request the token + err := authenticator.synchronizedRequestToken() + if err != nil { + return "", RepurposeSDKProblem(err, "request-token-fail") + } + } else if authenticator.getTokenData().needsRefresh() { + GetLogger().Debug("Performing background asynchronous token fetch...") + // If refresh needed, kick off a go routine in the background to get a new token + //nolint: errcheck + go authenticator.invokeRequestTokenData() + } else { + GetLogger().Debug("Using cached access token...") + } + + // return an error if the access token is not valid or was not fetched + if authenticator.getTokenData() == nil || authenticator.getTokenData().AccessToken == "" { + err := fmt.Errorf("Error while trying to get access token") + return "", SDKErrorf(err, "", "no-token", getComponentInfo()) + } + + return authenticator.getTokenData().AccessToken, nil +} + +// synchronizedRequestToken will synchronously fetch a new access token. +func (authenticator *IamAssumeAuthenticator) synchronizedRequestToken() error { + iamAssumeRequestTokenMutex.Lock() + defer iamAssumeRequestTokenMutex.Unlock() + // if cached token is still valid, then just continue to use it + if authenticator.getTokenData() != nil && authenticator.getTokenData().isTokenValid() { + return nil + } + + return authenticator.invokeRequestTokenData() +} + +// invokeRequestTokenData requests a new token from the token server and +// unmarshals the token information to the tokenData cache. Returns +// an error if the token was unable to be fetched, otherwise returns nil +func (authenticator *IamAssumeAuthenticator) invokeRequestTokenData() error { + tokenResponse, err := authenticator.RequestToken() + if err != nil { + return err + } + + if tokenData, err := newIamTokenData(tokenResponse); err != nil { + return err + } else { + authenticator.setTokenData(tokenData) + } + + return nil +} + +// RequestToken fetches a new access token from the token server and +// returns the response structure. +func (authenticator *IamAssumeAuthenticator) RequestToken() (*IamTokenServerResponse, error) { + // Step 1: Obtain the user's IAM access token. + userAccessToken, err := authenticator.iamDelegate.GetToken() + if err != nil { + return nil, RepurposeSDKProblem(err, "iam-error") + } + + // Step 2: Exchange the user's access token for one that reflects the trusted profile + // by invoking the getToken-assume operation. + builder := NewRequestBuilder(POST) + _, err = builder.ResolveRequestURL(authenticator.getURL(), iamAuthOperationPathGetToken, nil) + if err != nil { + return nil, RepurposeSDKProblem(err, "url-resolve-error") + } + + builder.AddHeader(CONTENT_TYPE, "application/x-www-form-urlencoded") + builder.AddHeader(Accept, APPLICATION_JSON) + builder.AddHeader(headerNameUserAgent, authenticator.getUserAgent()) + + builder.AddFormData("grant_type", "", "", iamGrantTypeAssume) + builder.AddFormData("access_token", "", "", userAccessToken) + if authenticator.iamProfileCRN != "" { + builder.AddFormData("profile_crn", "", "", authenticator.iamProfileCRN) + } else if authenticator.iamProfileID != "" { + builder.AddFormData("profile_id", "", "", authenticator.iamProfileID) + } else { + builder.AddFormData("profile_name", "", "", authenticator.iamProfileName) + builder.AddFormData("account", "", "", authenticator.iamAccountID) + } + + // Add user-defined headers to request. + for headerName, headerValue := range authenticator.headers { + builder.AddHeader(headerName, headerValue) + } + + req, err := builder.Build() + if err != nil { + return nil, RepurposeSDKProblem(err, "request-build-error") + } + + // If debug is enabled, then dump the request. + if GetLogger().IsLogLevelEnabled(LevelDebug) { + buf, dumpErr := httputil.DumpRequestOut(req, req.Body != nil) + if dumpErr == nil { + GetLogger().Debug("Request:\n%s\n", RedactSecrets(string(buf))) + } else { + GetLogger().Debug(fmt.Sprintf("error while attempting to log outbound request: %s", dumpErr.Error())) + } + } + + GetLogger().Debug("Invoking IAM 'get token (assume)' operation: %s", builder.URL) + resp, err := authenticator.getClient().Do(req) + if err != nil { + err = SDKErrorf(err, "", "request-error", getComponentInfo()) + return nil, err + } + GetLogger().Debug("Returned from IAM 'get token (assume)' operation, received status code %d", resp.StatusCode) + + // If debug is enabled, then dump the response. + if GetLogger().IsLogLevelEnabled(LevelDebug) { + buf, dumpErr := httputil.DumpResponse(resp, req.Body != nil) + if dumpErr == nil { + GetLogger().Debug("Response:\n%s\n", RedactSecrets(string(buf))) + } else { + GetLogger().Debug(fmt.Sprintf("error while attempting to log inbound response: %s", dumpErr.Error())) + } + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + detailedResponse, err := processErrorResponse(resp) + authError := authenticationErrorf(err, detailedResponse, "get_token", authenticator.getComponentInfo()) + + // The err Summary is typically the message computed for the HTTPError instance in + // processErrorResponse(). If the response body is non-JSON, the message will be generic + // text based on the status code but authenticators have always used the stringified + // RawResult, so update that here for compatibility. + iamErrorMsg := err.Summary + if detailedResponse.RawResult != nil { + // RawResult is only populated if the response body is + // non-JSON and we couldn't extract a message. + iamErrorMsg = string(detailedResponse.RawResult) + } + + authError.Summary = iamErrorMsg + + return nil, authError + } + + tokenResponse := &IamTokenServerResponse{} + _ = json.NewDecoder(resp.Body).Decode(tokenResponse) + defer resp.Body.Close() // #nosec G307 + return tokenResponse, nil +} + +func (authenticator *IamAssumeAuthenticator) getComponentInfo() *ProblemComponent { + return NewProblemComponent("iam_identity_services", "") +} diff --git a/core/iam_assume_authenticator_test.go b/core/iam_assume_authenticator_test.go new file mode 100644 index 0000000..70b049d --- /dev/null +++ b/core/iam_assume_authenticator_test.go @@ -0,0 +1,702 @@ +//go:build all || slow || auth + +package core + +// (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 ( + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + assert "github.com/stretchr/testify/assert" +) + +var ( + // To enable debug logging during test execution, set this to "LevelDebug" + iamAssumeTestLogLevel LogLevel = LevelError + + iamAssumeMockProfileCRN = "mock-profile-crn" + iamAssumeMockProfileID = "mock-profile-id" + iamAssumeMockProfileName = "mock-profile-name" + iamAssumeMockAccountID = "mock-account-id" + iamAssumeMockApiKey = "mock-apikey" + iamAssumeMockClientID = "bx" + iamAssumeMockClientSecret = "bx" + iamAssumeMockURL = "https://mock.iam.com" + iamAssumeMockScope = "scope1,scope2" + iamAssumeMockUserToken1 = "eyJraWQiOiIyMDI0MDkwMjA4NDIiLCJhbGciOiJSUzI1NiJ9.eyJpYW1faWQiOiJJQk1pZC01NTAwMDBFRDZKIiwiaWQiOiJJQk1pZC01NTAwMDBFRDZKIiwicmVhbG1pZCI6IklCTWlkIiwianRpIjoiYmY2YTA0NDQtZDk3YS00OWYxLTkzNTgtZmFkMGRmODZiNmRiIiwiaWRlbnRpZmllciI6IjU1MDAwMEVENkoiLCJnaXZlbl9uYW1lIjoiUGhpbCIsImZhbWlseV9uYW1lIjoiQWRhbXMiLCJuYW1lIjoiUGhpbCBBZGFtcyIsImVtYWlsIjoicGhpbF9hZGFtc0B1cy5pYm0uY29tIiwic3ViIjoicGhpbF9hZGFtc0B1cy5pYm0uY29tIiwiYXV0aG4iOnsic3ViIjoicGhpbF9hZGFtc0B1cy5pYm0uY29tIiwiaWFtX2lkIjoiSUJNaWQtNTUwMDAwRUQ2SiIsIm5hbWUiOiJQaGlsIEFkYW1zIiwiZ2l2ZW5fbmFtZSI6IlBoaWwiLCJmYW1pbHlfbmFtZSI6IkFkYW1zIiwiZW1haWwiOiJwaGlsX2FkYW1zQHVzLmlibS5jb20ifSwiYWNjb3VudCI6eyJ2YWxpZCI6dHJ1ZSwiYnNzIjoiOGI0ZGEzNzNjNmY4NDk0ODg4OTg2ZmNjNDk5MmVhMmQiLCJmcm96ZW4iOnRydWV9LCJpYXQiOjE3MjczMDE1NjQsImV4cCI6MTcyNzMwNTE2NCwiaXNzIjoiaHR0cHM6Ly9pYW0uY2xvdWQuaWJtLmNvbS9pZGVudGl0eSIsImdyYW50X3R5cGUiOiJ1cm46aWJtOnBhcmFtczpvYXV0aDpncmFudC10eXBlOmFwaWtleSIsInNjb3BlIjoiaWJtIG9wZW5pZCIsImNsaWVudF9pZCI6ImRlZmF1bHQiLCJhY3IiOjEsImFtciI6WyJwd2QiXX0.NHyw3JedZdHawuBTbFzfdYu5ESweUGXGOktmqUEB2plRmkleZQlyVZv1oXN2XWfTgXxr4er6LPGiZvglGCIKeABg557wZSg_kkgBCd2QABVJTJTcuQXC8zzgCKoKiIunHaBKzT--lvix-wGrlBb6D8zhcLBND1Xp5vXaGlLA9IIfe_HEEsmcUxqGCtQA5zb18dvQQFvXc_3ZVk5jM8pGNJXBO8R9ZAE_yA5Jc3wszSmclqhXWbmH3zxZfKuXbsPxsRJQUk4rEAvCUfQNBuFVhJkYQubxNKcOVOf67Up7-IxuxH7P9NBgqTYcHXKDx38foNpCX0ssrEgq2b36AQI2gA" // #nosec + iamAssumeMockUserToken2 = "eyJraWQiOiIyMDI0MDkwMjA4NDIiLCJhbGciOiJSUzI1NiJ9.eyJpYW1faWQiOiJJQk1pZC01NTAwMDBFRDZKIiwiaWQiOiJJQk1pZC01NTAwMDBFRDZKIiwicmVhbG1pZCI6IklCTWlkIiwianRpIjoiYmVmZjIyZjctY2Q2OC00MDViLWEyMzYtYmI0OTJlYmE0ZGRhIiwiaWRlbnRpZmllciI6IjU1MDAwMEVENkoiLCJnaXZlbl9uYW1lIjoiUGhpbCIsImZhbWlseV9uYW1lIjoiQWRhbXMiLCJuYW1lIjoiUGhpbCBBZGFtcyIsImVtYWlsIjoicGhpbF9hZGFtc0B1cy5pYm0uY29tIiwic3ViIjoicGhpbF9hZGFtc0B1cy5pYm0uY29tIiwiYXV0aG4iOnsic3ViIjoicGhpbF9hZGFtc0B1cy5pYm0uY29tIiwiaWFtX2lkIjoiSUJNaWQtNTUwMDAwRUQ2SiIsIm5hbWUiOiJQaGlsIEFkYW1zIiwiZ2l2ZW5fbmFtZSI6IlBoaWwiLCJmYW1pbHlfbmFtZSI6IkFkYW1zIiwiZW1haWwiOiJwaGlsX2FkYW1zQHVzLmlibS5jb20ifSwiYWNjb3VudCI6eyJ2YWxpZCI6dHJ1ZSwiYnNzIjoiOGI0ZGEzNzNjNmY4NDk0ODg4OTg2ZmNjNDk5MmVhMmQiLCJmcm96ZW4iOnRydWV9LCJpYXQiOjE3MjczMDE3MzgsImV4cCI6MTcyNzMwNTMzOCwiaXNzIjoiaHR0cHM6Ly9pYW0uY2xvdWQuaWJtLmNvbS9pZGVudGl0eSIsImdyYW50X3R5cGUiOiJ1cm46aWJtOnBhcmFtczpvYXV0aDpncmFudC10eXBlOmFwaWtleSIsInNjb3BlIjoiaWJtIG9wZW5pZCIsImNsaWVudF9pZCI6ImRlZmF1bHQiLCJhY3IiOjEsImFtciI6WyJwd2QiXX0.Yi2zlgrwwxpt0XhC6jQrZnDNHoFt2cE9vY9W3tRBcNVGAmGN2pYqTcdwlKEVKjc7MtR-SfaiVPc_4iVpEfYNeG-ISXma7x-ZKvpUoo41fGUY7AzEH336FZcPpPoGnFfKPafUUXaEIHcwzIobRBxmIlMbXKwEiQEu1BBDxIUYXDP-wkLEJ95PB8gTAbrx8yrGVTFpp9mOvanePMzwHj7sQXZ3E0InVTBk4HDFSb51ggvor09rLTDtHU8WDdh4GNuRS76MURRpZ3aLWIEtgvUGwgmZxatxwJeLHxZqtfBzbXS4JhhQOl5vUg_4DavSA7luwZbdZYbZbj22KJGm0qo6Rg" // #nosec + iamAssumeMockProfileToken1 = "eyJraWQiOiIyMDI0MDkwMjA4NDIiLCJhbGciOiJSUzI1NiJ9.eyJpYW1faWQiOiJpYW0tUHJvZmlsZS03YWRmOWMwNi01NmZlLTQwODEtOTE2Yy1hYzg3MmFmYWUzZjQiLCJpZCI6ImlhbS1Qcm9maWxlLTdhZGY5YzA2LTU2ZmUtNDA4MS05MTZjLWFjODcyYWZhZTNmNCIsInJlYWxtaWQiOiJpYW0iLCJqdGkiOiIxY2NlMGU1Zi05YTk5LTQzOTktOGNmYi1mN2U4YmRlMTM4ZjYiLCJpZGVudGlmaWVyIjoiUHJvZmlsZS03YWRmOWMwNi01NmZlLTQwODEtOTE2Yy1hYzg3MmFmYWUzZjQiLCJuYW1lIjoiQXNzdW1lZFByb2ZpbGUxIiwic3ViIjoiUHJvZmlsZS03YWRmOWMwNi01NmZlLTQwODEtOTE2Yy1hYzg3MmFmYWUzZjQiLCJzdWJfdHlwZSI6IlByb2ZpbGUiLCJhdXRobiI6eyJzdWIiOiJwaGlsX2FkYW1zQHVzLmlibS5jb20iLCJpYW1faWQiOiJJQk1pZC01NTAwMDBFRDZKIiwibmFtZSI6IlBoaWwgQWRhbXMiLCJnaXZlbl9uYW1lIjoiUGhpbCIsImZhbWlseV9uYW1lIjoiQWRhbXMiLCJlbWFpbCI6InBoaWxfYWRhbXNAdXMuaWJtLmNvbSJ9LCJhY2NvdW50Ijp7InZhbGlkIjp0cnVlLCJic3MiOiI4YjRkYTM3M2M2Zjg0OTQ4ODg5ODZmY2M0OTkyZWEyZCIsImZyb3plbiI6dHJ1ZX0sImlhdCI6MTcyNzMwMTU2NCwiZXhwIjoxNzI3MzA1MTYxLCJpc3MiOiJodHRwczovL2lhbS5jbG91ZC5pYm0uY29tL2lkZW50aXR5IiwiZ3JhbnRfdHlwZSI6InVybjppYm06cGFyYW1zOm9hdXRoOmdyYW50LXR5cGU6YXNzdW1lIiwic2NvcGUiOiJpYm0gb3BlbmlkIiwiY2xpZW50X2lkIjoiZGVmYXVsdCIsImFjciI6MSwiYW1yIjpbInB3ZCJdfQ.VtMNv7gHScrnWfuHHRXxp62AYRSDY5_RQZw8Wdj-hgMX7qmgquaKSvfwTooGJyamuUl0WNNW6avrqU0TVebyc-Aci4e71NchJf1nSol0EIxYQum8LBBUfyMcOVLfuPSAdabEUTqLR1nh1oxrRlSAVt5hLSDnQ-2WS8OrAWG8fWEvACrzXhrPUF5Ko702V7Y-Gnksoz3nkDvLeoVx6jwF3izrJ-1NwGuMGNLfu8E3zSl9utbY4FSSvEheHii1h1QfNYl9FCJpMWCfwpJVCktKlOlP_9g-lirWMoJ_lEc2DA-Pl54Ozmos08G7DoOmgmrtxvUcGXSc7_FKhj77LDuo0g" // #nosec + iamAssumeMockProfileToken2 = "eyJraWQiOiIyMDI0MDkwMjA4NDIiLCJhbGciOiJSUzI1NiJ9.eyJpYW1faWQiOiJpYW0tUHJvZmlsZS03YWRmOWMwNi01NmZlLTQwODEtOTE2Yy1hYzg3MmFmYWUzZjQiLCJpZCI6ImlhbS1Qcm9maWxlLTdhZGY5YzA2LTU2ZmUtNDA4MS05MTZjLWFjODcyYWZhZTNmNCIsInJlYWxtaWQiOiJpYW0iLCJqdGkiOiI4NTRhNjQ0Zi01MmY0LTRmNjMtYmE5Yy0yODBjYjkzYjE5MjkiLCJpZGVudGlmaWVyIjoiUHJvZmlsZS03YWRmOWMwNi01NmZlLTQwODEtOTE2Yy1hYzg3MmFmYWUzZjQiLCJuYW1lIjoiQXNzdW1lZFByb2ZpbGUxIiwic3ViIjoiUHJvZmlsZS03YWRmOWMwNi01NmZlLTQwODEtOTE2Yy1hYzg3MmFmYWUzZjQiLCJzdWJfdHlwZSI6IlByb2ZpbGUiLCJhdXRobiI6eyJzdWIiOiJwaGlsX2FkYW1zQHVzLmlibS5jb20iLCJpYW1faWQiOiJJQk1pZC01NTAwMDBFRDZKIiwibmFtZSI6IlBoaWwgQWRhbXMiLCJnaXZlbl9uYW1lIjoiUGhpbCIsImZhbWlseV9uYW1lIjoiQWRhbXMiLCJlbWFpbCI6InBoaWxfYWRhbXNAdXMuaWJtLmNvbSJ9LCJhY2NvdW50Ijp7InZhbGlkIjp0cnVlLCJic3MiOiI4YjRkYTM3M2M2Zjg0OTQ4ODg5ODZmY2M0OTkyZWEyZCIsImZyb3plbiI6dHJ1ZX0sImlhdCI6MTcyNzMwMTczOSwiZXhwIjoxNzI3MzA1MzM2LCJpc3MiOiJodHRwczovL2lhbS5jbG91ZC5pYm0uY29tL2lkZW50aXR5IiwiZ3JhbnRfdHlwZSI6InVybjppYm06cGFyYW1zOm9hdXRoOmdyYW50LXR5cGU6YXNzdW1lIiwic2NvcGUiOiJpYm0gb3BlbmlkIiwiY2xpZW50X2lkIjoiZGVmYXVsdCIsImFjciI6MSwiYW1yIjpbInB3ZCJdfQ.lr5HsElwOBMyyQ855KCPLeSXKYHImmogVpKzD_eGFI8kPWhd7lFslbC_6nALfehWpyMG4xCILg3eK-lGdkntVZ92mmKZKEzOd4GRm_bgIU_Ul0zyiZurnE8u5MDBSMp-sIQ5yPzBsDFxkAhm7f2Dt3TmjyUHGS8AIs_uQ8ldT0l0rj-rK6YjfF-hDsm414690dIbTaoSLUg679Qto0peiQ7HmE0RX91QkRqbKg7BKkIpsKxepnJoqlZuPBoKH8o8-dkqpW8ktm2e-Sk_eOJTiB07I0x1212gwuQFD_8Y7YYfzMqqtJmLiSwgHOnjHGSkqirfC_zA5rbjuI3a4yOr0g" // #nosec +) + +// Tests involving the Builder +func TestIamAssumeAuthBuilderErrors(t *testing.T) { + var err error + var auth *IamAssumeAuthenticator + + // Error: no apikey + auth, err = NewIamAssumeAuthenticatorBuilder(). + SetApiKey(""). + Build() + assert.NotNil(t, err) + assert.Nil(t, auth) + t.Logf("Expected error: %s", err.Error()) + + // Error: invalid apikey + auth, err = NewIamAssumeAuthenticatorBuilder(). + SetApiKey("{invalid-apikey}"). + Build() + assert.NotNil(t, err) + assert.Nil(t, auth) + t.Logf("Expected error: %s", err.Error()) + + // Error: apikey and client-id set, but no client-secret + auth, err = NewIamAssumeAuthenticatorBuilder(). + SetApiKey(iamAssumeMockApiKey). + SetClientIDSecret(iamAssumeMockClientID, ""). + Build() + assert.NotNil(t, err) + assert.Nil(t, auth) + t.Logf("Expected error: %s", err.Error()) + + // Error: apikey and client-secret set, but no client-id + auth, err = NewIamAssumeAuthenticatorBuilder(). + SetApiKey(iamAssumeMockApiKey). + SetClientIDSecret("", iamAssumeMockClientSecret). + Build() + assert.NotNil(t, err) + assert.Nil(t, auth) + t.Logf("Expected error: %s", err.Error()) + + // Error: no trusted profile specified + auth, err = NewIamAssumeAuthenticatorBuilder(). + SetApiKey(iamAssumeMockApiKey). + Build() + assert.NotNil(t, err) + assert.Nil(t, auth) + t.Logf("Expected error: %s", err.Error()) + + // Error: specify profile name with no account id + auth, err = NewIamAssumeAuthenticatorBuilder(). + SetApiKey(iamAssumeMockApiKey). + SetIAMProfileName(iamAssumeMockProfileName). + Build() + assert.NotNil(t, err) + assert.Nil(t, auth) + t.Logf("Expected error: %s", err.Error()) +} + +func TestIamAssumeAuthBuilderSuccess(t *testing.T) { + var err error + var auth *IamAssumeAuthenticator + var expectedHeaders = map[string]string{ + "header1": "value1", + } + + // Specify apikey and profile id. + auth, err = NewIamAssumeAuthenticatorBuilder(). + SetApiKey(iamAssumeMockApiKey). + SetIAMProfileID(iamAssumeMockProfileID). + Build() + assert.Nil(t, err) + assert.NotNil(t, auth) + assert.Equal(t, iamAssumeMockApiKey, auth.iamDelegate.ApiKey) + assert.Empty(t, auth.iamDelegate.RefreshToken) + assert.Empty(t, auth.iamDelegate.URL) + assert.Empty(t, auth.iamDelegate.ClientId) + assert.Empty(t, auth.iamDelegate.ClientSecret) + assert.False(t, auth.iamDelegate.DisableSSLVerification) + assert.Empty(t, auth.iamDelegate.Scope) + assert.Nil(t, auth.iamDelegate.Headers) + assert.Empty(t, auth.url) + assert.Empty(t, auth.iamProfileCRN) + assert.Equal(t, iamAssumeMockProfileID, auth.iamProfileID) + assert.Empty(t, auth.iamProfileName) + assert.Empty(t, auth.iamAccountID) + assert.Equal(t, AUTHTYPE_IAM, auth.iamDelegate.AuthenticationType()) + assert.Equal(t, AUTHTYPE_IAM_ASSUME, auth.AuthenticationType()) + + // Specify apikey and profile crn. + auth, err = NewIamAssumeAuthenticatorBuilder(). + SetApiKey(iamAssumeMockApiKey). + SetIAMProfileCRN(iamAssumeMockProfileCRN). + Build() + assert.Nil(t, err) + assert.NotNil(t, auth) + assert.Equal(t, iamAssumeMockProfileCRN, auth.iamProfileCRN) + assert.Empty(t, auth.iamProfileID) + assert.Empty(t, auth.iamProfileName) + assert.Empty(t, auth.iamAccountID) + + // Specify apikey and profile name. + auth, err = NewIamAssumeAuthenticatorBuilder(). + SetApiKey(iamAssumeMockApiKey). + SetIAMProfileName(iamAssumeMockProfileName). + SetIAMAccountID(iamAssumeMockAccountID). + Build() + assert.Nil(t, err) + assert.NotNil(t, auth) + assert.Empty(t, auth.iamProfileCRN) + assert.Empty(t, auth.iamProfileID) + assert.Equal(t, iamAssumeMockProfileName, auth.iamProfileName) + assert.Equal(t, iamAssumeMockAccountID, auth.iamAccountID) + + // Specify various IAM-related properties. + auth, err = NewIamAssumeAuthenticatorBuilder(). + SetURL(iamAssumeMockURL). + SetIAMProfileCRN(iamAssumeMockProfileCRN). + SetApiKey(iamAssumeMockApiKey). + SetClientIDSecret(iamAssumeMockClientID, iamAssumeMockClientSecret). + SetDisableSSLVerification(true). + SetScope(iamAssumeMockScope). + SetHeaders(expectedHeaders). + Build() + assert.Nil(t, err) + assert.NotNil(t, auth) + assert.Equal(t, iamAssumeMockURL, auth.url) + assert.Equal(t, iamAssumeMockProfileCRN, auth.iamProfileCRN) + assert.Empty(t, auth.iamProfileID) + assert.Empty(t, auth.iamProfileName) + assert.Empty(t, auth.iamAccountID) + assert.True(t, auth.disableSSLVerification) + assert.Equal(t, expectedHeaders, auth.headers) + assert.Equal(t, iamAssumeMockApiKey, auth.iamDelegate.ApiKey) + assert.Equal(t, iamAssumeMockURL, auth.iamDelegate.URL) + assert.Equal(t, iamAssumeMockClientID, auth.iamDelegate.ClientId) + assert.Equal(t, iamAssumeMockClientSecret, auth.iamDelegate.ClientSecret) + assert.True(t, auth.iamDelegate.DisableSSLVerification) + assert.Equal(t, iamAssumeMockScope, auth.iamDelegate.Scope) + assert.Equal(t, expectedHeaders, auth.iamDelegate.Headers) + assert.Equal(t, AUTHTYPE_IAM, auth.iamDelegate.AuthenticationType()) + assert.Equal(t, AUTHTYPE_IAM_ASSUME, auth.AuthenticationType()) + + // Exercise the NewBuilder method and verify that it returns a builder that can + // be used to construct an authenticator equivalent to "auth". + builder := auth.NewBuilder() + auth2, err := builder.Build() + assert.Nil(t, err) + assert.NotNil(t, auth2) + assert.Equal(t, auth, auth2) +} + +// Tests that construct an authenticator via map properties. +func TestIamAssumeAuthenticatorFromMap(t *testing.T) { + _, err := newIamAssumeAuthenticatorFromMap(nil) + assert.NotNil(t, err) + t.Logf("Expected error: %s", err.Error()) + + var props = map[string]string{ + PROPNAME_AUTH_URL: iamAssumeMockURL, + PROPNAME_APIKEY: iamAssumeMockApiKey, + } + _, err = newIamAssumeAuthenticatorFromMap(props) + assert.NotNil(t, err) + t.Logf("Expected error: %s", err.Error()) + + props = map[string]string{ + PROPNAME_APIKEY: "", + PROPNAME_IAM_PROFILE_ID: "", + } + _, err = newIamAssumeAuthenticatorFromMap(props) + assert.NotNil(t, err) + t.Logf("Expected error: %s", err.Error()) + + props = map[string]string{ + PROPNAME_APIKEY: iamAssumeMockApiKey, + PROPNAME_IAM_PROFILE_CRN: iamAssumeMockProfileCRN, + } + authenticator, err := newIamAssumeAuthenticatorFromMap(props) + assert.Nil(t, err) + assert.NotNil(t, authenticator) + assert.Equal(t, iamAssumeMockApiKey, authenticator.iamDelegate.ApiKey) + assert.Equal(t, iamAssumeMockProfileCRN, authenticator.iamProfileCRN) + assert.Equal(t, AUTHTYPE_IAM_ASSUME, authenticator.AuthenticationType()) + + props = map[string]string{ + PROPNAME_APIKEY: iamAssumeMockApiKey, + PROPNAME_IAM_PROFILE_NAME: iamAssumeMockProfileName, + PROPNAME_IAM_ACCOUNT_ID: iamAssumeMockAccountID, + PROPNAME_AUTH_DISABLE_SSL: "true", + PROPNAME_CLIENT_ID: iamAssumeMockClientID, + PROPNAME_CLIENT_SECRET: iamAssumeMockClientSecret, + PROPNAME_SCOPE: iamAssumeMockScope, + } + authenticator, err = newIamAssumeAuthenticatorFromMap(props) + assert.Nil(t, err) + assert.NotNil(t, authenticator) + assert.Equal(t, iamAssumeMockProfileName, authenticator.iamProfileName) + assert.Equal(t, iamAssumeMockAccountID, authenticator.iamAccountID) + assert.True(t, authenticator.disableSSLVerification) + assert.Equal(t, iamAssumeMockApiKey, authenticator.iamDelegate.ApiKey) + assert.True(t, authenticator.iamDelegate.DisableSSLVerification) + assert.Equal(t, iamAssumeMockClientID, authenticator.iamDelegate.ClientId) + assert.Equal(t, iamAssumeMockClientSecret, authenticator.iamDelegate.ClientSecret) + assert.Equal(t, iamAssumeMockScope, authenticator.iamDelegate.Scope) + assert.Equal(t, AUTHTYPE_IAM_ASSUME, authenticator.AuthenticationType()) +} + +func TestIamAssumeAuthDefaultURL(t *testing.T) { + auth, err := NewIamAssumeAuthenticatorBuilder(). + SetApiKey(iamAssumeMockApiKey). + SetIAMProfileID(iamAssumeMockProfileID). + Build() + assert.Nil(t, err) + + assert.Equal(t, defaultIamTokenServerEndpoint, auth.getURL()) + assert.Equal(t, defaultIamTokenServerEndpoint, auth.url) + assert.Equal(t, defaultIamTokenServerEndpoint, auth.iamDelegate.url()) + assert.Equal(t, defaultIamTokenServerEndpoint, auth.iamDelegate.URL) +} + +// startMockIAMAssumeServer will start a mock server endpoint that supports the +// "apikey" and "assume" flavors of the IAM getToken operation. +func startMockIAMAssumeServer(t *testing.T) *httptest.Server { + var numUserTokenRequests = 0 + var numProfileTokenRequests = 0 + + // Create the mock server. + server := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { + operationPath := req.URL.EscapedPath() + assert.Equal(t, "/identity/token", operationPath) + + // Assume that we'll return a 200 OK status code. + statusCode := http.StatusOK + responseBody := "" + + // Validate some common parts of the input request. + assert.Equal(t, APPLICATION_JSON, req.Header.Get("Accept")) + assert.Equal(t, FORM_URL_ENCODED_HEADER, req.Header.Get("Content-Type")) + userAgent := req.Header.Get(headerNameUserAgent) + grantType := req.FormValue("grant_type") + + var responseToken string + + // Validate and reply to the request based on the grant type. + if grantType == iamAuthGrantTypeApiKey { + numUserTokenRequests++ + assert.True(t, strings.HasPrefix(userAgent, + fmt.Sprintf("%s/%s", sdkName, "iam-authenticator"))) + if numUserTokenRequests == 1 { + responseToken = iamAssumeMockUserToken1 + } else { + responseToken = iamAssumeMockUserToken2 + } + apikey := req.FormValue("apikey") + if apikey != iamAssumeMockApiKey { + statusCode = http.StatusBadRequest + responseBody = "Bad Request: invalid apikey" + } else { + responseBody = fmt.Sprintf(`{"access_token": "%s", "refresh_token": "not_available", "token_type": "Bearer", "expires_in": 3600, "expiration": %d}`, + responseToken, GetCurrentTime()+3600) + } + } else if grantType == iamGrantTypeAssume { + numProfileTokenRequests++ + assert.True(t, strings.HasPrefix(userAgent, + fmt.Sprintf("%s/%s", sdkName, "iam-assume-authenticator"))) + profileCRN := req.FormValue("profile_crn") + profileID := req.FormValue("profile_id") + profileName := req.FormValue("profile_name") + accountID := req.FormValue("account") + if numProfileTokenRequests == 1 { + responseToken = iamAssumeMockProfileToken1 + } else { + responseToken = iamAssumeMockProfileToken2 + } + + if !validateTrustedProfile(profileCRN, profileID, profileName, accountID) { + statusCode = http.StatusBadRequest + responseBody = "Bad Request: invalid trusted profile" + } else { + responseBody = fmt.Sprintf(`{"access_token": "%s", "token_type": "Bearer", "expires_in": 3600, "expiration": %d}`, + responseToken, GetCurrentTime()+3600) + } + } else { + // error - incorrect grant type. + statusCode = http.StatusBadRequest + responseBody = "Bad Request: invalid grant type" + } + + res.WriteHeader(statusCode) + fmt.Fprint(res, responseBody) + })) + return server +} + +func validateTrustedProfile(profileCRN, profileID, profileName, accountID string) bool { + numParams := 0 + if profileCRN != "" { + numParams++ + } + if profileID != "" { + numParams++ + } + if profileName != "" { + numParams++ + } + + if numParams != 1 { + return false + } + + if (profileName == "") != (accountID == "") { + return false + } + + if profileCRN != "" && profileCRN != iamAssumeMockProfileCRN { + return false + } + + if profileID != "" && profileID != iamAssumeMockProfileID { + return false + } + + if profileName != "" && profileName != iamAssumeMockProfileName { + return false + } + + if accountID != "" && accountID != iamAssumeMockAccountID { + return false + } + + return true +} + +func TestIamAssumeAuthGetTokenSuccess(t *testing.T) { + GetLogger().SetLogLevel(iamAssumeTestLogLevel) + + server := startMockIAMAssumeServer(t) + defer server.Close() + + auth, err := NewIamAssumeAuthenticatorBuilder(). + SetApiKey(iamAssumeMockApiKey). + SetIAMProfileID(iamAssumeMockProfileID). + SetURL(server.URL). + Build() + assert.Nil(t, err) + + // Verify that we initially have no token data cached on the authenticator. + assert.Nil(t, auth.getTokenData()) + + // Force the first fetch and verify we got the first access token. + var accessToken string + accessToken, err = auth.GetToken() + assert.Nil(t, err) + + // Verify that the access token was returned by GetToken() and also + // stored in the authenticator's tokenData field as well. + assert.NotNil(t, auth.getTokenData()) + assert.Equal(t, iamAssumeMockProfileToken1, accessToken) + assert.Equal(t, iamAssumeMockProfileToken1, auth.getTokenData().AccessToken) + + // We should also get back a nil error from synchronizedRequestToken() + // because calling it should NOT result in a new token request. + assert.Nil(t, auth.synchronizedRequestToken()) + + // Call GetToken() again and verify that we get the cached value. + // Note: we'll Set Scope so that if the IAM operation is actually called again, + // we'll receive the second access token. We don't want the IAM operation called again yet. + accessToken, err = auth.GetToken() + assert.Nil(t, err) + assert.Equal(t, iamAssumeMockProfileToken1, accessToken) + + // Force expiration and verify that GetToken() fetched the second access token. + auth.getTokenData().Expiration = GetCurrentTime() - 1 + accessToken, err = auth.GetToken() + assert.Nil(t, err) + assert.NotNil(t, auth.getTokenData()) + assert.Equal(t, iamAssumeMockProfileToken2, accessToken) + assert.Equal(t, iamAssumeMockProfileToken2, auth.getTokenData().AccessToken) +} + +func TestIamAssumeAuthGetTokenSuccess10SecWindow(t *testing.T) { + GetLogger().SetLogLevel(iamAssumeTestLogLevel) + + server := startMockIAMAssumeServer(t) + defer server.Close() + + auth, err := NewIamAssumeAuthenticatorBuilder(). + SetApiKey(iamAssumeMockApiKey). + SetIAMProfileCRN(iamAssumeMockProfileCRN). + SetURL(server.URL). + Build() + assert.Nil(t, err) + + // Verify that we initially have no token data cached on the authenticator. + assert.Nil(t, auth.getTokenData()) + + // Force the first fetch and verify we got the first access token. + var accessToken string + accessToken, err = auth.GetToken() + assert.Nil(t, err) + + // Verify that the access token was returned by GetToken() and also + // stored in the authenticator's tokenData field as well. + assert.NotNil(t, auth.getTokenData()) + assert.Equal(t, iamAssumeMockProfileToken1, accessToken) + assert.Equal(t, iamAssumeMockProfileToken1, auth.getTokenData().AccessToken) + + // Call GetToken() again and verify that we get the cached value. + accessToken, err = auth.GetToken() + assert.Nil(t, err) + assert.Equal(t, iamAssumeMockProfileToken1, accessToken) + + // Force expiration and verify that GetToken() fetched the second access token. + // We'll set expiration to be current-time + (10 secs), + // to test the scenario where we should refresh the token when we are within 10 secs + // of expiration. + auth.getTokenData().Expiration = GetCurrentTime() + iamExpirationWindow + accessToken, err = auth.GetToken() + assert.Nil(t, err) + assert.NotNil(t, auth.getTokenData()) + assert.Equal(t, iamAssumeMockProfileToken2, accessToken) + assert.Equal(t, iamAssumeMockProfileToken2, auth.getTokenData().AccessToken) +} + +func TestIamAssumeAuthRequestTokenError1(t *testing.T) { + GetLogger().SetLogLevel(iamAssumeTestLogLevel) + + // Force an error while resolving the service URL. + auth, err := NewIamAssumeAuthenticatorBuilder(). + SetApiKey(iamAssumeMockApiKey). + SetIAMProfileCRN(iamAssumeMockProfileCRN). + SetURL("https://badhost"). + Build() + assert.Nil(t, err) + + iamToken, err := auth.RequestToken() + assert.NotNil(t, err) + assert.Nil(t, iamToken) + t.Logf("Expected error: %s\n", err.Error()) +} + +func TestIamAssumeAuthAuthenticateSuccess(t *testing.T) { + GetLogger().SetLogLevel(iamAssumeTestLogLevel) + + server := startMockIAMAssumeServer(t) + defer server.Close() + + auth, err := NewIamAssumeAuthenticatorBuilder(). + SetApiKey(iamAssumeMockApiKey). + SetIAMProfileName(iamAssumeMockProfileName). + SetIAMAccountID(iamAssumeMockAccountID). + SetURL(server.URL). + Build() + assert.Nil(t, err) + assert.NotNil(t, auth) + + // Create a new Request object to simulate an API request that needs authentication. + builder, err := NewRequestBuilder("GET").ConstructHTTPURL("https://myservice.localhost/api/v1", nil, nil) + assert.Nil(t, err) + + request, err := builder.Build() + assert.Nil(t, err) + assert.NotNil(t, request) + + // Try to authenticate the request. + err = auth.Authenticate(request) + + // Verify that it succeeded. + assert.Nil(t, err) + authHeader := request.Header.Get("Authorization") + assert.Equal(t, "Bearer "+iamAssumeMockProfileToken1, authHeader) + + // Call Authenticate again to make sure we used the cached access token. + err = auth.Authenticate(request) + assert.Nil(t, err) + authHeader = request.Header.Get("Authorization") + assert.Equal(t, "Bearer "+iamAssumeMockProfileToken1, authHeader) + + // Force expiration (in both the Iam and IamAssume authenticators) and + // verify that Authenticate() fetched the second access token. + auth.getTokenData().Expiration = GetCurrentTime() - 1 + auth.iamDelegate.getTokenData().Expiration = GetCurrentTime() - 1 + err = auth.Authenticate(request) + assert.Nil(t, err) + authHeader = request.Header.Get("Authorization") + assert.Equal(t, "Bearer "+iamAssumeMockProfileToken2, authHeader) + assert.Equal(t, iamAssumeMockUserToken2, auth.iamDelegate.getTokenData().AccessToken) +} + +func TestIamAssumeAuthAuthenticateFailBadApiKey(t *testing.T) { + GetLogger().SetLogLevel(iamAssumeTestLogLevel) + + server := startMockIAMAssumeServer(t) + defer server.Close() + + // Set up the authenticator with a bogus apikey + // so that we can't successfully retrieve an access token. + auth, err := NewIamAssumeAuthenticatorBuilder(). + SetApiKey("BAD_APIKEY"). + SetIAMProfileName(iamAssumeMockProfileName). + SetIAMAccountID(iamAssumeMockAccountID). + SetURL(server.URL). + Build() + assert.Nil(t, err) + assert.NotNil(t, auth) + + // Create a new Request object to simulate an API request that needs authentication. + builder, err := NewRequestBuilder("GET").ConstructHTTPURL("https://myservice.localhost/api/v1", nil, nil) + assert.Nil(t, err) + + request, err := builder.Build() + assert.Nil(t, err) + assert.NotNil(t, request) + + // Try to authenticate the request (should fail) + err = auth.Authenticate(request) + + // Validate the resulting error is a valid AuthenticationError. + assert.NotNil(t, err) + t.Logf("Expected error: %s\n", err.Error()) + authErr, ok := err.(*AuthenticationError) + assert.True(t, ok) + assert.NotNil(t, authErr) + assert.EqualValues(t, authErr, err) + // The auth error should match the original error message + assert.Equal(t, err.Error(), authErr.Error()) +} + +func TestIamAssumeAuthAuthenticateFailBadProfileCRN(t *testing.T) { + GetLogger().SetLogLevel(iamAssumeTestLogLevel) + + server := startMockIAMAssumeServer(t) + defer server.Close() + + // Set up the authenticator with a bogus profile crn value + // so that we can't successfully retrieve an access token. + auth, err := NewIamAssumeAuthenticatorBuilder(). + SetApiKey(iamAssumeMockApiKey). + SetIAMProfileCRN("BAD_PROFILE_CRN"). + SetURL(server.URL). + Build() + assert.Nil(t, err) + assert.NotNil(t, auth) + + // Create a new Request object to simulate an API request that needs authentication. + builder, err := NewRequestBuilder("GET").ConstructHTTPURL("https://myservice.localhost/api/v1", nil, nil) + assert.Nil(t, err) + + request, err := builder.Build() + assert.Nil(t, err) + assert.NotNil(t, request) + + // Try to authenticate the request (should fail) + err = auth.Authenticate(request) + + // Validate the resulting error is a valid AuthenticationError. + assert.NotNil(t, err) + t.Logf("Expected error: %s\n", err.Error()) + authErr, ok := err.(*AuthenticationError) + assert.True(t, ok) + assert.NotNil(t, authErr) + assert.EqualValues(t, authErr, err) + // The auth error should match the original error message + assert.Equal(t, err.Error(), authErr.Error()) +} + +func TestIamAssumeAuthBackgroundTokenRefreshSuccess(t *testing.T) { + GetLogger().SetLogLevel(iamAssumeTestLogLevel) + + server := startMockIAMAssumeServer(t) + defer server.Close() + + auth, err := NewIamAssumeAuthenticatorBuilder(). + SetApiKey(iamAssumeMockApiKey). + SetIAMProfileCRN(iamAssumeMockProfileCRN). + SetURL(server.URL). + Build() + assert.Nil(t, err) + assert.NotNil(t, auth) + + // Force the first fetch and verify we got the first access token. + accessToken, err := auth.GetToken() + assert.Nil(t, err) + assert.Equal(t, iamAssumeMockProfileToken1, accessToken) + + // Now simulate being in the refresh window where the token is not expired but still needs to be refreshed. + auth.getTokenData().RefreshTime = GetCurrentTime() - 1 + + // Authenticator should detect the need to get a new access token in the background but use the current + // cached access token for this next GetToken() call. + accessToken, err = auth.GetToken() + assert.Nil(t, err) + assert.Equal(t, iamAssumeMockProfileToken1, accessToken) + + // Wait for the background thread to finish. + time.Sleep(2 * time.Second) + accessToken, err = auth.GetToken() + assert.Nil(t, err) + assert.Equal(t, iamAssumeMockProfileToken2, accessToken) +} + +// In order to test with a live IAM server, create file "iamassume.env" in the project root. +// It should look like this: +// +// IAMASSUME1_AUTH_TYPE=iamAssume +// IAMASSUME1_APIKEY= +// IAMASSUME1_IAM_PROFILE_ID= +// +// Then comment out the "t.Skip()" line below, then run these commands: +// +// cd core +// go test -v -tags=auth -run=TestIamAssumeLiveTokenServer +// +// To trace request/response messages, change "iamAssumeTestLogLevel" above to be "LevelDebug". +func TestIamAssumeLiveTokenServer(t *testing.T) { + t.Skip("Skipping IamAssumeAuthenticator integration test...") + + GetLogger().SetLogLevel(iamAssumeTestLogLevel) + + var request *http.Request + var err error + var authHeader string + + // Get an iam authenticator from the environment. + t.Setenv("IBM_CREDENTIALS_FILE", "../iamassume.env") + auth, err := GetAuthenticatorFromEnvironment("iamassume1") + assert.Nil(t, err) + assert.NotNil(t, auth) + + _, ok := auth.(*IamAssumeAuthenticator) + assert.Equal(t, true, ok) + + // Create a new Request object. + builder, err := NewRequestBuilder("GET").ResolveRequestURL("https://localhost/placeholder/url", "", nil) + assert.Nil(t, err) + assert.NotNil(t, builder) + + request, _ = builder.Build() + assert.NotNil(t, request) + err = auth.Authenticate(request) + if err != nil { + authError := err.(*AuthenticationError) + iamError := authError.Err + iamResponse := authError.Response + t.Logf("Unexpected authentication error: %s\n", iamError.Error()) + t.Logf("Authentication response: %v+\n", iamResponse) + + } + assert.Nil(t, err) + + authHeader = request.Header.Get("Authorization") + assert.NotEmpty(t, authHeader) + assert.True(t, strings.HasPrefix(authHeader, "Bearer ")) + t.Logf("Authorization: %s\n", authHeader) +} diff --git a/resources/my-credentials.env b/resources/my-credentials.env index 596d720..cda0532 100644 --- a/resources/my-credentials.env +++ b/resources/my-credentials.env @@ -91,6 +91,12 @@ SERVICE10_APIKEY=my-api-key SERVICE10_AUTH_URL=https://mcsp.ibm.com SERVICE10_AUTH_DISABLE_SSL=true +SERVICE11_AUTH_TYPE=iAmAsSuME +SERVICE11_APIKEY=my-api-key +SERVICE11_IAM_PROFILE_ID=iam-profile-1 +SERVICE11_AUTH_URL=https://iamassume.ibm.com +SERVICE11_AUTH_DISABLE_SSL=true + # EQUAL service exercises value with = in them EQUAL_SERVICE_URL==https:/my=host.com/my=service/api EQUAL_SERVICE_APIKEY==my=api=key=