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:
2026-08-14 11:09:03 +02:00
commit ae30ba1240
85 changed files with 9413 additions and 0 deletions
+107
View File
@@ -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, "'", "''") + "'"
}
+102
View File
@@ -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)
}
+239
View File
@@ -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 }
+34
View File
@@ -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
}
+26
View File
@@ -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)
}
+24
View File
@@ -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))
}