// 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//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 }