-
Notifications
You must be signed in to change notification settings - Fork 4.3k
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
Changes from all 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 f62007f
Add AWS auth interface for Login, unexported struct fields for now
digivava 4b56a1b
Add Kubernetes client login
digivava 0a93b48
Merge branch 'main' into digivava/native-client-login
digivava 3649428
Add changelog
digivava 4fd0d35
Add a test for approle client login
digivava f68182f
Return errors from LoginOptions, use limited reader for secret ID
digivava 61ce205
Fix auth comment length
digivava 81d104c
Return actual type not interface, check for client token in tests
digivava c20b118
Require specification of secret ID location using SecretID struct as …
digivava fa24af1
Allow password from env, file, or plaintext
digivava ae75ce6
Add flexibility in how to fetch k8s service token, but still with def…
digivava 8b435a7
Avoid passing strings that need to be validated by just having differ…
digivava 9b3b833
Try a couple real tests with approle and userpass login
digivava 7dfe2a9
Merge branch 'main' into digivava/native-client-login
digivava be26874
Fix method name in comment
digivava 8f54369
Merge branch 'digivava/native-client-login' of github.com:hashicorp/v…
digivava 8516130
Add context to Login methods, remove comments about certain sources b…
digivava 73f02a8
Perform read of secret ID at login time
digivava 0233ede
Read password from file at login time
digivava 090730c
Pass context in integ tests
digivava 23859a1
Read env var values in at login time, add extra tests
digivava 1ef3949
Update api version
digivava a6af8d8
Revert "Update api version"
digivava ffa0fac
Update api version in all go.mod files
digivava 2e574c0
Merge branch 'main' into digivava/native-client-login
digivava d0fa46e
Merge branch 'main' into digivava/native-client-login
digivava File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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) | ||
} | ||
digivava marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
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.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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 @ncabatoffThere was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.