- 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.
240 lines
8.1 KiB
Go
240 lines
8.1 KiB
Go
// 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 }
|