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 6 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
23 changes: 23 additions & 0 deletions api/auth.go
@@ -1,11 +1,34 @@
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)
}

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
}
97 changes: 97 additions & 0 deletions auth/approle/approle.go
@@ -0,0 +1,97 @@
package approle
digivava marked this conversation as resolved.
Show resolved Hide resolved

import (
"fmt"
"io/ioutil"
"strings"

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

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

type LoginOption func(a *AppRoleAuth)
digivava marked this conversation as resolved.
Show resolved Hide resolved

// NewAppRoleAuth initializes a new AppRole auth method interface to be passed as a parameter to the client.Auth().Login method.
digivava marked this conversation as resolved.
Show resolved Hide resolved
//
// 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, pathToSecretID string, opts ...LoginOption) (api.AuthMethod, error) {
digivava marked this conversation as resolved.
Show resolved Hide resolved
if roleID == "" {
return nil, fmt.Errorf("no role ID provided for login")
}

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

const (
digivava marked this conversation as resolved.
Show resolved Hide resolved
defaultMountPath = "approle"
)

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

// Loop through each option
for _, opt := range opts {
// Call the option giving the instantiated
// *AppRoleAuth as the argument
opt(a)
}

// 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,
}

secretIDBytes, err := ioutil.ReadFile(a.pathToSecretID)
digivava marked this conversation as resolved.
Show resolved Hide resolved
if err != nil {
return nil, fmt.Errorf("unable to read file containing secret ID: %w", err)
}
secretID := string(secretIDBytes)

// 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(strings.TrimSuffix(secretID, "\n"))
digivava marked this conversation as resolved.
Show resolved Hide resolved
if err != nil {
return nil, fmt.Errorf("unable to unwrap token: %w. If the AppRoleAuth struct was initialized with the WithWrappingToken LoginOption, then the filepath used should be a path to a response-wrapping token", err)
}
loginData["secret_id"] = unwrappedToken.Data["secret_id"]
} else {
loginData["secret_id"] = 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) {
a.mountPath = mountPath
}
}

func WithWrappingToken() LoginOption {
return func(a *AppRoleAuth) {
a.unwrap = true
}
}
98 changes: 98 additions & 0 deletions 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", tmpfile.Name())
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 == nil {
digivava marked this conversation as resolved.
Show resolved Hide resolved
t.Fatalf("no authentication info returned by login")
}
}