-
Notifications
You must be signed in to change notification settings - Fork 4.3k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Native Login method for Go client (#12796)
* 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
Showing
19 changed files
with
2,650 additions
and
15 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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") | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 |
Oops, something went wrong.