feat(vault-tui): implement KV client and service for managing secrets
- Added internal/vault/client.go for creating a Vault client with configuration settings. - Introduced internal/vault/errors.go to classify Vault API errors for better UI handling. - Created internal/vault/kv.go to manage KV secrets, including listing, reading, writing, and deleting operations. - Implemented internal/vault/mounts.go to list and describe secret engine mounts. - Developed internal/vault/service.go to provide a unified entry point for Vault operations. - Added internal/vault/kv_test.go for comprehensive testing of KV operations. - Introduced internal/ui/toast.go for transient notifications in the UI. - Added renovate.json for dependency management and updates.
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/hashicorp/vault/api"
|
||||
)
|
||||
|
||||
func init() { register(approleMethod{}) }
|
||||
|
||||
type approleMethod struct{}
|
||||
|
||||
func (approleMethod) Name() string { return "approle" }
|
||||
func (approleMethod) DisplayName() string { return "AppRole" }
|
||||
func (approleMethod) DefaultMount() string { return "approle" }
|
||||
|
||||
func (approleMethod) Fields() []Field {
|
||||
return []Field{
|
||||
{Name: "role_id", Label: "Role ID", Kind: FieldText, Required: true,
|
||||
EnvFallback: []string{"VAULT_ROLE_ID"}},
|
||||
{Name: "secret_id", Label: "Secret ID", Kind: FieldSecret, Required: true,
|
||||
EnvFallback: []string{"VAULT_SECRET_ID"}},
|
||||
}
|
||||
}
|
||||
|
||||
func (approleMethod) Login(ctx context.Context, c *api.Client, req Request) (*api.Secret, error) {
|
||||
mount := mountOf(req, "approle")
|
||||
return loginWrite(ctx, c, mount, "login", map[string]interface{}{
|
||||
"role_id": req.Creds.Get("role_id"),
|
||||
"secret_id": req.Creds.Get("secret_id"),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/hashicorp/vault/api"
|
||||
)
|
||||
|
||||
func init() { register(certMethod{}) }
|
||||
|
||||
// certMethod is TLS client-cert auth. The certificate itself is supplied at
|
||||
// the transport layer via profile.tls.client_cert/client_key (see
|
||||
// config.Settings and internal/vault.NewClient) — this method's Login is
|
||||
// just the POST that tells Vault which cert role to match against.
|
||||
//
|
||||
// api/auth/cert has no tagged release (only a pseudo-version on the module
|
||||
// proxy), so this is implemented as a two-line raw request rather than
|
||||
// pulling in an unreleased dependency.
|
||||
type certMethod struct{}
|
||||
|
||||
func (certMethod) Name() string { return "cert" }
|
||||
func (certMethod) DisplayName() string { return "TLS Certificate" }
|
||||
func (certMethod) DefaultMount() string { return "cert" }
|
||||
|
||||
func (certMethod) Description() string {
|
||||
return "Client-certificate auth. Configure tls.client_cert / tls.client_key on the profile first."
|
||||
}
|
||||
|
||||
func (certMethod) Fields() []Field {
|
||||
return []Field{
|
||||
{Name: "name", Label: "Cert role name (optional)", Kind: FieldText},
|
||||
}
|
||||
}
|
||||
|
||||
func (certMethod) Login(ctx context.Context, c *api.Client, req Request) (*api.Secret, error) {
|
||||
mount := mountOf(req, "cert")
|
||||
data := map[string]interface{}{}
|
||||
if name := req.Creds.Get("name"); name != "" {
|
||||
data["name"] = name
|
||||
}
|
||||
return loginWrite(ctx, c, mount, "login", data)
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
//go:build cloud
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/hashicorp/vault/api"
|
||||
awsauth "github.com/hashicorp/vault/api/auth/aws"
|
||||
azureauth "github.com/hashicorp/vault/api/auth/azure"
|
||||
gcpauth "github.com/hashicorp/vault/api/auth/gcp"
|
||||
)
|
||||
|
||||
// Cloud auth methods (AWS/Azure/GCP) are gated behind the `cloud` build
|
||||
// tag: their SDKs transitively pull in tens of MB of dependencies
|
||||
// (aws-sdk-go, google.golang.org/api, the Azure SDK) that a typical
|
||||
// OIDC/userpass/LDAP-only deployment never needs. Build with `-tags cloud`
|
||||
// to include them; see m_cloud_stub.go for the default (excluded) build,
|
||||
// which lists these names in the picker greyed out with a reason instead
|
||||
// of letting them silently vanish.
|
||||
func init() {
|
||||
register(awsMethod{}, azureMethod{}, gcpMethod{})
|
||||
}
|
||||
|
||||
type awsMethod struct{}
|
||||
|
||||
func (awsMethod) Name() string { return "aws" }
|
||||
func (awsMethod) DisplayName() string { return "AWS" }
|
||||
func (awsMethod) DefaultMount() string { return "aws" }
|
||||
|
||||
func (awsMethod) Fields() []Field {
|
||||
return []Field{
|
||||
{Name: "role", Label: "Role", Kind: FieldText, Required: true, EnvFallback: []string{"VAULT_AUTH_AWS_ROLE"}},
|
||||
{Name: "type", Label: "Auth type", Kind: FieldSelect, Options: []string{"iam", "ec2"}, Default: "iam"},
|
||||
{Name: "region", Label: "AWS region (optional)", Kind: FieldText},
|
||||
}
|
||||
}
|
||||
|
||||
func (awsMethod) Login(ctx context.Context, c *api.Client, req Request) (*api.Secret, error) {
|
||||
opts := []awsauth.LoginOption{awsauth.WithMountPath(mountOf(req, "aws"))}
|
||||
if r := req.Creds.Get("role"); r != "" {
|
||||
opts = append(opts, awsauth.WithRole(r))
|
||||
}
|
||||
if r := req.Creds.Get("region"); r != "" {
|
||||
opts = append(opts, awsauth.WithRegion(r))
|
||||
}
|
||||
if req.Creds.Get("type") == "ec2" {
|
||||
opts = append(opts, awsauth.WithEC2Auth())
|
||||
} else {
|
||||
opts = append(opts, awsauth.WithIAMAuth())
|
||||
}
|
||||
a, err := awsauth.NewAWSAuth(opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return a.Login(ctx, c)
|
||||
}
|
||||
|
||||
type azureMethod struct{}
|
||||
|
||||
func (azureMethod) Name() string { return "azure" }
|
||||
func (azureMethod) DisplayName() string { return "Azure" }
|
||||
func (azureMethod) DefaultMount() string { return "azure" }
|
||||
|
||||
func (azureMethod) Fields() []Field {
|
||||
return []Field{
|
||||
{Name: "role", Label: "Role", Kind: FieldText, Required: true, EnvFallback: []string{"VAULT_AUTH_AZURE_ROLE"}},
|
||||
{Name: "resource", Label: "Resource URL (optional)", Kind: FieldText},
|
||||
}
|
||||
}
|
||||
|
||||
func (azureMethod) Login(ctx context.Context, c *api.Client, req Request) (*api.Secret, error) {
|
||||
opts := []azureauth.LoginOption{azureauth.WithMountPath(mountOf(req, "azure"))}
|
||||
if r := req.Creds.Get("resource"); r != "" {
|
||||
opts = append(opts, azureauth.WithResource(r))
|
||||
}
|
||||
a, err := azureauth.NewAzureAuth(req.Creds.Get("role"), opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return a.Login(ctx, c)
|
||||
}
|
||||
|
||||
type gcpMethod struct{}
|
||||
|
||||
func (gcpMethod) Name() string { return "gcp" }
|
||||
func (gcpMethod) DisplayName() string { return "GCP" }
|
||||
func (gcpMethod) DefaultMount() string { return "gcp" }
|
||||
|
||||
func (gcpMethod) Fields() []Field {
|
||||
return []Field{
|
||||
{Name: "role", Label: "Role", Kind: FieldText, Required: true, EnvFallback: []string{"VAULT_AUTH_GCP_ROLE"}},
|
||||
{Name: "type", Label: "Auth type", Kind: FieldSelect, Options: []string{"iam", "gce"}, Default: "iam"},
|
||||
{Name: "service_account", Label: "Service account email (iam only)", Kind: FieldText},
|
||||
}
|
||||
}
|
||||
|
||||
func (gcpMethod) Login(ctx context.Context, c *api.Client, req Request) (*api.Secret, error) {
|
||||
opts := []gcpauth.LoginOption{gcpauth.WithMountPath(mountOf(req, "gcp"))}
|
||||
if req.Creds.Get("type") == "gce" {
|
||||
opts = append(opts, gcpauth.WithGCEAuth())
|
||||
} else {
|
||||
opts = append(opts, gcpauth.WithIAMAuth(req.Creds.Get("service_account")))
|
||||
}
|
||||
a, err := gcpauth.NewGCPAuth(req.Creds.Get("role"), opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return a.Login(ctx, c)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
//go:build !cloud
|
||||
|
||||
package auth
|
||||
|
||||
// Default build: cloud auth methods are compiled out (see m_cloud.go).
|
||||
// Listing them as "unavailable" rather than omitting them means a method
|
||||
// picker can show "AWS (built without cloud auth support — build with
|
||||
// -tags cloud)" instead of the option silently not existing.
|
||||
func init() {
|
||||
registerUnavailable("built without cloud auth support (build with -tags cloud)", "aws", "azure", "gcp")
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/hashicorp/vault/api"
|
||||
)
|
||||
|
||||
func init() { register(githubMethod{}) }
|
||||
|
||||
type githubMethod struct{}
|
||||
|
||||
func (githubMethod) Name() string { return "github" }
|
||||
func (githubMethod) DisplayName() string { return "GitHub" }
|
||||
func (githubMethod) DefaultMount() string { return "github" }
|
||||
|
||||
func (githubMethod) Fields() []Field {
|
||||
return []Field{
|
||||
{Name: "token", Label: "GitHub personal access token", Kind: FieldSecret, Required: true,
|
||||
EnvFallback: []string{"VAULT_AUTH_GITHUB_TOKEN"}},
|
||||
}
|
||||
}
|
||||
|
||||
func (githubMethod) Login(ctx context.Context, c *api.Client, req Request) (*api.Secret, error) {
|
||||
mount := mountOf(req, "github")
|
||||
return loginWrite(ctx, c, mount, "login", map[string]interface{}{
|
||||
"token": req.Creds.Get("token"),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/hashicorp/vault/api"
|
||||
)
|
||||
|
||||
func init() { register(jwtMethod{}) }
|
||||
|
||||
type jwtMethod struct{}
|
||||
|
||||
func (jwtMethod) Name() string { return "jwt" }
|
||||
func (jwtMethod) DisplayName() string { return "JWT" }
|
||||
func (jwtMethod) DefaultMount() string { return "jwt" }
|
||||
|
||||
func (jwtMethod) Fields() []Field {
|
||||
return []Field{
|
||||
{Name: "role", Label: "Role", Kind: FieldText},
|
||||
{Name: "jwt", Label: "JWT", Kind: FieldSecret, Required: true,
|
||||
EnvFallback: []string{"VAULT_AUTH_JWT"}},
|
||||
}
|
||||
}
|
||||
|
||||
func (jwtMethod) Login(ctx context.Context, c *api.Client, req Request) (*api.Secret, error) {
|
||||
mount := mountOf(req, "jwt")
|
||||
data := map[string]interface{}{"jwt": req.Creds.Get("jwt")}
|
||||
if role := req.Creds.Get("role"); role != "" {
|
||||
data["role"] = role
|
||||
}
|
||||
return loginWrite(ctx, c, mount, "login", data)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/hashicorp/vault/api"
|
||||
)
|
||||
|
||||
func init() { register(kubernetesMethod{}) }
|
||||
|
||||
// defaultServiceAccountTokenPath is where a projected service account token
|
||||
// is normally mounted inside a pod.
|
||||
const defaultServiceAccountTokenPath = "/var/run/secrets/kubernetes.io/serviceaccount/token"
|
||||
|
||||
type kubernetesMethod struct{}
|
||||
|
||||
func (kubernetesMethod) Name() string { return "kubernetes" }
|
||||
func (kubernetesMethod) DisplayName() string { return "Kubernetes" }
|
||||
func (kubernetesMethod) DefaultMount() string { return "kubernetes" }
|
||||
|
||||
func (kubernetesMethod) Fields() []Field {
|
||||
return []Field{
|
||||
{Name: "role", Label: "Role", Kind: FieldText, Required: true,
|
||||
EnvFallback: []string{"VAULT_AUTH_KUBERNETES_ROLE"}},
|
||||
{Name: "jwt_path", Label: "Service account token path", Kind: FieldPath,
|
||||
Default: defaultServiceAccountTokenPath},
|
||||
}
|
||||
}
|
||||
|
||||
func (kubernetesMethod) Login(ctx context.Context, c *api.Client, req Request) (*api.Secret, error) {
|
||||
mount := mountOf(req, "kubernetes")
|
||||
jwtPath := req.Creds.Get("jwt_path")
|
||||
if jwtPath == "" {
|
||||
jwtPath = defaultServiceAccountTokenPath
|
||||
}
|
||||
jwt, err := os.ReadFile(jwtPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading service account token from %s: %w", jwtPath, err)
|
||||
}
|
||||
return loginWrite(ctx, c, mount, "login", map[string]interface{}{
|
||||
"role": req.Creds.Get("role"),
|
||||
"jwt": string(jwt),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/hashicorp/vault/api"
|
||||
)
|
||||
|
||||
func init() { register(ldapMethod{}) }
|
||||
|
||||
type ldapMethod struct{}
|
||||
|
||||
func (ldapMethod) Name() string { return "ldap" }
|
||||
func (ldapMethod) DisplayName() string { return "LDAP" }
|
||||
func (ldapMethod) DefaultMount() string { return "ldap" }
|
||||
|
||||
func (ldapMethod) Fields() []Field {
|
||||
return []Field{
|
||||
{Name: "username", Label: "Username", Kind: FieldText, Required: true,
|
||||
EnvFallback: []string{"VAULT_USERNAME", "LOGNAME", "USER"}},
|
||||
{Name: "password", Label: "Password", Kind: FieldSecret, Required: true,
|
||||
EnvFallback: []string{"VAULT_LDAP_PASSWORD", "VAULT_PASSWORD"}},
|
||||
}
|
||||
}
|
||||
|
||||
func (ldapMethod) Login(ctx context.Context, c *api.Client, req Request) (*api.Secret, error) {
|
||||
mount := mountOf(req, "ldap")
|
||||
return loginWrite(ctx, c, mount, "login/"+url.PathEscape(req.Creds.Get("username")),
|
||||
map[string]interface{}{"password": req.Creds.Get("password")})
|
||||
}
|
||||
|
||||
func init() { register(oktaMethod{}) }
|
||||
|
||||
// oktaMethod mirrors ldapMethod's request shape but adds an optional
|
||||
// best-effort poll of auth/<mount>/verify/<nonce> for Okta Verify
|
||||
// number-matching, reported via Request.Events so the TUI can show "tap 42
|
||||
// in Okta Verify". This endpoint is not documented in Vault's public API
|
||||
// reference; treat any poll failure as non-fatal and fall back to waiting
|
||||
// for the original login response.
|
||||
type oktaMethod struct{}
|
||||
|
||||
func (oktaMethod) Name() string { return "okta" }
|
||||
func (oktaMethod) DisplayName() string { return "Okta" }
|
||||
func (oktaMethod) DefaultMount() string { return "okta" }
|
||||
|
||||
func (oktaMethod) Description() string {
|
||||
return "Okta username/password, with optional TOTP and Okta Verify push."
|
||||
}
|
||||
|
||||
func (oktaMethod) Fields() []Field {
|
||||
return []Field{
|
||||
{Name: "username", Label: "Username", Kind: FieldText, Required: true,
|
||||
EnvFallback: []string{"VAULT_USERNAME", "LOGNAME", "USER"}},
|
||||
{Name: "password", Label: "Password", Kind: FieldSecret, Required: true,
|
||||
EnvFallback: []string{"VAULT_PASSWORD"}},
|
||||
{Name: "totp", Label: "TOTP code (optional)", Kind: FieldText},
|
||||
}
|
||||
}
|
||||
|
||||
func (oktaMethod) Login(ctx context.Context, c *api.Client, req Request) (*api.Secret, error) {
|
||||
mount := mountOf(req, "okta")
|
||||
data := map[string]interface{}{"password": req.Creds.Get("password")}
|
||||
if totp := req.Creds.Get("totp"); totp != "" {
|
||||
data["totp"] = totp
|
||||
}
|
||||
nonce := randomNonce(12)
|
||||
data["nonce"] = nonce
|
||||
|
||||
if req.Events != nil {
|
||||
go pollOktaVerify(ctx, c, mount, nonce, req)
|
||||
}
|
||||
req.Emit(Event{Kind: EventStatus, Message: "contacting Okta…"})
|
||||
return loginWrite(ctx, c, mount, "login/"+url.PathEscape(req.Creds.Get("username")), data)
|
||||
}
|
||||
|
||||
func pollOktaVerify(ctx context.Context, c *api.Client, mount, nonce string, req Request) {
|
||||
t := time.NewTicker(1 * time.Second)
|
||||
defer t.Stop()
|
||||
p := fmt.Sprintf("auth/%s/verify/%s", mount, nonce)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
sec, err := c.Logical().ReadWithContext(ctx, p)
|
||||
if err != nil || sec == nil || sec.Data == nil {
|
||||
continue // best-effort; the primary login request is the source of truth
|
||||
}
|
||||
if answer, ok := sec.Data["correct_answer"].(string); ok && answer != "" {
|
||||
req.Emit(Event{Kind: EventStatus, Message: fmt.Sprintf("in Okta Verify, tap the number %q", answer)})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/hashicorp/vault/api"
|
||||
)
|
||||
|
||||
func init() { register(tokenMethod{}) }
|
||||
|
||||
// tokenMethod is the trivial "I already have a token" method: it validates
|
||||
// the given token via lookup-self and returns a synthesized auth response so
|
||||
// the normal Result/token-storage pipeline works unchanged. This is what
|
||||
// backs `vault-tui login -method=token` and the VAULT_TOKEN/--token fast
|
||||
// path that internal/token.Resolve prefers over any interactive login.
|
||||
type tokenMethod struct{}
|
||||
|
||||
func (tokenMethod) Name() string { return "token" }
|
||||
func (tokenMethod) DisplayName() string { return "Token" }
|
||||
func (tokenMethod) DefaultMount() string { return "token" }
|
||||
|
||||
func (tokenMethod) Description() string {
|
||||
return "Use an existing Vault token (from VAULT_TOKEN, --token, or a saved token file)."
|
||||
}
|
||||
|
||||
func (tokenMethod) Fields() []Field {
|
||||
return []Field{
|
||||
{Name: "token", Label: "Token", Kind: FieldSecret, Required: true,
|
||||
EnvFallback: []string{"VAULT_TOKEN"}},
|
||||
}
|
||||
}
|
||||
|
||||
func (tokenMethod) Login(ctx context.Context, c *api.Client, req Request) (*api.Secret, error) {
|
||||
tok := req.Creds.Get("token")
|
||||
if tok == "" {
|
||||
return nil, fmt.Errorf("no token provided")
|
||||
}
|
||||
// lookup-self must run with the candidate token; c is otherwise
|
||||
// unauthenticated at this point (see internal/vault.NewClient's
|
||||
// ClearToken discipline), so this cannot affect any other caller.
|
||||
c.SetToken(tok)
|
||||
defer c.ClearToken()
|
||||
|
||||
sec, err := c.Logical().ReadWithContext(ctx, "auth/token/lookup-self")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("validating token: %w", err)
|
||||
}
|
||||
if sec == nil || sec.Data == nil {
|
||||
return nil, fmt.Errorf("token lookup returned no data")
|
||||
}
|
||||
|
||||
renewable, _ := sec.TokenIsRenewable()
|
||||
ttl, _ := sec.TokenTTL()
|
||||
policies, _ := sec.TokenPolicies()
|
||||
return &api.Secret{
|
||||
Auth: &api.SecretAuth{
|
||||
ClientToken: tok,
|
||||
Renewable: renewable,
|
||||
LeaseDuration: int(ttl.Seconds()),
|
||||
Policies: policies,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
|
||||
"github.com/hashicorp/vault/api"
|
||||
)
|
||||
|
||||
func init() {
|
||||
register(
|
||||
userpassMethod{name: "userpass", display: "Username & Password", mount: "userpass"},
|
||||
userpassMethod{name: "radius", display: "RADIUS", mount: "radius"},
|
||||
)
|
||||
}
|
||||
|
||||
// userpassMethod covers both userpass and radius: upstream's own CLI
|
||||
// handler registers radius as credUserpass with DefaultMount "radius", i.e.
|
||||
// they are the same request shape.
|
||||
type userpassMethod struct{ name, display, mount string }
|
||||
|
||||
func (m userpassMethod) Name() string { return m.name }
|
||||
func (m userpassMethod) DisplayName() string { return m.display }
|
||||
func (m userpassMethod) DefaultMount() string { return m.mount }
|
||||
|
||||
func (m userpassMethod) Fields() []Field {
|
||||
return []Field{
|
||||
{Name: "username", Label: "Username", Kind: FieldText, Required: true,
|
||||
EnvFallback: []string{"VAULT_USERNAME", "LOGNAME", "USER"}},
|
||||
{Name: "password", Label: "Password", Kind: FieldSecret, Required: true,
|
||||
EnvFallback: []string{"VAULT_PASSWORD"}},
|
||||
}
|
||||
}
|
||||
|
||||
func (m userpassMethod) Login(ctx context.Context, c *api.Client, req Request) (*api.Secret, error) {
|
||||
mount := mountOf(req, m.mount)
|
||||
return loginWrite(ctx, c, mount, "login/"+url.PathEscape(req.Creds.Get("username")),
|
||||
map[string]interface{}{"password": req.Creds.Get("password")})
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
// Package auth defines the auth-method abstraction vault-tui uses for every
|
||||
// login flow. A Method declares only which fields it needs (Fields); the
|
||||
// TUI and headless CLI both render that declaration generically — a
|
||||
// textinput form in the TUI, a TTY prompt or env/flag lookup on the CLI —
|
||||
// so adding a new method never requires UI code (see prefill.go and
|
||||
// registry.go).
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/hashicorp/vault/api"
|
||||
)
|
||||
|
||||
// FieldKind tells a renderer how to present a field.
|
||||
type FieldKind uint8
|
||||
|
||||
const (
|
||||
FieldText FieldKind = iota
|
||||
FieldSecret
|
||||
FieldBool
|
||||
FieldSelect
|
||||
FieldPath
|
||||
FieldInt
|
||||
)
|
||||
|
||||
// Field declares one credential input a Method needs.
|
||||
type Field struct {
|
||||
Name string
|
||||
Label string
|
||||
Help string
|
||||
Kind FieldKind
|
||||
Required bool
|
||||
Default string
|
||||
Options []string
|
||||
|
||||
// EnvFallback lists env vars consulted during Prefill, in order, before
|
||||
// falling back to config params. Mirrors Vault CLI behaviour (e.g.
|
||||
// VAULT_AUTH_GITHUB_TOKEN-style env overrides).
|
||||
EnvFallback []string
|
||||
|
||||
// ConfigKey is the key looked up in profile.auth.params. Empty => Name.
|
||||
ConfigKey string
|
||||
|
||||
// Validate runs before Login and must not have side effects.
|
||||
Validate func(value string) error
|
||||
}
|
||||
|
||||
// Credentials is a filled-in form: field name -> value.
|
||||
type Credentials map[string]string
|
||||
|
||||
func (c Credentials) Get(name string) string { return c[name] }
|
||||
func (c Credentials) Has(name string) bool {
|
||||
v, ok := c[name]
|
||||
return ok && v != ""
|
||||
}
|
||||
|
||||
// Redacted returns a copy safe for logs: every FieldSecret value becomes
|
||||
// "***". This is the only sanctioned way to log a Credentials map.
|
||||
func (c Credentials) Redacted(fields []Field) map[string]string {
|
||||
secret := map[string]bool{}
|
||||
for _, f := range fields {
|
||||
if f.Kind == FieldSecret {
|
||||
secret[f.Name] = true
|
||||
}
|
||||
}
|
||||
out := make(map[string]string, len(c))
|
||||
for k, v := range c {
|
||||
if secret[k] {
|
||||
out[k] = "***"
|
||||
} else {
|
||||
out[k] = v
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// EventKind classifies out-of-band progress from a long-running login (OIDC
|
||||
// browser wait, Okta push polling).
|
||||
type EventKind uint8
|
||||
|
||||
const (
|
||||
EventStatus EventKind = iota
|
||||
EventOpenURL
|
||||
EventWarning
|
||||
)
|
||||
|
||||
// Event is one piece of progress emitted on Request.Events.
|
||||
type Event struct {
|
||||
Kind EventKind
|
||||
Message string
|
||||
URL string
|
||||
}
|
||||
|
||||
// Request is everything a Method needs to perform one login.
|
||||
type Request struct {
|
||||
Mount string
|
||||
Namespace string
|
||||
Creds Credentials
|
||||
// Events, if non-nil, receives progress notifications. Login must send
|
||||
// non-blockingly (see emit) so a stalled consumer can never deadlock it.
|
||||
Events chan<- Event
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// Emit sends a progress event to Request.Events, non-blocking. Safe to
|
||||
// call even when Events is nil (a headless caller that doesn't want
|
||||
// progress) or when nobody is currently draining the channel.
|
||||
func (r Request) Emit(e Event) {
|
||||
if r.Events == nil {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case r.Events <- e:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// Result is the normalised outcome of a login.
|
||||
type Result struct {
|
||||
Secret *api.Secret
|
||||
Token string
|
||||
Accessor string
|
||||
Renewable bool
|
||||
TTL time.Duration
|
||||
Policies []string
|
||||
Namespace string
|
||||
}
|
||||
|
||||
func (r *Result) String() string {
|
||||
if r == nil {
|
||||
return "<nil result>"
|
||||
}
|
||||
return "<login result accessor=" + r.Accessor + ">"
|
||||
}
|
||||
func (r *Result) GoString() string { return r.String() }
|
||||
|
||||
// NewResult normalises an *api.Secret returned by a successful Login.
|
||||
func NewResult(sec *api.Secret, namespace string) (*Result, error) {
|
||||
if sec == nil || sec.Auth == nil {
|
||||
return nil, errNoAuth
|
||||
}
|
||||
ttl, _ := sec.TokenTTL()
|
||||
policies, _ := sec.TokenPolicies()
|
||||
accessor, _ := sec.TokenAccessor()
|
||||
return &Result{
|
||||
Secret: sec,
|
||||
Token: sec.Auth.ClientToken,
|
||||
Accessor: accessor,
|
||||
Renewable: sec.Auth.Renewable,
|
||||
TTL: ttl,
|
||||
Policies: policies,
|
||||
Namespace: namespace,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Method is the single abstraction every auth method implements.
|
||||
//
|
||||
// Contract: Fields() is pure and cheap. Login must respect ctx cancellation
|
||||
// and must never read os.Stdin — interactive prompting is the caller's job
|
||||
// (TUI form or CLI prompt), driven by Fields()/Missing(). Login must not
|
||||
// mutate client's token; token persistence is internal/token's job.
|
||||
type Method interface {
|
||||
Name() string
|
||||
DisplayName() string
|
||||
DefaultMount() string
|
||||
Fields() []Field
|
||||
Login(ctx context.Context, client *api.Client, req Request) (*api.Secret, error)
|
||||
}
|
||||
|
||||
// Describable is optional; implemented by methods with extra help text.
|
||||
type Describable interface{ Description() string }
|
||||
@@ -0,0 +1,107 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// openBrowser tries, in order: an explicit command override, $BROWSER, then
|
||||
// platform-appropriate auto-detection. It exists instead of
|
||||
// github.com/pkg/browser because that package shells out to xdg-open, which
|
||||
// is absent on a bare WSL2 install (verified: no xdg-open, no
|
||||
// xclip/wl-copy, no wslview on this project's own dev machine) — there,
|
||||
// the working option is handing the URL to the Windows side via
|
||||
// powershell.exe or cmd.exe.
|
||||
//
|
||||
// override, when non-empty, is treated as a command template: "%s" is
|
||||
// replaced with the URL if present, otherwise the URL is appended as the
|
||||
// final argument. It is run through the shell so users can supply
|
||||
// something like `firefox --new-tab %s`.
|
||||
func openBrowser(override, url string) error {
|
||||
if override != "" {
|
||||
return runShell(substituteOrAppend(override, url))
|
||||
}
|
||||
if b := os.Getenv("BROWSER"); b != "" {
|
||||
return runShell(substituteOrAppend(b, url))
|
||||
}
|
||||
|
||||
for _, cand := range candidates(url) {
|
||||
cmd := exec.Command(cand[0], cand[1:]...)
|
||||
if err := cmd.Start(); err == nil {
|
||||
// Don't Wait(): a real browser backgrounds itself, and waiting on
|
||||
// e.g. `cmd.exe /c start` (which returns immediately anyway) is
|
||||
// harmless, but waiting on something that stays foregrounded
|
||||
// would block the login flow.
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("no working browser launcher found for %s (tried: %s)", runtime.GOOS, candidateNames(url))
|
||||
}
|
||||
|
||||
// candidates returns launcher argv lists to try in order, most-specific
|
||||
// (and most likely to actually work on this host) first.
|
||||
func candidates(url string) [][]string {
|
||||
var out [][]string
|
||||
if isWSL() {
|
||||
out = append(out,
|
||||
[]string{"wslview", url},
|
||||
[]string{"powershell.exe", "-NoProfile", "-NonInteractive", "-Command", "Start-Process", quoteForPowerShell(url)},
|
||||
[]string{"cmd.exe", "/c", "start", "", url},
|
||||
)
|
||||
}
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
out = append(out, []string{"open", url})
|
||||
case "windows":
|
||||
out = append(out, []string{"cmd", "/c", "start", "", url},
|
||||
[]string{"rundll32", "url.dll,FileProtocolHandler", url})
|
||||
default:
|
||||
out = append(out, []string{"xdg-open", url}, []string{"x-www-browser", url})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func candidateNames(url string) string {
|
||||
var names []string
|
||||
for _, c := range candidates(url) {
|
||||
names = append(names, c[0])
|
||||
}
|
||||
return strings.Join(names, ", ")
|
||||
}
|
||||
|
||||
// isWSL detects WSL1/2 by checking /proc/version for the "microsoft"
|
||||
// marker Microsoft's kernel build injects there — the standard, widely
|
||||
// used detection technique since there's no dedicated syscall for it.
|
||||
func isWSL() bool {
|
||||
if runtime.GOOS != "linux" {
|
||||
return false
|
||||
}
|
||||
b, err := os.ReadFile("/proc/version")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
v := strings.ToLower(string(b))
|
||||
return strings.Contains(v, "microsoft") || strings.Contains(v, "wsl")
|
||||
}
|
||||
|
||||
func substituteOrAppend(template, url string) string {
|
||||
if strings.Contains(template, "%s") {
|
||||
return fmt.Sprintf(template, url)
|
||||
}
|
||||
return template + " " + url
|
||||
}
|
||||
|
||||
func runShell(command string) error {
|
||||
cmd := exec.Command("sh", "-c", command)
|
||||
return cmd.Start()
|
||||
}
|
||||
|
||||
// quoteForPowerShell wraps url in single quotes for use inside a
|
||||
// -Command argument; OIDC auth URLs are server-generated and may contain
|
||||
// characters PowerShell would otherwise interpret.
|
||||
func quoteForPowerShell(url string) string {
|
||||
return "'" + strings.ReplaceAll(url, "'", "''") + "'"
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/hashicorp/vault/api"
|
||||
)
|
||||
|
||||
// handler builds the http.HandlerFunc that completes the OIDC exchange.
|
||||
// once guards against a stray second request (retries, prefetchers, a
|
||||
// double-click) sending on the already-buffered done channel more than
|
||||
// once — sending twice would be harmless here (done is buffered 1 and
|
||||
// nobody reads twice) but responding twice to the browser is not, so we
|
||||
// gate the whole body.
|
||||
func (f *Flow) handler(c *api.Client, done chan<- result, once *sync.Once) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var res result
|
||||
handled := false
|
||||
defer func() {
|
||||
if !handled {
|
||||
return
|
||||
}
|
||||
if res.err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write(errorHTML(res.err))
|
||||
} else {
|
||||
_, _ = w.Write(successHTML())
|
||||
}
|
||||
once.Do(func() { done <- res })
|
||||
}()
|
||||
|
||||
if r.URL.Path != f.cfg.CallbackPath {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
handled = true
|
||||
|
||||
if err := r.ParseForm(); err != nil {
|
||||
res.err = fmt.Errorf("parsing callback request: %w", err)
|
||||
return
|
||||
}
|
||||
|
||||
if errCode := r.FormValue("error"); errCode != "" {
|
||||
res.err = ProviderRejectedError{Code: errCode, Description: r.FormValue("error_description")}
|
||||
return
|
||||
}
|
||||
|
||||
state := r.FormValue("state")
|
||||
code := r.FormValue("code")
|
||||
idToken := r.FormValue("id_token")
|
||||
if state == "" {
|
||||
res.err = fmt.Errorf("OIDC callback missing state parameter")
|
||||
return
|
||||
}
|
||||
|
||||
// The callback exchange is tied to the Flow's own context (captured
|
||||
// via closure below is not possible here since Login already holds
|
||||
// it) — use a background context bounded by a short deadline instead
|
||||
// of r.Context(), so a browser tab closed mid-exchange does not abort
|
||||
// an exchange that Vault is still processing.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
data := url.Values{
|
||||
"state": {state},
|
||||
"code": {code},
|
||||
"id_token": {idToken},
|
||||
"client_nonce": {f.nonce},
|
||||
}
|
||||
p := path.Join("auth", f.cfg.Mount, "oidc/callback")
|
||||
sec, err := c.Logical().ReadWithDataWithContext(ctx, p, data)
|
||||
if err != nil {
|
||||
res.err = fmt.Errorf("completing OIDC login: %w", err)
|
||||
return
|
||||
}
|
||||
if sec == nil || sec.Auth == nil || sec.Auth.ClientToken == "" {
|
||||
res.err = fmt.Errorf("Vault returned no token from the OIDC callback")
|
||||
return
|
||||
}
|
||||
res.secret = sec
|
||||
}
|
||||
}
|
||||
|
||||
// ProviderRejectedError wraps an error= / error_description= pair the
|
||||
// identity provider appended to the redirect (e.g. the user clicked "deny").
|
||||
type ProviderRejectedError struct {
|
||||
Code string
|
||||
Description string
|
||||
}
|
||||
|
||||
func (e ProviderRejectedError) Error() string {
|
||||
if e.Description != "" {
|
||||
return fmt.Sprintf("identity provider rejected the login: %s (%s)", e.Code, e.Description)
|
||||
}
|
||||
return fmt.Sprintf("identity provider rejected the login: %s", e.Code)
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
// Package oidc implements vault-tui's browser-based OIDC login against
|
||||
// Vault's jwt/oidc auth method. It follows the same auth_url ->
|
||||
// browser -> local callback -> oidc/callback sequence as the Vault CLI's
|
||||
// own `-method=oidc` handler, verified against a live Vault+Keycloak
|
||||
// instance during development, with three deliberate deviations documented
|
||||
// on Flow.Login and Config.redirectURI.
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"github.com/hashicorp/vault/api"
|
||||
|
||||
"git.morlana.online/f.weber/vault-tui/internal/auth"
|
||||
)
|
||||
|
||||
// Config configures one OIDC login flow. Defaults match the Vault CLI's own
|
||||
// jwt/oidc CLIHandler so a redirect URI registered for `vault login
|
||||
// -method=oidc` continues to work unchanged.
|
||||
type Config struct {
|
||||
Mount string // default "oidc"
|
||||
Role string // may be empty -> Vault uses the mount's default_role
|
||||
|
||||
ListenAddress string // where WE bind; default "127.0.0.1" (see Login doc)
|
||||
Port int // default 8250
|
||||
|
||||
CallbackMethod string // what we TELL Vault/the IdP; default "http"
|
||||
CallbackHost string // default "localhost" — must match what's registered with the IdP
|
||||
CallbackPort int // default: == Port
|
||||
CallbackPath string // default "/oidc/callback"
|
||||
|
||||
SkipBrowser bool
|
||||
AbortOnBrowserError bool
|
||||
Timeout time.Duration // default 2m
|
||||
|
||||
BrowserCommand string // explicit override, tried before auto-detection
|
||||
}
|
||||
|
||||
func (c Config) withDefaults() Config {
|
||||
if c.Mount == "" {
|
||||
c.Mount = "oidc"
|
||||
}
|
||||
if c.ListenAddress == "" {
|
||||
c.ListenAddress = "127.0.0.1"
|
||||
}
|
||||
if c.Port == 0 {
|
||||
c.Port = 8250
|
||||
}
|
||||
if c.CallbackMethod == "" {
|
||||
c.CallbackMethod = "http"
|
||||
}
|
||||
if c.CallbackHost == "" {
|
||||
c.CallbackHost = "localhost"
|
||||
}
|
||||
if c.CallbackPort == 0 {
|
||||
c.CallbackPort = c.Port
|
||||
}
|
||||
if c.CallbackPath == "" {
|
||||
c.CallbackPath = "/oidc/callback"
|
||||
}
|
||||
if c.Timeout == 0 {
|
||||
c.Timeout = 2 * time.Minute
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// redirectURI is the exact string sent to Vault as redirect_uri and thus
|
||||
// the URI that must be registered with the identity provider. We build it
|
||||
// from host/port/path only and never append our own query parameters:
|
||||
// Vault itself decides whether to add ?namespace=... depending on the
|
||||
// mount's namespace_in_state setting (default true, which keeps the
|
||||
// namespace inside the opaque `state` value instead) — see Flow.Login.
|
||||
func (c Config) redirectURI() string {
|
||||
return fmt.Sprintf("%s://%s:%d%s", c.CallbackMethod, c.CallbackHost, c.CallbackPort, c.CallbackPath)
|
||||
}
|
||||
|
||||
func (c Config) listenAddr() string {
|
||||
return net.JoinHostPort(c.ListenAddress, fmt.Sprint(c.Port))
|
||||
}
|
||||
|
||||
// Flow runs one OIDC login. Create a fresh Flow per login attempt — do not
|
||||
// reuse one across logins. (Vault's own reference implementation registers
|
||||
// its callback handler on http.DefaultServeMux, which panics on a second
|
||||
// login in the same process; Flow avoids that by owning a private
|
||||
// http.ServeMux + http.Server per instance, see listener.go.)
|
||||
type Flow struct {
|
||||
cfg Config
|
||||
nonce string
|
||||
}
|
||||
|
||||
func New(cfg Config) *Flow { return &Flow{cfg: cfg.withDefaults()} }
|
||||
|
||||
// AsMethod adapts Flow to auth.Method so it can be registered and driven
|
||||
// generically like every other login method. Role/mount/etc. still come
|
||||
// from Config (set by the caller from profile.auth.oidc); the only
|
||||
// Credentials field is the role, which lets a headless `login -method=oidc
|
||||
// role=eng` override the configured default without touching config.yaml.
|
||||
type Method struct {
|
||||
Cfg Config
|
||||
}
|
||||
|
||||
func (Method) Name() string { return "oidc" }
|
||||
func (Method) DisplayName() string { return "OIDC (browser)" }
|
||||
func (Method) DefaultMount() string { return "oidc" }
|
||||
|
||||
func (Method) Fields() []auth.Field {
|
||||
return []auth.Field{
|
||||
{Name: "role", Label: "Role (optional — server default if empty)", Kind: auth.FieldText},
|
||||
}
|
||||
}
|
||||
|
||||
func (m Method) Login(ctx context.Context, c *api.Client, req auth.Request) (*api.Secret, error) {
|
||||
cfg := m.Cfg
|
||||
cfg.Mount = req.Mount
|
||||
if role := req.Creds.Get("role"); role != "" {
|
||||
cfg.Role = role
|
||||
}
|
||||
if req.Timeout > 0 {
|
||||
cfg.Timeout = req.Timeout
|
||||
}
|
||||
f := New(cfg)
|
||||
return f.Login(ctx, c, req)
|
||||
}
|
||||
|
||||
// Login runs the full sequence:
|
||||
//
|
||||
// 1. generate client_nonce (crypto/rand, never logged/displayed)
|
||||
// 2. bind the local listener BEFORE calling auth_url, so a port conflict
|
||||
// surfaces as ErrPortInUse instead of leaving an orphaned state entry
|
||||
// server-side, and so there is never a window where auth_url has been
|
||||
// requested but nothing is listening for the redirect yet
|
||||
// 3. POST auth/<mount>/oidc/auth_url {role, redirect_uri, client_nonce}
|
||||
// 4. emit an EventOpenURL with the URL BEFORE attempting to launch a
|
||||
// browser, so a failed launch degrades to "copy this URL" with no
|
||||
// separate code path
|
||||
// 5. serve the callback on a private ServeMux/http.Server
|
||||
// 6. attempt to open a browser (browser.go)
|
||||
// 7. wait for: callback done | ctx cancelled | timeout
|
||||
//
|
||||
// Deliberate deviations from vault-plugin-auth-jwt's reference CLI handler:
|
||||
// - a fresh http.ServeMux per Flow (upstream uses http.DefaultServeMux,
|
||||
// which panics — "multiple registrations" — on a second login in the
|
||||
// same process; fatal for a long-lived TUI, harmless for a one-shot CLI)
|
||||
// - the listener binds to 127.0.0.1 explicitly rather than the literal
|
||||
// string "localhost" (which can resolve to ::1 or elsewhere depending on
|
||||
// host config), while CallbackHost stays "localhost" for the
|
||||
// redirect_uri, since that is what is registered with the IdP
|
||||
// - CSRF/state is verified entirely server-side by Vault (it binds our
|
||||
// client_nonce to the state it generates); there is nothing for the
|
||||
// client to compare locally, so none is attempted here
|
||||
func (f *Flow) Login(ctx context.Context, c *api.Client, req auth.Request) (*api.Secret, error) {
|
||||
f.nonce = randomNonce(20)
|
||||
|
||||
ln, err := net.Listen("tcp", f.cfg.listenAddr())
|
||||
if err != nil {
|
||||
return nil, PortInUseError{Addr: f.cfg.listenAddr(), Err: err}
|
||||
}
|
||||
|
||||
authURL, err := f.requestAuthURL(ctx, c)
|
||||
if err != nil {
|
||||
ln.Close()
|
||||
return nil, err
|
||||
}
|
||||
req.Emit(auth.Event{Kind: auth.EventOpenURL, URL: authURL,
|
||||
Message: "open this URL in your browser to finish signing in"})
|
||||
|
||||
srv, done := f.startServer(ln, c)
|
||||
defer func() {
|
||||
shutCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
_ = srv.Shutdown(shutCtx)
|
||||
ln.Close()
|
||||
}()
|
||||
|
||||
if !f.cfg.SkipBrowser {
|
||||
if err := openBrowser(f.cfg.BrowserCommand, authURL); err != nil {
|
||||
req.Emit(auth.Event{Kind: auth.EventWarning,
|
||||
Message: fmt.Sprintf("could not open a browser automatically (%v) — use the URL above", err)})
|
||||
if f.cfg.AbortOnBrowserError {
|
||||
return nil, fmt.Errorf("opening browser: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
timeout := f.cfg.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = 2 * time.Minute
|
||||
}
|
||||
select {
|
||||
case res := <-done:
|
||||
if res.err != nil {
|
||||
return nil, res.err
|
||||
}
|
||||
return res.secret, nil
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(timeout):
|
||||
return nil, fmt.Errorf("timed out after %s waiting for the OIDC callback", timeout)
|
||||
}
|
||||
}
|
||||
|
||||
func (f *Flow) requestAuthURL(ctx context.Context, c *api.Client) (string, error) {
|
||||
p := fmt.Sprintf("auth/%s/oidc/auth_url", f.cfg.Mount)
|
||||
data := map[string]interface{}{
|
||||
"redirect_uri": f.cfg.redirectURI(),
|
||||
"client_nonce": f.nonce,
|
||||
}
|
||||
if f.cfg.Role != "" {
|
||||
data["role"] = f.cfg.Role
|
||||
}
|
||||
sec, err := c.Logical().WriteWithContext(ctx, p, data)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("requesting OIDC auth URL: %w", err)
|
||||
}
|
||||
if sec == nil || sec.Data == nil {
|
||||
return "", fmt.Errorf("%s returned no data", p)
|
||||
}
|
||||
authURL, _ := sec.Data["auth_url"].(string)
|
||||
if authURL == "" {
|
||||
return "", fmt.Errorf("%s did not return an auth_url", p)
|
||||
}
|
||||
return authURL, nil
|
||||
}
|
||||
|
||||
// PortInUseError is returned when the configured OIDC callback port is
|
||||
// already bound. Deliberately not falling back to a random port: the
|
||||
// registered redirect_uri would then no longer match what the IdP expects.
|
||||
type PortInUseError struct {
|
||||
Addr string
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e PortInUseError) Error() string {
|
||||
return fmt.Sprintf("OIDC callback address %s is already in use (%v) — set a different auth.oidc.port for this profile", e.Addr, e.Err)
|
||||
}
|
||||
func (e PortInUseError) Unwrap() error { return e.Err }
|
||||
@@ -0,0 +1,34 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
"github.com/hashicorp/vault/api"
|
||||
)
|
||||
|
||||
// result is what the callback handler sends once the exchange with Vault
|
||||
// has concluded (success or failure).
|
||||
type result struct {
|
||||
secret *api.Secret
|
||||
err error
|
||||
}
|
||||
|
||||
// startServer serves the OIDC callback on ln using a private ServeMux (see
|
||||
// Flow.Login's doc for why not http.DefaultServeMux) and returns a channel
|
||||
// that receives exactly one result. The server keeps running until the
|
||||
// caller calls Shutdown — Flow.Login does this in its deferred cleanup
|
||||
// regardless of outcome, which is what makes an immediate retry after a
|
||||
// completed login not hit "address already in use".
|
||||
func (f *Flow) startServer(ln net.Listener, c *api.Client) (*http.Server, <-chan result) {
|
||||
done := make(chan result, 1)
|
||||
var once sync.Once
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc(f.cfg.CallbackPath, f.handler(c, done, &once))
|
||||
|
||||
srv := &http.Server{Handler: mux}
|
||||
go func() { _ = srv.Serve(ln) }()
|
||||
return srv, done
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"math/big"
|
||||
)
|
||||
|
||||
const base62Alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
|
||||
// randomNonce returns an n-character base62 string from crypto/rand, used
|
||||
// for client_nonce. Deliberately not a dependency on
|
||||
// github.com/hashicorp/go-secure-stdlib/base62 — this is the same
|
||||
// crypto/rand-backed generation in about ten lines.
|
||||
func randomNonce(n int) string {
|
||||
b := make([]byte, n)
|
||||
max := big.NewInt(int64(len(base62Alphabet)))
|
||||
for i := range b {
|
||||
idx, err := rand.Int(rand.Reader, max)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("oidc: crypto/rand unavailable: %v", err))
|
||||
}
|
||||
b[i] = base62Alphabet[idx.Int64()]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
)
|
||||
|
||||
// successHTML/errorHTML are served back to the browser tab, never to
|
||||
// stdout/stderr or any log — the request they respond to may still carry
|
||||
// the authorization code in its query string.
|
||||
func successHTML() []byte {
|
||||
return []byte(`<!DOCTYPE html><html><head><title>vault-tui</title><meta charset="utf-8"></head>
|
||||
<body style="font-family:sans-serif;text-align:center;margin-top:15%">
|
||||
<h2>Signed in</h2><p>You can close this tab and return to vault-tui.</p>
|
||||
</body></html>`)
|
||||
}
|
||||
|
||||
func errorHTML(err error) []byte {
|
||||
msg := html.EscapeString(err.Error())
|
||||
return []byte(fmt.Sprintf(`<!DOCTYPE html><html><head><title>vault-tui</title><meta charset="utf-8"></head>
|
||||
<body style="font-family:sans-serif;text-align:center;margin-top:15%%">
|
||||
<h2>Sign-in failed</h2><p>%s</p><p>Return to vault-tui and try again.</p>
|
||||
</body></html>`, msg))
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// EnvAuthParamPrefix + strings.ToUpper(Field.Name) is consulted by Prefill
|
||||
// alongside each Field's own EnvFallback list, so any field can be
|
||||
// overridden from the environment even if the method author didn't think
|
||||
// to name a specific var for it.
|
||||
const EnvAuthParamPrefix = "VAULT_TUI_AUTH_"
|
||||
|
||||
// PrefillSource is everything Prefill draws from, ordered highest priority
|
||||
// first: CLIArgs > env fallback / VAULT_TUI_AUTH_* > ConfigArgs > Field.Default.
|
||||
type PrefillSource struct {
|
||||
CLIArgs map[string]string // `vault-tui login -method=x role=eng`
|
||||
ConfigArgs map[string]string // profile.auth.params
|
||||
LookupEnv func(string) (string, bool)
|
||||
}
|
||||
|
||||
func (s PrefillSource) lookupEnv(key string) (string, bool) {
|
||||
if s.LookupEnv != nil {
|
||||
return s.LookupEnv(key)
|
||||
}
|
||||
return os.LookupEnv(key)
|
||||
}
|
||||
|
||||
// Prefill resolves each of m's fields' initial value from src, in
|
||||
// precedence order: CLIArgs > Field.EnvFallback > VAULT_TUI_AUTH_<NAME> >
|
||||
// ConfigArgs > Field.Default.
|
||||
func Prefill(m Method, src PrefillSource) Credentials {
|
||||
out := Credentials{}
|
||||
for _, f := range m.Fields() {
|
||||
key := f.ConfigKey
|
||||
if key == "" {
|
||||
key = f.Name
|
||||
}
|
||||
|
||||
if v, ok := src.CLIArgs[f.Name]; ok && v != "" {
|
||||
out[f.Name] = v
|
||||
continue
|
||||
}
|
||||
found := false
|
||||
for _, envKey := range f.EnvFallback {
|
||||
if v, ok := src.lookupEnv(envKey); ok && v != "" {
|
||||
out[f.Name] = v
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if found {
|
||||
continue
|
||||
}
|
||||
if v, ok := src.lookupEnv(EnvAuthParamPrefix + strings.ToUpper(f.Name)); ok && v != "" {
|
||||
out[f.Name] = v
|
||||
continue
|
||||
}
|
||||
if v, ok := src.ConfigArgs[key]; ok && v != "" {
|
||||
out[f.Name] = v
|
||||
continue
|
||||
}
|
||||
if f.Default != "" {
|
||||
out[f.Name] = f.Default
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Missing returns the Required fields that are still empty after Prefill.
|
||||
// The TUI renders a form for exactly these; a headless, TTY-attached CLI
|
||||
// prompts for them; a headless, non-TTY CLI should treat a non-empty result
|
||||
// as a hard error (never block on stdin).
|
||||
func Missing(m Method, creds Credentials) []Field {
|
||||
var out []Field
|
||||
for _, f := range m.Fields() {
|
||||
if f.Required && !creds.Has(f.Name) {
|
||||
out = append(out, f)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Validate runs every Field.Validate and checks Required, returning the
|
||||
// first error encountered.
|
||||
func Validate(m Method, creds Credentials) error {
|
||||
for _, f := range m.Fields() {
|
||||
v, ok := creds[f.Name]
|
||||
if f.Required && (!ok || v == "") {
|
||||
return fmt.Errorf("missing required field %q", f.Name)
|
||||
}
|
||||
if ok && f.Validate != nil {
|
||||
if err := f.Validate(v); err != nil {
|
||||
return fmt.Errorf("field %q: %w", f.Name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"path"
|
||||
|
||||
"github.com/hashicorp/vault/api"
|
||||
)
|
||||
|
||||
var errNoAuth = errors.New("empty response from credential provider")
|
||||
|
||||
// loginWrite performs POST auth/<mount>/<suffix> and normalises errors.
|
||||
// This single helper backs every "raw" method (see the method table in the
|
||||
// design doc) — the official api/auth/{userpass,approle,ldap,kubernetes}
|
||||
// submodules add nothing over this plus Field.EnvFallback.
|
||||
func loginWrite(ctx context.Context, c *api.Client, mount, suffix string, data map[string]interface{}) (*api.Secret, error) {
|
||||
p := path.Join("auth", mount, suffix)
|
||||
sec, err := c.Logical().WriteWithContext(ctx, p, data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("login at %s: %w", p, err)
|
||||
}
|
||||
if sec == nil || sec.Auth == nil || sec.Auth.ClientToken == "" {
|
||||
return nil, fmt.Errorf("%s: %w", p, errNoAuth)
|
||||
}
|
||||
return sec, nil
|
||||
}
|
||||
|
||||
// mountOf returns req.Mount if set, else def.
|
||||
func mountOf(req Request, def string) string {
|
||||
if req.Mount != "" {
|
||||
return req.Mount
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
const base62Alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
|
||||
// randomNonce returns an n-character base62 string from crypto/rand. Used
|
||||
// for OIDC's client_nonce and Okta's verify nonce — anywhere Vault's own
|
||||
// CLI uses go-secure-stdlib/base62.Random, which this deliberately does not
|
||||
// depend on (it is ~10 lines backed by the same crypto/rand primitive).
|
||||
func randomNonce(n int) string {
|
||||
b := make([]byte, n)
|
||||
max := big.NewInt(int64(len(base62Alphabet)))
|
||||
for i := range b {
|
||||
idx, err := rand.Int(rand.Reader, max)
|
||||
if err != nil {
|
||||
// crypto/rand failing is fatal for anything security-sensitive;
|
||||
// panic rather than silently degrade nonce quality.
|
||||
panic(fmt.Sprintf("auth: crypto/rand unavailable: %v", err))
|
||||
}
|
||||
b[i] = base62Alphabet[idx.Int64()]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package auth
|
||||
|
||||
import "sort"
|
||||
|
||||
// Registry holds every known Method, plus any that are named but
|
||||
// unavailable in this build (see the no_cloud build tag).
|
||||
type Registry struct {
|
||||
methods map[string]Method
|
||||
unavailable map[string]string // name -> reason
|
||||
}
|
||||
|
||||
var defaultRegistry = &Registry{
|
||||
methods: map[string]Method{},
|
||||
unavailable: map[string]string{},
|
||||
}
|
||||
|
||||
// register adds m to the default registry. Called from each method file's
|
||||
// init(), and from the cloud-methods files behind their build tags.
|
||||
func register(ms ...Method) {
|
||||
for _, m := range ms {
|
||||
defaultRegistry.methods[m.Name()] = m
|
||||
}
|
||||
}
|
||||
|
||||
// Register is the exported form of register, for methods that live in a
|
||||
// package auth cannot import without a cycle (internal/auth/oidc imports
|
||||
// auth for the Method interface itself, so it cannot self-register via
|
||||
// init() the way the raw methods in this package do). Callers that import
|
||||
// both packages — cmd/vault-tui, internal/cli — call this once at startup.
|
||||
func Register(ms ...Method) { register(ms...) }
|
||||
|
||||
// registerUnavailable records a method name that exists conceptually but
|
||||
// was compiled out (e.g. -tags no_cloud), so pickers can show it greyed out
|
||||
// with a reason instead of it silently vanishing.
|
||||
func registerUnavailable(reason string, names ...string) {
|
||||
for _, n := range names {
|
||||
defaultRegistry.unavailable[n] = reason
|
||||
}
|
||||
}
|
||||
|
||||
// Default returns the process-wide method registry, populated by every auth
|
||||
// package file's init().
|
||||
func Default() *Registry { return defaultRegistry }
|
||||
|
||||
// Get looks up a method by its stable name ("oidc", "userpass", ...).
|
||||
func (r *Registry) Get(name string) (Method, bool) {
|
||||
m, ok := r.methods[name]
|
||||
return m, ok
|
||||
}
|
||||
|
||||
// Unavailable returns the reason a compiled-out method is unavailable, if any.
|
||||
func (r *Registry) Unavailable(name string) (string, bool) {
|
||||
reason, ok := r.unavailable[name]
|
||||
return reason, ok
|
||||
}
|
||||
|
||||
// UnavailableAll returns every compiled-out method name mapped to its
|
||||
// reason, for pickers that want to list them (greyed out) alongside the
|
||||
// methods that are actually usable in this build.
|
||||
func (r *Registry) UnavailableAll() map[string]string {
|
||||
out := make(map[string]string, len(r.unavailable))
|
||||
for name, reason := range r.unavailable {
|
||||
out[name] = reason
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Names returns every registered method name, sorted.
|
||||
func (r *Registry) Names() []string {
|
||||
out := make([]string, 0, len(r.methods))
|
||||
for n := range r.methods {
|
||||
out = append(out, n)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// All returns every registered Method, sorted by name.
|
||||
func (r *Registry) All() []Method {
|
||||
names := r.Names()
|
||||
out := make([]Method, 0, len(names))
|
||||
for _, n := range names {
|
||||
out = append(out, r.methods[n])
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user