Skip to content

Commit

Permalink
Native Login method for Go client (#12796)
Browse files Browse the repository at this point in the history
* Native Login method, userpass and approle interfaces to implement it

* Add AWS auth interface for Login, unexported struct fields for now

* Add Kubernetes client login

* Add changelog

* Add a test for approle client login

* Return errors from LoginOptions, use limited reader for secret ID

* Fix auth comment length

* Return actual type not interface, check for client token in tests

* Require specification of secret ID location using SecretID struct as AppRole arg

* Allow password from env, file, or plaintext

* Add flexibility in how to fetch k8s service token, but still with default

* Avoid passing strings that need to be validated by just having different login options

* Try a couple real tests with approle and userpass login

* Fix method name in comment

* Add context to Login methods, remove comments about certain sources being inherently insecure

* Perform read of secret ID at login time

* Read password from file at login time

* Pass context in integ tests

* Read env var values in at login time, add extra tests

* Update api version

* Revert "Update api version"

This reverts commit 1ef3949.

* Update api version in all go.mod files
  • Loading branch information
digivava committed Oct 26, 2021
1 parent 8f1a4cc commit 3ed7bca
Show file tree
Hide file tree
Showing 19 changed files with 2,650 additions and 15 deletions.
35 changes: 35 additions & 0 deletions api/auth.go
@@ -1,11 +1,46 @@
package api

import (
"context"
"fmt"
)

// Auth is used to perform credential backend related operations.
type Auth struct {
c *Client
}

type AuthMethod interface {
Login(ctx context.Context, client *Client) (*Secret, error)
}

// Auth is used to return the client for credential-backend API calls.
func (c *Client) Auth() *Auth {
return &Auth{c: c}
}

// Login sets up the required request body for login requests to the given auth
// method's /login API endpoint, and then performs a write to it. After a
// successful login, this method will automatically set the client's token to
// the login response's ClientToken as well.
//
// The Secret returned is the authentication secret, which if desired can be
// passed as input to the NewLifetimeWatcher method in order to start
// automatically renewing the token.
func (a *Auth) Login(ctx context.Context, authMethod AuthMethod) (*Secret, error) {
if authMethod == nil {
return nil, fmt.Errorf("no auth method provided for login")
}

authSecret, err := authMethod.Login(ctx, a.c)
if err != nil {
return nil, fmt.Errorf("unable to log in to auth method: %w", err)
}
if authSecret == nil || authSecret.Auth == nil || authSecret.Auth.ClientToken == "" {
return nil, fmt.Errorf("login response from auth method did not return client token")
}

a.c.SetToken(authSecret.Auth.ClientToken)

return authSecret, nil
}
199 changes: 199 additions & 0 deletions api/auth/approle/approle.go
@@ -0,0 +1,199 @@
package approle

import (
"context"
"fmt"
"io"
"os"
"strings"

"github.com/hashicorp/vault/api"
)

type AppRoleAuth struct {
mountPath string
roleID string
secretID string
secretIDFile string
secretIDEnv string
unwrap bool
}

var _ api.AuthMethod = (*AppRoleAuth)(nil)

// SecretID is a struct that allows you to specify where your application is
// storing the secret ID required for login to the AppRole auth method.
type SecretID struct {
// Path on the file system where a trusted orchestrator has placed the
// application's secret ID. The recommended secure pattern is to use
// response-wrapping tokens rather than a plaintext value, by passing
// WithWrappingToken() to NewAppRoleAuth.
// https://learn.hashicorp.com/tutorials/vault/approle-best-practices?in=vault/auth-methods#secretid-delivery-best-practices
FromFile string
// The name of the environment variable containing the application's
// secret ID.
FromEnv string
// The secret ID as a plaintext string value.
FromString string
}

type LoginOption func(a *AppRoleAuth) error

const (
defaultMountPath = "approle"
)

// NewAppRoleAuth initializes a new AppRole auth method interface to be
// passed as a parameter to the client.Auth().Login method.
//
// For a secret ID, the recommended secure pattern is to unwrap a one-time-use
// response-wrapping token that was placed here by a trusted orchestrator
// (https://learn.hashicorp.com/tutorials/vault/approle-best-practices?in=vault/auth-methods#secretid-delivery-best-practices)
// To indicate that the filepath points to this wrapping token and not just
// a plaintext secret ID, initialize NewAppRoleAuth with the
// WithWrappingToken LoginOption.
//
// Supported options: WithMountPath, WithWrappingToken
func NewAppRoleAuth(roleID string, secretID *SecretID, opts ...LoginOption) (*AppRoleAuth, error) {
if roleID == "" {
return nil, fmt.Errorf("no role ID provided for login")
}

if secretID == nil {
return nil, fmt.Errorf("no secret ID provided for login")
}

err := secretID.validate()
if err != nil {
return nil, fmt.Errorf("invalid secret ID: %w", err)
}

a := &AppRoleAuth{
mountPath: defaultMountPath,
roleID: roleID,
}

// secret ID will be read in at login time if it comes from a file or environment variable, in case the underlying value changes
if secretID.FromFile != "" {
a.secretIDFile = secretID.FromFile
}

if secretID.FromEnv != "" {
a.secretIDEnv = secretID.FromEnv
}

if secretID.FromString != "" {
a.secretID = secretID.FromString
}

// Loop through each option
for _, opt := range opts {
// Call the option giving the instantiated
// *AppRoleAuth as the argument
err := opt(a)
if err != nil {
return nil, fmt.Errorf("error with login option: %w", err)
}
}

// return the modified auth struct instance
return a, nil
}

func (a *AppRoleAuth) Login(ctx context.Context, client *api.Client) (*api.Secret, error) {
loginData := map[string]interface{}{
"role_id": a.roleID,
}

if a.secretIDFile != "" {
secretIDValue, err := a.readSecretIDFromFile()
if err != nil {
return nil, fmt.Errorf("error reading secret ID: %w", err)
}

// if it was indicated that the value in the file was actually a wrapping
// token, unwrap it first
if a.unwrap {
unwrappedToken, err := client.Logical().Unwrap(secretIDValue)
if err != nil {
return nil, fmt.Errorf("unable to unwrap token: %w. If the AppRoleAuth struct was initialized with the WithWrappingToken LoginOption, then the secret ID's filepath should be a path to a response-wrapping token", err)
}
loginData["secret_id"] = unwrappedToken.Data["secret_id"]
} else {
loginData["secret_id"] = secretIDValue
}
} else if a.secretIDEnv != "" {
secretIDValue := os.Getenv(a.secretIDEnv)
if secretIDValue == "" {
return nil, fmt.Errorf("secret ID was specified with an environment variable with an empty value")
}
loginData["secret_id"] = secretIDValue
} else {
loginData["secret_id"] = a.secretID
}

path := fmt.Sprintf("auth/%s/login", a.mountPath)
resp, err := client.Logical().Write(path, loginData)
if err != nil {
return nil, fmt.Errorf("unable to log in with app role auth: %w", err)
}

return resp, nil
}

func WithMountPath(mountPath string) LoginOption {
return func(a *AppRoleAuth) error {
a.mountPath = mountPath
return nil
}
}

func WithWrappingToken() LoginOption {
return func(a *AppRoleAuth) error {
a.unwrap = true
return nil
}
}

func (a *AppRoleAuth) readSecretIDFromFile() (string, error) {
secretIDFile, err := os.Open(a.secretIDFile)
if err != nil {
return "", fmt.Errorf("unable to open file containing secret ID: %w", err)
}
defer secretIDFile.Close()

limitedReader := io.LimitReader(secretIDFile, 1000)
secretIDBytes, err := io.ReadAll(limitedReader)
if err != nil {
return "", fmt.Errorf("unable to read secret ID: %w", err)
}

secretIDValue := strings.TrimSuffix(string(secretIDBytes), "\n")

return secretIDValue, nil
}

func (secretID *SecretID) validate() error {
if secretID.FromFile == "" && secretID.FromEnv == "" && secretID.FromString == "" {
return fmt.Errorf("secret ID for AppRole must be provided with a source file, environment variable, or plaintext string")
}

if secretID.FromFile != "" {
if secretID.FromEnv != "" || secretID.FromString != "" {
return fmt.Errorf("only one source for the secret ID should be specified")
}
}

if secretID.FromEnv != "" {
if secretID.FromFile != "" || secretID.FromString != "" {
return fmt.Errorf("only one source for the secret ID should be specified")
}
}

if secretID.FromString != "" {
if secretID.FromFile != "" || secretID.FromEnv != "" {
return fmt.Errorf("only one source for the secret ID should be specified")
}
}
return nil
}
130 changes: 130 additions & 0 deletions api/auth/approle/approle_test.go
@@ -0,0 +1,130 @@
package approle

import (
"context"
"encoding/json"
"fmt"
"net"
"net/http"
"os"
"strings"
"testing"

"github.com/hashicorp/vault/api"
)

// testHTTPServer creates a test HTTP server that handles requests until
// the listener returned is closed.
func testHTTPServer(
t *testing.T, handler http.Handler) (*api.Config, net.Listener) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("err: %s", err)
}

server := &http.Server{Handler: handler}
go server.Serve(ln)

config := api.DefaultConfig()
config.Address = fmt.Sprintf("http://%s", ln.Addr())

return config, ln
}

func init() {
os.Setenv("VAULT_TOKEN", "")
}
func TestLogin(t *testing.T) {
secretIDEnvVar := "APPROLE_SECRET_ID"
allowedRoleID := "my-role-id"
allowedSecretID := "my-secret-id"

content := []byte(allowedSecretID)
tmpfile, err := os.CreateTemp("", "file-containing-secret-id")
if err != nil {
t.Fatalf("error creating temp file: %v", err)
}
defer os.Remove(tmpfile.Name()) // clean up
err = os.Setenv(secretIDEnvVar, allowedSecretID)
if err != nil {
t.Fatalf("error writing secret ID to env var: %v", err)
}

if _, err := tmpfile.Write(content); err != nil {
t.Fatalf("error writing to temp file: %v", err)
}
if err := tmpfile.Close(); err != nil {
t.Fatalf("error closing temp file: %v", err)
}

// a response to return if the correct values were passed to login
authSecret := &api.Secret{
Auth: &api.SecretAuth{
ClientToken: "a-client-token",
},
}

authBytes, err := json.Marshal(authSecret)
if err != nil {
t.Fatalf("error marshaling json: %v", err)
}

handler := func(w http.ResponseWriter, req *http.Request) {
payload := make(map[string]interface{})
err := json.NewDecoder(req.Body).Decode(&payload)
if err != nil {
t.Fatalf("error decoding json: %v", err)
}
if payload["role_id"] == allowedRoleID && payload["secret_id"] == allowedSecretID {
w.Write(authBytes)
}
}

config, ln := testHTTPServer(t, http.HandlerFunc(handler))
defer ln.Close()

config.Address = strings.ReplaceAll(config.Address, "127.0.0.1", "localhost")
client, err := api.NewClient(config)
if err != nil {
t.Fatalf("error initializing Vault client: %v", err)
}

authFromFile, err := NewAppRoleAuth(allowedRoleID, &SecretID{FromFile: tmpfile.Name()})
if err != nil {
t.Fatalf("error initializing AppRoleAuth with secret ID file: %v", err)
}

loginRespFromFile, err := client.Auth().Login(context.TODO(), authFromFile)
if err != nil {
t.Fatalf("error logging in with secret ID from file: %v", err)
}
if loginRespFromFile.Auth == nil || loginRespFromFile.Auth.ClientToken == "" {
t.Fatalf("no authentication info returned by login")
}

authFromEnv, err := NewAppRoleAuth(allowedRoleID, &SecretID{FromEnv: secretIDEnvVar})
if err != nil {
t.Fatalf("error initializing AppRoleAuth with secret ID env var: %v", err)
}

loginRespFromEnv, err := client.Auth().Login(context.TODO(), authFromEnv)
if err != nil {
t.Fatalf("error logging in with secret ID from env var: %v", err)
}
if loginRespFromEnv.Auth == nil || loginRespFromEnv.Auth.ClientToken == "" {
t.Fatalf("no authentication info returned by login with secret ID from env var")
}

authFromStr, err := NewAppRoleAuth(allowedRoleID, &SecretID{FromString: allowedSecretID})
if err != nil {
t.Fatalf("error initializing AppRoleAuth with secret ID string: %v", err)
}

loginRespFromStr, err := client.Auth().Login(context.TODO(), authFromStr)
if err != nil {
t.Fatalf("error logging in with string: %v", err)
}
if loginRespFromStr.Auth == nil || loginRespFromStr.Auth.ClientToken == "" {
t.Fatalf("no authentication info returned by login with secret ID from string")
}
}
7 changes: 7 additions & 0 deletions api/auth/approle/go.mod
@@ -0,0 +1,7 @@
module github.com/hashicorp/vault/api/auth/approle

go 1.16

replace github.com/hashicorp/vault/api => ../../../api

require github.com/hashicorp/vault/api v1.2.0

0 comments on commit 3ed7bca

Please sign in to comment.