From 765dea9471f9ea26d715479624979aaa932142d9 Mon Sep 17 00:00:00 2001 From: Dave Henderson Date: Sun, 13 Nov 2016 22:04:11 -0500 Subject: [PATCH] Support for Vault datasources Signed-off-by: Dave Henderson --- README.md | 38 ++++++++- cleanup.go | 13 +++ data.go | 42 ++++++++-- main.go | 1 + vault/app-id_strategy.go | 88 +++++++++++++++++++ vault/app-id_strategy_test.go | 111 ++++++++++++++++++++++++ vault/client.go | 154 ++++++++++++++++++++++++++++++++++ vault/client_test.go | 141 +++++++++++++++++++++++++++++++ vault/token_strategy.go | 67 +++++++++++++++ vault/token_strategy_test.go | 51 +++++++++++ 10 files changed, 696 insertions(+), 10 deletions(-) create mode 100644 cleanup.go create mode 100644 vault/app-id_strategy.go create mode 100644 vault/app-id_strategy_test.go create mode 100644 vault/client.go create mode 100644 vault/client_test.go create mode 100644 vault/token_strategy.go create mode 100644 vault/token_strategy_test.go diff --git a/README.md b/README.md index e3882741d..5b9618a4d 100644 --- a/README.md +++ b/README.md @@ -217,7 +217,7 @@ Hello world Parses a given datasource (provided by the [`--datasource/-d`](#--datasource-d) argument). -Currently, `file://`, `http://` and `https://` URLs are supported. +Currently, `file://`, `http://`, `https://`, and `vault://` URLs are supported. Currently-supported formats are JSON and YAML. @@ -249,6 +249,42 @@ $ echo 'Hello there, {{(datasource "foo").headers.Host}}...' | gomplate -d foo=h Hello there, httpbin.org... ``` +###### Usage with Vault data + +The special `vault://` URL scheme can be used to retrieve data from [Hashicorp +Vault](https://vaultproject.io). To use this, you must put the Vault server's +URL in the `$VAULT_ADDR` environment variable. + +Currently, the [`app-id`](https://www.vaultproject.io/docs/auth/app-id.html) +auth backend is supported, as well as Vault tokens obtained through external +means. + +To use a Vault datasource with a single secret, just use a URL of +`vault:///secret/mysecret`. Note the 3 `/`s - the host portion of the URL is left +empty. + +```console +$ echo 'My voice is my passport. {{(datasource "vault").value}}' \ + | gomplate -d vault=vault:///secret/sneakers +My voice is my passport. Verify me. +``` + +You can also specify the secret path in the template by using a URL of `vault://` +(or `vault:///`, or `vault:`): +```console +$ echo 'My voice is my passport. {{(datasource "vault" "secret/sneakers").value}}' \ + | gomplate -d vault=vault:// +My voice is my passport. Verify me. +``` + +And the two can be mixed to scope secrets to a specific namespace: + +```console +$ echo 'db_password={{(datasource "vault" "db/pass").value}}' \ + | gomplate -d vault=vault:///secret/production +db_password=prodsecret +``` + #### `ec2meta` Queries AWS [EC2 Instance Metadata](http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ec2-instance-metadata.html) for information. This only retrieves data in the `meta-data` path -- for data in the `dynamic` path use `ec2dynamic`. diff --git a/cleanup.go b/cleanup.go new file mode 100644 index 000000000..9ea78af42 --- /dev/null +++ b/cleanup.go @@ -0,0 +1,13 @@ +package main + +var cleanupHooks = make([]func(), 0) + +func addCleanupHook(hook func()) { + cleanupHooks = append(cleanupHooks, hook) +} + +func runCleanupHooks() { + for _, hook := range cleanupHooks { + hook() + } +} diff --git a/data.go b/data.go index 555309b09..0f966c84e 100644 --- a/data.go +++ b/data.go @@ -14,6 +14,7 @@ import ( "time" "github.com/blang/vfs" + "github.com/hairyhenderson/gomplate/vault" ) func init() { @@ -22,18 +23,19 @@ func init() { mime.AddExtensionType(".yml", "application/yaml") mime.AddExtensionType(".yaml", "application/yaml") - sourceReaders = make(map[string]func(*Source) ([]byte, error)) + sourceReaders = make(map[string]func(*Source, ...string) ([]byte, error)) // Register our source-reader functions addSourceReader("http", readHTTP) addSourceReader("https", readHTTP) addSourceReader("file", readFile) + addSourceReader("vault", readVault) } -var sourceReaders map[string]func(*Source) ([]byte, error) +var sourceReaders map[string]func(*Source, ...string) ([]byte, error) // addSourceReader - -func addSourceReader(scheme string, readFunc func(*Source) ([]byte, error)) { +func addSourceReader(scheme string, readFunc func(*Source, ...string) ([]byte, error)) { sourceReaders[scheme] = readFunc } @@ -67,6 +69,7 @@ type Source struct { Type string FS vfs.Filesystem // used for file: URLs, nil otherwise HC *http.Client // used for http[s]: URLs, nil otherwise + VC *vault.Client //used for vault: URLs, nil otherwise } // NewSource - builds a &Source @@ -141,9 +144,9 @@ func absURL(value string) *url.URL { } // Datasource - -func (d *Data) Datasource(alias string) map[string]interface{} { +func (d *Data) Datasource(alias string, args ...string) map[string]interface{} { source := d.Sources[alias] - b, err := d.ReadSource(source.FS, source) + b, err := d.ReadSource(source.FS, source, args...) if err != nil { log.Fatalf("Couldn't read datasource '%s': %s", alias, err) } @@ -160,7 +163,7 @@ func (d *Data) Datasource(alias string) map[string]interface{} { } // ReadSource - -func (d *Data) ReadSource(fs vfs.Filesystem, source *Source) ([]byte, error) { +func (d *Data) ReadSource(fs vfs.Filesystem, source *Source, args ...string) ([]byte, error) { if d.cache == nil { d.cache = make(map[string][]byte) } @@ -169,7 +172,7 @@ func (d *Data) ReadSource(fs vfs.Filesystem, source *Source) ([]byte, error) { return cached, nil } if r, ok := sourceReaders[source.URL.Scheme]; ok { - data, err := r(source) + data, err := r(source, args...) if err != nil { return nil, err } @@ -181,7 +184,7 @@ func (d *Data) ReadSource(fs vfs.Filesystem, source *Source) ([]byte, error) { return nil, nil } -func readFile(source *Source) ([]byte, error) { +func readFile(source *Source, args ...string) ([]byte, error) { if source.FS == nil { source.FS = vfs.OS() } @@ -207,7 +210,7 @@ func readFile(source *Source) ([]byte, error) { return b, nil } -func readHTTP(source *Source) ([]byte, error) { +func readHTTP(source *Source, args ...string) ([]byte, error) { if source.HC == nil { source.HC = &http.Client{Timeout: time.Second * 5} } @@ -234,3 +237,24 @@ func readHTTP(source *Source) ([]byte, error) { } return body, nil } + +func readVault(source *Source, args ...string) ([]byte, error) { + if source.VC == nil { + source.VC = vault.NewClient() + source.VC.Login() + addCleanupHook(source.VC.RevokeToken) + } + + p := source.URL.Path + if len(args) == 1 { + p = p + "/" + args[0] + } + + data, err := source.VC.Read(p) + if err != nil { + return nil, err + } + source.Type = "application/json" + + return data, nil +} diff --git a/main.go b/main.go index 50b614726..c3c923408 100644 --- a/main.go +++ b/main.go @@ -70,6 +70,7 @@ func NewGomplate(data *Data) *Gomplate { } func runTemplate(c *cli.Context) error { + defer runCleanupHooks() data := NewData(c.StringSlice("datasource")) g := NewGomplate(data) diff --git a/vault/app-id_strategy.go b/vault/app-id_strategy.go new file mode 100644 index 000000000..2d8b04b5c --- /dev/null +++ b/vault/app-id_strategy.go @@ -0,0 +1,88 @@ +package vault + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/url" + "os" + "time" +) + +// AppIDAuthStrategy - an AuthStrategy that uses Vault's app-id authentication backend. +type AppIDAuthStrategy struct { + AppID string `json:"app_id"` + UserID string `json:"user_id"` + hc *http.Client +} + +// NewAppIDAuthStrategy - create an AuthStrategy that uses Vault's app-id auth +// backend. +func NewAppIDAuthStrategy() *AppIDAuthStrategy { + appID := os.Getenv("VAULT_APP_ID") + userID := os.Getenv("VAULT_USER_ID") + if appID != "" && userID != "" { + return &AppIDAuthStrategy{appID, userID, nil} + } + return nil +} + +// GetToken - log in to the app-id auth backend and return the client token +func (a *AppIDAuthStrategy) GetToken(addr *url.URL) (string, error) { + if a.hc == nil { + a.hc = &http.Client{Timeout: time.Second * 5} + } + client := a.hc + + buf := new(bytes.Buffer) + json.NewEncoder(buf).Encode(&a) + + u := &url.URL{} + *u = *addr + u.Path = "/v1/auth/app-id/login" + res, err := client.Post(u.String(), "application/json; charset=utf-8", buf) + if err != nil { + return "", err + } + response := &AuthResponse{} + err = json.NewDecoder(res.Body).Decode(response) + res.Body.Close() + if err != nil { + return "", err + } + if res.StatusCode != 200 { + err := fmt.Errorf("Unexpected HTTP status %d on AppId login to %s: %s", res.StatusCode, u, response) + return "", err + } + return response.Auth.ClientToken, nil +} + +// Revokable - +func (a *AppIDAuthStrategy) Revokable() bool { + return true +} + +func (a *AppIDAuthStrategy) String() string { + return fmt.Sprintf("app-id: %s, user-id: %s", a.AppID, a.UserID) +} + +// AuthResponse - the Auth response from /v1/auth/app-id/login +type AuthResponse struct { + Auth struct { + ClientToken string `json:"client_token"` + LeaseDuration int64 `json:"lease_duration"` + Metadata struct { + AppID string `json:"app-id"` + UserID string `json:"user-id"` + } `json:"metadata"` + Policies []string `json:"policies"` + Renewable bool `json:"renewable"` + } `json:"auth"` +} + +func (a *AuthResponse) String() string { + buf := new(bytes.Buffer) + json.NewEncoder(buf).Encode(&a) + return string(buf.Bytes()) +} diff --git a/vault/app-id_strategy_test.go b/vault/app-id_strategy_test.go new file mode 100644 index 000000000..81824549b --- /dev/null +++ b/vault/app-id_strategy_test.go @@ -0,0 +1,111 @@ +package vault + +import ( + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "os" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewAppIDAuthStrategy(t *testing.T) { + os.Unsetenv("VAULT_APP_ID") + os.Unsetenv("VAULT_USER_ID") + assert.Nil(t, NewAppIDAuthStrategy()) + + os.Setenv("VAULT_APP_ID", "foo") + assert.Nil(t, NewAppIDAuthStrategy()) + + os.Unsetenv("VAULT_APP_ID") + os.Setenv("VAULT_USER_ID", "bar") + assert.Nil(t, NewAppIDAuthStrategy()) + + os.Setenv("VAULT_APP_ID", "foo") + os.Setenv("VAULT_USER_ID", "bar") + auth := NewAppIDAuthStrategy() + assert.Equal(t, "foo", auth.AppID) + assert.Equal(t, "bar", auth.UserID) +} + +func TestGetToken_AppIDErrorsGivenNetworkError(t *testing.T) { + server, client := setupErrorHTTP() + defer server.Close() + + vaultURL, _ := url.Parse("http://vault:8200") + + auth := &AppIDAuthStrategy{"foo", "bar", client} + _, err := auth.GetToken(vaultURL) + assert.Error(t, err) +} + +func TestGetToken_AppIDErrorsGivenHTTPErrorStatus(t *testing.T) { + server, client := setupHTTP(500, "application/json; charset=utf-8", `{}`) + defer server.Close() + + vaultURL, _ := url.Parse("http://vault:8200") + + auth := &AppIDAuthStrategy{"foo", "bar", client} + _, err := auth.GetToken(vaultURL) + assert.Error(t, err) +} + +func TestGetToken_AppIDErrorsGivenBadJSON(t *testing.T) { + server, client := setupHTTP(200, "application/json; charset=utf-8", `{`) + defer server.Close() + + vaultURL, _ := url.Parse("http://vault:8200") + + auth := &AppIDAuthStrategy{"foo", "bar", client} + _, err := auth.GetToken(vaultURL) + assert.Error(t, err) +} + +func TestGetToken_AppID(t *testing.T) { + server, client := setupHTTP(200, "application/json; charset=utf-8", `{"auth": {"client_token": "baz"}}`) + defer server.Close() + + vaultURL, _ := url.Parse("http://vault:8200") + + auth := &AppIDAuthStrategy{"foo", "bar", client} + token, err := auth.GetToken(vaultURL) + assert.NoError(t, err) + + assert.Equal(t, "baz", token) +} + +func setupHTTP(code int, mimetype string, body string) (*httptest.Server, *http.Client) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", mimetype) + w.WriteHeader(code) + fmt.Fprintln(w, body) + })) + + client := &http.Client{ + Transport: &http.Transport{ + Proxy: func(req *http.Request) (*url.URL, error) { + return url.Parse(server.URL) + }, + }, + } + + return server, client +} + +func setupErrorHTTP() (*httptest.Server, *http.Client) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + panic("boo") + })) + + client := &http.Client{ + Transport: &http.Transport{ + Proxy: func(req *http.Request) (*url.URL, error) { + return url.Parse(server.URL) + }, + }, + } + + return server, client +} diff --git a/vault/client.go b/vault/client.go new file mode 100644 index 000000000..3a8cad658 --- /dev/null +++ b/vault/client.go @@ -0,0 +1,154 @@ +package vault + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "log" + "net/http" + "net/url" + "os" + "regexp" + "time" +) + +// Client - +type Client struct { + Addr *url.URL + Auth AuthStrategy + // The cached auth token + token string + hc *http.Client +} + +// AuthStrategy - +type AuthStrategy interface { + fmt.Stringer + GetToken(addr *url.URL) (string, error) + Revokable() bool +} + +// NewClient - instantiate a new +func NewClient() *Client { + u := getVaultAddr() + auth := getAuthStrategy() + return &Client{u, auth, "", nil} +} + +func getVaultAddr() *url.URL { + vu := os.Getenv("VAULT_ADDR") + u, err := url.Parse(vu) + if err != nil { + log.Fatal("VAULT_ADDR is an unparseable URL!", err) + } + return u +} + +func getAuthStrategy() AuthStrategy { + if auth := NewAppIDAuthStrategy(); auth != nil { + return auth + } + if auth := NewTokenAuthStrategy(); auth != nil { + return auth + } + return nil +} + +// Login - log in to Vault with the discovered auth backend and save the token +func (c *Client) Login() error { + token, err := c.Auth.GetToken(c.Addr) + if err != nil { + log.Fatal(err) + return err + } + c.token = token + return nil +} + +// RevokeToken - revoke the current auth token - effectively logging out +func (c *Client) RevokeToken() { + // only do it if the auth strategy supports it! + if !c.Auth.Revokable() { + return + } + + if c.hc == nil { + c.hc = &http.Client{Timeout: time.Second * 5} + } + + u := &url.URL{} + *u = *c.Addr + u.Path = "/v1/auth/token/revoke-self" + req, _ := http.NewRequest("POST", u.String(), nil) + req.Header.Set("X-Vault-Token", c.token) + + res, err := c.hc.Do(req) + if err != nil { + log.Println("Error while revoking Vault Token", err) + } + + if res.StatusCode != 204 { + log.Printf("Unexpected HTTP status %d on RevokeToken from %s (token was %s)", res.StatusCode, u, c.token) + } +} + +func (c *Client) Read(path string) ([]byte, error) { + path = normalizeURLPath(path) + if c.hc == nil { + c.hc = &http.Client{Timeout: time.Second * 5} + } + + u := &url.URL{} + *u = *c.Addr + u.Path = "/v1" + path + req, err := http.NewRequest("GET", u.String(), nil) + if err != nil { + return nil, err + } + req.Header.Set("X-Vault-Token", c.token) + + res, err := c.hc.Do(req) + if err != nil { + return nil, err + } + + body, err := ioutil.ReadAll(res.Body) + res.Body.Close() + if err != nil { + return nil, err + } + + if res.StatusCode != 200 { + err = fmt.Errorf("Unexpected HTTP status %d on Read from %s: %s", res.StatusCode, u, body) + return nil, err + } + + response := make(map[string]interface{}) + err = json.Unmarshal(body, &response) + if err != nil { + log.Println("argh - couldn't decode the response", err) + return nil, err + } + + if _, ok := response["data"]; !ok { + return nil, fmt.Errorf("Unexpected HTTP body on Read for %s: %s", u, body) + } + + return json.Marshal(response["data"]) +} + +var rxDupSlashes = regexp.MustCompile(`/{2,}`) + +func normalizeURLPath(path string) string { + if len(path) > 0 { + path = rxDupSlashes.ReplaceAllString(path, "/") + } + return path +} + +// ReadResponse - +type ReadResponse struct { + Data struct { + Value string `json:"value"` + } `json:"data"` +} diff --git a/vault/client_test.go b/vault/client_test.go new file mode 100644 index 000000000..b25c9e11c --- /dev/null +++ b/vault/client_test.go @@ -0,0 +1,141 @@ +package vault + +import ( + "net/url" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestLogin_SavesToken(t *testing.T) { + auth := &TokenAuthStrategy{"foo"} + client := &Client{ + Auth: auth, + } + err := client.Login() + assert.NoError(t, err) + assert.Equal(t, "foo", client.token) +} + +func TestRead_ErrorsGivenNetworkError(t *testing.T) { + server, hc := setupErrorHTTP() + defer server.Close() + + auth := &TokenAuthStrategy{"foo"} + vaultURL, _ := url.Parse("http://vault:8200") + client := &Client{ + Addr: vaultURL, + Auth: auth, + token: "foo", + hc: hc, + } + _, err := client.Read("secret/bar") + assert.Error(t, err) +} + +func TestRead_ErrorsGivenNonOKStatus(t *testing.T) { + server, hc := setupHTTP(404, "application/json; charset=utf-8", `{}`) + defer server.Close() + + auth := &TokenAuthStrategy{"foo"} + vaultURL, _ := url.Parse("http://vault:8200") + client := &Client{ + Addr: vaultURL, + Auth: auth, + token: "foo", + hc: hc, + } + _, err := client.Read("secret/bar") + assert.Error(t, err) +} + +func TestRead_ErrorsGivenBadJSON(t *testing.T) { + server, hc := setupHTTP(200, "application/json; charset=utf-8", `{`) + defer server.Close() + + auth := &TokenAuthStrategy{"foo"} + vaultURL, _ := url.Parse("http://vault:8200") + client := &Client{ + Addr: vaultURL, + Auth: auth, + token: "foo", + hc: hc, + } + _, err := client.Read("secret/bar") + assert.Error(t, err) +} + +func TestRead_ErrorsGivenWrongJSON(t *testing.T) { + server, hc := setupHTTP(200, "application/json; charset=utf-8", `{}`) + defer server.Close() + + auth := &TokenAuthStrategy{"foo"} + vaultURL, _ := url.Parse("http://vault:8200") + client := &Client{ + Addr: vaultURL, + Auth: auth, + token: "foo", + hc: hc, + } + _, err := client.Read("secret/bar") + assert.Error(t, err) +} + +func TestRead_ReturnsDataProp(t *testing.T) { + server, hc := setupHTTP(200, "application/json; charset=utf-8", `{"data": {"value": "hi"}}`) + defer server.Close() + + auth := &TokenAuthStrategy{"foo"} + vaultURL, _ := url.Parse("http://vault:8200") + client := &Client{ + Addr: vaultURL, + Auth: auth, + token: "foo", + hc: hc, + } + value, err := client.Read("secret/bar") + assert.NoError(t, err) + assert.Equal(t, []byte(`{"value":"hi"}`), value) +} + +type fakeAuth struct { + revokable bool + token string +} + +func (a *fakeAuth) String() string { + return a.token +} + +func (a *fakeAuth) GetToken(addr *url.URL) (string, error) { + return a.token, nil +} + +func (a *fakeAuth) Revokable() bool { + return a.revokable +} + +func TestRevokeToken_NoopGivenNonRevokableAuth(t *testing.T) { + auth := &fakeAuth{false, "foo"} + client := &Client{ + Auth: auth, + } + client.Login() + client.RevokeToken() + assert.Equal(t, "foo", client.token) +} + +func TestRevokeToken(t *testing.T) { + server, hc := setupHTTP(204, "application/json; charset=utf-8", ``) + defer server.Close() + + auth := &fakeAuth{true, "foo"} + vaultURL, _ := url.Parse("http://vault:8200") + client := &Client{ + Addr: vaultURL, + Auth: auth, + token: "foo", + hc: hc, + } + client.RevokeToken() +} diff --git a/vault/token_strategy.go b/vault/token_strategy.go new file mode 100644 index 000000000..aa02d27ac --- /dev/null +++ b/vault/token_strategy.go @@ -0,0 +1,67 @@ +package vault + +import ( + "fmt" + "github.com/blang/vfs" + "io/ioutil" + "log" + "net/url" + "os" + "os/user" + "path" +) + +// TokenAuthStrategy - a pass-through strategy for situations where we already +// have a Vault token. +type TokenAuthStrategy struct { + Token string +} + +// NewTokenAuthStrategy - Try to create a new TokenAuthStrategy. If we can't +// nil will be returned. +func NewTokenAuthStrategy(fsOverrides ...vfs.Filesystem) *TokenAuthStrategy { + var fs vfs.Filesystem + if len(fsOverrides) == 0 { + fs = vfs.OS() + } else { + fs = fsOverrides[0] + } + + if token := os.Getenv("VAULT_TOKEN"); token != "" { + return &TokenAuthStrategy{token} + } + if token := getTokenFromFile(fs); token != "" { + return &TokenAuthStrategy{token} + } + return nil +} + +// GetToken - return the token +func (a *TokenAuthStrategy) GetToken(addr *url.URL) (string, error) { + return a.Token, nil +} + +func (a *TokenAuthStrategy) String() string { + return fmt.Sprintf("token: %s", a.Token) +} + +// Revokable - +func (a *TokenAuthStrategy) Revokable() bool { + return false +} + +func getTokenFromFile(fs vfs.Filesystem) string { + u, err := user.Current() + if err != nil { + log.Fatal(err) + } + f, err := fs.OpenFile(path.Join(u.HomeDir, ".vault-token"), os.O_RDONLY, 0) + if err != nil { + return "" + } + b, err := ioutil.ReadAll(f) + if err != nil { + return "" + } + return string(b) +} diff --git a/vault/token_strategy_test.go b/vault/token_strategy_test.go new file mode 100644 index 000000000..801d3045b --- /dev/null +++ b/vault/token_strategy_test.go @@ -0,0 +1,51 @@ +package vault + +import ( + "os" + "os/user" + "path" + "testing" + + "github.com/blang/vfs" + "github.com/blang/vfs/memfs" + "github.com/stretchr/testify/assert" +) + +func TestNewTokenAuthStrategy_FromEnvVar(t *testing.T) { + token := "deadbeef" + + os.Setenv("VAULT_TOKEN", token) + defer os.Unsetenv("VAULT_TOKEN") + + auth := NewTokenAuthStrategy() + assert.Equal(t, token, auth.Token) +} + +func TestNewTokenAuthStrategy_FromFileGivenNoEnvVar(t *testing.T) { + token := "deadbeef" + u, err := user.Current() + assert.NoError(t, err) + + fs := memfs.Create() + err = vfs.MkdirAll(fs, u.HomeDir, 0777) + assert.NoError(t, err) + f, err := vfs.Create(fs, path.Join(u.HomeDir, ".vault-token")) + assert.NoError(t, err) + f.Write([]byte(token)) + + auth := NewTokenAuthStrategy(fs) + assert.Equal(t, token, auth.Token) +} + +func TestNewTokenAuthStrategy_NilGivenNoVarOrFile(t *testing.T) { + os.Unsetenv("VAULT_TOKEN") + assert.Nil(t, NewTokenAuthStrategy(memfs.Create())) +} + +func TestGetToken_Token(t *testing.T) { + expected := "foo" + auth := &TokenAuthStrategy{expected} + actual, err := auth.GetToken(nil) + assert.NoError(t, err) + assert.Equal(t, expected, actual) +}