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

Native Login method for Go client #12796

Merged
merged 27 commits into from Oct 26, 2021
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
cb638e5
Native Login method, userpass and approle interfaces to implement it
digivava Oct 4, 2021
f62007f
Add AWS auth interface for Login, unexported struct fields for now
digivava Oct 8, 2021
4b56a1b
Add Kubernetes client login
digivava Oct 8, 2021
0a93b48
Merge branch 'main' into digivava/native-client-login
digivava Oct 11, 2021
3649428
Add changelog
digivava Oct 11, 2021
4fd0d35
Add a test for approle client login
digivava Oct 11, 2021
f68182f
Return errors from LoginOptions, use limited reader for secret ID
digivava Oct 14, 2021
61ce205
Fix auth comment length
digivava Oct 14, 2021
81d104c
Return actual type not interface, check for client token in tests
digivava Oct 14, 2021
c20b118
Require specification of secret ID location using SecretID struct as …
digivava Oct 14, 2021
fa24af1
Allow password from env, file, or plaintext
digivava Oct 14, 2021
ae75ce6
Add flexibility in how to fetch k8s service token, but still with def…
digivava Oct 15, 2021
8b435a7
Avoid passing strings that need to be validated by just having differ…
digivava Oct 15, 2021
9b3b833
Try a couple real tests with approle and userpass login
digivava Oct 15, 2021
7dfe2a9
Merge branch 'main' into digivava/native-client-login
digivava Oct 15, 2021
be26874
Fix method name in comment
digivava Oct 18, 2021
8f54369
Merge branch 'digivava/native-client-login' of github.com:hashicorp/v…
digivava Oct 18, 2021
8516130
Add context to Login methods, remove comments about certain sources b…
digivava Oct 21, 2021
73f02a8
Perform read of secret ID at login time
digivava Oct 21, 2021
0233ede
Read password from file at login time
digivava Oct 22, 2021
090730c
Pass context in integ tests
digivava Oct 25, 2021
23859a1
Read env var values in at login time, add extra tests
digivava Oct 25, 2021
1ef3949
Update api version
digivava Oct 26, 2021
a6af8d8
Revert "Update api version"
digivava Oct 26, 2021
ffa0fac
Update api version in all go.mod files
digivava Oct 26, 2021
2e574c0
Merge branch 'main' into digivava/native-client-login
digivava Oct 26, 2021
d0fa46e
Merge branch 'main' into digivava/native-client-login
digivava Oct 26, 2021
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
32 changes: 32 additions & 0 deletions api/auth.go
@@ -1,11 +1,43 @@
package api

import "fmt"

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

type AuthMethod interface {
Login(client *Client) (*Secret, error)
digivava marked this conversation as resolved.
Show resolved Hide resolved
}

// 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(authMethod AuthMethod) (*Secret, error) {
if authMethod == nil {
return nil, fmt.Errorf("no auth method provided for login")
}

authSecret, err := authMethod.Login(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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we also attach any X-Vault-State headers we get back here? That way we by default cover any eventual consistency issues? cc @ncabatoff

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It'll be cumbersome to do this using the callbacks as they are now: we'd need Login to return a new Client that contains a request callback to set the header.

There are two easier options available to users: if the Vault is 1.9+, do nothing and rely on index bearing tokens. Otherwise, they can create a client with ReadYourWrites enabled. The catch with the latter is if we encourage people to use that at login time, they're probably not going to turn it off afterwards, and we don't everyone running with that setting enabled due to the performance implications.

So maybe we add another special-purpose Client field that stores the state resulting from the login, and then we blindly add an index request header in RawRequestWithContext for that state, e.g. here. Vault is happy to accept multiple index request headers, so this shouldn't interact badly with the existing index request header mechanisms. The new Client field should be copied by Clone.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems to be out of the scope of this PR so I'll go ahead and create a new task on the devex team's Jira to track this.


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

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

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

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

// 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. Can be insecure if the environment variable is logged.
FromEnv string
// The secret ID as a plaintext string value. Insecure.
digivava marked this conversation as resolved.
Show resolved Hide resolved
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) {
var _ api.AuthMethod = (*AppRoleAuth)(nil)
digivava marked this conversation as resolved.
Show resolved Hide resolved

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")
}

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

secretIDValue, err := readSecretID(secretID)
if err != nil {
return nil, fmt.Errorf("error reading secret ID: %w", err)
}
digivava marked this conversation as resolved.
Show resolved Hide resolved

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

// 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(client *api.Client) (*api.Secret, error) {
loginData := map[string]interface{}{
"role_id": a.roleID,
}

// 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(a.secretID)
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"] = 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 readSecretID(secretID *SecretID) (string, error) {
var parsedSecretID string
if secretID.FromFile != "" {
if secretID.FromEnv != "" || secretID.FromString != "" {
return "", fmt.Errorf("only one location for the secret ID should be specified")
}
secretIDFile, err := os.Open(secretID.FromFile)
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)
}
parsedSecretID = string(secretIDBytes)
}

if secretID.FromEnv != "" {
if secretID.FromFile != "" || secretID.FromString != "" {
return "", fmt.Errorf("only one location for the secret ID should be specified")
}
parsedSecretID = os.Getenv(secretID.FromEnv)
if parsedSecretID == "" {
return "", fmt.Errorf("secret ID was specified with an environment variable with an empty value")
}
}

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

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

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

import (
"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) {
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

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)
}

appRoleAuth, err := NewAppRoleAuth("my-role-id", &SecretID{FromFile: tmpfile.Name()})
digivava marked this conversation as resolved.
Show resolved Hide resolved
if err != nil {
t.Fatalf("error initializing AppRoleAuth: %v", err)
}

authInfo, err := client.Auth().Login(appRoleAuth)
if err != nil {
t.Fatalf("error logging in: %v", err)
}
if authInfo.Auth == nil || authInfo.Auth.ClientToken == "" {
t.Fatalf("no authentication info returned by login")
}
}
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.1.1