Skip to content

Commit

Permalink
Add ability to authorize using a Service Account (vmware#562)
Browse files Browse the repository at this point in the history
* add ability to authorize using a service account

---------

Signed-off-by: Adam Jasinski <jasinskia@vmware.com>
  • Loading branch information
adezxc committed Apr 19, 2023
1 parent 35dc5da commit fe1bed6
Show file tree
Hide file tree
Showing 10 changed files with 175 additions and 12 deletions.
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

0 comments on commit fe1bed6

Please sign in to comment.