Development of auth plugin for HashiCorp Vault Enterprise
Recently, I helped an organization develop a HashiCorp Vault (Vault) auth plugin. During development I found the documentation around Vault auth plugin for its Enterprise version to be insufficient in places, so here I document an example using the things I discovered during development.
Vault Overview
HashiCorp Vault (Vault) can secure, store and tightly control access to tokens, passwords, certificates, encryption keys for protecting secrets and other sensitive data using a UI, CLI, or HTTP API. In general, Vault provides a comprehensive solution for users to manage their secrets. A typical Vault system consists of auth methods, secret engines, storage, auditing device and other system backend. In this article, we are going to demonstrate how to develop a custom auth method and deploy it to be part of the Vault system.
Vault Plugin System
All Vault auth methods and secret engines are considered plugins, which are completely separate, standalone applications that Vault executes and communicates with over RPC.
There are two ways to build a custom plugin - you can either customize existing built-in one, such as Approle, User-Pass auth methods, or build brand new one by following Vault plugin’s development guidelines. In this blog, I will focus on the latter.
Vault Auth Plugin Development
Prerequisite
Assuming you already know how Vault works, to start building an auth plugin there are some additional prerequisites you will need to get going:
- Golang programming language.
- Knowledge about common Vault response attributes, such as “lease_duration”, “policies”, “entity_id” etc.
- Auth plugin development guideline.
Now we have everything ready. Let’s go through the steps needed to build our Vault auth plugin.
Objective
In this example, we will build a simple custom auth plugin which uses a system environment variable as the auth provider. The plugin can also assign proper policies to the user by querying the entitlement database.
Build Plugin
- Project structure
Now it’s time for us to create new directory to nest our Plugin project, following will be project structure we use:
pluginProject
├- auth
│ ├- auth.go
├- main.go
As you can see, we will create two packages inside the project: the main
and auth
packages.
2. Configure project as plugin
package main
import (
"log"
"os"
"citihub.com/vault/plugin/auth"
"github.com/hashicorp/vault/api"
"github.com/hashicorp/vault/sdk/plugin"
)
//vault auth plugin entry point
func main() {
apiClientMeta := &api.PluginAPIClientMeta{}
flags := apiClientMeta.FlagSet()
flags.Parse(os.Args[1:])
tlsConfig := apiClientMeta.GetTLSConfig()
tlsProviderFunc := api.VaultPluginTLSProvider(tlsConfig)
if err := plugin.Serve(&plugin.ServeOpts{
BackendFactoryFunc: auth.Factory,
TLSProviderFunc: tlsProviderFunc,
}); err != nil {
log.Fatal(err)
}
}
The first thing we do here is import
all necessary package dependencies provided by HashiCorp. Inside the main function, we setup the configuration for plugin, which includes plugin backend functions and TLS handshake function used for communication between the Vault core and the plugin. In next step, we will need to implement functions for auth.Factory
.
3. Implement auth backend
There are several methods in Vault backend interface that need to be implemented. To do this we will add following code to auth.go
file.
package auth
import (
"context"
"net/http"
"time"
"github.com/hashicorp/vault/sdk/framework"
"github.com/hashicorp/vault/sdk/logical"
)
const (
PATH_NAME = "login"
ID_NAME = "id"
PASSWORD_NAME = "password"
NS_KEY = "X-Vault-Namespace"
NS_NAME = "namespace"
)
//custom auth interface
type auth interface {
GetAuthentication() (bool, error)
GetAuthorization() ([]string, error)
}
func Factory(ctx context.Context, c *logical.BackendConfig) (logical.Backend, error) {
b := Backend(c)
if err := b.Setup(ctx, c); err != nil {
return nil, err
}
return b, nil
}
type backend struct {
*framework.Backend
}
//config backend
func Backend(c *logical.BackendConfig) *backend {
var b backend
b.Backend = &framework.Backend{
BackendType: logical.TypeCredential,
AuthRenew: b.pathAuthRenew,
PathsSpecial: &logical.Paths{
Unauthenticated: []string{PATH_NAME},
},
Paths: []*framework.Path{
&framework.Path{
Pattern: PATH_NAME,
Fields: map[string]*framework.FieldSchema{
ID_NAME: &framework.FieldSchema{
Type: framework.TypeString,
},
PASSWORD_NAME: &framework.FieldSchema{
Type: framework.TypeString,
},
},
Callbacks: map[logical.Operation]framework.OperationFunc{
logical.UpdateOperation: b.pathAuthLogin,
},
},
},
}
return &b
}
The Backend
function is for configuring plugin details. Here are comments for each attribute:
b.Backend = &framework.Backend{
// indicate this plugin is auth plugin
BackendType: logical.TypeCredential,
// implementation of renew functions
AuthRenew: b.pathAuthRenew,
//define auth path, for instance '/login'
PathsSpecial: &logical.Paths{
Unauthenticated: []string{PATH_NAME},
},
//define attributes for login payload, we define two attributes here: 'id' and 'password'
Paths: []*framework.Path{
&framework.Path{
Pattern: PATH_NAME,
Fields: map[string]*framework.FieldSchema{
ID_NAME: &framework.FieldSchema{
Type: framework.TypeString,
},
PASSWORD_NAME: &framework.FieldSchema{
Type: framework.TypeString,
},
},
//callback function
Callbacks: map[logical.Operation]framework.OperationFunc{
logical.UpdateOperation: b.pathAuthLogin,
},
},
},
}
Now we have the backend configured.
We will continue to implement two functions used in above code: b.pathAuthRenew
and b.pathAuthLogin
.
Add those code to auth.go
file:
// raise custom error response
func raiseErrorResponse(msg string, respCode int) (*logical.Response, error) {
errResp := logical.Response{
Data: map[string]interface{}{
"error": msg,
},
}
return logical.RespondWithStatusCode(&errResp, nil, respCode)
}
// implement pathAuthLogin
func (b *backend) pathAuthLogin(_ context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) {
id := d.Get(ID_NAME).(string)
password := d.Get(PASSWORD_NAME).(string)
nameSpace := ""
ns := req.Headers
if val, hasVal := ns[NS_KEY]; hasVal {
nameSpace = val[0]
}
cauth := New(id, password)
if isAuth, err := cauth.GetAuthentication(); err != nil || !isAuth {
return raiseErrorResponse(err.Error(), http.StatusForbidden)
}
policies, err := cauth.GetAuthorization()
if err != nil {
return raiseErrorResponse(err.Error(), http.StatusForbidden)
}
// Compose the response
return &logical.Response{
Auth: &logical.Auth{
InternalData: map[string]interface{}{
PASSWORD_NAME: cauth.GetPassword(),
},
Policies: policies,
Metadata: map[string]string{
ID_NAME: cauth.GetId(),
NS_NAME: nameSpace,
},
LeaseOptions: logical.LeaseOptions{
TTL: 30 * time.Minute,
MaxTTL: 60 * time.Minute,
Renewable: true,
},
EntityID: id,
},
}, nil
}
//implement pathAuthRenew
func (b *backend) pathAuthRenew(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) {
if req.Auth == nil {
return nil, EmptyAuth
}
secretValue := req.Auth.InternalData[PASSWORD_NAME].(string)
password := d.Get(PASSWORD_NAME).(string)
if secretValue != password {
return nil, InternalDataNotMatch
}
return framework.LeaseExtend(30*time.Second, 60*time.Minute, b.System())(ctx, req, d)
}
You may notice that in the code, we catch X-Vault-Namespace
header. This header is supported only by Vault Enterprise and gives us the ability to implement secure multi-tenancy within Vault, in order to provide isolation and ensure teams can self-manage their own environments. In this example we write the namespace value to be part of token meta information, so users will know which namespace they have authenticated.
pathAuthLogin
will return the final response to users. We can custom the response string per our need. In the above example, if login fails we will raise error response with 403
status code. If login succeeds, a standard successful response message will return to users.
Here are explanations for each attribute in the response, check comments below:
logical.Response{
// contains auth info detail
Auth: &logical.Auth{
// internalData used to renew the lease
InternalData: map[string]interface{}{
PASSWORD_NAME: cauth.GetPassword(),
},
//policy names assigned to login token
Policies: policies,
//metadata attached to login token
Metadata: map[string]string{
ID_NAME: cauth.GetId(),
NS_NAME: nameSpace,
},
// lease info
LeaseOptions: logical.LeaseOptions{
// initial time to live
TTL: 30 * time.Minute,
// max time to live
MaxTTL: 60 * time.Minute,
Renewable: true,
},
// entity id connected to this login
EntityID: id,
},
}
As far as now, we have finished building our Vault auth plugin. Now we will need to build the code into binary.
Build plugin project
In this section, we are going to build the project, before doing so, we need to make sure all Vault library dependencies are installed in our development environment. Run the following commands to install them and build our plugin:
# cd to your project
$ cd /path/to/project
# init module path for your project
$ go mod init citihub.com/vault/plugin
#download all dependencies
$ go mod vendor
#build plugin 'ch' binary and save under 'plugins/' folder
$ go build -o plugins/ch
At this point, our plugin binary ready to test. In next section, I will show you how to register the plugin with the Vault plugin catalog, enable it to a specific path and then login.
Deploy and test the plugin
To test the plugin binary, we need a running Vault server. You can download latest Vault binary from Here . Save it to your $GOPATH/bin
directory.
Run the command below in the console to start the Vault server:
#setup test user's profile, for linux and Mac OS
$ export vaultUserId=tester1
$ export vaultPassword=123123
#start vault in dev mode
$ vault server -dev -dev-root-token-id=root -dev-plugin-dir=/path/to/project/plugins
You will see Vault status in the console. Vault will stay active if there is no error during startup. You can also try to open: http://127.0.0.1:8200 in your browser to visit the Vault web UI.
Now, open another console and try following commands to register and enable our auth plugin:
$ export VAULT_ADDR=http://127.0.0.1:8200
#make sure you have execute permission
$ chmod u+x /path/to/project/plugins/ch
#calculate shasum 256 vaule
$ SHASUM=$(shasum -a 256 /path/to/project/plugins/ch | cut -d " " -f1)
#register to vault plugin catalog
$ vault write sys/plugins/catalog/ch sha_256=$SHASUM command="ch"
#enable vault plugin at the path 'auth/ch/login', for vault enterprise we can white list the vault namespace header
$ vault auth enable -passthrough-request-headers=X-Vault-Namespace -path=ch -plugin-name=ch plugin
You will see a success message if no error occurs during enabling the plugin.
Now we can test login using following command:
$ vault write auth/ch/login id=tester1 password=123123
If login is successful Vault will return the access token with policies, meta, lease duration info attached.
Conclusion
In this article we demonstrated how to build a simple custom Vault auth method which supports features in Hashicorp Vault Enterprise. For authentication we used a simple environment variable for demonstration purposes — in a real-world scenario your organization with have a much more complex auth provider to integrate with, such as oAuth2 or database backed auth provider and this example can be modified accordingly.
Lastly, feel free to give feedback if you see errors or something doesn’t make sense to you. Happy coding!
Useful Reference
https://www.vaultproject.io/docs/internals/plugins/
https://learn.hashicorp.com/vault/developer/plugin-backends
https://www.hashicorp.com/blog/building-a-vault-secure-plugin/