Skip to content

Commit fe1bed6

Browse files
author
adezxc
authored
Add ability to authorize using a Service Account (#562)
* add ability to authorize using a service account --------- Signed-off-by: Adam Jasinski <jasinskia@vmware.com>
1 parent 35dc5da commit fe1bed6

10 files changed

+175
-12
lines changed

.changes/v2.20.0/562-improvements.md

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
* Add `SetServiceAccountApiToken` method of `*VCDClient` that allows
2+
authenticating using a service account token file and handles the refresh token rotation [GH-562]

README.md

+6-1
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ func main() {
114114

115115
## Authentication
116116

117-
You can authenticate to the vCD in four ways:
117+
You can authenticate to the vCD in five ways:
118118

119119
* With a System Administration user and password (`administrator@system`)
120120
* With an Organization user and password (`tenant-admin@org-name`)
@@ -133,6 +133,11 @@ For the above two methods, you use:
133133
The file `scripts/get_token.sh` provides a handy method of extracting the token
134134
(`x-vcloud-authorization` value) for future use.
135135

136+
* With a service account token (the file needs to have `r+w` rights)
137+
```go
138+
err := vcdClient.SetServiceAccountApiToken(Org, "tokenfile.json")
139+
```
140+
136141
* SAML user and password (works with ADFS as IdP using WS-TRUST endpoint
137142
"/adfs/services/trust/13/usernamemixed"). One must pass `govcd.WithSamlAdfs(true,customAdfsRptId)`
138143
and username must be formatted so that ADFS understands it ('user@contoso.com' or

govcd/api_token.go

+76
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,13 @@ import (
99
"encoding/json"
1010
"fmt"
1111
"io"
12+
"io/fs"
1213
"net/http"
1314
"net/url"
15+
"os"
16+
"path"
1417
"strings"
18+
"time"
1519

1620
"github.com/vmware/go-vcloud-director/v2/types/v56"
1721
"github.com/vmware/go-vcloud-director/v2/util"
@@ -31,6 +35,47 @@ func (vcdClient *VCDClient) SetApiToken(org, apiToken string) (*types.ApiTokenRe
3135
return tokenRefresh, nil
3236
}
3337

38+
// SetServiceAccountApiToken reads the current Service Account API token,
39+
// sets the client's bearer token and fetches a new API token for next
40+
// authentication request using SetApiToken and overwrites the old file.
41+
func (vcdClient *VCDClient) SetServiceAccountApiToken(org, apiTokenFile string) error {
42+
if vcdClient.Client.APIVCDMaxVersionIs("< 37.0") {
43+
version, err := vcdClient.Client.GetVcdFullVersion()
44+
if err == nil {
45+
return fmt.Errorf("minimum version for Service Account authentication is 10.4 - Version detected: %s", version.Version)
46+
}
47+
// If we can't get the VCD version, we return API version info
48+
return fmt.Errorf("minimum API version for Service Account authentication is 37.0 - Version detected: %s", vcdClient.Client.APIVersion)
49+
}
50+
51+
saApiToken := &types.ApiTokenRefresh{}
52+
// Read file contents and unmarshal them to saApiToken
53+
err := readFileAndUnmarshalJSON(apiTokenFile, saApiToken)
54+
if err != nil {
55+
return err
56+
}
57+
58+
// Get bearer token and update the refresh token for the next authentication request
59+
saApiToken, err = vcdClient.SetApiToken(org, saApiToken.RefreshToken)
60+
if err != nil {
61+
return err
62+
}
63+
64+
// leave only the refresh token to not leave any sensitive information
65+
saApiToken = &types.ApiTokenRefresh{
66+
RefreshToken: saApiToken.RefreshToken,
67+
TokenType: "Service Account",
68+
UpdatedBy: vcdClient.Client.UserAgent,
69+
UpdatedOn: time.Now().Format(time.RFC3339),
70+
}
71+
err = marshalJSONAndWriteToFile(apiTokenFile, saApiToken, 0600)
72+
if err != nil {
73+
return err
74+
}
75+
76+
return nil
77+
}
78+
3479
// GetBearerTokenFromApiToken uses an API token to retrieve a bearer token
3580
// using the refresh token operation.
3681
func (vcdClient *VCDClient) GetBearerTokenFromApiToken(org, token string) (*types.ApiTokenRefresh, error) {
@@ -112,3 +157,34 @@ func (vcdClient *VCDClient) GetBearerTokenFromApiToken(org, token string) (*type
112157
}
113158
return &tokenDef, nil
114159
}
160+
161+
// readFileAndUnmarshalJSON reads a file and unmarshals it to the given variable
162+
func readFileAndUnmarshalJSON(filename string, object any) error {
163+
data, err := os.ReadFile(path.Clean(filename))
164+
if err != nil {
165+
return fmt.Errorf("failed to read from file: %s", err)
166+
}
167+
168+
err = json.Unmarshal(data, object)
169+
if err != nil {
170+
return fmt.Errorf("failed to unmarshal file contents to the object: %s", err)
171+
}
172+
173+
return nil
174+
}
175+
176+
// marshalJSONAndWriteToFile marshalls the given object into JSON and writes
177+
// to a file with the given permissions in octal format (e.g 0600)
178+
func marshalJSONAndWriteToFile(filename string, object any, permissions int) error {
179+
data, err := json.MarshalIndent(object, " ", " ")
180+
if err != nil {
181+
return fmt.Errorf("error marshalling object to JSON: %s", err)
182+
}
183+
184+
err = os.WriteFile(filename, data, fs.FileMode(permissions))
185+
if err != nil {
186+
return fmt.Errorf("error writing to the file: %s", err)
187+
}
188+
189+
return nil
190+
}

govcd/api_token_unit_test.go

+75
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
//go:build unit || ALL
2+
3+
/*
4+
* Copyright 2023 VMware, Inc. All rights reserved. Licensed under the Apache v2 License.
5+
*/
6+
7+
package govcd
8+
9+
import (
10+
"reflect"
11+
"testing"
12+
)
13+
14+
func Test_readFileAndUnmarshalJSON(t *testing.T) {
15+
type args struct {
16+
filename string
17+
object *testEntity
18+
}
19+
tests := []struct {
20+
name string
21+
args args
22+
want *testEntity
23+
wantErr bool
24+
}{
25+
{
26+
name: "simpleCase",
27+
args: args{
28+
filename: "test-resources/test.json",
29+
object: &testEntity{},
30+
},
31+
want: &testEntity{Name: "test"},
32+
wantErr: false,
33+
},
34+
{
35+
name: "emptyFile",
36+
args: args{
37+
filename: "test-resources/test_empty.json",
38+
object: &testEntity{},
39+
},
40+
want: &testEntity{},
41+
wantErr: true,
42+
},
43+
{
44+
name: "emptyJSON",
45+
args: args{
46+
filename: "test-resources/test_emptyjson.json",
47+
object: &testEntity{},
48+
},
49+
want: &testEntity{},
50+
wantErr: false,
51+
},
52+
{
53+
name: "nonexistentFile",
54+
args: args{
55+
filename: "thisfiledoesntexist.json",
56+
object: &testEntity{},
57+
},
58+
want: &testEntity{},
59+
wantErr: true,
60+
},
61+
}
62+
for _, tt := range tests {
63+
t.Run(tt.name, func(t *testing.T) {
64+
err := readFileAndUnmarshalJSON(tt.args.filename, tt.args.object)
65+
if (err != nil) != tt.wantErr {
66+
t.Errorf("readFileAndUnmarshalJSON() error = %v, wantErr %v", err, tt.wantErr)
67+
return
68+
}
69+
70+
if !reflect.DeepEqual(tt.args.object, tt.want) {
71+
t.Errorf("readFileAndUnmarshalJSON() = %v, want %v", tt.args.object, tt.want)
72+
}
73+
})
74+
}
75+
}

govcd/generic_functions.go

+3-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package govcd
22

3-
import "fmt"
3+
import (
4+
"fmt"
5+
)
46

57
// oneOrError is used to cover up a common pattern in this codebase which is usually used in
68
// GetXByName functions.

govcd/generic_functions_unit_test.go

+5-6
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
/*
44
* Copyright 2023 VMware, Inc. All rights reserved. Licensed under the Apache v2 License.
55
*/
6-
76
package govcd
87

98
import (
@@ -29,9 +28,9 @@ func Test_oneOrError(t *testing.T) {
2928
args: args{
3029
key: "name",
3130
name: "test",
32-
entitySlice: []*testEntity{{name: "test"}},
31+
entitySlice: []*testEntity{{Name: "test"}},
3332
},
34-
want: &testEntity{name: "test"},
33+
want: &testEntity{Name: "test"},
3534
wantErr: false,
3635
},
3736
{
@@ -50,7 +49,7 @@ func Test_oneOrError(t *testing.T) {
5049
args: args{
5150
key: "name",
5251
name: "test",
53-
entitySlice: []*testEntity{{name: "test"}, {name: "best"}},
52+
entitySlice: []*testEntity{{Name: "test"}, {Name: "best"}},
5453
},
5554
want: nil,
5655
wantErr: true,
@@ -60,7 +59,7 @@ func Test_oneOrError(t *testing.T) {
6059
args: args{
6160
key: "name",
6261
name: "test",
63-
entitySlice: []*testEntity{{name: "test"}, {name: "best"}, {name: "rest"}},
62+
entitySlice: []*testEntity{{Name: "test"}, {Name: "best"}, {Name: "rest"}},
6463
},
6564
want: nil,
6665
wantErr: true,
@@ -98,5 +97,5 @@ func Test_oneOrError(t *testing.T) {
9897
}
9998

10099
type testEntity struct {
101-
name string
100+
Name string `json:"name"`
102101
}

govcd/test-resources/test.json

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{ "Name": "test" }

govcd/test-resources/test_empty.json

Whitespace-only changes.
+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{}

types/v56/types.go

+6-4
Original file line numberDiff line numberDiff line change
@@ -3257,10 +3257,12 @@ type UpdateVdcStorageProfiles struct {
32573257

32583258
// ApiTokenRefresh contains the access token resulting from a refresh_token operation
32593259
type ApiTokenRefresh struct {
3260-
AccessToken string `json:"access_token"`
3261-
TokenType string `json:"token_type"`
3262-
ExpiresIn int `json:"expires_in"`
3263-
RefreshToken interface{} `json:"refresh_token"`
3260+
AccessToken string `json:"access_token,omitempty"`
3261+
TokenType string `json:"token_type,omitempty"`
3262+
ExpiresIn int `json:"expires_in,omitempty"`
3263+
RefreshToken string `json:"refresh_token,omitempty"`
3264+
UpdatedBy string `json:"updated_by,omitempty"`
3265+
UpdatedOn string `json:"updated_on,omitempty"`
32643266
}
32653267

32663268
/**/

0 commit comments

Comments
 (0)