diff --git a/.changes/v2.20.0/562-improvements.md b/.changes/v2.20.0/562-improvements.md new file mode 100644 index 000000000..25d068960 --- /dev/null +++ b/.changes/v2.20.0/562-improvements.md @@ -0,0 +1,2 @@ +* Add `SetServiceAccountApiToken` method of `*VCDClient` that allows + authenticating using a service account token file and handles the refresh token rotation [GH-562] diff --git a/README.md b/README.md index 33a6a0bd2..a3e52ae24 100644 --- a/README.md +++ b/README.md @@ -114,7 +114,7 @@ func main() { ## Authentication -You can authenticate to the vCD in four ways: +You can authenticate to the vCD in five ways: * With a System Administration user and password (`administrator@system`) * With an Organization user and password (`tenant-admin@org-name`) @@ -133,6 +133,11 @@ For the above two methods, you use: The file `scripts/get_token.sh` provides a handy method of extracting the token (`x-vcloud-authorization` value) for future use. +* With a service account token (the file needs to have `r+w` rights) +```go + err := vcdClient.SetServiceAccountApiToken(Org, "tokenfile.json") +``` + * SAML user and password (works with ADFS as IdP using WS-TRUST endpoint "/adfs/services/trust/13/usernamemixed"). One must pass `govcd.WithSamlAdfs(true,customAdfsRptId)` and username must be formatted so that ADFS understands it ('user@contoso.com' or diff --git a/govcd/api_token.go b/govcd/api_token.go index 41040ff0b..497cf776c 100644 --- a/govcd/api_token.go +++ b/govcd/api_token.go @@ -9,9 +9,13 @@ import ( "encoding/json" "fmt" "io" + "io/fs" "net/http" "net/url" + "os" + "path" "strings" + "time" "github.com/vmware/go-vcloud-director/v2/types/v56" "github.com/vmware/go-vcloud-director/v2/util" @@ -31,6 +35,47 @@ func (vcdClient *VCDClient) SetApiToken(org, apiToken string) (*types.ApiTokenRe return tokenRefresh, nil } +// SetServiceAccountApiToken reads the current Service Account API token, +// sets the client's bearer token and fetches a new API token for next +// authentication request using SetApiToken and overwrites the old file. +func (vcdClient *VCDClient) SetServiceAccountApiToken(org, apiTokenFile string) error { + if vcdClient.Client.APIVCDMaxVersionIs("< 37.0") { + version, err := vcdClient.Client.GetVcdFullVersion() + if err == nil { + return fmt.Errorf("minimum version for Service Account authentication is 10.4 - Version detected: %s", version.Version) + } + // If we can't get the VCD version, we return API version info + return fmt.Errorf("minimum API version for Service Account authentication is 37.0 - Version detected: %s", vcdClient.Client.APIVersion) + } + + saApiToken := &types.ApiTokenRefresh{} + // Read file contents and unmarshal them to saApiToken + err := readFileAndUnmarshalJSON(apiTokenFile, saApiToken) + if err != nil { + return err + } + + // Get bearer token and update the refresh token for the next authentication request + saApiToken, err = vcdClient.SetApiToken(org, saApiToken.RefreshToken) + if err != nil { + return err + } + + // leave only the refresh token to not leave any sensitive information + saApiToken = &types.ApiTokenRefresh{ + RefreshToken: saApiToken.RefreshToken, + TokenType: "Service Account", + UpdatedBy: vcdClient.Client.UserAgent, + UpdatedOn: time.Now().Format(time.RFC3339), + } + err = marshalJSONAndWriteToFile(apiTokenFile, saApiToken, 0600) + if err != nil { + return err + } + + return nil +} + // GetBearerTokenFromApiToken uses an API token to retrieve a bearer token // using the refresh token operation. func (vcdClient *VCDClient) GetBearerTokenFromApiToken(org, token string) (*types.ApiTokenRefresh, error) { @@ -112,3 +157,34 @@ func (vcdClient *VCDClient) GetBearerTokenFromApiToken(org, token string) (*type } return &tokenDef, nil } + +// readFileAndUnmarshalJSON reads a file and unmarshals it to the given variable +func readFileAndUnmarshalJSON(filename string, object any) error { + data, err := os.ReadFile(path.Clean(filename)) + if err != nil { + return fmt.Errorf("failed to read from file: %s", err) + } + + err = json.Unmarshal(data, object) + if err != nil { + return fmt.Errorf("failed to unmarshal file contents to the object: %s", err) + } + + return nil +} + +// marshalJSONAndWriteToFile marshalls the given object into JSON and writes +// to a file with the given permissions in octal format (e.g 0600) +func marshalJSONAndWriteToFile(filename string, object any, permissions int) error { + data, err := json.MarshalIndent(object, " ", " ") + if err != nil { + return fmt.Errorf("error marshalling object to JSON: %s", err) + } + + err = os.WriteFile(filename, data, fs.FileMode(permissions)) + if err != nil { + return fmt.Errorf("error writing to the file: %s", err) + } + + return nil +} diff --git a/govcd/api_token_unit_test.go b/govcd/api_token_unit_test.go new file mode 100644 index 000000000..556408c13 --- /dev/null +++ b/govcd/api_token_unit_test.go @@ -0,0 +1,75 @@ +//go:build unit || ALL + +/* +* Copyright 2023 VMware, Inc. All rights reserved. Licensed under the Apache v2 License. + */ + +package govcd + +import ( + "reflect" + "testing" +) + +func Test_readFileAndUnmarshalJSON(t *testing.T) { + type args struct { + filename string + object *testEntity + } + tests := []struct { + name string + args args + want *testEntity + wantErr bool + }{ + { + name: "simpleCase", + args: args{ + filename: "test-resources/test.json", + object: &testEntity{}, + }, + want: &testEntity{Name: "test"}, + wantErr: false, + }, + { + name: "emptyFile", + args: args{ + filename: "test-resources/test_empty.json", + object: &testEntity{}, + }, + want: &testEntity{}, + wantErr: true, + }, + { + name: "emptyJSON", + args: args{ + filename: "test-resources/test_emptyjson.json", + object: &testEntity{}, + }, + want: &testEntity{}, + wantErr: false, + }, + { + name: "nonexistentFile", + args: args{ + filename: "thisfiledoesntexist.json", + object: &testEntity{}, + }, + want: &testEntity{}, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := readFileAndUnmarshalJSON(tt.args.filename, tt.args.object) + if (err != nil) != tt.wantErr { + t.Errorf("readFileAndUnmarshalJSON() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if !reflect.DeepEqual(tt.args.object, tt.want) { + t.Errorf("readFileAndUnmarshalJSON() = %v, want %v", tt.args.object, tt.want) + } + }) + } +} diff --git a/govcd/generic_functions.go b/govcd/generic_functions.go index 16f2b8c79..80b9e39ac 100644 --- a/govcd/generic_functions.go +++ b/govcd/generic_functions.go @@ -1,6 +1,8 @@ package govcd -import "fmt" +import ( + "fmt" +) // oneOrError is used to cover up a common pattern in this codebase which is usually used in // GetXByName functions. diff --git a/govcd/generic_functions_unit_test.go b/govcd/generic_functions_unit_test.go index fc27dc7fb..0dc39ba5f 100644 --- a/govcd/generic_functions_unit_test.go +++ b/govcd/generic_functions_unit_test.go @@ -3,7 +3,6 @@ /* * Copyright 2023 VMware, Inc. All rights reserved. Licensed under the Apache v2 License. */ - package govcd import ( @@ -29,9 +28,9 @@ func Test_oneOrError(t *testing.T) { args: args{ key: "name", name: "test", - entitySlice: []*testEntity{{name: "test"}}, + entitySlice: []*testEntity{{Name: "test"}}, }, - want: &testEntity{name: "test"}, + want: &testEntity{Name: "test"}, wantErr: false, }, { @@ -50,7 +49,7 @@ func Test_oneOrError(t *testing.T) { args: args{ key: "name", name: "test", - entitySlice: []*testEntity{{name: "test"}, {name: "best"}}, + entitySlice: []*testEntity{{Name: "test"}, {Name: "best"}}, }, want: nil, wantErr: true, @@ -60,7 +59,7 @@ func Test_oneOrError(t *testing.T) { args: args{ key: "name", name: "test", - entitySlice: []*testEntity{{name: "test"}, {name: "best"}, {name: "rest"}}, + entitySlice: []*testEntity{{Name: "test"}, {Name: "best"}, {Name: "rest"}}, }, want: nil, wantErr: true, @@ -98,5 +97,5 @@ func Test_oneOrError(t *testing.T) { } type testEntity struct { - name string + Name string `json:"name"` } diff --git a/govcd/test-resources/test.json b/govcd/test-resources/test.json new file mode 100644 index 000000000..10f2877cb --- /dev/null +++ b/govcd/test-resources/test.json @@ -0,0 +1 @@ +{ "Name": "test" } diff --git a/govcd/test-resources/test_empty.json b/govcd/test-resources/test_empty.json new file mode 100644 index 000000000..e69de29bb diff --git a/govcd/test-resources/test_emptyJSON.json b/govcd/test-resources/test_emptyJSON.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/govcd/test-resources/test_emptyJSON.json @@ -0,0 +1 @@ +{} diff --git a/types/v56/types.go b/types/v56/types.go index e814ba36d..7a63f781b 100644 --- a/types/v56/types.go +++ b/types/v56/types.go @@ -3257,10 +3257,12 @@ type UpdateVdcStorageProfiles struct { // ApiTokenRefresh contains the access token resulting from a refresh_token operation type ApiTokenRefresh struct { - AccessToken string `json:"access_token"` - TokenType string `json:"token_type"` - ExpiresIn int `json:"expires_in"` - RefreshToken interface{} `json:"refresh_token"` + AccessToken string `json:"access_token,omitempty"` + TokenType string `json:"token_type,omitempty"` + ExpiresIn int `json:"expires_in,omitempty"` + RefreshToken string `json:"refresh_token,omitempty"` + UpdatedBy string `json:"updated_by,omitempty"` + UpdatedOn string `json:"updated_on,omitempty"` } /**/