Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add ability to authorize using a Service Account #562

Merged
merged 17 commits into from
Apr 19, 2023
2 changes: 2 additions & 0 deletions .changes/v2.20.0/562-improvements.md
Original file line number Diff line number Diff line change
@@ -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]
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand All @@ -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
Expand Down
76 changes: 76 additions & 0 deletions govcd/api_token.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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) {
Expand Down Expand Up @@ -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
}
75 changes: 75 additions & 0 deletions govcd/api_token_unit_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
4 changes: 3 additions & 1 deletion govcd/generic_functions.go
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
11 changes: 5 additions & 6 deletions govcd/generic_functions_unit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
/*
* Copyright 2023 VMware, Inc. All rights reserved. Licensed under the Apache v2 License.
*/

package govcd

import (
Expand All @@ -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,
},
{
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -98,5 +97,5 @@ func Test_oneOrError(t *testing.T) {
}

type testEntity struct {
name string
Name string `json:"name"`
}
1 change: 1 addition & 0 deletions govcd/test-resources/test.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{ "Name": "test" }
Empty file.
1 change: 1 addition & 0 deletions govcd/test-resources/test_emptyJSON.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{}
10 changes: 6 additions & 4 deletions types/v56/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}

/**/
Expand Down